Skip to content

Commit c1680ce

Browse files
committed
Change 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 c1680ce

File tree

7 files changed

+85
-71
lines changed

7 files changed

+85
-71
lines changed

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: 33 additions & 2 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

@@ -273,8 +280,32 @@ def has_approved_accommodation(self):
273280
).exists()
274281

275282
@property
276-
def total_allocated_amount(self):
277-
return sum(r.granted_amount for r in self.reimbursements.all())
283+
def total_allocated_amount(self) -> Decimal:
284+
"""Return total of all reimbursements including internal categories."""
285+
return sum(
286+
(r.granted_amount for r in self.reimbursements.all()),
287+
start=Decimal(0),
288+
)
289+
290+
@property
291+
def total_grantee_reimbursement_amount(self) -> Decimal:
292+
"""Return total reimbursement excluding internal categories."""
293+
return sum(
294+
(
295+
r.granted_amount
296+
for r in self.reimbursements.all()
297+
if not r.category.is_internal
298+
),
299+
start=Decimal(0),
300+
)
301+
302+
@property
303+
def has_only_internal_reimbursements(self) -> bool:
304+
"""Return True if grant has only internal reimbursements."""
305+
reimbursements = list(self.reimbursements.all())
306+
if not reimbursements:
307+
return False
308+
return all(r.category.is_internal for r in reimbursements)
278309

279310
def has_approved(self, type_):
280311
return self.reimbursements.filter(category__category=type_).exists()

backend/grants/tasks.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,46 +14,34 @@
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/")
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-
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: 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)