Skip to content

Commit 7385bbc

Browse files
whabanksjimleroyer
andauthored
Validate variables in CSV do not exceed daily limit (#278)
* Validate variables in CSV do not exceed daily limit - Added has_message_too_long property to the Row class - Added message_to_long variable to the Cell class with accompanying property content_length_error to identify a cell with the aformentioned error - Added a check to the RecipientCSV class that checks if a variable in a CSV exceeds the message limit of 612 when added to the template content * Add validate_sms_message_length - Raise an execption when length is exceeded - Bubble exception message up to the UI for rendering * Check if combined variable content exceeds sms limit * Add tests - Removed message_too_long_error from the Cell class * Remove unneeded property * Use SMS_CHAR_COUNT_LIMIT instead of hard coded value Co-authored-by: Jimmy Royer <jimleroyer@gmail.com> * Use SMS_CHAR_COUNT_LIMIT instead of hard coded value * Use generators properly - Updated rows_with_combined_variable_content_too_long make use of generators properly - Removed None check * Bump utils + waffles version --------- Co-authored-by: Jimmy Royer <jimleroyer@gmail.com>
1 parent c0549f0 commit 7385bbc

5 files changed

Lines changed: 91 additions & 23 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
docopt==0.6.2
22
Flask==2.3.3
33
markupsafe==2.1.4
4-
git+https://github.com/cds-snc/notifier-utils.git@52.1.5#egg=notifications-utils
4+
git+https://github.com/cds-snc/notifier-utils.git@52.1.6#egg=notifications-utils

notifications_utils/columns.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ def __init__(
6363
template.values = row_dict
6464
self.message_too_long = template.is_message_too_long()
6565

66-
super().__init__(OrderedDict((key, Cell(key, value, error_fn, self.placeholders)) for key, value in row_dict.items()))
66+
super().__init__(
67+
OrderedDict(
68+
(key, Cell(key, value, error_fn, self.placeholders, len(template.content) if template else None))
69+
for key, value in row_dict.items()
70+
)
71+
)
6772

6873
def __getitem__(self, key):
6974
return super().__getitem__(key) or Cell()
@@ -121,7 +126,7 @@ def recipient_and_personalisation(self):
121126
class Cell:
122127
missing_field_error = "Missing"
123128

124-
def __init__(self, key=None, value=None, error_fn=None, placeholders=None):
129+
def __init__(self, key=None, value=None, error_fn=None, placeholders=None, template_content_length=None):
125130
self.data = value
126131
self.error = error_fn(key, value) if error_fn else None
127132
self.ignore = Columns.make_key(key) not in (placeholders or [])

notifications_utils/recipients.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
from collections import OrderedDict, namedtuple
1111
from ordered_set import OrderedSet
1212
from typing import Callable, Dict, List
13-
13+
from notifications_utils import SMS_CHAR_COUNT_LIMIT
1414
from flask import current_app
15-
15+
from notifications_utils.sanitise_text import SanitiseSMS
1616
from . import EMAIL_REGEX_PATTERN, hostname_part, tld_part
1717
from notifications_utils.formatters import strip_and_remove_obscure_whitespace, strip_whitespace
1818
from notifications_utils.template import SMSMessageTemplate, Template
@@ -276,6 +276,23 @@ def rows_with_missing_data(self):
276276
def rows_with_message_too_long(self):
277277
return self._filter_rows("message_too_long")
278278

279+
@property
280+
def rows_with_combined_variable_content_too_long(self):
281+
"""
282+
Checks if the length of all variable, plus the length of the template content,
283+
exceeds the SMS limit of 612 characters. Counts non-GSM characters as 2.
284+
"""
285+
if self.rows_as_list:
286+
for row in self.rows_as_list:
287+
if row.personalisation and self.template:
288+
variable_length = 0
289+
for variable in row.personalisation.as_dict_with_keys(self.template.placeholders).values():
290+
if variable:
291+
variable_length += sum(1 if char in SanitiseSMS.ALLOWED_CHARACTERS else 2 for char in str(variable))
292+
total_length = variable_length + (len(self.template.content) if self.template else 0)
293+
if total_length > SMS_CHAR_COUNT_LIMIT:
294+
yield row
295+
279296
@property
280297
def initial_rows_with_errors(self):
281298
return islice(self.rows_with_errors, self.max_errors_shown)
@@ -361,7 +378,9 @@ def _get_error_for_field(self, key, value): # noqa: C901
361378
if self.is_optional_address_column(key):
362379
return
363380

364-
if Columns.make_key(key) in self.recipient_column_headers_as_column_keys:
381+
formatted_key = Columns.make_key(key)
382+
383+
if formatted_key in self.recipient_column_headers_as_column_keys:
365384
if value in [None, ""] or isinstance(value, list):
366385
if self.duplicate_recipient_column_headers:
367386
return None
@@ -372,12 +391,19 @@ def _get_error_for_field(self, key, value): # noqa: C901
372391
except (InvalidEmailError, InvalidPhoneError, InvalidAddressError) as error:
373392
return str(error)
374393

375-
if Columns.make_key(key) not in self.placeholders_as_column_keys:
394+
if formatted_key not in self.placeholders_as_column_keys:
376395
return
377396

378397
if value in [None, ""]:
379398
return Cell.missing_field_error
380399

400+
if self.template:
401+
if formatted_key in self.placeholders_as_column_keys and self.template.template_type == "sms":
402+
try:
403+
validate_sms_message_length(value, self.template.content)
404+
except ValueError as error:
405+
return str(error)
406+
381407

382408
class InvalidEmailError(Exception):
383409
def __init__(self, message=None):
@@ -553,6 +579,14 @@ def validate_recipient(recipient, template_type: str, column=None, international
553579
return validators[template_type](recipient, column)
554580

555581

582+
def validate_sms_message_length(variable_content, template_content_length):
583+
variable_length = sum(1 if char in SanitiseSMS.ALLOWED_CHARACTERS else 2 for char in variable_content)
584+
585+
if variable_length + len(template_content_length) > SMS_CHAR_COUNT_LIMIT:
586+
raise ValueError(f"Maximum {SMS_CHAR_COUNT_LIMIT} characters. Some messages may be too long due to custom content.")
587+
return
588+
589+
556590
@lru_cache(maxsize=32, typed=False)
557591
def format_recipient(recipient):
558592
if not isinstance(recipient, str):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ include = '(notifications_utils|tests)/.*\.pyi?$'
55

66
[tool.poetry]
77
name = "notifications-utils"
8-
version = "52.1.5"
8+
version = "52.1.6"
99
description = "Shared python code for Notification - Provides logging utils etc."
1010
authors = ["Canadian Digital Service"]
1111
license = "MIT license"

tests/test_recipient_csv.py

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from math import floor
12
import pytest
23
import itertools
34
import unicodedata
@@ -745,29 +746,57 @@ def test_recipient_safelist(file_contents, template_type, safelist, count_of_row
745746
assert recipients.allowed_to_send_to
746747

747748

748-
def test_detects_rows_which_result_in_overly_long_messages():
749+
@pytest.mark.parametrize(
750+
"template_content, csv, error_rows",
751+
[
752+
(
753+
"((placeholder))",
754+
f"""
755+
phone number,placeholder
756+
6502532222,1
757+
6502532222,{"a" * (SMS_CHAR_COUNT_LIMIT - 1)}
758+
6502532223,{"a" * SMS_CHAR_COUNT_LIMIT}
759+
6502532224,{"a" * (SMS_CHAR_COUNT_LIMIT + 1)}
760+
""",
761+
{3},
762+
),
763+
( # Placeholder + content length should not exceed SMS_CHAR_COUNT_LIMIT. 72 = content length - placeholder text
764+
"((placeholder)) This is template content. Believe it or not there are character limits.",
765+
f"""
766+
phone number,placeholder
767+
6502532222,1
768+
6502532222,{'a' * (SMS_CHAR_COUNT_LIMIT + 1)}
769+
6502532222,{'a'* (SMS_CHAR_COUNT_LIMIT - 72)}
770+
6502532222,{'a' * (SMS_CHAR_COUNT_LIMIT - 73) }
771+
""",
772+
{1},
773+
),
774+
(
775+
"((placeholder1)) This is template content.((placeholder2)) Believe it or not there are character limits.",
776+
f"""
777+
phone number,placeholder1,placeholder2
778+
6502532222,1
779+
6502532222,{'a' * (floor(SMS_CHAR_COUNT_LIMIT / 2))},{'a' * (floor(SMS_CHAR_COUNT_LIMIT / 2) + 1)}
780+
6502532222,{'a'* (floor(SMS_CHAR_COUNT_LIMIT / 2))},{'a' * (floor(SMS_CHAR_COUNT_LIMIT / 2) - 72)}
781+
6502532222,{'a' * (floor(SMS_CHAR_COUNT_LIMIT / 2)) },{'a' * (floor(SMS_CHAR_COUNT_LIMIT / 2) - 73)}
782+
""",
783+
{1},
784+
),
785+
],
786+
)
787+
def test_detects_rows_which_result_in_overly_long_messages(template_content, csv, error_rows):
749788
template = SMSMessageTemplate(
750-
{"content": "((placeholder))", "template_type": "sms"},
789+
{"content": template_content, "template_type": "sms"},
751790
sender=None,
752791
prefix=None,
753792
)
754793
recipients = RecipientCSV(
755-
"""
756-
phone number,placeholder
757-
6502532222,1
758-
6502532222,{one_under}
759-
6502532223,{exactly}
760-
6502532224,{one_over}
761-
""".format(
762-
one_under="a" * (SMS_CHAR_COUNT_LIMIT - 1),
763-
exactly="a" * SMS_CHAR_COUNT_LIMIT,
764-
one_over="a" * (SMS_CHAR_COUNT_LIMIT + 1),
765-
),
794+
csv,
766795
template_type=template.template_type,
767796
template=template,
768797
)
769-
assert _index_rows(recipients.rows_with_errors) == {3}
770-
assert _index_rows(recipients.rows_with_message_too_long) == {3}
798+
assert _index_rows(recipients.rows_with_errors) == error_rows
799+
assert _index_rows(recipients.rows_with_message_too_long) == error_rows
771800
assert recipients.has_errors
772801

773802

0 commit comments

Comments
 (0)