Skip to content

Commit d32d672

Browse files
committed
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.
1 parent 39a223a commit d32d672

File tree

3 files changed

+88
-9
lines changed

3 files changed

+88
-9
lines changed

backend/grants/admin.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,19 @@ def send_reply_email_waiting_list_update(modeladmin, request, queryset):
301301
@validate_single_conference_selection
302302
@transaction.atomic
303303
def create_grant_vouchers(modeladmin, request, queryset):
304-
for grant in queryset.order_by("id").select_related("user", "conference"):
304+
grants_ordered = list(queryset.order_by("id").select_related("user", "conference"))
305+
conference_id = grants_ordered[0].conference_id if grants_ordered else None
306+
user_ids = {g.user_id for g in grants_ordered if g.user_id}
307+
308+
existing_by_user: Dict[int, ConferenceVoucher] = {}
309+
if conference_id is not None and user_ids:
310+
for voucher in ConferenceVoucher.objects.filter(
311+
conference_id=conference_id,
312+
user_id__in=user_ids,
313+
):
314+
existing_by_user[voucher.user_id] = voucher
315+
316+
for grant in grants_ordered:
305317
if grant.status != Grant.Status.confirmed:
306318
messages.error(
307319
request,
@@ -317,18 +329,15 @@ def create_grant_vouchers(modeladmin, request, queryset):
317329
)
318330
continue
319331

320-
existing = (
321-
ConferenceVoucher.objects.for_conference(grant.conference)
322-
.for_user(grant.user)
323-
.first()
324-
)
332+
existing = existing_by_user.get(grant.user_id)
325333

326334
if not existing:
327-
create_conference_voucher(
335+
new_voucher = create_conference_voucher(
328336
conference=grant.conference,
329337
user=grant.user,
330338
voucher_type=ConferenceVoucher.VoucherType.GRANT,
331339
)
340+
existing_by_user[grant.user_id] = new_voucher
332341
create_addition_admin_log_entry(
333342
request.user,
334343
grant,
@@ -359,7 +368,8 @@ def create_grant_vouchers(modeladmin, request, queryset):
359368
change_message="Updated existing Co-Speaker voucher to grant.",
360369
)
361370
existing.voucher_type = ConferenceVoucher.VoucherType.GRANT
362-
existing.save(update_fields=["voucher_type"])
371+
existing.voucher_email_sent_at = None
372+
existing.save(update_fields=["voucher_type", "voucher_email_sent_at"])
363373

364374
messages.success(request, "Vouchers created!")
365375

backend/grants/tasks.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ def send_grant_voucher_email(*, grant_id: int) -> None:
3737
if not conference_voucher:
3838
return
3939

40+
if conference_voucher.voucher_email_sent_at is not None:
41+
logger.info(
42+
"Grant voucher email already sent for user %s conference %s, skipping",
43+
grant.user_id,
44+
grant.conference_id,
45+
)
46+
return
47+
4048
visa_page_link = urljoin(settings.FRONTEND_URL, "/visa")
4149
conference_name = grant.conference.name.localize("en")
4250

@@ -85,7 +93,10 @@ def create_and_send_voucher_to_grantee(*, grant_id: int) -> None:
8593
return
8694
if conference_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER:
8795
conference_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT
88-
conference_voucher.save(update_fields=["voucher_type"])
96+
conference_voucher.voucher_email_sent_at = None
97+
conference_voucher.save(
98+
update_fields=["voucher_type", "voucher_email_sent_at"]
99+
)
89100
send_grant_voucher_email.delay(grant_id=grant.id)
90101
return
91102

backend/grants/tests/test_tasks.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,30 @@ def test_send_grant_voucher_email(settings, sent_emails):
528528
)
529529

530530

531+
def test_send_grant_voucher_email_skips_when_already_sent(settings, sent_emails):
532+
from notifications.models import EmailTemplateIdentifier
533+
from notifications.tests.factories import EmailTemplateFactory
534+
535+
settings.FRONTEND_URL = "https://pycon.it"
536+
user = UserFactory()
537+
grant = GrantFactory(user=user, status=Grant.Status.confirmed)
538+
ConferenceVoucherFactory(
539+
user=user,
540+
conference=grant.conference,
541+
voucher_type=ConferenceVoucher.VoucherType.GRANT,
542+
voucher_code="GRANT99",
543+
voucher_email_sent_at=datetime(2020, 1, 1, tzinfo=timezone.utc),
544+
)
545+
EmailTemplateFactory(
546+
conference=grant.conference,
547+
identifier=EmailTemplateIdentifier.grant_voucher_code,
548+
)
549+
550+
send_grant_voucher_email(grant_id=grant.id)
551+
552+
assert sent_emails().count() == 0
553+
554+
531555
def test_create_and_send_voucher_to_grantee(mocker):
532556
mock_create = mocker.patch(
533557
"conferences.vouchers.create_voucher", return_value={"id": 123}
@@ -626,6 +650,40 @@ def test_create_and_send_voucher_to_grantee_upgrades_co_speaker(mocker):
626650
mock_send_email.delay.assert_called_once_with(grant_id=grant.id)
627651

628652

653+
def test_create_and_send_voucher_to_grantee_upgrades_co_speaker_clears_email_sent_at(
654+
mocker, settings, sent_emails
655+
):
656+
from notifications.models import EmailTemplateIdentifier
657+
from notifications.tests.factories import EmailTemplateFactory
658+
659+
settings.FRONTEND_URL = "https://pycon.it"
660+
mock_create = mocker.patch("conferences.vouchers.create_voucher")
661+
grant = GrantFactory(status=Grant.Status.confirmed)
662+
prior_sent = datetime(2020, 5, 5, 12, 0, 0, tzinfo=timezone.utc)
663+
ConferenceVoucherFactory(
664+
conference=grant.conference,
665+
user=grant.user,
666+
voucher_type=ConferenceVoucher.VoucherType.CO_SPEAKER,
667+
voucher_email_sent_at=prior_sent,
668+
)
669+
EmailTemplateFactory(
670+
conference=grant.conference,
671+
identifier=EmailTemplateIdentifier.grant_voucher_code,
672+
)
673+
674+
create_and_send_voucher_to_grantee(grant_id=grant.id)
675+
676+
mock_create.assert_not_called()
677+
voucher = ConferenceVoucher.objects.get(
678+
conference=grant.conference,
679+
user=grant.user,
680+
)
681+
assert voucher.voucher_type == ConferenceVoucher.VoucherType.GRANT
682+
assert voucher.voucher_email_sent_at is not None
683+
assert voucher.voucher_email_sent_at != prior_sent
684+
assert sent_emails().count() == 1
685+
686+
629687
def test_create_and_send_voucher_to_grantee_creates_when_voucher_on_other_conference(
630688
mocker,
631689
):

0 commit comments

Comments
 (0)