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 ( +
+ + + + + + + + + + } + description={ + + } + > + + {ON_BEHALF_OF_OPTIONS.map((type) => ( + + ))} + + + + {onBehalfOfSelf && alreadySentRequest && ( +
+ + + + +
+ )} + + {onBehalfOfSelf && !hasAdmissionTicket && ( + + + + )} + + {canSeeForm && ( + <> + {onBehalfOfOther && ( + + } + description={ + + } + > + + + )} + + + } + description={ + + } + > + + + + + } + description={ + + } + > + + + + + } + description={ + + } + > + + + + + } + description={ + + } + > +