Skip to content

Commit 435dea0

Browse files
authored
Update grant approved email to show combined reimbursement total (#4541)
1 parent 5b03cba commit 435dea0

File tree

8 files changed

+204
-90
lines changed

8 files changed

+204
-90
lines changed

backend/grants/models.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -262,22 +262,46 @@ def get_admin_url(self):
262262
args=(self.pk,),
263263
)
264264

265-
def has_approved_travel(self):
266-
return self.reimbursements.filter(
267-
category__category=GrantReimbursementCategory.Category.TRAVEL
268-
).exists()
265+
def has_approved(self, category: GrantReimbursementCategory.Category) -> bool:
266+
"""Return True if grant has approved reimbursement for category."""
267+
return self.reimbursements.filter(category__category=category).exists()
269268

270-
def has_approved_accommodation(self):
271-
return self.reimbursements.filter(
272-
category__category=GrantReimbursementCategory.Category.ACCOMMODATION
273-
).exists()
269+
def has_approved_ticket(self) -> bool:
270+
return self.has_approved(GrantReimbursementCategory.Category.TICKET)
271+
272+
def has_approved_travel(self) -> bool:
273+
return self.has_approved(GrantReimbursementCategory.Category.TRAVEL)
274+
275+
def has_approved_accommodation(self) -> bool:
276+
return self.has_approved(GrantReimbursementCategory.Category.ACCOMMODATION)
277+
278+
def has_ticket_only(self) -> bool:
279+
"""Return True if grant has only ticket, no travel or accommodation."""
280+
return (
281+
self.has_approved_ticket()
282+
and not self.has_approved_travel()
283+
and not self.has_approved_accommodation()
284+
)
274285

275286
@property
276-
def total_allocated_amount(self):
277-
return sum(r.granted_amount for r in self.reimbursements.all())
287+
def total_allocated_amount(self) -> Decimal:
288+
"""Return total of all reimbursements including ticket."""
289+
return sum(
290+
(r.granted_amount for r in self.reimbursements.all()),
291+
start=Decimal(0),
292+
)
278293

279-
def has_approved(self, type_):
280-
return self.reimbursements.filter(category__category=type_).exists()
294+
@property
295+
def total_grantee_reimbursement_amount(self) -> Decimal:
296+
"""Return total reimbursement excluding ticket."""
297+
return sum(
298+
(
299+
r.granted_amount
300+
for r in self.reimbursements.all()
301+
if r.category.category != GrantReimbursementCategory.Category.TICKET
302+
),
303+
start=Decimal(0),
304+
)
281305

282306
@property
283307
def current_or_pending_status(self):

backend/grants/tasks.py

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,27 @@
1414
logger = logging.getLogger(__name__)
1515

1616

17-
def get_name(user: User | None, fallback: str = "<no name specified>"):
17+
def get_name(user: User | None, fallback: str = "<no name specified>") -> str:
1818
if not user:
1919
return fallback
2020

2121
return user.full_name or user.name or user.username or fallback
2222

2323

2424
@app.task
25-
def send_grant_reply_approved_email(*, grant_id, is_reminder):
25+
def send_grant_reply_approved_email(*, grant_id: int, is_reminder: bool) -> None:
2626
logger.info("Sending Reply APPROVED email for Grant %s", grant_id)
2727
grant = Grant.objects.get(id=grant_id)
28+
29+
total_amount = grant.total_grantee_reimbursement_amount
30+
ticket_only = grant.has_ticket_only()
31+
32+
if total_amount == 0 and not ticket_only:
33+
raise ValueError(
34+
f"Grant {grant_id} has no reimbursement amount and is not ticket-only. "
35+
"This indicates missing or zero-amount reimbursements."
36+
)
37+
2838
reply_url = urljoin(settings.FRONTEND_URL, "/grants/reply/")
2939

3040
variables = {
@@ -34,26 +44,11 @@ def send_grant_reply_approved_email(*, grant_id, is_reminder):
3444
"deadline_date_time": f"{grant.applicant_reply_deadline:%-d %B %Y %H:%M %Z}",
3545
"deadline_date": f"{grant.applicant_reply_deadline:%-d %B %Y}",
3646
"visa_page_link": urljoin(settings.FRONTEND_URL, "/visa"),
37-
"has_approved_travel": grant.has_approved_travel(),
38-
"has_approved_accommodation": grant.has_approved_accommodation(),
47+
"total_amount": f"{total_amount:.0f}" if total_amount > 0 else None,
48+
"ticket_only": ticket_only,
3949
"is_reminder": is_reminder,
4050
}
4151

42-
if grant.has_approved_travel():
43-
from grants.models import GrantReimbursementCategory
44-
45-
travel_reimbursements = grant.reimbursements.filter(
46-
category__category=GrantReimbursementCategory.Category.TRAVEL
47-
)
48-
travel_amount = sum(r.granted_amount for r in travel_reimbursements)
49-
50-
if not travel_amount or travel_amount == 0:
51-
raise ValueError(
52-
"Grant travel amount is set to Zero, can't send the email!"
53-
)
54-
55-
variables["travel_amount"] = f"{travel_amount:.0f}"
56-
5752
_new_send_grant_email(
5853
template_identifier=EmailTemplateIdentifier.grant_approved,
5954
grant=grant,

backend/grants/tests/test_models.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,24 @@ def test_calculate_grant_amounts(data):
145145
assert grant.total_allocated_amount == Decimal(expected_total)
146146

147147

148+
def test_has_approved_ticket():
149+
grant = GrantFactory()
150+
assert not grant.has_approved_ticket()
151+
152+
GrantReimbursementFactory(
153+
grant=grant,
154+
category__conference=grant.conference,
155+
category__ticket=True,
156+
granted_amount=Decimal("100"),
157+
)
158+
159+
assert grant.has_approved_ticket()
160+
161+
148162
def test_has_approved_travel():
149163
grant = GrantFactory()
164+
assert not grant.has_approved_travel()
165+
150166
GrantReimbursementFactory(
151167
grant=grant,
152168
category__conference=grant.conference,
@@ -159,6 +175,8 @@ def test_has_approved_travel():
159175

160176
def test_has_approved_accommodation():
161177
grant = GrantFactory()
178+
assert not grant.has_approved_accommodation()
179+
162180
GrantReimbursementFactory(
163181
grant=grant,
164182
category__conference=grant.conference,
@@ -169,6 +187,85 @@ def test_has_approved_accommodation():
169187
assert grant.has_approved_accommodation()
170188

171189

190+
def test_has_ticket_only_with_only_ticket():
191+
grant = GrantFactory()
192+
GrantReimbursementFactory(
193+
grant=grant,
194+
category__conference=grant.conference,
195+
category__ticket=True,
196+
granted_amount=Decimal("100"),
197+
)
198+
199+
assert grant.has_ticket_only()
200+
201+
202+
def test_has_ticket_only_with_ticket_and_travel():
203+
grant = GrantFactory()
204+
GrantReimbursementFactory(
205+
grant=grant,
206+
category__conference=grant.conference,
207+
category__ticket=True,
208+
granted_amount=Decimal("100"),
209+
)
210+
GrantReimbursementFactory(
211+
grant=grant,
212+
category__conference=grant.conference,
213+
category__travel=True,
214+
granted_amount=Decimal("500"),
215+
)
216+
217+
assert not grant.has_ticket_only()
218+
219+
220+
def test_has_ticket_only_without_ticket():
221+
grant = GrantFactory()
222+
GrantReimbursementFactory(
223+
grant=grant,
224+
category__conference=grant.conference,
225+
category__travel=True,
226+
granted_amount=Decimal("500"),
227+
)
228+
229+
assert not grant.has_ticket_only()
230+
231+
232+
def test_total_grantee_reimbursement_amount_excludes_ticket():
233+
grant = GrantFactory()
234+
GrantReimbursementFactory(
235+
grant=grant,
236+
category__conference=grant.conference,
237+
category__ticket=True,
238+
granted_amount=Decimal("100"),
239+
)
240+
GrantReimbursementFactory(
241+
grant=grant,
242+
category__conference=grant.conference,
243+
category__travel=True,
244+
granted_amount=Decimal("500"),
245+
)
246+
GrantReimbursementFactory(
247+
grant=grant,
248+
category__conference=grant.conference,
249+
category__accommodation=True,
250+
granted_amount=Decimal("200"),
251+
)
252+
253+
# Should be 500 + 200 = 700, excluding ticket
254+
assert grant.total_grantee_reimbursement_amount == Decimal("700")
255+
256+
257+
def test_total_grantee_reimbursement_amount_with_only_ticket():
258+
grant = GrantFactory()
259+
GrantReimbursementFactory(
260+
grant=grant,
261+
category__conference=grant.conference,
262+
category__ticket=True,
263+
granted_amount=Decimal("100"),
264+
)
265+
266+
assert grant.total_grantee_reimbursement_amount == Decimal("0")
267+
268+
172269
@pytest.mark.parametrize(
173270
"departure_country,country_type",
174271
[

backend/grants/tests/test_tasks.py

Lines changed: 45 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@
1010
send_grant_reply_waiting_list_email,
1111
send_grant_reply_waiting_list_update_email,
1212
)
13-
from grants.tests.factories import (
14-
GrantFactory,
15-
GrantReimbursementFactory,
16-
)
13+
from grants.tests.factories import GrantFactory, GrantReimbursementFactory
1714
from users.tests.factories import UserFactory
1815

1916
pytestmark = pytest.mark.django_db
@@ -166,8 +163,8 @@ def test_handle_grant_reply_sent_reminder(settings, sent_emails):
166163
assert sent_email.placeholders["deadline_date"] == "1 February 2023"
167164
assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/"
168165
assert sent_email.placeholders["visa_page_link"] == "https://pycon.it/visa"
169-
assert not sent_email.placeholders["has_approved_travel"]
170-
assert not sent_email.placeholders["has_approved_accommodation"]
166+
assert sent_email.placeholders["ticket_only"]
167+
assert sent_email.placeholders["total_amount"] is None
171168
assert sent_email.placeholders["is_reminder"]
172169

173170

@@ -240,51 +237,16 @@ def test_handle_grant_approved_ticket_travel_accommodation_reply_sent(
240237
)
241238
assert sent_email.placeholders["start_date"] == "2 May"
242239
assert sent_email.placeholders["end_date"] == "6 May"
243-
assert sent_email.placeholders["travel_amount"] == "680"
240+
# Total amount is 680 (travel) + 200 (accommodation) = 880, excluding ticket
241+
assert sent_email.placeholders["total_amount"] == "880"
244242
assert sent_email.placeholders["deadline_date_time"] == "1 February 2023 23:59 UTC"
245243
assert sent_email.placeholders["deadline_date"] == "1 February 2023"
246244
assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/"
247245
assert sent_email.placeholders["visa_page_link"] == "https://pycon.it/visa"
248-
assert sent_email.placeholders["has_approved_travel"]
249-
assert sent_email.placeholders["has_approved_accommodation"]
246+
assert not sent_email.placeholders["ticket_only"]
250247
assert not sent_email.placeholders["is_reminder"]
251248

252249

253-
def test_handle_grant_approved_ticket_travel_accommodation_fails_with_no_amount(
254-
settings,
255-
):
256-
settings.FRONTEND_URL = "https://pycon.it"
257-
258-
conference = ConferenceFactory(
259-
start=datetime(2023, 5, 2, tzinfo=timezone.utc),
260-
end=datetime(2023, 5, 5, tzinfo=timezone.utc),
261-
)
262-
user = UserFactory(
263-
full_name="Marco Acierno",
264-
265-
name="Marco",
266-
username="marco",
267-
)
268-
269-
grant = GrantFactory(
270-
conference=conference,
271-
applicant_reply_deadline=datetime(2023, 2, 1, 23, 59, tzinfo=timezone.utc),
272-
user=user,
273-
)
274-
GrantReimbursementFactory(
275-
grant=grant,
276-
category__conference=conference,
277-
category__travel=True,
278-
category__max_amount=Decimal("680"),
279-
granted_amount=Decimal("0"),
280-
)
281-
282-
with pytest.raises(
283-
ValueError, match="Grant travel amount is set to Zero, can't send the email!"
284-
):
285-
send_grant_reply_approved_email(grant_id=grant.id, is_reminder=False)
286-
287-
288250
def test_handle_grant_approved_ticket_only_reply_sent(settings, sent_emails):
289251
from notifications.models import EmailTemplateIdentifier
290252
from notifications.tests.factories import EmailTemplateFactory
@@ -344,8 +306,8 @@ def test_handle_grant_approved_ticket_only_reply_sent(settings, sent_emails):
344306
assert sent_email.placeholders["deadline_date"] == "1 February 2023"
345307
assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/"
346308
assert sent_email.placeholders["visa_page_link"] == "https://pycon.it/visa"
347-
assert not sent_email.placeholders["has_approved_travel"]
348-
assert not sent_email.placeholders["has_approved_accommodation"]
309+
assert sent_email.placeholders["ticket_only"]
310+
assert sent_email.placeholders["total_amount"] is None
349311
assert not sent_email.placeholders["is_reminder"]
350312

351313

@@ -415,12 +377,46 @@ def test_handle_grant_approved_travel_reply_sent(settings, sent_emails):
415377
assert sent_email.placeholders["deadline_date"] == "1 February 2023"
416378
assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/"
417379
assert sent_email.placeholders["visa_page_link"] == "https://pycon.it/visa"
418-
assert sent_email.placeholders["has_approved_travel"]
419-
assert not sent_email.placeholders["has_approved_accommodation"]
420-
assert sent_email.placeholders["travel_amount"] == "400"
380+
# Total amount is 400 (travel only), excluding ticket
381+
assert sent_email.placeholders["total_amount"] == "400"
382+
assert not sent_email.placeholders["ticket_only"]
421383
assert not sent_email.placeholders["is_reminder"]
422384

423385

386+
def test_send_grant_approved_email_raises_for_no_reimbursements(settings) -> None:
387+
"""Verify error is raised when grant has no valid reimbursements."""
388+
from notifications.models import EmailTemplateIdentifier
389+
from notifications.tests.factories import EmailTemplateFactory
390+
391+
settings.FRONTEND_URL = "https://pycon.it"
392+
393+
conference = ConferenceFactory(
394+
start=datetime(2023, 5, 2, tzinfo=timezone.utc),
395+
end=datetime(2023, 5, 5, tzinfo=timezone.utc),
396+
)
397+
user = UserFactory(
398+
full_name="Marco Acierno",
399+
400+
)
401+
402+
grant = GrantFactory(
403+
conference=conference,
404+
applicant_reply_deadline=datetime(2023, 2, 1, 23, 59, tzinfo=timezone.utc),
405+
user=user,
406+
)
407+
# No reimbursements - this is an invalid state
408+
409+
EmailTemplateFactory(
410+
conference=grant.conference,
411+
identifier=EmailTemplateIdentifier.grant_approved,
412+
)
413+
414+
with pytest.raises(
415+
ValueError, match="has no reimbursement amount and is not ticket-only"
416+
):
417+
send_grant_reply_approved_email(grant_id=grant.id, is_reminder=False)
418+
419+
424420
def test_send_grant_reply_waiting_list_update_email(settings, sent_emails):
425421
from notifications.models import EmailTemplateIdentifier
426422
from notifications.tests.factories import EmailTemplateFactory

0 commit comments

Comments
 (0)