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..759d6c3f4e --- /dev/null +++ b/backend/conferences/migrations/0055_remove_conference_grants_default_accommodation_amount_and_more.py @@ -0,0 +1,34 @@ +# 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'), + ('grants', '0030_remove_grant_accommodation_amount_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..4a73c6fb0f 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -1,45 +1,52 @@ 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, OuterRef 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 = ( @@ -164,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 @@ -208,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 @@ -394,6 +387,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 +425,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 +440,6 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "pending_status", "country_type", "occupation", - "approved_type", "needs_funds_for_travel", "need_visa", "need_accommodation", @@ -451,6 +465,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "delete_selected", ] autocomplete_fields = ("user",) + inlines = [GrantReimbursementInline] fieldsets = ( ( @@ -459,12 +474,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 +538,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 +606,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( 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..95cbeb820d --- /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', '0054_conference_frontend_revalidate_secret_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..781d6289e8 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: @@ -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/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_models.py b/backend/grants/tests/test_models.py index 5bb922cb4f..88a15e1bf1 100644 --- a/backend/grants/tests/test_models.py +++ b/backend/grants/tests/test_models.py @@ -1,7 +1,13 @@ -from grants.models import Grant -from grants.tests.factories import GrantFactory +from decimal import Decimal + import pytest +from grants.models import Grant, GrantReimbursement +from grants.tests.factories import ( + GrantFactory, + GrantReimbursementCategoryFactory, + GrantReimbursementFactory, +) pytestmark = pytest.mark.django_db @@ -10,35 +16,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 +53,120 @@ ], ) 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, ) - grant.pending_status = Grant.Status.approved - grant.save() + # Create categories and reimbursements based on test data + ticket_category = None + travel_category = None + accommodation_category = None + + if "ticket" in categories: + ticket_category = GrantReimbursementCategoryFactory( + conference=grant.conference, + ticket=True, + max_amount=Decimal("100"), + ) + GrantReimbursementFactory( + grant=grant, + category=ticket_category, + granted_amount=Decimal(expected_ticket_amount), + ) + if "travel" in categories: + travel_category = GrantReimbursementCategoryFactory( + conference=grant.conference, + travel=True, + max_amount=Decimal("500"), + ) + GrantReimbursementFactory( + grant=grant, + category=travel_category, + granted_amount=Decimal(expected_travel_amount), + ) + if "accommodation" in categories: + accommodation_category = GrantReimbursementCategoryFactory( + conference=grant.conference, + accommodation=True, + max_amount=Decimal("200"), + ) + GrantReimbursementFactory( + grant=grant, + category=accommodation_category, + 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__category="ticket" + ).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__category="travel" + ).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__category="accommodation" + ).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() + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__travel=True, + granted_amount=Decimal("500"), ) - 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 + assert grant.has_approved_travel() -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, +def test_has_approved_accommodation(): + grant = GrantFactory() + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__accommodation=True, + granted_amount=Decimal("200"), ) - grant.pending_status = Grant.Status.approved - grant.save(update_fields=["pending_status"]) - - 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() - - 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( @@ -194,6 +222,7 @@ def test_doesnt_sync_pending_status_if_different_values(): 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 +239,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/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/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..8ab73fa578 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -1,7 +1,17 @@ -from conferences.tests.factories import ConferenceFactory -from django.contrib.admin import AdminSite -from grants.tests.factories import GrantFactory +from decimal import Decimal + import pytest +from django.contrib.admin import AdminSite + +from conferences.tests.factories import ConferenceFactory +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 ( AvailableScoreOptionFactory, ReviewSessionFactory, @@ -10,9 +20,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 +276,239 @@ 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 = GrantReimbursementCategoryFactory( + conference=conference, + travel=True, + max_amount=Decimal("500"), + ) + ticket_category = GrantReimbursementCategoryFactory( + conference=conference, + ticket=True, + max_amount=Decimal("100"), + ) + accommodation_category = GrantReimbursementCategoryFactory( + conference=conference, + accommodation=True, + max_amount=Decimal("200"), + ) + + # 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 = GrantReimbursementCategoryFactory( + conference=conference, + travel=True, + max_amount=Decimal("500"), + ) + ticket_category = GrantReimbursementCategoryFactory( + conference=conference, + ticket=True, + max_amount=Decimal("100"), + ) + accommodation_category = GrantReimbursementCategoryFactory( + conference=conference, + accommodation=True, + max_amount=Decimal("200"), + ) + + # 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) + GrantReimbursementFactory( + grant=grant_1, + category=travel_category, + granted_amount=Decimal("500"), + ) + GrantReimbursementFactory( + grant=grant_1, + category=ticket_category, + granted_amount=Decimal("100"), + ) + GrantReimbursementFactory( + 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 = GrantReimbursementCategoryFactory( + conference=conference, + travel=True, + max_amount=Decimal("500"), + ) + ticket_category = GrantReimbursementCategoryFactory( + conference=conference, + ticket=True, + max_amount=Decimal("100"), + ) + accommodation_category = GrantReimbursementCategoryFactory( + conference=conference, + accommodation=True, + max_amount=Decimal("200"), + ) + + # 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) + GrantReimbursementFactory( + grant=grant_1, + category=travel_category, + granted_amount=Decimal("500"), + ) + GrantReimbursementFactory( + grant=grant_1, + category=ticket_category, + granted_amount=Decimal("100"), + ) + GrantReimbursementFactory( + 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} 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()