Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions backend/api/submissions/tests/test_submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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'),
),
]
10 changes: 10 additions & 0 deletions backend/notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions backend/pycon/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
10 changes: 9 additions & 1 deletion backend/visa/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)


Expand Down
15 changes: 14 additions & 1 deletion backend/visa/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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

Expand Down
32 changes: 32 additions & 0 deletions backend/visa/tasks.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"])
6 changes: 6 additions & 0 deletions backend/visa/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
InvitationLetterDocument,
InvitationLetterRequest,
InvitationLetterConferenceConfig,
InvitationLetterRequestStatus,
)
import factory
import factory.fuzzy
Expand Down Expand Up @@ -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)

Expand Down
16 changes: 15 additions & 1 deletion backend/visa/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def test_request_on_behalf_of_other():
assert request.email == "[email protected]"
assert request.user is None
assert request.role == "Attendee"
assert request.has_grant is False

# With matching user, it is found
user = UserFactory(email="[email protected]")
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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)
41 changes: 41 additions & 0 deletions backend/visa/tests/test_tasks.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -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
105 changes: 105 additions & 0 deletions backend/visa/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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."
)
Loading