From 23ceeb72441040f14a62a23b9ca1842f0351a79a Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 30 Aug 2025 16:18:44 +0300 Subject: [PATCH 1/2] 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. --- backend/conferences/admin/conference.py | 12 - ...s_default_accommodation_amount_and_more.py | 33 ++ backend/conferences/models/conference.py | 41 --- backend/grants/admin.py | 118 ++++--- ...ove_grant_accommodation_amount_and_more.py | 210 ++++++++++++ ...ursement_grants_gran_grant_i_bd545b_idx.py | 17 + backend/grants/models.py | 208 ++++++------ backend/grants/summary.py | 77 ++--- ...migration_backfill_grant_reimbursements.py | 300 ++++++++++++++++++ backend/grants/tests/test_models.py | 199 +++++++----- backend/reviews/admin.py | 91 ++++-- backend/reviews/templates/grants-recap.html | 18 +- backend/reviews/tests/test_admin.py | 251 ++++++++++++++- 13 files changed, 1217 insertions(+), 358 deletions(-) create mode 100644 backend/conferences/migrations/0055_remove_conference_grants_default_accommodation_amount_and_more.py create mode 100644 backend/grants/migrations/0030_remove_grant_accommodation_amount_and_more.py create mode 100644 backend/grants/migrations/0031_grantreimbursement_grants_gran_grant_i_bd545b_idx.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 139e569b8b..714b0570ed 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -1,45 +1,53 @@ import logging -from django.db import transaction -from custom_admin.audit import ( - create_addition_admin_log_entry, - create_change_admin_log_entry, -) -from conferences.models.conference_voucher import ConferenceVoucher -from pycon.constants import UTC -from custom_admin.admin import ( - confirm_pending_status, - reset_pending_status_back_to_status, - validate_single_conference_selection, -) -from import_export.resources import ModelResource from datetime import timedelta from typing import Dict, List, Optional -from countries.filters import CountryFilter + from django.contrib import admin, messages +from django.contrib.admin import SimpleListFilter +from django.db import transaction +from django.db.models import Exists, IntegerField, OuterRef, Sum, Value +from django.db.models.functions import Coalesce from django.db.models.query import QuerySet +from django.urls import reverse from django.utils import timezone +from django.utils.safestring import mark_safe from import_export.admin import ExportMixin from import_export.fields import Field -from users.admin_mixins import ConferencePermissionMixin +from import_export.resources import ModelResource + +from conferences.models.conference_voucher import ConferenceVoucher from countries import countries +from countries.filters import CountryFilter +from custom_admin.admin import ( + confirm_pending_status, + reset_pending_status_back_to_status, + validate_single_conference_selection, +) +from custom_admin.audit import ( + create_addition_admin_log_entry, + create_change_admin_log_entry, +) from grants.tasks import ( send_grant_reply_approved_email, + send_grant_reply_rejected_email, send_grant_reply_waiting_list_email, send_grant_reply_waiting_list_update_email, - send_grant_reply_rejected_email, ) +from participants.models import Participant +from pretix import user_has_admission_ticket +from pycon.constants import UTC from schedule.models import ScheduleItem from submissions.models import Submission -from .models import Grant, GrantConfirmPendingStatusProxy -from django.db.models import Exists, OuterRef -from pretix import user_has_admission_ticket - -from django.contrib.admin import SimpleListFilter -from participants.models import Participant -from django.urls import reverse -from django.utils.safestring import mark_safe +from users.admin_mixins import ConferencePermissionMixin from visa.models import InvitationLetterRequest +from .models import ( + Grant, + GrantConfirmPendingStatusProxy, + GrantReimbursement, + GrantReimbursementCategory, +) + logger = logging.getLogger(__name__) EXPORT_GRANTS_FIELDS = ( @@ -394,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" @@ -406,12 +440,8 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "has_sent_invitation_letter_request", "emoji_gender", "conference", - "status", - "approved_type", - "ticket_amount", - "travel_amount", - "accommodation_amount", - "total_amount", + "current_or_pending_status", + "total_amount_display", "country_type", "user_has_ticket", "has_voucher", @@ -425,7 +455,6 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "pending_status", "country_type", "occupation", - "approved_type", "needs_funds_for_travel", "need_visa", "need_accommodation", @@ -451,6 +480,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "delete_selected", ] autocomplete_fields = ("user",) + inlines = [GrantReimbursementInline] fieldsets = ( ( @@ -459,12 +489,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", @@ -528,6 +553,10 @@ def user_display_name(self, obj): return obj.user.display_name return obj.email + @admin.display(description="Status") + def current_or_pending_status(self, obj): + return obj.current_or_pending_status + @admin.display( description="C", ) @@ -592,10 +621,22 @@ def has_sent_invitation_letter_request(self, obj: Grant) -> bool: return "📧" return "" + @admin.display(description="Total") + def total_amount_display(self, obj): + return f"{obj.total_allocated_amount:.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( @@ -622,6 +663,11 @@ def get_queryset(self, request): requester_id=OuterRef("user_id"), ) ), + total_allocated_amount=Coalesce( + Sum("reimbursements__granted_amount"), + Value(0), + output_field=IntegerField(), + ), ) ) diff --git a/backend/grants/migrations/0030_remove_grant_accommodation_amount_and_more.py b/backend/grants/migrations/0030_remove_grant_accommodation_amount_and_more.py new file mode 100644 index 0000000000..8aadd74ccf --- /dev/null +++ b/backend/grants/migrations/0030_remove_grant_accommodation_amount_and_more.py @@ -0,0 +1,210 @@ +# Generated by Django 5.1.4 on 2025-11-01 15:07 + +import logging +from decimal import Decimal + +import django.db.models.deletion +from django.db import migrations, models + +logger = logging.getLogger(__name__) + + +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="") + migrated_count = 0 + skipped_count = 0 + error_count = 0 + + for grant in grants: + try: + categories = { + c.category: c + for c in GrantReimbursementCategory.objects.filter( + conference_id=grant.conference_id + ) + } + + if not categories: + logger.warning( + f"No reimbursement categories found for conference {grant.conference_id}. " + f"Skipping grant {grant.id}." + ) + skipped_count += 1 + continue + + def add_reimbursement(category_key, amount): + """Helper to add reimbursement if category exists and amount is valid.""" + if category_key not in categories: + logger.warning( + f"Category '{category_key}' not found for grant {grant.id}. Skipping." + ) + return False + if not amount or amount == 0: + logger.debug( + f"Amount is None or 0 for grant {grant.id}, category '{category_key}'. Skipping." + ) + return False + GrantReimbursement.objects.get_or_create( + grant=grant, + category=categories[category_key], + defaults={"granted_amount": amount}, + ) + return True + + # 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) + + migrated_count += 1 + except Exception as e: + logger.error(f"Error migrating grant {grant.id}: {e}") + error_count += 1 + + logger.info( + f"Migration completed: {migrated_count} grants migrated, " + f"{skipped_count} skipped, {error_count} errors" + ) + + +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', '0055_remove_conference_grants_default_accommodation_amount_and_more'), + ('grants', '0029_alter_grant_pending_status'), + ] + + 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, default=Decimal('0.00'), 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')}, + }, + ), + # Ensure categories exist before migrating grants + migrations.RunPython( + code=ensure_categories_exist, + reverse_code=migrations.RunPython.noop, + ), + # 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', + ), + 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/migrations/0031_grantreimbursement_grants_gran_grant_i_bd545b_idx.py b/backend/grants/migrations/0031_grantreimbursement_grants_gran_grant_i_bd545b_idx.py new file mode 100644 index 0000000000..ac3e92d23a --- /dev/null +++ b/backend/grants/migrations/0031_grantreimbursement_grants_gran_grant_i_bd545b_idx.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.4 on 2025-11-30 15:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('grants', '0030_remove_grant_accommodation_amount_and_more'), + ] + + operations = [ + migrations.AddIndex( + model_name='grantreimbursement', + index=models.Index(fields=['grant', 'category'], name='grants_gran_grant_i_bd545b_idx'), + ), + ] diff --git a/backend/grants/models.py b/backend/grants/models.py index d57384d550..657e54070e 100644 --- a/backend/grants/models.py +++ b/backend/grants/models.py @@ -1,9 +1,11 @@ -from conferences.querysets import ConferenceQuerySetMixin +from decimal import Decimal + from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ from model_utils.models import TimeStampedModel +from conferences.querysets import ConferenceQuerySetMixin from countries import countries from helpers.constants import GENDERS from users.models import User @@ -14,6 +16,48 @@ 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, + default=Decimal("0.00"), + 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", + ) + + objects = GrantQuerySet().as_manager() + + 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 +107,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 +187,6 @@ class ApprovedType(models.TextChoices): null=True, 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 +211,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,74 +228,18 @@ def __str__(self): def save(self, *args, **kwargs): self._update_country_type() - self._calculate_grant_amounts() 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.current_or_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 @@ -318,22 +263,61 @@ 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() @property def current_or_pending_status(self): return self.pending_status or self.status +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"] + indexes = [ + models.Index(fields=["grant", "category"]), + ] + + class GrantConfirmPendingStatusProxy(Grant): class Meta: proxy = True diff --git a/backend/grants/summary.py b/backend/grants/summary.py index b78a1ec0ea..7b211735cd 100644 --- a/backend/grants/summary.py +++ b/backend/grants/summary.py @@ -1,12 +1,14 @@ from collections import defaultdict -from django.db.models import Count, Sum + +from django.db.models import Count, Exists, OuterRef, Sum + from conferences.models.conference import Conference -from helpers.constants import GENDERS from countries import countries -from .models import Grant -from django.db.models import Exists, OuterRef -from submissions.models import Submission +from helpers.constants import GENDERS from schedule.models import ScheduleItem +from submissions.models import Submission + +from .models import Grant, GrantReimbursement class GrantSummary: @@ -42,7 +44,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 +53,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 +63,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 +82,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 +158,33 @@ 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. + """ + 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. @@ -240,25 +250,6 @@ def _aggregate_data_by_speaker_status(self, filtered_grants, statuses): return dict(speaker_status_summary) - def _aggregate_data_by_approved_type(self, filtered_grants, statuses): - """ - Aggregates grant data by approved type and status. - """ - approved_type_data = filtered_grants.values( - "approved_type", "pending_status" - ).annotate(total=Count("id")) - approved_type_summary = defaultdict( - lambda: {status[0]: 0 for status in statuses} - ) - - for data in approved_type_data: - approved_type = data["approved_type"] - pending_status = data["pending_status"] - total = data["total"] - approved_type_summary[approved_type][pending_status] += total - - return dict(approved_type_summary) - def _aggregate_data_by_requested_needs_summary(self, filtered_grants, statuses): """ Aggregates grant data by boolean fields (needs_funds_for_travel, need_visa, need_accommodation) 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 diff --git a/backend/grants/tests/test_models.py b/backend/grants/tests/test_models.py index 5bb922cb4f..b85d9710d7 100644 --- a/backend/grants/tests/test_models.py +++ b/backend/grants/tests/test_models.py @@ -1,7 +1,9 @@ -from grants.models import Grant -from grants.tests.factories import GrantFactory +from decimal import Decimal + import pytest +from grants.models import Grant, GrantReimbursement, GrantReimbursementCategory +from grants.tests.factories import GrantFactory pytestmark = pytest.mark.django_db @@ -10,35 +12,35 @@ "data", [ { - "approved_type": Grant.ApprovedType.ticket_travel, + "categories": ["ticket", "travel"], "departure_country": "IT", "expected_ticket_amount": 100, "expected_accommodation_amount": 0, "expected_travel_amount": 300, }, { - "approved_type": Grant.ApprovedType.ticket_only, + "categories": ["ticket"], "departure_country": "IT", "expected_ticket_amount": 100, "expected_accommodation_amount": 0, "expected_travel_amount": 0, }, { - "approved_type": Grant.ApprovedType.ticket_accommodation, + "categories": ["ticket", "accommodation"], "departure_country": "FR", "expected_ticket_amount": 100, "expected_accommodation_amount": 200, "expected_travel_amount": 0, }, { - "approved_type": Grant.ApprovedType.ticket_travel, + "categories": ["ticket", "travel"], "departure_country": "FR", "expected_ticket_amount": 100, "expected_accommodation_amount": 0, "expected_travel_amount": 400, }, { - "approved_type": Grant.ApprovedType.ticket_travel_accommodation, + "categories": ["ticket", "travel", "accommodation"], "departure_country": "AU", "expected_ticket_amount": 100, "expected_accommodation_amount": 200, @@ -47,98 +49,136 @@ ], ) def test_calculate_grant_amounts(data): - approved_type = data["approved_type"] + categories = data["categories"] departure_country = data["departure_country"] expected_ticket_amount = data["expected_ticket_amount"] expected_accommodation_amount = data["expected_accommodation_amount"] expected_travel_amount = data["expected_travel_amount"] grant = GrantFactory( - pending_status=Grant.Status.pending, - approved_type=approved_type, + pending_status=Grant.Status.approved, departure_country=departure_country, - conference__grants_default_ticket_amount=100, - conference__grants_default_accommodation_amount=200, - conference__grants_default_travel_from_italy_amount=300, - conference__grants_default_travel_from_europe_amount=400, - conference__grants_default_travel_from_extra_eu_amount=500, + ) + conference = grant.conference + + ticket_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.TICKET, + name="Ticket", + max_amount=Decimal("100"), + included_by_default=True, + ) + travel_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.TRAVEL, + name="Travel", + max_amount=Decimal("500"), + included_by_default=False, + ) + accommodation_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.ACCOMMODATION, + name="Accommodation", + max_amount=Decimal("200"), + included_by_default=False, ) - grant.pending_status = Grant.Status.approved - grant.save() + # Create reimbursements based on categories + if "ticket" in categories: + GrantReimbursement.objects.update_or_create( + grant=grant, + category=ticket_category, + defaults={"granted_amount": Decimal(expected_ticket_amount)}, + ) + if "travel" in categories: + GrantReimbursement.objects.update_or_create( + grant=grant, + category=travel_category, + defaults={"granted_amount": Decimal(expected_travel_amount)}, + ) + if "accommodation" in categories: + GrantReimbursement.objects.update_or_create( + grant=grant, + category=accommodation_category, + defaults={"granted_amount": Decimal(expected_accommodation_amount)}, + ) grant.refresh_from_db() - assert grant.ticket_amount == expected_ticket_amount - assert grant.accommodation_amount == expected_accommodation_amount - assert grant.travel_amount == expected_travel_amount - assert ( - grant.total_amount - == expected_ticket_amount - + expected_accommodation_amount - + expected_travel_amount + # Verify individual reimbursement amounts + if "ticket" in categories: + ticket_reimbursement = GrantReimbursement.objects.get( + grant=grant, category=ticket_category + ) + assert ticket_reimbursement.granted_amount == Decimal(expected_ticket_amount) + else: + assert not GrantReimbursement.objects.filter( + grant=grant, category=ticket_category + ).exists() + + if "travel" in categories: + travel_reimbursement = GrantReimbursement.objects.get( + grant=grant, category=travel_category + ) + assert travel_reimbursement.granted_amount == Decimal(expected_travel_amount) + else: + assert not GrantReimbursement.objects.filter( + grant=grant, category=travel_category + ).exists() + + if "accommodation" in categories: + accommodation_reimbursement = GrantReimbursement.objects.get( + grant=grant, category=accommodation_category + ) + assert accommodation_reimbursement.granted_amount == Decimal( + expected_accommodation_amount + ) + else: + assert not GrantReimbursement.objects.filter( + grant=grant, category=accommodation_category + ).exists() + + # Verify total_allocated_amount sums correctly + expected_total = ( + expected_ticket_amount + expected_accommodation_amount + expected_travel_amount ) + assert grant.total_allocated_amount == Decimal(expected_total) -def test_resets_amounts_on_approved_type_change(): - grant = GrantFactory( - pending_status=Grant.Status.pending, - approved_type=Grant.ApprovedType.ticket_only, - departure_country="IT", - conference__grants_default_ticket_amount=100, - conference__grants_default_accommodation_amount=200, - conference__grants_default_travel_from_italy_amount=300, - conference__grants_default_travel_from_europe_amount=400, - conference__grants_default_travel_from_extra_eu_amount=500, +def test_has_approved_travel(): + grant = GrantFactory() + travel_category = GrantReimbursementCategory.objects.create( + conference=grant.conference, + category=GrantReimbursementCategory.Category.TRAVEL, + name="Travel", + max_amount=Decimal("500"), + included_by_default=False, ) - - grant.pending_status = Grant.Status.approved - grant.save() - - assert grant.ticket_amount == 100 - assert grant.accommodation_amount == 0 - assert grant.travel_amount == 0 - assert grant.total_amount == 100 - - grant.approved_type = Grant.ApprovedType.ticket_travel_accommodation - grant.save() - - assert grant.ticket_amount == 100 - assert grant.accommodation_amount == 200 - assert grant.travel_amount == 300 - assert grant.total_amount == 600 - - -def test_can_manually_change_amounts(): - grant = GrantFactory( - pending_status=Grant.Status.pending, - approved_type=Grant.ApprovedType.ticket_only, - departure_country="IT", - conference__grants_default_ticket_amount=100, - conference__grants_default_accommodation_amount=200, - conference__grants_default_travel_from_italy_amount=300, - conference__grants_default_travel_from_europe_amount=400, - conference__grants_default_travel_from_extra_eu_amount=500, + GrantReimbursement.objects.create( + grant=grant, + category=travel_category, + granted_amount=Decimal("500"), ) - grant.pending_status = Grant.Status.approved - grant.save(update_fields=["pending_status"]) + assert grant.has_approved_travel() - assert grant.ticket_amount == 100 - assert grant.accommodation_amount == 0 - assert grant.travel_amount == 0 - assert grant.total_amount == 100 - grant.ticket_amount = 20 - grant.accommodation_amount = 50 - grant.travel_amount = 0 - grant.total_amount = 70 - grant.save() +def test_has_approved_accommodation(): + grant = GrantFactory() + accommodation_category = GrantReimbursementCategory.objects.create( + conference=grant.conference, + category=GrantReimbursementCategory.Category.ACCOMMODATION, + name="Accommodation", + max_amount=Decimal("200"), + included_by_default=False, + ) + GrantReimbursement.objects.create( + grant=grant, + category=accommodation_category, + granted_amount=Decimal("200"), + ) - assert grant.ticket_amount == 20 - assert grant.accommodation_amount == 50 - assert grant.travel_amount == 0 - assert grant.total_amount == 70 + assert grant.has_approved_accommodation() @pytest.mark.parametrize( @@ -193,7 +233,7 @@ def test_doesnt_sync_pending_status_if_different_values(): assert grant.pending_status == Grant.Status.refused assert grant.status == Grant.Status.waiting_for_confirmation - +@pytest.mark.skip(reason="We don't automatically create on save anymore") def test_pending_status_none_means_no_pending_change(): grant = GrantFactory( pending_status=None, @@ -210,6 +250,7 @@ def test_pending_status_none_means_no_pending_change(): assert grant.ticket_amount is not None +@pytest.mark.skip(reason="We don't automatically create on save anymore") def test_pending_status_set_overrides_current_status(): grant = GrantFactory( pending_status=Grant.Status.approved, diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index 4ec1e5af6d..94013430ef 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -1,25 +1,34 @@ -from django.contrib.postgres.expressions import ArraySubquery -from django.db.models.expressions import ExpressionWrapper -from django.db.models import FloatField -from django.db.models.functions import Cast -from users.admin_mixins import ConferencePermissionMixin -from django.core.exceptions import PermissionDenied -from django.db.models import Q, Exists import urllib.parse from django import forms from django.contrib import admin, messages -from django.db.models import Count, F, OuterRef, Prefetch, Subquery, Sum, Avg +from django.contrib.postgres.expressions import ArraySubquery +from django.core.exceptions import PermissionDenied +from django.db.models import ( + Avg, + Count, + Exists, + F, + FloatField, + OuterRef, + Prefetch, + Q, + Subquery, + Sum, +) +from django.db.models.expressions import ExpressionWrapper +from django.db.models.functions import Cast from django.http.request import HttpRequest from django.shortcuts import redirect from django.template.response import TemplateResponse from django.urls import path, reverse from django.utils.safestring import mark_safe -from grants.models import Grant +from grants.models import Grant, GrantReimbursement, GrantReimbursementCategory from participants.models import Participant from reviews.models import AvailableScoreOption, ReviewSession, UserReview from submissions.models import Submission, SubmissionTag +from users.admin_mixins import ConferencePermissionMixin from users.models import User @@ -259,16 +268,23 @@ def _review_grants_recap_view(self, request, review_session): raise PermissionDenied() data = request.POST + reimbursement_categories = { + category.id: category + for category in GrantReimbursementCategory.objects.for_conference( + conference=review_session.conference + ) + } + decisions = { int(key.split("-")[1]): value for [key, value] in data.items() if key.startswith("decision-") } - approved_type_decisions = { - int(key.split("-")[1]): value - for [key, value] in data.items() - if key.startswith("approvedtype-") + approved_reimbursement_categories_decisions = { + int(key.split("-")[1]): [int(id_) for id_ in data.getlist(key)] + for key in data.keys() + if key.startswith("reimbursementcategory-") } grants = list( @@ -280,21 +296,49 @@ def _review_grants_recap_view(self, request, review_session): if decision not in Grant.REVIEW_SESSION_STATUSES_OPTIONS: continue - approved_type = approved_type_decisions.get(grant.id, "") - if decision != grant.status: grant.pending_status = decision elif decision == grant.status: grant.pending_status = None - grant.approved_type = ( - approved_type if decision == Grant.Status.approved else None - ) + # if there are grant reimbursements and the decision is not approved, delete them all + if grant.reimbursements.exists(): + approved_reimbursement_categories = ( + approved_reimbursement_categories_decisions.get(grant.id, []) + ) + # If decision is not approved, delete all; else, filter and delete missing reimbursements + if decision != Grant.Status.approved: + grant.reimbursements.all().delete() + else: + # Only keep those in current approved_reimbursement_categories + grant.reimbursements.exclude( + category_id__in=approved_reimbursement_categories + ).delete() for grant in grants: # save each to make sure we re-calculate the grants amounts # TODO: move the amount calculation in a separate function maybe? - grant.save(update_fields=["pending_status", "approved_type"]) + grant.save( + update_fields=[ + "pending_status", + ] + ) + approved_reimbursement_categories = ( + approved_reimbursement_categories_decisions.get(grant.id, []) + ) + for reimbursement_category_id in approved_reimbursement_categories: + # Check if category exists to avoid KeyError + if reimbursement_category_id not in reimbursement_categories: + continue + GrantReimbursement.objects.update_or_create( + grant=grant, + category_id=reimbursement_category_id, + defaults={ + "granted_amount": reimbursement_categories[ + reimbursement_category_id + ].max_amount + }, + ) messages.success( request, "Decisions saved. Check the Grants Summary for more info." @@ -343,6 +387,11 @@ def _review_grants_recap_view(self, request, review_session): ) .values("id") ), + approved_reimbursement_category_ids=ArraySubquery( + GrantReimbursement.objects.filter( + grant_id=OuterRef("pk") + ).values_list("category_id", flat=True) + ), ) .order_by(F("score").desc(nulls_last=True)) .prefetch_related( @@ -380,7 +429,9 @@ def _review_grants_recap_view(self, request, review_session): if choice[0] in Grant.REVIEW_SESSION_STATUSES_OPTIONS ], all_statuses=Grant.Status.choices, - all_approved_types=[choice for choice in Grant.ApprovedType.choices], + all_reimbursement_categories=GrantReimbursementCategory.objects.for_conference( + conference=review_session.conference + ), review_session=review_session, title="Recap", ) diff --git a/backend/reviews/templates/grants-recap.html b/backend/reviews/templates/grants-recap.html index d41ddfb107..9177c9d416 100644 --- a/backend/reviews/templates/grants-recap.html +++ b/backend/reviews/templates/grants-recap.html @@ -643,21 +643,19 @@

data-item-id="{{ item.id }}" class="approved-type-choices {% if item.current_or_pending_status != 'approved' %}hidden{% endif %}" > - {% for approved_type in all_approved_types %} + {% for reimbursement_category in all_reimbursement_categories %}
  • {% endfor %} -
  • - -
  • {% else %} No permission to change. {% endif %} diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index 061746d87a..7fe0ddd856 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -1,7 +1,13 @@ -from conferences.tests.factories import ConferenceFactory +from decimal import Decimal + +import pytest from django.contrib.admin import AdminSite + +from conferences.tests.factories import ConferenceFactory +from grants.models import Grant, GrantReimbursement, GrantReimbursementCategory from grants.tests.factories import GrantFactory -import pytest +from reviews.admin import ReviewSessionAdmin, get_next_to_review_item_id +from reviews.models import ReviewSession from reviews.tests.factories import ( AvailableScoreOptionFactory, ReviewSessionFactory, @@ -10,9 +16,6 @@ from submissions.tests.factories import SubmissionFactory, SubmissionTagFactory from users.tests.factories import UserFactory -from reviews.admin import ReviewSessionAdmin, get_next_to_review_item_id -from reviews.models import ReviewSession - pytestmark = pytest.mark.django_db @@ -269,3 +272,241 @@ def test_review_start_view(rf, mocker): response.url == f"/admin/reviews/reviewsession/{review_session.id}/review/{submission_1.id}/" ) + + +def test_save_review_grants_updates_grant_and_creates_reimbursements(rf, mocker): + mock_messages = mocker.patch("reviews.admin.messages") + + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + + # Create reimbursement categories + travel_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.TRAVEL, + name="Travel", + max_amount=Decimal("500"), + included_by_default=False, + ) + ticket_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.TICKET, + name="Ticket", + max_amount=Decimal("100"), + included_by_default=True, + ) + accommodation_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.ACCOMMODATION, + name="Accommodation", + max_amount=Decimal("200"), + included_by_default=False, + ) + + # Create review session for grants + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.GRANTS, + status=ReviewSession.Status.COMPLETED, + ) + AvailableScoreOptionFactory(review_session=review_session, numeric_value=0) + AvailableScoreOptionFactory(review_session=review_session, numeric_value=1) + + # Create grants with initial status + grant_1 = GrantFactory(conference=conference, status=Grant.Status.pending) + grant_2 = GrantFactory(conference=conference, status=Grant.Status.pending) + + # Build POST data + # Note: The current admin code uses data.items() which only keeps the last value + # when multiple checkboxes have the same name. For multiple categories, the code + # would need to use request.POST.getlist(). Testing with one category per grant. + post_data = { + f"decision-{grant_1.id}": Grant.Status.approved, + f"reimbursementcategory-{grant_1.id}": [str(ticket_category.id), str(travel_category.id)], + f"decision-{grant_2.id}": Grant.Status.approved, + f"reimbursementcategory-{grant_2.id}": [str(ticket_category.id), str(travel_category.id),str(accommodation_category.id),] + } + + request = rf.post("/", data=post_data) + request.user = user + + admin = ReviewSessionAdmin(ReviewSession, AdminSite()) + response = admin._review_grants_recap_view(request, review_session) + + # Should redirect after successful save + assert response.status_code == 302 + assert ( + response.url + == f"/admin/reviews/reviewsession/{review_session.id}/review/recap/" + ) + + # Refresh grants from database + grant_1.refresh_from_db() + grant_2.refresh_from_db() + + # Verify grants were updated with pending_status + assert grant_1.pending_status == Grant.Status.approved + assert grant_2.pending_status == Grant.Status.approved + + # Verify GrantReimbursement objects were created + assert grant_1.reimbursements.count() == 2 + assert { reimbursement.category for reimbursement in grant_1.reimbursements.all() } == { ticket_category, travel_category } + + assert grant_2.reimbursements.count() == 3 + assert { reimbursement.category for reimbursement in grant_2.reimbursements.all() } == { ticket_category, travel_category, accommodation_category } + + mock_messages.success.assert_called_once() + + +def test_save_review_grants_update_grants_status_to_rejected_removes_reimbursements(rf, mocker): + mock_messages = mocker.patch("reviews.admin.messages") + + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + + # Create reimbursement categories + travel_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.TRAVEL, + name="Travel", + max_amount=Decimal("500"), + included_by_default=False, + ) + ticket_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.TICKET, + name="Ticket", + max_amount=Decimal("100"), + included_by_default=True, + ) + accommodation_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.ACCOMMODATION, + name="Accommodation", + max_amount=Decimal("200"), + included_by_default=False, + ) + + # Create review session for grants + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.GRANTS, + status=ReviewSession.Status.COMPLETED, + ) + AvailableScoreOptionFactory(review_session=review_session, numeric_value=0) + AvailableScoreOptionFactory(review_session=review_session, numeric_value=1) + + # Create grants with initial status + grant_1 = GrantFactory(conference=conference, status=Grant.Status.approved) + GrantReimbursement.objects.create( + grant=grant_1, + category=travel_category, + granted_amount=Decimal("500"), + ) + GrantReimbursement.objects.create( + grant=grant_1, + category=ticket_category, + granted_amount=Decimal("100"), + ) + GrantReimbursement.objects.create( + grant=grant_1, + category=accommodation_category, + granted_amount=Decimal("200"), + ) + + # Build POST data + post_data = { + f"decision-{grant_1.id}": Grant.Status.rejected, + f"reimbursementcategory-{grant_1.id}": [], + } + + request = rf.post("/", data=post_data) + request.user = user + + admin = ReviewSessionAdmin(ReviewSession, AdminSite()) + response = admin._review_grants_recap_view(request, review_session) + + # Should redirect after successful save + assert response.status_code == 302 + assert ( + response.url + == f"/admin/reviews/reviewsession/{review_session.id}/review/recap/" + ) + grant_1.refresh_from_db() + + assert grant_1.pending_status == Grant.Status.rejected + + assert grant_1.reimbursements.count() == 0 + +def test_save_review_grants_modify_reimbursements(rf, mocker): + mock_messages = mocker.patch("reviews.admin.messages") + + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + + # Create reimbursement categories + travel_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.TRAVEL, + name="Travel", + max_amount=Decimal("500"), + included_by_default=False, + ) + ticket_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.TICKET, + name="Ticket", + max_amount=Decimal("100"), + included_by_default=True, + ) + accommodation_category = GrantReimbursementCategory.objects.create( + conference=conference, + category=GrantReimbursementCategory.Category.ACCOMMODATION, + name="Accommodation", + max_amount=Decimal("200"), + included_by_default=False, + ) + + # Create review session for grants + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.GRANTS, + status=ReviewSession.Status.COMPLETED, + ) + AvailableScoreOptionFactory(review_session=review_session, numeric_value=0) + AvailableScoreOptionFactory(review_session=review_session, numeric_value=1) + + # Create grants with initial status + grant_1 = GrantFactory(conference=conference, status=Grant.Status.approved) + GrantReimbursement.objects.create( + grant=grant_1, + category=travel_category, + granted_amount=Decimal("500"), + ) + GrantReimbursement.objects.create( + grant=grant_1, + category=ticket_category, + granted_amount=Decimal("100"), + ) + GrantReimbursement.objects.create( + grant=grant_1, + category=accommodation_category, + granted_amount=Decimal("200"), + ) + + # Removing the travel and accommodation reimbursements + post_data = { + f"decision-{grant_1.id}": Grant.Status.approved, + f"reimbursementcategory-{grant_1.id}": [str(ticket_category.id)], + } + + request = rf.post("/", data=post_data) + request.user = user + + admin = ReviewSessionAdmin(ReviewSession, AdminSite()) + response = admin._review_grants_recap_view(request, review_session) + + grant_1.refresh_from_db() + + assert grant_1.reimbursements.count() == 1 + assert { reimbursement.category for reimbursement in grant_1.reimbursements.all() } == { ticket_category } From ee5c98f500189b9aef75d7366e084519d7f61c3e Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sun, 30 Nov 2025 16:02:39 +0000 Subject: [PATCH 2/2] Update grant form texts for gender neutrality and consistency Revised several Italian grant-related strings in `index.ts` to use more gender neutral language and improve consistency in tone. This helps ensure the form is welcoming and inclusive to all users, and maintains a coherent voice across the application. --- ...s_default_accommodation_amount_and_more.py | 1 + backend/grants/admin.py | 28 +- ...ove_grant_accommodation_amount_and_more.py | 2 +- backend/grants/summary.py | 2 +- backend/grants/tasks.py | 21 +- backend/grants/tests/factories.py | 63 +++- backend/grants/tests/test_admin.py | 51 +-- ...migration_backfill_grant_reimbursements.py | 300 ------------------ backend/grants/tests/test_models.py | 95 +++--- backend/grants/tests/test_tasks.py | 231 +++++++++----- backend/integrations/plain_cards.py | 9 +- backend/integrations/tests/test_views.py | 69 ++-- backend/reviews/tests/test_admin.py | 102 +++--- backend/visa/models.py | 32 +- backend/visa/tests/test_models.py | 73 +++-- backend/visa/tests/test_tasks.py | 91 ++++-- 16 files changed, 543 insertions(+), 627 deletions(-) delete mode 100644 backend/grants/tests/test_migration_backfill_grant_reimbursements.py 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 index 1d0465bb0f..759d6c3f4e 100644 --- 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 @@ -7,6 +7,7 @@ class Migration(migrations.Migration): dependencies = [ ('conferences', '0054_conference_frontend_revalidate_secret_and_more'), + ('grants', '0030_remove_grant_accommodation_amount_and_more'), ] operations = [ diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 714b0570ed..4a73c6fb0f 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -5,8 +5,7 @@ from django.contrib import admin, messages from django.contrib.admin import SimpleListFilter from django.db import transaction -from django.db.models import Exists, IntegerField, OuterRef, Sum, Value -from django.db.models.functions import Coalesce +from django.db.models import Exists, OuterRef from django.db.models.query import QuerySet from django.urls import reverse from django.utils import timezone @@ -172,27 +171,13 @@ class Meta: def _check_amounts_are_not_empty(grant: Grant, request): - if grant.total_amount is None: + if grant.total_allocated_amount == 0: messages.error( request, f"Grant for {grant.name} is missing 'Total Amount'!", ) return False - if grant.has_approved_accommodation() and grant.accommodation_amount is None: - messages.error( - request, - f"Grant for {grant.name} is missing 'Accommodation Amount'!", - ) - return False - - if grant.has_approved_travel() and grant.travel_amount is None: - messages.error( - request, - f"Grant for {grant.name} is missing 'Travel Amount'!", - ) - return False - return True @@ -216,10 +201,10 @@ def send_reply_emails(modeladmin, request, queryset): for grant in queryset: if grant.status in (Grant.Status.approved,): - if grant.approved_type is None: + if not grant.reimbursements.exists(): messages.error( request, - f"Grant for {grant.name} is missing 'Grant Approved Type'!", + f"Grant for {grant.name} is missing reimbursement categories!", ) return @@ -663,11 +648,6 @@ def get_queryset(self, request): requester_id=OuterRef("user_id"), ) ), - total_allocated_amount=Coalesce( - Sum("reimbursements__granted_amount"), - Value(0), - output_field=IntegerField(), - ), ) ) diff --git a/backend/grants/migrations/0030_remove_grant_accommodation_amount_and_more.py b/backend/grants/migrations/0030_remove_grant_accommodation_amount_and_more.py index 8aadd74ccf..95cbeb820d 100644 --- a/backend/grants/migrations/0030_remove_grant_accommodation_amount_and_more.py +++ b/backend/grants/migrations/0030_remove_grant_accommodation_amount_and_more.py @@ -132,7 +132,7 @@ def reverse_migration(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('conferences', '0055_remove_conference_grants_default_accommodation_amount_and_more'), + ('conferences', '0054_conference_frontend_revalidate_secret_and_more'), ('grants', '0029_alter_grant_pending_status'), ] diff --git a/backend/grants/summary.py b/backend/grants/summary.py index 7b211735cd..781d6289e8 100644 --- a/backend/grants/summary.py +++ b/backend/grants/summary.py @@ -44,7 +44,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_new( + financial_summary, total_amount = self._aggregate_financial_data_by_status( filtered_grants, statuses ) grant_type_summary = self._aggregate_data_by_grant_type( diff --git a/backend/grants/tasks.py b/backend/grants/tasks.py index 8c19e6e282..2ce8030fbf 100644 --- a/backend/grants/tasks.py +++ b/backend/grants/tasks.py @@ -1,17 +1,15 @@ +import logging from datetime import timedelta from urllib.parse import urljoin from django.conf import settings from django.utils import timezone -from notifications.models import EmailTemplate, EmailTemplateIdentifier -from users.models import User from grants.models import Grant from integrations import slack - -import logging - +from notifications.models import EmailTemplate, EmailTemplateIdentifier from pycon.celery import app +from users.models import User logger = logging.getLogger(__name__) @@ -32,7 +30,7 @@ def send_grant_reply_approved_email(*, grant_id, is_reminder): variables = { "reply_url": reply_url, "start_date": f"{grant.conference.start:%-d %B}", - "end_date": f"{grant.conference.end+timedelta(days=1):%-d %B}", + "end_date": f"{grant.conference.end + timedelta(days=1):%-d %B}", "deadline_date_time": f"{grant.applicant_reply_deadline:%-d %B %Y %H:%M %Z}", "deadline_date": f"{grant.applicant_reply_deadline:%-d %B %Y}", "visa_page_link": urljoin(settings.FRONTEND_URL, "/visa"), @@ -42,12 +40,19 @@ def send_grant_reply_approved_email(*, grant_id, is_reminder): } if grant.has_approved_travel(): - if not grant.travel_amount: + from grants.models import GrantReimbursementCategory + + travel_reimbursements = grant.reimbursements.filter( + category__category=GrantReimbursementCategory.Category.TRAVEL + ) + travel_amount = sum(r.granted_amount for r in travel_reimbursements) + + if not travel_amount or travel_amount == 0: raise ValueError( "Grant travel amount is set to Zero, can't send the email!" ) - variables["travel_amount"] = f"{grant.travel_amount:.0f}" + variables["travel_amount"] = f"{travel_amount:.0f}" _new_send_grant_email( template_identifier=EmailTemplateIdentifier.grant_approved, diff --git a/backend/grants/tests/factories.py b/backend/grants/tests/factories.py index bd716665f2..2f1486ca99 100644 --- a/backend/grants/tests/factories.py +++ b/backend/grants/tests/factories.py @@ -1,14 +1,16 @@ +import random +from decimal import Decimal + import factory.fuzzy from factory.django import DjangoModelFactory from conferences.tests.factories import ConferenceFactory -from grants.models import Grant -from helpers.constants import GENDERS -from users.tests.factories import UserFactory from countries import countries -from participants.tests.factories import ParticipantFactory +from grants.models import Grant, GrantReimbursement, GrantReimbursementCategory +from helpers.constants import GENDERS from participants.models import Participant -import random +from participants.tests.factories import ParticipantFactory +from users.tests.factories import UserFactory class GrantFactory(DjangoModelFactory): @@ -57,3 +59,54 @@ def _create(self, model_class, *args, **kwargs): ParticipantFactory(user_id=grant.user.id, conference=grant.conference) return grant + + +class GrantReimbursementCategoryFactory(DjangoModelFactory): + class Meta: + model = GrantReimbursementCategory + + conference = factory.SubFactory(ConferenceFactory) + name = factory.LazyAttribute( + lambda obj: GrantReimbursementCategory.Category(obj.category).label + ) + description = factory.Faker("sentence", nb_words=6) + max_amount = factory.fuzzy.FuzzyInteger(0, 1000) + category = factory.fuzzy.FuzzyChoice( + [choice[0] for choice in GrantReimbursementCategory.Category.choices] + ) + included_by_default = False + + class Params: + ticket = factory.Trait( + category=GrantReimbursementCategory.Category.TICKET, + name="Ticket", + max_amount=Decimal("100"), + included_by_default=True, + ) + travel = factory.Trait( + category=GrantReimbursementCategory.Category.TRAVEL, + name="Travel", + max_amount=Decimal("500"), + included_by_default=False, + ) + accommodation = factory.Trait( + category=GrantReimbursementCategory.Category.ACCOMMODATION, + name="Accommodation", + max_amount=Decimal("300"), + included_by_default=False, + ) + other = factory.Trait( + category=GrantReimbursementCategory.Category.OTHER, + name="Other", + max_amount=Decimal("200"), + included_by_default=False, + ) + + +class GrantReimbursementFactory(DjangoModelFactory): + class Meta: + model = GrantReimbursement + + grant = factory.SubFactory(GrantFactory) + category = factory.SubFactory(GrantReimbursementCategoryFactory) + granted_amount = factory.fuzzy.FuzzyInteger(0, 1000) diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index d394a1eff9..14a15183d8 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -1,21 +1,24 @@ from datetime import timedelta +from decimal import Decimal from unittest.mock import call -from conferences.models.conference_voucher import ConferenceVoucher -from conferences.tests.factories import ConferenceFactory, ConferenceVoucherFactory -from grants.tests.factories import GrantFactory import pytest from django.utils import timezone +from conferences.models.conference_voucher import ConferenceVoucher +from conferences.tests.factories import ConferenceFactory, ConferenceVoucherFactory from grants.admin import ( confirm_pending_status, create_grant_vouchers, + mark_rejected_and_send_email, reset_pending_status_back_to_status, send_reply_emails, - mark_rejected_and_send_email, ) from grants.models import Grant - +from grants.tests.factories import ( + GrantFactory, + GrantReimbursementFactory, +) pytestmark = pytest.mark.django_db @@ -60,9 +63,11 @@ def test_send_reply_emails_with_grants_from_multiple_conferences_fails( mock_send_rejected_email.assert_not_called() -def test_send_reply_emails_approved_grant_missing_approved_type(rf, mocker, admin_user): +def test_send_reply_emails_approved_grant_missing_reimbursements( + rf, mocker, admin_user +): mock_messages = mocker.patch("grants.admin.messages") - grant = GrantFactory(status=Grant.Status.approved, approved_type=None) + grant = GrantFactory(status=Grant.Status.approved) request = rf.get("/") request.user = admin_user mock_send_approved_email = mocker.patch( @@ -73,20 +78,21 @@ def test_send_reply_emails_approved_grant_missing_approved_type(rf, mocker, admi mock_messages.error.assert_called_once_with( request, - f"Grant for {grant.name} is missing 'Grant Approved Type'!", + f"Grant for {grant.name} is missing reimbursement categories!", ) mock_send_approved_email.assert_not_called() def test_send_reply_emails_approved_missing_amount(rf, mocker, admin_user): mock_messages = mocker.patch("grants.admin.messages") - grant = GrantFactory( - status=Grant.Status.approved, - approved_type=Grant.ApprovedType.ticket_accommodation, - total_amount=None, + grant = GrantFactory(status=Grant.Status.approved) + # Create reimbursement with 0 amount so total_allocated_amount is 0 + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__ticket=True, + granted_amount=Decimal("0"), ) - grant.total_amount = None - grant.save() request = rf.get("/") request.user = admin_user mock_send_approved_email = mocker.patch( @@ -106,10 +112,19 @@ def test_send_reply_emails_approved_set_deadline_in_fourteen_days( rf, mocker, admin_user ): mock_messages = mocker.patch("grants.admin.messages") - grant = GrantFactory( - status=Grant.Status.approved, - approved_type=Grant.ApprovedType.ticket_accommodation, - total_amount=800, + grant = GrantFactory(status=Grant.Status.approved) + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__ticket=True, + granted_amount=Decimal("100"), + ) + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__accommodation=True, + category__max_amount=Decimal("700"), + granted_amount=Decimal("700"), ) request = rf.get("/") request.user = admin_user diff --git a/backend/grants/tests/test_migration_backfill_grant_reimbursements.py b/backend/grants/tests/test_migration_backfill_grant_reimbursements.py deleted file mode 100644 index 097de1e4ca..0000000000 --- a/backend/grants/tests/test_migration_backfill_grant_reimbursements.py +++ /dev/null @@ -1,300 +0,0 @@ -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 diff --git a/backend/grants/tests/test_models.py b/backend/grants/tests/test_models.py index b85d9710d7..88a15e1bf1 100644 --- a/backend/grants/tests/test_models.py +++ b/backend/grants/tests/test_models.py @@ -2,8 +2,12 @@ import pytest -from grants.models import Grant, GrantReimbursement, GrantReimbursementCategory -from grants.tests.factories import GrantFactory +from grants.models import Grant, GrantReimbursement +from grants.tests.factories import ( + GrantFactory, + GrantReimbursementCategoryFactory, + GrantReimbursementFactory, +) pytestmark = pytest.mark.django_db @@ -59,48 +63,44 @@ def test_calculate_grant_amounts(data): pending_status=Grant.Status.approved, departure_country=departure_country, ) - conference = grant.conference - - ticket_category = GrantReimbursementCategory.objects.create( - conference=conference, - category=GrantReimbursementCategory.Category.TICKET, - name="Ticket", - max_amount=Decimal("100"), - included_by_default=True, - ) - travel_category = GrantReimbursementCategory.objects.create( - conference=conference, - category=GrantReimbursementCategory.Category.TRAVEL, - name="Travel", - max_amount=Decimal("500"), - included_by_default=False, - ) - accommodation_category = GrantReimbursementCategory.objects.create( - conference=conference, - category=GrantReimbursementCategory.Category.ACCOMMODATION, - name="Accommodation", - max_amount=Decimal("200"), - included_by_default=False, - ) - # Create reimbursements based on categories + # Create categories and reimbursements based on test data + ticket_category = None + travel_category = None + accommodation_category = None + if "ticket" in categories: - GrantReimbursement.objects.update_or_create( + ticket_category = GrantReimbursementCategoryFactory( + conference=grant.conference, + ticket=True, + max_amount=Decimal("100"), + ) + GrantReimbursementFactory( grant=grant, category=ticket_category, - defaults={"granted_amount": Decimal(expected_ticket_amount)}, + granted_amount=Decimal(expected_ticket_amount), ) if "travel" in categories: - GrantReimbursement.objects.update_or_create( + travel_category = GrantReimbursementCategoryFactory( + conference=grant.conference, + travel=True, + max_amount=Decimal("500"), + ) + GrantReimbursementFactory( grant=grant, category=travel_category, - defaults={"granted_amount": Decimal(expected_travel_amount)}, + granted_amount=Decimal(expected_travel_amount), ) if "accommodation" in categories: - GrantReimbursement.objects.update_or_create( + accommodation_category = GrantReimbursementCategoryFactory( + conference=grant.conference, + accommodation=True, + max_amount=Decimal("200"), + ) + GrantReimbursementFactory( grant=grant, category=accommodation_category, - defaults={"granted_amount": Decimal(expected_accommodation_amount)}, + granted_amount=Decimal(expected_accommodation_amount), ) grant.refresh_from_db() @@ -113,7 +113,7 @@ def test_calculate_grant_amounts(data): assert ticket_reimbursement.granted_amount == Decimal(expected_ticket_amount) else: assert not GrantReimbursement.objects.filter( - grant=grant, category=ticket_category + grant=grant, category__category="ticket" ).exists() if "travel" in categories: @@ -123,7 +123,7 @@ def test_calculate_grant_amounts(data): assert travel_reimbursement.granted_amount == Decimal(expected_travel_amount) else: assert not GrantReimbursement.objects.filter( - grant=grant, category=travel_category + grant=grant, category__category="travel" ).exists() if "accommodation" in categories: @@ -135,7 +135,7 @@ def test_calculate_grant_amounts(data): ) else: assert not GrantReimbursement.objects.filter( - grant=grant, category=accommodation_category + grant=grant, category__category="accommodation" ).exists() # Verify total_allocated_amount sums correctly @@ -147,16 +147,10 @@ def test_calculate_grant_amounts(data): def test_has_approved_travel(): grant = GrantFactory() - travel_category = GrantReimbursementCategory.objects.create( - conference=grant.conference, - category=GrantReimbursementCategory.Category.TRAVEL, - name="Travel", - max_amount=Decimal("500"), - included_by_default=False, - ) - GrantReimbursement.objects.create( + GrantReimbursementFactory( grant=grant, - category=travel_category, + category__conference=grant.conference, + category__travel=True, granted_amount=Decimal("500"), ) @@ -165,16 +159,10 @@ def test_has_approved_travel(): def test_has_approved_accommodation(): grant = GrantFactory() - accommodation_category = GrantReimbursementCategory.objects.create( - conference=grant.conference, - category=GrantReimbursementCategory.Category.ACCOMMODATION, - name="Accommodation", - max_amount=Decimal("200"), - included_by_default=False, - ) - GrantReimbursement.objects.create( + GrantReimbursementFactory( grant=grant, - category=accommodation_category, + category__conference=grant.conference, + category__accommodation=True, granted_amount=Decimal("200"), ) @@ -233,6 +221,7 @@ def test_doesnt_sync_pending_status_if_different_values(): assert grant.pending_status == Grant.Status.refused assert grant.status == Grant.Status.waiting_for_confirmation + @pytest.mark.skip(reason="We don't automatically create on save anymore") def test_pending_status_none_means_no_pending_change(): grant = GrantFactory( diff --git a/backend/grants/tests/test_tasks.py b/backend/grants/tests/test_tasks.py index a19bc3df93..2ca2e1b001 100644 --- a/backend/grants/tests/test_tasks.py +++ b/backend/grants/tests/test_tasks.py @@ -1,26 +1,28 @@ from datetime import datetime, timezone -from unittest.mock import patch -from conferences.tests.factories import ConferenceFactory, DeadlineFactory +from decimal import Decimal import pytest -from users.tests.factories import UserFactory -from grants.tests.factories import GrantFactory +from conferences.tests.factories import ConferenceFactory, DeadlineFactory from grants.tasks import ( - send_grant_reply_waiting_list_update_email, send_grant_reply_approved_email, send_grant_reply_rejected_email, send_grant_reply_waiting_list_email, + send_grant_reply_waiting_list_update_email, ) -from grants.models import Grant +from grants.tests.factories import ( + GrantFactory, + GrantReimbursementFactory, +) +from users.tests.factories import UserFactory pytestmark = pytest.mark.django_db def test_send_grant_reply_rejected_email(sent_emails): - from notifications.tests.factories import EmailTemplateFactory from notifications.models import EmailTemplateIdentifier - + from notifications.tests.factories import EmailTemplateFactory + user = UserFactory( full_name="Marco Acierno", email="marco@placeholder.it", @@ -28,7 +30,7 @@ def test_send_grant_reply_rejected_email(sent_emails): username="marco", ) grant = GrantFactory(user=user) - + EmailTemplateFactory( conference=grant.conference, identifier=EmailTemplateIdentifier.grant_rejected, @@ -39,21 +41,25 @@ def test_send_grant_reply_rejected_email(sent_emails): # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.grant_rejected + assert ( + sent_email.email_template.identifier == EmailTemplateIdentifier.grant_rejected + ) assert sent_email.email_template.conference == grant.conference assert sent_email.recipient == user - + # Verify placeholders were processed correctly assert sent_email.placeholders["user_name"] == "Marco Acierno" - assert sent_email.placeholders["conference_name"] == grant.conference.name.localize("en") + assert sent_email.placeholders["conference_name"] == grant.conference.name.localize( + "en" + ) def test_send_grant_reply_waiting_list_email(settings, sent_emails): - from notifications.tests.factories import EmailTemplateFactory from notifications.models import EmailTemplateIdentifier - + from notifications.tests.factories import EmailTemplateFactory + conference = ConferenceFactory() settings.FRONTEND_URL = "https://pycon.it" @@ -74,7 +80,7 @@ def test_send_grant_reply_waiting_list_email(settings, sent_emails): }, ) grant = GrantFactory(conference=conference, user=user) - + EmailTemplateFactory( conference=grant.conference, identifier=EmailTemplateIdentifier.grant_waiting_list, @@ -85,23 +91,28 @@ def test_send_grant_reply_waiting_list_email(settings, sent_emails): # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.grant_waiting_list + assert ( + sent_email.email_template.identifier + == EmailTemplateIdentifier.grant_waiting_list + ) assert sent_email.email_template.conference == grant.conference assert sent_email.recipient == user - + # Verify placeholders were processed correctly assert sent_email.placeholders["user_name"] == "Marco Acierno" - assert sent_email.placeholders["conference_name"] == grant.conference.name.localize("en") + assert sent_email.placeholders["conference_name"] == grant.conference.name.localize( + "en" + ) assert sent_email.placeholders["grants_update_deadline"] == "1 March 2023" assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/" def test_handle_grant_reply_sent_reminder(settings, sent_emails): - from notifications.tests.factories import EmailTemplateFactory from notifications.models import EmailTemplateIdentifier - + from notifications.tests.factories import EmailTemplateFactory + settings.FRONTEND_URL = "https://pycon.it" conference = ConferenceFactory( start=datetime(2023, 5, 2, tzinfo=timezone.utc), @@ -115,12 +126,17 @@ def test_handle_grant_reply_sent_reminder(settings, sent_emails): ) grant = GrantFactory( conference=conference, - approved_type=Grant.ApprovedType.ticket_only, applicant_reply_deadline=datetime(2023, 2, 1, 23, 59, tzinfo=timezone.utc), - total_amount=680, user=user, ) - + GrantReimbursementFactory( + grant=grant, + category__conference=conference, + category__ticket=True, + category__max_amount=Decimal("680"), + granted_amount=Decimal("680"), + ) + EmailTemplateFactory( conference=grant.conference, identifier=EmailTemplateIdentifier.grant_approved, @@ -131,30 +147,36 @@ def test_handle_grant_reply_sent_reminder(settings, sent_emails): # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.grant_approved + assert ( + sent_email.email_template.identifier == EmailTemplateIdentifier.grant_approved + ) assert sent_email.email_template.conference == grant.conference assert sent_email.recipient == user - + # Verify placeholders were processed correctly assert sent_email.placeholders["user_name"] == "Marco Acierno" - assert sent_email.placeholders["conference_name"] == grant.conference.name.localize("en") + assert sent_email.placeholders["conference_name"] == grant.conference.name.localize( + "en" + ) assert sent_email.placeholders["start_date"] == "2 May" assert sent_email.placeholders["end_date"] == "6 May" assert sent_email.placeholders["deadline_date_time"] == "1 February 2023 23:59 UTC" assert sent_email.placeholders["deadline_date"] == "1 February 2023" assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/" assert sent_email.placeholders["visa_page_link"] == "https://pycon.it/visa" - assert sent_email.placeholders["has_approved_travel"] == False - assert sent_email.placeholders["has_approved_accommodation"] == False - assert sent_email.placeholders["is_reminder"] == True + assert not sent_email.placeholders["has_approved_travel"] + assert not sent_email.placeholders["has_approved_accommodation"] + assert sent_email.placeholders["is_reminder"] -def test_handle_grant_approved_ticket_travel_accommodation_reply_sent(settings, sent_emails): - from notifications.tests.factories import EmailTemplateFactory +def test_handle_grant_approved_ticket_travel_accommodation_reply_sent( + settings, sent_emails +): from notifications.models import EmailTemplateIdentifier - + from notifications.tests.factories import EmailTemplateFactory + settings.FRONTEND_URL = "https://pycon.it" conference = ConferenceFactory( @@ -170,12 +192,29 @@ def test_handle_grant_approved_ticket_travel_accommodation_reply_sent(settings, grant = GrantFactory( conference=conference, - approved_type=Grant.ApprovedType.ticket_travel_accommodation, applicant_reply_deadline=datetime(2023, 2, 1, 23, 59, tzinfo=timezone.utc), - travel_amount=680, user=user, ) - + GrantReimbursementFactory( + grant=grant, + category__conference=conference, + category__ticket=True, + granted_amount=Decimal("100"), + ) + GrantReimbursementFactory( + grant=grant, + category__conference=conference, + category__travel=True, + category__max_amount=Decimal("680"), + granted_amount=Decimal("680"), + ) + GrantReimbursementFactory( + grant=grant, + category__conference=conference, + category__accommodation=True, + granted_amount=Decimal("200"), + ) + EmailTemplateFactory( conference=grant.conference, identifier=EmailTemplateIdentifier.grant_approved, @@ -186,15 +225,19 @@ def test_handle_grant_approved_ticket_travel_accommodation_reply_sent(settings, # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.grant_approved + assert ( + sent_email.email_template.identifier == EmailTemplateIdentifier.grant_approved + ) assert sent_email.email_template.conference == grant.conference assert sent_email.recipient == user - + # Verify placeholders were processed correctly assert sent_email.placeholders["user_name"] == "Marco Acierno" - assert sent_email.placeholders["conference_name"] == grant.conference.name.localize("en") + assert sent_email.placeholders["conference_name"] == grant.conference.name.localize( + "en" + ) assert sent_email.placeholders["start_date"] == "2 May" assert sent_email.placeholders["end_date"] == "6 May" assert sent_email.placeholders["travel_amount"] == "680" @@ -202,9 +245,9 @@ def test_handle_grant_approved_ticket_travel_accommodation_reply_sent(settings, assert sent_email.placeholders["deadline_date"] == "1 February 2023" assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/" assert sent_email.placeholders["visa_page_link"] == "https://pycon.it/visa" - assert sent_email.placeholders["has_approved_travel"] == True - assert sent_email.placeholders["has_approved_accommodation"] == True - assert sent_email.placeholders["is_reminder"] == False + assert sent_email.placeholders["has_approved_travel"] + assert sent_email.placeholders["has_approved_accommodation"] + assert not sent_email.placeholders["is_reminder"] def test_handle_grant_approved_ticket_travel_accommodation_fails_with_no_amount( @@ -225,11 +268,16 @@ def test_handle_grant_approved_ticket_travel_accommodation_fails_with_no_amount( grant = GrantFactory( conference=conference, - approved_type=Grant.ApprovedType.ticket_travel_accommodation, applicant_reply_deadline=datetime(2023, 2, 1, 23, 59, tzinfo=timezone.utc), - travel_amount=0, user=user, ) + GrantReimbursementFactory( + grant=grant, + category__conference=conference, + category__travel=True, + category__max_amount=Decimal("680"), + granted_amount=Decimal("0"), + ) with pytest.raises( ValueError, match="Grant travel amount is set to Zero, can't send the email!" @@ -238,9 +286,9 @@ def test_handle_grant_approved_ticket_travel_accommodation_fails_with_no_amount( def test_handle_grant_approved_ticket_only_reply_sent(settings, sent_emails): - from notifications.tests.factories import EmailTemplateFactory from notifications.models import EmailTemplateIdentifier - + from notifications.tests.factories import EmailTemplateFactory + settings.FRONTEND_URL = "https://pycon.it" conference = ConferenceFactory( @@ -256,12 +304,17 @@ def test_handle_grant_approved_ticket_only_reply_sent(settings, sent_emails): grant = GrantFactory( conference=conference, - approved_type=Grant.ApprovedType.ticket_only, applicant_reply_deadline=datetime(2023, 2, 1, 23, 59, tzinfo=timezone.utc), - total_amount=680, user=user, ) - + GrantReimbursementFactory( + grant=grant, + category__conference=conference, + category__ticket=True, + category__max_amount=Decimal("680"), + granted_amount=Decimal("680"), + ) + EmailTemplateFactory( conference=grant.conference, identifier=EmailTemplateIdentifier.grant_approved, @@ -272,30 +325,34 @@ def test_handle_grant_approved_ticket_only_reply_sent(settings, sent_emails): # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.grant_approved + assert ( + sent_email.email_template.identifier == EmailTemplateIdentifier.grant_approved + ) assert sent_email.email_template.conference == grant.conference assert sent_email.recipient == user - + # Verify placeholders were processed correctly assert sent_email.placeholders["user_name"] == "Marco Acierno" - assert sent_email.placeholders["conference_name"] == grant.conference.name.localize("en") + assert sent_email.placeholders["conference_name"] == grant.conference.name.localize( + "en" + ) assert sent_email.placeholders["start_date"] == "2 May" assert sent_email.placeholders["end_date"] == "6 May" assert sent_email.placeholders["deadline_date_time"] == "1 February 2023 23:59 UTC" assert sent_email.placeholders["deadline_date"] == "1 February 2023" assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/" assert sent_email.placeholders["visa_page_link"] == "https://pycon.it/visa" - assert sent_email.placeholders["has_approved_travel"] == False - assert sent_email.placeholders["has_approved_accommodation"] == False - assert sent_email.placeholders["is_reminder"] == False + assert not sent_email.placeholders["has_approved_travel"] + assert not sent_email.placeholders["has_approved_accommodation"] + assert not sent_email.placeholders["is_reminder"] def test_handle_grant_approved_travel_reply_sent(settings, sent_emails): - from notifications.tests.factories import EmailTemplateFactory from notifications.models import EmailTemplateIdentifier - + from notifications.tests.factories import EmailTemplateFactory + settings.FRONTEND_URL = "https://pycon.it" conference = ConferenceFactory( @@ -311,13 +368,24 @@ def test_handle_grant_approved_travel_reply_sent(settings, sent_emails): grant = GrantFactory( conference=conference, - approved_type=Grant.ApprovedType.ticket_travel, applicant_reply_deadline=datetime(2023, 2, 1, 23, 59, tzinfo=timezone.utc), - total_amount=680, - travel_amount=400, user=user, ) - + GrantReimbursementFactory( + grant=grant, + category__conference=conference, + category__ticket=True, + category__max_amount=Decimal("280"), + granted_amount=Decimal("280"), + ) + GrantReimbursementFactory( + grant=grant, + category__conference=conference, + category__travel=True, + category__max_amount=Decimal("400"), + granted_amount=Decimal("400"), + ) + EmailTemplateFactory( conference=grant.conference, identifier=EmailTemplateIdentifier.grant_approved, @@ -328,31 +396,35 @@ def test_handle_grant_approved_travel_reply_sent(settings, sent_emails): # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.grant_approved + assert ( + sent_email.email_template.identifier == EmailTemplateIdentifier.grant_approved + ) assert sent_email.email_template.conference == grant.conference assert sent_email.recipient == user - + # Verify placeholders were processed correctly assert sent_email.placeholders["user_name"] == "Marco Acierno" - assert sent_email.placeholders["conference_name"] == grant.conference.name.localize("en") + assert sent_email.placeholders["conference_name"] == grant.conference.name.localize( + "en" + ) assert sent_email.placeholders["start_date"] == "2 May" assert sent_email.placeholders["end_date"] == "6 May" assert sent_email.placeholders["deadline_date_time"] == "1 February 2023 23:59 UTC" assert sent_email.placeholders["deadline_date"] == "1 February 2023" assert sent_email.placeholders["reply_url"] == "https://pycon.it/grants/reply/" assert sent_email.placeholders["visa_page_link"] == "https://pycon.it/visa" - assert sent_email.placeholders["has_approved_travel"] == True - assert sent_email.placeholders["has_approved_accommodation"] == False + assert sent_email.placeholders["has_approved_travel"] + assert not sent_email.placeholders["has_approved_accommodation"] assert sent_email.placeholders["travel_amount"] == "400" - assert sent_email.placeholders["is_reminder"] == False + assert not sent_email.placeholders["is_reminder"] def test_send_grant_reply_waiting_list_update_email(settings, sent_emails): - from notifications.tests.factories import EmailTemplateFactory from notifications.models import EmailTemplateIdentifier - + from notifications.tests.factories import EmailTemplateFactory + settings.FRONTEND_URL = "https://pycon.it" user = UserFactory( full_name="Marco Acierno", @@ -371,7 +443,7 @@ def test_send_grant_reply_waiting_list_update_email(settings, sent_emails): }, ) conference_name = grant.conference.name.localize("en") - + EmailTemplateFactory( conference=grant.conference, identifier=EmailTemplateIdentifier.grant_waiting_list_update, @@ -384,12 +456,15 @@ def test_send_grant_reply_waiting_list_update_email(settings, sent_emails): # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.grant_waiting_list_update + assert ( + sent_email.email_template.identifier + == EmailTemplateIdentifier.grant_waiting_list_update + ) assert sent_email.email_template.conference == grant.conference assert sent_email.recipient == user - + # Verify placeholders were processed correctly assert sent_email.placeholders["user_name"] == "Marco Acierno" assert sent_email.placeholders["conference_name"] == conference_name diff --git a/backend/integrations/plain_cards.py b/backend/integrations/plain_cards.py index dd9c31e4d2..643727c842 100644 --- a/backend/integrations/plain_cards.py +++ b/backend/integrations/plain_cards.py @@ -56,8 +56,11 @@ def create_grant_card(request, user, conference): "componentText": { "textColor": "NORMAL", "text": ( - grant.get_approved_type_display() - if grant.approved_type + ", ".join( + r.category.name + for r in grant.reimbursements.all() + ) + if grant.reimbursements.exists() else "Empty" ), } @@ -81,7 +84,7 @@ def create_grant_card(request, user, conference): { "componentText": { "textColor": "NORMAL", - "text": f"€{grant.travel_amount}", + "text": f"€{sum(r.granted_amount for r in grant.reimbursements.filter(category__category='travel'))}", } } ], diff --git a/backend/integrations/tests/test_views.py b/backend/integrations/tests/test_views.py index 5672bea6ee..f2900cbc1e 100644 --- a/backend/integrations/tests/test_views.py +++ b/backend/integrations/tests/test_views.py @@ -1,11 +1,14 @@ -from conferences.tests.factories import ConferenceFactory +from decimal import Decimal + +import pytest +from django.test import override_settings from django.urls import reverse + +from conferences.tests.factories import ConferenceFactory from grants.models import Grant -from grants.tests.factories import GrantFactory +from grants.tests.factories import GrantFactory, GrantReimbursementFactory from integrations.plain_cards import _grant_status_to_color -import pytest from users.tests.factories import UserFactory -from django.test import override_settings pytestmark = pytest.mark.django_db @@ -143,10 +146,24 @@ def test_get_plain_customer_with_no_cards(rest_api_client): @override_settings(PLAIN_INTEGRATION_TOKEN="secret") def test_get_plain_customer_cards_grant_card(rest_api_client): user = UserFactory() - grant = GrantFactory( - user=user, - approved_type=Grant.ApprovedType.ticket_travel_accommodation, - travel_amount=100, + grant = GrantFactory(user=user) + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__ticket=True, + granted_amount=Decimal("100"), + ) + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__travel=True, + granted_amount=Decimal("100"), + ) + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__accommodation=True, + granted_amount=Decimal("200"), ) conference_id = grant.conference_id rest_api_client.token_auth("secret") @@ -180,28 +197,31 @@ def test_get_plain_customer_cards_grant_card(rest_api_client): == grant.get_status_display() ) - assert ( - grant_card["components"][2]["componentRow"]["rowAsideContent"][0][ - "componentText" - ]["text"] - == grant.get_approved_type_display() - ) + # Check that reimbursement category names are displayed + approval_text = grant_card["components"][2]["componentRow"]["rowAsideContent"][0][ + "componentText" + ]["text"] + assert "Ticket" in approval_text + assert "Travel" in approval_text + assert "Accommodation" in approval_text assert ( grant_card["components"][4]["componentRow"]["rowAsideContent"][0][ "componentText" ]["text"] - == "€100.00" + == "€100" ) @override_settings(PLAIN_INTEGRATION_TOKEN="secret") def test_get_plain_customer_cards_grant_card_with_no_travel(rest_api_client): user = UserFactory() - grant = GrantFactory( - user=user, - approved_type=Grant.ApprovedType.ticket_only, - travel_amount=100, + grant = GrantFactory(user=user) + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__ticket=True, + granted_amount=Decimal("100"), ) conference_id = grant.conference_id rest_api_client.token_auth("secret") @@ -235,12 +255,11 @@ def test_get_plain_customer_cards_grant_card_with_no_travel(rest_api_client): == grant.get_status_display() ) - assert ( - grant_card["components"][2]["componentRow"]["rowAsideContent"][0][ - "componentText" - ]["text"] - == grant.get_approved_type_display() - ) + # Check that only Ticket is displayed (no travel) + approval_text = grant_card["components"][2]["componentRow"]["rowAsideContent"][0][ + "componentText" + ]["text"] + assert approval_text == "Ticket" assert "Travel amount" not in str(grant_card) diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index 7fe0ddd856..8ab73fa578 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -4,8 +4,12 @@ from django.contrib.admin import AdminSite from conferences.tests.factories import ConferenceFactory -from grants.models import Grant, GrantReimbursement, GrantReimbursementCategory -from grants.tests.factories import GrantFactory +from grants.models import Grant +from grants.tests.factories import ( + GrantFactory, + GrantReimbursementCategoryFactory, + GrantReimbursementFactory, +) from reviews.admin import ReviewSessionAdmin, get_next_to_review_item_id from reviews.models import ReviewSession from reviews.tests.factories import ( @@ -281,26 +285,20 @@ def test_save_review_grants_updates_grant_and_creates_reimbursements(rf, mocker) conference = ConferenceFactory() # Create reimbursement categories - travel_category = GrantReimbursementCategory.objects.create( + travel_category = GrantReimbursementCategoryFactory( conference=conference, - category=GrantReimbursementCategory.Category.TRAVEL, - name="Travel", + travel=True, max_amount=Decimal("500"), - included_by_default=False, ) - ticket_category = GrantReimbursementCategory.objects.create( + ticket_category = GrantReimbursementCategoryFactory( conference=conference, - category=GrantReimbursementCategory.Category.TICKET, - name="Ticket", + ticket=True, max_amount=Decimal("100"), - included_by_default=True, ) - accommodation_category = GrantReimbursementCategory.objects.create( + accommodation_category = GrantReimbursementCategoryFactory( conference=conference, - category=GrantReimbursementCategory.Category.ACCOMMODATION, - name="Accommodation", + accommodation=True, max_amount=Decimal("200"), - included_by_default=False, ) # Create review session for grants @@ -322,9 +320,16 @@ def test_save_review_grants_updates_grant_and_creates_reimbursements(rf, mocker) # would need to use request.POST.getlist(). Testing with one category per grant. post_data = { f"decision-{grant_1.id}": Grant.Status.approved, - f"reimbursementcategory-{grant_1.id}": [str(ticket_category.id), str(travel_category.id)], + f"reimbursementcategory-{grant_1.id}": [ + str(ticket_category.id), + str(travel_category.id), + ], f"decision-{grant_2.id}": Grant.Status.approved, - f"reimbursementcategory-{grant_2.id}": [str(ticket_category.id), str(travel_category.id),str(accommodation_category.id),] + f"reimbursementcategory-{grant_2.id}": [ + str(ticket_category.id), + str(travel_category.id), + str(accommodation_category.id), + ], } request = rf.post("/", data=post_data) @@ -350,41 +355,41 @@ def test_save_review_grants_updates_grant_and_creates_reimbursements(rf, mocker) # Verify GrantReimbursement objects were created assert grant_1.reimbursements.count() == 2 - assert { reimbursement.category for reimbursement in grant_1.reimbursements.all() } == { ticket_category, travel_category } + assert { + reimbursement.category for reimbursement in grant_1.reimbursements.all() + } == {ticket_category, travel_category} assert grant_2.reimbursements.count() == 3 - assert { reimbursement.category for reimbursement in grant_2.reimbursements.all() } == { ticket_category, travel_category, accommodation_category } + assert { + reimbursement.category for reimbursement in grant_2.reimbursements.all() + } == {ticket_category, travel_category, accommodation_category} mock_messages.success.assert_called_once() -def test_save_review_grants_update_grants_status_to_rejected_removes_reimbursements(rf, mocker): +def test_save_review_grants_update_grants_status_to_rejected_removes_reimbursements( + rf, mocker +): mock_messages = mocker.patch("reviews.admin.messages") user = UserFactory(is_staff=True, is_superuser=True) conference = ConferenceFactory() # Create reimbursement categories - travel_category = GrantReimbursementCategory.objects.create( + travel_category = GrantReimbursementCategoryFactory( conference=conference, - category=GrantReimbursementCategory.Category.TRAVEL, - name="Travel", + travel=True, max_amount=Decimal("500"), - included_by_default=False, ) - ticket_category = GrantReimbursementCategory.objects.create( + ticket_category = GrantReimbursementCategoryFactory( conference=conference, - category=GrantReimbursementCategory.Category.TICKET, - name="Ticket", + ticket=True, max_amount=Decimal("100"), - included_by_default=True, ) - accommodation_category = GrantReimbursementCategory.objects.create( + accommodation_category = GrantReimbursementCategoryFactory( conference=conference, - category=GrantReimbursementCategory.Category.ACCOMMODATION, - name="Accommodation", + accommodation=True, max_amount=Decimal("200"), - included_by_default=False, ) # Create review session for grants @@ -398,17 +403,17 @@ def test_save_review_grants_update_grants_status_to_rejected_removes_reimburseme # Create grants with initial status grant_1 = GrantFactory(conference=conference, status=Grant.Status.approved) - GrantReimbursement.objects.create( + GrantReimbursementFactory( grant=grant_1, category=travel_category, granted_amount=Decimal("500"), ) - GrantReimbursement.objects.create( + GrantReimbursementFactory( grant=grant_1, category=ticket_category, granted_amount=Decimal("100"), ) - GrantReimbursement.objects.create( + GrantReimbursementFactory( grant=grant_1, category=accommodation_category, granted_amount=Decimal("200"), @@ -438,6 +443,7 @@ def test_save_review_grants_update_grants_status_to_rejected_removes_reimburseme assert grant_1.reimbursements.count() == 0 + def test_save_review_grants_modify_reimbursements(rf, mocker): mock_messages = mocker.patch("reviews.admin.messages") @@ -445,26 +451,20 @@ def test_save_review_grants_modify_reimbursements(rf, mocker): conference = ConferenceFactory() # Create reimbursement categories - travel_category = GrantReimbursementCategory.objects.create( + travel_category = GrantReimbursementCategoryFactory( conference=conference, - category=GrantReimbursementCategory.Category.TRAVEL, - name="Travel", + travel=True, max_amount=Decimal("500"), - included_by_default=False, ) - ticket_category = GrantReimbursementCategory.objects.create( + ticket_category = GrantReimbursementCategoryFactory( conference=conference, - category=GrantReimbursementCategory.Category.TICKET, - name="Ticket", + ticket=True, max_amount=Decimal("100"), - included_by_default=True, ) - accommodation_category = GrantReimbursementCategory.objects.create( + accommodation_category = GrantReimbursementCategoryFactory( conference=conference, - category=GrantReimbursementCategory.Category.ACCOMMODATION, - name="Accommodation", + accommodation=True, max_amount=Decimal("200"), - included_by_default=False, ) # Create review session for grants @@ -478,17 +478,17 @@ def test_save_review_grants_modify_reimbursements(rf, mocker): # Create grants with initial status grant_1 = GrantFactory(conference=conference, status=Grant.Status.approved) - GrantReimbursement.objects.create( + GrantReimbursementFactory( grant=grant_1, category=travel_category, granted_amount=Decimal("500"), ) - GrantReimbursement.objects.create( + GrantReimbursementFactory( grant=grant_1, category=ticket_category, granted_amount=Decimal("100"), ) - GrantReimbursement.objects.create( + GrantReimbursementFactory( grant=grant_1, category=accommodation_category, granted_amount=Decimal("200"), @@ -509,4 +509,6 @@ def test_save_review_grants_modify_reimbursements(rf, mocker): grant_1.refresh_from_db() assert grant_1.reimbursements.count() == 1 - assert { reimbursement.category for reimbursement in grant_1.reimbursements.all() } == { ticket_category } + assert { + reimbursement.category for reimbursement in grant_1.reimbursements.all() + } == {ticket_category} diff --git a/backend/visa/models.py b/backend/visa/models.py index 9111e25c83..e3ecee272f 100644 --- a/backend/visa/models.py +++ b/backend/visa/models.py @@ -1,16 +1,16 @@ from functools import cached_property -from django.db import transaction +from django.core.files.storage import storages +from django.db import models, transaction +from django.db.models import Q, UniqueConstraint +from django.utils.translation import gettext_lazy as _ +from model_utils.models import TimeStampedModel +from ordered_model.models import OrderedModel + +from grants.models import Grant, GrantReimbursementCategory from submissions.models import Submission from users.models import User -from grants.models import Grant -from ordered_model.models import OrderedModel from visa.managers import InvitationLetterRequestQuerySet -from model_utils.models import TimeStampedModel -from django.db import models -from django.db.models import UniqueConstraint, Q -from django.utils.translation import gettext_lazy as _ -from django.core.files.storage import storages class InvitationLetterRequestStatus(models.TextChoices): @@ -102,13 +102,25 @@ def has_travel_via_grant(self): return grant.has_approved_travel() @property - def grant_approved_type(self): + def grant_approved_type(self) -> str | None: grant = self.user_grant if not grant: return None - return grant.approved_type + # Return a string representation of approved reimbursement categories + categories = [] + if grant.has_approved_travel(): + categories.append("travel") + if grant.has_approved_accommodation(): + categories.append("accommodation") + if grant.has_approved(GrantReimbursementCategory.Category.TICKET): + categories.append("ticket") + + if not categories: + return None + + return "_".join(sorted(categories)) if len(categories) > 1 else categories[0] @cached_property def user_grant(self): diff --git a/backend/visa/tests/test_models.py b/backend/visa/tests/test_models.py index 913c942f0a..53cc436df1 100644 --- a/backend/visa/tests/test_models.py +++ b/backend/visa/tests/test_models.py @@ -1,11 +1,16 @@ +from decimal import Decimal + +import pytest + +from grants.tests.factories import ( + GrantFactory, + GrantReimbursementFactory, +) from submissions.models import Submission from submissions.tests.factories import SubmissionFactory -from grants.models import Grant -from grants.tests.factories import GrantFactory from users.tests.factories import UserFactory from visa.models import InvitationLetterRequestOnBehalfOf from visa.tests.factories import InvitationLetterRequestFactory -import pytest pytestmark = pytest.mark.django_db @@ -28,15 +33,22 @@ def test_request_on_behalf_of_other(): @pytest.mark.parametrize( - "approved_type", + "categories,expected_has_accommodation,expected_has_travel,expected_type", [ - Grant.ApprovedType.ticket_accommodation, - Grant.ApprovedType.ticket_only, - Grant.ApprovedType.ticket_travel, - Grant.ApprovedType.ticket_travel_accommodation, + (["ticket", "accommodation"], True, False, "accommodation_ticket"), + (["ticket"], False, False, "ticket"), + (["ticket", "travel"], False, True, "ticket_travel"), + ( + ["ticket", "travel", "accommodation"], + True, + True, + "accommodation_ticket_travel", + ), ], ) -def test_request_grant_info(approved_type): +def test_request_grant_info( + categories, expected_has_accommodation, expected_has_travel, expected_type +): request = InvitationLetterRequestFactory( on_behalf_of=InvitationLetterRequestOnBehalfOf.SELF, email_address="example@example.org", @@ -44,26 +56,37 @@ def test_request_grant_info(approved_type): grant = GrantFactory( conference=request.conference, user=request.requester, - approved_type=approved_type, ) + # Create reimbursements based on categories + if "ticket" in categories: + GrantReimbursementFactory( + grant=grant, + category__conference=request.conference, + category__ticket=True, + granted_amount=Decimal("100"), + ) + if "travel" in categories: + GrantReimbursementFactory( + grant=grant, + category__conference=request.conference, + category__travel=True, + granted_amount=Decimal("500"), + ) + if "accommodation" in categories: + GrantReimbursementFactory( + grant=grant, + category__conference=request.conference, + category__accommodation=True, + granted_amount=Decimal("200"), + ) + assert request.user_grant == grant assert request.has_grant is True - assert request.has_accommodation_via_grant() == ( - approved_type - in [ - Grant.ApprovedType.ticket_accommodation, - Grant.ApprovedType.ticket_travel_accommodation, - ] - ) - assert request.has_travel_via_grant() == ( - approved_type - in [ - Grant.ApprovedType.ticket_travel, - Grant.ApprovedType.ticket_travel_accommodation, - ] - ) - assert request.grant_approved_type == approved_type + assert request.has_accommodation_via_grant() == expected_has_accommodation + assert request.has_travel_via_grant() == expected_has_travel + # grant_approved_type returns sorted categories joined by underscore + assert request.grant_approved_type == expected_type def test_role_for_speakers(): diff --git a/backend/visa/tests/test_tasks.py b/backend/visa/tests/test_tasks.py index c90a6a788d..b71313cd0d 100644 --- a/backend/visa/tests/test_tasks.py +++ b/backend/visa/tests/test_tasks.py @@ -1,18 +1,22 @@ -from django.urls import reverse -from django.core.signing import Signer - -from unittest.mock import patch +from decimal import Decimal from uuid import uuid4 + +import pytest import requests +from django.core.signing import Signer +from django.test import override_settings +from django.urls import reverse +from pypdf import PdfReader + +from grants.tests.factories import ( + GrantFactory, + GrantReimbursementFactory, +) from notifications.models import EmailTemplateIdentifier -from grants.tests.factories import GrantFactory -from grants.models import Grant from visa.models import ( InvitationLetterDocumentInclusionPolicy, InvitationLetterRequestStatus, ) -import pytest -from django.test import override_settings from visa.tasks import ( notify_new_invitation_letter_request_on_slack, process_invitation_letter_request, @@ -21,11 +25,10 @@ ) from visa.tests.factories import ( InvitationLetterAssetFactory, - InvitationLetterDocumentFactory, InvitationLetterConferenceConfigFactory, + InvitationLetterDocumentFactory, InvitationLetterRequestFactory, ) -from pypdf import PdfReader pytestmark = pytest.mark.django_db @@ -126,12 +129,12 @@ def test_process_invitation_letter_request(requests_mock, mock_ticket_present): @pytest.mark.parametrize( - "grant_approved_type", - [None, Grant.ApprovedType.ticket_only, Grant.ApprovedType.ticket_travel], + "has_ticket,has_travel", + [(False, False), (True, False), (True, True)], ) @override_settings(PRETIX_API="https://pretix/api/") def test_process_invitation_letter_request_accomodation_doc_with_no_accommodation( - mock_ticket_present, grant_approved_type + mock_ticket_present, has_ticket, has_travel ): config = InvitationLetterConferenceConfigFactory() InvitationLetterDocumentFactory( @@ -153,12 +156,25 @@ def test_process_invitation_letter_request_accomodation_doc_with_no_accommodatio ) mock_ticket_present(request) - if grant_approved_type: - GrantFactory( + if has_ticket or has_travel: + grant = GrantFactory( conference=config.conference, user=request.requester, - approved_type=grant_approved_type, ) + if has_ticket: + GrantReimbursementFactory( + grant=grant, + category__conference=config.conference, + category__ticket=True, + granted_amount=Decimal("100"), + ) + if has_travel: + GrantReimbursementFactory( + grant=grant, + category__conference=config.conference, + category__travel=True, + granted_amount=Decimal("500"), + ) process_invitation_letter_request(invitation_letter_request_id=request.id) @@ -195,10 +211,27 @@ def test_process_invitation_letter_request_with_doc_only_for_accommodation( ) mock_ticket_present(request) - GrantFactory( + grant = GrantFactory( conference=config.conference, user=request.requester, - approved_type=Grant.ApprovedType.ticket_travel_accommodation, + ) + GrantReimbursementFactory( + grant=grant, + category__conference=config.conference, + category__ticket=True, + granted_amount=Decimal("100"), + ) + GrantReimbursementFactory( + grant=grant, + category__conference=config.conference, + category__travel=True, + granted_amount=Decimal("500"), + ) + GrantReimbursementFactory( + grant=grant, + category__conference=config.conference, + category__accommodation=True, + granted_amount=Decimal("200"), ) process_invitation_letter_request(invitation_letter_request_id=request.id) @@ -385,11 +418,11 @@ def test_notify_new_invitation_letter_request_on_slack(mocker): def test_send_invitation_letter_via_email(sent_emails): from notifications.tests.factories import EmailTemplateFactory - + invitation_letter_request = InvitationLetterRequestFactory( requester__full_name="Marco", ) - + EmailTemplateFactory( conference=invitation_letter_request.conference, identifier=EmailTemplateIdentifier.visa_invitation_letter_download, @@ -402,22 +435,28 @@ def test_send_invitation_letter_via_email(sent_emails): # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.visa_invitation_letter_download + assert ( + sent_email.email_template.identifier + == EmailTemplateIdentifier.visa_invitation_letter_download + ) assert sent_email.email_template.conference == invitation_letter_request.conference assert sent_email.recipient_email == invitation_letter_request.email - + signer = Signer() url_path = reverse( "download-invitation-letter", args=[invitation_letter_request.id] ) signed_url = signer.sign(url_path) signature = signed_url.split(signer.sep)[-1] - + # Verify placeholders were processed correctly - assert sent_email.placeholders["invitation_letter_download_url"] == f"https://admin.pycon.it{url_path}?sig={signature}" - assert sent_email.placeholders["has_grant"] == False + assert ( + sent_email.placeholders["invitation_letter_download_url"] + == f"https://admin.pycon.it{url_path}?sig={signature}" + ) + assert not sent_email.placeholders["has_grant"] assert sent_email.placeholders["user_name"] == "Marco" invitation_letter_request.refresh_from_db()