From 2158bf3d2944ec11ea80c0aed343b898a9db5ada Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 30 Aug 2025 16:18:44 +0300 Subject: [PATCH] WIP --- backend/conferences/admin/conference.py | 12 - ...s_default_accommodation_amount_and_more.py | 33 ++ backend/conferences/models/conference.py | 41 --- backend/grants/admin.py | 67 +++- ...ove_grant_accommodation_amount_and_more.py | 243 ++++++++++++++ .../0031_backfill_grant_reimbursements.py | 0 backend/grants/models.py | 196 +++++------- backend/grants/summary.py | 48 +-- ...migration_backfill_grant_reimbursements.py | 300 ++++++++++++++++++ 9 files changed, 745 insertions(+), 195 deletions(-) create mode 100644 backend/conferences/migrations/0055_remove_conference_grants_default_accommodation_amount_and_more.py create mode 100644 backend/grants/migrations/0029_remove_grant_accommodation_amount_and_more.py create mode 100644 backend/grants/migrations/0031_backfill_grant_reimbursements.py create mode 100644 backend/grants/tests/test_migration_backfill_grant_reimbursements.py diff --git a/backend/conferences/admin/conference.py b/backend/conferences/admin/conference.py index 3244a302d7..a833b8af9a 100644 --- a/backend/conferences/admin/conference.py +++ b/backend/conferences/admin/conference.py @@ -184,18 +184,6 @@ class ConferenceAdmin( ) }, ), - ( - "Grants", - { - "fields": ( - "grants_default_ticket_amount", - "grants_default_accommodation_amount", - "grants_default_travel_from_italy_amount", - "grants_default_travel_from_europe_amount", - "grants_default_travel_from_extra_eu_amount", - ) - }, - ), ("YouTube", {"fields": ("video_title_template", "video_description_template")}), ) inlines = [DeadlineInline, DurationInline, SponsorLevelInline, IncludedEventInline] diff --git a/backend/conferences/migrations/0055_remove_conference_grants_default_accommodation_amount_and_more.py b/backend/conferences/migrations/0055_remove_conference_grants_default_accommodation_amount_and_more.py new file mode 100644 index 0000000000..1d0465bb0f --- /dev/null +++ b/backend/conferences/migrations/0055_remove_conference_grants_default_accommodation_amount_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.4 on 2025-07-27 14:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('conferences', '0054_conference_frontend_revalidate_secret_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='conference', + name='grants_default_accommodation_amount', + ), + migrations.RemoveField( + model_name='conference', + name='grants_default_ticket_amount', + ), + migrations.RemoveField( + model_name='conference', + name='grants_default_travel_from_europe_amount', + ), + migrations.RemoveField( + model_name='conference', + name='grants_default_travel_from_extra_eu_amount', + ), + migrations.RemoveField( + model_name='conference', + name='grants_default_travel_from_italy_amount', + ), + ] diff --git a/backend/conferences/models/conference.py b/backend/conferences/models/conference.py index e04db0665a..1388becd07 100644 --- a/backend/conferences/models/conference.py +++ b/backend/conferences/models/conference.py @@ -93,47 +93,6 @@ class Conference(GeoLocalizedModel, TimeFramedModel, TimeStampedModel): default="", ) - grants_default_ticket_amount = models.DecimalField( - verbose_name=_("grants default ticket amount"), - null=True, - blank=True, - max_digits=6, - decimal_places=2, - default=None, - ) - grants_default_accommodation_amount = models.DecimalField( - verbose_name=_("grants default accommodation amount"), - null=True, - blank=True, - max_digits=6, - decimal_places=2, - default=None, - ) - grants_default_travel_from_italy_amount = models.DecimalField( - verbose_name=_("grants default travel from Italy amount"), - null=True, - blank=True, - max_digits=6, - decimal_places=2, - default=None, - ) - grants_default_travel_from_europe_amount = models.DecimalField( - verbose_name=_("grants default travel from Europe amount"), - null=True, - blank=True, - max_digits=6, - decimal_places=2, - default=None, - ) - grants_default_travel_from_extra_eu_amount = models.DecimalField( - verbose_name=_("grants default travel from Extra EU amount"), - null=True, - blank=True, - max_digits=6, - decimal_places=2, - default=None, - ) - video_title_template = models.TextField( default="", blank=True, diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 6615e8bd06..09b0ac4509 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,6 +402,32 @@ 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" @@ -405,11 +440,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "emoji_gender", "conference", "status", - "approved_type", - "ticket_amount", - "travel_amount", - "accommodation_amount", - "total_amount", + "total_amount_display", "country_type", "user_has_ticket", "has_voucher", @@ -423,7 +454,6 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "pending_status", "country_type", "occupation", - "approved_type", "needs_funds_for_travel", "need_visa", "need_accommodation", @@ -449,6 +479,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "delete_selected", ] autocomplete_fields = ("user",) + inlines = [GrantReimbursementInline] fieldsets = ( ( @@ -457,12 +488,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "fields": ( "status", "pending_status", - "approved_type", "country_type", - "ticket_amount", - "travel_amount", - "accommodation_amount", - "total_amount", "applicant_reply_sent_at", "applicant_reply_deadline", "internal_notes", @@ -584,10 +610,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 +646,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/migrations/0029_remove_grant_accommodation_amount_and_more.py b/backend/grants/migrations/0029_remove_grant_accommodation_amount_and_more.py new file mode 100644 index 0000000000..c0dabb4f0b --- /dev/null +++ b/backend/grants/migrations/0029_remove_grant_accommodation_amount_and_more.py @@ -0,0 +1,243 @@ +# Generated by Django 5.1.4 on 2025-07-27 13:45 + +import django.db.models.deletion +from django.db import migrations, models + +import django.db.models.deletion +from decimal import Decimal +from django.db import migrations, models + + +def ensure_categories_exist(apps, schema_editor): + """Ensure reimbursement categories exist for all conferences.""" + Conference = apps.get_model("conferences", "Conference") + GrantReimbursementCategory = apps.get_model("grants", "GrantReimbursementCategory") + + 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(apps, schema_editor): + """Migrate existing grants to use the new reimbursement system.""" + Grant = apps.get_model("grants", "Grant") + GrantReimbursement = apps.get_model("grants", "GrantReimbursement") + GrantReimbursementCategory = apps.get_model("grants", "GrantReimbursementCategory") + + grants = Grant.objects.filter(approved_type__isnull=False).exclude(approved_type="") + + for grant in grants: + categories = { + c.category: c + for c in GrantReimbursementCategory.objects.filter( + conference_id=grant.conference_id + ) + } + + 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}, + ) + + # Always add ticket reimbursement + add_reimbursement("ticket", grant.ticket_amount) + + # Add travel reimbursement if approved + if grant.approved_type in ("ticket_travel", "ticket_travel_accommodation"): + add_reimbursement("travel", grant.travel_amount) + + # Add accommodation reimbursement if approved + if grant.approved_type in ( + "ticket_accommodation", + "ticket_travel_accommodation", + ): + add_reimbursement("accommodation", grant.accommodation_amount) + + +def reverse_migration(apps, schema_editor): + """Reverse the migration by deleting all reimbursements.""" + GrantReimbursement = apps.get_model("grants", "GrantReimbursement") + GrantReimbursement.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("conferences", "0054_conference_frontend_revalidate_secret_and_more"), + ("grants", "0028_remove_grant_pretix_voucher_id_and_more"), + ] + + operations = [ + # Create reimbursement tables + 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")}, + }, + ), + 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", + ), + ), + ( + "grant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reimbursements", + to="grants.grant", + verbose_name="grant", + ), + ), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="grants.grantreimbursementcategory", + verbose_name="reimbursement category", + ), + ), + ], + 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", + ), + ), + # Backfill existing grants + migrations.RunPython( + code=migrate_grants, + reverse_code=reverse_migration, + ), + # Finally, remove old fields + migrations.RemoveField( + model_name="grant", + name="accommodation_amount", + ), + migrations.RemoveField( + model_name="grant", + name="approved_type", + ), + migrations.RemoveField( + model_name="grant", + name="ticket_amount", + ), + migrations.RemoveField( + model_name="grant", + name="total_amount", + ), + migrations.RemoveField( + model_name="grant", + name="travel_amount", + ), + ] diff --git a/backend/grants/migrations/0031_backfill_grant_reimbursements.py b/backend/grants/migrations/0031_backfill_grant_reimbursements.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/grants/models.py b/backend/grants/models.py index a7bb447af8..019a829014 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): @@ -63,15 +100,6 @@ class GrantType(models.TextChoices): unemployed = "unemployed", _("Unemployed") speaker = "speaker", _("Speaker") - class ApprovedType(models.TextChoices): - ticket_only = "ticket_only", _("Ticket Only") - ticket_travel = "ticket_travel", _("Ticket + Travel") - ticket_accommodation = "ticket_accommodation", _("Ticket + Accommodation") - ticket_travel_accommodation = ( - "ticket_travel_accommodation", - _("Ticket + Travel + Accommodation"), - ) - conference = models.ForeignKey( "conferences.Conference", on_delete=models.CASCADE, @@ -152,43 +180,6 @@ class ApprovedType(models.TextChoices): default=Status.pending, blank=True, ) - approved_type = models.CharField( - verbose_name=_("approved type"), - choices=ApprovedType.choices, - max_length=30, - blank=True, - null=True, - ) - - # Financial amounts - ticket_amount = models.DecimalField( - verbose_name=_("ticket amount"), - null=True, - max_digits=6, - decimal_places=2, - default=0, - ) - accommodation_amount = models.DecimalField( - verbose_name=_("accommodation amount"), - null=True, - max_digits=6, - decimal_places=2, - default=0, - ) - travel_amount = models.DecimalField( - verbose_name=_("travel amount"), - null=True, - max_digits=6, - decimal_places=2, - default=0, - ) - total_amount = models.DecimalField( - verbose_name=_("total amount"), - null=True, - max_digits=6, - decimal_places=2, - default=0, - ) country_type = models.CharField( _("Country type"), @@ -213,13 +204,16 @@ 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): super().__init__(*args, **kwargs) self._original_status = self.status self._original_pending_status = self.pending_status - self._original_approved_type = self.approved_type self._original_country_type = self.country_type def __str__(self): @@ -227,77 +221,21 @@ def __str__(self): def save(self, *args, **kwargs): self._update_country_type() - self._calculate_grant_amounts() if self.pending_status == self._original_status: self.pending_status = self.status update_fields = kwargs.get("update_fields", None) if update_fields: - update_fields.append("total_amount") - update_fields.append("ticket_amount") - update_fields.append("accommodation_amount") - update_fields.append("travel_amount") update_fields.append("country_type") update_fields.append("pending_status") super().save(*args, **kwargs) - self._original_approved_type = self.approved_type self._original_country_type = self.country_type self._original_pending_status = self.pending_status self._original_status = self.status - def _calculate_grant_amounts(self): - if self.pending_status != Grant.Status.approved: - return - - if ( - self._original_pending_status == self.pending_status - and self._original_approved_type == self.approved_type - and self._original_country_type == self.country_type - ): - return - - conference = self.conference - self.ticket_amount = conference.grants_default_ticket_amount or 0 - self.accommodation_amount = 0 - self.travel_amount = 0 - - default_accommodation_amount = ( - conference.grants_default_accommodation_amount or 0 - ) - default_travel_from_italy_amount = ( - conference.grants_default_travel_from_italy_amount or 0 - ) - default_travel_from_europe_amount = ( - conference.grants_default_travel_from_europe_amount or 0 - ) - default_travel_from_extra_eu_amount = ( - conference.grants_default_travel_from_extra_eu_amount or 0 - ) - - if self.approved_type in ( - Grant.ApprovedType.ticket_accommodation, - Grant.ApprovedType.ticket_travel_accommodation, - ): - self.accommodation_amount = default_accommodation_amount - - if self.approved_type in ( - Grant.ApprovedType.ticket_travel_accommodation, - Grant.ApprovedType.ticket_travel, - ): - if self.country_type == Grant.CountryType.italy: - self.travel_amount = default_travel_from_italy_amount - elif self.country_type == Grant.CountryType.europe: - self.travel_amount = default_travel_from_europe_amount - elif self.country_type == Grant.CountryType.extra_eu: - self.travel_amount = default_travel_from_extra_eu_amount - - self.total_amount = ( - self.ticket_amount + self.accommodation_amount + self.travel_amount - ) - def _update_country_type(self): if not self.departure_country: return @@ -321,16 +259,52 @@ def get_admin_url(self): ) def has_approved_travel(self): - return ( - self.approved_type == Grant.ApprovedType.ticket_travel_accommodation - or self.approved_type == Grant.ApprovedType.ticket_travel - ) + return self.reimbursements.filter( + category__category=GrantReimbursementCategory.Category.TRAVEL + ).exists() def has_approved_accommodation(self): - return ( - self.approved_type == Grant.ApprovedType.ticket_accommodation - or self.approved_type == Grant.ApprovedType.ticket_travel_accommodation - ) + return self.reimbursements.filter( + category__category=GrantReimbursementCategory.Category.ACCOMMODATION + ).exists() + + @property + def total_allocated_amount(self): + return sum(r.granted_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): diff --git a/backend/grants/summary.py b/backend/grants/summary.py index b78a1ec0ea..da1c861a59 100644 --- a/backend/grants/summary.py +++ b/backend/grants/summary.py @@ -42,7 +42,7 @@ def calculate(self, conference_id): filtered_grants, statuses ) gender_stats = self._aggregate_data_by_gender(filtered_grants, statuses) - financial_summary, total_amount = self._aggregate_financial_data_by_status( + financial_summary, total_amount = self._aggregate_financial_data_by_status_new( filtered_grants, statuses ) grant_type_summary = self._aggregate_data_by_grant_type( @@ -51,16 +51,9 @@ def calculate(self, conference_id): speaker_status_summary = self._aggregate_data_by_speaker_status( filtered_grants, statuses ) - approved_type_summary = self._aggregate_data_by_approved_type( - filtered_grants, statuses - ) requested_needs_summary = self._aggregate_data_by_requested_needs_summary( filtered_grants, statuses ) - approved_types = { - approved_type.value: approved_type.label - for approved_type in Grant.ApprovedType - } country_types = { country_type.value: country_type.label for country_type in Grant.CountryType } @@ -68,6 +61,10 @@ def calculate(self, conference_id): filtered_grants, statuses ) + reimbursement_category_summary = self._aggregate_data_by_reimbursement_category( + filtered_grants, statuses + ) + return dict( conference_id=conference_id, conference_repr=str(conference), @@ -83,8 +80,7 @@ def calculate(self, conference_id): preselected_statuses=["approved", "confirmed"], grant_type_summary=grant_type_summary, speaker_status_summary=speaker_status_summary, - approved_type_summary=approved_type_summary, - approved_types=approved_types, + reimbursement_category_summary=reimbursement_category_summary, requested_needs_summary=requested_needs_summary, country_type_summary=country_type_summary, country_types=country_types, @@ -160,21 +156,35 @@ def _aggregate_financial_data_by_status(self, filtered_grants, statuses): """ Aggregates financial data (total amounts) by grant status. """ - financial_data = filtered_grants.values("pending_status").annotate( - total_amount_sum=Sum("total_amount") - ) financial_summary = {status[0]: 0 for status in statuses} overall_total = 0 - for data in financial_data: - pending_status = data["pending_status"] - total_amount = data["total_amount_sum"] or 0 - financial_summary[pending_status] += total_amount - if pending_status in self.BUDGET_STATUSES: - overall_total += total_amount + for status in statuses: + grants_for_status = filtered_grants.filter(pending_status=status[0]) + reimbursements = GrantReimbursement.objects.filter( + grant__in=grants_for_status + ) + total = reimbursements.aggregate(total=Sum("granted_amount"))["total"] or 0 + financial_summary[status[0]] = total + if status[0] in self.BUDGET_STATUSES: + overall_total += total return financial_summary, overall_total + def _aggregate_data_by_reimbursement_category(self, filtered_grants, statuses): + """ + Aggregates grant data by reimbursement category and status. + """ + from grants.models import GrantReimbursement + + category_summary = defaultdict(lambda: {status[0]: 0 for status in statuses}) + reimbursements = GrantReimbursement.objects.filter(grant__in=filtered_grants) + for r in reimbursements: + category = r.category.category + status = r.grant.pending_status + category_summary[category][status] += 1 + return dict(category_summary) + def _aggregate_data_by_grant_type(self, filtered_grants, statuses): """ Aggregates grant data by grant_type and status. diff --git a/backend/grants/tests/test_migration_backfill_grant_reimbursements.py b/backend/grants/tests/test_migration_backfill_grant_reimbursements.py new file mode 100644 index 0000000000..097de1e4ca --- /dev/null +++ b/backend/grants/tests/test_migration_backfill_grant_reimbursements.py @@ -0,0 +1,300 @@ +import pytest +from decimal import Decimal +from grants.models import GrantReimbursement, GrantReimbursementCategory +from grants.tests.factories import GrantFactory, GrantReimbursementCategoryFactory +from conferences.tests.factories import ConferenceFactory + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def conference_with_categories(): + """Create a conference with standard reimbursement categories.""" + conference = ConferenceFactory() + + GrantReimbursementCategoryFactory( + conference=conference, + category="ticket", + name="Ticket", + description="Conference ticket", + max_amount=Decimal("100.00"), + included_by_default=True, + ) + + GrantReimbursementCategoryFactory( + conference=conference, + category="travel", + name="Travel", + description="Travel support", + max_amount=Decimal("500.00"), + included_by_default=False, + ) + + GrantReimbursementCategoryFactory( + conference=conference, + category="accommodation", + name="Accommodation", + description="Accommodation support", + max_amount=Decimal("200.00"), + included_by_default=True, + ) + + return conference + + +def _create_reimbursements_for_grant(grant): + """Simulate the migration logic for creating reimbursements from grant amounts.""" + categories = { + c.category: c + for c in GrantReimbursementCategory.objects.filter(conference=grant.conference) + } + + if "ticket" in categories and grant.ticket_amount: + GrantReimbursement.objects.get_or_create( + grant=grant, + category=categories["ticket"], + defaults={"granted_amount": grant.ticket_amount}, + ) + + if ( + grant.approved_type in ("ticket_travel", "ticket_travel_accommodation") + and "travel" in categories + and grant.travel_amount + ): + GrantReimbursement.objects.get_or_create( + grant=grant, + category=categories["travel"], + defaults={"granted_amount": grant.travel_amount}, + ) + + if ( + grant.approved_type in ("ticket_accommodation", "ticket_travel_accommodation") + and "accommodation" in categories + and grant.accommodation_amount + ): + GrantReimbursement.objects.get_or_create( + grant=grant, + category=categories["accommodation"], + defaults={"granted_amount": grant.accommodation_amount}, + ) + + +def _ensure_categories_exist_for_conference(conference): + """Create grant reimbursement categories if they don't exist.""" + GrantReimbursementCategory.objects.get_or_create( + conference=conference, + category="ticket", + defaults={ + "name": "Ticket", + "description": "Conference ticket", + "max_amount": Decimal("150.00"), + "included_by_default": True, + }, + ) + GrantReimbursementCategory.objects.get_or_create( + conference=conference, + category="travel", + defaults={ + "name": "Travel", + "description": "Travel support", + "max_amount": Decimal("400.00"), + "included_by_default": False, + }, + ) + GrantReimbursementCategory.objects.get_or_create( + conference=conference, + category="accommodation", + defaults={ + "name": "Accommodation", + "description": "Accommodation support", + "max_amount": Decimal("300.00"), + "included_by_default": True, + }, + ) + + +def test_creates_ticket_reimbursement_for_ticket_only_grant(conference_with_categories): + grant = GrantFactory( + conference=conference_with_categories, + approved_type="ticket_only", + ticket_amount=Decimal("100.00"), + travel_amount=Decimal("0.00"), + accommodation_amount=Decimal("0.00"), + ) + + _create_reimbursements_for_grant(grant) + + reimbursements = GrantReimbursement.objects.filter(grant=grant) + assert reimbursements.count() == 1 + + ticket_reimbursement = reimbursements.get(category__category="ticket") + assert ticket_reimbursement.granted_amount == Decimal("100.00") + + +def test_creates_ticket_and_travel_reimbursement_for_ticket_travel_grant( + conference_with_categories, +): + grant = GrantFactory( + conference=conference_with_categories, + approved_type="ticket_travel", + ticket_amount=Decimal("100.00"), + travel_amount=Decimal("400.00"), + accommodation_amount=Decimal("0.00"), + ) + + _create_reimbursements_for_grant(grant) + + reimbursements = GrantReimbursement.objects.filter(grant=grant) + assert reimbursements.count() == 2 + + ticket_reimbursement = reimbursements.get(category__category="ticket") + travel_reimbursement = reimbursements.get(category__category="travel") + + assert ticket_reimbursement.granted_amount == Decimal("100.00") + assert travel_reimbursement.granted_amount == Decimal("400.00") + + +def test_creates_ticket_and_accommodation_reimbursement_for_ticket_accommodation_grant( + conference_with_categories, +): + grant = GrantFactory( + conference=conference_with_categories, + approved_type="ticket_accommodation", + ticket_amount=Decimal("100.00"), + travel_amount=Decimal("0.00"), + accommodation_amount=Decimal("200.00"), + ) + + _create_reimbursements_for_grant(grant) + + reimbursements = GrantReimbursement.objects.filter(grant=grant) + assert reimbursements.count() == 2 + + ticket_reimbursement = reimbursements.get(category__category="ticket") + accommodation_reimbursement = reimbursements.get(category__category="accommodation") + + assert ticket_reimbursement.granted_amount == Decimal("100.00") + assert accommodation_reimbursement.granted_amount == Decimal("200.00") + + +def test_creates_all_reimbursements_for_full_grant(conference_with_categories): + grant = GrantFactory( + conference=conference_with_categories, + approved_type="ticket_travel_accommodation", + ticket_amount=Decimal("100.00"), + travel_amount=Decimal("400.00"), + accommodation_amount=Decimal("200.00"), + ) + + _create_reimbursements_for_grant(grant) + + reimbursements = GrantReimbursement.objects.filter(grant=grant) + assert reimbursements.count() == 3 + + ticket_reimbursement = reimbursements.get(category__category="ticket") + travel_reimbursement = reimbursements.get(category__category="travel") + accommodation_reimbursement = reimbursements.get(category__category="accommodation") + + assert ticket_reimbursement.granted_amount == Decimal("100.00") + assert travel_reimbursement.granted_amount == Decimal("400.00") + assert accommodation_reimbursement.granted_amount == Decimal("200.00") + + +def test_skips_grants_without_approved_type(conference_with_categories): + grant = GrantFactory( + conference=conference_with_categories, + approved_type=None, + ticket_amount=Decimal("0.00"), + travel_amount=Decimal("0.00"), + accommodation_amount=Decimal("0.00"), + ) + + if grant.approved_type is not None and grant.approved_type != "": + _create_reimbursements_for_grant(grant) + + reimbursements = GrantReimbursement.objects.filter(grant=grant) + assert reimbursements.count() == 0 + + +def test_preserves_total_amounts_after_migration(conference_with_categories): + grants = [ + GrantFactory( + conference=conference_with_categories, + approved_type="ticket_only", + ticket_amount=Decimal("100.00"), + travel_amount=Decimal("0.00"), + accommodation_amount=Decimal("0.00"), + ), + GrantFactory( + conference=conference_with_categories, + approved_type="ticket_travel", + ticket_amount=Decimal("100.00"), + travel_amount=Decimal("400.00"), + accommodation_amount=Decimal("0.00"), + ), + GrantFactory( + conference=conference_with_categories, + approved_type="ticket_travel_accommodation", + ticket_amount=Decimal("100.00"), + travel_amount=Decimal("400.00"), + accommodation_amount=Decimal("200.00"), + ), + ] + + for grant in grants: + _create_reimbursements_for_grant(grant) + + original_total = ( + grant.ticket_amount + grant.travel_amount + grant.accommodation_amount + ) + reimbursements_total = sum( + r.granted_amount for r in GrantReimbursement.objects.filter(grant=grant) + ) + assert original_total == reimbursements_total + + +def test_does_not_create_duplicates_when_run_multiple_times(conference_with_categories): + grant = GrantFactory( + conference=conference_with_categories, + approved_type="ticket_travel_accommodation", + ticket_amount=Decimal("100.00"), + travel_amount=Decimal("400.00"), + accommodation_amount=Decimal("200.00"), + ) + + _create_reimbursements_for_grant(grant) + initial_count = GrantReimbursement.objects.filter(grant=grant).count() + assert initial_count == 3 + + _create_reimbursements_for_grant(grant) + final_count = GrantReimbursement.objects.filter(grant=grant).count() + assert final_count == 3 + + +def test_creates_categories_with_conference_defaults(): + conference = ConferenceFactory( + grants_default_ticket_amount=Decimal("150.00"), + grants_default_accommodation_amount=Decimal("250.00"), + grants_default_travel_from_extra_eu_amount=Decimal("550.00"), + ) + + _ensure_categories_exist_for_conference(conference) + + categories = GrantReimbursementCategory.objects.filter(conference=conference) + assert categories.count() == 3 + + ticket_cat = categories.get(category="ticket") + travel_cat = categories.get(category="travel") + accommodation_cat = categories.get(category="accommodation") + + assert ticket_cat.name == "Ticket" + assert ticket_cat.max_amount == Decimal("150.00") + assert ticket_cat.included_by_default is True + + assert travel_cat.name == "Travel" + assert travel_cat.max_amount == Decimal("550.00") + assert travel_cat.included_by_default is False + + assert accommodation_cat.name == "Accommodation" + assert accommodation_cat.max_amount == Decimal("250.00") + assert accommodation_cat.included_by_default is True