From ba1882e61c6a1d12a577b79d3177b855beb2feb7 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Sat, 28 Dec 2024 23:55:05 +0100 Subject: [PATCH 1/9] Implement form to request an invitation letter --- backend/api/schema.py | 4 +- backend/api/types.py | 5 + backend/api/users/types.py | 35 ++ backend/api/visa/mutation.py | 13 + backend/api/visa/mutations/__init__.py | 0 .../mutations/request_invitation_letter.py | 148 +++++++ .../update_invitation_letter_document.py} | 7 - backend/api/visa/queries/__init__.py | 0 .../invitation_letter_document.py} | 7 - backend/api/visa/query.py | 8 + backend/api/visa/types.py | 61 +++ backend/visa/admin.py | 4 + backend/visa/managers.py | 3 +- frontend/src/components/alert/index.tsx | 3 +- .../index.tsx | 48 ++ .../invitation-letter-form.graphql | 10 + .../invitation-letter-form.tsx | 418 ++++++++++++++++++ .../request-invitation-letter.graphql | 22 + frontend/src/locale/index.ts | 90 ++++ frontend/src/pages/grants/reply/index.tsx | 4 +- .../pages/request-invitation-letter/index.tsx | 46 ++ 21 files changed, 915 insertions(+), 21 deletions(-) create mode 100644 backend/api/visa/mutation.py create mode 100644 backend/api/visa/mutations/__init__.py create mode 100644 backend/api/visa/mutations/request_invitation_letter.py rename backend/api/visa/{mutations.py => mutations/update_invitation_letter_document.py} (93%) create mode 100644 backend/api/visa/queries/__init__.py rename backend/api/visa/{queries.py => queries/invitation_letter_document.py} (82%) create mode 100644 backend/api/visa/query.py create mode 100644 frontend/src/components/request-invitation-letter-page-handler/index.tsx create mode 100644 frontend/src/components/request-invitation-letter-page-handler/invitation-letter-form.graphql create mode 100644 frontend/src/components/request-invitation-letter-page-handler/invitation-letter-form.tsx create mode 100644 frontend/src/components/request-invitation-letter-page-handler/request-invitation-letter.graphql create mode 100644 frontend/src/pages/request-invitation-letter/index.tsx 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..34254f38bc 100644 --- a/backend/api/types.py +++ b/backend/api/types.py @@ -146,3 +146,8 @@ def paginate_list( @strawberry.type class NotFound: message: str = "Not found" + + +@strawberry.type +class NoAdmissionTicket: + message: str = "User does not have admission ticket" 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..26c74ad9f7 --- /dev/null +++ b/backend/api/visa/mutations/request_invitation_letter.py @@ -0,0 +1,148 @@ +from django.db import transaction +from datetime import date +from typing import Annotated +from api.types import BaseErrorType, NoAdmissionTicket +from api.utils import validate_email +from api.visa.types import InvitationLetterOnBehalfOf, InvitationLetterRequest +from api.extensions import RateLimit +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 + + +@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") + + 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, + 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 + + 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, _ = 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, + ) + + 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/types.py b/backend/api/visa/types.py index 50281798d4..a346db30bc 100644 --- a/backend/api/visa/types.py +++ b/backend/api/visa/types.py @@ -1,6 +1,67 @@ +from enum import Enum +from api.conferences.types import Conference +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 in [ + InvitationLetterRequestStatusDB.PENDING, + InvitationLetterRequestStatusDB.PROCESSING, + InvitationLetterRequestStatusDB.PROCESSED, + InvitationLetterRequestStatusDB.FAILED_TO_GENERATE, + ]: + return InvitationLetterRequestStatus.PENDING + + if status == InvitationLetterRequestStatusDB.SENT: + return InvitationLetterRequestStatus.SENT + + if status == InvitationLetterRequestStatusDB.REJECTED: + return InvitationLetterRequestStatus.REJECTED + + +@strawberry.type +class InvitationLetterRequest: + id: strawberry.ID + conference: Conference + status: InvitationLetterRequestStatus + full_name: str + on_behalf_of: InvitationLetterOnBehalfOf + email: str + nationality: str + address: str + passport_number: str + embassy_name: str + + @classmethod + def from_model(cls, instance): + return cls( + id=instance.id, + conference=instance.conference, + status=_convert_request_status_to_public(instance.status), + full_name=instance.full_name, + on_behalf_of=instance.on_behalf_of, + email=instance.email, + nationality=instance.nationality, + address=instance.address, + passport_number=instance.passport_number, + embassy_name=instance.embassy_name, + ) + + @strawberry.type class InvitationLetterDocumentPage: id: strawberry.ID 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/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..a1870b7175 --- /dev/null +++ b/frontend/src/components/request-invitation-letter-page-handler/index.tsx @@ -0,0 +1,48 @@ +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 { createHref } from "../link"; +import { InvitationLetterForm } from "./invitation-letter-form"; + +export const RequestInvitationLetterPageHandler = () => { + const language = useCurrentLanguage(); + + return ( + + + {(text) => } + + +
+ + + + + + + + + + + + + + + +
+
+ ); +}; diff --git a/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-form.graphql b/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-form.graphql new file mode 100644 index 0000000000..ffea770cc1 --- /dev/null +++ b/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-form.graphql @@ -0,0 +1,10 @@ +query InvitationLetterForm($conference: String!) { + me { + id + hasAdmissionTicket(conference: $conference) + invitationLetterRequest(conference: $conference) { + id + status + } + } +} 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..b9cc74c3ad --- /dev/null +++ b/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-form.tsx @@ -0,0 +1,418 @@ +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, + InvitationLetterRequestStatus, + useInvitationLetterFormQuery, + useRequestInvitationLetterMutation, +} from "~/types"; +import { Alert } from "../alert"; +import { createHref } from "../link"; + +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 = () => { + const { + data: { + me: { hasAdmissionTicket, invitationLetterRequest }, + }, + } = useInvitationLetterFormQuery({ + variables: { + conference: process.env.conferenceCode, + }, + }); + + 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: { + InvitationLetterForm: (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 && ( +
+ + + + + {invitationLetterRequest.status === + InvitationLetterRequestStatus.Pending && ( + + + + )} + {invitationLetterRequest.status === + InvitationLetterRequestStatus.Sent && ( + + + + )} + {invitationLetterRequest.status === + InvitationLetterRequestStatus.Rejected && ( + + + + )} +
+ )} + + {onBehalfOfSelf && !hasAdmissionTicket && ( + + + + )} + + {onBehalfOfOther && ( + + } + description={ + + } + > + + + )} + + {canSeeForm && ( + <> + + } + description={ + + } + > + + + + + } + description={ + + } + > + + + + + } + description={ + + } + > + + + + + } + description={ + + } + > +