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/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/admin.py b/backend/grants/admin.py index 04fea81c3d..33f1f6a8fb 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,19 @@ 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), - ) - } + 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 +322,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 +367,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..b20c8a0b77 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,52 @@ 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, + ) + 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..4fa09bc00c 100644 --- a/backend/grants/tests/test_tasks.py +++ b/backend/grants/tests/test_tasks.py @@ -1,10 +1,17 @@ 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, @@ -380,7 +387,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 +468,175 @@ 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, + ) + + 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("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..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",