diff --git a/backend/api/schema.py b/backend/api/schema.py
index dae15417ca..a545c71c27 100644
--- a/backend/api/schema.py
+++ b/backend/api/schema.py
@@ -26,8 +26,8 @@
from .association_membership.mutation import AssociationMembershipMutation
from .cms.schema import CMSQuery
from .sponsors.schema import SponsorsMutation
-from .visa.queries import VisaQuery
-from .visa.mutations import VisaMutation
+from .visa.query import VisaQuery
+from .visa.mutation import VisaMutation
@strawberry.type
diff --git a/backend/api/types.py b/backend/api/types.py
index 7300efb292..469ab453ee 100644
--- a/backend/api/types.py
+++ b/backend/api/types.py
@@ -146,3 +146,13 @@ def paginate_list(
@strawberry.type
class NotFound:
message: str = "Not found"
+
+
+@strawberry.type
+class NoAdmissionTicket:
+ message: str = "User does not have admission ticket"
+
+
+@strawberry.type
+class FormNotAvailable:
+ message: str = "Form is not available"
diff --git a/backend/api/users/tests/test_invitation_letter_request.py b/backend/api/users/tests/test_invitation_letter_request.py
new file mode 100644
index 0000000000..34960e13eb
--- /dev/null
+++ b/backend/api/users/tests/test_invitation_letter_request.py
@@ -0,0 +1,155 @@
+from api.visa.types import (
+ InvitationLetterRequestStatus as InvitationLetterRequestStatusAPI,
+)
+from visa.models import InvitationLetterRequestStatus as InvitationLetterRequestStatusDB
+from users.tests.factories import UserFactory
+from visa.models import InvitationLetterRequestOnBehalfOf
+from visa.tests.factories import InvitationLetterRequestFactory
+from conferences.tests.factories import ConferenceFactory
+import pytest
+
+pytestmark = pytest.mark.django_db
+
+
+def _query_invitation_letter_request(client, conference):
+ return client.query(
+ """query($conference: String!) {
+ me {
+ invitationLetterRequest(conference: $conference) {
+ id
+ status
+ }
+ }
+ }""",
+ variables={
+ "conference": conference.code,
+ },
+ )
+
+
+def test_get_user_invitation_letter_request_with_none_present(graphql_client, user):
+ graphql_client.force_login(user)
+
+ conference = ConferenceFactory()
+ response = _query_invitation_letter_request(graphql_client, conference)
+
+ me = response["data"]["me"]
+ assert me["invitationLetterRequest"] is None
+
+
+def test_get_user_invitation_letter_request(graphql_client, user):
+ graphql_client.force_login(user)
+
+ conference = ConferenceFactory()
+ invitation_letter_request = InvitationLetterRequestFactory(
+ requester=user,
+ conference=conference,
+ )
+
+ response = _query_invitation_letter_request(graphql_client, conference)
+
+ me = response["data"]["me"]
+ assert me["invitationLetterRequest"]["id"] == str(invitation_letter_request.id)
+ assert me["invitationLetterRequest"]["status"] == invitation_letter_request.status
+
+
+@pytest.mark.parametrize(
+ "actual_status,exposed_status",
+ [
+ (
+ InvitationLetterRequestStatusDB.PENDING,
+ InvitationLetterRequestStatusAPI.PENDING,
+ ),
+ (
+ InvitationLetterRequestStatusDB.PROCESSING,
+ InvitationLetterRequestStatusAPI.PENDING,
+ ),
+ (
+ InvitationLetterRequestStatusDB.FAILED_TO_GENERATE,
+ InvitationLetterRequestStatusAPI.PENDING,
+ ),
+ (
+ InvitationLetterRequestStatusDB.PROCESSED,
+ InvitationLetterRequestStatusAPI.PENDING,
+ ),
+ (InvitationLetterRequestStatusDB.SENT, InvitationLetterRequestStatusAPI.SENT),
+ (
+ InvitationLetterRequestStatusDB.REJECTED,
+ InvitationLetterRequestStatusAPI.REJECTED,
+ ),
+ ],
+)
+def test_user_invitation_letter_request_has_user_friendly_status(
+ graphql_client, user, actual_status, exposed_status
+):
+ graphql_client.force_login(user)
+
+ conference = ConferenceFactory()
+ invitation_letter_request = InvitationLetterRequestFactory(
+ requester=user, conference=conference, status=actual_status
+ )
+
+ response = _query_invitation_letter_request(graphql_client, conference)
+
+ me = response["data"]["me"]
+ assert me["invitationLetterRequest"]["id"] == str(invitation_letter_request.id)
+ assert me["invitationLetterRequest"]["status"] == exposed_status.name
+
+
+def test_on_behalf_of_others_invitation_letter_request_are_excluded(
+ graphql_client, user
+):
+ graphql_client.force_login(user)
+
+ conference = ConferenceFactory()
+
+ InvitationLetterRequestFactory(
+ requester=user,
+ conference=conference,
+ on_behalf_of=InvitationLetterRequestOnBehalfOf.OTHER,
+ )
+
+ InvitationLetterRequestFactory(
+ requester=user,
+ conference=conference,
+ on_behalf_of=InvitationLetterRequestOnBehalfOf.OTHER,
+ )
+
+ response = _query_invitation_letter_request(graphql_client, conference)
+
+ me = response["data"]["me"]
+ assert me["invitationLetterRequest"] is None
+
+
+def test_other_users_invitation_letter_requests_are_excluded(graphql_client, user):
+ graphql_client.force_login(user)
+
+ conference = ConferenceFactory()
+
+ InvitationLetterRequestFactory(
+ requester=UserFactory(),
+ conference=conference,
+ on_behalf_of=InvitationLetterRequestOnBehalfOf.SELF,
+ )
+
+ response = _query_invitation_letter_request(graphql_client, conference)
+
+ me = response["data"]["me"]
+ assert me["invitationLetterRequest"] is None
+
+
+def test_other_conferences_invitation_letter_request_are_excluded(graphql_client, user):
+ graphql_client.force_login(user)
+
+ conference = ConferenceFactory()
+
+ InvitationLetterRequestFactory(
+ requester=user,
+ conference=ConferenceFactory(),
+ on_behalf_of=InvitationLetterRequestOnBehalfOf.SELF,
+ )
+
+ response = _query_invitation_letter_request(graphql_client, conference)
+
+ me = response["data"]["me"]
+ assert me["invitationLetterRequest"] is None
diff --git a/backend/api/users/tests/test_me.py b/backend/api/users/tests/test_me.py
index 30f16d3b49..bef99fc231 100644
--- a/backend/api/users/tests/test_me.py
+++ b/backend/api/users/tests/test_me.py
@@ -95,3 +95,49 @@ def test_is_python_italia_member_with_expired_membership(graphql_client, user, s
me = response["data"]["me"]
assert me["isPythonItaliaMember"] is False
+
+
+@pytest.mark.parametrize("has_ticket", [True, False])
+def test_has_admission_ticket(mock_has_ticket, has_ticket, graphql_client, user):
+ graphql_client.force_login(user)
+
+ conference = ConferenceFactory()
+ mock_has_ticket(conference, has_ticket=has_ticket, user=user)
+
+ response = graphql_client.query(
+ """query($conference: String!) {
+ me {
+ hasAdmissionTicket(conference: $conference)
+ }
+ }""",
+ variables={
+ "conference": conference.code,
+ },
+ )
+
+ me = response["data"]["me"]
+ assert me["hasAdmissionTicket"] == has_ticket
+
+
+@pytest.mark.parametrize("has_ticket", [True, False])
+def test_has_admission_ticket_with_non_existent_conference(
+ mock_has_ticket, has_ticket, graphql_client, user
+):
+ graphql_client.force_login(user)
+
+ conference = ConferenceFactory()
+ mock_has_ticket(conference, has_ticket=has_ticket, user=user)
+
+ response = graphql_client.query(
+ """query($conference: String!) {
+ me {
+ hasAdmissionTicket(conference: $conference)
+ }
+ }""",
+ variables={
+ "conference": "invalid",
+ },
+ )
+
+ me = response["data"]["me"]
+ assert me["hasAdmissionTicket"] is False
diff --git a/backend/api/users/types.py b/backend/api/users/types.py
index e849670575..de731308de 100644
--- a/backend/api/users/types.py
+++ b/backend/api/users/types.py
@@ -5,6 +5,12 @@
from django.urls import reverse
from api.billing.types import BillingAddress
+from api.visa.types import InvitationLetterRequest
+from visa.models import (
+ InvitationLetterRequest as InvitationLetterRequestModel,
+ InvitationLetterRequestOnBehalfOf,
+)
+from pretix import user_has_admission_ticket
from pycon.signing import sign_path
import strawberry
from strawberry.types import Info
@@ -139,6 +145,19 @@ def tickets(
attendee_tickets = get_user_tickets(conference, self.email, language)
return [ticket for ticket in attendee_tickets]
+ @strawberry.field
+ def has_admission_ticket(self, conference: str) -> bool:
+ conference = Conference.objects.filter(code=conference).first()
+
+ if not conference:
+ return False
+
+ return user_has_admission_ticket(
+ email=self.email,
+ event_organizer=conference.pretix_organizer_id,
+ event_slug=conference.pretix_event_id,
+ )
+
@strawberry.field
def submissions(self, info: Info, conference: str) -> list[Submission]:
return SubmissionModel.objects.filter(
@@ -162,6 +181,22 @@ def billing_addresses(self, conference: str) -> list[BillingAddress]:
.all()
]
+ @strawberry.field
+ def invitation_letter_request(
+ self, conference: str
+ ) -> InvitationLetterRequest | None:
+ invitation_letter_request = (
+ InvitationLetterRequestModel.objects.for_conference_code(conference)
+ .of_user(self.id)
+ .filter(on_behalf_of=InvitationLetterRequestOnBehalfOf.SELF)
+ .first()
+ )
+ return (
+ InvitationLetterRequest.from_model(invitation_letter_request)
+ if invitation_letter_request
+ else None
+ )
+
@classmethod
def from_django_model(cls, user):
return cls(
diff --git a/backend/api/visa/mutation.py b/backend/api/visa/mutation.py
new file mode 100644
index 0000000000..8456062124
--- /dev/null
+++ b/backend/api/visa/mutation.py
@@ -0,0 +1,13 @@
+from api.visa.mutations.update_invitation_letter_document import (
+ update_invitation_letter_document,
+)
+from api.visa.mutations.request_invitation_letter import request_invitation_letter
+from strawberry.tools import create_type
+
+VisaMutation = create_type(
+ "VisaMutation",
+ (
+ update_invitation_letter_document,
+ request_invitation_letter,
+ ),
+)
diff --git a/backend/api/visa/mutations/__init__.py b/backend/api/visa/mutations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/api/visa/mutations/request_invitation_letter.py b/backend/api/visa/mutations/request_invitation_letter.py
new file mode 100644
index 0000000000..73c6f1b216
--- /dev/null
+++ b/backend/api/visa/mutations/request_invitation_letter.py
@@ -0,0 +1,172 @@
+from django.db import transaction
+from datetime import date
+from typing import Annotated
+from api.types import BaseErrorType, FormNotAvailable, NoAdmissionTicket
+from api.utils import validate_email
+from api.visa.types import InvitationLetterOnBehalfOf, InvitationLetterRequest
+from api.extensions import RateLimit
+from conferences.models.deadline import Deadline
+from privacy_policy.record import record_privacy_policy_acceptance
+from visa.models import (
+ InvitationLetterRequest as InvitationLetterRequestModel,
+ InvitationLetterRequestOnBehalfOf,
+)
+from api.permissions import IsAuthenticated
+from api.context import Context
+from api.conferences.types import Conference
+from conferences.models import Conference as ConferenceModel
+from pretix import user_has_admission_ticket
+import strawberry
+
+MAX_LENGTH_FIELDS = {
+ "email": 254,
+ "full_name": 300,
+ "nationality": 100,
+ "address": 300,
+ "passport_number": 20,
+ "embassy_name": 300,
+}
+
+
+@strawberry.type
+class InvitationLetterAlreadyRequested:
+ message: str = "Invitation letter has already been requested for this conference"
+
+
+@strawberry.type
+class RequestInvitationLetterErrors(BaseErrorType):
+ @strawberry.type
+ class _RequestInvitationLetterErrors:
+ conference: list[str] = strawberry.field(default_factory=list)
+ on_behalf_of: list[str] = strawberry.field(default_factory=list)
+ email: list[str] = strawberry.field(default_factory=list)
+ full_name: list[str] = strawberry.field(default_factory=list)
+ date_of_birth: list[str] = strawberry.field(default_factory=list)
+ nationality: list[str] = strawberry.field(default_factory=list)
+ address: list[str] = strawberry.field(default_factory=list)
+ passport_number: list[str] = strawberry.field(default_factory=list)
+ embassy_name: list[str] = strawberry.field(default_factory=list)
+
+ errors: _RequestInvitationLetterErrors = None
+
+
+@strawberry.input
+class RequestInvitationLetterInput:
+ conference: str
+ on_behalf_of: InvitationLetterOnBehalfOf
+ email: str
+ full_name: str
+ nationality: str
+ address: str
+ passport_number: str
+ embassy_name: str
+ date_of_birth: date
+
+ def validate(self, conference: Conference) -> RequestInvitationLetterErrors | None:
+ errors = RequestInvitationLetterErrors()
+
+ required_fields = [
+ "conference",
+ "on_behalf_of",
+ "full_name",
+ "nationality",
+ "address",
+ "passport_number",
+ "embassy_name",
+ "date_of_birth",
+ ]
+
+ if self.on_behalf_of == InvitationLetterOnBehalfOf.OTHER:
+ required_fields.append("email")
+
+ for field_name in required_fields:
+ if not getattr(self, field_name):
+ errors.add_error(field_name, "This field is required")
+
+ for field_name, max_length in MAX_LENGTH_FIELDS.items():
+ value = getattr(self, field_name)
+
+ if value and len(value) > max_length:
+ errors.add_error(
+ field_name,
+ f"Ensure this field has no more than {max_length} characters",
+ )
+
+ if self.email and not validate_email(self.email):
+ errors.add_error("email", "Invalid email address")
+
+ if not conference:
+ errors.add_error("conference", "Conference not found")
+
+ return errors.if_has_errors
+
+
+RequestInvitationLetterResult = Annotated[
+ InvitationLetterRequest
+ | RequestInvitationLetterErrors
+ | NoAdmissionTicket
+ | InvitationLetterAlreadyRequested
+ | FormNotAvailable,
+ strawberry.union(name="RequestInvitationLetterResult"),
+]
+
+
+@strawberry.mutation(
+ permission_classes=[IsAuthenticated],
+ extensions=[RateLimit("5/m")],
+)
+def request_invitation_letter(
+ info: strawberry.Info[Context], input: RequestInvitationLetterInput
+) -> RequestInvitationLetterResult:
+ conference = ConferenceModel.objects.filter(code=input.conference).first()
+
+ if errors := input.validate(conference):
+ return errors
+
+ if not conference.is_deadline_active(Deadline.TYPES.invitation_letter_request):
+ return FormNotAvailable()
+
+ user = info.context.request.user
+
+ if input.on_behalf_of == InvitationLetterOnBehalfOf.SELF:
+ input.email = ""
+
+ if not user_has_admission_ticket(
+ email=info.context.request.user.email,
+ event_organizer=conference.pretix_organizer_id,
+ event_slug=conference.pretix_event_id,
+ ):
+ return NoAdmissionTicket()
+
+ if (
+ InvitationLetterRequestModel.objects.for_conference(conference)
+ .of_user(user)
+ .filter(
+ on_behalf_of=InvitationLetterRequestOnBehalfOf.SELF,
+ )
+ .exists()
+ ):
+ return InvitationLetterAlreadyRequested()
+
+ with transaction.atomic():
+ invitation_letter, created = InvitationLetterRequestModel.objects.get_or_create(
+ conference=conference,
+ requester=user,
+ on_behalf_of=InvitationLetterRequestOnBehalfOf(input.on_behalf_of.name),
+ full_name=input.full_name,
+ email_address=input.email,
+ nationality=input.nationality,
+ address=input.address,
+ date_of_birth=input.date_of_birth,
+ passport_number=input.passport_number,
+ embassy_name=input.embassy_name,
+ )
+
+ if created:
+ record_privacy_policy_acceptance(
+ info.context.request,
+ conference,
+ "invitation_letter",
+ )
+
+ return InvitationLetterRequest.from_model(invitation_letter)
diff --git a/backend/api/visa/mutations.py b/backend/api/visa/mutations/update_invitation_letter_document.py
similarity index 93%
rename from backend/api/visa/mutations.py
rename to backend/api/visa/mutations/update_invitation_letter_document.py
index b1cfdeaf49..22adcb6f24 100644
--- a/backend/api/visa/mutations.py
+++ b/backend/api/visa/mutations/update_invitation_letter_document.py
@@ -4,7 +4,6 @@
from custom_admin.audit import create_change_admin_log_entry
from visa.models import InvitationLetterDocument as InvitationLetterDocumentModel
from api.visa.permissions import CanEditInvitationLetterDocument
-from strawberry.tools import create_type
from api.visa.types import InvitationLetterDocument
import strawberry
@@ -65,9 +64,3 @@ def update_invitation_letter_document(
change_message="Invitation letter document updated",
)
return InvitationLetterDocument.from_model(invitation_letter_document)
-
-
-VisaMutation = create_type(
- "VisaMutation",
- (update_invitation_letter_document,),
-)
diff --git a/backend/api/visa/queries/__init__.py b/backend/api/visa/queries/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/api/visa/queries.py b/backend/api/visa/queries/invitation_letter_document.py
similarity index 82%
rename from backend/api/visa/queries.py
rename to backend/api/visa/queries/invitation_letter_document.py
index 637fd44dc0..a195276bd2 100644
--- a/backend/api/visa/queries.py
+++ b/backend/api/visa/queries/invitation_letter_document.py
@@ -2,7 +2,6 @@
from visa.models import InvitationLetterDocument as InvitationLetterDocumentModel
from api.visa.types import InvitationLetterDocument
import strawberry
-from strawberry.tools import create_type
@strawberry.field(permission_classes=[CanViewInvitationLetterDocument])
@@ -13,9 +12,3 @@ def invitation_letter_document(id: strawberry.ID) -> InvitationLetterDocument |
return InvitationLetterDocument.from_model(invitation_letter_document)
return None
-
-
-VisaQuery = create_type(
- "VisaQuery",
- (invitation_letter_document,),
-)
diff --git a/backend/api/visa/query.py b/backend/api/visa/query.py
new file mode 100644
index 0000000000..04720a5db8
--- /dev/null
+++ b/backend/api/visa/query.py
@@ -0,0 +1,8 @@
+from api.visa.queries.invitation_letter_document import invitation_letter_document
+from strawberry.tools import create_type
+
+
+VisaQuery = create_type(
+ "VisaQuery",
+ (invitation_letter_document,),
+)
diff --git a/backend/api/visa/tests/mutations/test_request_invitation_letter.py b/backend/api/visa/tests/mutations/test_request_invitation_letter.py
new file mode 100644
index 0000000000..592060ea5b
--- /dev/null
+++ b/backend/api/visa/tests/mutations/test_request_invitation_letter.py
@@ -0,0 +1,618 @@
+from conferences.models.deadline import Deadline
+from privacy_policy.models import PrivacyPolicyAcceptanceRecord
+
+from datetime import date
+from api.visa.mutations.request_invitation_letter import MAX_LENGTH_FIELDS
+from visa.tests.factories import InvitationLetterRequestFactory
+from visa.models import (
+ InvitationLetterRequest,
+ InvitationLetterRequestOnBehalfOf,
+ InvitationLetterRequestStatus,
+)
+from conferences.tests.factories import (
+ PastDeadlineFactory,
+ ConferenceFactory,
+ FutureDeadlineFactory,
+ ActiveDeadlineFactory,
+)
+import pytest
+
+pytestmark = pytest.mark.django_db
+
+
+def _request_invitation_letter(client, **input):
+ return client.query(
+ """mutation RequestInvitationLetter($input: RequestInvitationLetterInput!) {
+ requestInvitationLetter(input: $input) {
+ __typename
+
+ ... on InvitationLetterRequest {
+ id
+ status
+ }
+
+ ... on RequestInvitationLetterErrors {
+ errors {
+ address
+ conference
+ dateOfBirth
+ email
+ embassyName
+ fullName
+ nationality
+ onBehalfOf
+ passportNumber
+ }
+ }
+ }
+ }""",
+ variables=input,
+ )
+
+
+def test_request_invitation_letter(graphql_client, user, mock_has_ticket):
+ conference = ConferenceFactory()
+ ActiveDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+ mock_has_ticket(conference)
+
+ graphql_client.force_login(user)
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input={
+ "conference": conference.code,
+ "onBehalfOf": "SELF",
+ "fullName": "Mario Rossi",
+ "email": "",
+ "nationality": "Italian",
+ "address": "via Roma",
+ "passportNumber": "YA1234567",
+ "embassyName": "Italian Embassy in France",
+ "dateOfBirth": "1999-01-01",
+ },
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"]
+ == "InvitationLetterRequest"
+ )
+ assert response["data"]["requestInvitationLetter"]["status"] == "PENDING"
+
+ invitation_letter_request = InvitationLetterRequest.objects.get()
+
+ assert response["data"]["requestInvitationLetter"]["id"] == str(
+ invitation_letter_request.id
+ )
+
+ assert invitation_letter_request.requester == user
+ assert invitation_letter_request.conference == conference
+ assert (
+ invitation_letter_request.on_behalf_of == InvitationLetterRequestOnBehalfOf.SELF
+ )
+ assert invitation_letter_request.full_name == "Mario Rossi"
+ assert invitation_letter_request.email_address == ""
+ assert invitation_letter_request.nationality == "Italian"
+ assert invitation_letter_request.address == "via Roma"
+ assert invitation_letter_request.passport_number == "YA1234567"
+ assert invitation_letter_request.embassy_name == "Italian Embassy in France"
+ assert invitation_letter_request.date_of_birth == date(1999, 1, 1)
+ assert invitation_letter_request.status == InvitationLetterRequestStatus.PENDING
+ assert PrivacyPolicyAcceptanceRecord.objects.filter(
+ user=user, conference=conference, privacy_policy="invitation_letter"
+ ).exists()
+
+
+def test_can_request_invitation_letter_to_multiple_conferences(
+ graphql_client, user, mock_has_ticket
+):
+ graphql_client.force_login(user)
+
+ conference = ConferenceFactory()
+ mock_has_ticket(conference)
+ ActiveDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ InvitationLetterRequestFactory(
+ requester=user,
+ conference=conference,
+ on_behalf_of=InvitationLetterRequestOnBehalfOf.SELF,
+ )
+
+ other_conference = ConferenceFactory()
+ mock_has_ticket(other_conference)
+ ActiveDeadlineFactory(
+ conference=other_conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input={
+ "conference": other_conference.code,
+ "onBehalfOf": "SELF",
+ "fullName": "Mario Rossi",
+ "email": "",
+ "nationality": "Italian",
+ "address": "via Roma",
+ "passportNumber": "YA1234567",
+ "embassyName": "Italian Embassy in France",
+ "dateOfBirth": "1999-01-01",
+ },
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"]
+ == "InvitationLetterRequest"
+ )
+ assert response["data"]["requestInvitationLetter"]["status"] == "PENDING"
+
+ invitation_letter_request = InvitationLetterRequest.objects.filter(
+ requester=user,
+ conference=other_conference,
+ ).get()
+
+ assert response["data"]["requestInvitationLetter"]["id"] == str(
+ invitation_letter_request.id
+ )
+
+ assert invitation_letter_request.requester == user
+ assert invitation_letter_request.conference == other_conference
+ assert (
+ invitation_letter_request.on_behalf_of == InvitationLetterRequestOnBehalfOf.SELF
+ )
+ assert invitation_letter_request.full_name == "Mario Rossi"
+ assert invitation_letter_request.email_address == ""
+ assert invitation_letter_request.nationality == "Italian"
+ assert invitation_letter_request.address == "via Roma"
+ assert invitation_letter_request.passport_number == "YA1234567"
+ assert invitation_letter_request.embassy_name == "Italian Embassy in France"
+ assert invitation_letter_request.date_of_birth == date(1999, 1, 1)
+ assert invitation_letter_request.status == InvitationLetterRequestStatus.PENDING
+ assert PrivacyPolicyAcceptanceRecord.objects.filter(
+ user=user, conference=other_conference, privacy_policy="invitation_letter"
+ ).exists()
+
+
+def test_request_invitation_letter_email_is_ignored_for_self_requests(
+ graphql_client, user, mock_has_ticket
+):
+ conference = ConferenceFactory()
+ mock_has_ticket(conference)
+ ActiveDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ graphql_client.force_login(user)
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input={
+ "conference": conference.code,
+ "onBehalfOf": "SELF",
+ "fullName": "Mario Rossi",
+ "email": "ignored@example.com",
+ "nationality": "Italian",
+ "address": "via Roma",
+ "passportNumber": "YA1234567",
+ "embassyName": "Italian Embassy in France",
+ "dateOfBirth": "1999-01-01",
+ },
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"]
+ == "InvitationLetterRequest"
+ )
+ assert response["data"]["requestInvitationLetter"]["status"] == "PENDING"
+
+ invitation_letter_request = InvitationLetterRequest.objects.get()
+
+ assert response["data"]["requestInvitationLetter"]["id"] == str(
+ invitation_letter_request.id
+ )
+
+ assert invitation_letter_request.requester == user
+ assert invitation_letter_request.conference == conference
+ assert (
+ invitation_letter_request.on_behalf_of == InvitationLetterRequestOnBehalfOf.SELF
+ )
+ assert invitation_letter_request.full_name == "Mario Rossi"
+ assert invitation_letter_request.email_address == ""
+
+
+@pytest.mark.parametrize("has_ticket", [True, False])
+def test_request_invitation_letter_on_behalf_of_other(
+ graphql_client, user, mock_has_ticket, has_ticket
+):
+ conference = ConferenceFactory()
+ mock_has_ticket(conference, has_ticket=has_ticket, user=user)
+ ActiveDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ graphql_client.force_login(user)
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input={
+ "conference": conference.code,
+ "onBehalfOf": "OTHER",
+ "fullName": "Jane Doe",
+ "email": "other@example.com",
+ "nationality": "Italian",
+ "address": "via Roma",
+ "passportNumber": "YA1234567",
+ "embassyName": "Italian Embassy in London",
+ "dateOfBirth": "1995-12-03",
+ },
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"]
+ == "InvitationLetterRequest"
+ )
+ assert response["data"]["requestInvitationLetter"]["status"] == "PENDING"
+
+ invitation_letter_request = InvitationLetterRequest.objects.get()
+
+ assert response["data"]["requestInvitationLetter"]["id"] == str(
+ invitation_letter_request.id
+ )
+
+ assert invitation_letter_request.requester == user
+ assert invitation_letter_request.conference == conference
+ assert (
+ invitation_letter_request.on_behalf_of
+ == InvitationLetterRequestOnBehalfOf.OTHER
+ )
+ assert invitation_letter_request.full_name == "Jane Doe"
+ assert invitation_letter_request.email_address == "other@example.com"
+ assert invitation_letter_request.nationality == "Italian"
+ assert invitation_letter_request.address == "via Roma"
+ assert invitation_letter_request.passport_number == "YA1234567"
+ assert invitation_letter_request.embassy_name == "Italian Embassy in London"
+ assert invitation_letter_request.date_of_birth == date(1995, 12, 3)
+ assert invitation_letter_request.status == InvitationLetterRequestStatus.PENDING
+
+
+def test_duplicate_requests_for_others_are_ignored(
+ graphql_client, user, mock_has_ticket
+):
+ conference = ConferenceFactory()
+ mock_has_ticket(conference, has_ticket=True, user=user)
+ ActiveDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ graphql_client.force_login(user)
+
+ input = {
+ "conference": conference.code,
+ "onBehalfOf": "OTHER",
+ "fullName": "Jane Doe",
+ "email": "other@example.com",
+ "nationality": "Italian",
+ "address": "via Roma",
+ "passportNumber": "YA1234567",
+ "embassyName": "Italian Embassy in London",
+ "dateOfBirth": "1995-12-03",
+ }
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input=input,
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"]
+ == "InvitationLetterRequest"
+ )
+ assert response["data"]["requestInvitationLetter"]["status"] == "PENDING"
+
+ invitation_letter_request = InvitationLetterRequest.objects.get()
+ assert response["data"]["requestInvitationLetter"]["id"] == str(
+ invitation_letter_request.id
+ )
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input=input,
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"]
+ == "InvitationLetterRequest"
+ )
+ assert response["data"]["requestInvitationLetter"]["status"] == "PENDING"
+ assert response["data"]["requestInvitationLetter"]["id"] == str(
+ invitation_letter_request.id
+ )
+ assert InvitationLetterRequest.objects.count() == 1
+ assert (
+ PrivacyPolicyAcceptanceRecord.objects.filter(
+ user=user, conference=conference, privacy_policy="invitation_letter"
+ ).count()
+ == 1
+ )
+
+
+def test_cannot_request_invitation_letter_if_already_done(
+ graphql_client, user, mock_has_ticket
+):
+ conference = ConferenceFactory()
+ mock_has_ticket(conference)
+ ActiveDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ InvitationLetterRequestFactory(
+ requester=user,
+ conference=conference,
+ )
+
+ graphql_client.force_login(user)
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input={
+ "conference": conference.code,
+ "onBehalfOf": "SELF",
+ "fullName": "Mario Rossi",
+ "email": "",
+ "nationality": "Italian",
+ "address": "via Roma",
+ "passportNumber": "YA1234567",
+ "embassyName": "Italian Embassy in France",
+ "dateOfBirth": "1999-01-01",
+ },
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"]
+ == "InvitationLetterAlreadyRequested"
+ )
+ assert InvitationLetterRequest.objects.count() == 1
+
+
+def test_cannot_request_invitation_letter_without_ticket(
+ graphql_client, user, mock_has_ticket
+):
+ conference = ConferenceFactory()
+ mock_has_ticket(conference, False)
+ ActiveDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ graphql_client.force_login(user)
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input={
+ "conference": conference.code,
+ "onBehalfOf": "SELF",
+ "fullName": "Mario Rossi",
+ "email": "",
+ "nationality": "Italian",
+ "address": "via Roma",
+ "passportNumber": "YA1234567",
+ "embassyName": "Italian Embassy in France",
+ "dateOfBirth": "1999-01-01",
+ },
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"] == "NoAdmissionTicket"
+ )
+ assert InvitationLetterRequest.objects.count() == 0
+
+
+def test_cannot_request_invitation_letter_for_non_existing_conference(
+ graphql_client, user, mock_has_ticket
+):
+ conference = ConferenceFactory()
+ mock_has_ticket(conference)
+ ActiveDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ graphql_client.force_login(user)
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input={
+ "conference": "non-existing",
+ "onBehalfOf": "SELF",
+ "fullName": "Mario Rossi",
+ "email": "",
+ "nationality": "Italian",
+ "address": "via Roma",
+ "passportNumber": "YA1234567",
+ "embassyName": "Italian Embassy in France",
+ "dateOfBirth": "1999-01-01",
+ },
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"]
+ == "RequestInvitationLetterErrors"
+ )
+ assert response["data"]["requestInvitationLetter"]["errors"]["conference"] == [
+ "Conference not found"
+ ]
+ assert InvitationLetterRequest.objects.count() == 0
+
+
+def test_email_is_required_when_requesting_on_behalf_of_other(
+ graphql_client, user, mock_has_ticket
+):
+ conference = ConferenceFactory()
+ mock_has_ticket(conference, has_ticket=True, user=user)
+ ActiveDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ graphql_client.force_login(user)
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input={
+ "conference": conference.code,
+ "onBehalfOf": "OTHER",
+ "fullName": "Mario Rossi",
+ "email": "",
+ "nationality": "Italian",
+ "address": "via Roma",
+ "passportNumber": "YA1234567",
+ "embassyName": "Italian Embassy in France",
+ "dateOfBirth": "1999-01-01",
+ },
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"]
+ == "RequestInvitationLetterErrors"
+ )
+ assert response["data"]["requestInvitationLetter"]["errors"]["email"] == [
+ "This field is required"
+ ]
+ assert InvitationLetterRequest.objects.count() == 0
+
+
+def test_required_fields_are_enforced(graphql_client, user, mock_has_ticket):
+ conference = ConferenceFactory()
+ mock_has_ticket(conference, has_ticket=True, user=user)
+ ActiveDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ graphql_client.force_login(user)
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input={
+ "conference": conference.code,
+ "onBehalfOf": "SELF",
+ "fullName": "",
+ "email": "",
+ "nationality": "",
+ "address": "",
+ "passportNumber": "",
+ "embassyName": "",
+ "dateOfBirth": "1992-10-10",
+ },
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"]
+ == "RequestInvitationLetterErrors"
+ )
+ assert response["data"]["requestInvitationLetter"]["errors"] == {
+ "conference": [],
+ "address": ["This field is required"],
+ "embassyName": ["This field is required"],
+ "fullName": ["This field is required"],
+ "passportNumber": ["This field is required"],
+ "nationality": ["This field is required"],
+ "dateOfBirth": [],
+ "onBehalfOf": [],
+ "email": [],
+ }
+ assert InvitationLetterRequest.objects.count() == 0
+
+
+def test_max_lengths_are_enforced(graphql_client, user, mock_has_ticket):
+ conference = ConferenceFactory()
+ mock_has_ticket(conference, has_ticket=True, user=user)
+ ActiveDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ graphql_client.force_login(user)
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input={
+ "conference": conference.code,
+ "onBehalfOf": "SELF",
+ "fullName": "a" * (MAX_LENGTH_FIELDS["full_name"] + 1),
+ "email": "a" * (MAX_LENGTH_FIELDS["email"] + 1),
+ "nationality": "a" * (MAX_LENGTH_FIELDS["nationality"] + 1),
+ "address": "a" * (MAX_LENGTH_FIELDS["address"] + 1),
+ "passportNumber": "a" * (MAX_LENGTH_FIELDS["passport_number"] + 1),
+ "embassyName": "a" * (MAX_LENGTH_FIELDS["embassy_name"] + 1),
+ "dateOfBirth": "1992-10-10",
+ },
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"]
+ == "RequestInvitationLetterErrors"
+ )
+ assert response["data"]["requestInvitationLetter"]["errors"] == {
+ "conference": [],
+ "address": [
+ f"Ensure this field has no more than {MAX_LENGTH_FIELDS['address']} characters"
+ ],
+ "embassyName": [
+ f"Ensure this field has no more than {MAX_LENGTH_FIELDS['embassy_name']} characters"
+ ],
+ "fullName": [
+ f"Ensure this field has no more than {MAX_LENGTH_FIELDS['full_name']} characters"
+ ],
+ "passportNumber": [
+ f"Ensure this field has no more than {MAX_LENGTH_FIELDS['passport_number']} characters"
+ ],
+ "nationality": [
+ f"Ensure this field has no more than {MAX_LENGTH_FIELDS['nationality']} characters"
+ ],
+ "dateOfBirth": [],
+ "onBehalfOf": [],
+ "email": [
+ f"Ensure this field has no more than {MAX_LENGTH_FIELDS['email']} characters",
+ "Invalid email address",
+ ],
+ }
+ assert InvitationLetterRequest.objects.count() == 0
+
+
+@pytest.mark.parametrize("deadline_status", [None, "past", "future"])
+def test_cannot_request_invitation_letter_if_form_is_disabled(
+ graphql_client, user, mock_has_ticket, deadline_status
+):
+ conference = ConferenceFactory()
+
+ if deadline_status == "past":
+ PastDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+ elif deadline_status == "future":
+ FutureDeadlineFactory(
+ conference=conference, type=Deadline.TYPES.invitation_letter_request
+ )
+
+ mock_has_ticket(conference)
+
+ graphql_client.force_login(user)
+
+ response = _request_invitation_letter(
+ graphql_client,
+ input={
+ "conference": conference.code,
+ "onBehalfOf": "SELF",
+ "fullName": "Mario Rossi",
+ "email": "",
+ "nationality": "Italian",
+ "address": "via Roma",
+ "passportNumber": "YA1234567",
+ "embassyName": "Italian Embassy in France",
+ "dateOfBirth": "1999-01-01",
+ },
+ )
+
+ assert (
+ response["data"]["requestInvitationLetter"]["__typename"] == "FormNotAvailable"
+ )
+
+ assert InvitationLetterRequest.objects.count() == 0
diff --git a/backend/api/visa/types.py b/backend/api/visa/types.py
index 50281798d4..f49100f00d 100644
--- a/backend/api/visa/types.py
+++ b/backend/api/visa/types.py
@@ -1,6 +1,44 @@
+from enum import Enum
+from visa.models import InvitationLetterRequestStatus as InvitationLetterRequestStatusDB
import strawberry
+@strawberry.enum
+class InvitationLetterOnBehalfOf(Enum):
+ SELF = "self"
+ OTHER = "other"
+
+
+@strawberry.enum
+class InvitationLetterRequestStatus(Enum):
+ PENDING = "pending"
+ SENT = "sent"
+ REJECTED = "rejected"
+
+
+def _convert_request_status_to_public(status):
+ if status == InvitationLetterRequestStatusDB.REJECTED:
+ return InvitationLetterRequestStatus.REJECTED
+
+ if status == InvitationLetterRequestStatusDB.SENT:
+ return InvitationLetterRequestStatus.SENT
+
+ return InvitationLetterRequestStatus.PENDING
+
+
+@strawberry.type
+class InvitationLetterRequest:
+ id: strawberry.ID
+ status: InvitationLetterRequestStatus
+
+ @classmethod
+ def from_model(cls, instance):
+ return cls(
+ id=instance.id,
+ status=_convert_request_status_to_public(instance.status),
+ )
+
+
@strawberry.type
class InvitationLetterDocumentPage:
id: strawberry.ID
diff --git a/backend/conferences/migrations/0052_alter_deadline_type.py b/backend/conferences/migrations/0052_alter_deadline_type.py
new file mode 100644
index 0000000000..d24206717d
--- /dev/null
+++ b/backend/conferences/migrations/0052_alter_deadline_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.4 on 2024-12-29 15:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('conferences', '0051_conference_location'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='deadline',
+ name='type',
+ field=models.CharField(choices=[('cfp', 'Call for proposal'), ('voting', 'Voting'), ('refund', 'Ticket refund'), ('grants', 'Grants'), ('badge_preview', 'Badge preview'), ('invitation_letter_request', 'Invitation letter request'), ('custom', 'Custom deadline')], max_length=256, verbose_name='type'),
+ ),
+ ]
diff --git a/backend/conferences/models/conference.py b/backend/conferences/models/conference.py
index 292bba2896..dc5971f0c5 100644
--- a/backend/conferences/models/conference.py
+++ b/backend/conferences/models/conference.py
@@ -184,6 +184,13 @@ def is_grants_open(self):
except Deadline.DoesNotExist:
return False
+ def is_deadline_active(self, deadline_type: str):
+ try:
+ deadline = self.deadlines.get(type=deadline_type)
+ return deadline.status == DeadlineStatus.HAPPENING_NOW
+ except Deadline.DoesNotExist:
+ return False
+
def __str__(self):
return f"{self.name} <{self.code}>"
diff --git a/backend/conferences/models/deadline.py b/backend/conferences/models/deadline.py
index 289d8f7711..69b7816144 100644
--- a/backend/conferences/models/deadline.py
+++ b/backend/conferences/models/deadline.py
@@ -22,6 +22,7 @@ class Deadline(TimeFramedModel):
("refund", _("Ticket refund")),
("grants", _("Grants")),
("badge_preview", _("Badge preview")),
+ ("invitation_letter_request", _("Invitation letter request")),
("custom", _("Custom deadline")),
)
diff --git a/backend/conferences/tests/factories.py b/backend/conferences/tests/factories.py
index 0c2c36daf0..88af464851 100644
--- a/backend/conferences/tests/factories.py
+++ b/backend/conferences/tests/factories.py
@@ -177,6 +177,21 @@ class Meta:
model = Deadline
+class PastDeadlineFactory(DeadlineFactory):
+ start = factory.Faker("past_datetime", tzinfo=UTC)
+ end = factory.Faker("past_datetime", tzinfo=UTC)
+
+
+class FutureDeadlineFactory(DeadlineFactory):
+ start = factory.Faker("future_datetime", tzinfo=UTC)
+ end = factory.Faker("future_datetime", tzinfo=UTC)
+
+
+class ActiveDeadlineFactory(DeadlineFactory):
+ start = factory.Faker("past_datetime", tzinfo=UTC)
+ end = factory.Faker("future_datetime", tzinfo=UTC)
+
+
class AudienceLevelFactory(DjangoModelFactory):
name = factory.fuzzy.FuzzyChoice(("Beginner", "Intermidiate", "Advanced"))
diff --git a/backend/conftest.py b/backend/conftest.py
index 525652533e..c1bd36a6ca 100644
--- a/backend/conftest.py
+++ b/backend/conftest.py
@@ -1,3 +1,5 @@
+import json
+import requests
from wagtail.models import Locale
from django.core.files.images import ImageFile
import os
@@ -91,10 +93,26 @@ def locale():
@pytest.fixture
def mock_has_ticket(requests_mock, settings):
- def wrapper(conference, has_ticket=True):
- requests_mock.post(
- f"{settings.PRETIX_API}organizers/{conference.pretix_organizer_id}/events/{conference.pretix_event_id}/tickets/attendee-has-ticket/",
- json={"user_has_admission_ticket": has_ticket},
- )
+ def wrapper(conference, has_ticket=True, user=None):
+ def matcher(req):
+ path = f"{settings.PRETIX_API}organizers/{conference.pretix_organizer_id}/events/{conference.pretix_event_id}/tickets/attendee-has-ticket/"
+ if req.url != path:
+ return None
+
+ resp = requests.Response()
+ resp.status_code = 200
+ data = req.json()
+
+ ticket_result = (
+ has_ticket
+ if not user or data["attendee_email"] == user.email
+ else False
+ )
+ resp._content = json.dumps(
+ {"user_has_admission_ticket": ticket_result}
+ ).encode("utf-8")
+ return resp
+
+ requests_mock.add_matcher(matcher)
return wrapper
diff --git a/backend/visa/admin.py b/backend/visa/admin.py
index 47b462f3f9..d6a67a96ae 100644
--- a/backend/visa/admin.py
+++ b/backend/visa/admin.py
@@ -83,6 +83,10 @@ def save_form(self, request, form, change):
def process_now(self, obj):
pretix_api = PretixAPI.for_conference(obj.conference)
+
+ if not obj.email:
+ return "No email address provided! Can't generate invitation letter."
+
if not pretix_api.has_attendee_ticket(obj.email):
return "No attendee ticket found! Can't generate invitation letter."
diff --git a/backend/visa/managers.py b/backend/visa/managers.py
index 8a3ab3eb9e..017e1781b0 100644
--- a/backend/visa/managers.py
+++ b/backend/visa/managers.py
@@ -3,4 +3,5 @@
class InvitationLetterRequestQuerySet(ConferenceQuerySetMixin, models.QuerySet):
- ...
+ def of_user(self, user):
+ return self.filter(requester=user)
diff --git a/backend/visa/tests/test_admin.py b/backend/visa/tests/test_admin.py
index d1ce6cfbf0..19520643ea 100644
--- a/backend/visa/tests/test_admin.py
+++ b/backend/visa/tests/test_admin.py
@@ -1,6 +1,10 @@
from django.urls import reverse
from django.contrib.admin.sites import AdminSite
-from visa.models import InvitationLetterConferenceConfig, InvitationLetterRequest
+from visa.models import (
+ InvitationLetterConferenceConfig,
+ InvitationLetterRequest,
+ InvitationLetterRequestOnBehalfOf,
+)
from visa.admin import InvitationLetterDocumentInline, InvitationLetterRequestAdmin
import pytest
@@ -97,6 +101,21 @@ def test_generate_button_doesnt_appear_with_no_ticket(mock_has_ticket):
assert "No attendee ticket found!" in admin.process_now(invitation_letter_request)
+def test_generate_button_doesnt_appear_with_no_email(mock_has_ticket):
+ admin = InvitationLetterRequestAdmin(
+ model=InvitationLetterRequest, admin_site=AdminSite()
+ )
+
+ invitation_letter_request = InvitationLetterRequestFactory(
+ on_behalf_of=InvitationLetterRequestOnBehalfOf.OTHER, email_address=""
+ )
+
+ assert (
+ "No email address provided! Can't generate invitation letter."
+ in admin.process_now(invitation_letter_request)
+ )
+
+
def test_invitation_letter_request_admin_post_processing_redirects_to_changelist(
rf, admin_user
):
diff --git a/frontend/src/components/alert/index.tsx b/frontend/src/components/alert/index.tsx
index c1d91d8e38..0a71a0201d 100644
--- a/frontend/src/components/alert/index.tsx
+++ b/frontend/src/components/alert/index.tsx
@@ -1,3 +1,4 @@
+import { Text } from "@python-italia/pycon-styleguide";
import clsx from "clsx";
import type React from "react";
@@ -23,7 +24,7 @@ export const Alert = ({
)}
{...props}
>
- {children}
+ {children}
);
diff --git a/frontend/src/components/request-invitation-letter-page-handler/index.tsx b/frontend/src/components/request-invitation-letter-page-handler/index.tsx
new file mode 100644
index 0000000000..c6fa157a5b
--- /dev/null
+++ b/frontend/src/components/request-invitation-letter-page-handler/index.tsx
@@ -0,0 +1,114 @@
+import {
+ Heading,
+ Link,
+ Page,
+ Section,
+ Spacer,
+ Text,
+} from "@python-italia/pycon-styleguide";
+import { FormattedMessage } from "react-intl";
+import { MetaTags } from "~/components/meta-tags";
+import { useCurrentLanguage } from "~/locale/context";
+import {
+ DeadlineStatus,
+ type InvitationLetterRequest,
+ useRequestInvitationLetterPageQuery,
+} from "~/types";
+import { createHref } from "../link";
+import { InvitationLetterForm } from "./invitation-letter-form";
+import { InvitationLetterRequestStatusCallout } from "./invitation-letter-request-status-callout";
+
+export const RequestInvitationLetterPageHandler = () => {
+ const language = useCurrentLanguage();
+ const {
+ data: {
+ conference: { invitationLetterRequestDeadline },
+ me: { hasAdmissionTicket, invitationLetterRequest },
+ },
+ } = useRequestInvitationLetterPageQuery({
+ variables: {
+ conference: process.env.conferenceCode,
+ },
+ });
+
+ const deadlineStatus = invitationLetterRequestDeadline?.status;
+
+ return (
+
+
+ {(text) => }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(!deadlineStatus || deadlineStatus === DeadlineStatus.InThePast) && (
+
+ )}
+ {deadlineStatus === DeadlineStatus.InTheFuture && (
+
+ )}
+ {deadlineStatus === DeadlineStatus.HappeningNow && (
+
+ )}
+
+
+ );
+};
+
+const FormClosed = ({
+ invitationLetterRequest,
+}: {
+ invitationLetterRequest: InvitationLetterRequest;
+}) => (
+ <>
+
+
+
+
+ >
+);
+
+const FormOpeningSoon = ({ date }) => {
+ const language = useCurrentLanguage();
+ const formatter = new Intl.DateTimeFormat(language, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "numeric",
+ minute: "numeric",
+ });
+
+ return (
+
+
+
+ );
+};
diff --git a/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-form.tsx b/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-form.tsx
new file mode 100644
index 0000000000..b6e8ced0c6
--- /dev/null
+++ b/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-form.tsx
@@ -0,0 +1,399 @@
+import {
+ Button,
+ CardPart,
+ Checkbox,
+ Grid,
+ GridColumn,
+ Heading,
+ HorizontalStack,
+ Input,
+ InputWrapper,
+ Link,
+ MultiplePartsCard,
+ Spacer,
+ Text,
+ Textarea,
+ VerticalStack,
+} from "@python-italia/pycon-styleguide";
+import { FormattedMessage } from "react-intl";
+import { useFormState } from "react-use-form-state";
+import { useTranslatedMessage } from "~/helpers/use-translated-message";
+import { useCurrentLanguage } from "~/locale/context";
+import {
+ InvitationLetterOnBehalfOf,
+ type InvitationLetterRequest,
+ InvitationLetterRequestStatus,
+ useRequestInvitationLetterMutation,
+} from "~/types";
+import { Alert } from "../alert";
+import { createHref } from "../link";
+import { InvitationLetterRequestStatusCallout } from "./invitation-letter-request-status-callout";
+
+const ON_BEHALF_OF_OPTIONS = [
+ {
+ label: ,
+ value: InvitationLetterOnBehalfOf.Self,
+ },
+ {
+ label: (
+
+ ),
+ value: InvitationLetterOnBehalfOf.Other,
+ },
+];
+
+type InvitationLetterFormFields = {
+ onBehalfOf: InvitationLetterOnBehalfOf;
+ email: string;
+ fullName: string;
+ nationality: string;
+ address: string;
+ passportNumber: string;
+ embassyName: string;
+ acceptedPrivacyPolicy: boolean;
+ dateOfBirth: string;
+};
+
+export const InvitationLetterForm = ({
+ hasAdmissionTicket,
+ invitationLetterRequest,
+}: {
+ hasAdmissionTicket: boolean;
+ invitationLetterRequest?: InvitationLetterRequest;
+}) => {
+ const language = useCurrentLanguage();
+ const [formState, { checkbox, radio, text, textarea, email, date }] =
+ useFormState({
+ onBehalfOf: InvitationLetterOnBehalfOf.Self,
+ acceptedPrivacyPolicy: false,
+ });
+
+ const alreadySentRequest = invitationLetterRequest !== null;
+ const onBehalfOfSelf =
+ formState.values.onBehalfOf === InvitationLetterOnBehalfOf.Self;
+ const onBehalfOfOther =
+ formState.values.onBehalfOf === InvitationLetterOnBehalfOf.Other;
+ const inputPlaceholder = useTranslatedMessage("input.placeholder");
+
+ const [
+ requestInvitationLetter,
+ {
+ loading: isRequestingInvitationLetter,
+ data: requestInvitationLetterData,
+ error: requestInvitationLetterError,
+ },
+ ] = useRequestInvitationLetterMutation({
+ updateQueries: {
+ RequestInvitationLetterPage: (prev, { mutationResult }) => {
+ if (
+ onBehalfOfOther ||
+ mutationResult.data.requestInvitationLetter.__typename !==
+ "InvitationLetterRequest"
+ ) {
+ return prev;
+ }
+
+ return {
+ ...prev,
+ me: {
+ ...prev.me,
+ invitationLetterRequest:
+ mutationResult.data.requestInvitationLetter,
+ },
+ };
+ },
+ },
+ });
+ const canSeeForm =
+ onBehalfOfOther ||
+ (onBehalfOfSelf && hasAdmissionTicket && !alreadySentRequest);
+ const canSubmit =
+ formState.values.acceptedPrivacyPolicy &&
+ canSeeForm &&
+ !isRequestingInvitationLetter;
+
+ const onSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ if (canSubmit) {
+ await requestInvitationLetter({
+ variables: {
+ input: {
+ conference: process.env.conferenceCode,
+ email: formState.values.email || "",
+ fullName: formState.values.fullName,
+ address: formState.values.address,
+ dateOfBirth: formState.values.dateOfBirth,
+ embassyName: formState.values.embassyName,
+ nationality: formState.values.nationality,
+ onBehalfOf: formState.values.onBehalfOf,
+ passportNumber: formState.values.passportNumber,
+ },
+ },
+ });
+ }
+ };
+
+ const getErrors = (field: string) =>
+ (requestInvitationLetterData?.requestInvitationLetter.__typename ===
+ "RequestInvitationLetterErrors" &&
+ requestInvitationLetterData.requestInvitationLetter.errors[field]) ||
+ [];
+
+ const formHasErrors =
+ requestInvitationLetterData?.requestInvitationLetter.__typename ===
+ "RequestInvitationLetterErrors";
+ const requestSubmitted =
+ requestInvitationLetterData?.requestInvitationLetter.__typename ===
+ "InvitationLetterRequest";
+
+ return (
+
+ );
+};
diff --git a/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-request-status-callout.tsx b/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-request-status-callout.tsx
new file mode 100644
index 0000000000..90f9f5fad4
--- /dev/null
+++ b/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-request-status-callout.tsx
@@ -0,0 +1,34 @@
+import { Spacer, Text } from "@python-italia/pycon-styleguide";
+import { FormattedMessage } from "react-intl";
+
+import {
+ type InvitationLetterRequest,
+ InvitationLetterRequestStatus,
+} from "~/types";
+
+export const InvitationLetterRequestStatusCallout = ({
+ invitationLetterRequest,
+}: {
+ invitationLetterRequest: InvitationLetterRequest;
+}) => (
+ <>
+
+ {invitationLetterRequest.status ===
+ InvitationLetterRequestStatus.Pending && (
+
+
+
+ )}
+ {invitationLetterRequest.status === InvitationLetterRequestStatus.Sent && (
+
+
+
+ )}
+ {invitationLetterRequest.status ===
+ InvitationLetterRequestStatus.Rejected && (
+
+
+
+ )}
+ >
+);
diff --git a/frontend/src/components/request-invitation-letter-page-handler/request-invitation-letter-page.graphql b/frontend/src/components/request-invitation-letter-page-handler/request-invitation-letter-page.graphql
new file mode 100644
index 0000000000..e4742b9361
--- /dev/null
+++ b/frontend/src/components/request-invitation-letter-page-handler/request-invitation-letter-page.graphql
@@ -0,0 +1,18 @@
+query RequestInvitationLetterPage($conference: String!) {
+ conference(code: $conference) {
+ id
+ invitationLetterRequestDeadline: deadline(type: "invitation_letter_request") {
+ id
+ status
+ start
+ }
+ }
+ me {
+ id
+ hasAdmissionTicket(conference: $conference)
+ invitationLetterRequest(conference: $conference) {
+ id
+ status
+ }
+ }
+}
diff --git a/frontend/src/components/request-invitation-letter-page-handler/request-invitation-letter.graphql b/frontend/src/components/request-invitation-letter-page-handler/request-invitation-letter.graphql
new file mode 100644
index 0000000000..cef7e929ef
--- /dev/null
+++ b/frontend/src/components/request-invitation-letter-page-handler/request-invitation-letter.graphql
@@ -0,0 +1,24 @@
+mutation RequestInvitationLetter($input: RequestInvitationLetterInput!) {
+ requestInvitationLetter(input: $input) {
+ __typename
+
+ ... on InvitationLetterRequest {
+ id
+ status
+ }
+
+ ... on RequestInvitationLetterErrors {
+ errors {
+ address
+ conference
+ dateOfBirth
+ email
+ embassyName
+ fullName
+ nationality
+ onBehalfOf
+ passportNumber
+ }
+ }
+ }
+}
diff --git a/frontend/src/locale/index.ts b/frontend/src/locale/index.ts
index 0bbd8910bb..7fb885e054 100644
--- a/frontend/src/locale/index.ts
+++ b/frontend/src/locale/index.ts
@@ -4,6 +4,8 @@ export const messages = {
titleTemplate: "%s | PyCon Italia",
description:
"Join PyCon Italia May 28th to May 31st 2025 in Bologna, Italy!",
+ "global.submit": "Submit",
+ "global.pleaseWait": "Please wait",
"tickets.description":
"After you have purchased your tickets, you will be able to access the {page} with our discounted codes.",
@@ -1075,11 +1077,61 @@ The sooner you buy your ticket, the more you save!`,
"scheduleView.filter.starred": "Starred",
"scheduleView.filter.notStarred": "Not Starred",
+
+ "requestInvitationLetter.pageTitle": "Request an invitation letter",
+ "requestInvitationLetter.heading": "Request an invitation letter",
+ "invitationLetterForm.noAdmissionTicket":
+ "You need to buy a ticket to request an invitation letter.",
+ "invitationLetterForm.title": "Invitation letter",
+ "invitationLetterForm.onBehalfOf.title": "On behalf of",
+ "invitationLetterForm.onBehalfOf.description":
+ "Please only request an invitation on someone's behalf if they are unable to create their own account. Additional verification will be required.",
+ "invitationLetterForm.onBehalfOf.value.self": "Myself",
+ "invitationLetterForm.onBehalfOf.value.other": "Someone else",
+ "invitationLetterForm.email.title": "Email",
+ "invitationLetterForm.email.description":
+ "The email address of the person who needs the invitation letter.",
+ "invitationLetterForm.fullName.title": "Full name",
+ "invitationLetterForm.fullName.description":
+ "The full name of the person who needs the invitation letter as it appears in official documents.",
+ "invitationLetterForm.dateOfBirth.title": "Date of birth",
+ "invitationLetterForm.dateOfBirth.description":
+ "The date of birth of the person who needs the invitation letter.",
+ "invitationLetterForm.nationality.title": "Nationality",
+ "invitationLetterForm.nationality.description":
+ "Nationality of the person who needs the invitation letter.",
+ "invitationLetterForm.address.title": "Address",
+ "invitationLetterForm.address.description":
+ "The residential address of the person who needs the invitation letter.",
+ "invitationLetterForm.passportNumber.title": "Passport number",
+ "invitationLetterForm.passportNumber.description":
+ "The passport number of the person who needs the invitation letter.",
+ "invitationLetterForm.embassyName.title": "Embassy name",
+ "invitationLetterForm.embassyName.description":
+ "The name of the embassy that will receive the invitation letter.",
+ "invitationLetterForm.requestAlreadySent":
+ "You have already requested an invitation letter!",
+ "invitationLetterForm.requestStatus.pending":
+ "We are processing your request. We will contact you if we need more information. If you think you entered incorrect information, contact us (without sharing sensitive details), and we’ll reopen the form.",
+ "invitationLetterForm.requestStatus.sent":
+ "The invitation letter has been sent to your email address. If you need to make any changes, please contact us (without sharing sensitive details).",
+ "invitationLetterForm.requestStatus.rejected":
+ "We are sorry, but we cannot issue an invitation letter. Please contact us for more info.",
+ "invitationLetterForm.errors.generic":
+ "Check the form is filled correctly and try again.",
+ "invitationLetterForm.requestSubmitted": "Request submitted!",
+ "requestInvitationLetter.formOpeningSoon":
+ "The form will open on {date}. Please watch our socials for updates and any changes.",
+ "requestInvitationLetter.formClosed":
+ "The form is closed. If you have any questions, please contact us.",
},
it: {
titleTemplate: "%s | PyCon Italia",
description:
"Partecipa a PyCon Italia! Dal 28 al 31 Maggio 2025 a Bologna!",
+ "global.submit": "Invia",
+ "global.pleaseWait": "Attendi",
+
"global.accordion.close": "Chiudi",
"global.accordion.readMore": "Apri",
"global.here": "qui",
@@ -2176,6 +2228,52 @@ Clicca sulla casella per cambiare. Se lasciato vuoto, presumeremo che tu sia dis
"tickets.description":
"Dopo aver acquistato i tuoi biglietti, potrai accedere alla {page} con i nostri codici sconto.",
"tickets.description.page": "pagina hotels",
+
+ "requestInvitationLetter.pageTitle": "Richiedi una lettera d'invito",
+ "requestInvitationLetter.heading": "Richiedi una lettera d'invito",
+ "invitationLetterForm.noAdmissionTicket":
+ "È necessario acquistare un biglietto per richiedere una lettera d'invito.",
+ "invitationLetterForm.title": "Lettera d'invito",
+ "invitationLetterForm.onBehalfOf.title": "Per conto di",
+ "invitationLetterForm.onBehalfOf.description":
+ "Si prega di richiedere un invito per conto di qualcun altro solo se non sono in grado di creare un proprio account. Sarà necessaria una verifica aggiuntiva.",
+ "invitationLetterForm.onBehalfOf.value.self": "Per me",
+ "invitationLetterForm.onBehalfOf.value.other": "Qualcun altro",
+ "invitationLetterForm.email.title": "Email",
+ "invitationLetterForm.email.description":
+ "L'indirizzo email della persona che necessita della lettera d'invito.",
+ "invitationLetterForm.fullName.title": "Nome completo",
+ "invitationLetterForm.fullName.description":
+ "Il nome completo come appare nei documenti ufficiali.",
+ "invitationLetterForm.nationality.title": "Nazionalità",
+ "invitationLetterForm.nationality.description":
+ "Nazionalità come specificata nel passaporto.",
+ "invitationLetterForm.dateOfBirth.title": "Data di nascita",
+ "invitationLetterForm.dateOfBirth.description":
+ "La data di nascita della persona che necessita della lettera d'invito.",
+ "invitationLetterForm.address.title": "Indirizzo",
+ "invitationLetterForm.address.description": "L'indirizzo di residenza.",
+ "invitationLetterForm.passportNumber.title": "Numero di passaporto",
+ "invitationLetterForm.passportNumber.description":
+ "Il numero di passaporto della persona che necessita della lettera d'invito.",
+ "invitationLetterForm.embassyName.title": "Nome dell'ambasciata",
+ "invitationLetterForm.embassyName.description":
+ "Il nome dell'ambasciata che riceverà la lettera d'invito.",
+ "invitationLetterForm.requestAlreadySent":
+ "Hai già richiesto una lettera di invito!",
+ "invitationLetterForm.requestStatus.pending":
+ "Stiamo elaborando la tua richiesta. Ti contatteremo se avremo bisogno di ulteriori informazioni. Se pensi di aver inserito informazioni errate, contattaci (senza condividere dettagli sensibili) e riapriremo il modulo.",
+ "invitationLetterForm.requestStatus.sent":
+ "La lettera di invito è stata inviata al tuo indirizzo email. Se hai bisogno di apportare modifiche, contattaci (senza condividere dettagli sensibili).",
+ "invitationLetterForm.requestStatus.rejected":
+ "Siamo spiacenti, ma non possiamo emettere una lettera di invito. Contattaci per maggiori informazioni.",
+ "invitationLetterForm.errors.generic":
+ "Verifica che il form sia compilato correttamente e riprova.",
+ "invitationLetterForm.requestSubmitted": "Richiesta inviata!",
+ "requestInvitationLetter.formOpeningSoon":
+ "Il form aprirà il {date}. Segui i nostri socials per aggiornamenti e cambiamenti.",
+ "requestInvitationLetter.formClosed":
+ "Il form è chiuso. Se hai domande, contattaci.",
},
};
diff --git a/frontend/src/pages/grants/reply/index.tsx b/frontend/src/pages/grants/reply/index.tsx
index 504182ee05..ab9c94bd85 100644
--- a/frontend/src/pages/grants/reply/index.tsx
+++ b/frontend/src/pages/grants/reply/index.tsx
@@ -276,9 +276,7 @@ const GrantReply = () => {
{(replyError ||
replyData?.sendGrantReply.__typename === "SendGrantReplyError") && (
-
- {replyError.message || replyData?.sendGrantReply.__typename}
-
+ {replyError.message || replyData?.sendGrantReply.__typename}
)}
diff --git a/frontend/src/pages/request-invitation-letter/index.tsx b/frontend/src/pages/request-invitation-letter/index.tsx
new file mode 100644
index 0000000000..5d197f98e2
--- /dev/null
+++ b/frontend/src/pages/request-invitation-letter/index.tsx
@@ -0,0 +1,46 @@
+import type { GetServerSideProps } from "next";
+import { addApolloState, getApolloClient } from "~/apollo/client";
+import { prefetchSharedQueries } from "~/helpers/prefetch";
+import { queryRequestInvitationLetterPage } from "~/types";
+
+export const getServerSideProps: GetServerSideProps = async ({
+ req,
+ locale,
+}) => {
+ const identityToken = req.cookies.pythonitalia_sessionid;
+ if (!identityToken) {
+ return {
+ redirect: {
+ destination: "/login",
+ permanent: false,
+ },
+ };
+ }
+
+ const client = getApolloClient(null, req.cookies);
+ try {
+ await Promise.all([
+ prefetchSharedQueries(client, locale),
+ queryRequestInvitationLetterPage(client, {
+ conference: process.env.conferenceCode,
+ }),
+ ]);
+ } catch (e) {
+ return {
+ redirect: {
+ destination: "/login",
+ permanent: false,
+ },
+ };
+ }
+
+ return addApolloState(
+ client,
+ {
+ props: {},
+ },
+ null,
+ );
+};
+
+export { RequestInvitationLetterPageHandler as default } from "~/components/request-invitation-letter-page-handler";