Skip to content

Commit 5b535e5

Browse files
committed
Implement sending the invitation letter via email
1 parent 1aa2ba0 commit 5b535e5

File tree

11 files changed

+223
-3
lines changed

11 files changed

+223
-3
lines changed
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+
"conference_name",
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: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def user(self):
138138

139139
return self.requester
140140

141-
def schedule(self):
141+
def process(self):
142142
from visa.tasks import process_invitation_letter_request
143143

144144
transaction.on_commit(
@@ -147,6 +147,15 @@ def schedule(self):
147147
)
148148
)
149149

150+
def send_via_email(self):
151+
from visa.tasks import send_invitation_letter_via_email
152+
153+
transaction.on_commit(
154+
lambda: send_invitation_letter_via_email.delay(
155+
invitation_letter_request_id=self.id
156+
)
157+
)
158+
150159
def get_config(self):
151160
return self.conference.invitation_letter_config
152161

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+
"conference_name": conference.name.localize("en"),
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,6 @@ def test_schedule_processing(django_capture_on_commit_callbacks, mocker):
8484
)
8585

8686
with django_capture_on_commit_callbacks(execute=True):
87-
request.schedule()
87+
request.process()
8888

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

backend/visa/tests/test_views.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import pytest
2+
from django.core.signing import Signer
3+
from visa.models import InvitationLetterRequestStatus
4+
from visa.tests.factories import (
5+
InvitationLetterRequestFactory,
6+
SentInvitationLetterRequestFactory,
7+
)
8+
from pytest import mark
9+
from django.urls import reverse
10+
11+
pytestmark = mark.django_db
12+
13+
14+
def test_download_invitation_letter_without_signature_fails(client):
15+
invitation_letter_request = InvitationLetterRequestFactory(
16+
status=InvitationLetterRequestStatus.SENT
17+
)
18+
response = client.get(
19+
reverse("download-invitation-letter", args=[invitation_letter_request.id])
20+
)
21+
22+
assert response.status_code == 403
23+
assert response.content.decode() == "Missing signature."
24+
25+
26+
def test_download_invitation_letter_with_invalid_signature_fails(client):
27+
signer = Signer()
28+
signature = signer.sign("something-else").split(signer.sep)[-1]
29+
30+
invitation_letter_request = InvitationLetterRequestFactory(
31+
status=InvitationLetterRequestStatus.SENT
32+
)
33+
response = client.get(
34+
reverse("download-invitation-letter", args=[invitation_letter_request.id])
35+
+ f"?sig={signature}"
36+
)
37+
38+
assert response.status_code == 403
39+
assert response.content.decode() == "Invalid signature."
40+
41+
42+
def test_download_invitation_letter(client):
43+
invitation_letter_request = SentInvitationLetterRequestFactory()
44+
45+
url_path = reverse(
46+
"download-invitation-letter", args=[invitation_letter_request.id]
47+
)
48+
49+
signer = Signer()
50+
signature = signer.sign(url_path).split(signer.sep)[-1]
51+
response = client.get(url_path + f"?sig={signature}")
52+
53+
assert response.status_code == 302
54+
assert response.url == invitation_letter_request.invitation_letter.url
55+
56+
57+
@pytest.mark.parametrize(
58+
"status",
59+
[
60+
InvitationLetterRequestStatus.PENDING,
61+
InvitationLetterRequestStatus.PROCESSING,
62+
InvitationLetterRequestStatus.PROCESSED,
63+
InvitationLetterRequestStatus.FAILED_TO_GENERATE,
64+
InvitationLetterRequestStatus.REJECTED,
65+
],
66+
)
67+
def test_cannot_download_non_sent_invitation_letter_request(client, status):
68+
invitation_letter_request = InvitationLetterRequestFactory(status=status)
69+
70+
url_path = reverse(
71+
"download-invitation-letter", args=[invitation_letter_request.id]
72+
)
73+
74+
signer = Signer()
75+
signature = signer.sign(url_path).split(signer.sep)[-1]
76+
response = client.get(url_path + f"?sig={signature}")
77+
78+
assert response.status_code == 404
79+
assert (
80+
response.content.decode("utf-8")
81+
== "We can't find this invitation letter request. Please contact us."
82+
)
83+
84+
85+
def test_cannot_download_non_existent_invitation_letter_request(client):
86+
invitation_letter_request = InvitationLetterRequestFactory(
87+
status=InvitationLetterRequestStatus.REJECTED
88+
)
89+
90+
url_path = reverse(
91+
"download-invitation-letter", args=[invitation_letter_request.id]
92+
)
93+
94+
signer = Signer()
95+
signature = signer.sign(url_path).split(signer.sep)[-1]
96+
97+
invitation_letter_request.delete()
98+
99+
response = client.get(url_path + f"?sig={signature}")
100+
101+
assert response.status_code == 404
102+
assert (
103+
response.content.decode("utf-8")
104+
== "We can't find this invitation letter request. Please contact us."
105+
)

backend/visa/urls.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django.urls import path
2+
3+
from visa.views import download_invitation_letter
4+
5+
urlpatterns = [
6+
path(
7+
"download-invitation-letter/<int:id>",
8+
download_invitation_letter,
9+
name="download-invitation-letter",
10+
),
11+
]

0 commit comments

Comments
 (0)