Skip to content

Commit 21c2cbd

Browse files
authored
feat(grants): Pretix voucher and email when grantee confirms (#4602)
1 parent 4b6e29a commit 21c2cbd

File tree

10 files changed

+432
-52
lines changed

10 files changed

+432
-52
lines changed

backend/api/grants/mutations.py

Lines changed: 13 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,17 @@ 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+
transaction.on_commit(
355+
lambda gid=grant.id: create_and_send_voucher_to_grantee.delay(
356+
grant_id=gid
357+
)
358+
)
359+
348360
create_change_admin_log_entry(
349361
request.user, grant, f"Grantee has replied with status {grant.status}."
350362
)

backend/api/grants/tests/test_send_grant_reply.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,25 @@ 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(
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)
83+
mock_voucher = mocker.patch(
84+
"api.grants.mutations.create_and_send_voucher_to_grantee"
85+
)
8186

82-
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")
8389

8490
assert response["data"]["sendGrantReply"]["__typename"] == "Grant"
8591

8692
grant.refresh_from_db()
8793
assert grant.status == Grant.Status.confirmed
8894

95+
mock_voucher.delay.assert_called_once_with(grant_id=grant.id)
96+
8997
# Verify audit log entry was created correctly
9098
assert LogEntry.objects.filter(
9199
user=user,
@@ -94,9 +102,12 @@ def test_status_is_updated_when_reply_is_confirmed(graphql_client, user):
94102
).exists()
95103

96104

97-
def test_status_is_updated_when_reply_is_refused(graphql_client, user):
105+
def test_status_is_updated_when_reply_is_refused(graphql_client, user, mocker):
98106
graphql_client.force_login(user)
99107
grant = GrantFactory(user_id=user.id, status=Grant.Status.waiting_for_confirmation)
108+
mock_voucher = mocker.patch(
109+
"api.grants.mutations.create_and_send_voucher_to_grantee"
110+
)
100111

101112
response = _send_grant_reply(graphql_client, grant, status="refused")
102113

@@ -105,6 +116,9 @@ def test_status_is_updated_when_reply_is_refused(graphql_client, user):
105116
grant.refresh_from_db()
106117
assert grant.status == Grant.Status.refused
107118

119+
# Verify voucher was not sent
120+
mock_voucher.delay.assert_not_called()
121+
108122
# Verify audit log entry was created correctly
109123
assert LogEntry.objects.filter(
110124
user=user,

backend/conferences/tasks.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
from django.utils import timezone
22
from notifications.models import EmailTemplate, EmailTemplateIdentifier
33
from grants.tasks import get_name
4-
import logging
54
from pycon.celery import app
65

7-
logger = logging.getLogger(__name__)
8-
96

107
@app.task
11-
def send_conference_voucher_email(conference_voucher_id):
8+
def send_conference_voucher_email(conference_voucher_id: int) -> None:
129
from conferences.models import ConferenceVoucher
1310

1411
conference_voucher = ConferenceVoucher.objects.get(id=conference_voucher_id)
@@ -31,4 +28,4 @@ def send_conference_voucher_email(conference_voucher_id):
3128
)
3229

3330
conference_voucher.voucher_email_sent_at = timezone.now()
34-
conference_voucher.save()
31+
conference_voucher.save(update_fields=["voucher_email_sent_at"])

backend/conferences/tests/test_tasks.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,26 @@ def test_send_conference_voucher_email(voucher_type, sent_emails):
6060
assert conference_voucher.voucher_email_sent_at == datetime(
6161
2020, 10, 10, 10, 0, 0, tzinfo=timezone.utc
6262
)
63+
64+
65+
def test_send_conference_voucher_email_sends_again_when_already_sent(sent_emails):
66+
user = UserFactory(full_name="Remind Me")
67+
conference_voucher = ConferenceVoucherFactory(
68+
user=user,
69+
voucher_type=ConferenceVoucher.VoucherType.SPEAKER,
70+
voucher_code="REM123",
71+
voucher_email_sent_at=datetime(2020, 1, 1, tzinfo=timezone.utc),
72+
)
73+
EmailTemplateFactory(
74+
conference=conference_voucher.conference,
75+
identifier=EmailTemplateIdentifier.voucher_code,
76+
)
77+
78+
with time_machine.travel("2020-06-01 12:00:00Z", tick=False):
79+
send_conference_voucher_email(conference_voucher_id=conference_voucher.id)
80+
81+
assert sent_emails().count() == 1
82+
conference_voucher.refresh_from_db()
83+
assert conference_voucher.voucher_email_sent_at == datetime(
84+
2020, 6, 1, 12, 0, 0, tzinfo=timezone.utc
85+
)

backend/grants/admin.py

Lines changed: 40 additions & 30 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
@@ -15,6 +14,7 @@
1514
from import_export.resources import ModelResource
1615

1716
from conferences.models.conference_voucher import ConferenceVoucher
17+
from conferences.vouchers import create_conference_voucher
1818
from countries import countries
1919
from countries.filters import CountryFilter
2020
from custom_admin.admin import (
@@ -49,6 +49,7 @@
4949

5050
logger = logging.getLogger(__name__)
5151

52+
5253
EXPORT_GRANTS_FIELDS = (
5354
"name",
5455
"full_name",
@@ -297,20 +298,20 @@ def send_reply_email_waiting_list_update(modeladmin, request, queryset):
297298

298299
@admin.action(description="Create grant vouchers")
299300
@validate_single_conference_selection
300-
@transaction.atomic
301301
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-
}
302+
grants_ordered = list(queryset.order_by("id").select_related("user", "conference"))
303+
conference_id = grants_ordered[0].conference_id if grants_ordered else None
304+
user_ids = {g.user_id for g in grants_ordered if g.user_id}
309305

310-
vouchers_to_create = []
311-
vouchers_to_update = []
306+
existing_by_user: Dict[int, ConferenceVoucher] = {}
307+
if conference_id is not None and user_ids:
308+
for voucher in ConferenceVoucher.objects.filter(
309+
conference_id=conference_id,
310+
user_id__in=user_ids,
311+
):
312+
existing_by_user[voucher.user_id] = voucher
312313

313-
for grant in queryset.order_by("id"):
314+
for grant in grants_ordered:
314315
if grant.status != Grant.Status.confirmed:
315316
messages.error(
316317
request,
@@ -319,45 +320,54 @@ def create_grant_vouchers(modeladmin, request, queryset):
319320
)
320321
continue
321322

322-
existing_voucher = existing_vouchers_by_user_id.get(grant.user_id)
323+
if not grant.user_id:
324+
messages.error(
325+
request,
326+
f"Grant for {grant.name} has no user linked; can't create a voucher.",
327+
)
328+
continue
323329

324-
if not existing_voucher:
330+
existing = existing_by_user.get(grant.user_id)
331+
332+
if not existing:
333+
new_voucher = create_conference_voucher(
334+
conference=grant.conference,
335+
user=grant.user,
336+
voucher_type=ConferenceVoucher.VoucherType.GRANT,
337+
)
338+
existing_by_user[grant.user_id] = new_voucher
325339
create_addition_admin_log_entry(
326340
request.user,
327341
grant,
328342
change_message="Created voucher for this grant.",
329343
)
344+
continue
330345

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-
)
346+
if existing.voucher_type in (
347+
ConferenceVoucher.VoucherType.GRANT,
348+
ConferenceVoucher.VoucherType.SPEAKER,
349+
):
339350
continue
340351

341-
if existing_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER:
352+
if existing.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER:
342353
messages.warning(
343354
request,
344-
f"Grant for {grant.name} already has a Co-Speaker voucher. Upgrading to a Grant voucher.",
355+
f"Grant for {grant.name} already has a Co-Speaker voucher. "
356+
"Upgrading to a Grant voucher.",
345357
)
346358
create_change_admin_log_entry(
347359
request.user,
348-
existing_voucher,
360+
existing,
349361
change_message="Upgraded Co-Speaker voucher to Grant voucher.",
350362
)
351363
create_change_admin_log_entry(
352364
request.user,
353365
grant,
354366
change_message="Updated existing Co-Speaker voucher to grant.",
355367
)
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"])
368+
existing.voucher_type = ConferenceVoucher.VoucherType.GRANT
369+
existing.voucher_email_sent_at = None
370+
existing.save(update_fields=["voucher_type", "voucher_email_sent_at"])
361371

362372
messages.success(request, "Vouchers created!")
363373

backend/grants/tasks.py

Lines changed: 54 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,58 @@ 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 create_and_send_voucher_to_grantee(*, grant_id: int) -> None:
28+
from conferences.tasks import send_conference_voucher_email
29+
30+
grant = Grant.objects.select_related("user", "conference").get(pk=grant_id)
31+
if grant.status != Grant.Status.confirmed:
32+
return
33+
if not grant.user_id:
34+
return
35+
36+
user = grant.user
37+
conference = grant.conference
38+
conference_voucher = (
39+
ConferenceVoucher.objects.for_conference(conference).for_user(user).first()
40+
)
41+
42+
if conference_voucher:
43+
if conference_voucher.voucher_type in (
44+
ConferenceVoucher.VoucherType.GRANT,
45+
ConferenceVoucher.VoucherType.SPEAKER,
46+
):
47+
logger.info(
48+
"User %s already has a voucher for conference %s, not creating a new one",
49+
user.id,
50+
conference.id,
51+
)
52+
# Skip duplicate mail once sent_at is set; staff resends use Voucher admin
53+
# "Send voucher via email" (calls send_conference_voucher_email directly).
54+
if conference_voucher.voucher_email_sent_at is None:
55+
send_conference_voucher_email.delay(
56+
conference_voucher_id=conference_voucher.id
57+
)
58+
return
59+
if conference_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER:
60+
conference_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT
61+
conference_voucher.voucher_email_sent_at = None
62+
conference_voucher.save(
63+
update_fields=["voucher_type", "voucher_email_sent_at"]
64+
)
65+
send_conference_voucher_email.delay(
66+
conference_voucher_id=conference_voucher.id
67+
)
68+
return
69+
70+
new_voucher = create_conference_voucher(
71+
conference=conference,
72+
user=user,
73+
voucher_type=ConferenceVoucher.VoucherType.GRANT,
74+
)
75+
send_conference_voucher_email.delay(conference_voucher_id=new_voucher.id)
76+
77+
2478
@app.task
2579
def send_grant_reply_approved_email(*, grant_id: int, is_reminder: bool) -> None:
2680
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)