Skip to content

Commit e73df02

Browse files
committed
Create Pretix voucher and email on grantee confirmation
Queue `create_and_send_voucher_to_grantee` when `sendGrantReply` moves status to `confirmed`. Task mirrors schedule voucher flow: Pretix via `create_conference_voucher`, then grant_voucher_code email. Grant admin Create grant vouchers now uses `create_conference_voucher` and handles co-speaker upgrade. Grant pending-status proxy keeps shared confirm_pending_status from custom_admin. Tests patch Pretix in grant voucher admin tests; sendGrantReply tests assert voucher task on confirm only.
1 parent 4b6e29a commit e73df02

5 files changed

Lines changed: 136 additions & 34 deletions

File tree

backend/api/grants/mutations.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
create_change_admin_log_entry,
1616
)
1717
from grants.models import Grant as GrantModel
18-
from grants.tasks import get_name, notify_new_grant_reply_slack
18+
from grants.tasks import (
19+
create_and_send_voucher_to_grantee,
20+
get_name,
21+
notify_new_grant_reply_slack,
22+
)
1923
from notifications.models import EmailTemplate, EmailTemplateIdentifier
2024
from participants.models import Participant
2125
from privacy_policy.record import record_privacy_policy_acceptance
@@ -342,9 +346,13 @@ def send_grant_reply(
342346
if grant.status in (GrantModel.Status.pending, GrantModel.Status.rejected):
343347
return SendGrantReplyError(message="You cannot reply to this grant")
344348

349+
old_status = grant.status
345350
grant.status = input.status.to_grant_status()
346351
grant.save()
347352

353+
if old_status != grant.status and grant.status == GrantModel.Status.confirmed:
354+
create_and_send_voucher_to_grantee.delay(grant_id=grant.id)
355+
348356
create_change_admin_log_entry(
349357
request.user, grant, f"Grantee has replied with status {grant.status}."
350358
)

backend/api/grants/tests/test_send_grant_reply.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,12 @@ 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):
78+
def test_status_is_updated_when_reply_is_confirmed(graphql_client, user, mocker):
7979
graphql_client.force_login(user)
8080
grant = GrantFactory(user_id=user.id, status=Grant.Status.waiting_for_confirmation)
81+
mock_voucher = mocker.patch(
82+
"api.grants.mutations.create_and_send_voucher_to_grantee"
83+
)
8184

8285
response = _send_grant_reply(graphql_client, grant, status="confirmed")
8386

@@ -86,6 +89,8 @@ def test_status_is_updated_when_reply_is_confirmed(graphql_client, user):
8689
grant.refresh_from_db()
8790
assert grant.status == Grant.Status.confirmed
8891

92+
mock_voucher.delay.assert_called_once_with(grant_id=grant.id)
93+
8994
# Verify audit log entry was created correctly
9095
assert LogEntry.objects.filter(
9196
user=user,
@@ -94,9 +99,12 @@ def test_status_is_updated_when_reply_is_confirmed(graphql_client, user):
9499
).exists()
95100

96101

97-
def test_status_is_updated_when_reply_is_refused(graphql_client, user):
102+
def test_status_is_updated_when_reply_is_refused(graphql_client, user, mocker):
98103
graphql_client.force_login(user)
99104
grant = GrantFactory(user_id=user.id, status=Grant.Status.waiting_for_confirmation)
105+
mock_voucher = mocker.patch(
106+
"api.grants.mutations.create_and_send_voucher_to_grantee"
107+
)
100108

101109
response = _send_grant_reply(graphql_client, grant, status="refused")
102110

@@ -105,6 +113,9 @@ def test_status_is_updated_when_reply_is_refused(graphql_client, user):
105113
grant.refresh_from_db()
106114
assert grant.status == Grant.Status.refused
107115

116+
# Verify voucher was not sent
117+
mock_voucher.delay.assert_not_called()
118+
108119
# Verify audit log entry was created correctly
109120
assert LogEntry.objects.filter(
110121
user=user,

backend/grants/admin.py

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from import_export.resources import ModelResource
1616

1717
from conferences.models.conference_voucher import ConferenceVoucher
18+
from conferences.vouchers import create_conference_voucher
1819
from countries import countries
1920
from countries.filters import CountryFilter
2021
from custom_admin.admin import (
@@ -49,6 +50,7 @@
4950

5051
logger = logging.getLogger(__name__)
5152

53+
5254
EXPORT_GRANTS_FIELDS = (
5355
"name",
5456
"full_name",
@@ -299,18 +301,7 @@ def send_reply_email_waiting_list_update(modeladmin, request, queryset):
299301
@validate_single_conference_selection
300302
@transaction.atomic
301303
def create_grant_vouchers(modeladmin, request, queryset):
302-
conference = queryset.first().conference
303-
existing_vouchers_by_user_id = {
304-
voucher.user_id: voucher
305-
for voucher in ConferenceVoucher.objects.for_conference(conference).filter(
306-
user_id__in=queryset.values_list("user_id", flat=True),
307-
)
308-
}
309-
310-
vouchers_to_create = []
311-
vouchers_to_update = []
312-
313-
for grant in queryset.order_by("id"):
304+
for grant in queryset.order_by("id").select_related("user", "conference"):
314305
if grant.status != Grant.Status.confirmed:
315306
messages.error(
316307
request,
@@ -319,45 +310,56 @@ def create_grant_vouchers(modeladmin, request, queryset):
319310
)
320311
continue
321312

322-
existing_voucher = existing_vouchers_by_user_id.get(grant.user_id)
313+
if not grant.user_id:
314+
messages.error(
315+
request,
316+
f"Grant for {grant.name} has no user linked; can't create a voucher.",
317+
)
318+
continue
319+
320+
existing = (
321+
ConferenceVoucher.objects.for_conference(grant.conference)
322+
.for_user(grant.user)
323+
.first()
324+
)
323325

324-
if not existing_voucher:
326+
if not existing:
327+
create_conference_voucher(
328+
conference=grant.conference,
329+
user=grant.user,
330+
voucher_type=ConferenceVoucher.VoucherType.GRANT,
331+
)
325332
create_addition_admin_log_entry(
326333
request.user,
327334
grant,
328335
change_message="Created voucher for this grant.",
329336
)
337+
continue
330338

331-
vouchers_to_create.append(
332-
ConferenceVoucher(
333-
conference_id=grant.conference_id,
334-
user_id=grant.user_id,
335-
voucher_code=ConferenceVoucher.generate_code(),
336-
voucher_type=ConferenceVoucher.VoucherType.GRANT,
337-
)
338-
)
339+
if existing.voucher_type in (
340+
ConferenceVoucher.VoucherType.GRANT,
341+
ConferenceVoucher.VoucherType.SPEAKER,
342+
):
339343
continue
340344

341-
if existing_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER:
345+
if existing.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER:
342346
messages.warning(
343347
request,
344-
f"Grant for {grant.name} already has a Co-Speaker voucher. Upgrading to a Grant voucher.",
348+
f"Grant for {grant.name} already has a Co-Speaker voucher. "
349+
"Upgrading to a Grant voucher.",
345350
)
346351
create_change_admin_log_entry(
347352
request.user,
348-
existing_voucher,
353+
existing,
349354
change_message="Upgraded Co-Speaker voucher to Grant voucher.",
350355
)
351356
create_change_admin_log_entry(
352357
request.user,
353358
grant,
354359
change_message="Updated existing Co-Speaker voucher to grant.",
355360
)
356-
existing_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT
357-
vouchers_to_update.append(existing_voucher)
358-
359-
ConferenceVoucher.objects.bulk_create(vouchers_to_create, ignore_conflicts=True)
360-
ConferenceVoucher.objects.bulk_update(vouchers_to_update, ["voucher_type"])
361+
existing.voucher_type = ConferenceVoucher.VoucherType.GRANT
362+
existing.save(update_fields=["voucher_type"])
361363

362364
messages.success(request, "Vouchers created!")
363365

backend/grants/tasks.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from django.conf import settings
66
from django.utils import timezone
77

8+
from conferences.models.conference_voucher import ConferenceVoucher
9+
from conferences.vouchers import create_conference_voucher
810
from grants.models import Grant
911
from integrations import slack
1012
from notifications.models import EmailTemplate, EmailTemplateIdentifier
@@ -21,6 +23,80 @@ def get_name(user: User | None, fallback: str = "<no name specified>") -> str:
2123
return user.full_name or user.name or user.username or fallback
2224

2325

26+
@app.task
27+
def send_grant_voucher_email(*, grant_id: int) -> None:
28+
grant = Grant.objects.select_related("conference", "user").get(pk=grant_id)
29+
if not grant.user_id:
30+
return
31+
32+
conference_voucher = (
33+
ConferenceVoucher.objects.for_conference(grant.conference)
34+
.for_user(grant.user)
35+
.first()
36+
)
37+
if not conference_voucher:
38+
return
39+
40+
visa_page_link = urljoin(settings.FRONTEND_URL, "/visa")
41+
conference_name = grant.conference.name.localize("en")
42+
43+
email_template = EmailTemplate.objects.for_conference(
44+
grant.conference
45+
).get_by_identifier(EmailTemplateIdentifier.grant_voucher_code)
46+
email_template.send_email(
47+
recipient=grant.user,
48+
placeholders={
49+
"conference_name": conference_name,
50+
"user_name": get_name(grant.user, "there"),
51+
"voucher_code": conference_voucher.voucher_code,
52+
"has_approved_accommodation": grant.has_approved_accommodation(),
53+
"visa_page_link": visa_page_link,
54+
},
55+
)
56+
57+
conference_voucher.voucher_email_sent_at = timezone.now()
58+
conference_voucher.save(update_fields=["voucher_email_sent_at"])
59+
60+
61+
@app.task
62+
def create_and_send_voucher_to_grantee(*, grant_id: int) -> None:
63+
grant = Grant.objects.select_related("user", "conference").get(pk=grant_id)
64+
if grant.status != Grant.Status.confirmed:
65+
return
66+
if not grant.user_id:
67+
return
68+
69+
user = grant.user
70+
conference = grant.conference
71+
conference_voucher = (
72+
ConferenceVoucher.objects.for_conference(conference).for_user(user).first()
73+
)
74+
75+
if conference_voucher:
76+
if conference_voucher.voucher_type in (
77+
ConferenceVoucher.VoucherType.GRANT,
78+
ConferenceVoucher.VoucherType.SPEAKER,
79+
):
80+
logger.info(
81+
"User %s already has a voucher for conference %s, not creating a new one",
82+
user.id,
83+
conference.id,
84+
)
85+
return
86+
if conference_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER:
87+
conference_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT
88+
conference_voucher.save(update_fields=["voucher_type"])
89+
send_grant_voucher_email.delay(grant_id=grant.id)
90+
return
91+
92+
create_conference_voucher(
93+
conference=conference,
94+
user=user,
95+
voucher_type=ConferenceVoucher.VoucherType.GRANT,
96+
)
97+
send_grant_voucher_email.delay(grant_id=grant.id)
98+
99+
24100
@app.task
25101
def send_grant_reply_approved_email(*, grant_id: int, is_reminder: bool) -> None:
26102
logger.info("Sending Reply APPROVED email for Grant %s", grant_id)

backend/grants/tests/test_admin.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
from conferences.models.conference_voucher import ConferenceVoucher
1111
from conferences.tests.factories import ConferenceFactory, ConferenceVoucherFactory
1212
from grants.admin import (
13-
confirm_pending_status,
1413
GrantAdmin,
1514
GrantReimbursementAdmin,
15+
confirm_pending_status,
1616
create_grant_vouchers,
1717
mark_rejected_and_send_email,
1818
reset_pending_status_back_to_status,
@@ -305,6 +305,7 @@ def test_send_reply_email_waiting_list_update(rf, mocker, admin_user):
305305

306306
def test_create_grant_vouchers(rf, mocker, admin_user):
307307
mock_messages = mocker.patch("grants.admin.messages")
308+
mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999})
308309

309310
conference = ConferenceFactory()
310311

@@ -357,6 +358,7 @@ def test_create_grant_vouchers_with_existing_voucher_is_reused(
357358
rf, mocker, admin_user, type
358359
):
359360
mock_messages = mocker.patch("grants.admin.messages")
361+
mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999})
360362

361363
conference = ConferenceFactory()
362364

@@ -407,6 +409,7 @@ def test_create_grant_vouchers_with_voucher_from_other_conf_is_ignored(
407409
rf, mocker, type, admin_user
408410
):
409411
mock_messages = mocker.patch("grants.admin.messages")
412+
mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999})
410413

411414
conference = ConferenceFactory()
412415
other_conference = ConferenceFactory()
@@ -461,6 +464,7 @@ def test_create_grant_vouchers_with_voucher_from_other_conf_is_ignored(
461464

462465
def test_create_grant_vouchers_co_speaker_voucher_is_upgraded(rf, mocker, admin_user):
463466
mock_messages = mocker.patch("grants.admin.messages")
467+
mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999})
464468

465469
conference = ConferenceFactory()
466470

@@ -506,6 +510,7 @@ def test_create_grant_vouchers_co_speaker_voucher_is_upgraded(rf, mocker, admin_
506510

507511
def test_create_grant_vouchers_only_for_confirmed_grants(rf, mocker, admin_user):
508512
mock_messages = mocker.patch("grants.admin.messages")
513+
mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999})
509514
conference = ConferenceFactory()
510515
grant_1 = GrantFactory(
511516
status=Grant.Status.refused,

0 commit comments

Comments
 (0)