From a2d336a3af2250c7ec0b7ec256086dda058556f9 Mon Sep 17 00:00:00 2001 From: Etty Date: Sat, 12 Oct 2024 19:47:20 +0100 Subject: [PATCH] WIP --- ...s_default_accommodation_amount_and_more.py | 32 ++ backend/conferences/models/conference.py | 41 -- backend/grants/admin.py | 108 +++-- ...ove_grant_accommodation_amount_and_more.py | 425 ++++++++++++++++++ backend/grants/models.py | 216 +++------ backend/grants/tasks.py | 8 - backend/grants/tests/factories.py | 32 +- backend/grants/tests/test_admin.py | 10 +- backend/grants/tests/test_models.py | 202 +++------ backend/grants/tests/test_tasks.py | 13 - backend/integrations/tests/test_views.py | 4 - backend/reviews/admin.py | 13 +- backend/reviews/templates/grants-recap.html | 78 ++-- 13 files changed, 743 insertions(+), 439 deletions(-) create mode 100644 backend/conferences/migrations/0048_remove_conference_grants_default_accommodation_amount_and_more.py create mode 100644 backend/grants/migrations/0021_remove_grant_accommodation_amount_and_more.py diff --git a/backend/conferences/migrations/0048_remove_conference_grants_default_accommodation_amount_and_more.py b/backend/conferences/migrations/0048_remove_conference_grants_default_accommodation_amount_and_more.py new file mode 100644 index 0000000000..a7a9b93e5f --- /dev/null +++ b/backend/conferences/migrations/0048_remove_conference_grants_default_accommodation_amount_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.0.8 on 2024-10-12 18:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("conferences", "0047_alter_conferencevoucher_options_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 9d39378dc5..a4a75d13dd 100644 --- a/backend/conferences/models/conference.py +++ b/backend/conferences/models/conference.py @@ -99,47 +99,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 81986604ee..2760256b0f 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -23,7 +23,7 @@ from pretix import create_voucher from schedule.models import ScheduleItem from submissions.models import Submission -from .models import Grant +from .models import Grant, AidCategory, CountryAidAmount, GrantAllocation from django.db.models import Exists, OuterRef from django.contrib.admin import SimpleListFilter @@ -150,31 +150,6 @@ class Meta: export_order = EXPORT_GRANTS_FIELDS -def _check_amounts_are_not_empty(grant: Grant, request): - if grant.total_amount is None: - 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 - - @admin.action(description="Send Approved/Waiting List/Rejected reply emails") @validate_single_conference_selection def send_reply_emails(modeladmin, request, queryset): @@ -195,16 +170,6 @@ def send_reply_emails(modeladmin, request, queryset): for grant in queryset: if grant.status in (Grant.Status.approved,): - if grant.approved_type is None: - messages.error( - request, - f"Grant for {grant.name} is missing 'Grant Approved Type'!", - ) - return - - if not _check_amounts_are_not_empty(grant, request): - return - now = timezone.now() grant.applicant_reply_deadline = timezone.datetime( now.year, now.month, now.day, 23, 59, 59, tzinfo=UTC @@ -242,8 +207,6 @@ def send_grant_reminder_to_waiting_for_confirmation(modeladmin, request, queryse ) return - _check_amounts_are_not_empty(grant, request) - send_grant_reply_approved_email.delay(grant_id=grant.id, is_reminder=True) messages.info(request, f"Grant reminder sent to {grant.name}") @@ -348,11 +311,6 @@ class Meta: "id", "name", "status", - "approved_type", - "ticket_amount", - "travel_amount", - "accommodation_amount", - "total_amount", "full_name", "conference", "user", @@ -367,7 +325,6 @@ class Meta: "why", "notes", "travelling_from", - "country_type", "applicant_reply_sent_at", "applicant_reply_deadline", ) @@ -405,6 +362,53 @@ def queryset(self, request, queryset): return queryset +@admin.register(AidCategory) +class AidCategoryAdmin(admin.ModelAdmin): + list_display = ( + "name", + "conference", + "category", + "max_amount", + "included_by_default", + ) + + list_filter = ("conference", "category") + search_fields = ("name", "description", "conference", "max_amount") + + +@admin.register(CountryAidAmount) +class CountryAidAmountAdmin(admin.ModelAdmin): + list_display = ("conference", "_country", "max_amount") + + def _country(self, obj): + if obj.country: + country = countries.get(code=obj.country) + if country: + return f"{country.name} {country.emoji}" + + return "" + + +class GrantAllocationFormSet: + pass + + +class GrantAllocationInline(admin.StackedInline): + model = GrantAllocation + + def formfield_for_foreignkey(self, db_field, request=None, **kwargs): + if db_field.name == "category": + grant_id = request.resolver_match.kwargs.get("object_id") + if grant_id: + grant = Grant.objects.get(pk=grant_id) + kwargs["queryset"] = AidCategory.objects.filter( + conference=grant.conference + ) + else: + kwargs["queryset"] = AidCategory.objects.none() + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + @admin.register(Grant) class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): change_list_template = "admin/grants/grant/change_list.html" @@ -418,12 +422,6 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): "emoji_gender", "conference", "status", - "approved_type", - "ticket_amount", - "travel_amount", - "accommodation_amount", - "total_amount", - "country_type", "applicant_reply_sent_at", "applicant_reply_deadline", "voucher_code", @@ -433,9 +431,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): list_filter = ( "conference", "status", - "country_type", "occupation", - "approved_type", "interested_in_volunteering", "needs_funds_for_travel", "need_visa", @@ -447,6 +443,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): ) search_fields = ( "email", + "name", "full_name", "travelling_from", "been_to_other_events", @@ -470,12 +467,6 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): { "fields": ( "status", - "approved_type", - "country_type", - "ticket_amount", - "travel_amount", - "accommodation_amount", - "total_amount", "applicant_reply_sent_at", "applicant_reply_deadline", "pretix_voucher_id", @@ -523,6 +514,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): }, ), ) + inlines = [GrantAllocationInline] @admin.display(description="User", ordering="user__full_name") def user_display_name(self, obj): diff --git a/backend/grants/migrations/0021_remove_grant_accommodation_amount_and_more.py b/backend/grants/migrations/0021_remove_grant_accommodation_amount_and_more.py new file mode 100644 index 0000000000..19a5d076b6 --- /dev/null +++ b/backend/grants/migrations/0021_remove_grant_accommodation_amount_and_more.py @@ -0,0 +1,425 @@ +# Generated by Django 5.0.8 on 2024-10-12 18:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "conferences", + "0048_remove_conference_grants_default_accommodation_amount_and_more", + ), + ("grants", "0020_remove_grant_applicant_message_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="grant", + name="accommodation_amount", + ), + migrations.RemoveField( + model_name="grant", + name="approved_type", + ), + migrations.RemoveField( + model_name="grant", + name="country_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.CreateModel( + name="AidCategory", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("description", models.TextField(blank=True, null=True)), + ( + "max_amount", + models.DecimalField( + decimal_places=0, + help_text="Maximum amount for this category", + max_digits=6, + ), + ), + ( + "category", + models.CharField( + choices=[ + ("travel", "Travel"), + ("ticket", "Ticket"), + ("accommodation", "Accommodation"), + ("other", "Other"), + ], + max_length=20, + ), + ), + ( + "included_by_default", + models.BooleanField( + default=False, + help_text="Automatically include this category in grants by default", + ), + ), + ( + "conference", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="aid_categories", + to="conferences.conference", + ), + ), + ], + ), + migrations.CreateModel( + name="CountryAidAmount", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "country", + models.CharField( + blank=True, + choices=[ + ("AF", "Afghanistan"), + ("AL", "Albania"), + ("DZ", "Algeria"), + ("AS", "American Samoa"), + ("AD", "Andorra"), + ("AO", "Angola"), + ("AI", "Anguilla"), + ("AQ", "Antarctica"), + ("AG", "Antigua and Barbuda"), + ("AR", "Argentina"), + ("AM", "Armenia"), + ("AW", "Aruba"), + ("AU", "Australia"), + ("AT", "Austria"), + ("AZ", "Azerbaijan"), + ("BS", "Bahamas"), + ("BH", "Bahrain"), + ("BD", "Bangladesh"), + ("BB", "Barbados"), + ("BY", "Belarus"), + ("BE", "Belgium"), + ("BZ", "Belize"), + ("BJ", "Benin"), + ("BM", "Bermuda"), + ("BT", "Bhutan"), + ("BO", "Bolivia, Plurinational State of"), + ("BQ", "Bonaire, Sint Eustatius and Saba"), + ("BA", "Bosnia and Herzegovina"), + ("BW", "Botswana"), + ("BV", "Bouvet Island"), + ("BR", "Brazil"), + ("IO", "British Indian Ocean Territory"), + ("BN", "Brunei Darussalam"), + ("BG", "Bulgaria"), + ("BF", "Burkina Faso"), + ("BI", "Burundi"), + ("CV", "Cabo Verde"), + ("KH", "Cambodia"), + ("CM", "Cameroon"), + ("CA", "Canada"), + ("KY", "Cayman Islands"), + ("CF", "Central African Republic"), + ("TD", "Chad"), + ("CL", "Chile"), + ("CN", "China"), + ("CX", "Christmas Island"), + ("CC", "Cocos (Keeling) Islands"), + ("CO", "Colombia"), + ("KM", "Comoros"), + ("CG", "Congo"), + ("CD", "Congo, The Democratic Republic of the"), + ("CK", "Cook Islands"), + ("CR", "Costa Rica"), + ("HR", "Croatia"), + ("CU", "Cuba"), + ("CW", "Curaçao"), + ("CY", "Cyprus"), + ("CZ", "Czechia"), + ("CI", "Côte d'Ivoire"), + ("DK", "Denmark"), + ("DJ", "Djibouti"), + ("DM", "Dominica"), + ("DO", "Dominican Republic"), + ("EC", "Ecuador"), + ("EG", "Egypt"), + ("SV", "El Salvador"), + ("GQ", "Equatorial Guinea"), + ("ER", "Eritrea"), + ("EE", "Estonia"), + ("SZ", "Eswatini"), + ("ET", "Ethiopia"), + ("FK", "Falkland Islands (Malvinas)"), + ("FO", "Faroe Islands"), + ("FJ", "Fiji"), + ("FI", "Finland"), + ("FR", "France"), + ("GF", "French Guiana"), + ("PF", "French Polynesia"), + ("TF", "French Southern Territories"), + ("GA", "Gabon"), + ("GM", "Gambia"), + ("GE", "Georgia"), + ("DE", "Germany"), + ("GH", "Ghana"), + ("GI", "Gibraltar"), + ("GR", "Greece"), + ("GL", "Greenland"), + ("GD", "Grenada"), + ("GP", "Guadeloupe"), + ("GU", "Guam"), + ("GT", "Guatemala"), + ("GG", "Guernsey"), + ("GN", "Guinea"), + ("GW", "Guinea-Bissau"), + ("GY", "Guyana"), + ("HT", "Haiti"), + ("HM", "Heard Island and McDonald Islands"), + ("VA", "Holy See (Vatican City State)"), + ("HN", "Honduras"), + ("HK", "Hong Kong"), + ("HU", "Hungary"), + ("IS", "Iceland"), + ("IN", "India"), + ("ID", "Indonesia"), + ("IR", "Iran, Islamic Republic of"), + ("IQ", "Iraq"), + ("IE", "Ireland"), + ("IM", "Isle of Man"), + ("IL", "Israel"), + ("IT", "Italy"), + ("JM", "Jamaica"), + ("JP", "Japan"), + ("JE", "Jersey"), + ("JO", "Jordan"), + ("KZ", "Kazakhstan"), + ("KE", "Kenya"), + ("KI", "Kiribati"), + ("KP", "Korea, Democratic People's Republic of"), + ("KR", "Korea, Republic of"), + ("KW", "Kuwait"), + ("KG", "Kyrgyzstan"), + ("LA", "Lao People's Democratic Republic"), + ("LV", "Latvia"), + ("LB", "Lebanon"), + ("LS", "Lesotho"), + ("LR", "Liberia"), + ("LY", "Libya"), + ("LI", "Liechtenstein"), + ("LT", "Lithuania"), + ("LU", "Luxembourg"), + ("MO", "Macao"), + ("MG", "Madagascar"), + ("MW", "Malawi"), + ("MY", "Malaysia"), + ("MV", "Maldives"), + ("ML", "Mali"), + ("MT", "Malta"), + ("MH", "Marshall Islands"), + ("MQ", "Martinique"), + ("MR", "Mauritania"), + ("MU", "Mauritius"), + ("YT", "Mayotte"), + ("MX", "Mexico"), + ("FM", "Micronesia, Federated States of"), + ("MD", "Moldova, Republic of"), + ("MC", "Monaco"), + ("MN", "Mongolia"), + ("ME", "Montenegro"), + ("MS", "Montserrat"), + ("MA", "Morocco"), + ("MZ", "Mozambique"), + ("MM", "Myanmar"), + ("NA", "Namibia"), + ("NR", "Nauru"), + ("NP", "Nepal"), + ("NL", "Netherlands"), + ("NC", "New Caledonia"), + ("NZ", "New Zealand"), + ("NI", "Nicaragua"), + ("NE", "Niger"), + ("NG", "Nigeria"), + ("NU", "Niue"), + ("NF", "Norfolk Island"), + ("MK", "North Macedonia"), + ("MP", "Northern Mariana Islands"), + ("NO", "Norway"), + ("OM", "Oman"), + ("PK", "Pakistan"), + ("PW", "Palau"), + ("PS", "Palestine, State of"), + ("PA", "Panama"), + ("PG", "Papua New Guinea"), + ("PY", "Paraguay"), + ("PE", "Peru"), + ("PH", "Philippines"), + ("PN", "Pitcairn"), + ("PL", "Poland"), + ("PT", "Portugal"), + ("PR", "Puerto Rico"), + ("QA", "Qatar"), + ("RO", "Romania"), + ("RU", "Russian Federation"), + ("RW", "Rwanda"), + ("RE", "Réunion"), + ("BL", "Saint Barthélemy"), + ("SH", "Saint Helena, Ascension and Tristan da Cunha"), + ("KN", "Saint Kitts and Nevis"), + ("LC", "Saint Lucia"), + ("MF", "Saint Martin (French part)"), + ("PM", "Saint Pierre and Miquelon"), + ("VC", "Saint Vincent and the Grenadines"), + ("WS", "Samoa"), + ("SM", "San Marino"), + ("ST", "Sao Tome and Principe"), + ("SA", "Saudi Arabia"), + ("SN", "Senegal"), + ("RS", "Serbia"), + ("SC", "Seychelles"), + ("SL", "Sierra Leone"), + ("SG", "Singapore"), + ("SX", "Sint Maarten (Dutch part)"), + ("SK", "Slovakia"), + ("SI", "Slovenia"), + ("SB", "Solomon Islands"), + ("SO", "Somalia"), + ("ZA", "South Africa"), + ("GS", "South Georgia and the South Sandwich Islands"), + ("SS", "South Sudan"), + ("ES", "Spain"), + ("LK", "Sri Lanka"), + ("SD", "Sudan"), + ("SR", "Suriname"), + ("SJ", "Svalbard and Jan Mayen"), + ("SE", "Sweden"), + ("CH", "Switzerland"), + ("SY", "Syrian Arab Republic"), + ("TW", "Taiwan, Province of China"), + ("TJ", "Tajikistan"), + ("TZ", "Tanzania, United Republic of"), + ("TH", "Thailand"), + ("TL", "Timor-Leste"), + ("TG", "Togo"), + ("TK", "Tokelau"), + ("TO", "Tonga"), + ("TT", "Trinidad and Tobago"), + ("TN", "Tunisia"), + ("TM", "Turkmenistan"), + ("TC", "Turks and Caicos Islands"), + ("TV", "Tuvalu"), + ("TR", "Türkiye"), + ("UG", "Uganda"), + ("UA", "Ukraine"), + ("AE", "United Arab Emirates"), + ("GB", "United Kingdom"), + ("US", "United States"), + ("UM", "United States Minor Outlying Islands"), + ("UY", "Uruguay"), + ("UZ", "Uzbekistan"), + ("VU", "Vanuatu"), + ("VE", "Venezuela, Bolivarian Republic of"), + ("VN", "Viet Nam"), + ("VG", "Virgin Islands, British"), + ("VI", "Virgin Islands, U.S."), + ("WF", "Wallis and Futuna"), + ("EH", "Western Sahara"), + ("YE", "Yemen"), + ("ZM", "Zambia"), + ("ZW", "Zimbabwe"), + ("AX", "Åland Islands"), + ], + null=True, + verbose_name="Country", + ), + ), + ( + "max_amount", + models.DecimalField( + decimal_places=0, + help_text="Maximum amount for this category", + max_digits=6, + ), + ), + ( + "conference", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="travel_costs", + to="conferences.conference", + ), + ), + ], + ), + migrations.CreateModel( + name="GrantAllocation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "allocated_amount", + models.DecimalField( + decimal_places=0, + help_text="Actual amount allocated for this category", + max_digits=6, + ), + ), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="grants.aidcategory", + ), + ), + ( + "grant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="allocations", + to="grants.grant", + ), + ), + ], + ), + migrations.AddField( + model_name="grant", + name="aid_categories", + field=models.ManyToManyField( + through="grants.GrantAllocation", to="grants.aidcategory" + ), + ), + ] diff --git a/backend/grants/models.py b/backend/grants/models.py index 111d47abf8..1b943beff0 100644 --- a/backend/grants/models.py +++ b/backend/grants/models.py @@ -14,6 +14,55 @@ def of_user(self, user): return self.filter(user=user) +class AidCategory(models.Model): + class AidType(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="aid_categories", + ) + name = models.CharField(max_length=100) + description = models.TextField(blank=True, null=True) + max_amount = models.DecimalField( + max_digits=6, decimal_places=0, help_text=_("Maximum amount for this category") + ) + category = models.CharField(max_length=20, choices=AidType.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 CountryAidAmount(models.Model): + conference = models.ForeignKey( + "conferences.Conference", on_delete=models.CASCADE, related_name="travel_costs" + ) + country = models.CharField( + "Country", + choices=[(country.code, country.name) for country in countries], + null=True, + blank=True, + ) + max_amount = models.DecimalField( + max_digits=6, decimal_places=0, help_text=_("Maximum amount for this category") + ) + + objects = GrantQuerySet().as_manager() + + def __str__(self): + return f"{self.country} ({self.conference.name}) - {self.max_amount}€" + + class Grant(TimeStampedModel): # TextChoices class Status(models.TextChoices): @@ -37,11 +86,6 @@ class Status(models.TextChoices): Status.waiting_list_maybe.value, ] - class CountryType(models.TextChoices): - italy = "italy", _("Italy") - europe = "europe", _("Europe") - extra_eu = "extra_eu", _("Extra EU") - class AgeGroup(models.TextChoices): range_less_than_10 = "range_less_than_10", _("10 years old or under") range_11_18 = "range_11_18", _("11 - 18 years old") @@ -69,12 +113,6 @@ class InterestedInVolunteering(models.TextChoices): yes = "yes", _("Yes") absolutely = "absolutely", _("My soul is yours to take!") - 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", _("Ticket + Travel + Accommodation") - conference = models.ForeignKey( "conferences.Conference", on_delete=models.CASCADE, @@ -143,51 +181,9 @@ class ApprovedType(models.TextChoices): status = models.CharField( _("status"), choices=Status.choices, max_length=30, default=Status.pending ) - 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"), - max_length=10, - choices=CountryType.choices, - null=True, - blank=True, - ) + aid_categories = models.ManyToManyField(AidCategory, through="GrantAllocation") # Applicant Communication Tracking applicant_reply_sent_at = models.DateTimeField( @@ -219,96 +215,9 @@ class ApprovedType(models.TextChoices): objects = GrantQuerySet().as_manager() - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._original_status = self.status - self._original_approved_type = self.approved_type - self._original_country_type = self.country_type - def __str__(self): return f"{self.full_name}" - 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") - - super().save(*args, **kwargs) - - self._original_approved_type = self.approved_type - self._original_country_type = self.country_type - self._original_status = self.status - - def _calculate_grant_amounts(self): - if self.status != Grant.Status.approved: - return - - if ( - self._original_status == self.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.travelling_from: - return - - country = countries.get(code=self.travelling_from) - assert country - if country.code == "IT": - self.country_type = Grant.CountryType.italy - elif country.continent == "EU": - self.country_type = Grant.CountryType.europe - else: - self.country_type = Grant.CountryType.extra_eu - def can_edit(self, user: User): return self.user_id == user.id @@ -319,13 +228,26 @@ 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.aid_categories.filter(category=AidCategory.AidType.TRAVEL).exists() def has_approved_accommodation(self): + return self.aid_categories.filter( + category=AidCategory.AidType.ACCOMMODATION + ).exists() + + +class GrantAllocation(models.Model): + grant = models.ForeignKey( + Grant, on_delete=models.CASCADE, related_name="allocations" + ) + category = models.ForeignKey(AidCategory, on_delete=models.CASCADE) + allocated_amount = models.DecimalField( + max_digits=6, + decimal_places=0, + help_text="Actual amount allocated for this category", + ) + + def __str__(self): return ( - self.approved_type == Grant.ApprovedType.ticket_accommodation - or self.approved_type == Grant.ApprovedType.ticket_travel_accommodation + f"{self.grant.full_name} - {self.category.name} - {self.allocated_amount}€" ) diff --git a/backend/grants/tasks.py b/backend/grants/tasks.py index ba25eb6e8f..e68fee1a86 100644 --- a/backend/grants/tasks.py +++ b/backend/grants/tasks.py @@ -38,14 +38,6 @@ def send_grant_reply_approved_email(*, grant_id, is_reminder): "is_reminder": is_reminder, } - if grant.has_approved_travel(): - if not grant.travel_amount: - raise ValueError( - "Grant travel amount is set to Zero, can't send the email!" - ) - - variables["travel_amount"] = f"{grant.travel_amount:.0f}" - _new_send_grant_email( template_identifier=EmailTemplateIdentifier.grant_approved, grant=grant, diff --git a/backend/grants/tests/factories.py b/backend/grants/tests/factories.py index 7094b3a237..f07aa7c3c2 100644 --- a/backend/grants/tests/factories.py +++ b/backend/grants/tests/factories.py @@ -2,12 +2,33 @@ from factory.django import DjangoModelFactory from conferences.tests.factories import ConferenceFactory -from grants.models import Grant +from grants.models import Grant, AidCategory, CountryAidAmount, GrantAllocation from helpers.constants import GENDERS from users.tests.factories import UserFactory from countries import countries +class AidCategoryFactory(DjangoModelFactory): + class Meta: + model = AidCategory + + name = factory.Faker("word") + description = factory.Faker("sentence") + conference = factory.SubFactory(ConferenceFactory) + max_amount = factory.fuzzy.FuzzyDecimal(50, 500) + category = factory.fuzzy.FuzzyChoice(AidCategory.AidType) + included_by_default = factory.Faker("boolean") + + +class CountryAidAmountFactory(DjangoModelFactory): + class Meta: + model = CountryAidAmount + + conference = factory.SubFactory(ConferenceFactory) + country = factory.fuzzy.FuzzyChoice([country.code for country in countries]) + max_amount = factory.fuzzy.FuzzyDecimal(100, 1000) + + class GrantFactory(DjangoModelFactory): class Meta: model = Grant @@ -36,3 +57,12 @@ class Meta: github_handle = factory.Faker("user_name") linkedin_url = factory.Faker("user_name") mastodon_handle = factory.Faker("user_name") + + +class GrantAllocationFactory(DjangoModelFactory): + class Meta: + model = GrantAllocation + + grant = factory.SubFactory(GrantFactory) + category = factory.SubFactory(AidCategoryFactory) + allocated_amount = factory.fuzzy.FuzzyDecimal(50, 500) diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index f72c2f329e..c3205dbd3f 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -61,7 +61,9 @@ def test_send_reply_emails_with_grants_from_multiple_conferences_fails( def test_send_reply_emails_approved_grant_missing_approved_type(rf, mocker): 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("/") mock_send_approved_email = mocker.patch( "grants.admin.send_grant_reply_approved_email.delay" @@ -80,11 +82,7 @@ def test_send_reply_emails_approved_missing_amount(rf, mocker): mock_messages = mocker.patch("grants.admin.messages") grant = GrantFactory( status=Grant.Status.approved, - approved_type=Grant.ApprovedType.ticket_accommodation, - total_amount=None, ) - grant.total_amount = None - grant.save() request = rf.get("/") mock_send_approved_email = mocker.patch( "grants.admin.send_grant_reply_approved_email.delay" @@ -103,8 +101,6 @@ def test_send_reply_emails_approved_set_deadline_in_fourteen_days(rf, mocker): mock_messages = mocker.patch("grants.admin.messages") grant = GrantFactory( status=Grant.Status.approved, - approved_type=Grant.ApprovedType.ticket_accommodation, - total_amount=800, ) request = rf.get("/") mock_send_approved_email = mocker.patch( diff --git a/backend/grants/tests/test_models.py b/backend/grants/tests/test_models.py index 2fc1001c86..3b6fd4aa30 100644 --- a/backend/grants/tests/test_models.py +++ b/backend/grants/tests/test_models.py @@ -1,162 +1,104 @@ -from grants.models import Grant from grants.tests.factories import GrantFactory +from conferences.tests.factories import ConferenceFactory import pytest +from grants.models import AidCategory +from grants.models import GrantAllocation +from grants.tests.factories import ( + AidCategoryFactory, + CountryAidAmountFactory, + GrantAllocationFactory, +) pytestmark = pytest.mark.django_db -@pytest.mark.parametrize( - "data", - [ - { - "approved_type": Grant.ApprovedType.ticket_travel, - "travelling_from": "IT", - "expected_ticket_amount": 100, - "expected_accommodation_amount": 0, - "expected_travel_amount": 300, - }, - { - "approved_type": Grant.ApprovedType.ticket_only, - "travelling_from": "IT", - "expected_ticket_amount": 100, - "expected_accommodation_amount": 0, - "expected_travel_amount": 0, - }, - { - "approved_type": Grant.ApprovedType.ticket_accommodation, - "travelling_from": "FR", - "expected_ticket_amount": 100, - "expected_accommodation_amount": 200, - "expected_travel_amount": 0, - }, - { - "approved_type": Grant.ApprovedType.ticket_travel, - "travelling_from": "FR", - "expected_ticket_amount": 100, - "expected_accommodation_amount": 0, - "expected_travel_amount": 400, - }, - { - "approved_type": Grant.ApprovedType.ticket_travel_accommodation, - "travelling_from": "AU", - "expected_ticket_amount": 100, - "expected_accommodation_amount": 200, - "expected_travel_amount": 500, - }, - ], -) -def test_calculate_grant_amounts(data): - approved_type = data["approved_type"] - travelling_from = data["travelling_from"] - expected_ticket_amount = data["expected_ticket_amount"] - expected_accommodation_amount = data["expected_accommodation_amount"] - expected_travel_amount = data["expected_travel_amount"] +def test_grant_with_default_aid_categories(): + # Create an AidCategory that should be included by default + default_category = AidCategoryFactory(included_by_default=True) - grant = GrantFactory( - status=Grant.Status.pending, - approved_type=approved_type, - travelling_from=travelling_from, - 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, - ) + # Create a Grant + grant = GrantFactory(conference=default_category.conference) - grant.status = Grant.Status.approved - grant.save() + # Ensure that the default AidCategory is included in the Grant's allocations + assert GrantAllocation.objects.filter( + grant=grant, category=default_category + ).exists() - 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 - ) +def test_grant_travel_aid_calculation(): + # Create a conference and country-specific travel cost + conference = ConferenceFactory() + country_aid = CountryAidAmountFactory(conference=conference, max_amount=300) + # Create a travel-related AidCategory + travel_category = AidCategoryFactory( + conference=conference, category=AidCategory.AidType.TRAVEL + ) -def test_resets_amounts_on_approved_type_change(): + # Create a Grant with travelling_from matching the country aid grant = GrantFactory( - status=Grant.Status.pending, - approved_type=Grant.ApprovedType.ticket_only, - travelling_from="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, + conference=conference, + travelling_from=country_aid.country, + needs_funds_for_travel=True, ) - grant.status = Grant.Status.approved + # Trigger save to assign aid categories 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() + # Check that the travel allocation is correctly calculated + travel_allocation = GrantAllocation.objects.get( + grant=grant, category=travel_category + ) + assert travel_allocation.allocated_amount == country_aid.max_amount - assert grant.ticket_amount == 100 - assert grant.accommodation_amount == 200 - assert grant.travel_amount == 300 - assert grant.total_amount == 600 +def test_grant_with_custom_allocated_amount(): + # Create a custom AidCategory + custom_category = AidCategoryFactory(max_amount=400) -def test_can_manually_change_amounts(): - grant = GrantFactory( - status=Grant.Status.pending, - approved_type=Grant.ApprovedType.ticket_only, - travelling_from="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, + # Create a Grant and manually allocate a custom amount + grant = GrantFactory() + allocation = GrantAllocationFactory( + grant=grant, category=custom_category, allocated_amount=350 ) - grant.status = Grant.Status.approved - grant.save(update_fields=["status"]) + # Ensure that the allocation is correct + assert allocation.allocated_amount == 350 + assert allocation.category == custom_category + assert allocation.grant == grant - 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() +@pytest.mark.django_db +def test_grant_has_approved_travel_true(): + travel_category = AidCategoryFactory(category=AidCategory.AidType.TRAVEL) + grant = GrantFactory() + GrantAllocationFactory(grant=grant, category=travel_category) - assert grant.ticket_amount == 20 - assert grant.accommodation_amount == 50 - assert grant.travel_amount == 0 - assert grant.total_amount == 70 + # Test that has_approved_travel returns True + assert grant.has_approved_travel() is True -@pytest.mark.parametrize( - "travelling_from,country_type", - [ - ("IT", Grant.CountryType.italy), - ("FR", Grant.CountryType.europe), - ("AU", Grant.CountryType.extra_eu), - ("US", Grant.CountryType.extra_eu), - ], -) -def test_sets_country_type(travelling_from, country_type): - grant = GrantFactory(travelling_from=travelling_from) +@pytest.mark.django_db +def test_grant_has_approved_travel_false(): + grant = GrantFactory() + + assert grant.has_approved_travel() is False + + +@pytest.mark.django_db +def test_grant_has_approved_accommodation_true(): + accommodation_category = AidCategoryFactory( + category=AidCategory.AidType.ACCOMMODATION + ) + grant = GrantFactory() + GrantAllocationFactory(grant=grant, category=accommodation_category) - assert grant.country_type == country_type + assert grant.has_approved_accommodation() is True -def test_sets_country_type_does_nothing_if_unset(): - grant = GrantFactory(travelling_from=None) +@pytest.mark.django_db +def test_grant_has_approved_accommodation_false(): + grant = GrantFactory() - assert grant.country_type is None + assert grant.has_approved_accommodation() is False diff --git a/backend/grants/tests/test_tasks.py b/backend/grants/tests/test_tasks.py index 7f8949b366..7e7172c71b 100644 --- a/backend/grants/tests/test_tasks.py +++ b/backend/grants/tests/test_tasks.py @@ -13,7 +13,6 @@ send_grant_reply_rejected_email, send_grant_reply_waiting_list_email, ) -from grants.models import Grant pytestmark = pytest.mark.django_db @@ -30,7 +29,6 @@ def test_send_grant_voucher_email(settings): grant = GrantFactory( user=user, voucher_code="ABC123", - approved_type=Grant.ApprovedType.ticket_only, ) with patch("grants.tasks.EmailTemplate") as mock_email_template: @@ -119,9 +117,7 @@ def test_handle_grant_reply_sent_reminder(settings): ) 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, ) @@ -162,9 +158,7 @@ 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, ) @@ -208,9 +202,7 @@ 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, ) @@ -236,9 +228,7 @@ def test_handle_grant_approved_ticket_only_reply_sent(settings): 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, ) @@ -279,10 +269,7 @@ def test_handle_grant_approved_travel_reply_sent(settings): 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, ) diff --git a/backend/integrations/tests/test_views.py b/backend/integrations/tests/test_views.py index 5672bea6ee..0dcb2ef57c 100644 --- a/backend/integrations/tests/test_views.py +++ b/backend/integrations/tests/test_views.py @@ -145,8 +145,6 @@ 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, ) conference_id = grant.conference_id rest_api_client.token_auth("secret") @@ -200,8 +198,6 @@ 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, ) conference_id = grant.conference_id rest_api_client.token_auth("secret") diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index ebd21c5ea4..a0ff0be0a1 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -1,4 +1,5 @@ from django.contrib.postgres.expressions import ArraySubquery +from django.contrib.postgres.aggregates import ArrayAgg from django.db.models.expressions import ExpressionWrapper from django.db.models import FloatField from django.db.models.functions import Cast @@ -17,7 +18,7 @@ from django.urls import path, reverse from django.utils.safestring import mark_safe -from grants.models import Grant +from grants.models import Grant, AidCategory from participants.models import Participant from reviews.models import AvailableScoreOption, ReviewSession, UserReview from submissions.models import Submission, SubmissionTag @@ -340,6 +341,9 @@ def _review_grants_recap_view(self, request, review_session): ) .values("id") ), + approved_aid_categories=ArrayAgg( + "allocations__category_id", + ), ) .order_by(F("score").desc(nulls_last=True)) .prefetch_related( @@ -376,8 +380,13 @@ def _review_grants_recap_view(self, request, review_session): for choice in Grant.Status.choices if choice[0] in Grant.REVIEW_SESSION_STATUSES_OPTIONS ], + all_approved_category=[ + category + for category in AidCategory.objects.for_conference( + conference=review_session.conference + ) + ], all_statuses=Grant.Status.choices, - all_approved_types=[choice for choice in Grant.ApprovedType.choices], review_session=review_session, title="Recap", ) diff --git a/backend/reviews/templates/grants-recap.html b/backend/reviews/templates/grants-recap.html index 71851bd534..7f26343415 100644 --- a/backend/reviews/templates/grants-recap.html +++ b/backend/reviews/templates/grants-recap.html @@ -22,20 +22,24 @@ width: 150px; } - .approved-type-choices { + .approved-aid-categories-choices { width: 200px; } .status-choices, - .approved-type-choices { + .approved-aid-categories-choices { list-style: none; } .status-choices li, - .approved-type-choices li { + .approved-aid-categories-choices li { list-style: none; } + label:has(input.approved-aid-category:disabled) { + opacity: 0.6; + } + .needs-list { display: inline-block; margin-top: 0; @@ -289,18 +293,26 @@ const grantRow = document.querySelector(`#grant-${grantId}`); const originalStatus = grantRow.dataset.originalStatus; - const originalApprovedType = grantRow.dataset.originalApprovedType; - + const originalApprovedAidCategories = grantRow.dataset.originalApprovedAidCategories; grantRow.querySelector(`.status-decision-radio[value="${originalStatus}"]`).checked = true; - if (originalApprovedType !== 'None') { - grantRow.querySelector(`.approved-type-choices input[value="${originalApprovedType}"]`).checked = true; - } + const checkboxes = grantRow.querySelectorAll(`.approved-aid-categories-choices input[type="checkbox"]`); + checkboxes.forEach(checkbox => { + checkbox.checked = false; + + if (originalApprovedAidCategories.includes(checkbox.value)) { + checkbox.checked = true; + } + }); if (originalStatus === "approved") { - grantRow.querySelector(`.approved-type-choices`).classList.remove('hidden'); + checkboxes.forEach(checkbox => { + checkbox.disabled = false; + }); } else { - grantRow.querySelector(`.approved-type-choices`).classList.add('hidden'); + checkboxes.forEach(checkbox => { + checkbox.disabled = true; + }); } }); }); @@ -308,13 +320,17 @@ document.querySelectorAll('.status-decision-radio').forEach(radio => { radio.addEventListener('click', () => { const grantId = radio.name.split('-')[1]; - - const approvedTypeSection = document.querySelector(`.approved-type-choices[data-item-id="${grantId}"]`) + const approvedAidCategorySection = document.querySelector(`.approved-aid-categories-choices[data-item-id="${grantId}"]`); + const checkboxes = approvedAidCategorySection.querySelectorAll('input[type="checkbox"]'); if (radio.value === "approved") { - approvedTypeSection.classList.remove('hidden'); + checkboxes.forEach(checkbox => { + checkbox.disabled = false; + }); } else { - approvedTypeSection.classList.add('hidden'); + checkboxes.forEach(checkbox => { + checkbox.disabled = true; + }); } }); }); @@ -452,7 +468,7 @@

- Approved type + Approved Aids
@@ -470,7 +486,7 @@

{% if perms.reviews.decision_reviewsession %} {% else %} No permission to change. {% endif %}