diff --git a/backend/api/grants/types.py b/backend/api/grants/types.py index 87ba072fcf..aa0d2b9162 100644 --- a/backend/api/grants/types.py +++ b/backend/api/grants/types.py @@ -4,8 +4,6 @@ from typing import Optional import strawberry -from api.participants.types import Participant -from participants.models import Participant as ParticipantModel from grants.models import Grant as GrantModel @@ -36,15 +34,8 @@ class Grant: travelling_from: Optional[str] applicant_reply_deadline: Optional[datetime] - participant: Participant - @classmethod def from_model(cls, grant: GrantModel) -> Grant: - participant = ParticipantModel.objects.filter( - user_id=grant.user_id, - conference=grant.conference, - ).first() - return cls( id=grant.id, status=Status(grant.status), @@ -64,5 +55,4 @@ def from_model(cls, grant: GrantModel) -> Grant: notes=grant.notes, travelling_from=grant.travelling_from, applicant_reply_deadline=grant.applicant_reply_deadline, - participant=Participant.from_model(participant), ) diff --git a/backend/api/participants/types.py b/backend/api/participants/types.py index df704c2620..c1228e6797 100644 --- a/backend/api/participants/types.py +++ b/backend/api/participants/types.py @@ -1,5 +1,6 @@ from typing import Optional +from strawberry.scalars import JSON import strawberry from strawberry import ID @@ -22,6 +23,7 @@ class Participant: mastodon_handle: str speaker_id: strawberry.Private[int] fullname: str + speaker_availabilities: JSON _speaker_level: strawberry.Private[str] _previous_talk_video: strawberry.Private[str] @@ -59,4 +61,5 @@ def from_model(cls, instance): linkedin_url=instance.linkedin_url, facebook_url=instance.facebook_url, mastodon_handle=instance.mastodon_handle, + speaker_availabilities=instance.speaker_availabilities or {}, ) diff --git a/backend/api/submissions/mutations.py b/backend/api/submissions/mutations.py index 91a7f368be..0e610b59ac 100644 --- a/backend/api/submissions/mutations.py +++ b/backend/api/submissions/mutations.py @@ -1,3 +1,5 @@ +from strawberry.scalars import JSON + from django.db import transaction import math import re @@ -208,6 +210,7 @@ class SendSubmissionInput(BaseSubmissionInput): speaker_linkedin_url: str speaker_facebook_url: str speaker_mastodon_handle: str + speaker_availabilities: JSON topic: Optional[ID] = strawberry.field(default=None) tags: list[ID] = strawberry.field(default_factory=list) @@ -236,6 +239,7 @@ class UpdateSubmissionInput(BaseSubmissionInput): speaker_linkedin_url: str speaker_facebook_url: str speaker_mastodon_handle: str + speaker_availabilities: JSON topic: Optional[ID] = strawberry.field(default=None) tags: list[ID] = strawberry.field(default_factory=list) @@ -307,6 +311,7 @@ def update_submission( "linkedin_url": input.speaker_linkedin_url, "facebook_url": input.speaker_facebook_url, "mastodon_handle": input.speaker_mastodon_handle, + "speaker_availabilities": input.speaker_availabilities, }, ) @@ -368,6 +373,7 @@ def send_submission( "linkedin_url": input.speaker_linkedin_url, "facebook_url": input.speaker_facebook_url, "mastodon_handle": input.speaker_mastodon_handle, + "speaker_availabilities": input.speaker_availabilities, }, ) diff --git a/backend/api/submissions/tests/test_edit_submission.py b/backend/api/submissions/tests/test_edit_submission.py index f8ebac9882..6c484a3339 100644 --- a/backend/api/submissions/tests/test_edit_submission.py +++ b/backend/api/submissions/tests/test_edit_submission.py @@ -33,12 +33,14 @@ def _update_submission( new_speaker_linkedin_url="", new_speaker_facebook_url="", new_speaker_mastodon_handle="", + new_speaker_availabilities=None, ): new_title = new_title or {"en": "new title to use"} new_elevator_pitch = new_elevator_pitch or {"en": "This is an elevator pitch"} new_abstract = new_abstract or {"en": "abstract here"} short_social_summary = new_short_social_summary or "" new_speaker_photo = new_speaker_photo or FileFactory().id + new_speaker_availabilities = new_speaker_availabilities or {} return graphql_client.query( """ @@ -141,6 +143,7 @@ def _update_submission( "speakerLinkedinUrl": new_speaker_linkedin_url, "speakerFacebookUrl": new_speaker_facebook_url, "speakerMastodonHandle": new_speaker_mastodon_handle, + "speakerAvailabilities": new_speaker_availabilities, } }, ) @@ -201,6 +204,67 @@ def test_update_submission(graphql_client, user): assert participant.linkedin_url == "http://linkedin.com/company/pythonpizza" +def test_update_submission_speaker_availabilities(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + + graphql_client.force_login(user) + + new_topic = conference.topics.filter(name="diy").first() + new_audience = conference.audience_levels.filter(name="senior").first() + new_tag = SubmissionTagFactory(name="yello") + new_duration = conference.durations.filter(name="20m").first() + new_type = conference.submission_types.filter(name="workshop").first() + + response = _update_submission( + graphql_client, + submission=submission, + new_topic=new_topic, + new_audience=new_audience, + new_tag=new_tag, + new_duration=new_duration, + new_type=new_type, + new_speaker_level=Submission.SPEAKER_LEVELS.experienced, + new_speaker_availabilities={ + "2023-12-10@am": "unavailable", + "2023-12-11@pm": "unavailable", + "2023-12-12@am": "preferred", + "2023-12-13@am": None, + }, + ) + + submission.refresh_from_db() + + assert response["data"]["updateSubmission"]["__typename"] == "Submission" + + participant = Participant.objects.first() + assert participant.speaker_availabilities == { + "2023-12-10@am": "unavailable", + "2023-12-11@pm": "unavailable", + "2023-12-12@am": "preferred", + "2023-12-13@am": None, + } + + def test_update_submission_with_invalid_facebook_social_url(graphql_client, user): conference = ConferenceFactory( topics=("life", "diy"), diff --git a/backend/api/submissions/tests/test_send_submission.py b/backend/api/submissions/tests/test_send_submission.py index ba1980481e..0a319aa8b9 100644 --- a/backend/api/submissions/tests/test_send_submission.py +++ b/backend/api/submissions/tests/test_send_submission.py @@ -67,6 +67,7 @@ def _submit_proposal(client, conference, submission, **kwargs): "speakerFacebookUrl": "https://facebook.com/fake-link", "speakerMastodonHandle": "fake@mastodon.social", "tags": [tag.id], + "speakerAvailabilities": {}, } override_conference = kwargs.pop("override_conference", None) @@ -163,6 +164,11 @@ def test_submit_talk(graphql_client, user, django_capture_on_commit_callbacks, m shortSocialSummary="summary", speakerBio="my bio", speakerPhoto=speaker_photo, + speakerAvailabilities={ + "2023-10-10@am": "preferred", + "2023-10-11@pm": "unavailable", + "2023-10-12@am": "available", + }, ) assert resp["data"]["sendSubmission"]["__typename"] == "Submission" @@ -190,6 +196,11 @@ def test_submit_talk(graphql_client, user, django_capture_on_commit_callbacks, m participant = Participant.objects.get(conference=conference, user_id=user.id) assert participant.bio == "my bio" assert participant.photo_file_id == speaker_photo + assert participant.speaker_availabilities == { + "2023-10-10@am": "preferred", + "2023-10-11@pm": "unavailable", + "2023-10-12@am": "available", + } assert PrivacyPolicyAcceptanceRecord.objects.filter( user=user, conference=conference, privacy_policy="cfp" diff --git a/backend/participants/admin.py b/backend/participants/admin.py index 36448d4a65..beaafaea67 100644 --- a/backend/participants/admin.py +++ b/backend/participants/admin.py @@ -8,20 +8,7 @@ class ParticipantForm(forms.ModelForm): class Meta: model = Participant - fields = [ - "conference", - "user", - "photo", - "bio", - "website", - "twitter_handle", - "instagram_handle", - "linkedin_url", - "facebook_url", - "mastodon_handle", - "speaker_level", - "previous_talk_video", - ] + fields = "__all__" @admin.register(Participant) @@ -54,6 +41,7 @@ class ParticipantAdmin(admin.ModelAdmin): "mastodon_handle", "speaker_level", "previous_talk_video", + "speaker_availabilities", ), }, ), diff --git a/backend/participants/migrations/0012_participant_speaker_availabilities.py b/backend/participants/migrations/0012_participant_speaker_availabilities.py new file mode 100644 index 0000000000..211fb791ed --- /dev/null +++ b/backend/participants/migrations/0012_participant_speaker_availabilities.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2024-11-28 22:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('participants', '0011_alter_participant_photo'), + ] + + operations = [ + migrations.AddField( + model_name='participant', + name='speaker_availabilities', + field=models.JSONField(blank=True, null=True, verbose_name='speaker availabilities'), + ), + ] diff --git a/backend/participants/models.py b/backend/participants/models.py index 31a68a03f6..29e6cc0cb3 100644 --- a/backend/participants/models.py +++ b/backend/participants/models.py @@ -54,6 +54,9 @@ class SpeakerLevels(models.TextChoices): previous_talk_video = models.URLField( _("previous talk video"), blank=True, max_length=2049 ) + speaker_availabilities = models.JSONField( + _("speaker availabilities"), null=True, blank=True + ) objects = ParticipantQuerySet().as_manager() diff --git a/frontend/src/components/cfp-form/about-you-section.tsx b/frontend/src/components/cfp-form/about-you-section.tsx new file mode 100644 index 0000000000..6a7405b271 --- /dev/null +++ b/frontend/src/components/cfp-form/about-you-section.tsx @@ -0,0 +1,93 @@ +import { + CardPart, + Grid, + Heading, + Input, + InputWrapper, + MultiplePartsCard, + Select, + Text, +} from "@python-italia/pycon-styleguide"; +import { FormattedMessage } from "react-intl"; +import { useTranslatedMessage } from "~/helpers/use-translated-message"; + +const SPEAKER_LEVEL_OPTIONS = [ + { + value: "", + disabled: true, + messageId: "cfp.selectSpeakerLevel", + }, + { + disabled: false, + value: "new", + messageId: "cfp.speakerLevel.new", + }, + { + disabled: false, + value: "intermediate", + messageId: "cfp.speakerLevel.intermediate", + }, + { + disabled: false, + value: "experienced", + messageId: "cfp.speakerLevel.experienced", + }, +]; + +export const AboutYouSection = ({ formOptions, getErrors }) => { + const inputPlaceholder = useTranslatedMessage("input.placeholder"); + const { select, url } = formOptions; + + return ( + + + + + + + + + + + + } + description={} + > + + + + } + description={ + + } + > + + + + + + ); +}; diff --git a/frontend/src/components/cfp-form/availability-section.tsx b/frontend/src/components/cfp-form/availability-section.tsx new file mode 100644 index 0000000000..24ee3abb84 --- /dev/null +++ b/frontend/src/components/cfp-form/availability-section.tsx @@ -0,0 +1,125 @@ +import { + CardPart, + Heading, + MultiplePartsCard, + Spacer, + Text, +} from "@python-italia/pycon-styleguide"; +import { eachDayOfInterval, format, parseISO } from "date-fns"; +import { Fragment } from "react"; +import { FormattedMessage } from "react-intl"; +import { useCurrentLanguage } from "~/locale/context"; +import type { CfpFormQuery } from "~/types"; + +const CHOICES = ["available", "preferred", "unavailable"]; +const RANGES = ["am", "pm"]; + +type Props = { + conferenceData: CfpFormQuery; + selectedDuration: any; + speakerAvailabilities: any; + onChangeAvailability: any; +}; +export const AvailabilitySection = ({ + conferenceData, + selectedDuration, + speakerAvailabilities, + onChangeAvailability, +}: Props) => { + const language = useCurrentLanguage(); + const { + conference: { start, end }, + } = conferenceData; + const parsedStart = parseISO(start); + const parsedEnd = parseISO(end); + const daysBetween = eachDayOfInterval({ start: parsedStart, end: parsedEnd }); + const dateFormatter = new Intl.DateTimeFormat(language, { + day: "2-digit", + month: "long", + }); + + return ( + + + + + + + + + + + +
+
+ + {daysBetween.map((day) => ( + + {dateFormatter.format(day)} + + ))} + + {RANGES.map((hour) => ( + +
+ + {hour === "am" ? ( + + ) : ( + + )} + + + + {hour === "am" ? ( + + ) : ( + + )} + +
+ {daysBetween.map((day) => { + const availabilityDate = `${format(day, "yyyy-MM-dd")}@${hour}`; + const currentChoice = + speakerAvailabilities?.[availabilityDate] || "available"; + + return ( +
{ + const nextChoice = + CHOICES[ + (CHOICES.indexOf(currentChoice) + 1) % CHOICES.length + ]; + onChangeAvailability(availabilityDate, nextChoice); + }} + > + {currentChoice === "available" && <> } + {currentChoice === "preferred" && "✔️"} + {currentChoice === "unavailable" && "❌"} +
+ ); + })} +
+ ))} +
+ + + + + + + + + ); +}; diff --git a/frontend/src/components/cfp-form/cfp-form.graphql b/frontend/src/components/cfp-form/cfp-form.graphql index a8631b6d97..0f1d2b3116 100644 --- a/frontend/src/components/cfp-form/cfp-form.graphql +++ b/frontend/src/components/cfp-form/cfp-form.graphql @@ -1,6 +1,8 @@ query CfpForm($conference: String!) { conference(code: $conference) { id + start + end durations { name diff --git a/frontend/src/components/cfp-form/index.tsx b/frontend/src/components/cfp-form/index.tsx index 2064abc550..f7e9f374e5 100644 --- a/frontend/src/components/cfp-form/index.tsx +++ b/frontend/src/components/cfp-form/index.tsx @@ -38,6 +38,9 @@ import { PublicProfileCard, } from "../public-profile-card"; import { TagsSelect } from "../tags-select"; +import { AboutYouSection } from "./about-you-section"; +import { AvailabilitySection } from "./availability-section"; +import { ProposalSection } from "./proposal-section"; export type CfpFormFields = ParticipantFormFields & { type: string; @@ -53,6 +56,7 @@ export type CfpFormFields = ParticipantFormFields & { previousTalkVideo: string; shortSocialSummary: string; acceptedPrivacyPolicy: boolean; + speakerAvailabilities: { [time: number]: null | string }; }; export type SubmissionStructure = { @@ -82,29 +86,6 @@ type Props = { data: SendSubmissionMutation | UpdateSubmissionMutation; }; -const SPEAKER_LEVEL_OPTIONS = [ - { - value: "", - disabled: true, - messageId: "cfp.selectSpeakerLevel", - }, - { - disabled: false, - value: "new", - messageId: "cfp.speakerLevel.new", - }, - { - disabled: false, - value: "intermediate", - messageId: "cfp.speakerLevel.intermediate", - }, - { - disabled: false, - value: "experienced", - messageId: "cfp.speakerLevel.experienced", - }, -]; - const filterOutInactiveLanguages = ( value: MultiLingualInputType, languages: string[], @@ -149,7 +130,7 @@ export const CfpForm = ({ }, ); - const { textarea, radio, select, checkbox, url, raw } = formOptions; + const { checkbox } = formOptions; const { data: conferenceData } = useCfpFormQuery({ variables: { @@ -199,6 +180,7 @@ export const CfpForm = ({ formState.values.participantPhoto ?? participantData.me.participant?.photoId, acceptedPrivacyPolicy: formState.values.acceptedPrivacyPolicy, + speakerAvailabilities: formState.values.speakerAvailabilities, }); }; @@ -289,11 +271,13 @@ export const CfpForm = ({ "participantMastodonHandle", participantData.me.participant.mastodonHandle, ); + formState.setField( + "speakerAvailabilities", + participantData.me.participant.speakerAvailabilities, + ); } }, []); - const inputPlaceholder = useTranslatedMessage("input.placeholder"); - const hasValidationErrors = submissionData?.mutationOp.__typename === "SendSubmissionErrors"; @@ -326,274 +310,37 @@ export const CfpForm = ({ submissionData!.mutationOp.errors[key]) || []; + const onChangeAvailability = (date, choice) => { + formState.setField("speakerAvailabilities", { + ...formState.values.speakerAvailabilities, + [date]: choice, + }); + }; + return (
- - - - - - - - - } - description={} - > - - {conferenceData!.conference.submissionTypes.map((type) => ( - - ))} - - - - } - description={} - > - - {conferenceData!.conference.languages.map((language) => ( - - ))} - - - - } - description={} - > - - - - - - } - description={ - - } - > - -