Skip to content

Commit 9a2e69c

Browse files
authored
feat(profile): add My Workshops page for booked schedule items (#4660)
1 parent 5be0ac3 commit 9a2e69c

9 files changed

Lines changed: 421 additions & 1 deletion

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import datetime
2+
3+
from schedule.tests.factories import (
4+
DayFactory,
5+
ScheduleItemAttendeeFactory,
6+
ScheduleItemFactory,
7+
SlotFactory,
8+
)
9+
from submissions.tests.factories import SubmissionFactory
10+
from users.tests.factories import UserFactory
11+
import pytest
12+
13+
from schedule.models import ScheduleItem
14+
15+
pytestmark = pytest.mark.django_db
16+
17+
18+
def _bookable_schedule_item(attendees_total_capacity: int = 30):
19+
submission = SubmissionFactory()
20+
return ScheduleItemFactory(
21+
status=ScheduleItem.STATUS.confirmed,
22+
submission=submission,
23+
type=ScheduleItem.TYPES.training,
24+
conference=submission.conference,
25+
attendees_total_capacity=attendees_total_capacity,
26+
slot=SlotFactory(
27+
day=DayFactory(
28+
day=datetime.date(2020, 10, 10),
29+
conference=submission.conference,
30+
),
31+
hour=datetime.time(10, 10, 0),
32+
duration=30,
33+
),
34+
)
35+
36+
37+
def test_get_booked_schedule_items(graphql_client, user):
38+
graphql_client.force_login(user)
39+
40+
booked_item = _bookable_schedule_item()
41+
_bookable_schedule_item()
42+
ScheduleItemAttendeeFactory(schedule_item=booked_item, user_id=user.id)
43+
44+
response = graphql_client.query(
45+
"""query($conference: String!) {
46+
me {
47+
bookedScheduleItems(conference: $conference) {
48+
id
49+
title
50+
slug
51+
start
52+
end
53+
}
54+
}
55+
}""",
56+
variables={"conference": booked_item.conference.code},
57+
)
58+
59+
booked_schedule_items = response["data"]["me"]["bookedScheduleItems"]
60+
assert len(booked_schedule_items) == 1
61+
assert booked_schedule_items[0]["id"] == str(booked_item.id)
62+
assert booked_schedule_items[0]["slug"] == booked_item.slug
63+
assert booked_schedule_items[0]["start"] is not None
64+
assert booked_schedule_items[0]["end"] is not None
65+
66+
67+
def test_booked_schedule_items_excludes_items_without_slot(graphql_client, user):
68+
graphql_client.force_login(user)
69+
70+
submission = SubmissionFactory()
71+
unscheduled_workshop = ScheduleItemFactory(
72+
status=ScheduleItem.STATUS.confirmed,
73+
submission=submission,
74+
type=ScheduleItem.TYPES.training,
75+
conference=submission.conference,
76+
attendees_total_capacity=30,
77+
slot=None,
78+
)
79+
ScheduleItemAttendeeFactory(schedule_item=unscheduled_workshop, user_id=user.id)
80+
81+
response = graphql_client.query(
82+
"""query($conference: String!) {
83+
me {
84+
bookedScheduleItems(conference: $conference) {
85+
id
86+
start
87+
end
88+
}
89+
}
90+
}""",
91+
variables={"conference": unscheduled_workshop.conference.code},
92+
)
93+
94+
assert response["data"]["me"]["bookedScheduleItems"] == []
95+
96+
97+
def test_booked_schedule_items_excludes_other_users_bookings(graphql_client, user):
98+
graphql_client.force_login(user)
99+
100+
booked_item = _bookable_schedule_item()
101+
other_user = UserFactory()
102+
ScheduleItemAttendeeFactory(schedule_item=booked_item, user_id=other_user.id)
103+
104+
response = graphql_client.query(
105+
"""query($conference: String!) {
106+
me {
107+
bookedScheduleItems(conference: $conference) {
108+
id
109+
}
110+
}
111+
}""",
112+
variables={"conference": booked_item.conference.code},
113+
)
114+
115+
assert response["data"]["me"]["bookedScheduleItems"] == []
116+
117+
118+
def test_booked_schedule_items_requires_authentication(graphql_client):
119+
schedule_item = _bookable_schedule_item()
120+
ScheduleItemAttendeeFactory(schedule_item=schedule_item)
121+
122+
response = graphql_client.query(
123+
"""query($conference: String!) {
124+
me {
125+
bookedScheduleItems(conference: $conference) {
126+
id
127+
}
128+
}
129+
}""",
130+
variables={"conference": schedule_item.conference.code},
131+
)
132+
133+
assert response["errors"][0]["message"] == "User not logged in"

backend/api/users/types.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from api.permissions import IsAuthenticated
44
from django.conf import settings
55

6+
from django.db.models import Prefetch
67
from django.urls import reverse
78
from api.billing.types import BillingAddress
89
from api.visa.types import InvitationLetterRequest
@@ -26,6 +27,8 @@
2627
from api.helpers.ids import encode_hashid
2728
from badges.roles import ConferenceRole, get_conference_roles_for_user
2829
from association_membership.models import Membership
30+
from api.schedule.types import ScheduleItem
31+
from schedule.models import Room, ScheduleItem as ScheduleItemModel
2932
from schedule.models import ScheduleItemStar as ScheduleItemStarModel
3033
from submissions.models import Submission as SubmissionModel
3134
from billing.models import BillingAddress as BillingAddressModel
@@ -111,6 +114,22 @@ def starred_schedule_items(
111114
).values_list("schedule_item_id", flat=True)
112115
return stars
113116

117+
@strawberry.field(permission_classes=[IsAuthenticated])
118+
def booked_schedule_items(self, info: Info, conference: str) -> list[ScheduleItem]:
119+
return list(
120+
ScheduleItemModel.objects.filter(
121+
conference__code=conference,
122+
attendees__user_id=self.id,
123+
slot__isnull=False,
124+
)
125+
.distinct()
126+
.select_related("slot", "slot__day", "language")
127+
.prefetch_related(
128+
Prefetch("rooms", queryset=Room.objects.only("id", "name", "type"))
129+
)
130+
.order_by("slot__day__day", "slot__hour")
131+
)
132+
114133
@strawberry.field
115134
def grant(self, info: Info, conference: str) -> Grant | None:
116135
grant = GrantModel.objects.filter(
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Heading, Page, Section } from "@python-italia/pycon-styleguide";
2+
import React from "react";
3+
import { FormattedMessage } from "react-intl";
4+
5+
import { useMyProfileWithBookedWorkshopsQuery } from "~/types";
6+
7+
import { MetaTags } from "../meta-tags";
8+
import { MyWorkshopsTable } from "./my-workshops-table";
9+
import { NoWorkshops } from "./no-workshops";
10+
11+
export const MyWorkshopsProfilePageHandler = () => {
12+
const {
13+
data: {
14+
me: { bookedScheduleItems },
15+
},
16+
} = useMyProfileWithBookedWorkshopsQuery({
17+
variables: {
18+
conference: process.env.conferenceCode,
19+
},
20+
});
21+
22+
return (
23+
<Page endSeparator={false}>
24+
<FormattedMessage id="profile.myWorkshops.title">
25+
{(text) => <MetaTags title={text} />}
26+
</FormattedMessage>
27+
28+
<Section background="coral">
29+
<Heading size="display2">
30+
<FormattedMessage id="profile.myWorkshops" />
31+
</Heading>
32+
</Section>
33+
<Section>
34+
{bookedScheduleItems.length > 0 && (
35+
<MyWorkshopsTable workshops={bookedScheduleItems} />
36+
)}
37+
{bookedScheduleItems.length === 0 && <NoWorkshops />}
38+
</Section>
39+
</Page>
40+
);
41+
};
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {
2+
Button,
3+
Heading,
4+
Link,
5+
Spacer,
6+
Text,
7+
VerticalStack,
8+
} from "@python-italia/pycon-styleguide";
9+
import { parseISO } from "date-fns";
10+
import { FormattedMessage } from "react-intl";
11+
12+
import { useCurrentLanguage } from "~/locale/context";
13+
import type { MyProfileWithBookedWorkshopsQuery } from "~/types";
14+
15+
import { createHref } from "../link";
16+
import { EventTag } from "../schedule-event-detail/event-tag";
17+
import { Table } from "../table";
18+
19+
type Props = {
20+
workshops: MyProfileWithBookedWorkshopsQuery["me"]["bookedScheduleItems"];
21+
};
22+
23+
export const MyWorkshopsTable = ({ workshops }: Props) => {
24+
const language = useCurrentLanguage();
25+
const dateFormatter = new Intl.DateTimeFormat(language, {
26+
day: "2-digit",
27+
month: "long",
28+
weekday: "long",
29+
});
30+
const hourFormatter = new Intl.DateTimeFormat(language, {
31+
hour: "2-digit",
32+
minute: "2-digit",
33+
hour12: false,
34+
});
35+
36+
return (
37+
<Table
38+
cols={3}
39+
rowGetter={(workshop) => {
40+
const parsedStartTime = parseISO(workshop.start);
41+
const parsedEndTime = parseISO(workshop.end);
42+
const roomNames = workshop.rooms.map((room) => room.name).join(", ");
43+
44+
return [
45+
<div>
46+
<EventTag type={workshop.type.toLowerCase()} />
47+
<Spacer size="small" />
48+
<Link
49+
href={createHref({
50+
path: `/event/${workshop.slug}`,
51+
locale: language,
52+
})}
53+
>
54+
<Heading color="none" size={4}>
55+
{workshop.title}
56+
</Heading>
57+
</Link>
58+
</div>,
59+
<VerticalStack gap="small" alignItems="start">
60+
<Text size={2} weight="strong" as="p">
61+
<FormattedMessage
62+
id="profile.myProposals.date"
63+
values={{
64+
day: dateFormatter.format(parsedStartTime),
65+
start: hourFormatter.format(parsedStartTime),
66+
end: hourFormatter.format(parsedEndTime),
67+
}}
68+
/>
69+
</Text>
70+
{roomNames && (
71+
<Text size={2} as="p">
72+
<FormattedMessage
73+
id="profile.myWorkshops.room"
74+
values={{ room: roomNames }}
75+
/>
76+
</Text>
77+
)}
78+
</VerticalStack>,
79+
<Button
80+
href={createHref({
81+
path: `/event/${workshop.slug}`,
82+
locale: language,
83+
})}
84+
size="small"
85+
variant="secondary"
86+
>
87+
<FormattedMessage id="profile.myWorkshops.viewWorkshop" />
88+
</Button>,
89+
];
90+
}}
91+
keyGetter={(workshop) => workshop.id}
92+
data={workshops}
93+
/>
94+
);
95+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {
2+
Button,
3+
Container,
4+
Heading,
5+
Spacer,
6+
Text,
7+
} from "@python-italia/pycon-styleguide";
8+
import { FormattedMessage } from "react-intl";
9+
10+
import { useCurrentLanguage } from "~/locale/context";
11+
12+
import { createHref } from "../link";
13+
14+
export const NoWorkshops = () => {
15+
const language = useCurrentLanguage();
16+
17+
return (
18+
<Container size="small" center={false} noPadding>
19+
<Heading size={2}>
20+
<FormattedMessage id="profile.myWorkshops.noWorkshops.heading" />
21+
</Heading>
22+
<Spacer size="small" />
23+
<Text size={2}>
24+
<FormattedMessage id="profile.myWorkshops.noWorkshops.body" />
25+
</Text>
26+
<Spacer size="large" />
27+
<Button
28+
variant="secondary"
29+
href={createHref({
30+
path: "/schedule",
31+
locale: language,
32+
})}
33+
>
34+
<FormattedMessage id="profile.myWorkshops.browseSchedule" />
35+
</Button>
36+
</Container>
37+
);
38+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
query MyProfileWithBookedWorkshops($conference: String!) {
2+
me {
3+
id
4+
bookedScheduleItems(conference: $conference) {
5+
id
6+
title
7+
slug
8+
type
9+
start
10+
end
11+
rooms {
12+
id
13+
name
14+
}
15+
}
16+
}
17+
}

frontend/src/components/profile-page-handler/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ export const ProfilePageHandler = () => {
119119
icon: "circle",
120120
iconBackground: "purple",
121121
},
122+
{
123+
id: "workshops",
124+
link: createHref({
125+
path: "/profile/my-workshops",
126+
locale: language,
127+
}),
128+
label: <FormattedMessage id="profile.myWorkshops" />,
129+
icon: "drink",
130+
iconBackground: "coral",
131+
},
122132
{
123133
id: "grants",
124134
link: createHref({

0 commit comments

Comments
 (0)