Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions backend/api/users/tests/test_booked_schedule_items.py
Original file line number Diff line number Diff line change
@@ -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"
19 changes: 19 additions & 0 deletions backend/api/users/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Page endSeparator={false}>
<FormattedMessage id="profile.myWorkshops.title">
{(text) => <MetaTags title={text} />}
</FormattedMessage>

<Section background="coral">
<Heading size="display2">
<FormattedMessage id="profile.myWorkshops" />
</Heading>
</Section>
<Section>
{bookedScheduleItems.length > 0 && (
<MyWorkshopsTable workshops={bookedScheduleItems} />
)}
{bookedScheduleItems.length === 0 && <NoWorkshops />}
</Section>
</Page>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<Table
cols={3}
rowGetter={(workshop) => {
const parsedStartTime = parseISO(workshop.start);
const parsedEndTime = parseISO(workshop.end);
const roomNames = workshop.rooms.map((room) => room.name).join(", ");

return [
<div>
<EventTag type={workshop.type.toLowerCase()} />
<Spacer size="small" />
<Link
href={createHref({
path: `/event/${workshop.slug}`,
locale: language,
})}
>
<Heading color="none" size={4}>
{workshop.title}
</Heading>
</Link>
</div>,
<VerticalStack gap="small" alignItems="start">
<Text size={2} weight="strong" as="p">
<FormattedMessage
id="profile.myProposals.date"
values={{
day: dateFormatter.format(parsedStartTime),
start: hourFormatter.format(parsedStartTime),
end: hourFormatter.format(parsedEndTime),
}}
/>
</Text>
{roomNames && (
<Text size={2} as="p">
<FormattedMessage
id="profile.myWorkshops.room"
values={{ room: roomNames }}
/>
</Text>
)}
</VerticalStack>,
<Button
href={createHref({
path: `/event/${workshop.slug}`,
locale: language,
})}
size="small"
variant="secondary"
>
<FormattedMessage id="profile.myWorkshops.viewWorkshop" />
</Button>,
];
}}
keyGetter={(workshop) => workshop.id}
data={workshops}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<Container size="small" center={false} noPadding>
<Heading size={2}>
<FormattedMessage id="profile.myWorkshops.noWorkshops.heading" />
</Heading>
<Spacer size="small" />
<Text size={2}>
<FormattedMessage id="profile.myWorkshops.noWorkshops.body" />
</Text>
<Spacer size="large" />
<Button
variant="secondary"
href={createHref({
path: "/schedule",
locale: language,
})}
>
<FormattedMessage id="profile.myWorkshops.browseSchedule" />
</Button>
</Container>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
query MyProfileWithBookedWorkshops($conference: String!) {
me {
id
bookedScheduleItems(conference: $conference) {
id
title
slug
type
start
end
rooms {
id
name
}
}
}
}
10 changes: 10 additions & 0 deletions frontend/src/components/profile-page-handler/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ export const ProfilePageHandler = () => {
icon: "circle",
iconBackground: "purple",
},
{
id: "workshops",
link: createHref({
path: "/profile/my-workshops",
locale: language,
}),
label: <FormattedMessage id="profile.myWorkshops" />,
icon: "drink",
iconBackground: "coral",
},
{
id: "grants",
link: createHref({
Expand Down
Loading
Loading