Skip to content

Commit afe84a7

Browse files
committed
fix(grants): address PR review — retry email, on_commit, admin atomic
- create_and_send_voucher_to_grantee: if GRANT/SPEAKER row exists but voucher_email_sent_at is None, queue send_conference_voucher_email (Celery retry after DB+Pretix succeeded but email enqueue failed). - Catch IntegrityError on create_conference_voucher; refetch row and queue email if still unsent (concurrent task / race). - sendGrantReply: schedule voucher task via transaction.on_commit so workers see committed grant status. - create_grant_vouchers: drop @transaction.atomic to avoid Pretix orphans on rollback. - Migration 0022: document noop_reverse (irreversible consolidation). - Tests: voucher already sent vs never sent, IntegrityError path, on_commit helper for GraphQL confirm.
1 parent ae6c392 commit afe84a7

File tree

6 files changed

+103
-10
lines changed

6 files changed

+103
-10
lines changed

backend/api/grants/mutations.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,11 @@ def send_grant_reply(
351351
grant.save()
352352

353353
if old_status != grant.status and grant.status == GrantModel.Status.confirmed:
354-
create_and_send_voucher_to_grantee.delay(grant_id=grant.id)
354+
transaction.on_commit(
355+
lambda gid=grant.id: create_and_send_voucher_to_grantee.delay(
356+
grant_id=gid
357+
)
358+
)
355359

356360
create_change_admin_log_entry(
357361
request.user, grant, f"Grantee has replied with status {grant.status}."

backend/api/grants/tests/test_send_grant_reply.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,17 @@ def test_user_cannot_reply_if_status_is_rejected(graphql_client, user):
7575
)
7676

7777

78-
def test_status_is_updated_when_reply_is_confirmed(graphql_client, user, mocker):
78+
def test_status_is_updated_when_reply_is_confirmed(
79+
graphql_client, user, mocker, django_capture_on_commit_callbacks
80+
):
7981
graphql_client.force_login(user)
8082
grant = GrantFactory(user_id=user.id, status=Grant.Status.waiting_for_confirmation)
8183
mock_voucher = mocker.patch(
8284
"api.grants.mutations.create_and_send_voucher_to_grantee"
8385
)
8486

85-
response = _send_grant_reply(graphql_client, grant, status="confirmed")
87+
with django_capture_on_commit_callbacks(execute=True):
88+
response = _send_grant_reply(graphql_client, grant, status="confirmed")
8689

8790
assert response["data"]["sendGrantReply"]["__typename"] == "Grant"
8891

backend/grants/admin.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
from django.contrib import admin, messages
66
from django.contrib.admin import SimpleListFilter
7-
from django.db import transaction
87
from django.db.models import Exists, OuterRef
98
from django.db.models.query import QuerySet
109
from django.urls import reverse
@@ -299,7 +298,6 @@ def send_reply_email_waiting_list_update(modeladmin, request, queryset):
299298

300299
@admin.action(description="Create grant vouchers")
301300
@validate_single_conference_selection
302-
@transaction.atomic
303301
def create_grant_vouchers(modeladmin, request, queryset):
304302
grants_ordered = list(queryset.order_by("id").select_related("user", "conference"))
305303
conference_id = grants_ordered[0].conference_id if grants_ordered else None

backend/grants/tasks.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from urllib.parse import urljoin
44

55
from django.conf import settings
6+
from django.db import IntegrityError
67
from django.utils import timezone
78

89
from conferences.models.conference_voucher import ConferenceVoucher
@@ -49,6 +50,10 @@ def create_and_send_voucher_to_grantee(*, grant_id: int) -> None:
4950
user.id,
5051
conference.id,
5152
)
53+
if conference_voucher.voucher_email_sent_at is None:
54+
send_conference_voucher_email.delay(
55+
conference_voucher_id=conference_voucher.id
56+
)
5257
return
5358
if conference_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER:
5459
conference_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT
@@ -61,11 +66,31 @@ def create_and_send_voucher_to_grantee(*, grant_id: int) -> None:
6166
)
6267
return
6368

64-
new_voucher = create_conference_voucher(
65-
conference=conference,
66-
user=user,
67-
voucher_type=ConferenceVoucher.VoucherType.GRANT,
68-
)
69+
try:
70+
new_voucher = create_conference_voucher(
71+
conference=conference,
72+
user=user,
73+
voucher_type=ConferenceVoucher.VoucherType.GRANT,
74+
)
75+
except IntegrityError:
76+
logger.warning(
77+
"IntegrityError creating grant voucher for user %s conference %s "
78+
"(likely concurrent task); syncing email state",
79+
user.id,
80+
conference.id,
81+
)
82+
existing_after_race = (
83+
ConferenceVoucher.objects.for_conference(conference).for_user(user).first()
84+
)
85+
if (
86+
existing_after_race is not None
87+
and existing_after_race.voucher_email_sent_at is None
88+
):
89+
send_conference_voucher_email.delay(
90+
conference_voucher_id=existing_after_race.id
91+
)
92+
return
93+
6994
send_conference_voucher_email.delay(conference_voucher_id=new_voucher.id)
7095

7196

backend/grants/tests/test_tasks.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from datetime import datetime, timezone
22
from decimal import Decimal
3+
34
import pytest
45
import time_machine
6+
from django.db import IntegrityError
57

68
from conferences.models.conference_voucher import ConferenceVoucher
79
from conferences.tests.factories import (
@@ -537,6 +539,7 @@ def test_create_and_send_voucher_to_grantee_does_nothing_if_voucher_exists(
537539
conference=grant.conference,
538540
user=grant.user,
539541
voucher_type=voucher_type,
542+
voucher_email_sent_at=datetime(2020, 1, 1, tzinfo=timezone.utc),
540543
)
541544

542545
create_and_send_voucher_to_grantee(grant_id=grant.id)
@@ -545,6 +548,64 @@ def test_create_and_send_voucher_to_grantee_does_nothing_if_voucher_exists(
545548
mock_send_email.delay.assert_not_called()
546549

547550

551+
@pytest.mark.parametrize(
552+
"voucher_type",
553+
[
554+
ConferenceVoucher.VoucherType.SPEAKER,
555+
ConferenceVoucher.VoucherType.GRANT,
556+
],
557+
)
558+
def test_create_and_send_voucher_to_grantee_queues_email_when_voucher_exists_but_never_sent(
559+
mocker, voucher_type
560+
):
561+
mock_create = mocker.patch("conferences.vouchers.create_voucher")
562+
mock_send_email = mocker.patch("conferences.tasks.send_conference_voucher_email")
563+
564+
grant = GrantFactory(status=Grant.Status.confirmed)
565+
voucher = ConferenceVoucherFactory(
566+
conference=grant.conference,
567+
user=grant.user,
568+
voucher_type=voucher_type,
569+
voucher_email_sent_at=None,
570+
)
571+
572+
create_and_send_voucher_to_grantee(grant_id=grant.id)
573+
574+
mock_create.assert_not_called()
575+
mock_send_email.delay.assert_called_once_with(conference_voucher_id=voucher.id)
576+
577+
578+
def test_create_and_send_voucher_to_grantee_integrity_error_queues_email_if_unsent(
579+
mocker,
580+
):
581+
mock_send_email = mocker.patch("conferences.tasks.send_conference_voucher_email")
582+
583+
def another_worker_won_race_then_integrity(**kwargs):
584+
ConferenceVoucherFactory(
585+
conference=kwargs["conference"],
586+
user=kwargs["user"],
587+
voucher_type=kwargs["voucher_type"],
588+
voucher_email_sent_at=None,
589+
)
590+
raise IntegrityError()
591+
592+
mock_create = mocker.patch(
593+
"grants.tasks.create_conference_voucher",
594+
side_effect=another_worker_won_race_then_integrity,
595+
)
596+
597+
grant = GrantFactory(status=Grant.Status.confirmed)
598+
599+
create_and_send_voucher_to_grantee(grant_id=grant.id)
600+
601+
mock_create.assert_called_once()
602+
voucher = ConferenceVoucher.objects.get(
603+
conference=grant.conference,
604+
user=grant.user,
605+
)
606+
mock_send_email.delay.assert_called_once_with(conference_voucher_id=voucher.id)
607+
608+
548609
def test_create_and_send_voucher_to_grantee_upgrades_co_speaker(mocker):
549610
mock_create = mocker.patch("conferences.vouchers.create_voucher")
550611
mock_send_email = mocker.patch("conferences.tasks.send_conference_voucher_email")

backend/notifications/migrations/0022_remove_grant_voucher_code_template_identifier.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ def forwards_grant_voucher_to_voucher_code(apps, schema_editor):
1111

1212

1313
def noop_reverse(apps, schema_editor):
14+
# Intentionally empty: reversing would not restore grant_voucher_code rows in a
15+
# distinguishable way from existing voucher_code templates (data loss on rollback).
1416
pass
1517

1618

0 commit comments

Comments
 (0)