Skip to content

Commit 7b82d48

Browse files
committed
Simplify grant approved email to use total amount instead of per-category
- Add is_internal field to GrantReimbursementCategory to mark categories for internal budget tracking only (e.g., Ticket) - Add total_grantee_reimbursement_amount property to Grant that excludes internal categories from the total shown to grantees - Add has_only_internal_reimbursements property to detect ticket-only grants - Replace has_approved_travel, has_approved_accommodation, travel_amount placeholders with simpler total_amount and ticket_only placeholders - Update tests to reflect new placeholder structure 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 7b82d48

7 files changed

Lines changed: 77 additions & 67 deletions

File tree

backend/grants/admin.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,8 +421,14 @@ def queryset(self, request, queryset):
421421

422422
@admin.register(GrantReimbursementCategory)
423423
class GrantReimbursementCategoryAdmin(ConferencePermissionMixin, admin.ModelAdmin):
424-
list_display = ("__str__", "max_amount", "category", "included_by_default")
425-
list_filter = ("conference", "category", "included_by_default")
424+
list_display = (
425+
"__str__",
426+
"max_amount",
427+
"category",
428+
"included_by_default",
429+
"is_internal",
430+
)
431+
list_filter = ("conference", "category", "included_by_default", "is_internal")
426432
search_fields = ("category", "name")
427433

428434

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.8 on 2026-01-27 12:27
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('grants', '0031_grantreimbursement_grants_gran_grant_i_bd545b_idx'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='grantreimbursementcategory',
15+
name='is_internal',
16+
field=models.BooleanField(default=False, help_text='Internal categories are for budget tracking only and not shown to grantees (e.g., Ticket cost)'),
17+
),
18+
]

backend/grants/models.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ class Category(models.TextChoices):
4545
default=False,
4646
help_text="Automatically include this category in grants by default",
4747
)
48+
is_internal = models.BooleanField(
49+
default=False,
50+
help_text=_(
51+
"Internal categories are for budget tracking only and "
52+
"not shown to grantees (e.g., Ticket cost)"
53+
),
54+
)
4855

4956
objects = GrantQuerySet().as_manager()
5057

@@ -274,8 +281,30 @@ def has_approved_accommodation(self):
274281

275282
@property
276283
def total_allocated_amount(self):
284+
"""Total of all reimbursements (including internal categories like Ticket)."""
277285
return sum(r.granted_amount for r in self.reimbursements.all())
278286

287+
@property
288+
def total_grantee_reimbursement_amount(self):
289+
"""
290+
Total reimbursement amount shown to grantees.
291+
Excludes internal categories (like Ticket) that are for budget tracking only.
292+
"""
293+
return sum(
294+
r.granted_amount
295+
for r in self.reimbursements.filter(category__is_internal=False)
296+
)
297+
298+
@property
299+
def has_only_internal_reimbursements(self):
300+
"""
301+
Returns True if the grant only has internal reimbursements (e.g., ticket only).
302+
Used to determine if we should show "ticket only" messaging to grantees.
303+
"""
304+
if not self.reimbursements.exists():
305+
return False
306+
return not self.reimbursements.filter(category__is_internal=False).exists()
307+
279308
def has_approved(self, type_):
280309
return self.reimbursements.filter(category__category=type_).exists()
281310

backend/grants/tasks.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,33 +27,21 @@ def send_grant_reply_approved_email(*, grant_id, is_reminder):
2727
grant = Grant.objects.get(id=grant_id)
2828
reply_url = urljoin(settings.FRONTEND_URL, "/grants/reply/")
2929

30+
total_amount = grant.total_grantee_reimbursement_amount
31+
ticket_only = grant.has_only_internal_reimbursements
32+
3033
variables = {
3134
"reply_url": reply_url,
3235
"start_date": f"{grant.conference.start:%-d %B}",
3336
"end_date": f"{grant.conference.end + timedelta(days=1):%-d %B}",
3437
"deadline_date_time": f"{grant.applicant_reply_deadline:%-d %B %Y %H:%M %Z}",
3538
"deadline_date": f"{grant.applicant_reply_deadline:%-d %B %Y}",
3639
"visa_page_link": urljoin(settings.FRONTEND_URL, "/visa"),
37-
"has_approved_travel": grant.has_approved_travel(),
38-
"has_approved_accommodation": grant.has_approved_accommodation(),
40+
"total_amount": f"{total_amount:.0f}" if total_amount > 0 else None,
41+
"ticket_only": ticket_only,
3942
"is_reminder": is_reminder,
4043
}
4144

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-
5745
_new_send_grant_email(
5846
template_identifier=EmailTemplateIdentifier.grant_approved,
5947
grant=grant,

backend/grants/tests/factories.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,31 +75,36 @@ class Meta:
7575
[choice[0] for choice in GrantReimbursementCategory.Category.choices]
7676
)
7777
included_by_default = False
78+
is_internal = False
7879

7980
class Params:
8081
ticket = factory.Trait(
8182
category=GrantReimbursementCategory.Category.TICKET,
8283
name="Ticket",
8384
max_amount=Decimal("100"),
8485
included_by_default=True,
86+
is_internal=True, # Ticket is for internal budget tracking only
8587
)
8688
travel = factory.Trait(
8789
category=GrantReimbursementCategory.Category.TRAVEL,
8890
name="Travel",
8991
max_amount=Decimal("500"),
9092
included_by_default=False,
93+
is_internal=False,
9194
)
9295
accommodation = factory.Trait(
9396
category=GrantReimbursementCategory.Category.ACCOMMODATION,
9497
name="Accommodation",
9598
max_amount=Decimal("300"),
9699
included_by_default=False,
100+
is_internal=False,
97101
)
98102
other = factory.Trait(
99103
category=GrantReimbursementCategory.Category.OTHER,
100104
name="Other",
101105
max_amount=Decimal("200"),
102106
included_by_default=False,
107+
is_internal=False,
103108
)
104109

105110

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/notifications/models.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,8 @@ class EmailTemplate(TimeStampedModel):
119119
"deadline_date_time",
120120
"deadline_date",
121121
"visa_page_link",
122-
"has_approved_travel",
123-
"has_approved_accommodation",
124-
"travel_amount",
122+
"total_amount",
123+
"ticket_only",
125124
"is_reminder",
126125
],
127126
EmailTemplateIdentifier.grant_rejected: [

0 commit comments

Comments
 (0)