Skip to content

Commit 27394a3

Browse files
authored
Merge branch 'develop' into 5599-email-template-updates
2 parents 0918e74 + 33ed6c8 commit 27394a3

File tree

56 files changed

+5242
-1361
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+5242
-1361
lines changed

Taskfile.yml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,18 +231,22 @@ tasks:
231231
- npm install
232232

233233
frontend-test:
234-
desc: Run frontend unit tests in docker (watch-less)
234+
desc: 'Run frontend unit tests in docker (watch-less). E.g: task frontend-test JEST_ARGS="--testPathPattern=FeedbackReports"'
235235
dir: tdrs-frontend
236+
vars:
237+
JEST_ARGS: '{{.JEST_ARGS | default ""}}'
236238
cmds:
237239
- docker compose -f docker-compose.local.yml up -d --build tdp-frontend-test
238-
- docker compose -f docker-compose.local.yml exec tdp-frontend-test sh -c "npm run test"
240+
- docker compose -f docker-compose.local.yml exec tdp-frontend-test sh -c "npm run test -- {{.JEST_ARGS}}"
239241

240242
frontend-test-cov:
241-
desc: Run frontend unit tests with coverage in docker
243+
desc: 'Run frontend unit tests with coverage in docker. E.g: task frontend-test-cov JEST_ARGS="--testPathPattern=FeedbackReports"'
242244
dir: tdrs-frontend
245+
vars:
246+
JEST_ARGS: '{{.JEST_ARGS | default ""}}'
243247
cmds:
244248
- docker compose -f docker-compose.local.yml up -d --build tdp-frontend-test
245-
- docker compose -f docker-compose.local.yml exec tdp-frontend-test sh -c "npm run test:cov"
249+
- docker compose -f docker-compose.local.yml exec tdp-frontend-test sh -c "npm run test:cov -- {{.JEST_ARGS}}"
246250

247251
frontend-lint:
248252
desc: Run eslint in the frontend test container

tdrs-backend/tdpservice/email/email_enums.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ class DataFileEmail(Enum):
2525
UPCOMING_SUBMISSION_DEADLINE = "upcoming-submission-deadline.html"
2626

2727

28+
class FeedbackReportEmail(Enum):
29+
"""Email templates related to feedback report reminders."""
30+
31+
REPORT_AVAILABLE = "feedback/report-available.html"
32+
33+
2834
class TanfDataFileEmail(Enum):
2935
"""Email templates related to TANF data file submissions."""
3036

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Helper functions for sending feedback report notification emails."""
2+
3+
from django.conf import settings
4+
5+
from tdpservice.email.email import automated_email, log
6+
from tdpservice.email.email_enums import FeedbackReportEmail
7+
from tdpservice.reports.models import ReportFile
8+
9+
10+
def send_feedback_report_available_email(report_file: ReportFile, recipients):
11+
"""
12+
Send an email to Data Analysts when a feedback report is available.
13+
14+
Parameters
15+
----------
16+
report_file: The ReportFile that was created
17+
recipients: List of email addresses (usernames) to send to
18+
"""
19+
if not recipients:
20+
return
21+
22+
# Format date_extracted_on as MM/DD/YYYY
23+
date_extracted_str = (
24+
report_file.date_extracted_on.strftime("%m/%d/%Y")
25+
if report_file.date_extracted_on
26+
else "N/A"
27+
)
28+
29+
# Only create logger_context if we have a valid user (LogEntry requires user_id)
30+
logger_context = None
31+
if report_file.user:
32+
logger_context = {
33+
"user_id": report_file.user.id,
34+
"object_id": report_file.id,
35+
"object_repr": f"ReportFile for {report_file.stt.name} FY {report_file.year} ({date_extracted_str})",
36+
"content_type": ReportFile,
37+
}
38+
39+
template_path = FeedbackReportEmail.REPORT_AVAILABLE.value
40+
subject = f"Feedback Report Available: {report_file.stt.name} - FY {report_file.year}"
41+
text_message = (
42+
f"A new feedback report is available for {report_file.stt.name} "
43+
f"for Fiscal Year {report_file.year} (reflects data submitted through {date_extracted_str})."
44+
)
45+
46+
context = {
47+
"stt_name": report_file.stt.name,
48+
"fiscal_year": report_file.year,
49+
"date_extracted_on": date_extracted_str,
50+
"report_date": report_file.created_at.strftime("%m/%d/%Y"),
51+
"url": settings.FRONTEND_BASE_URL,
52+
"subject": subject,
53+
}
54+
55+
log(
56+
f"Feedback report available; emailing Data Analysts {list(recipients)}",
57+
logger_context=logger_context,
58+
)
59+
60+
automated_email(
61+
email_path=template_path,
62+
recipient_email=recipients,
63+
subject=subject,
64+
email_context=context,
65+
text_message=text_message,
66+
logger_context=logger_context,
67+
)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Test cases for feedback report email helper."""
2+
3+
from datetime import date
4+
5+
import pytest
6+
from unittest.mock import patch, MagicMock
7+
from django.core import mail
8+
from django.utils import timezone
9+
10+
from tdpservice.email.helpers.feedback_report import send_feedback_report_available_email
11+
12+
13+
@pytest.fixture
14+
def mock_report_file(stt, user):
15+
"""Create a mock ReportFile for testing."""
16+
report_file = MagicMock()
17+
report_file.id = 1
18+
report_file.stt = stt
19+
report_file.year = 2025
20+
report_file.date_extracted_on = date(2025, 1, 31)
21+
report_file.created_at = timezone.now()
22+
report_file.user = user
23+
return report_file
24+
25+
26+
@pytest.mark.django_db
27+
class TestSendFeedbackReportAvailableEmail:
28+
"""Tests for send_feedback_report_available_email function."""
29+
30+
def test_sends_email_to_recipients(self, mock_report_file):
31+
"""Test that email is sent to provided recipients."""
32+
recipients = ["user1@example.com", "user2@example.com"]
33+
34+
send_feedback_report_available_email(mock_report_file, recipients)
35+
36+
assert len(mail.outbox) == 1
37+
assert mock_report_file.stt.name in mail.outbox[0].subject
38+
assert "2025" in mail.outbox[0].subject
39+
40+
def test_does_not_send_email_when_no_recipients(self, mock_report_file):
41+
"""Test that no email is sent when recipients list is empty."""
42+
send_feedback_report_available_email(mock_report_file, [])
43+
44+
assert len(mail.outbox) == 0
45+
46+
def test_does_not_send_email_when_recipients_is_none(self, mock_report_file):
47+
"""Test that no email is sent when recipients is None."""
48+
send_feedback_report_available_email(mock_report_file, None)
49+
50+
assert len(mail.outbox) == 0
51+
52+
def test_email_contains_expected_text_message(self, mock_report_file):
53+
"""Test that email body contains expected text."""
54+
recipients = ["user@example.com"]
55+
56+
send_feedback_report_available_email(mock_report_file, recipients)
57+
58+
assert len(mail.outbox) == 1
59+
assert mock_report_file.stt.name in mail.outbox[0].body
60+
assert "2025" in mail.outbox[0].body
61+
assert "01/31/2025" in mail.outbox[0].body
62+
63+
def test_uses_correct_email_template(self, mock_report_file):
64+
"""Test that the correct email template is referenced."""
65+
recipients = ["user@example.com"]
66+
67+
with patch("tdpservice.email.helpers.feedback_report.automated_email") as mock_email:
68+
send_feedback_report_available_email(mock_report_file, recipients)
69+
70+
mock_email.assert_called_once()
71+
call_kwargs = mock_email.call_args[1]
72+
assert call_kwargs["email_path"] == "feedback/report-available.html"
73+
74+
def test_email_context_contains_required_fields(self, mock_report_file):
75+
"""Test that email context contains all required template variables."""
76+
recipients = ["user@example.com"]
77+
78+
with patch("tdpservice.email.helpers.feedback_report.automated_email") as mock_email:
79+
send_feedback_report_available_email(mock_report_file, recipients)
80+
81+
call_kwargs = mock_email.call_args[1]
82+
context = call_kwargs["email_context"]
83+
84+
assert "stt_name" in context
85+
assert "fiscal_year" in context
86+
assert "date_extracted_on" in context
87+
assert "report_date" in context
88+
assert "url" in context
89+
assert "subject" in context
90+
91+
assert context["stt_name"] == mock_report_file.stt.name
92+
assert context["fiscal_year"] == 2025
93+
assert context["date_extracted_on"] == "01/31/2025"
94+
95+
def test_handles_report_file_without_user(self, stt):
96+
"""Test that email is sent even if report_file.user is None."""
97+
report_file = MagicMock()
98+
report_file.id = 1
99+
report_file.stt = stt
100+
report_file.year = 2025
101+
report_file.date_extracted_on = date(2025, 1, 31)
102+
report_file.created_at = timezone.now()
103+
report_file.user = None
104+
105+
recipients = ["user@example.com"]
106+
107+
send_feedback_report_available_email(report_file, recipients)
108+
109+
assert len(mail.outbox) == 1
110+
111+
def test_handles_report_file_without_date_extracted_on(self, stt, user):
112+
"""Test that email is sent even if date_extracted_on is None."""
113+
report_file = MagicMock()
114+
report_file.id = 1
115+
report_file.stt = stt
116+
report_file.year = 2025
117+
report_file.date_extracted_on = None
118+
report_file.created_at = timezone.now()
119+
report_file.user = user
120+
121+
recipients = ["user@example.com"]
122+
123+
send_feedback_report_available_email(report_file, recipients)
124+
125+
assert len(mail.outbox) == 1
126+
assert "N/A" in mail.outbox[0].body
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{% extends 'base.html' %}
2+
3+
{% block content %}
4+
<div style="color: #000000;">
5+
<p>Hello,</p>
6+
7+
<p>
8+
A new Feedback Report is available for <b>{{ stt_name }}</b> for Fiscal Year <b>{{ fiscal_year }}</b> (reflects data submitted through <b>{{ date_extracted_on }}</b>).
9+
</p>
10+
11+
<p>
12+
The report was uploaded on <b>{{ report_date }}</b>. Please review the feedback and, if needed, resubmit complete and accurate data.
13+
</p>
14+
15+
<p>
16+
<a
17+
class="button"
18+
href="{{ url }}/feedback-reports?year={{ fiscal_year }}"
19+
style="background-color:#336a90;border-radius:4px;color:#ffffff;display:inline-block;font-family:sans-serif;font-size:18px;font-weight:bold;line-height:60px;text-align:center;text-decoration:none;width: auto; padding-left: 24px; padding-right: 24px;-webkit-text-size-adjust:none;"
20+
>
21+
View Feedback Reports
22+
</a>
23+
</p>
24+
25+
<p>
26+
Please contact TDP Admin team at <a href="mailto:TANFData@acf.hhs.gov" target="_blank">TANFData@acf.hhs.gov</a> if you need further assistance.
27+
</p>
28+
29+
<p>Thank you,</p>
30+
TDP Team
31+
</div>
32+
{% endblock %}

tdrs-backend/tdpservice/reports/admin.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def queryset(self, request, queryset):
2323
versions = queryset.filter(
2424
stt__stt_code=OuterRef("stt__stt_code"),
2525
year=OuterRef("year"),
26-
quarter=OuterRef("quarter"),
26+
date_extracted_on=OuterRef("date_extracted_on"),
2727
).order_by("-version")
2828

2929
# Filter to only records with the latest version
@@ -39,15 +39,15 @@ class ReportFileAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
3939
list_display = [
4040
"id",
4141
"year",
42-
"quarter",
42+
"date_extracted_on",
4343
"stt",
4444
"version",
4545
"user",
4646
]
4747
list_filter = [
4848
"stt",
4949
"year",
50-
"quarter",
50+
"date_extracted_on",
5151
"user",
5252
VersionFilter
5353
]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 3.2.15 on 2026-01-30 19:50
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('reports', '0002_initial'),
10+
]
11+
12+
operations = [
13+
migrations.RemoveConstraint(
14+
model_name='reportfile',
15+
name='unique_reports_reportfile_fields',
16+
),
17+
migrations.RemoveField(
18+
model_name='reportfile',
19+
name='quarter',
20+
),
21+
migrations.RemoveField(
22+
model_name='reportsource',
23+
name='quarter',
24+
),
25+
migrations.AddField(
26+
model_name='reportfile',
27+
name='date_extracted_on',
28+
field=models.DateField(blank=True, null=True),
29+
),
30+
migrations.AddField(
31+
model_name='reportsource',
32+
name='date_extracted_on',
33+
field=models.DateField(blank=True, null=True),
34+
),
35+
migrations.AddConstraint(
36+
model_name='reportfile',
37+
constraint=models.UniqueConstraint(fields=('version', 'date_extracted_on', 'year', 'stt'), name='unique_reports_reportfile_fields'),
38+
),
39+
]

0 commit comments

Comments
 (0)