Skip to content

Commit b361444

Browse files
Humanize audience validation breakdowns
1 parent db87421 commit b361444

3 files changed

Lines changed: 47 additions & 18 deletions

File tree

mailing/services/operator_ui.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,9 @@ def contact_explorer_options(client: Client | None = None):
636636
"tags": tag_queryset.order_by("audience__slug", "slug"),
637637
"subscription_statuses": choices_from_text_choices(SubscriptionStatus),
638638
"verified_states": [Choice(value=key, label=label) for key, label in VERIFIED_FILTER_LABELS.items()],
639-
"email_validation_statuses": choices_from_text_choices(EmailValidationStatus),
639+
"email_validation_statuses": [
640+
Choice(value=value, label=email_validation_label(value)) for value, _label in EmailValidationStatus.choices
641+
],
640642
"suppression_states": [Choice(value=key, label=label) for key, label in SUPPRESSION_FILTER_LABELS.items()],
641643
"campaign_statuses": choices_from_text_choices(CampaignRecipientStatus),
642644
"skip_reasons": choices_from_text_choices(CampaignRecipientSkipReason),
@@ -659,7 +661,7 @@ def active_contact_filters(filters: ContactExplorerFilters) -> list[ActiveFilter
659661
if filters.verified_state:
660662
chips.append(ActiveFilter("Verification", VERIFIED_FILTER_LABELS[filters.verified_state]))
661663
if filters.email_validation_status:
662-
chips.append(ActiveFilter("Validation", EmailValidationStatus(filters.email_validation_status).label))
664+
chips.append(ActiveFilter("Validation", email_validation_label(filters.email_validation_status)))
663665
if filters.suppression_state:
664666
chips.append(ActiveFilter("Suppression", SUPPRESSION_FILTER_LABELS[filters.suppression_state]))
665667
if filters.campaign_status:
@@ -1037,7 +1039,7 @@ def verification_badge(contact: Contact) -> Badge:
10371039

10381040

10391041
def validation_badge(contact: Contact) -> Badge:
1040-
label = contact.get_email_validation_status_display()
1042+
label = email_validation_label(contact.email_validation_status)
10411043
if contact.email_validation_status in {
10421044
EmailValidationStatus.VALID,
10431045
EmailValidationStatus.EXTERNALLY_VALIDATED,
@@ -1285,7 +1287,7 @@ def marketing_reasons(contact: Contact, subscription: Subscription):
12851287
if contact.verified_at is None:
12861288
yield "unverified"
12871289
if has_invalid_email_validation(contact):
1288-
yield f"invalid email validation: {contact.get_email_validation_status_display()}"
1290+
yield f"invalid email validation: {email_validation_label(contact.email_validation_status)}"
12891291
if contact.global_unsubscribed_at is not None:
12901292
yield "global unsubscribe"
12911293
if contact.hard_bounced_at is not None:
@@ -1562,18 +1564,36 @@ def linked_count(**signal) -> int:
15621564
def count_by_field(queryset, field, *, choices=None):
15631565
raw_counts = dict(queryset.values_list(field).annotate(count=Count("id")))
15641566
if choices:
1565-
return [(label, raw_counts.get(value, 0)) for value, label in choices]
1567+
return [(label, count) for value, label in choices if (count := raw_counts.get(value, 0)) > 0]
15661568
return sorted(raw_counts.items())
15671569

15681570

1571+
def email_validation_label(value) -> str:
1572+
labels = {
1573+
EmailValidationStatus.UNKNOWN: "No validation data",
1574+
EmailValidationStatus.VALID: "Valid email",
1575+
EmailValidationStatus.INVALID_SYNTAX: "Malformed email",
1576+
EmailValidationStatus.NO_MX: "Missing email DNS",
1577+
EmailValidationStatus.DISPOSABLE: "Disposable email",
1578+
EmailValidationStatus.RISKY: "Risky email",
1579+
EmailValidationStatus.MANUALLY_INVALID: "Marked invalid",
1580+
EmailValidationStatus.EXTERNALLY_VALIDATED: "Validated externally",
1581+
}
1582+
return labels.get(value, str(value))
1583+
1584+
15691585
def audience_breakdowns(audience: Audience, client: Client | None = None):
15701586
contacts = audience_contacts(audience)
15711587
campaign_recipients = CampaignRecipient.objects.filter(campaign__audience=audience)
15721588
if client is not None:
15731589
contacts = contacts.filter(subscriptions__client=client).distinct()
15741590
campaign_recipients = campaign_recipients.filter(campaign__client=client)
15751591
return {
1576-
"validation": count_by_field(contacts, "email_validation_status", choices=EmailValidationStatus.choices),
1592+
"validation": count_by_field(
1593+
contacts,
1594+
"email_validation_status",
1595+
choices=[(value, email_validation_label(value)) for value, _label in EmailValidationStatus.choices],
1596+
),
15771597
"tags": Tag.objects.filter(audience=audience)
15781598
.annotate(count=Count("contact_tags", distinct=True))
15791599
.order_by("slug"),

mailing/tests/test_operator_ui.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ def test_active_contact_filters_summarize_applied_filter_state(audience, client_
574574
assert ("Client", "DTC Courses") in labels
575575
assert ("Subscription", "Subscribed") in labels
576576
assert ("Verification", "Verified") in labels
577-
assert ("Validation", "Valid") in labels
577+
assert ("Validation", "Valid email") in labels
578578
assert ("Suppression", "Hard bounced") in labels
579579
assert ("Engagement", "Inactive since 2026-05-01") in labels
580580
assert ("Includes tag", "newsletter") in labels
@@ -595,11 +595,11 @@ def test_operator_contact_views_show_email_validation_status(client, operator, a
595595
detail_response = client.get(reverse("mailing:contact_detail", args=[contact.normalized_email]))
596596

597597
assert search_response.status_code == 200
598-
assert b"Manually invalid" in search_response.content
598+
assert b"Marked invalid" in search_response.content
599599
assert b"staff marked bad" in search_response.content
600600
assert detail_response.status_code == 200
601601
assert b"Validation" in detail_response.content
602-
assert b"Manually invalid" in detail_response.content
602+
assert b"Marked invalid" in detail_response.content
603603
assert b"staff marked bad" in detail_response.content
604604

605605

@@ -750,7 +750,7 @@ def test_operator_contact_explorer_renders_redesigned_filter_groups_and_result_h
750750
assert "Applied filters" in html
751751
assert "Audience: DataTalksClub" in html
752752
assert "Subscription: Subscribed" in html
753-
assert "Validation: Valid" in html
753+
assert "Validation: Valid email" in html
754754
assert "Includes tag: very-long-newsletter-segment-for-returning-learners" in html
755755
assert 'class="table-wrap contact-explorer-table"' in html
756756
assert "Last activity" in html
@@ -790,7 +790,7 @@ def test_operator_contact_explorer_badges_suppressed_unverified_and_no_activity_
790790
assert response.status_code == 200
791791
assert 'href="/contacts/suppressed@example.com/"' in html
792792
assert "Hard bounced" in html
793-
assert "Manually invalid" in html
793+
assert "Marked invalid" in html
794794
assert "Unverified" in html
795795
assert "manual block" in html
796796
assert "Sent never" in html
@@ -857,13 +857,13 @@ def test_contact_detail_eligibility_explains_marketing_and_transactional_blocks(
857857

858858
assert detail.eligibility[0].can_send_marketing is False
859859
assert "unverified" in detail.eligibility[0].marketing_reasons
860-
assert "invalid email validation: Manually invalid" in detail.eligibility[0].marketing_reasons
860+
assert "invalid email validation: Marked invalid" in detail.eligibility[0].marketing_reasons
861861
assert "client unsubscribe" in detail.eligibility[0].marketing_reasons
862862
assert detail.eligibility[0].can_send_transactional is False
863863
assert "hard bounce" in detail.eligibility[0].transactional_reasons
864864
assert response.status_code == 200
865865
assert b"Send Eligibility" in response.content
866-
assert b"invalid email validation: Manually invalid" in response.content
866+
assert b"invalid email validation: Marked invalid" in response.content
867867
assert b"client unsubscribe" in response.content
868868

869869

@@ -956,7 +956,7 @@ def test_contact_detail_summary_shows_blocked_reasons_and_secondary_raw_details(
956956
html = response.content.decode()
957957

958958
assert response.status_code == 200
959-
assert "Risky" in html
959+
assert "Risky email" in html
960960
assert "Globally unsubscribed" in html
961961
assert "Cannot send marketing" in html
962962
assert "Cannot send transactional" in html
@@ -978,7 +978,7 @@ def test_contact_detail_no_membership_explains_not_subscribed_and_no_activity(cl
978978

979979
assert response.status_code == 200
980980
assert "No subscriptions" in html
981-
assert "Unknown" in html
981+
assert "No validation data" in html
982982
assert "Cannot send marketing" in html
983983
assert "not subscribed" in html
984984
assert "Can send transactional" in html
@@ -1191,7 +1191,16 @@ def test_audience_list_and_detail_render_summaries_members_history_and_events(
11911191
assert "Inactive since" in detail_html
11921192
assert 'name="include_tags" value="newsletter"' in detail_html
11931193
assert 'class="table-wrap audience-member-table"' in detail_html
1194-
assert "No MX" in detail_html
1194+
assert "Missing email DNS" in detail_html
1195+
assert "No MX: 1" not in detail_html
1196+
assert "Valid email: 1" in detail_html
1197+
assert "No validation data: 1" in detail_html
1198+
assert "Malformed email: 0" not in detail_html
1199+
assert "Sent: 2" in detail_html
1200+
assert "Skipped: 1" in detail_html
1201+
assert "Failed: 0" not in detail_html
1202+
assert "Invalid email: 1" in detail_html
1203+
assert "Client unsubscribe: 0" not in detail_html
11951204
assert "Hard bounced" in detail_html
11961205
assert "invalid@example.com" in detail_html
11971206
assert 'href="/contacts/invalid@example.com/"' in detail_html

templates/mailing/operator/audience_detail.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ <h2 id="audience-segmentation">Segmentation</h2>
6262
<div class="meta-item">
6363
<span class="meta-label">Validation</span>
6464
<div class="badge-row">
65-
{% for label, count in breakdowns.validation %}<span class="badge neutral">{{ label }}: {{ count }}</span>{% endfor %}
65+
{% for label, count in breakdowns.validation %}<span class="badge neutral">{{ label }}: {{ count }}</span>{% empty %}<span class="badge neutral">No contacts</span>{% endfor %}
6666
</div>
6767
</div>
6868
<div class="meta-item">
@@ -78,7 +78,7 @@ <h2 id="audience-segmentation">Segmentation</h2>
7878
<div class="meta-item">
7979
<span class="meta-label">Campaign statuses</span>
8080
<div class="badge-row">
81-
{% for label, count in breakdowns.campaign_statuses %}<span class="badge neutral">{{ label }}: {{ count }}</span>{% endfor %}
81+
{% for label, count in breakdowns.campaign_statuses %}<span class="badge neutral">{{ label }}: {{ count }}</span>{% empty %}<span class="badge neutral">No campaign recipients</span>{% endfor %}
8282
</div>
8383
</div>
8484
<div class="meta-item">

0 commit comments

Comments
 (0)