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,
}),
]);