From e73df02b85fa47f780fd770fedceb7a4837ff3cf Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Tue, 7 Apr 2026 15:36:06 +0100 Subject: [PATCH 1/4] Create Pretix voucher and email on grantee confirmation Queue `create_and_send_voucher_to_grantee` when `sendGrantReply` moves status to `confirmed`. Task mirrors schedule voucher flow: Pretix via `create_conference_voucher`, then grant_voucher_code email. Grant admin Create grant vouchers now uses `create_conference_voucher` and handles co-speaker upgrade. Grant pending-status proxy keeps shared confirm_pending_status from custom_admin. Tests patch Pretix in grant voucher admin tests; sendGrantReply tests assert voucher task on confirm only. --- backend/api/grants/mutations.py | 10 ++- .../api/grants/tests/test_send_grant_reply.py | 15 +++- backend/grants/admin.py | 62 +++++++-------- backend/grants/tasks.py | 76 +++++++++++++++++++ backend/grants/tests/test_admin.py | 7 +- 5 files changed, 136 insertions(+), 34 deletions(-) diff --git a/backend/api/grants/mutations.py b/backend/api/grants/mutations.py index 4ff0d1cccb..6b32d8ccba 100644 --- a/backend/api/grants/mutations.py +++ b/backend/api/grants/mutations.py @@ -15,7 +15,11 @@ create_change_admin_log_entry, ) from grants.models import Grant as GrantModel -from grants.tasks import get_name, notify_new_grant_reply_slack +from grants.tasks import ( + create_and_send_voucher_to_grantee, + get_name, + notify_new_grant_reply_slack, +) from notifications.models import EmailTemplate, EmailTemplateIdentifier from participants.models import Participant from privacy_policy.record import record_privacy_policy_acceptance @@ -342,9 +346,13 @@ def send_grant_reply( if grant.status in (GrantModel.Status.pending, GrantModel.Status.rejected): return SendGrantReplyError(message="You cannot reply to this grant") + old_status = grant.status grant.status = input.status.to_grant_status() grant.save() + if old_status != grant.status and grant.status == GrantModel.Status.confirmed: + create_and_send_voucher_to_grantee.delay(grant_id=grant.id) + create_change_admin_log_entry( request.user, grant, f"Grantee has replied with status {grant.status}." ) diff --git a/backend/api/grants/tests/test_send_grant_reply.py b/backend/api/grants/tests/test_send_grant_reply.py index 44c637b9b6..8cde0739fe 100644 --- a/backend/api/grants/tests/test_send_grant_reply.py +++ b/backend/api/grants/tests/test_send_grant_reply.py @@ -75,9 +75,12 @@ def test_user_cannot_reply_if_status_is_rejected(graphql_client, user): ) -def test_status_is_updated_when_reply_is_confirmed(graphql_client, user): +def test_status_is_updated_when_reply_is_confirmed(graphql_client, user, mocker): graphql_client.force_login(user) grant = GrantFactory(user_id=user.id, status=Grant.Status.waiting_for_confirmation) + mock_voucher = mocker.patch( + "api.grants.mutations.create_and_send_voucher_to_grantee" + ) response = _send_grant_reply(graphql_client, grant, status="confirmed") @@ -86,6 +89,8 @@ def test_status_is_updated_when_reply_is_confirmed(graphql_client, user): grant.refresh_from_db() assert grant.status == Grant.Status.confirmed + mock_voucher.delay.assert_called_once_with(grant_id=grant.id) + # Verify audit log entry was created correctly assert LogEntry.objects.filter( user=user, @@ -94,9 +99,12 @@ def test_status_is_updated_when_reply_is_confirmed(graphql_client, user): ).exists() -def test_status_is_updated_when_reply_is_refused(graphql_client, user): +def test_status_is_updated_when_reply_is_refused(graphql_client, user, mocker): graphql_client.force_login(user) grant = GrantFactory(user_id=user.id, status=Grant.Status.waiting_for_confirmation) + mock_voucher = mocker.patch( + "api.grants.mutations.create_and_send_voucher_to_grantee" + ) response = _send_grant_reply(graphql_client, grant, status="refused") @@ -105,6 +113,9 @@ def test_status_is_updated_when_reply_is_refused(graphql_client, user): grant.refresh_from_db() assert grant.status == Grant.Status.refused + # Verify voucher was not sent + mock_voucher.delay.assert_not_called() + # Verify audit log entry was created correctly assert LogEntry.objects.filter( user=user, diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 04fea81c3d..2e6094c439 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -15,6 +15,7 @@ from import_export.resources import ModelResource from conferences.models.conference_voucher import ConferenceVoucher +from conferences.vouchers import create_conference_voucher from countries import countries from countries.filters import CountryFilter from custom_admin.admin import ( @@ -49,6 +50,7 @@ logger = logging.getLogger(__name__) + EXPORT_GRANTS_FIELDS = ( "name", "full_name", @@ -299,18 +301,7 @@ def send_reply_email_waiting_list_update(modeladmin, request, queryset): @validate_single_conference_selection @transaction.atomic def create_grant_vouchers(modeladmin, request, queryset): - conference = queryset.first().conference - existing_vouchers_by_user_id = { - voucher.user_id: voucher - for voucher in ConferenceVoucher.objects.for_conference(conference).filter( - user_id__in=queryset.values_list("user_id", flat=True), - ) - } - - vouchers_to_create = [] - vouchers_to_update = [] - - for grant in queryset.order_by("id"): + for grant in queryset.order_by("id").select_related("user", "conference"): if grant.status != Grant.Status.confirmed: messages.error( request, @@ -319,33 +310,47 @@ def create_grant_vouchers(modeladmin, request, queryset): ) continue - existing_voucher = existing_vouchers_by_user_id.get(grant.user_id) + if not grant.user_id: + messages.error( + request, + f"Grant for {grant.name} has no user linked; can't create a voucher.", + ) + continue + + existing = ( + ConferenceVoucher.objects.for_conference(grant.conference) + .for_user(grant.user) + .first() + ) - if not existing_voucher: + if not existing: + create_conference_voucher( + conference=grant.conference, + user=grant.user, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + ) create_addition_admin_log_entry( request.user, grant, change_message="Created voucher for this grant.", ) + continue - vouchers_to_create.append( - ConferenceVoucher( - conference_id=grant.conference_id, - user_id=grant.user_id, - voucher_code=ConferenceVoucher.generate_code(), - voucher_type=ConferenceVoucher.VoucherType.GRANT, - ) - ) + if existing.voucher_type in ( + ConferenceVoucher.VoucherType.GRANT, + ConferenceVoucher.VoucherType.SPEAKER, + ): continue - if existing_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER: + if existing.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER: messages.warning( request, - f"Grant for {grant.name} already has a Co-Speaker voucher. Upgrading to a Grant voucher.", + f"Grant for {grant.name} already has a Co-Speaker voucher. " + "Upgrading to a Grant voucher.", ) create_change_admin_log_entry( request.user, - existing_voucher, + existing, change_message="Upgraded Co-Speaker voucher to Grant voucher.", ) create_change_admin_log_entry( @@ -353,11 +358,8 @@ def create_grant_vouchers(modeladmin, request, queryset): grant, change_message="Updated existing Co-Speaker voucher to grant.", ) - existing_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT - vouchers_to_update.append(existing_voucher) - - ConferenceVoucher.objects.bulk_create(vouchers_to_create, ignore_conflicts=True) - ConferenceVoucher.objects.bulk_update(vouchers_to_update, ["voucher_type"]) + existing.voucher_type = ConferenceVoucher.VoucherType.GRANT + existing.save(update_fields=["voucher_type"]) messages.success(request, "Vouchers created!") diff --git a/backend/grants/tasks.py b/backend/grants/tasks.py index 9d79947c72..8b333da6c8 100644 --- a/backend/grants/tasks.py +++ b/backend/grants/tasks.py @@ -5,6 +5,8 @@ from django.conf import settings from django.utils import timezone +from conferences.models.conference_voucher import ConferenceVoucher +from conferences.vouchers import create_conference_voucher from grants.models import Grant from integrations import slack from notifications.models import EmailTemplate, EmailTemplateIdentifier @@ -21,6 +23,80 @@ def get_name(user: User | None, fallback: str = "") -> str: return user.full_name or user.name or user.username or fallback +@app.task +def send_grant_voucher_email(*, grant_id: int) -> None: + grant = Grant.objects.select_related("conference", "user").get(pk=grant_id) + if not grant.user_id: + return + + conference_voucher = ( + ConferenceVoucher.objects.for_conference(grant.conference) + .for_user(grant.user) + .first() + ) + if not conference_voucher: + return + + visa_page_link = urljoin(settings.FRONTEND_URL, "/visa") + conference_name = grant.conference.name.localize("en") + + email_template = EmailTemplate.objects.for_conference( + grant.conference + ).get_by_identifier(EmailTemplateIdentifier.grant_voucher_code) + email_template.send_email( + recipient=grant.user, + placeholders={ + "conference_name": conference_name, + "user_name": get_name(grant.user, "there"), + "voucher_code": conference_voucher.voucher_code, + "has_approved_accommodation": grant.has_approved_accommodation(), + "visa_page_link": visa_page_link, + }, + ) + + conference_voucher.voucher_email_sent_at = timezone.now() + conference_voucher.save(update_fields=["voucher_email_sent_at"]) + + +@app.task +def create_and_send_voucher_to_grantee(*, grant_id: int) -> None: + grant = Grant.objects.select_related("user", "conference").get(pk=grant_id) + if grant.status != Grant.Status.confirmed: + return + if not grant.user_id: + return + + user = grant.user + conference = grant.conference + conference_voucher = ( + ConferenceVoucher.objects.for_conference(conference).for_user(user).first() + ) + + if conference_voucher: + if conference_voucher.voucher_type in ( + ConferenceVoucher.VoucherType.GRANT, + ConferenceVoucher.VoucherType.SPEAKER, + ): + logger.info( + "User %s already has a voucher for conference %s, not creating a new one", + user.id, + conference.id, + ) + return + if conference_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER: + conference_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT + conference_voucher.save(update_fields=["voucher_type"]) + send_grant_voucher_email.delay(grant_id=grant.id) + return + + create_conference_voucher( + conference=conference, + user=user, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + ) + send_grant_voucher_email.delay(grant_id=grant.id) + + @app.task def send_grant_reply_approved_email(*, grant_id: int, is_reminder: bool) -> None: logger.info("Sending Reply APPROVED email for Grant %s", grant_id) diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index e5662e3c27..eee16643ca 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -10,9 +10,9 @@ from conferences.models.conference_voucher import ConferenceVoucher from conferences.tests.factories import ConferenceFactory, ConferenceVoucherFactory from grants.admin import ( - confirm_pending_status, GrantAdmin, GrantReimbursementAdmin, + confirm_pending_status, create_grant_vouchers, mark_rejected_and_send_email, reset_pending_status_back_to_status, @@ -305,6 +305,7 @@ def test_send_reply_email_waiting_list_update(rf, mocker, admin_user): def test_create_grant_vouchers(rf, mocker, admin_user): mock_messages = mocker.patch("grants.admin.messages") + mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999}) conference = ConferenceFactory() @@ -357,6 +358,7 @@ def test_create_grant_vouchers_with_existing_voucher_is_reused( rf, mocker, admin_user, type ): mock_messages = mocker.patch("grants.admin.messages") + mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999}) conference = ConferenceFactory() @@ -407,6 +409,7 @@ def test_create_grant_vouchers_with_voucher_from_other_conf_is_ignored( rf, mocker, type, admin_user ): mock_messages = mocker.patch("grants.admin.messages") + mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999}) conference = ConferenceFactory() other_conference = ConferenceFactory() @@ -461,6 +464,7 @@ def test_create_grant_vouchers_with_voucher_from_other_conf_is_ignored( def test_create_grant_vouchers_co_speaker_voucher_is_upgraded(rf, mocker, admin_user): mock_messages = mocker.patch("grants.admin.messages") + mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999}) conference = ConferenceFactory() @@ -506,6 +510,7 @@ def test_create_grant_vouchers_co_speaker_voucher_is_upgraded(rf, mocker, admin_ def test_create_grant_vouchers_only_for_confirmed_grants(rf, mocker, admin_user): mock_messages = mocker.patch("grants.admin.messages") + mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999}) conference = ConferenceFactory() grant_1 = GrantFactory( status=Grant.Status.refused, From 39a223abdb6d7e26ab1653d5f07b4b710679b58e Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Tue, 7 Apr 2026 15:54:20 +0100 Subject: [PATCH 2/4] test(grants): cover voucher task and grant voucher email Add tests for send_grant_voucher_email and create_and_send_voucher_to_grantee (not confirmed, no user, existing speaker/grant voucher, co-speaker upgrade, voucher on other conference), aligned with schedule voucher task tests. --- backend/grants/tests/test_tasks.py | 197 ++++++++++++++++++++++++++++- 1 file changed, 193 insertions(+), 4 deletions(-) diff --git a/backend/grants/tests/test_tasks.py b/backend/grants/tests/test_tasks.py index e70bfc0a84..733a2f4f97 100644 --- a/backend/grants/tests/test_tasks.py +++ b/backend/grants/tests/test_tasks.py @@ -1,14 +1,22 @@ from datetime import datetime, timezone from decimal import Decimal - import pytest +import time_machine -from conferences.tests.factories import ConferenceFactory, DeadlineFactory +from conferences.models.conference_voucher import ConferenceVoucher +from conferences.tests.factories import ( + ConferenceFactory, + ConferenceVoucherFactory, + DeadlineFactory, +) +from grants.models import Grant from grants.tasks import ( + create_and_send_voucher_to_grantee, 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_voucher_email, ) from grants.tests.factories import GrantFactory, GrantReimbursementFactory from users.tests.factories import UserFactory @@ -380,7 +388,6 @@ def test_handle_grant_approved_travel_reply_sent(settings, sent_emails): 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 @@ -462,7 +469,189 @@ def test_send_grant_reply_waiting_list_update_email(settings, sent_emails): def test_send_grant_waiting_list_email_missing_deadline(): grant = GrantFactory() - # No deadline created with pytest.raises(ValueError, match="missing grants_waiting_list_update deadline"): send_grant_reply_waiting_list_email(grant_id=grant.id) + + +def test_send_grant_voucher_email(settings, sent_emails): + from notifications.models import EmailTemplateIdentifier + from notifications.tests.factories import EmailTemplateFactory + + settings.FRONTEND_URL = "https://pycon.it" + user = UserFactory( + full_name="Marco Acierno", + email="marco@placeholder.it", + name="Marco", + username="marco", + ) + grant = GrantFactory(user=user, status=Grant.Status.confirmed) + ConferenceVoucherFactory( + user=user, + conference=grant.conference, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + voucher_code="GRANT99", + ) + + EmailTemplateFactory( + conference=grant.conference, + identifier=EmailTemplateIdentifier.grant_voucher_code, + ) + + with time_machine.travel("2020-10-10 10:00:00Z", tick=False): + send_grant_voucher_email(grant_id=grant.id) + + emails_sent = sent_emails() + assert emails_sent.count() == 1 + + sent_email = emails_sent.first() + assert ( + sent_email.email_template.identifier + == EmailTemplateIdentifier.grant_voucher_code + ) + assert sent_email.email_template.conference == grant.conference + assert sent_email.recipient == user + assert sent_email.placeholders["user_name"] == "Marco Acierno" + assert sent_email.placeholders["conference_name"] == grant.conference.name.localize( + "en" + ) + assert sent_email.placeholders["voucher_code"] == "GRANT99" + assert sent_email.placeholders["has_approved_accommodation"] is False + assert sent_email.placeholders["visa_page_link"] == "https://pycon.it/visa" + + voucher = ConferenceVoucher.objects.get( + conference=grant.conference, + user=user, + ) + assert voucher.voucher_email_sent_at == datetime( + 2020, 10, 10, 10, 0, 0, tzinfo=timezone.utc + ) + + +def test_create_and_send_voucher_to_grantee(mocker): + mock_create = mocker.patch( + "conferences.vouchers.create_voucher", return_value={"id": 123} + ) + mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + + grant = GrantFactory(status=Grant.Status.confirmed) + + create_and_send_voucher_to_grantee(grant_id=grant.id) + + assert ( + ConferenceVoucher.objects.for_conference(grant.conference) + .filter( + user=grant.user, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + ) + .exists() + ) + + mock_create.assert_called_once() + mock_send_email.delay.assert_called_once_with(grant_id=grant.id) + + +def test_create_and_send_voucher_to_grantee_does_nothing_if_not_confirmed(mocker): + mock_create = mocker.patch("conferences.vouchers.create_voucher") + mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + + grant = GrantFactory(status=Grant.Status.waiting_for_confirmation) + + create_and_send_voucher_to_grantee(grant_id=grant.id) + + mock_create.assert_not_called() + mock_send_email.delay.assert_not_called() + + +def test_create_and_send_voucher_to_grantee_does_nothing_if_no_user(mocker): + mock_create = mocker.patch("conferences.vouchers.create_voucher") + mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + + grant = GrantFactory(status=Grant.Status.confirmed) + Grant.objects.filter(pk=grant.pk).update(user_id=None) + grant.refresh_from_db() + + create_and_send_voucher_to_grantee(grant_id=grant.id) + + mock_create.assert_not_called() + mock_send_email.delay.assert_not_called() + + +@pytest.mark.parametrize( + "voucher_type", + [ + ConferenceVoucher.VoucherType.SPEAKER, + ConferenceVoucher.VoucherType.GRANT, + ], +) +def test_create_and_send_voucher_to_grantee_does_nothing_if_voucher_exists( + mocker, voucher_type +): + mock_create = mocker.patch("conferences.vouchers.create_voucher") + mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + + grant = GrantFactory(status=Grant.Status.confirmed) + ConferenceVoucherFactory( + conference=grant.conference, + user=grant.user, + voucher_type=voucher_type, + ) + + create_and_send_voucher_to_grantee(grant_id=grant.id) + + mock_create.assert_not_called() + mock_send_email.delay.assert_not_called() + + +def test_create_and_send_voucher_to_grantee_upgrades_co_speaker(mocker): + mock_create = mocker.patch("conferences.vouchers.create_voucher") + mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + + grant = GrantFactory(status=Grant.Status.confirmed) + ConferenceVoucherFactory( + conference=grant.conference, + user=grant.user, + voucher_type=ConferenceVoucher.VoucherType.CO_SPEAKER, + ) + + create_and_send_voucher_to_grantee(grant_id=grant.id) + + voucher = ConferenceVoucher.objects.get( + conference=grant.conference, + user=grant.user, + ) + assert voucher.voucher_type == ConferenceVoucher.VoucherType.GRANT + + mock_create.assert_not_called() + mock_send_email.delay.assert_called_once_with(grant_id=grant.id) + + +def test_create_and_send_voucher_to_grantee_creates_when_voucher_on_other_conference( + mocker, +): + mock_create = mocker.patch( + "conferences.vouchers.create_voucher", return_value={"id": 123} + ) + mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + + other_conference = ConferenceFactory() + grant = GrantFactory(status=Grant.Status.confirmed) + ConferenceVoucherFactory( + conference=other_conference, + user=grant.user, + voucher_type=ConferenceVoucher.VoucherType.SPEAKER, + ) + + create_and_send_voucher_to_grantee(grant_id=grant.id) + + assert ( + ConferenceVoucher.objects.for_conference(grant.conference) + .filter( + user=grant.user, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + ) + .exists() + ) + + mock_create.assert_called_once() + mock_send_email.delay.assert_called_once_with(grant_id=grant.id) From d32d672428a0b60aaa0c45fcff870461db713bf0 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Tue, 7 Apr 2026 16:04:12 +0100 Subject: [PATCH 3/4] fix(grants): prefetch vouchers in admin; idempotent grant voucher email - create_grant_vouchers: load conference vouchers for selected users in one query; update map after creating new vouchers (avoids N+1). - send_grant_voucher_email: no-op when voucher_email_sent_at is set. - Clear voucher_email_sent_at when upgrading CO_SPEAKER to GRANT so the grant template still sends after a prior voucher email. - Tests: skip-when-sent; co-speaker upgrade with prior sent_at. --- backend/grants/admin.py | 26 +++++++++----- backend/grants/tasks.py | 13 ++++++- backend/grants/tests/test_tasks.py | 58 ++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 2e6094c439..33f1f6a8fb 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -301,7 +301,19 @@ def send_reply_email_waiting_list_update(modeladmin, request, queryset): @validate_single_conference_selection @transaction.atomic def create_grant_vouchers(modeladmin, request, queryset): - for grant in queryset.order_by("id").select_related("user", "conference"): + grants_ordered = list(queryset.order_by("id").select_related("user", "conference")) + conference_id = grants_ordered[0].conference_id if grants_ordered else None + user_ids = {g.user_id for g in grants_ordered if g.user_id} + + existing_by_user: Dict[int, ConferenceVoucher] = {} + if conference_id is not None and user_ids: + for voucher in ConferenceVoucher.objects.filter( + conference_id=conference_id, + user_id__in=user_ids, + ): + existing_by_user[voucher.user_id] = voucher + + for grant in grants_ordered: if grant.status != Grant.Status.confirmed: messages.error( request, @@ -317,18 +329,15 @@ def create_grant_vouchers(modeladmin, request, queryset): ) continue - existing = ( - ConferenceVoucher.objects.for_conference(grant.conference) - .for_user(grant.user) - .first() - ) + existing = existing_by_user.get(grant.user_id) if not existing: - create_conference_voucher( + new_voucher = create_conference_voucher( conference=grant.conference, user=grant.user, voucher_type=ConferenceVoucher.VoucherType.GRANT, ) + existing_by_user[grant.user_id] = new_voucher create_addition_admin_log_entry( request.user, grant, @@ -359,7 +368,8 @@ def create_grant_vouchers(modeladmin, request, queryset): change_message="Updated existing Co-Speaker voucher to grant.", ) existing.voucher_type = ConferenceVoucher.VoucherType.GRANT - existing.save(update_fields=["voucher_type"]) + existing.voucher_email_sent_at = None + existing.save(update_fields=["voucher_type", "voucher_email_sent_at"]) messages.success(request, "Vouchers created!") diff --git a/backend/grants/tasks.py b/backend/grants/tasks.py index 8b333da6c8..a7e344a954 100644 --- a/backend/grants/tasks.py +++ b/backend/grants/tasks.py @@ -37,6 +37,14 @@ def send_grant_voucher_email(*, grant_id: int) -> None: if not conference_voucher: return + if conference_voucher.voucher_email_sent_at is not None: + logger.info( + "Grant voucher email already sent for user %s conference %s, skipping", + grant.user_id, + grant.conference_id, + ) + return + visa_page_link = urljoin(settings.FRONTEND_URL, "/visa") conference_name = grant.conference.name.localize("en") @@ -85,7 +93,10 @@ def create_and_send_voucher_to_grantee(*, grant_id: int) -> None: return if conference_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER: conference_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT - conference_voucher.save(update_fields=["voucher_type"]) + conference_voucher.voucher_email_sent_at = None + conference_voucher.save( + update_fields=["voucher_type", "voucher_email_sent_at"] + ) send_grant_voucher_email.delay(grant_id=grant.id) return diff --git a/backend/grants/tests/test_tasks.py b/backend/grants/tests/test_tasks.py index 733a2f4f97..e3c96065cb 100644 --- a/backend/grants/tests/test_tasks.py +++ b/backend/grants/tests/test_tasks.py @@ -528,6 +528,30 @@ def test_send_grant_voucher_email(settings, sent_emails): ) +def test_send_grant_voucher_email_skips_when_already_sent(settings, sent_emails): + from notifications.models import EmailTemplateIdentifier + from notifications.tests.factories import EmailTemplateFactory + + settings.FRONTEND_URL = "https://pycon.it" + user = UserFactory() + grant = GrantFactory(user=user, status=Grant.Status.confirmed) + ConferenceVoucherFactory( + user=user, + conference=grant.conference, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + voucher_code="GRANT99", + voucher_email_sent_at=datetime(2020, 1, 1, tzinfo=timezone.utc), + ) + EmailTemplateFactory( + conference=grant.conference, + identifier=EmailTemplateIdentifier.grant_voucher_code, + ) + + send_grant_voucher_email(grant_id=grant.id) + + assert sent_emails().count() == 0 + + def test_create_and_send_voucher_to_grantee(mocker): mock_create = mocker.patch( "conferences.vouchers.create_voucher", return_value={"id": 123} @@ -626,6 +650,40 @@ def test_create_and_send_voucher_to_grantee_upgrades_co_speaker(mocker): mock_send_email.delay.assert_called_once_with(grant_id=grant.id) +def test_create_and_send_voucher_to_grantee_upgrades_co_speaker_clears_email_sent_at( + mocker, settings, sent_emails +): + from notifications.models import EmailTemplateIdentifier + from notifications.tests.factories import EmailTemplateFactory + + settings.FRONTEND_URL = "https://pycon.it" + mock_create = mocker.patch("conferences.vouchers.create_voucher") + grant = GrantFactory(status=Grant.Status.confirmed) + prior_sent = datetime(2020, 5, 5, 12, 0, 0, tzinfo=timezone.utc) + ConferenceVoucherFactory( + conference=grant.conference, + user=grant.user, + voucher_type=ConferenceVoucher.VoucherType.CO_SPEAKER, + voucher_email_sent_at=prior_sent, + ) + EmailTemplateFactory( + conference=grant.conference, + identifier=EmailTemplateIdentifier.grant_voucher_code, + ) + + create_and_send_voucher_to_grantee(grant_id=grant.id) + + mock_create.assert_not_called() + voucher = ConferenceVoucher.objects.get( + conference=grant.conference, + user=grant.user, + ) + assert voucher.voucher_type == ConferenceVoucher.VoucherType.GRANT + assert voucher.voucher_email_sent_at is not None + assert voucher.voucher_email_sent_at != prior_sent + assert sent_emails().count() == 1 + + def test_create_and_send_voucher_to_grantee_creates_when_voucher_on_other_conference( mocker, ): From 180e3ae60dc7f06c13465a44f4eea0c4eac61479 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Tue, 7 Apr 2026 16:22:08 +0100 Subject: [PATCH 4/4] refactor(grants): use send_conference_voucher_email; drop grant_voucher_code - create_and_send_voucher_to_grantee queues send_conference_voucher_email with conference_voucher_id (lazy import avoids circular import with get_name). - Remove send_grant_voucher_email; grantees get the shared voucher_code template like the Voucher admin action. - send_conference_voucher_email: skip if voucher_email_sent_at set; save with update_fields. - Remove EmailTemplateIdentifier.grant_voucher_code; migration reassigns any rows to voucher_code then tightens choices. --- backend/conferences/tasks.py | 14 +- backend/conferences/tests/test_tasks.py | 18 +++ backend/grants/tasks.py | 53 +------ backend/grants/tests/test_tasks.py | 139 +++++------------- ..._grant_voucher_code_template_identifier.py | 69 +++++++++ backend/notifications/models.py | 9 -- 6 files changed, 138 insertions(+), 164 deletions(-) create mode 100644 backend/notifications/migrations/0022_remove_grant_voucher_code_template_identifier.py diff --git a/backend/conferences/tasks.py b/backend/conferences/tasks.py index 6a89219b9b..88c08317d5 100644 --- a/backend/conferences/tasks.py +++ b/backend/conferences/tasks.py @@ -1,17 +1,25 @@ +import logging + from django.utils import timezone from notifications.models import EmailTemplate, EmailTemplateIdentifier from grants.tasks import get_name -import logging from pycon.celery import app logger = logging.getLogger(__name__) @app.task -def send_conference_voucher_email(conference_voucher_id): +def send_conference_voucher_email(conference_voucher_id: int) -> None: from conferences.models import ConferenceVoucher conference_voucher = ConferenceVoucher.objects.get(id=conference_voucher_id) + if conference_voucher.voucher_email_sent_at is not None: + logger.info( + "Voucher email already sent for conference_voucher %s, skipping", + conference_voucher_id, + ) + return + conference = conference_voucher.conference user = conference_voucher.user @@ -31,4 +39,4 @@ def send_conference_voucher_email(conference_voucher_id): ) conference_voucher.voucher_email_sent_at = timezone.now() - conference_voucher.save() + conference_voucher.save(update_fields=["voucher_email_sent_at"]) diff --git a/backend/conferences/tests/test_tasks.py b/backend/conferences/tests/test_tasks.py index a562d33f18..75e8828703 100644 --- a/backend/conferences/tests/test_tasks.py +++ b/backend/conferences/tests/test_tasks.py @@ -60,3 +60,21 @@ def test_send_conference_voucher_email(voucher_type, sent_emails): assert conference_voucher.voucher_email_sent_at == datetime( 2020, 10, 10, 10, 0, 0, tzinfo=timezone.utc ) + + +def test_send_conference_voucher_email_skips_when_already_sent(sent_emails): + user = UserFactory() + conference_voucher = ConferenceVoucherFactory( + user=user, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + voucher_code="GRANT99", + voucher_email_sent_at=datetime(2020, 1, 1, tzinfo=timezone.utc), + ) + EmailTemplateFactory( + conference=conference_voucher.conference, + identifier=EmailTemplateIdentifier.voucher_code, + ) + + send_conference_voucher_email(conference_voucher_id=conference_voucher.id) + + assert sent_emails().count() == 0 diff --git a/backend/grants/tasks.py b/backend/grants/tasks.py index a7e344a954..b20c8a0b77 100644 --- a/backend/grants/tasks.py +++ b/backend/grants/tasks.py @@ -23,51 +23,10 @@ def get_name(user: User | None, fallback: str = "") -> str: return user.full_name or user.name or user.username or fallback -@app.task -def send_grant_voucher_email(*, grant_id: int) -> None: - grant = Grant.objects.select_related("conference", "user").get(pk=grant_id) - if not grant.user_id: - return - - conference_voucher = ( - ConferenceVoucher.objects.for_conference(grant.conference) - .for_user(grant.user) - .first() - ) - if not conference_voucher: - return - - if conference_voucher.voucher_email_sent_at is not None: - logger.info( - "Grant voucher email already sent for user %s conference %s, skipping", - grant.user_id, - grant.conference_id, - ) - return - - visa_page_link = urljoin(settings.FRONTEND_URL, "/visa") - conference_name = grant.conference.name.localize("en") - - email_template = EmailTemplate.objects.for_conference( - grant.conference - ).get_by_identifier(EmailTemplateIdentifier.grant_voucher_code) - email_template.send_email( - recipient=grant.user, - placeholders={ - "conference_name": conference_name, - "user_name": get_name(grant.user, "there"), - "voucher_code": conference_voucher.voucher_code, - "has_approved_accommodation": grant.has_approved_accommodation(), - "visa_page_link": visa_page_link, - }, - ) - - conference_voucher.voucher_email_sent_at = timezone.now() - conference_voucher.save(update_fields=["voucher_email_sent_at"]) - - @app.task def create_and_send_voucher_to_grantee(*, grant_id: int) -> None: + from conferences.tasks import send_conference_voucher_email + grant = Grant.objects.select_related("user", "conference").get(pk=grant_id) if grant.status != Grant.Status.confirmed: return @@ -97,15 +56,17 @@ def create_and_send_voucher_to_grantee(*, grant_id: int) -> None: conference_voucher.save( update_fields=["voucher_type", "voucher_email_sent_at"] ) - send_grant_voucher_email.delay(grant_id=grant.id) + send_conference_voucher_email.delay( + conference_voucher_id=conference_voucher.id + ) return - create_conference_voucher( + new_voucher = create_conference_voucher( conference=conference, user=user, voucher_type=ConferenceVoucher.VoucherType.GRANT, ) - send_grant_voucher_email.delay(grant_id=grant.id) + send_conference_voucher_email.delay(conference_voucher_id=new_voucher.id) @app.task diff --git a/backend/grants/tests/test_tasks.py b/backend/grants/tests/test_tasks.py index e3c96065cb..4fa09bc00c 100644 --- a/backend/grants/tests/test_tasks.py +++ b/backend/grants/tests/test_tasks.py @@ -16,7 +16,6 @@ send_grant_reply_rejected_email, send_grant_reply_waiting_list_email, send_grant_reply_waiting_list_update_email, - send_grant_voucher_email, ) from grants.tests.factories import GrantFactory, GrantReimbursementFactory from users.tests.factories import UserFactory @@ -474,110 +473,29 @@ def test_send_grant_waiting_list_email_missing_deadline(): send_grant_reply_waiting_list_email(grant_id=grant.id) -def test_send_grant_voucher_email(settings, sent_emails): - from notifications.models import EmailTemplateIdentifier - from notifications.tests.factories import EmailTemplateFactory - - settings.FRONTEND_URL = "https://pycon.it" - user = UserFactory( - full_name="Marco Acierno", - email="marco@placeholder.it", - name="Marco", - username="marco", - ) - grant = GrantFactory(user=user, status=Grant.Status.confirmed) - ConferenceVoucherFactory( - user=user, - conference=grant.conference, - voucher_type=ConferenceVoucher.VoucherType.GRANT, - voucher_code="GRANT99", - ) - - EmailTemplateFactory( - conference=grant.conference, - identifier=EmailTemplateIdentifier.grant_voucher_code, - ) - - with time_machine.travel("2020-10-10 10:00:00Z", tick=False): - send_grant_voucher_email(grant_id=grant.id) - - emails_sent = sent_emails() - assert emails_sent.count() == 1 - - sent_email = emails_sent.first() - assert ( - sent_email.email_template.identifier - == EmailTemplateIdentifier.grant_voucher_code - ) - assert sent_email.email_template.conference == grant.conference - assert sent_email.recipient == user - assert sent_email.placeholders["user_name"] == "Marco Acierno" - assert sent_email.placeholders["conference_name"] == grant.conference.name.localize( - "en" - ) - assert sent_email.placeholders["voucher_code"] == "GRANT99" - assert sent_email.placeholders["has_approved_accommodation"] is False - assert sent_email.placeholders["visa_page_link"] == "https://pycon.it/visa" - - voucher = ConferenceVoucher.objects.get( - conference=grant.conference, - user=user, - ) - assert voucher.voucher_email_sent_at == datetime( - 2020, 10, 10, 10, 0, 0, tzinfo=timezone.utc - ) - - -def test_send_grant_voucher_email_skips_when_already_sent(settings, sent_emails): - from notifications.models import EmailTemplateIdentifier - from notifications.tests.factories import EmailTemplateFactory - - settings.FRONTEND_URL = "https://pycon.it" - user = UserFactory() - grant = GrantFactory(user=user, status=Grant.Status.confirmed) - ConferenceVoucherFactory( - user=user, - conference=grant.conference, - voucher_type=ConferenceVoucher.VoucherType.GRANT, - voucher_code="GRANT99", - voucher_email_sent_at=datetime(2020, 1, 1, tzinfo=timezone.utc), - ) - EmailTemplateFactory( - conference=grant.conference, - identifier=EmailTemplateIdentifier.grant_voucher_code, - ) - - send_grant_voucher_email(grant_id=grant.id) - - assert sent_emails().count() == 0 - - def test_create_and_send_voucher_to_grantee(mocker): mock_create = mocker.patch( "conferences.vouchers.create_voucher", return_value={"id": 123} ) - mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + mock_send_email = mocker.patch("conferences.tasks.send_conference_voucher_email") grant = GrantFactory(status=Grant.Status.confirmed) create_and_send_voucher_to_grantee(grant_id=grant.id) - assert ( - ConferenceVoucher.objects.for_conference(grant.conference) - .filter( - user=grant.user, - voucher_type=ConferenceVoucher.VoucherType.GRANT, - ) - .exists() + voucher = ConferenceVoucher.objects.get( + conference=grant.conference, + user=grant.user, + voucher_type=ConferenceVoucher.VoucherType.GRANT, ) mock_create.assert_called_once() - mock_send_email.delay.assert_called_once_with(grant_id=grant.id) + mock_send_email.delay.assert_called_once_with(conference_voucher_id=voucher.id) def test_create_and_send_voucher_to_grantee_does_nothing_if_not_confirmed(mocker): mock_create = mocker.patch("conferences.vouchers.create_voucher") - mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + mock_send_email = mocker.patch("conferences.tasks.send_conference_voucher_email") grant = GrantFactory(status=Grant.Status.waiting_for_confirmation) @@ -589,7 +507,7 @@ def test_create_and_send_voucher_to_grantee_does_nothing_if_not_confirmed(mocker def test_create_and_send_voucher_to_grantee_does_nothing_if_no_user(mocker): mock_create = mocker.patch("conferences.vouchers.create_voucher") - mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + mock_send_email = mocker.patch("conferences.tasks.send_conference_voucher_email") grant = GrantFactory(status=Grant.Status.confirmed) Grant.objects.filter(pk=grant.pk).update(user_id=None) @@ -612,7 +530,7 @@ def test_create_and_send_voucher_to_grantee_does_nothing_if_voucher_exists( mocker, voucher_type ): mock_create = mocker.patch("conferences.vouchers.create_voucher") - mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + mock_send_email = mocker.patch("conferences.tasks.send_conference_voucher_email") grant = GrantFactory(status=Grant.Status.confirmed) ConferenceVoucherFactory( @@ -629,10 +547,10 @@ def test_create_and_send_voucher_to_grantee_does_nothing_if_voucher_exists( def test_create_and_send_voucher_to_grantee_upgrades_co_speaker(mocker): mock_create = mocker.patch("conferences.vouchers.create_voucher") - mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + mock_send_email = mocker.patch("conferences.tasks.send_conference_voucher_email") grant = GrantFactory(status=Grant.Status.confirmed) - ConferenceVoucherFactory( + voucher = ConferenceVoucherFactory( conference=grant.conference, user=grant.user, voucher_type=ConferenceVoucher.VoucherType.CO_SPEAKER, @@ -640,23 +558,19 @@ def test_create_and_send_voucher_to_grantee_upgrades_co_speaker(mocker): create_and_send_voucher_to_grantee(grant_id=grant.id) - voucher = ConferenceVoucher.objects.get( - conference=grant.conference, - user=grant.user, - ) + voucher.refresh_from_db() assert voucher.voucher_type == ConferenceVoucher.VoucherType.GRANT mock_create.assert_not_called() - mock_send_email.delay.assert_called_once_with(grant_id=grant.id) + mock_send_email.delay.assert_called_once_with(conference_voucher_id=voucher.id) def test_create_and_send_voucher_to_grantee_upgrades_co_speaker_clears_email_sent_at( - mocker, settings, sent_emails + mocker, sent_emails ): from notifications.models import EmailTemplateIdentifier from notifications.tests.factories import EmailTemplateFactory - settings.FRONTEND_URL = "https://pycon.it" mock_create = mocker.patch("conferences.vouchers.create_voucher") grant = GrantFactory(status=Grant.Status.confirmed) prior_sent = datetime(2020, 5, 5, 12, 0, 0, tzinfo=timezone.utc) @@ -668,10 +582,11 @@ def test_create_and_send_voucher_to_grantee_upgrades_co_speaker_clears_email_sen ) EmailTemplateFactory( conference=grant.conference, - identifier=EmailTemplateIdentifier.grant_voucher_code, + identifier=EmailTemplateIdentifier.voucher_code, ) - create_and_send_voucher_to_grantee(grant_id=grant.id) + with time_machine.travel("2020-10-10 10:00:00Z", tick=False): + create_and_send_voucher_to_grantee(grant_id=grant.id) mock_create.assert_not_called() voucher = ConferenceVoucher.objects.get( @@ -679,9 +594,15 @@ def test_create_and_send_voucher_to_grantee_upgrades_co_speaker_clears_email_sen user=grant.user, ) assert voucher.voucher_type == ConferenceVoucher.VoucherType.GRANT - assert voucher.voucher_email_sent_at is not None - assert voucher.voucher_email_sent_at != prior_sent + assert voucher.voucher_email_sent_at == datetime( + 2020, 10, 10, 10, 0, 0, tzinfo=timezone.utc + ) assert sent_emails().count() == 1 + sent_email = sent_emails().first() + assert sent_email.email_template.identifier == EmailTemplateIdentifier.voucher_code + assert ( + sent_email.placeholders["voucher_type"] == ConferenceVoucher.VoucherType.GRANT + ) def test_create_and_send_voucher_to_grantee_creates_when_voucher_on_other_conference( @@ -690,7 +611,7 @@ def test_create_and_send_voucher_to_grantee_creates_when_voucher_on_other_confer mock_create = mocker.patch( "conferences.vouchers.create_voucher", return_value={"id": 123} ) - mock_send_email = mocker.patch("grants.tasks.send_grant_voucher_email") + mock_send_email = mocker.patch("conferences.tasks.send_conference_voucher_email") other_conference = ConferenceFactory() grant = GrantFactory(status=Grant.Status.confirmed) @@ -711,5 +632,11 @@ def test_create_and_send_voucher_to_grantee_creates_when_voucher_on_other_confer .exists() ) + voucher = ConferenceVoucher.objects.get( + conference=grant.conference, + user=grant.user, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + ) + mock_create.assert_called_once() - mock_send_email.delay.assert_called_once_with(grant_id=grant.id) + mock_send_email.delay.assert_called_once_with(conference_voucher_id=voucher.id) diff --git a/backend/notifications/migrations/0022_remove_grant_voucher_code_template_identifier.py b/backend/notifications/migrations/0022_remove_grant_voucher_code_template_identifier.py new file mode 100644 index 0000000000..76553ee837 --- /dev/null +++ b/backend/notifications/migrations/0022_remove_grant_voucher_code_template_identifier.py @@ -0,0 +1,69 @@ +# Generated by Django 5.1.4 + +from django.db import migrations, models + + +def forwards_grant_voucher_to_voucher_code(apps, schema_editor): + EmailTemplate = apps.get_model("notifications", "EmailTemplate") + EmailTemplate.objects.filter(identifier="grant_voucher_code").update( + identifier="voucher_code" + ) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("notifications", "0021_alter_emailtemplate_identifier"), + ] + + operations = [ + migrations.RunPython( + forwards_grant_voucher_to_voucher_code, + noop_reverse, + ), + migrations.AlterField( + model_name="emailtemplate", + name="identifier", + field=models.CharField( + choices=[ + ("proposal_accepted", "Proposal accepted"), + ("proposal_scheduled", "Proposal scheduled"), + ("proposal_rejected", "Proposal rejected"), + ("proposal_in_waiting_list", "Proposal in waiting list"), + ( + "proposal_scheduled_time_changed", + "Proposal scheduled time changed", + ), + ( + "proposal_received_confirmation", + "Proposal received confirmation", + ), + ("speaker_communication", "Speaker communication"), + ("voucher_code", "Voucher code"), + ("reset_password", "[System] Reset password"), + ( + "grant_application_confirmation", + "Grant application confirmation", + ), + ("grant_approved", "Grant approved"), + ("grant_rejected", "Grant rejected"), + ("grant_waiting_list", "Grant waiting list"), + ( + "grant_waiting_list_update", + "Grant waiting list update", + ), + ("sponsorship_brochure", "Sponsorship brochure"), + ( + "visa_invitation_letter_download", + "Visa invitation letter download", + ), + ("custom", "Custom"), + ], + max_length=200, + verbose_name="identifier", + ), + ), + ] diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 8e5860ed5a..bf9f1b1b5d 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -45,7 +45,6 @@ class EmailTemplateIdentifier(models.TextChoices): "grant_waiting_list_update", _("Grant waiting list update"), ) - grant_voucher_code = "grant_voucher_code", _("Grant voucher code") sponsorship_brochure = "sponsorship_brochure", _("Sponsorship brochure") @@ -155,14 +154,6 @@ class EmailTemplate(TimeStampedModel): "body", "subject", ], - EmailTemplateIdentifier.grant_voucher_code: [ - *BASE_PLACEHOLDERS, - "conference_name", - "voucher_code", - "user_name", - "has_approved_accommodation", - "visa_page_link", - ], EmailTemplateIdentifier.sponsorship_brochure: [ *BASE_PLACEHOLDERS, "brochure_url",