diff --git a/backend/grants/models.py b/backend/grants/models.py index 657e54070e..ce926b81b9 100644 --- a/backend/grants/models.py +++ b/backend/grants/models.py @@ -262,22 +262,46 @@ def get_admin_url(self): args=(self.pk,), ) - def has_approved_travel(self): - return self.reimbursements.filter( - category__category=GrantReimbursementCategory.Category.TRAVEL - ).exists() + def has_approved(self, category: GrantReimbursementCategory.Category) -> bool: + """Return True if grant has approved reimbursement for category.""" + return self.reimbursements.filter(category__category=category).exists() - def has_approved_accommodation(self): - return self.reimbursements.filter( - category__category=GrantReimbursementCategory.Category.ACCOMMODATION - ).exists() + def has_approved_ticket(self) -> bool: + return self.has_approved(GrantReimbursementCategory.Category.TICKET) + + def has_approved_travel(self) -> bool: + return self.has_approved(GrantReimbursementCategory.Category.TRAVEL) + + def has_approved_accommodation(self) -> bool: + return self.has_approved(GrantReimbursementCategory.Category.ACCOMMODATION) + + def has_ticket_only(self) -> bool: + """Return True if grant has only ticket, no travel or accommodation.""" + return ( + self.has_approved_ticket() + and not self.has_approved_travel() + and not self.has_approved_accommodation() + ) @property - def total_allocated_amount(self): - return sum(r.granted_amount for r in self.reimbursements.all()) + def total_allocated_amount(self) -> Decimal: + """Return total of all reimbursements including ticket.""" + return sum( + (r.granted_amount for r in self.reimbursements.all()), + start=Decimal(0), + ) - def has_approved(self, type_): - return self.reimbursements.filter(category__category=type_).exists() + @property + def total_grantee_reimbursement_amount(self) -> Decimal: + """Return total reimbursement excluding ticket.""" + return sum( + ( + r.granted_amount + for r in self.reimbursements.all() + if r.category.category != GrantReimbursementCategory.Category.TICKET + ), + start=Decimal(0), + ) @property def current_or_pending_status(self): diff --git a/backend/grants/tasks.py b/backend/grants/tasks.py index 2ce8030fbf..b05f310cd8 100644 --- a/backend/grants/tasks.py +++ b/backend/grants/tasks.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -def get_name(user: User | None, fallback: str = ""): +def get_name(user: User | None, fallback: str = "") -> str: if not user: return fallback @@ -22,9 +22,19 @@ def get_name(user: User | None, fallback: str = ""): @app.task -def send_grant_reply_approved_email(*, grant_id, is_reminder): +def send_grant_reply_approved_email(*, grant_id: int, is_reminder: bool) -> None: logger.info("Sending Reply APPROVED email for Grant %s", grant_id) grant = Grant.objects.get(id=grant_id) + + total_amount = grant.total_grantee_reimbursement_amount + ticket_only = grant.has_ticket_only() + + if total_amount == 0 and not ticket_only: + raise ValueError( + f"Grant {grant_id} has no reimbursement amount and is not ticket-only. " + "This indicates missing or zero-amount reimbursements." + ) + reply_url = urljoin(settings.FRONTEND_URL, "/grants/reply/") variables = { @@ -34,26 +44,11 @@ def send_grant_reply_approved_email(*, grant_id, is_reminder): "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"), - "has_approved_travel": grant.has_approved_travel(), - "has_approved_accommodation": grant.has_approved_accommodation(), + "total_amount": f"{total_amount:.0f}" if total_amount > 0 else None, + "ticket_only": ticket_only, "is_reminder": is_reminder, } - if grant.has_approved_travel(): - 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"{travel_amount:.0f}" - _new_send_grant_email( template_identifier=EmailTemplateIdentifier.grant_approved, grant=grant, diff --git a/backend/grants/tests/test_models.py b/backend/grants/tests/test_models.py index 88a15e1bf1..3f54c45baf 100644 --- a/backend/grants/tests/test_models.py +++ b/backend/grants/tests/test_models.py @@ -145,8 +145,24 @@ def test_calculate_grant_amounts(data): assert grant.total_allocated_amount == Decimal(expected_total) +def test_has_approved_ticket(): + grant = GrantFactory() + assert not grant.has_approved_ticket() + + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__ticket=True, + granted_amount=Decimal("100"), + ) + + assert grant.has_approved_ticket() + + def test_has_approved_travel(): grant = GrantFactory() + assert not grant.has_approved_travel() + GrantReimbursementFactory( grant=grant, category__conference=grant.conference, @@ -159,6 +175,8 @@ def test_has_approved_travel(): def test_has_approved_accommodation(): grant = GrantFactory() + assert not grant.has_approved_accommodation() + GrantReimbursementFactory( grant=grant, category__conference=grant.conference, @@ -169,6 +187,85 @@ def test_has_approved_accommodation(): assert grant.has_approved_accommodation() +def test_has_ticket_only_with_only_ticket(): + grant = GrantFactory() + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__ticket=True, + granted_amount=Decimal("100"), + ) + + assert grant.has_ticket_only() + + +def test_has_ticket_only_with_ticket_and_travel(): + grant = GrantFactory() + 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("500"), + ) + + assert not grant.has_ticket_only() + + +def test_has_ticket_only_without_ticket(): + grant = GrantFactory() + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__travel=True, + granted_amount=Decimal("500"), + ) + + assert not grant.has_ticket_only() + + +def test_total_grantee_reimbursement_amount_excludes_ticket(): + grant = GrantFactory() + 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("500"), + ) + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__accommodation=True, + granted_amount=Decimal("200"), + ) + + # Should be 500 + 200 = 700, excluding ticket + assert grant.total_grantee_reimbursement_amount == Decimal("700") + + +def test_total_grantee_reimbursement_amount_with_only_ticket(): + grant = GrantFactory() + GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__ticket=True, + granted_amount=Decimal("100"), + ) + + assert grant.total_grantee_reimbursement_amount == Decimal("0") + + @pytest.mark.parametrize( "departure_country,country_type", [ diff --git a/backend/grants/tests/test_tasks.py b/backend/grants/tests/test_tasks.py index 2ca2e1b001..fa01b67171 100644 --- a/backend/grants/tests/test_tasks.py +++ b/backend/grants/tests/test_tasks.py @@ -10,10 +10,7 @@ send_grant_reply_waiting_list_email, send_grant_reply_waiting_list_update_email, ) -from grants.tests.factories import ( - GrantFactory, - GrantReimbursementFactory, -) +from grants.tests.factories import GrantFactory, GrantReimbursementFactory from users.tests.factories import UserFactory pytestmark = pytest.mark.django_db @@ -166,8 +163,8 @@ def test_handle_grant_reply_sent_reminder(settings, sent_emails): 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 not sent_email.placeholders["has_approved_travel"] - assert not sent_email.placeholders["has_approved_accommodation"] + assert sent_email.placeholders["ticket_only"] + assert sent_email.placeholders["total_amount"] is None assert sent_email.placeholders["is_reminder"] @@ -240,51 +237,16 @@ def test_handle_grant_approved_ticket_travel_accommodation_reply_sent( ) assert sent_email.placeholders["start_date"] == "2 May" assert sent_email.placeholders["end_date"] == "6 May" - assert sent_email.placeholders["travel_amount"] == "680" + # Total amount is 680 (travel) + 200 (accommodation) = 880, excluding ticket + assert sent_email.placeholders["total_amount"] == "880" 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"] - assert sent_email.placeholders["has_approved_accommodation"] + assert not sent_email.placeholders["ticket_only"] assert not sent_email.placeholders["is_reminder"] -def test_handle_grant_approved_ticket_travel_accommodation_fails_with_no_amount( - settings, -): - settings.FRONTEND_URL = "https://pycon.it" - - conference = ConferenceFactory( - start=datetime(2023, 5, 2, tzinfo=timezone.utc), - end=datetime(2023, 5, 5, tzinfo=timezone.utc), - ) - user = UserFactory( - full_name="Marco Acierno", - email="marco@placeholder.it", - name="Marco", - username="marco", - ) - - grant = GrantFactory( - conference=conference, - applicant_reply_deadline=datetime(2023, 2, 1, 23, 59, tzinfo=timezone.utc), - 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!" - ): - send_grant_reply_approved_email(grant_id=grant.id, is_reminder=False) - - def test_handle_grant_approved_ticket_only_reply_sent(settings, sent_emails): from notifications.models import EmailTemplateIdentifier from notifications.tests.factories import EmailTemplateFactory @@ -344,8 +306,8 @@ def test_handle_grant_approved_ticket_only_reply_sent(settings, sent_emails): 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 not sent_email.placeholders["has_approved_travel"] - assert not sent_email.placeholders["has_approved_accommodation"] + assert sent_email.placeholders["ticket_only"] + assert sent_email.placeholders["total_amount"] is None assert not sent_email.placeholders["is_reminder"] @@ -415,12 +377,46 @@ def test_handle_grant_approved_travel_reply_sent(settings, sent_emails): 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"] - assert not sent_email.placeholders["has_approved_accommodation"] - assert sent_email.placeholders["travel_amount"] == "400" + # Total amount is 400 (travel only), excluding ticket + assert sent_email.placeholders["total_amount"] == "400" + assert not sent_email.placeholders["ticket_only"] assert not sent_email.placeholders["is_reminder"] +def test_send_grant_approved_email_raises_for_no_reimbursements(settings) -> None: + """Verify error is raised when grant has no valid reimbursements.""" + 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), + end=datetime(2023, 5, 5, tzinfo=timezone.utc), + ) + user = UserFactory( + full_name="Marco Acierno", + email="marco@placeholder.it", + ) + + grant = GrantFactory( + conference=conference, + applicant_reply_deadline=datetime(2023, 2, 1, 23, 59, tzinfo=timezone.utc), + user=user, + ) + # No reimbursements - this is an invalid state + + EmailTemplateFactory( + conference=grant.conference, + identifier=EmailTemplateIdentifier.grant_approved, + ) + + with pytest.raises( + ValueError, match="has no reimbursement amount and is not ticket-only" + ): + send_grant_reply_approved_email(grant_id=grant.id, is_reminder=False) + + def test_send_grant_reply_waiting_list_update_email(settings, sent_emails): from notifications.models import EmailTemplateIdentifier from notifications.tests.factories import EmailTemplateFactory diff --git a/backend/integrations/plain_cards.py b/backend/integrations/plain_cards.py index 643727c842..4c1cc7527b 100644 --- a/backend/integrations/plain_cards.py +++ b/backend/integrations/plain_cards.py @@ -76,7 +76,7 @@ def create_grant_card(request, user, conference): { "componentText": { "textColor": "MUTED", - "text": "Travel amount", + "text": "Total reimbursement", } } ], @@ -84,18 +84,18 @@ def create_grant_card(request, user, conference): { "componentText": { "textColor": "NORMAL", - "text": f"€{sum(r.granted_amount for r in grant.reimbursements.filter(category__category='travel'))}", + "text": f"€{grant.total_grantee_reimbursement_amount}", } } ], } } - if grant.has_approved_travel() + if grant.total_grantee_reimbursement_amount > 0 else None ), ( {"componentSpacer": {"spacerSize": "M"}} - if grant.has_approved_travel() + if grant.total_grantee_reimbursement_amount > 0 else None ), { diff --git a/backend/integrations/tests/test_views.py b/backend/integrations/tests/test_views.py index f2900cbc1e..4e24a636b3 100644 --- a/backend/integrations/tests/test_views.py +++ b/backend/integrations/tests/test_views.py @@ -205,11 +205,12 @@ def test_get_plain_customer_cards_grant_card(rest_api_client): assert "Travel" in approval_text assert "Accommodation" in approval_text + # Total reimbursement is 100 (travel) + 200 (accommodation) = 300, excluding ticket assert ( grant_card["components"][4]["componentRow"]["rowAsideContent"][0][ "componentText" ]["text"] - == "€100" + == "€300" ) diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 1af21ce945..8e5860ed5a 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -113,15 +113,16 @@ class EmailTemplate(TimeStampedModel): ], EmailTemplateIdentifier.grant_approved: [ *BASE_PLACEHOLDERS, + "conference_name", + "user_name", "reply_url", "start_date", "end_date", "deadline_date_time", "deadline_date", "visa_page_link", - "has_approved_travel", - "has_approved_accommodation", - "travel_amount", + "total_amount", + "ticket_only", "is_reminder", ], EmailTemplateIdentifier.grant_rejected: [ diff --git a/backend/visa/models.py b/backend/visa/models.py index e3ecee272f..7a9b00fd81 100644 --- a/backend/visa/models.py +++ b/backend/visa/models.py @@ -7,7 +7,7 @@ from model_utils.models import TimeStampedModel from ordered_model.models import OrderedModel -from grants.models import Grant, GrantReimbursementCategory +from grants.models import Grant from submissions.models import Submission from users.models import User from visa.managers import InvitationLetterRequestQuerySet @@ -114,7 +114,7 @@ def grant_approved_type(self) -> str | None: categories.append("travel") if grant.has_approved_accommodation(): categories.append("accommodation") - if grant.has_approved(GrantReimbursementCategory.Category.TICKET): + if grant.has_approved_ticket(): categories.append("ticket") if not categories: