From 735f333378d323a94daec11780dccaccee7a7f95 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:13:36 +0000 Subject: [PATCH] feat: Automatically send voucher when grant is confirmed When a user accepts a grant by setting the status to "confirmed", the system now automatically creates a voucher for the free ticket and sends an email to the grantee, similar to how it works for speakers. Changes: - Add `create_and_send_grant_voucher` Celery task in grants/tasks.py - Creates voucher via Pretix integration - Handles case where user already has a speaker voucher (skips) - Upgrades co-speaker voucher to grant voucher if applicable - Sends voucher code email to the grantee - Trigger the task when grant status changes to "confirmed" in the send_grant_reply GraphQL mutation - Add tests for the new functionality Closes #4572 Co-authored-by: Marco Acierno --- backend/api/grants/mutations.py | 9 +- .../api/grants/tests/test_send_grant_reply.py | 26 +++++ backend/grants/tasks.py | 47 ++++++++ backend/grants/tests/test_tasks.py | 107 ++++++++++++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) diff --git a/backend/api/grants/mutations.py b/backend/api/grants/mutations.py index 4ff0d1cccb..255922ad67 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_grant_voucher, + 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 @@ -352,4 +356,7 @@ def send_grant_reply( admin_url = request.build_absolute_uri(grant.get_admin_url()) notify_new_grant_reply_slack.delay(grant_id=grant.id, admin_url=admin_url) + if grant.status == GrantModel.Status.confirmed: + create_and_send_grant_voucher.delay(grant_id=grant.id) + return Grant.from_model(grant) diff --git a/backend/api/grants/tests/test_send_grant_reply.py b/backend/api/grants/tests/test_send_grant_reply.py index 44c637b9b6..9805b615b3 100644 --- a/backend/api/grants/tests/test_send_grant_reply.py +++ b/backend/api/grants/tests/test_send_grant_reply.py @@ -122,3 +122,29 @@ def test_call_notify_new_grant_reply(graphql_client, user, mocker): assert response["data"]["sendGrantReply"]["__typename"] == "Grant" mock_publisher.delay.assert_called_once_with(grant_id=grant.id, admin_url=ANY) + + +def test_create_voucher_when_grant_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_task = mocker.patch( + "api.grants.mutations.create_and_send_grant_voucher" + ) + + response = _send_grant_reply(graphql_client, grant, status="confirmed") + + assert response["data"]["sendGrantReply"]["__typename"] == "Grant" + mock_voucher_task.delay.assert_called_once_with(grant_id=grant.id) + + +def test_voucher_not_created_when_grant_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_task = mocker.patch( + "api.grants.mutations.create_and_send_grant_voucher" + ) + + response = _send_grant_reply(graphql_client, grant, status="refused") + + assert response["data"]["sendGrantReply"]["__typename"] == "Grant" + mock_voucher_task.delay.assert_not_called() diff --git a/backend/grants/tasks.py b/backend/grants/tasks.py index 9d79947c72..2bd44b772e 100644 --- a/backend/grants/tasks.py +++ b/backend/grants/tasks.py @@ -5,6 +5,9 @@ from django.conf import settings from django.utils import timezone +from conferences.models.conference_voucher import ConferenceVoucher +from conferences.tasks import send_conference_voucher_email +from conferences.vouchers import create_conference_voucher from grants.models import Grant from integrations import slack from notifications.models import EmailTemplate, EmailTemplateIdentifier @@ -182,3 +185,47 @@ def _new_send_grant_email( grant.applicant_reply_sent_at = timezone.now() grant.save() + + +@app.task +def create_and_send_grant_voucher(grant_id: int): + """ + Creates a voucher for a confirmed grant and sends an email to the grantee. + This is triggered when a grant is confirmed by the user. + """ + grant = Grant.objects.get(id=grant_id) + conference = grant.conference + user = grant.user + + # Check if user already has a voucher for this conference + existing_voucher = ( + ConferenceVoucher.objects.for_conference(conference).for_user(user).first() + ) + + if existing_voucher: + # If user has a co-speaker voucher, upgrade it to a grant voucher + if existing_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER: + logger.info( + "User %s already has a co-speaker voucher for conference %s, " + "upgrading to grant voucher", + user.id, + conference.id, + ) + existing_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT + existing_voucher.save(update_fields=["voucher_type"]) + send_conference_voucher_email.delay(conference_voucher_id=existing_voucher.id) + else: + logger.info( + "User %s already has a voucher for conference %s, not creating a new one", + user.id, + conference.id, + ) + return + + conference_voucher = create_conference_voucher( + conference=conference, + user=user, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + ) + + send_conference_voucher_email.delay(conference_voucher_id=conference_voucher.id) diff --git a/backend/grants/tests/test_tasks.py b/backend/grants/tests/test_tasks.py index e70bfc0a84..305e8685f2 100644 --- a/backend/grants/tests/test_tasks.py +++ b/backend/grants/tests/test_tasks.py @@ -5,6 +5,7 @@ from conferences.tests.factories import ConferenceFactory, DeadlineFactory from grants.tasks import ( + create_and_send_grant_voucher, send_grant_reply_approved_email, send_grant_reply_rejected_email, send_grant_reply_waiting_list_email, @@ -466,3 +467,109 @@ def test_send_grant_waiting_list_email_missing_deadline(): 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_grant_voucher(mocker, sent_emails): + from conferences.models.conference_voucher import ConferenceVoucher + from notifications.models import EmailTemplateIdentifier + from notifications.tests.factories import EmailTemplateFactory + + mock_create_voucher = mocker.patch( + "grants.tasks.create_conference_voucher", + ) + mock_conference_voucher = mocker.MagicMock() + mock_conference_voucher.id = 123 + mock_create_voucher.return_value = mock_conference_voucher + + mock_send_email = mocker.patch( + "grants.tasks.send_conference_voucher_email", + ) + + user = UserFactory( + full_name="Marco Acierno", + email="marco@placeholder.it", + ) + grant = GrantFactory(user=user) + + EmailTemplateFactory( + conference=grant.conference, + identifier=EmailTemplateIdentifier.voucher_code, + ) + + create_and_send_grant_voucher(grant_id=grant.id) + + mock_create_voucher.assert_called_once_with( + conference=grant.conference, + user=user, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + ) + mock_send_email.delay.assert_called_once_with(conference_voucher_id=123) + + +def test_create_and_send_grant_voucher_user_already_has_voucher(mocker): + from conferences.models.conference_voucher import ConferenceVoucher + from conferences.tests.factories import ConferenceVoucherFactory + + mock_create_voucher = mocker.patch( + "grants.tasks.create_conference_voucher", + ) + mock_send_email = mocker.patch( + "grants.tasks.send_conference_voucher_email", + ) + + user = UserFactory( + full_name="Marco Acierno", + email="marco@placeholder.it", + ) + grant = GrantFactory(user=user) + + # Create an existing voucher for this user and conference + ConferenceVoucherFactory( + conference=grant.conference, + user=user, + voucher_type=ConferenceVoucher.VoucherType.SPEAKER, + ) + + create_and_send_grant_voucher(grant_id=grant.id) + + # Should not create a new voucher + mock_create_voucher.assert_not_called() + mock_send_email.delay.assert_not_called() + + +def test_create_and_send_grant_voucher_upgrades_co_speaker_voucher(mocker): + from conferences.models.conference_voucher import ConferenceVoucher + from conferences.tests.factories import ConferenceVoucherFactory + + mock_create_voucher = mocker.patch( + "grants.tasks.create_conference_voucher", + ) + mock_send_email = mocker.patch( + "grants.tasks.send_conference_voucher_email", + ) + + user = UserFactory( + full_name="Marco Acierno", + email="marco@placeholder.it", + ) + grant = GrantFactory(user=user) + + # Create an existing co-speaker voucher for this user and conference + existing_voucher = ConferenceVoucherFactory( + conference=grant.conference, + user=user, + voucher_type=ConferenceVoucher.VoucherType.CO_SPEAKER, + ) + + create_and_send_grant_voucher(grant_id=grant.id) + + # Should not create a new voucher but should upgrade the existing one + mock_create_voucher.assert_not_called() + + existing_voucher.refresh_from_db() + assert existing_voucher.voucher_type == ConferenceVoucher.VoucherType.GRANT + + # Should send email with the upgraded voucher + mock_send_email.delay.assert_called_once_with( + conference_voucher_id=existing_voucher.id + )