Skip to content

Commit a9233db

Browse files
committed
Refactor grant reimbursement categories to be flexible
Remove hardcoded default grant amounts for ticket, accommodation, and travel from `Conference` in favor of using `GrantReimbursementCategory`. Update all relevant admin forms, models, and templates to reference flexible categories instead of fixed fields. - Remove legacy fields: `grants_default_ticket_amount`, `grants_default_accommodation_amount`, `grants_default_travel_from_italy_amount`, and `grants_default_travel_from_europe_amount` from `Conference` - Update `Grant` and `GrantReimbursement` logic to work exclusively with `GrantReimbursementCategory` - Refactor grant review admin and summary logic to support multiple, configurable reimbursement categories per grant - Migrate existing grants to new reimbursement category scheme - Add and update tests and migrations to cover flexible grant categories This change allows flexible reimbursement types (and amounts) to be configured per conference, supports granular grant allocation, and paves the way for internationalization and more complex business rules.
1 parent c9c8475 commit a9233db

File tree

12 files changed

+1117
-333
lines changed

12 files changed

+1117
-333
lines changed

backend/conferences/admin/conference.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -184,18 +184,6 @@ class ConferenceAdmin(
184184
)
185185
},
186186
),
187-
(
188-
"Grants",
189-
{
190-
"fields": (
191-
"grants_default_ticket_amount",
192-
"grants_default_accommodation_amount",
193-
"grants_default_travel_from_italy_amount",
194-
"grants_default_travel_from_europe_amount",
195-
"grants_default_travel_from_extra_eu_amount",
196-
)
197-
},
198-
),
199187
("YouTube", {"fields": ("video_title_template", "video_description_template")}),
200188
)
201189
inlines = [DeadlineInline, DurationInline, SponsorLevelInline, IncludedEventInline]
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 5.1.4 on 2025-07-27 14:30
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('conferences', '0054_conference_frontend_revalidate_secret_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.RemoveField(
14+
model_name='conference',
15+
name='grants_default_accommodation_amount',
16+
),
17+
migrations.RemoveField(
18+
model_name='conference',
19+
name='grants_default_ticket_amount',
20+
),
21+
migrations.RemoveField(
22+
model_name='conference',
23+
name='grants_default_travel_from_europe_amount',
24+
),
25+
migrations.RemoveField(
26+
model_name='conference',
27+
name='grants_default_travel_from_extra_eu_amount',
28+
),
29+
migrations.RemoveField(
30+
model_name='conference',
31+
name='grants_default_travel_from_italy_amount',
32+
),
33+
]

backend/conferences/models/conference.py

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -93,47 +93,6 @@ class Conference(GeoLocalizedModel, TimeFramedModel, TimeStampedModel):
9393
default="",
9494
)
9595

96-
grants_default_ticket_amount = models.DecimalField(
97-
verbose_name=_("grants default ticket amount"),
98-
null=True,
99-
blank=True,
100-
max_digits=6,
101-
decimal_places=2,
102-
default=None,
103-
)
104-
grants_default_accommodation_amount = models.DecimalField(
105-
verbose_name=_("grants default accommodation amount"),
106-
null=True,
107-
blank=True,
108-
max_digits=6,
109-
decimal_places=2,
110-
default=None,
111-
)
112-
grants_default_travel_from_italy_amount = models.DecimalField(
113-
verbose_name=_("grants default travel from Italy amount"),
114-
null=True,
115-
blank=True,
116-
max_digits=6,
117-
decimal_places=2,
118-
default=None,
119-
)
120-
grants_default_travel_from_europe_amount = models.DecimalField(
121-
verbose_name=_("grants default travel from Europe amount"),
122-
null=True,
123-
blank=True,
124-
max_digits=6,
125-
decimal_places=2,
126-
default=None,
127-
)
128-
grants_default_travel_from_extra_eu_amount = models.DecimalField(
129-
verbose_name=_("grants default travel from Extra EU amount"),
130-
null=True,
131-
blank=True,
132-
max_digits=6,
133-
decimal_places=2,
134-
default=None,
135-
)
136-
13796
video_title_template = models.TextField(
13897
default="",
13998
blank=True,

backend/grants/admin.py

Lines changed: 76 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,53 @@
11
import logging
2-
from django.db import transaction
3-
from custom_admin.audit import (
4-
create_addition_admin_log_entry,
5-
create_change_admin_log_entry,
6-
)
7-
from conferences.models.conference_voucher import ConferenceVoucher
8-
from pycon.constants import UTC
9-
from custom_admin.admin import (
10-
confirm_pending_status,
11-
reset_pending_status_back_to_status,
12-
validate_single_conference_selection,
13-
)
14-
from import_export.resources import ModelResource
152
from datetime import timedelta
163
from typing import Dict, List, Optional
17-
from countries.filters import CountryFilter
4+
185
from django.contrib import admin, messages
6+
from django.contrib.admin import SimpleListFilter
7+
from django.db import transaction
8+
from django.db.models import Exists, F, IntegerField, OuterRef, Sum, Value
9+
from django.db.models.functions import Coalesce
1910
from django.db.models.query import QuerySet
11+
from django.urls import reverse
2012
from django.utils import timezone
13+
from django.utils.safestring import mark_safe
2114
from import_export.admin import ExportMixin
2215
from import_export.fields import Field
23-
from users.admin_mixins import ConferencePermissionMixin
16+
from import_export.resources import ModelResource
17+
18+
from conferences.models.conference_voucher import ConferenceVoucher
2419
from countries import countries
20+
from countries.filters import CountryFilter
21+
from custom_admin.admin import (
22+
confirm_pending_status,
23+
reset_pending_status_back_to_status,
24+
validate_single_conference_selection,
25+
)
26+
from custom_admin.audit import (
27+
create_addition_admin_log_entry,
28+
create_change_admin_log_entry,
29+
)
2530
from grants.tasks import (
2631
send_grant_reply_approved_email,
32+
send_grant_reply_rejected_email,
2733
send_grant_reply_waiting_list_email,
2834
send_grant_reply_waiting_list_update_email,
29-
send_grant_reply_rejected_email,
3035
)
36+
from participants.models import Participant
37+
from pretix import user_has_admission_ticket
38+
from pycon.constants import UTC
3139
from schedule.models import ScheduleItem
3240
from submissions.models import Submission
33-
from .models import Grant, GrantConfirmPendingStatusProxy
34-
from django.db.models import Exists, OuterRef
35-
from pretix import user_has_admission_ticket
36-
37-
from django.contrib.admin import SimpleListFilter
38-
from participants.models import Participant
39-
from django.urls import reverse
40-
from django.utils.safestring import mark_safe
41+
from users.admin_mixins import ConferencePermissionMixin
4142
from visa.models import InvitationLetterRequest
4243

44+
from .models import (
45+
Grant,
46+
GrantConfirmPendingStatusProxy,
47+
GrantReimbursement,
48+
GrantReimbursementCategory,
49+
)
50+
4351
logger = logging.getLogger(__name__)
4452

4553
EXPORT_GRANTS_FIELDS = (
@@ -394,6 +402,32 @@ def queryset(self, request, queryset):
394402
return queryset
395403

396404

405+
@admin.register(GrantReimbursementCategory)
406+
class GrantReimbursementCategoryAdmin(ConferencePermissionMixin, admin.ModelAdmin):
407+
list_display = ("__str__", "max_amount", "category", "included_by_default")
408+
list_filter = ("conference", "category", "included_by_default")
409+
search_fields = ("category", "name")
410+
411+
412+
@admin.register(GrantReimbursement)
413+
class GrantReimbursementAdmin(ConferencePermissionMixin, admin.ModelAdmin):
414+
list_display = (
415+
"grant",
416+
"category",
417+
"granted_amount",
418+
)
419+
list_filter = ("grant__conference", "category")
420+
search_fields = ("grant__full_name", "grant__email")
421+
autocomplete_fields = ("grant",)
422+
423+
424+
class GrantReimbursementInline(admin.TabularInline):
425+
model = GrantReimbursement
426+
extra = 0
427+
autocomplete_fields = ["category"]
428+
fields = ["category", "granted_amount"]
429+
430+
397431
@admin.register(Grant)
398432
class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
399433
change_list_template = "admin/grants/grant/change_list.html"
@@ -407,11 +441,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
407441
"emoji_gender",
408442
"conference",
409443
"status",
410-
"approved_type",
411-
"ticket_amount",
412-
"travel_amount",
413-
"accommodation_amount",
414-
"total_amount",
444+
"total_amount_display",
415445
"country_type",
416446
"user_has_ticket",
417447
"has_voucher",
@@ -425,7 +455,6 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
425455
"pending_status",
426456
"country_type",
427457
"occupation",
428-
"approved_type",
429458
"needs_funds_for_travel",
430459
"need_visa",
431460
"need_accommodation",
@@ -451,6 +480,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
451480
"delete_selected",
452481
]
453482
autocomplete_fields = ("user",)
483+
inlines = [GrantReimbursementInline]
454484

455485
fieldsets = (
456486
(
@@ -459,12 +489,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
459489
"fields": (
460490
"status",
461491
"pending_status",
462-
"approved_type",
463492
"country_type",
464-
"ticket_amount",
465-
"travel_amount",
466-
"accommodation_amount",
467-
"total_amount",
468493
"applicant_reply_sent_at",
469494
"applicant_reply_deadline",
470495
"internal_notes",
@@ -591,11 +616,22 @@ def has_sent_invitation_letter_request(self, obj: Grant) -> bool:
591616
if obj.has_invitation_letter_request:
592617
return "📧"
593618
return ""
619+
@admin.display(description="Total")
620+
def total_amount_display(self, obj):
621+
return f"{obj.total_allocated:.2f}"
622+
623+
@admin.display(description="Approved Reimbursements")
624+
def approved_amounts_display(self, obj):
625+
return ", ".join(
626+
f"{r.category.name}: {r.granted_amount}" for r in obj.reimbursements.all()
627+
)
594628

595629
def get_queryset(self, request):
596630
qs = (
597631
super()
598632
.get_queryset(request)
633+
.select_related("user")
634+
.prefetch_related("reimbursements__category")
599635
.annotate(
600636
is_proposed_speaker=Exists(
601637
Submission.objects.non_cancelled().filter(
@@ -622,6 +658,11 @@ def get_queryset(self, request):
622658
requester_id=OuterRef("user_id"),
623659
)
624660
),
661+
total_allocated=Coalesce(
662+
Sum("reimbursements__granted_amount"),
663+
Value(0),
664+
output_field=IntegerField(),
665+
),
625666
)
626667
)
627668

0 commit comments

Comments
 (0)