Skip to content

Commit 659b67a

Browse files
authored
Translate report values (#2506)
This change: - updates the sql query used to generate the report so that the notification_type and status are translated into the appropriate display values. These values should match what is in current report feature - updates the date time in the report to be in eastern time, to match the current report feature - some light refactoring in models.py so I can import the email and sms statuses into utils.py - an integration test
1 parent b1a9b1a commit 659b67a

File tree

3 files changed

+183
-34
lines changed

3 files changed

+183
-34
lines changed

app/models.py

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,33 @@
7373
sms_sending_vehicles = db.Enum(*[vehicle.value for vehicle in SmsSendingVehicles], name="sms_sending_vehicles")
7474

7575

76+
EMAIL_STATUS_FORMATTED = {
77+
"failed": "Failed",
78+
"technical-failure": "Tech issue",
79+
"temporary-failure": "Content or inbox issue",
80+
"virus-scan-failed": "Attachment has virus",
81+
"delivered": "Delivered",
82+
"sending": "In transit",
83+
"created": "In transit",
84+
"sent": "Delivered",
85+
"pending": "In transit",
86+
"pending-virus-check": "In transit",
87+
"pii-check-failed": "Exceeds Protected A",
88+
}
89+
90+
SMS_STATUS_FORMATTED = {
91+
"failed": "Failed",
92+
"technical-failure": "Tech issue",
93+
"temporary-failure": "Carrier issue",
94+
"permanent-failure": "No such number",
95+
"delivered": "Delivered",
96+
"sending": "In transit",
97+
"created": "In transit",
98+
"pending": "In transit",
99+
"sent": "Sent",
100+
}
101+
102+
76103
def filter_null_value_fields(obj):
77104
return dict(filter(lambda x: x[1] is not None, obj.items()))
78105

@@ -1885,6 +1912,7 @@ def subject(self):
18851912
def formatted_status(self):
18861913
def _getStatusByBounceSubtype():
18871914
"""Return the status of a notification based on the bounce sub type"""
1915+
# note: if this function changes, update the report query in app/report/utils.py::build_notifications_query
18881916
if self.feedback_subtype:
18891917
return {
18901918
"suppressed": "Blocked",
@@ -1895,6 +1923,7 @@ def _getStatusByBounceSubtype():
18951923

18961924
def _get_sms_status_by_feedback_reason():
18971925
"""Return the status of a notification based on the feedback reason"""
1926+
# note: if this function changes, update the report query in app/report/utils.py::build_notifications_query
18981927
if self.feedback_reason:
18991928
return {
19001929
"NO_ORIGINATION_IDENTITIES_FOUND": "Can't send to this international number",
@@ -1905,30 +1934,12 @@ def _get_sms_status_by_feedback_reason():
19051934

19061935
return {
19071936
"email": {
1908-
"failed": "Failed",
1909-
"technical-failure": "Tech issue",
1910-
"temporary-failure": "Content or inbox issue",
1937+
**EMAIL_STATUS_FORMATTED,
19111938
"permanent-failure": _getStatusByBounceSubtype(),
1912-
"virus-scan-failed": "Attachment has virus",
1913-
"delivered": "Delivered",
1914-
"sending": "In transit",
1915-
"created": "In transit",
1916-
"sent": "Delivered",
1917-
"pending": "In transit",
1918-
"pending-virus-check": "In transit",
1919-
"pii-check-failed": "Exceeds Protected A",
19201939
},
19211940
"sms": {
1922-
"failed": "Failed",
1923-
"technical-failure": "Tech issue",
1924-
"temporary-failure": "Carrier issue",
1925-
"permanent-failure": "No such number",
1941+
**SMS_STATUS_FORMATTED,
19261942
"provider-failure": _get_sms_status_by_feedback_reason(),
1927-
"delivered": "Delivered",
1928-
"sending": "In transit",
1929-
"created": "In transit",
1930-
"pending": "In transit",
1931-
"sent": "Sent",
19321943
},
19331944
"letter": {
19341945
"technical-failure": "Technical failure",

app/report/utils.py

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
from sqlalchemy import func, text
1+
from sqlalchemy import case, func, text
22
from sqlalchemy.orm import aliased
33

44
from app import db
55
from app.aws.s3 import stream_to_s3
6-
from app.models import Job, Notification, Template, User
6+
from app.models import EMAIL_STATUS_FORMATTED, SMS_STATUS_FORMATTED, Job, Notification, Template, User
77

88
FR_TRANSLATIONS = {
99
"Recipient": "Destinataire",
@@ -14,6 +14,23 @@
1414
"Job": "Tâche",
1515
"Status": "État",
1616
"Sent Time": "Heure d’envoi",
17+
# notification types
18+
"email": "courriel",
19+
"sms": "sms",
20+
# notification statuses
21+
"Failed": "Échec",
22+
"Tech issue": "Problème technique",
23+
"Content or inbox issue": "Problème de contenu ou de boîte de réception",
24+
"Attachment has virus": "La pièce jointe contient un virus",
25+
"Delivered": "Livraison réussie",
26+
"In transit": "Envoi en cours",
27+
"Exceeds Protected A": "Niveau supérieur à Protégé A",
28+
"Carrier issue": "Problème du fournisseur",
29+
"No such number": "Numéro inexistant",
30+
"Sent": "Envoyé",
31+
"Blocked": "Message bloqué",
32+
"No such address": "Adresse inexistante",
33+
# "Can't send to this international number": "" # no translation exists for this yet
1734
}
1835

1936

@@ -51,19 +68,19 @@ def build_notifications_query(service_id, notification_type, language, days_limi
5168
j = aliased(Job)
5269
u = aliased(User)
5370

54-
translate = Translate(language).translate
55-
56-
# Build the query using SQLAlchemy
57-
return (
71+
# Build the inner subquery (returns enum values, cast as text for notification_type)
72+
inner_query = (
5873
db.session.query(
59-
n.to.label(translate("Recipient")),
60-
t.name.label(translate("Template")),
61-
n.notification_type.label(translate("Type")),
62-
func.coalesce(u.name, "").label(translate("Sent by")),
63-
func.coalesce(u.email_address, "").label(translate("Sent by email")),
64-
func.coalesce(j.original_file_name, "").label(translate("Job")),
65-
n.status.label(translate("Status")),
66-
func.to_char(n.created_at, "YYYY-MM-DD HH24:MI:SS").label(translate("Sent Time")),
74+
n.to.label("to"),
75+
t.name.label("template_name"),
76+
n.notification_type.cast(db.String).label("notification_type"),
77+
u.name.label("user_name"),
78+
u.email_address.label("user_email"),
79+
j.original_file_name.label("job_name"),
80+
n.status.label("status"),
81+
n.created_at.label("created_at"),
82+
n.feedback_subtype.label("feedback_subtype"),
83+
n.feedback_reason.label("feedback_reason"),
6784
)
6885
.join(t, t.id == n.template_id)
6986
.outerjoin(j, j.id == n.job_id)
@@ -74,6 +91,63 @@ def build_notifications_query(service_id, notification_type, language, days_limi
7491
n.created_at > func.now() - text(f"interval '{days_limit} days'"),
7592
)
7693
.order_by(n.created_at.desc())
94+
.subquery()
95+
)
96+
97+
# Map statuses for translation
98+
translate = Translate(language).translate
99+
# Provider-failure logic for email
100+
provider_failure_email = case(
101+
[(inner_query.c.feedback_subtype.in_(["suppressed", "on-account-suppression-list"]), "Blocked")], else_="No such address"
102+
)
103+
# Provider-failure logic for sms
104+
provider_failure_sms = case(
105+
[
106+
(
107+
inner_query.c.feedback_reason.in_(["NO_ORIGINATION_IDENTITIES_FOUND", "DESTINATION_COUNTRY_BLOCKED"]),
108+
"Can't send to this international number",
109+
)
110+
],
111+
else_="No such number",
112+
)
113+
114+
email_status_cases = [(inner_query.c.status == k, translate(v)) for k, v in EMAIL_STATUS_FORMATTED.items()]
115+
sms_status_cases = [(inner_query.c.status == k, translate(v)) for k, v in SMS_STATUS_FORMATTED.items()]
116+
# Add provider-failure logic
117+
if notification_type == "email":
118+
email_status_cases.append((inner_query.c.status == "provider-failure", translate(provider_failure_email)))
119+
status_expr = case(email_status_cases, else_=inner_query.c.status)
120+
elif notification_type == "sms":
121+
sms_status_cases.append((inner_query.c.status == "provider-failure", translate(provider_failure_sms)))
122+
status_expr = case(sms_status_cases, else_=inner_query.c.status)
123+
else:
124+
status_expr = inner_query.c.status
125+
if language == "fr":
126+
status_expr = func.coalesce(func.nullif(status_expr, ""), "").label(translate("Status"))
127+
else:
128+
status_expr = status_expr.label(translate("Status"))
129+
130+
# Outer query: translate notification_type for display
131+
notification_type_translated = case(
132+
[
133+
(inner_query.c.notification_type == "email", translate("email")),
134+
(inner_query.c.notification_type == "sms", translate("sms")),
135+
],
136+
else_=inner_query.c.notification_type,
137+
).label(translate("Type"))
138+
139+
return db.session.query(
140+
inner_query.c.to.label(translate("Recipient")),
141+
inner_query.c.template_name.label(translate("Template")),
142+
notification_type_translated,
143+
func.coalesce(inner_query.c.user_name, "").label(translate("Sent by")),
144+
func.coalesce(inner_query.c.user_email, "").label(translate("Sent by email")),
145+
func.coalesce(inner_query.c.job_name, "").label(translate("Job")),
146+
status_expr,
147+
# Explicitly cast created_at to UTC, then to America/Toronto
148+
func.to_char(
149+
func.timezone("America/Toronto", func.timezone("UTC", inner_query.c.created_at)), "YYYY-MM-DD HH24:MI:SS"
150+
).label(translate("Sent Time")),
77151
)
78152

79153

tests/app/report/test_utils.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import csv
2+
import io
3+
from datetime import datetime, timedelta
14
from unittest.mock import patch
25

36
from app.report.utils import (
47
Translate,
8+
build_notifications_query,
59
generate_csv_from_notifications,
610
)
11+
from tests.app.conftest import create_sample_email_template, create_sample_notification, create_sample_service
712

813

914
def test_translate_en():
@@ -42,3 +47,62 @@ def test_calls_helper_functions_with_correct_parameters(self):
4247
mock_build_query.assert_called_once_with(service_id, notification_type, language, days_limit)
4348
mock_compile_query.assert_called_once_with("mock query")
4449
mock_stream.assert_called_once_with("mock copy command", s3_bucket, s3_key)
50+
51+
52+
class TestNotificationReportIntegration:
53+
def test_generate_csv_from_notifications_integration(self, notify_db, notify_db_session, sample_user):
54+
service = create_sample_service(notify_db, notify_db_session, user=sample_user)
55+
template = create_sample_email_template(notify_db, notify_db_session, service=service)
56+
57+
now = datetime.utcnow()
58+
notification_data = [
59+
{"to_field": "[email protected]", "status": "delivered", "personalisation": {"name": "User1"}, "created_at": now},
60+
{
61+
"to_field": "[email protected]",
62+
"status": "failed",
63+
"personalisation": {"name": "User2"},
64+
"created_at": now - timedelta(minutes=1),
65+
},
66+
]
67+
for data in notification_data:
68+
create_sample_notification(
69+
notify_db,
70+
notify_db_session,
71+
service=service,
72+
template=template,
73+
to_field=data["to_field"],
74+
status=data["status"],
75+
personalisation=data["personalisation"],
76+
created_at=data["created_at"],
77+
)
78+
79+
# Patch stream_query_to_s3 to write to a buffer instead of S3
80+
csv_buffer = io.StringIO()
81+
82+
def fake_stream_query_to_s3(copy_command, s3_bucket, s3_key):
83+
# Actually run the query and write CSV to the buffer
84+
query = build_notifications_query(str(service.id), "email", "en", 7)
85+
result = query.all()
86+
fieldnames = [col["name"] for col in query.column_descriptions]
87+
writer = csv.DictWriter(csv_buffer, fieldnames=fieldnames)
88+
writer.writeheader()
89+
for row in result:
90+
writer.writerow(dict(zip(fieldnames, row)))
91+
csv_buffer.seek(0)
92+
93+
with patch("app.report.utils.stream_query_to_s3", fake_stream_query_to_s3):
94+
generate_csv_from_notifications(
95+
str(service.id),
96+
"email",
97+
"en",
98+
days_limit=7,
99+
s3_bucket="test-bucket",
100+
s3_key="test-key.csv",
101+
)
102+
103+
# Check the buffer content
104+
rows = list(csv.DictReader(csv_buffer.getvalue().splitlines()))
105+
assert len(rows) == 2
106+
assert rows[0]["Recipient"] == "[email protected]"
107+
assert rows[1]["Recipient"] == "[email protected]"
108+
assert set(r["Status"] for r in rows) == {"Delivered", "Failed"}

0 commit comments

Comments
 (0)