diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 6615e8bd06..8274ce3e46 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -4,6 +4,10 @@ create_addition_admin_log_entry, create_change_admin_log_entry, ) +from django.db.models import Value, IntegerField + +from django.db.models import Sum +from django.db.models.functions import Coalesce from conferences.models.conference_voucher import ConferenceVoucher from pycon.constants import UTC from custom_admin.admin import ( @@ -30,7 +34,12 @@ ) from schedule.models import ScheduleItem from submissions.models import Submission -from .models import Grant, GrantConfirmPendingStatusProxy +from .models import ( + Grant, + GrantConfirmPendingStatusProxy, + GrantReimbursementCategory, + GrantReimbursement, +) from django.db.models import Exists, OuterRef, F from pretix import user_has_admission_ticket @@ -393,12 +402,38 @@ def queryset(self, request, queryset): return queryset +@admin.register(GrantReimbursementCategory) +class GrantReimbursementCategoryAdmin(ConferencePermissionMixin, admin.ModelAdmin): + list_display = ("__str__", "max_amount", "category", "included_by_default") + list_filter = ("conference", "category", "included_by_default") + search_fields = ("category", "name") + + +@admin.register(GrantReimbursement) +class GrantReimbursementAdmin(ConferencePermissionMixin, admin.ModelAdmin): + list_display = ( + "grant", + "category", + "granted_amount", + ) + list_filter = ("grant__conference", "category") + search_fields = ("grant__full_name", "grant__email") + autocomplete_fields = ("grant",) + + +class GrantReimbursementInline(admin.TabularInline): + model = GrantReimbursement + extra = 0 + autocomplete_fields = ["category"] + fields = ["category", "granted_amount"] + + @admin.register(Grant) class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): change_list_template = "admin/grants/grant/change_list.html" resource_class = GrantResource list_display = ( - "user_display_name", + "user", "country", "is_proposed_speaker", "is_confirmed_speaker", @@ -406,10 +441,12 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "conference", "status", "approved_type", + "approved_amounts_display", "ticket_amount", "travel_amount", "accommodation_amount", "total_amount", + "total_amount_display", "country_type", "user_has_ticket", "has_voucher", @@ -449,6 +486,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "delete_selected", ] autocomplete_fields = ("user",) + inlines = [GrantReimbursementInline] fieldsets = ( ( @@ -584,10 +622,22 @@ def user_has_ticket(self, obj: Grant) -> bool: def has_voucher(self, obj: Grant) -> bool: return obj.has_voucher + @admin.display(description="Total") + def total_amount_display(self, obj): + return f"{obj.total_allocated:.2f}" + + @admin.display(description="Approved Reimbursements") + def approved_amounts_display(self, obj): + return ", ".join( + f"{r.category.name}: {r.granted_amount}" for r in obj.reimbursements.all() + ) + def get_queryset(self, request): qs = ( super() .get_queryset(request) + .select_related("user") + .prefetch_related("reimbursements__category") .annotate( is_proposed_speaker=Exists( Submission.objects.non_cancelled().filter( @@ -608,6 +658,11 @@ def get_queryset(self, request): user_id=OuterRef("user_id"), ) ), + total_allocated=Coalesce( + Sum("reimbursements__granted_amount"), + Value(0), + output_field=IntegerField(), + ), ) ) diff --git a/backend/grants/management/commands/__init__.py b/backend/grants/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/grants/management/commands/backfill_grant_reimbursements.py b/backend/grants/management/commands/backfill_grant_reimbursements.py new file mode 100644 index 0000000000..94bb4baf6a --- /dev/null +++ b/backend/grants/management/commands/backfill_grant_reimbursements.py @@ -0,0 +1,127 @@ +from decimal import Decimal + +from django.core.management.base import BaseCommand +from grants.models import Grant, GrantReimbursement, GrantReimbursementCategory +from conferences.models import Conference + + +class Command(BaseCommand): + help = "Backfill GrantReimbursement entries from approved_type data on approved grants." + + def handle(self, *args, **options): + self.stdout.write("🚀 Starting backfill of grant reimbursements...") + + self._ensure_categories_exist() + self._migrate_grants() + self._validate_migration() + + self.stdout.write(self.style.SUCCESS("✅ Backfill completed successfully.")) + + def _ensure_categories_exist(self): + for conference in Conference.objects.all(): + GrantReimbursementCategory.objects.get_or_create( + conference=conference, + category="ticket", + defaults={ + "name": "Ticket", + "description": "Conference ticket", + "max_amount": conference.grants_default_ticket_amount + or Decimal("0.00"), + "included_by_default": True, + }, + ) + GrantReimbursementCategory.objects.get_or_create( + conference=conference, + category="travel", + defaults={ + "name": "Travel", + "description": "Travel support", + "max_amount": conference.grants_default_travel_from_extra_eu_amount + or Decimal("400.00"), + "included_by_default": False, + }, + ) + GrantReimbursementCategory.objects.get_or_create( + conference=conference, + category="accommodation", + defaults={ + "name": "Accommodation", + "description": "Accommodation support", + "max_amount": conference.grants_default_accommodation_amount + or Decimal("300.00"), + "included_by_default": True, + }, + ) + + def _migrate_grants(self): + grants = Grant.objects.filter(approved_type__isnull=False).exclude( + approved_type="" + ) + + self.stdout.write(f"📦 Migrating {grants.count()} grants...") + + for grant in grants: + categories = { + c.category: c + for c in GrantReimbursementCategory.objects.filter( + conference=grant.conference + ) + } + + def add_reimbursement(category_key, amount): + if category_key in categories and amount: + GrantReimbursement.objects.get_or_create( + grant=grant, + category=categories[category_key], + defaults={ + "granted_amount": amount, + }, + ) + + add_reimbursement("ticket", grant.ticket_amount) + + if grant.approved_type in ("ticket_travel", "ticket_travel_accommodation"): + add_reimbursement("travel", grant.travel_amount) + + if grant.approved_type in ( + "ticket_accommodation", + "ticket_travel_accommodation", + ): + add_reimbursement("accommodation", grant.accommodation_amount) + + def _validate_migration(self): + errors = [] + grants = Grant.objects.filter(approved_type__isnull=False).exclude( + approved_type="" + ) + + for grant in grants: + original_total = sum( + filter( + None, + [ + grant.ticket_amount, + grant.travel_amount, + grant.accommodation_amount, + ], + ) + ) + reimbursements_total = sum( + r.granted_amount for r in GrantReimbursement.objects.filter(grant=grant) + ) + + if abs(original_total - reimbursements_total) > Decimal("0.01"): + errors.append( + f"Grant ID {grant.id} total mismatch: expected {original_total}, got {reimbursements_total}" + ) + + if errors: + self.stdout.write( + self.style.ERROR(f"⚠️ Found {len(errors)} grants with mismatched totals") + ) + for msg in errors: + self.stdout.write(self.style.WARNING(f" {msg}")) + else: + self.stdout.write( + self.style.SUCCESS("🧮 All grant totals match correctly.") + ) diff --git a/backend/grants/migrations/0029_grantreimbursementcategory.py b/backend/grants/migrations/0029_grantreimbursementcategory.py new file mode 100644 index 0000000000..f33e6d5604 --- /dev/null +++ b/backend/grants/migrations/0029_grantreimbursementcategory.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.4 on 2025-06-04 15:20 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conferences', '0054_conference_frontend_revalidate_secret_and_more'), + ('grants', '0028_remove_grant_pretix_voucher_id_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='GrantReimbursementCategory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True, null=True)), + ('max_amount', models.DecimalField(decimal_places=0, help_text='Maximum amount for this category', max_digits=6)), + ('category', models.CharField(choices=[('travel', 'Travel'), ('ticket', 'Ticket'), ('accommodation', 'Accommodation'), ('other', 'Other')], max_length=20)), + ('included_by_default', models.BooleanField(default=False, help_text='Automatically include this category in grants by default')), + ('conference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reimbursement_categories', to='conferences.conference')), + ], + options={ + 'verbose_name': 'Grant Reimbursement Category', + 'verbose_name_plural': 'Grant Reimbursement Categories', + 'ordering': ['conference', 'category'], + 'unique_together': {('conference', 'category')}, + }, + ), + ] diff --git a/backend/grants/migrations/0030_grantreimbursement_grant_reimbursement_categories.py b/backend/grants/migrations/0030_grantreimbursement_grant_reimbursement_categories.py new file mode 100644 index 0000000000..d86c6708c1 --- /dev/null +++ b/backend/grants/migrations/0030_grantreimbursement_grant_reimbursement_categories.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.4 on 2025-06-04 16:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('grants', '0029_grantreimbursementcategory'), + ] + + operations = [ + migrations.CreateModel( + name='GrantReimbursement', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('granted_amount', models.DecimalField(decimal_places=0, help_text='Actual amount granted for this category', max_digits=6, verbose_name='granted amount')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='grants.grantreimbursementcategory', verbose_name='reimbursement category')), + ('grant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reimbursements', to='grants.grant', verbose_name='grant')), + ], + options={ + 'verbose_name': 'Grant Reimbursement', + 'verbose_name_plural': 'Grant Reimbursements', + 'ordering': ['grant', 'category'], + 'unique_together': {('grant', 'category')}, + }, + ), + migrations.AddField( + model_name='grant', + name='reimbursement_categories', + field=models.ManyToManyField(related_name='grants', through='grants.GrantReimbursement', to='grants.grantreimbursementcategory'), + ), + ] diff --git a/backend/grants/models.py b/backend/grants/models.py index a7bb447af8..06b3478033 100644 --- a/backend/grants/models.py +++ b/backend/grants/models.py @@ -14,6 +14,43 @@ def of_user(self, user): return self.filter(user=user) +class GrantReimbursementCategory(models.Model): + """ + Define types of reimbursements available for a grant (e.g., Travel, Ticket, Accommodation). + """ + + class Category(models.TextChoices): + TRAVEL = "travel", _("Travel") + TICKET = "ticket", _("Ticket") + ACCOMMODATION = "accommodation", _("Accommodation") + OTHER = "other", _("Other") + + conference = models.ForeignKey( + "conferences.Conference", + on_delete=models.CASCADE, + related_name="reimbursement_categories", + ) + name = models.CharField(max_length=100) + description = models.TextField(blank=True, null=True) + max_amount = models.DecimalField( + max_digits=6, decimal_places=0, help_text=_("Maximum amount for this category") + ) + category = models.CharField(max_length=20, choices=Category.choices) + included_by_default = models.BooleanField( + default=False, + help_text="Automatically include this category in grants by default", + ) + + def __str__(self): + return f"{self.name} ({self.conference.name})" + + class Meta: + verbose_name = _("Grant Reimbursement Category") + verbose_name_plural = _("Grant Reimbursement Categories") + unique_together = [("conference", "category")] + ordering = ["conference", "category"] + + class Grant(TimeStampedModel): # TextChoices class Status(models.TextChoices): @@ -213,6 +250,10 @@ class ApprovedType(models.TextChoices): blank=True, ) + reimbursement_categories = models.ManyToManyField( + GrantReimbursementCategory, through="GrantReimbursement", related_name="grants" + ) + objects = GrantQuerySet().as_manager() def __init__(self, *args, **kwargs): @@ -332,6 +373,44 @@ def has_approved_accommodation(self): or self.approved_type == Grant.ApprovedType.ticket_travel_accommodation ) + @property + def total_allocated_amount(self): + return sum(r.allocated_amount for r in self.reimbursements.all()) + + def has_approved(self, type_): + return self.reimbursements.filter(category__category=type_).exists() + + +class GrantReimbursement(models.Model): + """Links a Grant to its reimbursement categories and stores the actual amount granted.""" + + grant = models.ForeignKey( + Grant, + on_delete=models.CASCADE, + related_name="reimbursements", + verbose_name=_("grant"), + ) + category = models.ForeignKey( + GrantReimbursementCategory, + on_delete=models.CASCADE, + verbose_name=_("reimbursement category"), + ) + granted_amount = models.DecimalField( + _("granted amount"), + max_digits=6, + decimal_places=0, + help_text=_("Actual amount granted for this category"), + ) + + def __str__(self): + return f"{self.grant.full_name} - {self.category.name} - {self.granted_amount}" + + class Meta: + verbose_name = _("Grant Reimbursement") + verbose_name_plural = _("Grant Reimbursements") + unique_together = [("grant", "category")] + ordering = ["grant", "category"] + class GrantConfirmPendingStatusProxy(Grant): class Meta: diff --git a/backend/grants/tests/factories.py b/backend/grants/tests/factories.py index bd716665f2..6284ae43ba 100644 --- a/backend/grants/tests/factories.py +++ b/backend/grants/tests/factories.py @@ -2,7 +2,7 @@ from factory.django import DjangoModelFactory from conferences.tests.factories import ConferenceFactory -from grants.models import Grant +from grants.models import Grant, GrantReimbursementCategory, GrantReimbursement from helpers.constants import GENDERS from users.tests.factories import UserFactory from countries import countries @@ -11,6 +11,24 @@ import random +class GrantReimbursementCategoryFactory(DjangoModelFactory): + """ + Factory for creating GrantReimbursementCategory instances for testing. + """ + + class Meta: + model = GrantReimbursementCategory + + conference = factory.SubFactory(ConferenceFactory) + name = factory.Faker("word") + description = factory.Faker("sentence") + max_amount = factory.fuzzy.FuzzyDecimal(50, 500, precision=2) + category = factory.fuzzy.FuzzyChoice( + GrantReimbursementCategory.Category.choices, getter=lambda x: x[0] + ) + included_by_default = factory.Faker("boolean") + + class GrantFactory(DjangoModelFactory): class Meta: model = Grant @@ -57,3 +75,12 @@ def _create(self, model_class, *args, **kwargs): ParticipantFactory(user_id=grant.user.id, conference=grant.conference) return grant + + +class GrantReimbursementFactory(DjangoModelFactory): + class Meta: + model = GrantReimbursement + + grant = factory.SubFactory(GrantFactory) + category = factory.SubFactory(GrantReimbursementCategoryFactory) + granted_amount = factory.LazyAttribute(lambda obj: obj.category.max_amount) diff --git a/backend/grants/tests/test_backfill_grant_reimbursements.py b/backend/grants/tests/test_backfill_grant_reimbursements.py new file mode 100644 index 0000000000..4169d87e7f --- /dev/null +++ b/backend/grants/tests/test_backfill_grant_reimbursements.py @@ -0,0 +1,77 @@ +import pytest +from decimal import Decimal +from django.core.management import call_command +from grants.models import GrantReimbursement +from grants.tests.factories import GrantFactory + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "approved_type,expected_categories", + [ + ("ticket_only", ["ticket"]), + ("ticket_travel", ["ticket", "travel"]), + ("ticket_accommodation", ["ticket", "accommodation"]), + ("ticket_travel_accommodation", ["ticket", "travel", "accommodation"]), + ], +) +def test_backfill_grant_reimbursements_all_types(approved_type, expected_categories): + ticket_amount = Decimal("100.00") + travel_amount = Decimal("150.00") + accommodation_amount = Decimal("200.00") + + grant = GrantFactory( + approved_type=approved_type, + status="confirmed", + ticket_amount=ticket_amount, + travel_amount=travel_amount, + accommodation_amount=accommodation_amount, + ) + + call_command("backfill_grant_reimbursements") + + reimbursements = GrantReimbursement.objects.filter(grant=grant) + assert reimbursements.count() == len(expected_categories) + + for category in expected_categories: + r = reimbursements.get(category__category=category) + if category == "ticket": + assert r.granted_amount == ticket_amount + elif category == "travel": + assert r.granted_amount == travel_amount + elif category == "accommodation": + assert r.granted_amount == accommodation_amount + + +def test_grant_reimbursement_does_not_duplicate_on_rerun(): + grant = GrantFactory( + approved_type="ticket_travel_accommodation", + status="approved", + ticket_amount=Decimal("100.00"), + travel_amount=Decimal("100.00"), + accommodation_amount=Decimal("100.00"), + ) + + call_command("backfill_grant_reimbursements") + initial_count = GrantReimbursement.objects.filter(grant=grant).count() + assert initial_count == 3 + + call_command("backfill_grant_reimbursements") + second_count = GrantReimbursement.objects.filter(grant=grant).count() + assert second_count == 3 # no duplicates + + +def test_total_amount_consistency(): + grant = GrantFactory( + approved_type="ticket_travel", + status="approved", + ticket_amount=Decimal("120.00"), + travel_amount=Decimal("80.00"), + accommodation_amount=Decimal("0.00"), + ) + + call_command("backfill_grant_reimbursements") + reimbursements = GrantReimbursement.objects.filter(grant=grant) + total = sum(r.granted_amount for r in reimbursements) + expected = grant.ticket_amount + grant.travel_amount + assert total == expected