Skip to content

Commit a68486d

Browse files
committed
feat: detect duplicate recipients in CSV uploads (#3319)
Adds duplicate-recipient detection to RecipientCSV so that admin can warn senders before a bulk send when their CSV contains the same recipient more than once. Detection is case-insensitive, ignores leading/trailing whitespace, and (for SMS) treats phone numbers in different formats as equivalent. Letters are excluded because multiple recipients can legitimately share an address. The new properties (has_duplicate_recipients, count_of_unique_duplicate_recipients, count_of_duplicate_recipient_rows, rows_with_duplicate_recipients) are non-blocking: they do *not* affect has_errors. Admin can use them to render a warning banner and a download-duplicates link without preventing the user from sending. Refs: cds-snc/notification-planning#3319
1 parent 835a826 commit a68486d

3 files changed

Lines changed: 209 additions & 1 deletion

File tree

notifications_utils/recipients.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,94 @@ def rows_with_combined_variable_content_too_long(self):
311311
if total_length > SMS_CHAR_COUNT_LIMIT:
312312
yield row
313313

314+
def _normalise_recipient_for_dedupe(self, recipient):
315+
"""
316+
Normalise a recipient value for case-insensitive, whitespace-insensitive
317+
duplicate detection. Phone numbers are normalised to E.164 when possible
318+
so that "+1 555-123-4567" and "5551234567" are treated as the same recipient.
319+
Returns ``None`` if the value cannot meaningfully be normalised (e.g. empty).
320+
"""
321+
if recipient is None:
322+
return None
323+
normalised = strip_and_remove_obscure_whitespace(str(recipient)).strip().lower()
324+
if not normalised:
325+
return None
326+
if self.template_type == "sms":
327+
try:
328+
return validate_phone_number(recipient, international=self.international_sms)
329+
except InvalidPhoneError:
330+
return normalised
331+
return normalised
332+
333+
@property
334+
def _duplicate_recipient_row_indices(self):
335+
"""
336+
Returns a set of row indices for rows whose recipient value has already
337+
appeared in an earlier row. The first occurrence of each recipient is
338+
not flagged. Rows with bad or missing recipients are skipped, and
339+
duplicate detection is disabled for letter templates (where multiple
340+
recipients can legitimately share an address).
341+
"""
342+
if self.template_type == "letter":
343+
return set()
344+
seen = set()
345+
duplicate_indices = set()
346+
for row in self.rows:
347+
if row is None:
348+
continue
349+
if row.has_bad_recipient or row.recipient is None:
350+
continue
351+
normalised = self._normalise_recipient_for_dedupe(row.recipient)
352+
if normalised is None:
353+
continue
354+
if normalised in seen:
355+
duplicate_indices.add(row.index)
356+
else:
357+
seen.add(normalised)
358+
return duplicate_indices
359+
360+
@property
361+
def rows_with_duplicate_recipients(self):
362+
"""
363+
Yields rows whose recipient is a duplicate of an earlier row. The first
364+
occurrence of each recipient is *not* yielded; only the subsequent
365+
copies are yielded so callers can highlight or export them.
366+
"""
367+
duplicate_indices = self._duplicate_recipient_row_indices
368+
if not duplicate_indices:
369+
return
370+
for row in self.rows:
371+
if row is None:
372+
continue
373+
if row.index in duplicate_indices:
374+
yield row
375+
376+
@property
377+
def has_duplicate_recipients(self):
378+
return bool(self._duplicate_recipient_row_indices)
379+
380+
@property
381+
def count_of_duplicate_recipient_rows(self):
382+
"""Total number of duplicate rows (i.e. extra copies beyond the first)."""
383+
return len(self._duplicate_recipient_row_indices)
384+
385+
@property
386+
def count_of_unique_duplicate_recipients(self):
387+
"""Number of distinct recipients that appear more than once."""
388+
if self.template_type == "letter":
389+
return 0
390+
counts: Dict[str, int] = {}
391+
for row in self.rows:
392+
if row is None:
393+
continue
394+
if row.has_bad_recipient or row.recipient is None:
395+
continue
396+
normalised = self._normalise_recipient_for_dedupe(row.recipient)
397+
if normalised is None:
398+
continue
399+
counts[normalised] = counts.get(normalised, 0) + 1
400+
return sum(1 for count in counts.values() if count > 1)
401+
314402
@property
315403
def initial_rows_with_errors(self):
316404
return islice(self.rows_with_errors, self.max_errors_shown)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "notifications-utils"
3-
version = "53.2.24"
3+
version = "53.2.25"
44
description = "Shared python code for Notification - Provides logging utils etc."
55
authors = ["Canadian Digital Service"]
66
license = "MIT license"

tests/test_recipient_csv.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,3 +1152,123 @@ def test_multi_line_placeholders_work():
11521152
)
11531153

11541154
assert recipients.rows[0].personalisation["data"] == "a\nb\n\nc"
1155+
1156+
1157+
class TestDuplicateRecipients:
1158+
"""Duplicate-recipient detection (issue #3319).
1159+
1160+
The detection should be case-insensitive, ignore leading/trailing
1161+
whitespace, and treat phone numbers as equivalent when they normalise to
1162+
the same E.164 form. It should be a non-blocking warning -- ``has_errors``
1163+
must remain ``False`` when the only issue is duplicate recipients.
1164+
"""
1165+
1166+
def test_no_duplicates_when_all_emails_unique(self):
1167+
recipients = RecipientCSV(
1168+
"""
1169+
email address
1170+
alice@example.com
1171+
bob@example.com
1172+
carol@example.com
1173+
""",
1174+
template_type="email",
1175+
)
1176+
assert recipients.has_duplicate_recipients is False
1177+
assert recipients.count_of_duplicate_recipient_rows == 0
1178+
assert recipients.count_of_unique_duplicate_recipients == 0
1179+
assert list(recipients.rows_with_duplicate_recipients) == []
1180+
assert recipients.has_errors is False
1181+
1182+
def test_detects_exact_duplicate_emails(self):
1183+
recipients = RecipientCSV(
1184+
"""
1185+
email address
1186+
alice@example.com
1187+
bob@example.com
1188+
alice@example.com
1189+
""",
1190+
template_type="email",
1191+
)
1192+
assert recipients.has_duplicate_recipients is True
1193+
assert recipients.count_of_duplicate_recipient_rows == 1
1194+
assert recipients.count_of_unique_duplicate_recipients == 1
1195+
duplicates = list(recipients.rows_with_duplicate_recipients)
1196+
# Only the *second* occurrence is flagged; the first is kept.
1197+
assert [row.index for row in duplicates] == [2]
1198+
# Duplicates are a warning, not a hard error.
1199+
assert recipients.has_errors is False
1200+
1201+
def test_email_dedupe_is_case_insensitive_and_trims_whitespace(self):
1202+
# Build the CSV explicitly so leading/trailing whitespace and case
1203+
# differences are preserved without tripping the linter.
1204+
file_contents = "email address\n" "Alice@Example.com\n" " alice@example.COM \n" "ALICE@EXAMPLE.COM\n"
1205+
recipients = RecipientCSV(file_contents, template_type="email")
1206+
assert recipients.count_of_duplicate_recipient_rows == 2
1207+
assert recipients.count_of_unique_duplicate_recipients == 1
1208+
1209+
def test_counts_unique_duplicate_recipients(self):
1210+
recipients = RecipientCSV(
1211+
"""
1212+
email address
1213+
alice@example.com
1214+
bob@example.com
1215+
alice@example.com
1216+
bob@example.com
1217+
carol@example.com
1218+
bob@example.com
1219+
""",
1220+
template_type="email",
1221+
)
1222+
# alice appears twice (1 extra), bob appears 3 times (2 extra) -> 3 duplicate rows
1223+
assert recipients.count_of_duplicate_recipient_rows == 3
1224+
# Two distinct recipients have duplicates.
1225+
assert recipients.count_of_unique_duplicate_recipients == 2
1226+
1227+
def test_detects_duplicate_phone_numbers_in_different_formats(self):
1228+
recipients = RecipientCSV(
1229+
"""
1230+
phone number
1231+
6502532222
1232+
+1 650-253-2222
1233+
650 253 2222
1234+
6502532223
1235+
""",
1236+
template_type="sms",
1237+
international_sms=True,
1238+
)
1239+
assert recipients.count_of_duplicate_recipient_rows == 2
1240+
assert recipients.count_of_unique_duplicate_recipients == 1
1241+
1242+
def test_skips_rows_with_bad_or_missing_recipients(self):
1243+
file_contents = "email address\n" "alice@example.com\n" "not-an-email\n" "\n" "alice@example.com\n"
1244+
recipients = RecipientCSV(file_contents, template_type="email")
1245+
# The blank row and bad-email row should not be considered for dedupe.
1246+
assert recipients.count_of_duplicate_recipient_rows == 1
1247+
1248+
def test_duplicate_detection_disabled_for_letters(self):
1249+
recipients = RecipientCSV(
1250+
"""
1251+
address line 1, address line 2, address line 3, address line 4, address line 5, address line 6, postcode
1252+
A, B, C, , , , X1A0A1
1253+
A, B, C, , , , X1A0A1
1254+
""",
1255+
template_type="letter",
1256+
)
1257+
# Letters can legitimately share an address, so we don't flag duplicates.
1258+
assert recipients.has_duplicate_recipients is False
1259+
assert recipients.count_of_duplicate_recipient_rows == 0
1260+
assert recipients.count_of_unique_duplicate_recipients == 0
1261+
1262+
def test_duplicates_do_not_make_has_errors_true(self):
1263+
recipients = RecipientCSV(
1264+
"""
1265+
email address
1266+
alice@example.com
1267+
alice@example.com
1268+
""",
1269+
template_type="email",
1270+
)
1271+
assert recipients.has_duplicate_recipients is True
1272+
# Critically: duplicates are a non-blocking warning, not an error.
1273+
assert recipients.has_errors is False
1274+
assert list(recipients.rows_with_errors) == []

0 commit comments

Comments
 (0)