From 72c81505e764caf168a6d77d8e38f1f8b6cdb23f Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Thu, 13 Aug 2026 21:03:07 +0200 Subject: [PATCH 1/3] Migrate schedule types to Strawberry Django --- backend/api/conferences/schema.py | 15 +- backend/api/conferences/types.py | 68 ++------- backend/api/schedule/optimization.py | 137 ++++++++++++++++++ backend/api/schedule/types/day.py | 11 +- backend/api/schedule/types/room.py | 10 +- backend/api/schedule/types/schedule_item.py | 81 ++++++++--- backend/api/schedule/types/slot.py | 11 +- .../api/tests/schema/conference/test_days.py | 129 +++++++++++++++++ 8 files changed, 370 insertions(+), 92 deletions(-) create mode 100644 backend/api/schedule/optimization.py diff --git a/backend/api/conferences/schema.py b/backend/api/conferences/schema.py index 881028656e..cfb62f3c2f 100644 --- a/backend/api/conferences/schema.py +++ b/backend/api/conferences/schema.py @@ -1,12 +1,21 @@ from api.context import Info from api.conferences.types import Conference import strawberry +import strawberry_django +from strawberry_django.optimizer import OptimizerConfig, optimize -from conferences.models import Conference as ConferenceModel +from conferences import models @strawberry.type class ConferenceQuery: - @strawberry.field + @strawberry_django.field def conference(self, info: Info, code: str) -> Conference: - return ConferenceModel.objects.prefetch_related("durations").get(code=code) + conferences = models.Conference.objects.filter(code=code).prefetch_related( + "durations" + ) + return optimize( + conferences, + info, + config=OptimizerConfig(enable_only=False), + ).get() diff --git a/backend/api/conferences/types.py b/backend/api/conferences/types.py index c7fc6e6588..761a4dcba2 100644 --- a/backend/api/conferences/types.py +++ b/backend/api/conferences/types.py @@ -1,6 +1,4 @@ from api.context import Info -from django.db.models import Case, When, Value, IntegerField -from django.db.models import Prefetch from participants.models import Participant as ParticipantModel from datetime import datetime from api.participants.types import Participant @@ -18,6 +16,7 @@ from api.languages.types import Language from api.pretix.query import get_conference_tickets, get_voucher from api.pretix.types import TicketItem, Voucher +from api.schedule.optimization import schedule_days_prefetch, schedule_item_speakers from api.schedule.types import Room, ScheduleItem, ScheduleItemUser from api.sponsors.types import ( SponsorBenefit, @@ -139,21 +138,21 @@ def youtube_video_id(self) -> str | None: return schedule_item.youtube_video_id if schedule_item else None -@strawberry.type +@strawberry_django.type(conference_models.Conference) class Conference: - id: strawberry.ID + id: auto name: str = strawberry.field(resolver=make_localized_resolver("name")) introduction: str = strawberry.field( resolver=make_localized_resolver("introduction") ) - code: str - hostname: str + code: auto + hostname: auto start: datetime end: datetime map: Map | None = strawberry.field(resolver=resolve_map) - pretix_event_url: str + pretix_event_url: auto @strawberry.field def voucher(self, info: Info, code: str) -> Voucher | None: @@ -296,64 +295,15 @@ def ranking(self, info: Info, topic: strawberry.ID) -> RankRequest | None: stats=rank_request.stats.all(), ) - @strawberry.field + @strawberry_django.field(prefetch_related=[schedule_days_prefetch]) def days(self, info: Info) -> list[Day]: - days = list( - self.days.order_by("day") - .prefetch_related( - "slots", - "slots__day", - "slots__day__added_rooms", - "slots__day__added_rooms__room", - Prefetch( - "slots__items", - queryset=( - ScheduleItemModel.objects.for_conference(self.id) - .annotate( - order=Case( - When(type="custom", then=Value(1)), - When(type="break", then=Value(1)), - When(type="talk", then=Value(2)), - When(type="panel", then=Value(3)), - default=Value(4), - output_field=IntegerField(), - ) - ) - .order_by("order") - .prefetch_related( - "audience_level", - "language", - "rooms", - "additional_speakers", - "additional_speakers__user", - "language", - "submission", - "submission__type", - "submission__tags", - "submission__duration", - "submission__audience_level", - "submission__speaker", - "submission__languages", - "submission__schedule_items", - "keynote", - "keynote__schedule_items", - "keynote__schedule_items__rooms", - "keynote__schedule_items__slot", - "keynote__schedule_items__slot__day", - "keynote__speakers", - "keynote__speakers__user", - ) - ), - ), - ) - .all() - ) + days = list(self.days.all()) all_speakers = [ speaker.id for day in days for slot in day.slots.all() for item in slot.items.all() - for speaker in item.speakers + for speaker in schedule_item_speakers(item) ] info.context._participants_data = { participant.user_id: participant diff --git a/backend/api/schedule/optimization.py b/backend/api/schedule/optimization.py new file mode 100644 index 0000000000..c4d37526aa --- /dev/null +++ b/backend/api/schedule/optimization.py @@ -0,0 +1,137 @@ +from django.db import models as django_models +from django.db.models.functions import Coalesce + +from api.context import Info +from conferences import models as conference_models +from schedule import models + + +ATTENDEES_COUNT_ANNOTATION = "graphql_attendees_count" +CAPACITY_ANNOTATION = "graphql_attendees_total_capacity" +USER_HAS_SPOT_ANNOTATION = "graphql_user_has_spot" + + +def attendees_count_annotation(_info: Info): + return django_models.Count("attendees", distinct=True) + + +def capacity_annotation(_info: Info): + room_capacity = ( + models.Room.objects.filter(talks=django_models.OuterRef("pk")) + .order_by("pk") + .values("attendees_total_capacity")[:1] + ) + return Coalesce( + "attendees_total_capacity", + django_models.Subquery(room_capacity), + output_field=django_models.PositiveIntegerField(), + ) + + +def user_has_spot_annotation(info: Info): + user_id = info.context.request.user.id + if not user_id: + return django_models.Value(False, output_field=django_models.BooleanField()) + + return django_models.Exists( + models.ScheduleItemAttendee.objects.filter( + schedule_item_id=django_models.OuterRef("pk"), + user_id=user_id, + ) + ) + + +def attendance_annotations(info: Info): + return { + ATTENDEES_COUNT_ANNOTATION: attendees_count_annotation(info), + CAPACITY_ANNOTATION: capacity_annotation(info), + USER_HAS_SPOT_ANNOTATION: user_has_spot_annotation(info), + } + + +def schedule_item_speakers(schedule_item): + speakers = [] + + if schedule_item.submission_id: + speakers.append(schedule_item.submission.speaker) + + if schedule_item.keynote_id: + keynote_speakers = getattr( + schedule_item.keynote, + "graphql_speakers", + None, + ) + if keynote_speakers is None: + keynote_speakers = schedule_item.keynote.speakers.order_by("id").all() + speakers.extend(speaker.user for speaker in keynote_speakers) + + additional_speakers = getattr( + schedule_item, + "graphql_additional_speakers", + None, + ) + if additional_speakers is None: + additional_speakers = schedule_item.additional_speakers.order_by("id").all() + speakers.extend(speaker.user for speaker in additional_speakers) + + return [speaker for speaker in speakers if speaker is not None] + + +def schedule_days_prefetch(info: Info): + schedule_items = ( + models.ScheduleItem.objects.annotate( + order=django_models.Case( + django_models.When(type="custom", then=django_models.Value(1)), + django_models.When(type="break", then=django_models.Value(1)), + django_models.When(type="talk", then=django_models.Value(2)), + django_models.When(type="panel", then=django_models.Value(3)), + default=django_models.Value(4), + output_field=django_models.IntegerField(), + ), + **attendance_annotations(info), + ) + .order_by("order") + .prefetch_related( + "audience_level", + "language", + "rooms", + django_models.Prefetch( + "additional_speakers", + queryset=models.ScheduleItemAdditionalSpeaker.objects.order_by( + "id" + ).select_related("user"), + to_attr="graphql_additional_speakers", + ), + "submission", + "submission__type", + "submission__tags", + "submission__duration", + "submission__audience_level", + "submission__speaker", + "submission__languages", + "submission__schedule_items", + "keynote", + "keynote__schedule_items", + "keynote__schedule_items__rooms", + "keynote__schedule_items__slot", + "keynote__schedule_items__slot__day", + django_models.Prefetch( + "keynote__speakers", + queryset=conference_models.KeynoteSpeaker.objects.order_by( + "id" + ).select_related("user"), + to_attr="graphql_speakers", + ), + ) + ) + + return django_models.Prefetch( + "days", + queryset=models.Day.objects.order_by("day").prefetch_related( + "slots", + "slots__day", + "slots__day__added_rooms", + "slots__day__added_rooms__room", + django_models.Prefetch("slots__items", queryset=schedule_items), + ), + ) diff --git a/backend/api/schedule/types/day.py b/backend/api/schedule/types/day.py index ec54ec83dd..748e74715d 100644 --- a/backend/api/schedule/types/day.py +++ b/backend/api/schedule/types/day.py @@ -2,16 +2,19 @@ from schedule.models import ScheduleItem as ScheduleItemModel from api.schedule.types.schedule_item import ScheduleItem from django.utils import timezone -from datetime import date, timedelta +from datetime import timedelta from api.schedule.types.day_room import DayRoom from api.schedule.types.slot import ScheduleSlot import strawberry +import strawberry_django +from schedule import models +from strawberry import auto -@strawberry.type +@strawberry_django.type(models.Day) class Day: - id: strawberry.ID - day: date + id: auto + day: auto @strawberry.field def random_events(self, limit: int = 4) -> list[ScheduleItem]: diff --git a/backend/api/schedule/types/room.py b/backend/api/schedule/types/room.py index bbbab9a2e2..e0b4d6527e 100644 --- a/backend/api/schedule/types/room.py +++ b/backend/api/schedule/types/room.py @@ -1,8 +1,10 @@ -import strawberry +import strawberry_django +from schedule import models +from strawberry import auto -@strawberry.type +@strawberry_django.type(models.Room) class Room: - id: strawberry.ID - name: str + id: auto + name: auto type: str diff --git a/backend/api/schedule/types/schedule_item.py b/backend/api/schedule/types/schedule_item.py index 1ddf847dae..3ab5194ea6 100644 --- a/backend/api/schedule/types/schedule_item.py +++ b/backend/api/schedule/types/schedule_item.py @@ -1,4 +1,13 @@ from api.context import Info +from api.schedule.optimization import ( + ATTENDEES_COUNT_ANNOTATION, + CAPACITY_ANNOTATION, + USER_HAS_SPOT_ANNOTATION, + attendees_count_annotation, + capacity_annotation, + schedule_item_speakers, + user_has_spot_annotation, +) from api.participants.types import Participant from participants.models import Participant as ParticipantModel from typing import TYPE_CHECKING @@ -9,32 +18,47 @@ from api.schedule.types.schedule_item_user import ScheduleItemUser from api.submissions.types import Submission import strawberry +import strawberry_django +from schedule import models +from strawberry import auto from api.schedule.types.room import Room if TYPE_CHECKING: # pragma: no cover from api.conferences.types import AudienceLevel, Conference, Keynote -@strawberry.type +def _capacity(schedule_item) -> int | None: + if CAPACITY_ANNOTATION in schedule_item.__dict__: + return getattr(schedule_item, CAPACITY_ANNOTATION) + return schedule_item.actual_attendees_total_capacity + + +def _attendees_count(schedule_item) -> int: + if ATTENDEES_COUNT_ANNOTATION in schedule_item.__dict__: + return getattr(schedule_item, ATTENDEES_COUNT_ANNOTATION) + return schedule_item.attendees.count() + + +@strawberry_django.type(models.ScheduleItem) class ScheduleItem: - id: strawberry.ID + id: auto conference: Annotated["Conference", strawberry.lazy("api.conferences.types")] - title: str + title: auto start: datetime end: datetime - status: str + status: auto submission: Submission | None slug: str - description: str + description: auto type: str - duration: int | None + duration: auto highlight_color: str | None language: Language audience_level: ( Annotated["AudienceLevel", strawberry.lazy("api.conferences.types")] | None ) youtube_video_id: str | None - link_to: str + link_to: auto abstract: str elevator_pitch: str @@ -43,26 +67,45 @@ class ScheduleItem: ) livestreaming_room: Room | None - @strawberry.field + @strawberry_django.field( + annotate={CAPACITY_ANNOTATION: capacity_annotation}, + ) def has_limited_capacity(self) -> bool: - return self.actual_attendees_total_capacity is not None + return _capacity(self) is not None - @strawberry.field + @strawberry_django.field( + annotate={ + ATTENDEES_COUNT_ANNOTATION: attendees_count_annotation, + CAPACITY_ANNOTATION: capacity_annotation, + }, + ) def has_spaces_left(self) -> bool: - if self.actual_attendees_total_capacity is None: + capacity = _capacity(self) + if capacity is None: return True - return self.actual_attendees_total_capacity - self.attendees.count() > 0 + return capacity - _attendees_count(self) > 0 - @strawberry.field + @strawberry_django.field( + annotate={ + ATTENDEES_COUNT_ANNOTATION: attendees_count_annotation, + CAPACITY_ANNOTATION: capacity_annotation, + }, + ) def spaces_left(self) -> int: - if self.actual_attendees_total_capacity is None: + capacity = _capacity(self) + if capacity is None: return 0 - return self.actual_attendees_total_capacity - self.attendees.count() + return capacity - _attendees_count(self) - @strawberry.field + @strawberry_django.field( + annotate={USER_HAS_SPOT_ANNOTATION: user_has_spot_annotation}, + ) def user_has_spot(self, info: Info) -> bool: + if USER_HAS_SPOT_ANNOTATION in self.__dict__: + return getattr(self, USER_HAS_SPOT_ANNOTATION) + user_id = info.context.request.user.id return self.attendees.filter(user_id=user_id).exists() @@ -83,12 +126,14 @@ def speakers(self, info: Info) -> list[ScheduleItemUser]: participants_data = { participant.user_id: participant for participant in ParticipantModel.objects.filter( - user_id__in=[speaker.id for speaker in self.speakers], + user_id__in=[ + speaker.id for speaker in schedule_item_speakers(self) + ], conference_id=self.conference_id, ) } - for speaker in self.speakers: + for speaker in schedule_item_speakers(self): speakers.append( ScheduleItemUser( id=speaker.id, diff --git a/backend/api/schedule/types/slot.py b/backend/api/schedule/types/slot.py index e0073ae0ec..959ccb596d 100644 --- a/backend/api/schedule/types/slot.py +++ b/backend/api/schedule/types/slot.py @@ -8,6 +8,9 @@ import strawberry +import strawberry_django +from schedule import models +from strawberry import auto @strawberry.enum @@ -17,11 +20,11 @@ class ScheduleSlotType(Enum): BREAK = "break" -@strawberry.type +@strawberry_django.type(models.Slot) class ScheduleSlot: - id: strawberry.ID - hour: time - duration: int + id: auto + hour: auto + duration: auto type: ScheduleSlotType @strawberry.field diff --git a/backend/api/tests/schema/conference/test_days.py b/backend/api/tests/schema/conference/test_days.py index 49b05f2857..71cbb7c631 100644 --- a/backend/api/tests/schema/conference/test_days.py +++ b/backend/api/tests/schema/conference/test_days.py @@ -4,9 +4,11 @@ from schedule.tests.factories import ( DayFactory, RoomFactory, + ScheduleItemAttendeeFactory, ScheduleItemFactory, SlotFactory, ) +from users.tests.factories import UserFactory from pytest import mark from pycon.constants import UTC @@ -197,3 +199,130 @@ def test_filter_days_by_room_not_found(graphql_client): assert "errors" not in resp assert len(resp["data"]["conference"]["days"][0]["slots"]) == 0 + + +@mark.parametrize("item_count", [1, 4]) +@mark.django_db +def test_schedule_capacity_query_is_constant( + graphql_client, django_assert_num_queries, item_count +): + conference = ConferenceFactory( + start=datetime(2020, 4, 2, tzinfo=UTC), + end=datetime(2020, 4, 2, tzinfo=UTC), + ) + day = DayFactory(conference=conference, day=date(2020, 4, 2)) + slot = SlotFactory(day=day, hour=time(8, 45), duration=60) + room = RoomFactory(attendees_total_capacity=20) + items = [ + ScheduleItemFactory( + conference=conference, + slot=slot, + submission=None, + type="custom", + rooms=[room], + ) + for _ in range(item_count) + ] + user = UserFactory() + ScheduleItemAttendeeFactory(schedule_item=items[0], user=user) + graphql_client.force_login(user) + + with django_assert_num_queries(11): + resp = graphql_client.query( + """ + query($code: String!, $language: String!) { + conference(code: $code) { + id + timezone + days { + day + rooms { + id + name + type + } + slots { + id + hour + endHour + duration + type + items { + id + title + slug + type + duration + hasLimitedCapacity + hasSpacesLeft + spacesLeft + userHasSpot + linkTo + audienceLevel { + id + name + } + language { + id + name + code + } + submission { + id + title(language: $language) + } + keynote { + id + title(language: "en") + } + speakers { + id + fullname + participant { + id + photo + } + } + rooms { + id + name + type + } + } + } + } + } + } + """, + variables={"code": conference.code, "language": "en"}, + ) + + assert "errors" not in resp + schedule_items = resp["data"]["conference"]["days"][0]["slots"][0]["items"] + capacity_fields = { + "id", + "hasLimitedCapacity", + "hasSpacesLeft", + "spacesLeft", + "userHasSpot", + } + assert {field: schedule_items[0][field] for field in capacity_fields} == { + "id": str(items[0].id), + "hasLimitedCapacity": True, + "hasSpacesLeft": True, + "spacesLeft": 19, + "userHasSpot": True, + } + assert [ + {field: schedule_item[field] for field in capacity_fields} + for schedule_item in schedule_items[1:] + ] == [ + { + "id": str(item.id), + "hasLimitedCapacity": True, + "hasSpacesLeft": True, + "spacesLeft": 20, + "userHasSpot": False, + } + for item in items[1:] + ] From d1e01d70821deb116a102f677d88040e3e0cb689 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Thu, 13 Aug 2026 21:52:05 +0200 Subject: [PATCH 2/3] Let Strawberry Django optimize conference queries --- backend/api/conferences/schema.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/backend/api/conferences/schema.py b/backend/api/conferences/schema.py index cfb62f3c2f..af004c10f1 100644 --- a/backend/api/conferences/schema.py +++ b/backend/api/conferences/schema.py @@ -1,21 +1,12 @@ -from api.context import Info from api.conferences.types import Conference import strawberry import strawberry_django -from strawberry_django.optimizer import OptimizerConfig, optimize from conferences import models @strawberry.type class ConferenceQuery: - @strawberry_django.field - def conference(self, info: Info, code: str) -> Conference: - conferences = models.Conference.objects.filter(code=code).prefetch_related( - "durations" - ) - return optimize( - conferences, - info, - config=OptimizerConfig(enable_only=False), - ).get() + @strawberry_django.field(prefetch_related=["durations"]) + def conference(self, code: str) -> Conference: + return models.Conference.objects.filter(code=code) From c2f201efa2d6873587af5ec4d7db94f27d7b6b98 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Thu, 13 Aug 2026 22:22:05 +0200 Subject: [PATCH 3/3] Delegate schedule queries to Strawberry Django optimizer --- backend/api/conferences/schema.py | 2 +- .../api/conferences/tests/test_query_talk.py | 32 ++-- backend/api/conferences/types.py | 51 ++---- backend/api/schedule/optimization.py | 137 ---------------- backend/api/schedule/types/day.py | 13 +- backend/api/schedule/types/room.py | 6 +- backend/api/schedule/types/schedule_item.py | 155 +++++++++++++----- backend/api/schedule/types/slot.py | 12 +- .../schema/conference/test_conference.py | 80 ++++----- .../api/tests/schema/conference/test_days.py | 109 +++++++++--- backend/schema.graphql | 6 +- 11 files changed, 298 insertions(+), 305 deletions(-) delete mode 100644 backend/api/schedule/optimization.py diff --git a/backend/api/conferences/schema.py b/backend/api/conferences/schema.py index af004c10f1..5be3d825b4 100644 --- a/backend/api/conferences/schema.py +++ b/backend/api/conferences/schema.py @@ -7,6 +7,6 @@ @strawberry.type class ConferenceQuery: - @strawberry_django.field(prefetch_related=["durations"]) + @strawberry_django.field def conference(self, code: str) -> Conference: return models.Conference.objects.filter(code=code) diff --git a/backend/api/conferences/tests/test_query_talk.py b/backend/api/conferences/tests/test_query_talk.py index 26d6b7bbfe..2c36e9924b 100644 --- a/backend/api/conferences/tests/test_query_talk.py +++ b/backend/api/conferences/tests/test_query_talk.py @@ -144,23 +144,29 @@ def test_abstract_shows_keynote_description(graphql_client, user): } -def test_fetch_schedule_talk(simple_schedule_item, graphql_client, user): +def test_fetch_schedule_talk( + simple_schedule_item, graphql_client, user, django_assert_num_queries +): graphql_client.force_login(user) schedule_item = simple_schedule_item - response = graphql_client.query( - """query($slug: String!, $code: String!) { - conference(code: $code) { - talk(slug: $slug) { - youtubeVideoId - userHasSpot - hasSpacesLeft - spacesLeft + with django_assert_num_queries(4): + response = graphql_client.query( + """query($slug: String!, $code: String!) { + conference(code: $code) { + talk(slug: $slug) { + youtubeVideoId + userHasSpot + hasSpacesLeft + spacesLeft + } } - } - }""", - variables={"slug": schedule_item.slug, "code": schedule_item.conference.code}, - ) + }""", + variables={ + "slug": schedule_item.slug, + "code": schedule_item.conference.code, + }, + ) assert response["data"]["conference"]["talk"] == { "youtubeVideoId": "AbCdEfGhIjK", diff --git a/backend/api/conferences/types.py b/backend/api/conferences/types.py index 761a4dcba2..622cc2eebf 100644 --- a/backend/api/conferences/types.py +++ b/backend/api/conferences/types.py @@ -16,7 +16,6 @@ from api.languages.types import Language from api.pretix.query import get_conference_tickets, get_voucher from api.pretix.types import TicketItem, Voucher -from api.schedule.optimization import schedule_days_prefetch, schedule_item_speakers from api.schedule.types import Room, ScheduleItem, ScheduleItemUser from api.sponsors.types import ( SponsorBenefit, @@ -140,25 +139,29 @@ def youtube_video_id(self) -> str | None: @strawberry_django.type(conference_models.Conference) class Conference: - id: auto + id: strawberry.auto - name: str = strawberry.field(resolver=make_localized_resolver("name")) - introduction: str = strawberry.field( - resolver=make_localized_resolver("introduction") + name: str = strawberry_django.field( + resolver=make_localized_resolver("name"), only=["name"] ) - code: auto - hostname: auto + introduction: str = strawberry_django.field( + resolver=make_localized_resolver("introduction"), only=["introduction"] + ) + code: strawberry.auto + hostname: strawberry.auto start: datetime end: datetime map: Map | None = strawberry.field(resolver=resolve_map) - pretix_event_url: auto + pretix_event_url: strawberry.auto - @strawberry.field + @strawberry_django.field( + only=["pretix_organizer_id", "pretix_event_id"], + ) def voucher(self, info: Info, code: str) -> Voucher | None: return get_voucher(self, code) - @strawberry.field + @strawberry_django.field(only=["timezone"]) def timezone(self, info: Info) -> str: return str(self.timezone) @@ -206,9 +209,7 @@ def topics(self, info: Info) -> list[Topic]: def languages(self, info: Info) -> list[Language]: return self.languages.all() - @strawberry.field - def durations(self, info: Info) -> list["Duration"]: - return self.durations.all() + durations: list["Duration"] @strawberry.field def submission_types(self, info: Info) -> list[SubmissionType]: @@ -271,9 +272,9 @@ def talks(self, info: Info) -> list[ScheduleItem]: .all() ) - @strawberry.field + @strawberry_django.field def talk(self, info: Info, slug: str) -> ScheduleItem | None: - return self.schedule_items.filter(slug=slug).prefetch_related("rooms").first() + return self.schedule_items.filter(slug=slug) @strawberry.field def ranking(self, info: Info, topic: strawberry.ID) -> RankRequest | None: @@ -295,25 +296,9 @@ def ranking(self, info: Info, topic: strawberry.ID) -> RankRequest | None: stats=rank_request.stats.all(), ) - @strawberry_django.field(prefetch_related=[schedule_days_prefetch]) + @strawberry_django.field def days(self, info: Info) -> list[Day]: - days = list(self.days.all()) - all_speakers = [ - speaker.id - for day in days - for slot in day.slots.all() - for item in slot.items.all() - for speaker in schedule_item_speakers(item) - ] - info.context._participants_data = { - participant.user_id: participant - for participant in ParticipantModel.objects.for_conference(self.id) - .filter(user_id__in=all_speakers) - .prefetch_related("user") - .all() - } - - return days + return self.days.all() @strawberry.field def current_day(self, info: Info) -> Day | None: diff --git a/backend/api/schedule/optimization.py b/backend/api/schedule/optimization.py deleted file mode 100644 index c4d37526aa..0000000000 --- a/backend/api/schedule/optimization.py +++ /dev/null @@ -1,137 +0,0 @@ -from django.db import models as django_models -from django.db.models.functions import Coalesce - -from api.context import Info -from conferences import models as conference_models -from schedule import models - - -ATTENDEES_COUNT_ANNOTATION = "graphql_attendees_count" -CAPACITY_ANNOTATION = "graphql_attendees_total_capacity" -USER_HAS_SPOT_ANNOTATION = "graphql_user_has_spot" - - -def attendees_count_annotation(_info: Info): - return django_models.Count("attendees", distinct=True) - - -def capacity_annotation(_info: Info): - room_capacity = ( - models.Room.objects.filter(talks=django_models.OuterRef("pk")) - .order_by("pk") - .values("attendees_total_capacity")[:1] - ) - return Coalesce( - "attendees_total_capacity", - django_models.Subquery(room_capacity), - output_field=django_models.PositiveIntegerField(), - ) - - -def user_has_spot_annotation(info: Info): - user_id = info.context.request.user.id - if not user_id: - return django_models.Value(False, output_field=django_models.BooleanField()) - - return django_models.Exists( - models.ScheduleItemAttendee.objects.filter( - schedule_item_id=django_models.OuterRef("pk"), - user_id=user_id, - ) - ) - - -def attendance_annotations(info: Info): - return { - ATTENDEES_COUNT_ANNOTATION: attendees_count_annotation(info), - CAPACITY_ANNOTATION: capacity_annotation(info), - USER_HAS_SPOT_ANNOTATION: user_has_spot_annotation(info), - } - - -def schedule_item_speakers(schedule_item): - speakers = [] - - if schedule_item.submission_id: - speakers.append(schedule_item.submission.speaker) - - if schedule_item.keynote_id: - keynote_speakers = getattr( - schedule_item.keynote, - "graphql_speakers", - None, - ) - if keynote_speakers is None: - keynote_speakers = schedule_item.keynote.speakers.order_by("id").all() - speakers.extend(speaker.user for speaker in keynote_speakers) - - additional_speakers = getattr( - schedule_item, - "graphql_additional_speakers", - None, - ) - if additional_speakers is None: - additional_speakers = schedule_item.additional_speakers.order_by("id").all() - speakers.extend(speaker.user for speaker in additional_speakers) - - return [speaker for speaker in speakers if speaker is not None] - - -def schedule_days_prefetch(info: Info): - schedule_items = ( - models.ScheduleItem.objects.annotate( - order=django_models.Case( - django_models.When(type="custom", then=django_models.Value(1)), - django_models.When(type="break", then=django_models.Value(1)), - django_models.When(type="talk", then=django_models.Value(2)), - django_models.When(type="panel", then=django_models.Value(3)), - default=django_models.Value(4), - output_field=django_models.IntegerField(), - ), - **attendance_annotations(info), - ) - .order_by("order") - .prefetch_related( - "audience_level", - "language", - "rooms", - django_models.Prefetch( - "additional_speakers", - queryset=models.ScheduleItemAdditionalSpeaker.objects.order_by( - "id" - ).select_related("user"), - to_attr="graphql_additional_speakers", - ), - "submission", - "submission__type", - "submission__tags", - "submission__duration", - "submission__audience_level", - "submission__speaker", - "submission__languages", - "submission__schedule_items", - "keynote", - "keynote__schedule_items", - "keynote__schedule_items__rooms", - "keynote__schedule_items__slot", - "keynote__schedule_items__slot__day", - django_models.Prefetch( - "keynote__speakers", - queryset=conference_models.KeynoteSpeaker.objects.order_by( - "id" - ).select_related("user"), - to_attr="graphql_speakers", - ), - ) - ) - - return django_models.Prefetch( - "days", - queryset=models.Day.objects.order_by("day").prefetch_related( - "slots", - "slots__day", - "slots__day__added_rooms", - "slots__day__added_rooms__room", - django_models.Prefetch("slots__items", queryset=schedule_items), - ), - ) diff --git a/backend/api/schedule/types/day.py b/backend/api/schedule/types/day.py index 748e74715d..ca44e6bcd9 100644 --- a/backend/api/schedule/types/day.py +++ b/backend/api/schedule/types/day.py @@ -8,13 +8,12 @@ import strawberry import strawberry_django from schedule import models -from strawberry import auto @strawberry_django.type(models.Day) class Day: - id: auto - day: auto + id: strawberry.auto + day: strawberry.auto @strawberry.field def random_events(self, limit: int = 4) -> list[ScheduleItem]: @@ -34,13 +33,13 @@ def random_events(self, limit: int = 4) -> list[ScheduleItem]: .order_by("?")[:limit] ) - @strawberry.field + @strawberry_django.field def slots( self, info: Info, room: strawberry.ID | None = None ) -> list[ScheduleSlot]: if room: - return list(self.slots.filter(items__rooms__id=room)) - return list(self.slots.all()) + return self.slots.filter(items__rooms__id=room) + return self.slots.all() @strawberry.field def running_events(self, info: Info) -> list[ScheduleItem]: @@ -62,7 +61,7 @@ def running_events(self, info: Info) -> list[ScheduleItem]: return [item for item in current_slot.items.all()] - @strawberry.field + @strawberry_django.field(prefetch_related=["added_rooms__room"]) def rooms(self) -> list[DayRoom]: added_rooms = self.added_rooms.all() return [ diff --git a/backend/api/schedule/types/room.py b/backend/api/schedule/types/room.py index e0b4d6527e..4454dc8a20 100644 --- a/backend/api/schedule/types/room.py +++ b/backend/api/schedule/types/room.py @@ -1,10 +1,10 @@ +import strawberry import strawberry_django from schedule import models -from strawberry import auto @strawberry_django.type(models.Room) class Room: - id: auto - name: auto + id: strawberry.auto + name: strawberry.auto type: str diff --git a/backend/api/schedule/types/schedule_item.py b/backend/api/schedule/types/schedule_item.py index 3ab5194ea6..40909dedaf 100644 --- a/backend/api/schedule/types/schedule_item.py +++ b/backend/api/schedule/types/schedule_item.py @@ -1,13 +1,4 @@ from api.context import Info -from api.schedule.optimization import ( - ATTENDEES_COUNT_ANNOTATION, - CAPACITY_ANNOTATION, - USER_HAS_SPOT_ANNOTATION, - attendees_count_annotation, - capacity_annotation, - schedule_item_speakers, - user_has_spot_annotation, -) from api.participants.types import Participant from participants.models import Participant as ParticipantModel from typing import TYPE_CHECKING @@ -19,46 +10,84 @@ from api.submissions.types import Submission import strawberry import strawberry_django +from django.db import models as django_models +from django.db.models.functions import Coalesce from schedule import models -from strawberry import auto from api.schedule.types.room import Room if TYPE_CHECKING: # pragma: no cover from api.conferences.types import AudienceLevel, Conference, Keynote +ATTENDEES_COUNT_ANNOTATION = "graphql_attendees_count" +CAPACITY_ANNOTATION = "graphql_attendees_total_capacity" +USER_HAS_SPOT_ANNOTATION = "graphql_user_has_spot" + + +def attendees_count_annotation(_info: Info): + return django_models.Count("attendees", distinct=True) + + +def capacity_annotation(_info: Info): + room_capacity = ( + models.Room.objects.filter(talks=django_models.OuterRef("pk")) + .order_by("pk") + .values("attendees_total_capacity")[:1] + ) + return Coalesce( + "attendees_total_capacity", + django_models.Subquery(room_capacity), + output_field=django_models.PositiveIntegerField(), + ) + + +def user_has_spot_annotation(info: Info): + user_id = info.context.request.user.id + if not user_id: + return django_models.Value(False, output_field=django_models.BooleanField()) + + return django_models.Exists( + models.ScheduleItemAttendee.objects.filter( + schedule_item_id=django_models.OuterRef("pk"), + user_id=user_id, + ) + ) + + def _capacity(schedule_item) -> int | None: - if CAPACITY_ANNOTATION in schedule_item.__dict__: - return getattr(schedule_item, CAPACITY_ANNOTATION) - return schedule_item.actual_attendees_total_capacity + try: + return schedule_item.graphql_attendees_total_capacity + except AttributeError: + return schedule_item.actual_attendees_total_capacity def _attendees_count(schedule_item) -> int: - if ATTENDEES_COUNT_ANNOTATION in schedule_item.__dict__: - return getattr(schedule_item, ATTENDEES_COUNT_ANNOTATION) - return schedule_item.attendees.count() + try: + return schedule_item.graphql_attendees_count + except AttributeError: + return schedule_item.attendees.count() @strawberry_django.type(models.ScheduleItem) class ScheduleItem: - id: auto + id: strawberry.auto conference: Annotated["Conference", strawberry.lazy("api.conferences.types")] - title: auto + title: strawberry.auto start: datetime end: datetime - status: auto + status: strawberry.auto submission: Submission | None slug: str - description: auto + description: strawberry.auto type: str - duration: auto + duration: strawberry.auto highlight_color: str | None language: Language audience_level: ( Annotated["AudienceLevel", strawberry.lazy("api.conferences.types")] | None ) youtube_video_id: str | None - link_to: auto + link_to: strawberry.auto abstract: str elevator_pitch: str @@ -67,6 +96,19 @@ class ScheduleItem: ) livestreaming_room: Room | None + @classmethod + def get_queryset(cls, queryset, info: Info): + return queryset.annotate( + graphql_order=django_models.Case( + django_models.When(type="custom", then=django_models.Value(1)), + django_models.When(type="break", then=django_models.Value(1)), + django_models.When(type="talk", then=django_models.Value(2)), + django_models.When(type="panel", then=django_models.Value(3)), + default=django_models.Value(4), + output_field=django_models.IntegerField(), + ) + ).order_by("graphql_order") + @strawberry_django.field( annotate={CAPACITY_ANNOTATION: capacity_annotation}, ) @@ -103,11 +145,11 @@ def spaces_left(self) -> int: annotate={USER_HAS_SPOT_ANNOTATION: user_has_spot_annotation}, ) def user_has_spot(self, info: Info) -> bool: - if USER_HAS_SPOT_ANNOTATION in self.__dict__: - return getattr(self, USER_HAS_SPOT_ANNOTATION) - - user_id = info.context.request.user.id - return self.attendees.filter(user_id=user_id).exists() + try: + return self.graphql_user_has_spot + except AttributeError: + user_id = info.context.request.user.id + return self.attendees.filter(user_id=user_id).exists() @strawberry.field def user_is_talk_manager(self, info: Info) -> bool: @@ -116,24 +158,56 @@ def user_is_talk_manager(self, info: Info) -> bool: return self.talk_manager_id == user_id - @strawberry.field + @strawberry_django.field( + only=["conference_id"], + select_related=["submission__speaker"], + prefetch_related=[ + "keynote__speakers__user", + "additional_speakers__user", + ], + ) def speakers(self, info: Info) -> list[ScheduleItemUser]: speakers = [] - # TODO: Find a better solution participants_data = info.context._participants_data - if not participants_data: + if participants_data is None: + schedule_items = models.ScheduleItem.objects.filter( + conference_id=self.conference_id + ) + submission_speakers = schedule_items.values("submission__speaker_id") + keynote_speakers = schedule_items.values("keynote__speakers__user_id") + additional_speakers = schedule_items.values("additional_speakers__user_id") participants_data = { participant.user_id: participant for participant in ParticipantModel.objects.filter( - user_id__in=[ - speaker.id for speaker in schedule_item_speakers(self) - ], - conference_id=self.conference_id, + conference_id=self.conference_id ) + .filter( + django_models.Q(user_id__in=submission_speakers) + | django_models.Q(user_id__in=keynote_speakers) + | django_models.Q(user_id__in=additional_speakers) + ) + .select_related("user") } + info.context._participants_data = participants_data + + schedule_item_speakers = [] + if self.submission_id: + schedule_item_speakers.append(self.submission.speaker) + + if self.keynote_id: + schedule_item_speakers.extend( + speaker.user for speaker in self.keynote.speakers.all() + ) + + schedule_item_speakers.extend( + speaker.user for speaker in self.additional_speakers.all() + ) + + for speaker in schedule_item_speakers: + if speaker is None: + continue - for speaker in schedule_item_speakers(self): speakers.append( ScheduleItemUser( id=speaker.id, @@ -147,18 +221,13 @@ def speakers(self, info: Info) -> list[ScheduleItemUser]: return speakers - @strawberry.field + @strawberry_django.field(select_related=["keynote"]) def keynote( self, info: Info ) -> Annotated["Keynote", strawberry.lazy("api.conferences.types")] | None: - if not self.keynote_id: - return None - - return self.keynote + return self.keynote if self.keynote_id else None - @strawberry.field - def rooms(self, info: Info) -> list[Room]: - return self.rooms.all() + rooms: list[Room] @strawberry.field def image(self, info: Info) -> str | None: diff --git a/backend/api/schedule/types/slot.py b/backend/api/schedule/types/slot.py index 959ccb596d..131cf7fed9 100644 --- a/backend/api/schedule/types/slot.py +++ b/backend/api/schedule/types/slot.py @@ -3,14 +3,12 @@ from django.utils import timezone -from api.context import Info from api.schedule.types.schedule_item import ScheduleItem import strawberry import strawberry_django from schedule import models -from strawberry import auto @strawberry.enum @@ -22,9 +20,9 @@ class ScheduleSlotType(Enum): @strawberry_django.type(models.Slot) class ScheduleSlot: - id: auto - hour: auto - duration: auto + id: strawberry.auto + hour: strawberry.auto + duration: strawberry.auto type: ScheduleSlotType @strawberry.field @@ -43,6 +41,4 @@ def end_hour(self) -> time: + timedelta(minutes=self.duration) ).time() - @strawberry.field - def items(self, info: Info) -> list[ScheduleItem]: - return self.items.all() + items: list[ScheduleItem] diff --git a/backend/api/tests/schema/conference/test_conference.py b/backend/api/tests/schema/conference/test_conference.py index d22c0f5368..b4db1fa746 100644 --- a/backend/api/tests/schema/conference/test_conference.py +++ b/backend/api/tests/schema/conference/test_conference.py @@ -23,21 +23,22 @@ pytestmark = mark.django_db -def test_get_conference_info(graphql_client): +def test_get_conference_info(graphql_client, django_assert_num_queries): conference = ConferenceFactory() - resp = graphql_client.query( - """ - query($code: String!) { - conference(code: $code) { - id - code - name - introduction + with django_assert_num_queries(1): + resp = graphql_client.query( + """ + query($code: String!) { + conference(code: $code) { + id + code + name + introduction + } } - } - """, - variables={"code": conference.code}, - ) + """, + variables={"code": conference.code}, + ) assert "errors" not in resp assert { @@ -345,6 +346,7 @@ def test_query_conference_languages(graphql_client, language): def test_get_conference_durations( graphql_client, + django_assert_num_queries, ): talk_type = SubmissionTypeFactory(name="talk") tutorial_type = SubmissionTypeFactory(name="tutorial") @@ -356,25 +358,26 @@ def test_get_conference_durations( conference = d1.conference - resp = graphql_client.query( - """ - query($code: String!) { - conference(code: $code) { - durations { - id - name - duration - notes - allowedSubmissionTypes { + with django_assert_num_queries(3): + resp = graphql_client.query( + """ + query($code: String!) { + conference(code: $code) { + durations { id name + duration + notes + allowedSubmissionTypes { + id + name + } } } } - } - """, - variables={"code": conference.code}, - ) + """, + variables={"code": conference.code}, + ) assert "errors" not in resp assert { @@ -654,23 +657,26 @@ def test_can_see_submissions_if_they_have_sent_one(graphql_client): assert len(response["data"]["conference"]["submissions"]) == 2 -def test_get_conference_voucher_with_invalid_code(graphql_client, requests_mock): +def test_get_conference_voucher_with_invalid_code( + graphql_client, requests_mock, django_assert_num_queries +): conference = ConferenceFactory() requests_mock.get( "https://pretix/api/organizers/base-pretix-organizer-id/events/base-pretix-event-id/extended-vouchers/test/", status_code=404, ) - response = graphql_client.query( - """query($code: String!, $voucherCode: String!) { - conference(code: $code) { - voucher(code: $voucherCode) { - id + with django_assert_num_queries(1): + response = graphql_client.query( + """query($code: String!, $voucherCode: String!) { + conference(code: $code) { + voucher(code: $voucherCode) { + id + } } - } - }""", - variables={"code": conference.code, "voucherCode": "test"}, - ) + }""", + variables={"code": conference.code, "voucherCode": "test"}, + ) assert response["data"]["conference"]["voucher"] is None diff --git a/backend/api/tests/schema/conference/test_days.py b/backend/api/tests/schema/conference/test_days.py index 71cbb7c631..5a5173144e 100644 --- a/backend/api/tests/schema/conference/test_days.py +++ b/backend/api/tests/schema/conference/test_days.py @@ -1,6 +1,11 @@ from datetime import date, datetime, time -from conferences.tests.factories import ConferenceFactory +from conferences.tests.factories import ( + ConferenceFactory, + KeynoteFactory, + KeynoteSpeakerFactory, +) +from participants.tests.factories import ParticipantFactory from schedule.tests.factories import ( DayFactory, RoomFactory, @@ -93,9 +98,8 @@ def test_days_item_sorted(graphql_client): day = DayFactory(conference=conference, day=date(2020, 4, 2)) slot = SlotFactory(day=day, hour=time(8, 45), duration=60) - slot_2 = SlotFactory(day=day, hour=time(9, 45), duration=60) - ScheduleItemFactory(conference=conference, slot=slot, type="custom") - ScheduleItemFactory(conference=conference, slot=slot_2, image=None, type="talk") + ScheduleItemFactory(conference=conference, slot=slot, type="talk") + ScheduleItemFactory(conference=conference, slot=slot, image=None, type="custom") resp = graphql_client.query( """ @@ -118,8 +122,7 @@ def test_days_item_sorted(graphql_client): assert "errors" not in resp slots = resp["data"]["conference"]["days"][0]["slots"] - assert slots[0]["items"][0]["type"] == "custom" - assert slots[1]["items"][0]["type"] == "talk" + assert [item["type"] for item in slots[0]["items"]] == ["custom", "talk"] @mark.django_db @@ -201,33 +204,89 @@ def test_filter_days_by_room_not_found(graphql_client): assert len(resp["data"]["conference"]["days"][0]["slots"]) == 0 +@mark.django_db +def test_anonymous_user_does_not_have_schedule_spot(graphql_client): + conference = ConferenceFactory() + day = DayFactory(conference=conference) + slot = SlotFactory(day=day, hour=time(8, 45), duration=60) + ScheduleItemFactory( + conference=conference, + slot=slot, + submission=None, + type="custom", + ) + + resp = graphql_client.query( + """ + query($code: String!) { + conference(code: $code) { + days { + slots { + items { + userHasSpot + } + } + } + } + } + """, + variables={"code": conference.code}, + ) + + assert "errors" not in resp + assert resp["data"]["conference"]["days"][0]["slots"][0]["items"] == [ + {"userHasSpot": False} + ] + + @mark.parametrize("item_count", [1, 4]) +@mark.parametrize("item_source", ["submission", "keynote"]) @mark.django_db def test_schedule_capacity_query_is_constant( - graphql_client, django_assert_num_queries, item_count + graphql_client, django_assert_num_queries, item_count, item_source ): conference = ConferenceFactory( start=datetime(2020, 4, 2, tzinfo=UTC), end=datetime(2020, 4, 2, tzinfo=UTC), ) day = DayFactory(conference=conference, day=date(2020, 4, 2)) - slot = SlotFactory(day=day, hour=time(8, 45), duration=60) - room = RoomFactory(attendees_total_capacity=20) - items = [ - ScheduleItemFactory( - conference=conference, - slot=slot, - submission=None, - type="custom", - rooms=[room], - ) - for _ in range(item_count) + slots = [ + SlotFactory(day=day, hour=time(8 + index, 45), duration=60) + for index in range(item_count) ] + room = RoomFactory(attendees_total_capacity=20) + items = [] + speakers = [] + for index in range(item_count): + if item_source == "keynote": + keynote = KeynoteFactory(conference=conference) + speaker = KeynoteSpeakerFactory(keynote=keynote).user + item = ScheduleItemFactory( + conference=conference, + slot=slots[index], + type="keynote", + submission=None, + keynote=keynote, + rooms=[room], + ) + else: + item = ScheduleItemFactory( + conference=conference, + slot=slots[index], + type="submission", + rooms=[room], + ) + speaker = item.submission.speaker + + ParticipantFactory(conference=conference, user=speaker) + items.append(item) + speakers.append(speaker) user = UserFactory() ScheduleItemAttendeeFactory(schedule_item=items[0], user=user) graphql_client.force_login(user) - with django_assert_num_queries(11): + expected_queries = 12 if item_source == "keynote" else 10 + with django_assert_num_queries(expected_queries): resp = graphql_client.query( """ query($code: String!, $language: String!) { @@ -298,7 +357,11 @@ def test_schedule_capacity_query_is_constant( ) assert "errors" not in resp - schedule_items = resp["data"]["conference"]["days"][0]["slots"][0]["items"] + schedule_items = [ + item + for slot in resp["data"]["conference"]["days"][0]["slots"] + for item in slot["items"] + ] capacity_fields = { "id", "hasLimitedCapacity", @@ -326,3 +389,9 @@ def test_schedule_capacity_query_is_constant( } for item in items[1:] ] + assert [schedule_item["speakers"][0]["id"] for schedule_item in schedule_items] == [ + str(speaker.id) for speaker in speakers + ] + assert all( + schedule_item["speakers"][0]["participant"] for schedule_item in schedule_items + ) diff --git a/backend/schema.graphql b/backend/schema.graphql index 0c9cf4ebe6..6865a0c649 100644 --- a/backend/schema.graphql +++ b/backend/schema.graphql @@ -196,6 +196,7 @@ type Conference { end: DateTime! map: Map pretixEventUrl: String! + durations: [Duration!]! voucher(code: String!): Voucher timezone: String! tickets(language: String!, showUnavailableTickets: Boolean! = false): [TicketItem!]! @@ -208,7 +209,6 @@ type Conference { audienceLevels: [AudienceLevel!]! topics: [Topic!]! languages: [Language!]! - durations: [Duration!]! submissionTypes: [SubmissionType!]! proposalTags: [SubmissionTag!]! submissions: [Submission!] @@ -1140,6 +1140,7 @@ type ScheduleItem { elevatorPitch: String! talkManager: ScheduleItemUser livestreamingRoom: Room + rooms: [Room!]! hasLimitedCapacity: Boolean! hasSpacesLeft: Boolean! spacesLeft: Int! @@ -1147,7 +1148,6 @@ type ScheduleItem { userIsTalkManager: Boolean! speakers: [ScheduleItemUser!]! keynote: Keynote - rooms: [Room!]! image: String slidoUrl: String! } @@ -1179,9 +1179,9 @@ type ScheduleSlot { hour: Time! duration: Int! type: ScheduleSlotType! + items: [ScheduleItem!]! isLive: Boolean! endHour: Time! - items: [ScheduleItem!]! } enum ScheduleSlotType {