Skip to content

Commit 23ceeb7

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 19f667c commit 23ceeb7

13 files changed

+1217
-358
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: 82 additions & 36 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, 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"
@@ -406,12 +440,8 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
406440
"has_sent_invitation_letter_request",
407441
"emoji_gender",
408442
"conference",
409-
"status",
410-
"approved_type",
411-
"ticket_amount",
412-
"travel_amount",
413-
"accommodation_amount",
414-
"total_amount",
443+
"current_or_pending_status",
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",
@@ -528,6 +553,10 @@ def user_display_name(self, obj):
528553
return obj.user.display_name
529554
return obj.email
530555

556+
@admin.display(description="Status")
557+
def current_or_pending_status(self, obj):
558+
return obj.current_or_pending_status
559+
531560
@admin.display(
532561
description="C",
533562
)
@@ -592,10 +621,22 @@ def has_sent_invitation_letter_request(self, obj: Grant) -> bool:
592621
return "📧"
593622
return ""
594623

624+
@admin.display(description="Total")
625+
def total_amount_display(self, obj):
626+
return f"{obj.total_allocated_amount:.2f}"
627+
628+
@admin.display(description="Approved Reimbursements")
629+
def approved_amounts_display(self, obj):
630+
return ", ".join(
631+
f"{r.category.name}: {r.granted_amount}" for r in obj.reimbursements.all()
632+
)
633+
595634
def get_queryset(self, request):
596635
qs = (
597636
super()
598637
.get_queryset(request)
638+
.select_related("user")
639+
.prefetch_related("reimbursements__category")
599640
.annotate(
600641
is_proposed_speaker=Exists(
601642
Submission.objects.non_cancelled().filter(
@@ -622,6 +663,11 @@ def get_queryset(self, request):
622663
requester_id=OuterRef("user_id"),
623664
)
624665
),
666+
total_allocated_amount=Coalesce(
667+
Sum("reimbursements__granted_amount"),
668+
Value(0),
669+
output_field=IntegerField(),
670+
),
625671
)
626672
)
627673

0 commit comments

Comments
 (0)