From 318c9fbad3c830bddbe154f1b3482c06f58f92c4 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Fri, 10 Apr 2026 11:19:52 +0100 Subject: [PATCH] feat(grants): Pretix voucher and email when grantee confirms When a grantee confirms via sendGrantReply, enqueue create_and_send_voucher_to_grantee after transaction.on_commit: create or reuse ConferenceVoucher in Pretix (create_conference_voucher), then send_conference_voucher_email (voucher_code template, same as Voucher admin). Task mirrors schedule voucher flow: skip duplicate Pretix row for existing GRANT/SPEAKER; queue email if voucher_email_sent_at is None (retry after partial success); upgrade CO_SPEAKER to GRANT. Staff resend anytime via Voucher admin Send voucher via email. Admin Create grant vouchers uses create_conference_voucher with prefetched existing rows; no transaction.atomic around Pretix loop. Confirm pending status unchanged. Remove grant_voucher_code email identifier; migration 0022 rewrites rows to voucher_code and documents noop reverse. Tests: GraphQL confirm + on_commit, grant admin vouchers, grant/conference tasks. --- backend/api/grants/mutations.py | 14 +- .../api/grants/tests/test_send_grant_reply.py | 20 +- backend/conferences/tasks.py | 7 +- backend/conferences/tests/test_tasks.py | 23 ++ backend/grants/admin.py | 70 +++--- backend/grants/tasks.py | 54 +++++ backend/grants/tests/test_admin.py | 7 +- backend/grants/tests/test_tasks.py | 209 +++++++++++++++++- ..._grant_voucher_code_template_identifier.py | 71 ++++++ backend/notifications/models.py | 9 - 10 files changed, 432 insertions(+), 52 deletions(-) create mode 100644 backend/notifications/migrations/0022_remove_grant_voucher_code_template_identifier.py diff --git a/backend/api/grants/mutations.py b/backend/api/grants/mutations.py index 4ff0d1cccb..8dce2e4e3c 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,17 @@ 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: + transaction.on_commit( + lambda gid=grant.id: create_and_send_voucher_to_grantee.delay( + grant_id=gid + ) + ) + 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..e2dab34779 100644 --- a/backend/api/grants/tests/test_send_grant_reply.py +++ b/backend/api/grants/tests/test_send_grant_reply.py @@ -75,17 +75,25 @@ 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, django_capture_on_commit_callbacks +): 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") + with django_capture_on_commit_callbacks(execute=True): + response = _send_grant_reply(graphql_client, grant, status="confirmed") assert response["data"]["sendGrantReply"]["__typename"] == "Grant" 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 +102,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 +116,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/conferences/tasks.py b/backend/conferences/tasks.py index 6a89219b9b..e594243ec8 100644 --- a/backend/conferences/tasks.py +++ b/backend/conferences/tasks.py @@ -1,14 +1,11 @@ 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) @@ -31,4 +28,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..9d501bb421 100644 --- a/backend/conferences/tests/test_tasks.py +++ b/backend/conferences/tests/test_tasks.py @@ -60,3 +60,26 @@ 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_sends_again_when_already_sent(sent_emails): + user = UserFactory(full_name="Remind Me") + conference_voucher = ConferenceVoucherFactory( + user=user, + voucher_type=ConferenceVoucher.VoucherType.SPEAKER, + voucher_code="REM123", + voucher_email_sent_at=datetime(2020, 1, 1, tzinfo=timezone.utc), + ) + EmailTemplateFactory( + conference=conference_voucher.conference, + identifier=EmailTemplateIdentifier.voucher_code, + ) + + with time_machine.travel("2020-06-01 12:00:00Z", tick=False): + send_conference_voucher_email(conference_voucher_id=conference_voucher.id) + + assert sent_emails().count() == 1 + conference_voucher.refresh_from_db() + assert conference_voucher.voucher_email_sent_at == datetime( + 2020, 6, 1, 12, 0, 0, tzinfo=timezone.utc + ) diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 04fea81c3d..cbf071c972 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -4,7 +4,6 @@ from django.contrib import admin, messages from django.contrib.admin import SimpleListFilter -from django.db import transaction from django.db.models import Exists, OuterRef from django.db.models.query import QuerySet from django.urls import reverse @@ -15,6 +14,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 +49,7 @@ logger = logging.getLogger(__name__) + EXPORT_GRANTS_FIELDS = ( "name", "full_name", @@ -297,20 +298,20 @@ def send_reply_email_waiting_list_update(modeladmin, request, queryset): @admin.action(description="Create grant vouchers") @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), - ) - } + 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} - vouchers_to_create = [] - vouchers_to_update = [] + 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 queryset.order_by("id"): + for grant in grants_ordered: if grant.status != Grant.Status.confirmed: messages.error( request, @@ -319,33 +320,44 @@ 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 - if not existing_voucher: + existing = existing_by_user.get(grant.user_id) + + if not existing: + 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, 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 +365,9 @@ 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.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 9d79947c72..a233be2c06 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,58 @@ def get_name(user: User | None, fallback: str = "") -> str: return user.full_name or user.name or user.username or fallback +@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 + 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, + ) + # Skip duplicate mail once sent_at is set; staff resends use Voucher admin + # "Send voucher via email" (calls send_conference_voucher_email directly). + if conference_voucher.voucher_email_sent_at is None: + send_conference_voucher_email.delay( + conference_voucher_id=conference_voucher.id + ) + return + if conference_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER: + conference_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT + conference_voucher.voucher_email_sent_at = None + conference_voucher.save( + update_fields=["voucher_type", "voucher_email_sent_at"] + ) + send_conference_voucher_email.delay( + conference_voucher_id=conference_voucher.id + ) + return + + new_voucher = create_conference_voucher( + conference=conference, + user=user, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + ) + send_conference_voucher_email.delay(conference_voucher_id=new_voucher.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, diff --git a/backend/grants/tests/test_tasks.py b/backend/grants/tests/test_tasks.py index e70bfc0a84..c9071ea232 100644 --- a/backend/grants/tests/test_tasks.py +++ b/backend/grants/tests/test_tasks.py @@ -2,9 +2,17 @@ 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, @@ -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,203 @@ 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_create_and_send_voucher_to_grantee(mocker): + mock_create = mocker.patch( + "conferences.vouchers.create_voucher", return_value={"id": 123} + ) + 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) + + 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(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("conferences.tasks.send_conference_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("conferences.tasks.send_conference_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("conferences.tasks.send_conference_voucher_email") + + grant = GrantFactory(status=Grant.Status.confirmed) + ConferenceVoucherFactory( + conference=grant.conference, + user=grant.user, + voucher_type=voucher_type, + voucher_email_sent_at=datetime(2020, 1, 1, tzinfo=timezone.utc), + ) + + 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_queues_email_when_voucher_exists_but_never_sent( + mocker, voucher_type +): + mock_create = mocker.patch("conferences.vouchers.create_voucher") + mock_send_email = mocker.patch("conferences.tasks.send_conference_voucher_email") + + grant = GrantFactory(status=Grant.Status.confirmed) + voucher = ConferenceVoucherFactory( + conference=grant.conference, + user=grant.user, + voucher_type=voucher_type, + voucher_email_sent_at=None, + ) + + create_and_send_voucher_to_grantee(grant_id=grant.id) + + mock_create.assert_not_called() + mock_send_email.delay.assert_called_once_with(conference_voucher_id=voucher.id) + + +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("conferences.tasks.send_conference_voucher_email") + + grant = GrantFactory(status=Grant.Status.confirmed) + voucher = ConferenceVoucherFactory( + conference=grant.conference, + user=grant.user, + voucher_type=ConferenceVoucher.VoucherType.CO_SPEAKER, + ) + + create_and_send_voucher_to_grantee(grant_id=grant.id) + + voucher.refresh_from_db() + assert voucher.voucher_type == ConferenceVoucher.VoucherType.GRANT + + mock_create.assert_not_called() + 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, sent_emails +): + from notifications.models import EmailTemplateIdentifier + from notifications.tests.factories import EmailTemplateFactory + + 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.voucher_code, + ) + + 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( + conference=grant.conference, + user=grant.user, + ) + assert voucher.voucher_type == ConferenceVoucher.VoucherType.GRANT + 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( + mocker, +): + mock_create = mocker.patch( + "conferences.vouchers.create_voucher", return_value={"id": 123} + ) + mock_send_email = mocker.patch("conferences.tasks.send_conference_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() + ) + + 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(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..c358bc573c --- /dev/null +++ b/backend/notifications/migrations/0022_remove_grant_voucher_code_template_identifier.py @@ -0,0 +1,71 @@ +# 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): + # Intentionally empty: reversing would not restore grant_voucher_code rows in a + # distinguishable way from existing voucher_code templates (data loss on rollback). + 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",