diff --git a/backend/api/submissions/tests/test_submissions.py b/backend/api/submissions/tests/test_submissions.py index 738bf7eefb..4f97876dda 100644 --- a/backend/api/submissions/tests/test_submissions.py +++ b/backend/api/submissions/tests/test_submissions.py @@ -87,14 +87,22 @@ def test_returns_submissions_paginated(graphql_client, user): ) assert not resp.get("errors") - assert resp["data"]["submissions"]["items"] == [{"id": submission_2.hashid}] + assert len(resp["data"]["submissions"]["items"]) == 1 + item = resp["data"]["submissions"]["items"][0] assert resp["data"]["submissions"]["pageInfo"] == {"totalPages": 2, "totalItems": 2} resp_2 = graphql_client.query( query, variables={"code": submission.conference.code, "page": 2}, ) - assert resp_2["data"]["submissions"]["items"] == [{"id": submission.hashid}] + + assert not resp_2.get("errors") + assert len(resp_2["data"]["submissions"]["items"]) == 1 + item_2 = resp_2["data"]["submissions"]["items"][0] + + assert set([item["id"], item_2["id"]]) == set( + [submission.hashid, submission_2.hashid] + ) def test_canceled_submissions_are_excluded(graphql_client, user, mock_has_ticket): diff --git a/backend/notifications/migrations/0020_alter_emailtemplate_identifier.py b/backend/notifications/migrations/0020_alter_emailtemplate_identifier.py new file mode 100644 index 0000000000..a63d44d613 --- /dev/null +++ b/backend/notifications/migrations/0020_alter_emailtemplate_identifier.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2025-02-06 23:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0019_alter_emailtemplate_identifier'), + ] + + operations = [ + migrations.AlterField( + model_name='emailtemplate', + name='identifier', + 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'), + ), + ] diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 954438c85c..b1457f3592 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -48,6 +48,11 @@ class EmailTemplateIdentifier(models.TextChoices): sponsorship_brochure = "sponsorship_brochure", _("Sponsorship brochure") + visa_invitation_letter_download = ( + "visa_invitation_letter_download", + _("Visa invitation letter download"), + ) + custom = "custom", _("Custom") @@ -154,6 +159,11 @@ class EmailTemplate(TimeStampedModel): "brochure_url", "conference_name", ], + EmailTemplateIdentifier.visa_invitation_letter_download: [ + *BASE_PLACEHOLDERS, + "invitation_letter_download_url", + "has_grant", + ], } conference = models.ForeignKey( diff --git a/backend/pycon/urls.py b/backend/pycon/urls.py index 04f2466f58..2811f3d1c8 100644 --- a/backend/pycon/urls.py +++ b/backend/pycon/urls.py @@ -32,6 +32,7 @@ path("", include("healthchecks.urls")), path("", include("files_upload.urls")), path("", include("notifications.urls")), + path("visa/", include("visa.urls")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/visa/admin.py b/backend/visa/admin.py index 5bb1bf96fb..3f49f07079 100644 --- a/backend/visa/admin.py +++ b/backend/visa/admin.py @@ -78,7 +78,10 @@ def save_form(self, request, form, change): obj = super().save_form(request, form, change) if "_process_now" in form.data: - obj.schedule() + obj.process() + + if "_send_via_email" in form.data: + obj.send_via_email() return obj @@ -112,6 +115,11 @@ def response_post_save_change(self, request, obj): reverse("admin:visa_invitationletterrequest_change", args=[obj.id]) ) + if "_send_via_email" in request.POST: + return HttpResponseRedirect( + reverse("admin:visa_invitationletterrequest_change", args=[obj.id]) + ) + return self._response_post_save(request, obj) diff --git a/backend/visa/models.py b/backend/visa/models.py index 7686542c87..9111e25c83 100644 --- a/backend/visa/models.py +++ b/backend/visa/models.py @@ -114,6 +114,10 @@ def grant_approved_type(self): def user_grant(self): return Grant.objects.for_conference(self.conference).of_user(self.user).first() + @property + def has_grant(self): + return self.user_grant is not None + @cached_property def role(self): user = self.user @@ -138,7 +142,7 @@ def user(self): return self.requester - def schedule(self): + def process(self): from visa.tasks import process_invitation_letter_request transaction.on_commit( @@ -147,6 +151,15 @@ def schedule(self): ) ) + def send_via_email(self): + from visa.tasks import send_invitation_letter_via_email + + transaction.on_commit( + lambda: send_invitation_letter_via_email.delay( + invitation_letter_request_id=self.id + ) + ) + def get_config(self): return self.conference.invitation_letter_config diff --git a/backend/visa/tasks.py b/backend/visa/tasks.py index 03f0824b59..640b1a0621 100644 --- a/backend/visa/tasks.py +++ b/backend/visa/tasks.py @@ -1,4 +1,6 @@ from django.urls import reverse +from notifications.models import EmailTemplate, EmailTemplateIdentifier +from pycon.signing import sign_path from integrations import slack import time from django.template import Template, Context @@ -231,3 +233,33 @@ def notify_new_invitation_letter_request_on_slack( oauth_token=conference.get_slack_oauth_token(), channel_id=conference.slack_new_invitation_letter_request_channel_id, ) + + +@app.task +def send_invitation_letter_via_email(*, invitation_letter_request_id: int): + invitation_letter_request = InvitationLetterRequest.objects.get( + id=invitation_letter_request_id + ) + + conference = invitation_letter_request.conference + + download_invitation_letter_path = reverse( + "download-invitation-letter", args=[invitation_letter_request.id] + ) + signed_path = sign_path(download_invitation_letter_path) + + invitation_letter_download_url = f"https://admin.pycon.it{signed_path}" + + email_template = EmailTemplate.objects.for_conference(conference).get_by_identifier( + EmailTemplateIdentifier.visa_invitation_letter_download + ) + email_template.send_email( + recipient_email=invitation_letter_request.email, + placeholders={ + "invitation_letter_download_url": invitation_letter_download_url, + "has_grant": invitation_letter_request.has_grant, + }, + ) + + invitation_letter_request.status = InvitationLetterRequestStatus.SENT + invitation_letter_request.save(update_fields=["status"]) diff --git a/backend/visa/tests/factories.py b/backend/visa/tests/factories.py index 43af65229c..35b6daec0a 100644 --- a/backend/visa/tests/factories.py +++ b/backend/visa/tests/factories.py @@ -6,6 +6,7 @@ InvitationLetterDocument, InvitationLetterRequest, InvitationLetterConferenceConfig, + InvitationLetterRequestStatus, ) import factory import factory.fuzzy @@ -39,6 +40,11 @@ class Meta: model = InvitationLetterRequest +class SentInvitationLetterRequestFactory(InvitationLetterRequestFactory): + status = InvitationLetterRequestStatus.SENT + invitation_letter = factory.django.FileField() + + class InvitationLetterConferenceConfigFactory(DjangoModelFactory): conference = factory.SubFactory(ConferenceFactory) diff --git a/backend/visa/tests/test_models.py b/backend/visa/tests/test_models.py index 356ef84192..913c942f0a 100644 --- a/backend/visa/tests/test_models.py +++ b/backend/visa/tests/test_models.py @@ -20,6 +20,7 @@ def test_request_on_behalf_of_other(): assert request.email == "example@example.org" assert request.user is None assert request.role == "Attendee" + assert request.has_grant is False # With matching user, it is found user = UserFactory(email="example@example.org") @@ -47,6 +48,7 @@ def test_request_grant_info(approved_type): ) assert request.user_grant == grant + assert request.has_grant is True assert request.has_accommodation_via_grant() == ( approved_type in [ @@ -84,6 +86,18 @@ def test_schedule_processing(django_capture_on_commit_callbacks, mocker): ) with django_capture_on_commit_callbacks(execute=True): - request.schedule() + request.process() + + mock_task.delay.assert_called_once_with(invitation_letter_request_id=request.id) + + +def test_send_via_email(django_capture_on_commit_callbacks, mocker): + mock_task = mocker.patch("visa.tasks.send_invitation_letter_via_email") + request = InvitationLetterRequestFactory( + on_behalf_of=InvitationLetterRequestOnBehalfOf.SELF, + ) + + with django_capture_on_commit_callbacks(execute=True): + request.send_via_email() mock_task.delay.assert_called_once_with(invitation_letter_request_id=request.id) diff --git a/backend/visa/tests/test_tasks.py b/backend/visa/tests/test_tasks.py index 0e410fbeff..d653dc6c05 100644 --- a/backend/visa/tests/test_tasks.py +++ b/backend/visa/tests/test_tasks.py @@ -1,5 +1,10 @@ +from django.urls import reverse +from django.core.signing import Signer + +from unittest.mock import patch from uuid import uuid4 import requests +from notifications.models import EmailTemplateIdentifier from grants.tests.factories import GrantFactory from grants.models import Grant from visa.models import ( @@ -12,6 +17,7 @@ notify_new_invitation_letter_request_on_slack, process_invitation_letter_request, process_invitation_letter_request_failed, + send_invitation_letter_via_email, ) from visa.tests.factories import ( InvitationLetterAssetFactory, @@ -375,3 +381,38 @@ def test_notify_new_invitation_letter_request_on_slack(mocker): kwargs = mock_slack.mock_calls[0][2] assert kwargs["oauth_token"] == "token123" assert kwargs["channel_id"] == "S123" + + +def test_send_invitation_letter_via_email(): + invitation_letter_request = InvitationLetterRequestFactory() + + with patch("visa.tasks.EmailTemplate") as mock_email_template: + send_invitation_letter_via_email( + invitation_letter_request_id=invitation_letter_request.id + ) + + mock_email_template.objects.for_conference.assert_called_once_with( + invitation_letter_request.conference + ) + mock_email_template.objects.for_conference().get_by_identifier.assert_called_once_with( + EmailTemplateIdentifier.visa_invitation_letter_download + ) + + signer = Signer() + url_path = reverse( + "download-invitation-letter", args=[invitation_letter_request.id] + ) + signed_url = signer.sign(url_path) + signature = signed_url.split(signer.sep)[-1] + + mock_email_template.objects.for_conference().get_by_identifier().send_email.assert_called_once_with( + recipient_email=invitation_letter_request.email, + placeholders={ + "invitation_letter_download_url": f"https://admin.pycon.it{url_path}?sig={signature}", + "has_grant": False, + }, + ) + + invitation_letter_request.refresh_from_db() + + assert invitation_letter_request.status == InvitationLetterRequestStatus.SENT diff --git a/backend/visa/tests/test_views.py b/backend/visa/tests/test_views.py new file mode 100644 index 0000000000..7e6eb1f79a --- /dev/null +++ b/backend/visa/tests/test_views.py @@ -0,0 +1,105 @@ +import pytest +from django.core.signing import Signer +from visa.models import InvitationLetterRequestStatus +from visa.tests.factories import ( + InvitationLetterRequestFactory, + SentInvitationLetterRequestFactory, +) +from pytest import mark +from django.urls import reverse + +pytestmark = mark.django_db + + +def test_download_invitation_letter_without_signature_fails(client): + invitation_letter_request = InvitationLetterRequestFactory( + status=InvitationLetterRequestStatus.SENT + ) + response = client.get( + reverse("download-invitation-letter", args=[invitation_letter_request.id]) + ) + + assert response.status_code == 403 + assert response.content.decode() == "Missing signature." + + +def test_download_invitation_letter_with_invalid_signature_fails(client): + signer = Signer() + signature = signer.sign("something-else").split(signer.sep)[-1] + + invitation_letter_request = InvitationLetterRequestFactory( + status=InvitationLetterRequestStatus.SENT + ) + response = client.get( + reverse("download-invitation-letter", args=[invitation_letter_request.id]) + + f"?sig={signature}" + ) + + assert response.status_code == 403 + assert response.content.decode() == "Invalid signature." + + +def test_download_invitation_letter(client): + invitation_letter_request = SentInvitationLetterRequestFactory() + + url_path = reverse( + "download-invitation-letter", args=[invitation_letter_request.id] + ) + + signer = Signer() + signature = signer.sign(url_path).split(signer.sep)[-1] + response = client.get(url_path + f"?sig={signature}") + + assert response.status_code == 302 + assert response.url == invitation_letter_request.invitation_letter.url + + +@pytest.mark.parametrize( + "status", + [ + InvitationLetterRequestStatus.PENDING, + InvitationLetterRequestStatus.PROCESSING, + InvitationLetterRequestStatus.PROCESSED, + InvitationLetterRequestStatus.FAILED_TO_GENERATE, + InvitationLetterRequestStatus.REJECTED, + ], +) +def test_cannot_download_non_sent_invitation_letter_request(client, status): + invitation_letter_request = InvitationLetterRequestFactory(status=status) + + url_path = reverse( + "download-invitation-letter", args=[invitation_letter_request.id] + ) + + signer = Signer() + signature = signer.sign(url_path).split(signer.sep)[-1] + response = client.get(url_path + f"?sig={signature}") + + assert response.status_code == 404 + assert ( + response.content.decode("utf-8") + == "We can't find this invitation letter request. Please contact us." + ) + + +def test_cannot_download_non_existent_invitation_letter_request(client): + invitation_letter_request = InvitationLetterRequestFactory( + status=InvitationLetterRequestStatus.REJECTED + ) + + url_path = reverse( + "download-invitation-letter", args=[invitation_letter_request.id] + ) + + signer = Signer() + signature = signer.sign(url_path).split(signer.sep)[-1] + + invitation_letter_request.delete() + + response = client.get(url_path + f"?sig={signature}") + + assert response.status_code == 404 + assert ( + response.content.decode("utf-8") + == "We can't find this invitation letter request. Please contact us." + ) diff --git a/backend/visa/urls.py b/backend/visa/urls.py new file mode 100644 index 0000000000..48af9e6c9f --- /dev/null +++ b/backend/visa/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from visa.views import download_invitation_letter + +urlpatterns = [ + path( + "download-invitation-letter/", + download_invitation_letter, + name="download-invitation-letter", + ), +] diff --git a/backend/visa/views.py b/backend/visa/views.py new file mode 100644 index 0000000000..416a2fce84 --- /dev/null +++ b/backend/visa/views.py @@ -0,0 +1,20 @@ +from django.http import HttpResponse +from visa.models import InvitationLetterRequest, InvitationLetterRequestStatus +from pycon.signing import require_signed_request +from django.shortcuts import redirect + + +@require_signed_request +def download_invitation_letter(request, id: int): + invitation_letter_request = InvitationLetterRequest.objects.filter(id=id).first() + + if ( + not invitation_letter_request + or invitation_letter_request.status != InvitationLetterRequestStatus.SENT + ): + return HttpResponse( + "We can't find this invitation letter request. Please contact us.", + status=404, + ) + + return redirect(invitation_letter_request.invitation_letter.url, permanent=False)