diff --git a/backend/api/users/tests/test_booked_schedule_items.py b/backend/api/users/tests/test_booked_schedule_items.py new file mode 100644 index 0000000000..64e68d5fe8 --- /dev/null +++ b/backend/api/users/tests/test_booked_schedule_items.py @@ -0,0 +1,133 @@ +import datetime + +from schedule.tests.factories import ( + DayFactory, + ScheduleItemAttendeeFactory, + ScheduleItemFactory, + SlotFactory, +) +from submissions.tests.factories import SubmissionFactory +from users.tests.factories import UserFactory +import pytest + +from schedule.models import ScheduleItem + +pytestmark = pytest.mark.django_db + + +def _bookable_schedule_item(attendees_total_capacity: int = 30): + submission = SubmissionFactory() + return ScheduleItemFactory( + status=ScheduleItem.STATUS.confirmed, + submission=submission, + type=ScheduleItem.TYPES.training, + conference=submission.conference, + attendees_total_capacity=attendees_total_capacity, + slot=SlotFactory( + day=DayFactory( + day=datetime.date(2020, 10, 10), + conference=submission.conference, + ), + hour=datetime.time(10, 10, 0), + duration=30, + ), + ) + + +def test_get_booked_schedule_items(graphql_client, user): + graphql_client.force_login(user) + + booked_item = _bookable_schedule_item() + _bookable_schedule_item() + ScheduleItemAttendeeFactory(schedule_item=booked_item, user_id=user.id) + + response = graphql_client.query( + """query($conference: String!) { + me { + bookedScheduleItems(conference: $conference) { + id + title + slug + start + end + } + } + }""", + variables={"conference": booked_item.conference.code}, + ) + + booked_schedule_items = response["data"]["me"]["bookedScheduleItems"] + assert len(booked_schedule_items) == 1 + assert booked_schedule_items[0]["id"] == str(booked_item.id) + assert booked_schedule_items[0]["slug"] == booked_item.slug + assert booked_schedule_items[0]["start"] is not None + assert booked_schedule_items[0]["end"] is not None + + +def test_booked_schedule_items_excludes_items_without_slot(graphql_client, user): + graphql_client.force_login(user) + + submission = SubmissionFactory() + unscheduled_workshop = ScheduleItemFactory( + status=ScheduleItem.STATUS.confirmed, + submission=submission, + type=ScheduleItem.TYPES.training, + conference=submission.conference, + attendees_total_capacity=30, + slot=None, + ) + ScheduleItemAttendeeFactory(schedule_item=unscheduled_workshop, user_id=user.id) + + response = graphql_client.query( + """query($conference: String!) { + me { + bookedScheduleItems(conference: $conference) { + id + start + end + } + } + }""", + variables={"conference": unscheduled_workshop.conference.code}, + ) + + assert response["data"]["me"]["bookedScheduleItems"] == [] + + +def test_booked_schedule_items_excludes_other_users_bookings(graphql_client, user): + graphql_client.force_login(user) + + booked_item = _bookable_schedule_item() + other_user = UserFactory() + ScheduleItemAttendeeFactory(schedule_item=booked_item, user_id=other_user.id) + + response = graphql_client.query( + """query($conference: String!) { + me { + bookedScheduleItems(conference: $conference) { + id + } + } + }""", + variables={"conference": booked_item.conference.code}, + ) + + assert response["data"]["me"]["bookedScheduleItems"] == [] + + +def test_booked_schedule_items_requires_authentication(graphql_client): + schedule_item = _bookable_schedule_item() + ScheduleItemAttendeeFactory(schedule_item=schedule_item) + + response = graphql_client.query( + """query($conference: String!) { + me { + bookedScheduleItems(conference: $conference) { + id + } + } + }""", + variables={"conference": schedule_item.conference.code}, + ) + + assert response["errors"][0]["message"] == "User not logged in" diff --git a/backend/api/users/types.py b/backend/api/users/types.py index de731308de..de296f647e 100644 --- a/backend/api/users/types.py +++ b/backend/api/users/types.py @@ -3,6 +3,7 @@ from api.permissions import IsAuthenticated from django.conf import settings +from django.db.models import Prefetch from django.urls import reverse from api.billing.types import BillingAddress from api.visa.types import InvitationLetterRequest @@ -26,6 +27,8 @@ from api.helpers.ids import encode_hashid from badges.roles import ConferenceRole, get_conference_roles_for_user from association_membership.models import Membership +from api.schedule.types import ScheduleItem +from schedule.models import Room, ScheduleItem as ScheduleItemModel from schedule.models import ScheduleItemStar as ScheduleItemStarModel from submissions.models import Submission as SubmissionModel from billing.models import BillingAddress as BillingAddressModel @@ -111,6 +114,22 @@ def starred_schedule_items( ).values_list("schedule_item_id", flat=True) return stars + @strawberry.field(permission_classes=[IsAuthenticated]) + def booked_schedule_items(self, info: Info, conference: str) -> list[ScheduleItem]: + return list( + ScheduleItemModel.objects.filter( + conference__code=conference, + attendees__user_id=self.id, + slot__isnull=False, + ) + .distinct() + .select_related("slot", "slot__day", "language") + .prefetch_related( + Prefetch("rooms", queryset=Room.objects.only("id", "name", "type")) + ) + .order_by("slot__day__day", "slot__hour") + ) + @strawberry.field def grant(self, info: Info, conference: str) -> Grant | None: grant = GrantModel.objects.filter( diff --git a/frontend/src/components/my-workshops-profile-page-handler/index.tsx b/frontend/src/components/my-workshops-profile-page-handler/index.tsx new file mode 100644 index 0000000000..2325d428d7 --- /dev/null +++ b/frontend/src/components/my-workshops-profile-page-handler/index.tsx @@ -0,0 +1,41 @@ +import { Heading, Page, Section } from "@python-italia/pycon-styleguide"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +import { useMyProfileWithBookedWorkshopsQuery } from "~/types"; + +import { MetaTags } from "../meta-tags"; +import { MyWorkshopsTable } from "./my-workshops-table"; +import { NoWorkshops } from "./no-workshops"; + +export const MyWorkshopsProfilePageHandler = () => { + const { + data: { + me: { bookedScheduleItems }, + }, + } = useMyProfileWithBookedWorkshopsQuery({ + variables: { + conference: process.env.conferenceCode, + }, + }); + + return ( + + + {(text) => } + + +
+ + + +
+
+ {bookedScheduleItems.length > 0 && ( + + )} + {bookedScheduleItems.length === 0 && } +
+
+ ); +}; diff --git a/frontend/src/components/my-workshops-profile-page-handler/my-workshops-table.tsx b/frontend/src/components/my-workshops-profile-page-handler/my-workshops-table.tsx new file mode 100644 index 0000000000..1bf85a7537 --- /dev/null +++ b/frontend/src/components/my-workshops-profile-page-handler/my-workshops-table.tsx @@ -0,0 +1,95 @@ +import { + Button, + Heading, + Link, + Spacer, + Text, + VerticalStack, +} from "@python-italia/pycon-styleguide"; +import { parseISO } from "date-fns"; +import { FormattedMessage } from "react-intl"; + +import { useCurrentLanguage } from "~/locale/context"; +import type { MyProfileWithBookedWorkshopsQuery } from "~/types"; + +import { createHref } from "../link"; +import { EventTag } from "../schedule-event-detail/event-tag"; +import { Table } from "../table"; + +type Props = { + workshops: MyProfileWithBookedWorkshopsQuery["me"]["bookedScheduleItems"]; +}; + +export const MyWorkshopsTable = ({ workshops }: Props) => { + const language = useCurrentLanguage(); + const dateFormatter = new Intl.DateTimeFormat(language, { + day: "2-digit", + month: "long", + weekday: "long", + }); + const hourFormatter = new Intl.DateTimeFormat(language, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + return ( + { + const parsedStartTime = parseISO(workshop.start); + const parsedEndTime = parseISO(workshop.end); + const roomNames = workshop.rooms.map((room) => room.name).join(", "); + + return [ +
+ + + + + {workshop.title} + + +
, + + + + + {roomNames && ( + + + + )} + , + , + ]; + }} + keyGetter={(workshop) => workshop.id} + data={workshops} + /> + ); +}; diff --git a/frontend/src/components/my-workshops-profile-page-handler/no-workshops.tsx b/frontend/src/components/my-workshops-profile-page-handler/no-workshops.tsx new file mode 100644 index 0000000000..ff9178b406 --- /dev/null +++ b/frontend/src/components/my-workshops-profile-page-handler/no-workshops.tsx @@ -0,0 +1,38 @@ +import { + Button, + Container, + Heading, + Spacer, + Text, +} from "@python-italia/pycon-styleguide"; +import { FormattedMessage } from "react-intl"; + +import { useCurrentLanguage } from "~/locale/context"; + +import { createHref } from "../link"; + +export const NoWorkshops = () => { + const language = useCurrentLanguage(); + + return ( + + + + + + + + + + + + ); +}; diff --git a/frontend/src/components/my-workshops-profile-page-handler/profile-with-booked-workshops.graphql b/frontend/src/components/my-workshops-profile-page-handler/profile-with-booked-workshops.graphql new file mode 100644 index 0000000000..7a7b983d8b --- /dev/null +++ b/frontend/src/components/my-workshops-profile-page-handler/profile-with-booked-workshops.graphql @@ -0,0 +1,17 @@ +query MyProfileWithBookedWorkshops($conference: String!) { + me { + id + bookedScheduleItems(conference: $conference) { + id + title + slug + type + start + end + rooms { + id + name + } + } + } +} diff --git a/frontend/src/components/profile-page-handler/index.tsx b/frontend/src/components/profile-page-handler/index.tsx index 2f14df992f..f2a013eecf 100644 --- a/frontend/src/components/profile-page-handler/index.tsx +++ b/frontend/src/components/profile-page-handler/index.tsx @@ -119,6 +119,16 @@ export const ProfilePageHandler = () => { icon: "circle", iconBackground: "purple", }, + { + id: "workshops", + link: createHref({ + path: "/profile/my-workshops", + locale: language, + }), + label: , + icon: "drink", + iconBackground: "coral", + }, { id: "grants", link: createHref({ diff --git a/frontend/src/locale/index.ts b/frontend/src/locale/index.ts index 040024bf2f..9f832361e6 100644 --- a/frontend/src/locale/index.ts +++ b/frontend/src/locale/index.ts @@ -271,6 +271,15 @@ Failing to notify us may impact your eligibility for financial aid at future eve "profile.myOrders.title": "My Orders", "profile.myProposals.title": "My Proposals", "profile.myTickets.title": "My Tickets", + "profile.myWorkshops.title": "My Workshops", + "profile.myWorkshops": "My Workshops", + "profile.myWorkshops.noWorkshops.heading": + "You have not booked any workshops yet", + "profile.myWorkshops.noWorkshops.body": + "Browse the schedule and book a seat for workshops with limited capacity.", + "profile.myWorkshops.browseSchedule": "Browse schedule", + "profile.myWorkshops.viewWorkshop": "View workshop", + "profile.myWorkshops.room": "Room: {room}", "home.title": "Home", "home.deadline.begins": "Begins", @@ -1440,7 +1449,8 @@ The sooner you buy your ticket, the more you save!`, "Il tuo ordine è ancora pending e non confermato!", "orderConfirmation.cardMessage": "Se hai pagato con carta, clicca il pulsante per riprovare", - "orderConfirmation.bankMessage": `Se hai pagato con bonifico bancario, tieni presente che i bonifici vengono elaborati manualmente e potrebbe essere necessaria fino a una settimana affinché il tuo ordine venga contrassegnato come pagato. Una volta effettuato il bonifico, contattaci a {email} con il tuo codice {code} per aiutarci a confermarlo più velocemente.`, + "orderConfirmation.bankMessage": + "Se hai pagato con bonifico bancario, tieni presente che i bonifici vengono elaborati manualmente e potrebbe essere necessaria fino a una settimana affinché il tuo ordine venga contrassegnato come pagato. Una volta effettuato il bonifico, contattaci a {email} con il tuo codice {code} per aiutarci a confermarlo più velocemente.", "orderConfirmation.tryAgain": "Prova a creare un nuovo ordine", "orderConfirmation.tickets": "Biglietti", "order.soldout": "Sold out", @@ -2136,6 +2146,15 @@ Usa il pulsante 'Gestisci' nella pagina per confermare o rifiutare il grant. Hai "profile.myOrders.title": "I miei ordini", "profile.myProposals.title": "Le mie proposte", "profile.myTickets.title": "I miei biglietti", + "profile.myWorkshops.title": "I miei workshop", + "profile.myWorkshops": "I miei workshop", + "profile.myWorkshops.noWorkshops.heading": + "Non hai ancora prenotato nessun workshop", + "profile.myWorkshops.noWorkshops.body": + "Sfoglia il programma e prenota un posto per i workshop con posti limitati.", + "profile.myWorkshops.browseSchedule": "Vai al programma", + "profile.myWorkshops.viewWorkshop": "Vedi workshop", + "profile.myWorkshops.room": "Sala: {room}", "homepage.eventPreviewCard.time": "{start} - {end}", "homepage.schedulePreviewSection.goToSchedule": "Apri Programma", diff --git a/frontend/src/pages/profile/my-workshops.tsx b/frontend/src/pages/profile/my-workshops.tsx new file mode 100644 index 0000000000..39df48ccbc --- /dev/null +++ b/frontend/src/pages/profile/my-workshops.tsx @@ -0,0 +1,48 @@ +import type { GetServerSideProps } from "next"; + +import { addApolloState, getApolloClient } from "~/apollo/client"; +import { prefetchSharedQueries } from "~/helpers/prefetch"; +import { queryMyProfileWithBookedWorkshops } from "~/types"; + +export const getServerSideProps: GetServerSideProps = async ({ + req, + locale, +}) => { + const identityToken = req.cookies.pythonitalia_sessionid; + if (!identityToken) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + const client = getApolloClient(null, req.cookies); + + try { + await Promise.all([ + prefetchSharedQueries(client, locale), + queryMyProfileWithBookedWorkshops(client, { + conference: process.env.conferenceCode, + }), + ]); + } catch { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + return addApolloState( + client, + { + props: {}, + }, + null, + ); +}; + +export { MyWorkshopsProfilePageHandler as default } from "~/components/my-workshops-profile-page-handler";