diff --git a/backend/api/types.py b/backend/api/types.py index 34254f38bc..469ab453ee 100644 --- a/backend/api/types.py +++ b/backend/api/types.py @@ -151,3 +151,8 @@ class NotFound: @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/visa/mutations/request_invitation_letter.py b/backend/api/visa/mutations/request_invitation_letter.py index 3cfb755e0f..73c6f1b216 100644 --- a/backend/api/visa/mutations/request_invitation_letter.py +++ b/backend/api/visa/mutations/request_invitation_letter.py @@ -1,10 +1,11 @@ from django.db import transaction from datetime import date from typing import Annotated -from api.types import BaseErrorType, NoAdmissionTicket +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, @@ -104,7 +105,8 @@ def validate(self, conference: Conference) -> RequestInvitationLetterErrors | No InvitationLetterRequest | RequestInvitationLetterErrors | NoAdmissionTicket - | InvitationLetterAlreadyRequested, + | InvitationLetterAlreadyRequested + | FormNotAvailable, strawberry.union(name="RequestInvitationLetterResult"), ] @@ -121,6 +123,9 @@ def request_invitation_letter( 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: diff --git a/backend/api/visa/tests/mutations/test_request_invitation_letter.py b/backend/api/visa/tests/mutations/test_request_invitation_letter.py index 2c75afae58..592060ea5b 100644 --- a/backend/api/visa/tests/mutations/test_request_invitation_letter.py +++ b/backend/api/visa/tests/mutations/test_request_invitation_letter.py @@ -1,3 +1,4 @@ +from conferences.models.deadline import Deadline from privacy_policy.models import PrivacyPolicyAcceptanceRecord from datetime import date @@ -8,7 +9,12 @@ InvitationLetterRequestOnBehalfOf, InvitationLetterRequestStatus, ) -from conferences.tests.factories import ConferenceFactory +from conferences.tests.factories import ( + PastDeadlineFactory, + ConferenceFactory, + FutureDeadlineFactory, + ActiveDeadlineFactory, +) import pytest pytestmark = pytest.mark.django_db @@ -46,6 +52,9 @@ def _request_invitation_letter(client, **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) @@ -102,6 +111,9 @@ def test_can_request_invitation_letter_to_multiple_conferences( conference = ConferenceFactory() mock_has_ticket(conference) + ActiveDeadlineFactory( + conference=conference, type=Deadline.TYPES.invitation_letter_request + ) InvitationLetterRequestFactory( requester=user, @@ -111,6 +123,9 @@ def test_can_request_invitation_letter_to_multiple_conferences( other_conference = ConferenceFactory() mock_has_ticket(other_conference) + ActiveDeadlineFactory( + conference=other_conference, type=Deadline.TYPES.invitation_letter_request + ) response = _request_invitation_letter( graphql_client, @@ -165,6 +180,9 @@ def test_request_invitation_letter_email_is_ignored_for_self_requests( ): conference = ConferenceFactory() mock_has_ticket(conference) + ActiveDeadlineFactory( + conference=conference, type=Deadline.TYPES.invitation_letter_request + ) graphql_client.force_login(user) @@ -210,6 +228,9 @@ def test_request_invitation_letter_on_behalf_of_other( ): 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) @@ -261,6 +282,9 @@ def test_duplicate_requests_for_others_are_ignored( ): 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) @@ -319,6 +343,9 @@ def test_cannot_request_invitation_letter_if_already_done( ): conference = ConferenceFactory() mock_has_ticket(conference) + ActiveDeadlineFactory( + conference=conference, type=Deadline.TYPES.invitation_letter_request + ) InvitationLetterRequestFactory( requester=user, @@ -354,6 +381,9 @@ def test_cannot_request_invitation_letter_without_ticket( ): conference = ConferenceFactory() mock_has_ticket(conference, False) + ActiveDeadlineFactory( + conference=conference, type=Deadline.TYPES.invitation_letter_request + ) graphql_client.force_login(user) @@ -383,6 +413,9 @@ def test_cannot_request_invitation_letter_for_non_existing_conference( ): conference = ConferenceFactory() mock_has_ticket(conference) + ActiveDeadlineFactory( + conference=conference, type=Deadline.TYPES.invitation_letter_request + ) graphql_client.force_login(user) @@ -416,6 +449,9 @@ def test_email_is_required_when_requesting_on_behalf_of_other( ): 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) @@ -447,6 +483,9 @@ def test_email_is_required_when_requesting_on_behalf_of_other( 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) @@ -486,6 +525,9 @@ def test_required_fields_are_enforced(graphql_client, user, mock_has_ticket): 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) @@ -533,3 +575,44 @@ def test_max_lengths_are_enforced(graphql_client, user, mock_has_ticket): ], } 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/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/frontend/src/components/request-invitation-letter-page-handler/index.tsx b/frontend/src/components/request-invitation-letter-page-handler/index.tsx index a1870b7175..4967f2fdcc 100644 --- a/frontend/src/components/request-invitation-letter-page-handler/index.tsx +++ b/frontend/src/components/request-invitation-letter-page-handler/index.tsx @@ -9,11 +9,24 @@ import { import { FormattedMessage } from "react-intl"; import { MetaTags } from "~/components/meta-tags"; import { useCurrentLanguage } from "~/locale/context"; +import { DeadlineStatus, useRequestInvitationLetterPageQuery } from "~/types"; import { createHref } from "../link"; import { InvitationLetterForm } from "./invitation-letter-form"; export const RequestInvitationLetterPageHandler = () => { const language = useCurrentLanguage(); + const { + data: { + conference: { invitationLetterRequestDeadline }, + me: { hasAdmissionTicket, invitationLetterRequest }, + }, + } = useRequestInvitationLetterPageQuery({ + variables: { + conference: process.env.conferenceCode, + }, + }); + + const deadlineStatus = invitationLetterRequestDeadline?.status; return ( @@ -41,8 +54,47 @@ export const RequestInvitationLetterPageHandler = () => { - + {(!deadlineStatus || deadlineStatus === DeadlineStatus.InThePast) && ( + + )} + {deadlineStatus === DeadlineStatus.InTheFuture && ( + + )} + {deadlineStatus === DeadlineStatus.HappeningNow && ( + + )} ); }; + +const FormClosed = () => ( + + + +); + +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.graphql b/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-form.graphql deleted file mode 100644 index ffea770cc1..0000000000 --- a/frontend/src/components/request-invitation-letter-page-handler/invitation-letter-form.graphql +++ /dev/null @@ -1,10 +0,0 @@ -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 index d1afe78aba..f99541bd45 100644 --- 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 @@ -21,8 +21,8 @@ import { useTranslatedMessage } from "~/helpers/use-translated-message"; import { useCurrentLanguage } from "~/locale/context"; import { InvitationLetterOnBehalfOf, + type InvitationLetterRequest, InvitationLetterRequestStatus, - useInvitationLetterFormQuery, useRequestInvitationLetterMutation, } from "~/types"; import { Alert } from "../alert"; @@ -53,17 +53,13 @@ type InvitationLetterFormFields = { dateOfBirth: string; }; -export const InvitationLetterForm = () => { - const { - data: { - me: { hasAdmissionTicket, invitationLetterRequest }, - }, - } = useInvitationLetterFormQuery({ - variables: { - conference: process.env.conferenceCode, - }, - }); - +export const InvitationLetterForm = ({ + hasAdmissionTicket, + invitationLetterRequest, +}: { + hasAdmissionTicket: boolean; + invitationLetterRequest?: InvitationLetterRequest; +}) => { const language = useCurrentLanguage(); const [formState, { checkbox, radio, text, textarea, email, date }] = useFormState({ @@ -87,7 +83,7 @@ export const InvitationLetterForm = () => { }, ] = useRequestInvitationLetterMutation({ updateQueries: { - InvitationLetterForm: (prev, { mutationResult }) => { + RequestInvitationLetterPage: (prev, { mutationResult }) => { if ( onBehalfOfOther || mutationResult.data.requestInvitationLetter.__typename !== 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 index c0abe88217..cef7e929ef 100644 --- 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 @@ -1,10 +1,12 @@ mutation RequestInvitationLetter($input: RequestInvitationLetterInput!) { requestInvitationLetter(input: $input) { __typename + ... on InvitationLetterRequest { id status } + ... on RequestInvitationLetterErrors { errors { address diff --git a/frontend/src/locale/index.ts b/frontend/src/locale/index.ts index e6561cdcef..7fb885e054 100644 --- a/frontend/src/locale/index.ts +++ b/frontend/src/locale/index.ts @@ -1120,6 +1120,10 @@ The sooner you buy your ticket, the more you save!`, "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", @@ -2266,6 +2270,10 @@ Clicca sulla casella per cambiare. Se lasciato vuoto, presumeremo che tu sia dis "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/request-invitation-letter/index.tsx b/frontend/src/pages/request-invitation-letter/index.tsx index 0839d639ef..5d197f98e2 100644 --- a/frontend/src/pages/request-invitation-letter/index.tsx +++ b/frontend/src/pages/request-invitation-letter/index.tsx @@ -1,7 +1,7 @@ import type { GetServerSideProps } from "next"; import { addApolloState, getApolloClient } from "~/apollo/client"; import { prefetchSharedQueries } from "~/helpers/prefetch"; -import { queryInvitationLetterForm } from "~/types"; +import { queryRequestInvitationLetterPage } from "~/types"; export const getServerSideProps: GetServerSideProps = async ({ req, @@ -21,7 +21,7 @@ export const getServerSideProps: GetServerSideProps = async ({ try { await Promise.all([ prefetchSharedQueries(client, locale), - queryInvitationLetterForm(client, { + queryRequestInvitationLetterPage(client, { conference: process.env.conferenceCode, }), ]);