Skip to content

Commit 7f14509

Browse files
authored
Implement sending the invitation letter via email (#4346)
1 parent 1aa2ba0 commit 7f14509

File tree

13 files changed

+292
-5
lines changed

13 files changed

+292
-5
lines changed

backend/api/submissions/tests/test_submissions.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,22 @@ def test_returns_submissions_paginated(graphql_client, user):
8787
)
8888

8989
assert not resp.get("errors")
90-
assert resp["data"]["submissions"]["items"] == [{"id": submission_2.hashid}]
90+
assert len(resp["data"]["submissions"]["items"]) == 1
91+
item = resp["data"]["submissions"]["items"][0]
9192
assert resp["data"]["submissions"]["pageInfo"] == {"totalPages": 2, "totalItems": 2}
9293

9394
resp_2 = graphql_client.query(
9495
query,
9596
variables={"code": submission.conference.code, "page": 2},
9697
)
97-
assert resp_2["data"]["submissions"]["items"] == [{"id": submission.hashid}]
98+
99+
assert not resp_2.get("errors")
100+
assert len(resp_2["data"]["submissions"]["items"]) == 1
101+
item_2 = resp_2["data"]["submissions"]["items"][0]
102+
103+
assert set([item["id"], item_2["id"]]) == set(
104+
[submission.hashid, submission_2.hashid]
105+
)
98106

99107

100108
def test_canceled_submissions_are_excluded(graphql_client, user, mock_has_ticket):
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.1.4 on 2025-02-06 23:10
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('notifications', '0019_alter_emailtemplate_identifier'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='emailtemplate',
15+
name='identifier',
16+
field=models.CharField(choices=[('proposal_accepted', 'Proposal accepted'), ('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'), ('grant_voucher_code', 'Grant voucher code'), ('sponsorship_brochure', 'Sponsorship brochure'), ('visa_invitation_letter_download', 'Visa invitation letter download'), ('custom', 'Custom')], max_length=200, verbose_name='identifier'),
17+
),
18+
]

backend/notifications/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ class EmailTemplateIdentifier(models.TextChoices):
4848

4949
sponsorship_brochure = "sponsorship_brochure", _("Sponsorship brochure")
5050

51+
visa_invitation_letter_download = (
52+
"visa_invitation_letter_download",
53+
_("Visa invitation letter download"),
54+
)
55+
5156
custom = "custom", _("Custom")
5257

5358

@@ -154,6 +159,11 @@ class EmailTemplate(TimeStampedModel):
154159
"brochure_url",
155160
"conference_name",
156161
],
162+
EmailTemplateIdentifier.visa_invitation_letter_download: [
163+
*BASE_PLACEHOLDERS,
164+
"invitation_letter_download_url",
165+
"has_grant",
166+
],
157167
}
158168

159169
conference = models.ForeignKey(

backend/pycon/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
path("", include("healthchecks.urls")),
3333
path("", include("files_upload.urls")),
3434
path("", include("notifications.urls")),
35+
path("visa/", include("visa.urls")),
3536
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
3637

3738

backend/visa/admin.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ def save_form(self, request, form, change):
7878
obj = super().save_form(request, form, change)
7979

8080
if "_process_now" in form.data:
81-
obj.schedule()
81+
obj.process()
82+
83+
if "_send_via_email" in form.data:
84+
obj.send_via_email()
8285

8386
return obj
8487

@@ -112,6 +115,11 @@ def response_post_save_change(self, request, obj):
112115
reverse("admin:visa_invitationletterrequest_change", args=[obj.id])
113116
)
114117

118+
if "_send_via_email" in request.POST:
119+
return HttpResponseRedirect(
120+
reverse("admin:visa_invitationletterrequest_change", args=[obj.id])
121+
)
122+
115123
return self._response_post_save(request, obj)
116124

117125

backend/visa/models.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ def grant_approved_type(self):
114114
def user_grant(self):
115115
return Grant.objects.for_conference(self.conference).of_user(self.user).first()
116116

117+
@property
118+
def has_grant(self):
119+
return self.user_grant is not None
120+
117121
@cached_property
118122
def role(self):
119123
user = self.user
@@ -138,7 +142,7 @@ def user(self):
138142

139143
return self.requester
140144

141-
def schedule(self):
145+
def process(self):
142146
from visa.tasks import process_invitation_letter_request
143147

144148
transaction.on_commit(
@@ -147,6 +151,15 @@ def schedule(self):
147151
)
148152
)
149153

154+
def send_via_email(self):
155+
from visa.tasks import send_invitation_letter_via_email
156+
157+
transaction.on_commit(
158+
lambda: send_invitation_letter_via_email.delay(
159+
invitation_letter_request_id=self.id
160+
)
161+
)
162+
150163
def get_config(self):
151164
return self.conference.invitation_letter_config
152165

backend/visa/tasks.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from django.urls import reverse
2+
from notifications.models import EmailTemplate, EmailTemplateIdentifier
3+
from pycon.signing import sign_path
24
from integrations import slack
35
import time
46
from django.template import Template, Context
@@ -231,3 +233,33 @@ def notify_new_invitation_letter_request_on_slack(
231233
oauth_token=conference.get_slack_oauth_token(),
232234
channel_id=conference.slack_new_invitation_letter_request_channel_id,
233235
)
236+
237+
238+
@app.task
239+
def send_invitation_letter_via_email(*, invitation_letter_request_id: int):
240+
invitation_letter_request = InvitationLetterRequest.objects.get(
241+
id=invitation_letter_request_id
242+
)
243+
244+
conference = invitation_letter_request.conference
245+
246+
download_invitation_letter_path = reverse(
247+
"download-invitation-letter", args=[invitation_letter_request.id]
248+
)
249+
signed_path = sign_path(download_invitation_letter_path)
250+
251+
invitation_letter_download_url = f"https://admin.pycon.it{signed_path}"
252+
253+
email_template = EmailTemplate.objects.for_conference(conference).get_by_identifier(
254+
EmailTemplateIdentifier.visa_invitation_letter_download
255+
)
256+
email_template.send_email(
257+
recipient_email=invitation_letter_request.email,
258+
placeholders={
259+
"invitation_letter_download_url": invitation_letter_download_url,
260+
"has_grant": invitation_letter_request.has_grant,
261+
},
262+
)
263+
264+
invitation_letter_request.status = InvitationLetterRequestStatus.SENT
265+
invitation_letter_request.save(update_fields=["status"])

backend/visa/tests/factories.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
InvitationLetterDocument,
77
InvitationLetterRequest,
88
InvitationLetterConferenceConfig,
9+
InvitationLetterRequestStatus,
910
)
1011
import factory
1112
import factory.fuzzy
@@ -39,6 +40,11 @@ class Meta:
3940
model = InvitationLetterRequest
4041

4142

43+
class SentInvitationLetterRequestFactory(InvitationLetterRequestFactory):
44+
status = InvitationLetterRequestStatus.SENT
45+
invitation_letter = factory.django.FileField()
46+
47+
4248
class InvitationLetterConferenceConfigFactory(DjangoModelFactory):
4349
conference = factory.SubFactory(ConferenceFactory)
4450

backend/visa/tests/test_models.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def test_request_on_behalf_of_other():
2020
assert request.email == "[email protected]"
2121
assert request.user is None
2222
assert request.role == "Attendee"
23+
assert request.has_grant is False
2324

2425
# With matching user, it is found
2526
user = UserFactory(email="[email protected]")
@@ -47,6 +48,7 @@ def test_request_grant_info(approved_type):
4748
)
4849

4950
assert request.user_grant == grant
51+
assert request.has_grant is True
5052
assert request.has_accommodation_via_grant() == (
5153
approved_type
5254
in [
@@ -84,6 +86,18 @@ def test_schedule_processing(django_capture_on_commit_callbacks, mocker):
8486
)
8587

8688
with django_capture_on_commit_callbacks(execute=True):
87-
request.schedule()
89+
request.process()
90+
91+
mock_task.delay.assert_called_once_with(invitation_letter_request_id=request.id)
92+
93+
94+
def test_send_via_email(django_capture_on_commit_callbacks, mocker):
95+
mock_task = mocker.patch("visa.tasks.send_invitation_letter_via_email")
96+
request = InvitationLetterRequestFactory(
97+
on_behalf_of=InvitationLetterRequestOnBehalfOf.SELF,
98+
)
99+
100+
with django_capture_on_commit_callbacks(execute=True):
101+
request.send_via_email()
88102

89103
mock_task.delay.assert_called_once_with(invitation_letter_request_id=request.id)

backend/visa/tests/test_tasks.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
from django.urls import reverse
2+
from django.core.signing import Signer
3+
4+
from unittest.mock import patch
15
from uuid import uuid4
26
import requests
7+
from notifications.models import EmailTemplateIdentifier
38
from grants.tests.factories import GrantFactory
49
from grants.models import Grant
510
from visa.models import (
@@ -12,6 +17,7 @@
1217
notify_new_invitation_letter_request_on_slack,
1318
process_invitation_letter_request,
1419
process_invitation_letter_request_failed,
20+
send_invitation_letter_via_email,
1521
)
1622
from visa.tests.factories import (
1723
InvitationLetterAssetFactory,
@@ -375,3 +381,38 @@ def test_notify_new_invitation_letter_request_on_slack(mocker):
375381
kwargs = mock_slack.mock_calls[0][2]
376382
assert kwargs["oauth_token"] == "token123"
377383
assert kwargs["channel_id"] == "S123"
384+
385+
386+
def test_send_invitation_letter_via_email():
387+
invitation_letter_request = InvitationLetterRequestFactory()
388+
389+
with patch("visa.tasks.EmailTemplate") as mock_email_template:
390+
send_invitation_letter_via_email(
391+
invitation_letter_request_id=invitation_letter_request.id
392+
)
393+
394+
mock_email_template.objects.for_conference.assert_called_once_with(
395+
invitation_letter_request.conference
396+
)
397+
mock_email_template.objects.for_conference().get_by_identifier.assert_called_once_with(
398+
EmailTemplateIdentifier.visa_invitation_letter_download
399+
)
400+
401+
signer = Signer()
402+
url_path = reverse(
403+
"download-invitation-letter", args=[invitation_letter_request.id]
404+
)
405+
signed_url = signer.sign(url_path)
406+
signature = signed_url.split(signer.sep)[-1]
407+
408+
mock_email_template.objects.for_conference().get_by_identifier().send_email.assert_called_once_with(
409+
recipient_email=invitation_letter_request.email,
410+
placeholders={
411+
"invitation_letter_download_url": f"https://admin.pycon.it{url_path}?sig={signature}",
412+
"has_grant": False,
413+
},
414+
)
415+
416+
invitation_letter_request.refresh_from_db()
417+
418+
assert invitation_letter_request.status == InvitationLetterRequestStatus.SENT

0 commit comments

Comments
 (0)