Skip to content

Commit b98e030

Browse files
committed
Update grant approved email to show combined reimbursement total
- Add has_approved_ticket(), has_ticket_only() methods to Grant model - Add total_grantee_reimbursement_amount property (excludes ticket) - Refactor has_approved() to be base method for all has_approved_* methods - Replace old placeholders (has_approved_travel, has_approved_accommodation, travel_amount) with simpler total_amount and ticket_only - Update plain_cards to show total reimbursement instead of just travel - Update visa/models.py to use has_approved_ticket() - Add comprehensive tests for new model methods This allows showing grantees a single total amount they can use flexibly for travel and/or accommodation, rather than separate category amounts.
1 parent fcbab6e commit b98e030

8 files changed

Lines changed: 161 additions & 86 deletions

File tree

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: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@
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)
2828
reply_url = urljoin(settings.FRONTEND_URL, "/grants/reply/")
@@ -34,26 +34,13 @@ def send_grant_reply_approved_email(*, grant_id, is_reminder):
3434
"deadline_date_time": f"{grant.applicant_reply_deadline:%-d %B %Y %H:%M %Z}",
3535
"deadline_date": f"{grant.applicant_reply_deadline:%-d %B %Y}",
3636
"visa_page_link": urljoin(settings.FRONTEND_URL, "/visa"),
37-
"has_approved_travel": grant.has_approved_travel(),
38-
"has_approved_accommodation": grant.has_approved_accommodation(),
37+
"total_amount": f"{grant.total_grantee_reimbursement_amount:.0f}"
38+
if grant.total_grantee_reimbursement_amount > 0
39+
else None,
40+
"ticket_only": grant.has_ticket_only(),
3941
"is_reminder": is_reminder,
4042
}
4143

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-
5744
_new_send_grant_email(
5845
template_identifier=EmailTemplateIdentifier.grant_approved,
5946
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: 10 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ def test_handle_grant_reply_sent_reminder(settings, sent_emails):
166166
assert sent_email.placeholders["deadline_date"] == "1 February 2023"
167167
assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/"
168168
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"]
169+
assert sent_email.placeholders["ticket_only"]
170+
assert sent_email.placeholders["total_amount"] is None
171171
assert sent_email.placeholders["is_reminder"]
172172

173173

@@ -240,51 +240,16 @@ def test_handle_grant_approved_ticket_travel_accommodation_reply_sent(
240240
)
241241
assert sent_email.placeholders["start_date"] == "2 May"
242242
assert sent_email.placeholders["end_date"] == "6 May"
243-
assert sent_email.placeholders["travel_amount"] == "680"
243+
# Total amount is 680 (travel) + 200 (accommodation) = 880, excluding ticket
244+
assert sent_email.placeholders["total_amount"] == "880"
244245
assert sent_email.placeholders["deadline_date_time"] == "1 February 2023 23:59 UTC"
245246
assert sent_email.placeholders["deadline_date"] == "1 February 2023"
246247
assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/"
247248
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"]
249+
assert not sent_email.placeholders["ticket_only"]
250250
assert not sent_email.placeholders["is_reminder"]
251251

252252

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-
email="marco@placeholder.it",
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-
288253
def test_handle_grant_approved_ticket_only_reply_sent(settings, sent_emails):
289254
from notifications.models import EmailTemplateIdentifier
290255
from notifications.tests.factories import EmailTemplateFactory
@@ -344,8 +309,8 @@ def test_handle_grant_approved_ticket_only_reply_sent(settings, sent_emails):
344309
assert sent_email.placeholders["deadline_date"] == "1 February 2023"
345310
assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/"
346311
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"]
312+
assert sent_email.placeholders["ticket_only"]
313+
assert sent_email.placeholders["total_amount"] is None
349314
assert not sent_email.placeholders["is_reminder"]
350315

351316

@@ -415,9 +380,9 @@ def test_handle_grant_approved_travel_reply_sent(settings, sent_emails):
415380
assert sent_email.placeholders["deadline_date"] == "1 February 2023"
416381
assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/"
417382
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"
383+
# Total amount is 400 (travel only), excluding ticket
384+
assert sent_email.placeholders["total_amount"] == "400"
385+
assert not sent_email.placeholders["ticket_only"]
421386
assert not sent_email.placeholders["is_reminder"]
422387

423388

backend/integrations/plain_cards.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,26 +76,26 @@ def create_grant_card(request, user, conference):
7676
{
7777
"componentText": {
7878
"textColor": "MUTED",
79-
"text": "Travel amount",
79+
"text": "Total reimbursement",
8080
}
8181
}
8282
],
8383
"rowAsideContent": [
8484
{
8585
"componentText": {
8686
"textColor": "NORMAL",
87-
"text": f"€{sum(r.granted_amount for r in grant.reimbursements.filter(category__category='travel'))}",
87+
"text": f"€{grant.total_grantee_reimbursement_amount}",
8888
}
8989
}
9090
],
9191
}
9292
}
93-
if grant.has_approved_travel()
93+
if grant.total_grantee_reimbursement_amount > 0
9494
else None
9595
),
9696
(
9797
{"componentSpacer": {"spacerSize": "M"}}
98-
if grant.has_approved_travel()
98+
if grant.total_grantee_reimbursement_amount > 0
9999
else None
100100
),
101101
{

backend/integrations/tests/test_views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,12 @@ def test_get_plain_customer_cards_grant_card(rest_api_client):
205205
assert "Travel" in approval_text
206206
assert "Accommodation" in approval_text
207207

208+
# Total reimbursement is 100 (travel) + 200 (accommodation) = 300, excluding ticket
208209
assert (
209210
grant_card["components"][4]["componentRow"]["rowAsideContent"][0][
210211
"componentText"
211212
]["text"]
212-
== "€100"
213+
== "€300"
213214
)
214215

215216

backend/notifications/models.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,16 @@ class EmailTemplate(TimeStampedModel):
113113
],
114114
EmailTemplateIdentifier.grant_approved: [
115115
*BASE_PLACEHOLDERS,
116+
"conference_name",
117+
"user_name",
116118
"reply_url",
117119
"start_date",
118120
"end_date",
119121
"deadline_date_time",
120122
"deadline_date",
121123
"visa_page_link",
122-
"has_approved_travel",
123-
"has_approved_accommodation",
124-
"travel_amount",
124+
"total_amount",
125+
"ticket_only",
125126
"is_reminder",
126127
],
127128
EmailTemplateIdentifier.grant_rejected: [

0 commit comments

Comments
 (0)