Skip to content

Commit 55de16d

Browse files
authored
Flexible Grant categories (#4420)
1 parent 7c8529c commit 55de16d

21 files changed

+1347
-572
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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
('grants', '0030_remove_grant_accommodation_amount_and_more'),
11+
]
12+
13+
operations = [
14+
migrations.RemoveField(
15+
model_name='conference',
16+
name='grants_default_accommodation_amount',
17+
),
18+
migrations.RemoveField(
19+
model_name='conference',
20+
name='grants_default_ticket_amount',
21+
),
22+
migrations.RemoveField(
23+
model_name='conference',
24+
name='grants_default_travel_from_europe_amount',
25+
),
26+
migrations.RemoveField(
27+
model_name='conference',
28+
name='grants_default_travel_from_extra_eu_amount',
29+
),
30+
migrations.RemoveField(
31+
model_name='conference',
32+
name='grants_default_travel_from_italy_amount',
33+
),
34+
]

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: 79 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,52 @@
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, OuterRef
199
from django.db.models.query import QuerySet
10+
from django.urls import reverse
2011
from django.utils import timezone
12+
from django.utils.safestring import mark_safe
2113
from import_export.admin import ExportMixin
2214
from import_export.fields import Field
23-
from users.admin_mixins import ConferencePermissionMixin
15+
from import_export.resources import ModelResource
16+
17+
from conferences.models.conference_voucher import ConferenceVoucher
2418
from countries import countries
19+
from countries.filters import CountryFilter
20+
from custom_admin.admin import (
21+
confirm_pending_status,
22+
reset_pending_status_back_to_status,
23+
validate_single_conference_selection,
24+
)
25+
from custom_admin.audit import (
26+
create_addition_admin_log_entry,
27+
create_change_admin_log_entry,
28+
)
2529
from grants.tasks import (
2630
send_grant_reply_approved_email,
31+
send_grant_reply_rejected_email,
2732
send_grant_reply_waiting_list_email,
2833
send_grant_reply_waiting_list_update_email,
29-
send_grant_reply_rejected_email,
3034
)
35+
from participants.models import Participant
36+
from pretix import user_has_admission_ticket
37+
from pycon.constants import UTC
3138
from schedule.models import ScheduleItem
3239
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
40+
from users.admin_mixins import ConferencePermissionMixin
4141
from visa.models import InvitationLetterRequest
4242

43+
from .models import (
44+
Grant,
45+
GrantConfirmPendingStatusProxy,
46+
GrantReimbursement,
47+
GrantReimbursementCategory,
48+
)
49+
4350
logger = logging.getLogger(__name__)
4451

4552
EXPORT_GRANTS_FIELDS = (
@@ -164,27 +171,13 @@ class Meta:
164171

165172

166173
def _check_amounts_are_not_empty(grant: Grant, request):
167-
if grant.total_amount is None:
174+
if grant.total_allocated_amount == 0:
168175
messages.error(
169176
request,
170177
f"Grant for {grant.name} is missing 'Total Amount'!",
171178
)
172179
return False
173180

174-
if grant.has_approved_accommodation() and grant.accommodation_amount is None:
175-
messages.error(
176-
request,
177-
f"Grant for {grant.name} is missing 'Accommodation Amount'!",
178-
)
179-
return False
180-
181-
if grant.has_approved_travel() and grant.travel_amount is None:
182-
messages.error(
183-
request,
184-
f"Grant for {grant.name} is missing 'Travel Amount'!",
185-
)
186-
return False
187-
188181
return True
189182

190183

@@ -208,10 +201,10 @@ def send_reply_emails(modeladmin, request, queryset):
208201

209202
for grant in queryset:
210203
if grant.status in (Grant.Status.approved,):
211-
if grant.approved_type is None:
204+
if not grant.reimbursements.exists():
212205
messages.error(
213206
request,
214-
f"Grant for {grant.name} is missing 'Grant Approved Type'!",
207+
f"Grant for {grant.name} is missing reimbursement categories!",
215208
)
216209
return
217210

@@ -394,6 +387,32 @@ def queryset(self, request, queryset):
394387
return queryset
395388

396389

390+
@admin.register(GrantReimbursementCategory)
391+
class GrantReimbursementCategoryAdmin(ConferencePermissionMixin, admin.ModelAdmin):
392+
list_display = ("__str__", "max_amount", "category", "included_by_default")
393+
list_filter = ("conference", "category", "included_by_default")
394+
search_fields = ("category", "name")
395+
396+
397+
@admin.register(GrantReimbursement)
398+
class GrantReimbursementAdmin(ConferencePermissionMixin, admin.ModelAdmin):
399+
list_display = (
400+
"grant",
401+
"category",
402+
"granted_amount",
403+
)
404+
list_filter = ("grant__conference", "category")
405+
search_fields = ("grant__full_name", "grant__email")
406+
autocomplete_fields = ("grant",)
407+
408+
409+
class GrantReimbursementInline(admin.TabularInline):
410+
model = GrantReimbursement
411+
extra = 0
412+
autocomplete_fields = ["category"]
413+
fields = ["category", "granted_amount"]
414+
415+
397416
@admin.register(Grant)
398417
class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
399418
change_list_template = "admin/grants/grant/change_list.html"
@@ -406,12 +425,8 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
406425
"has_sent_invitation_letter_request",
407426
"emoji_gender",
408427
"conference",
409-
"status",
410-
"approved_type",
411-
"ticket_amount",
412-
"travel_amount",
413-
"accommodation_amount",
414-
"total_amount",
428+
"current_or_pending_status",
429+
"total_amount_display",
415430
"country_type",
416431
"user_has_ticket",
417432
"has_voucher",
@@ -425,7 +440,6 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
425440
"pending_status",
426441
"country_type",
427442
"occupation",
428-
"approved_type",
429443
"needs_funds_for_travel",
430444
"need_visa",
431445
"need_accommodation",
@@ -451,6 +465,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
451465
"delete_selected",
452466
]
453467
autocomplete_fields = ("user",)
468+
inlines = [GrantReimbursementInline]
454469

455470
fieldsets = (
456471
(
@@ -459,12 +474,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
459474
"fields": (
460475
"status",
461476
"pending_status",
462-
"approved_type",
463477
"country_type",
464-
"ticket_amount",
465-
"travel_amount",
466-
"accommodation_amount",
467-
"total_amount",
468478
"applicant_reply_sent_at",
469479
"applicant_reply_deadline",
470480
"internal_notes",
@@ -528,6 +538,10 @@ def user_display_name(self, obj):
528538
return obj.user.display_name
529539
return obj.email
530540

541+
@admin.display(description="Status")
542+
def current_or_pending_status(self, obj):
543+
return obj.current_or_pending_status
544+
531545
@admin.display(
532546
description="C",
533547
)
@@ -592,10 +606,22 @@ def has_sent_invitation_letter_request(self, obj: Grant) -> bool:
592606
return "📧"
593607
return ""
594608

609+
@admin.display(description="Total")
610+
def total_amount_display(self, obj):
611+
return f"{obj.total_allocated_amount:.2f}"
612+
613+
@admin.display(description="Approved Reimbursements")
614+
def approved_amounts_display(self, obj):
615+
return ", ".join(
616+
f"{r.category.name}: {r.granted_amount}" for r in obj.reimbursements.all()
617+
)
618+
595619
def get_queryset(self, request):
596620
qs = (
597621
super()
598622
.get_queryset(request)
623+
.select_related("user")
624+
.prefetch_related("reimbursements__category")
599625
.annotate(
600626
is_proposed_speaker=Exists(
601627
Submission.objects.non_cancelled().filter(

0 commit comments

Comments
 (0)