diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index b8395bc92e..cc22588ca2 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -84,6 +84,10 @@ Endpoints The ``clone_from`` parameter has been added to the event creation endpoint. +.. versionchanged:: 4.1 + + The ``with_availability_for`` parameter has been added. + .. http:get:: /api/v1/organizers/(organizer)/events/ Returns a list of all events within a given organizer the authenticated user/token has access to. @@ -162,6 +166,10 @@ Endpoints events having set their ``Format`` meta data to ``Seminar``, ``?attr[Format]=`` only those, that have no value set. Please note that this filter will respect default values set on organizer level. :query sales_channel: If set to a sales channel identifier, only events allowed to be sold on the specified sales channel are returned. + :query with_availability_for: If set to a sales channel identifier, the response will contain a special ``best_availability_state`` + attribute with values of 100 for "tickets available", values less than 100 for "tickets sold out or reserved", + and ``null`` for "status unknown". These values might be served from a cache. This parameter can make the response + slow. :param organizer: The ``slug`` field of a valid organizer :statuscode 200: no error :statuscode 401: Authentication failure diff --git a/doc/api/resources/subevents.rst b/doc/api/resources/subevents.rst index d30c5621a5..466c708686 100644 --- a/doc/api/resources/subevents.rst +++ b/doc/api/resources/subevents.rst @@ -82,6 +82,10 @@ Endpoints The sub-events resource can now be filtered by meta data attributes. +.. versionchanged:: 4.1 + + The ``with_availability_for`` parameter has been added. + .. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/ Returns a list of all sub-events of an event. @@ -152,6 +156,10 @@ Endpoints only those sub-events having set their ``Format`` meta data to ``Seminar``, ``?attr[Format]=`` only those, that have no value set. Please note that this filter will respect default values set on organizer or event level. + :query with_availability_for: If set to a sales channel identifier, the response will contain a special ``best_availability_state`` + attribute with values of 100 for "tickets available", values less than 100 for "tickets sold out or reserved", + and ``null`` for "status unknown". These values might be served from a cache. This parameter can make the response + slow. :statuscode 200: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer does not exist **or** you have no permission to view it. diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index df35a5abf6..3ea81340ca 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -42,6 +42,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext as _ from django_countries.serializers import CountryFieldMixin from pytz import common_timezones +from rest_framework import serializers from rest_framework.fields import ChoiceField, Field from rest_framework.relations import SlugRelatedField @@ -93,9 +94,12 @@ class MetaPropertyField(Field): class SeatCategoryMappingField(Field): def to_representation(self, value): - qs = value.seat_category_mappings.all() - if isinstance(value, Event): - qs = qs.filter(subevent=None) + if hasattr(value, '_seat_category_mappings'): + qs = value._seat_category_mappings + else: + qs = value.seat_category_mappings.all() + if isinstance(value, Event): + qs = qs.filter(subevent=None) return { v.layout_category: v.product_id for v in qs } @@ -156,6 +160,7 @@ class EventSerializer(I18nAwareModelSerializer): seat_category_mapping = SeatCategoryMappingField(source='*', required=False) timezone = TimeZoneField(required=False, choices=[(a, a) for a in common_timezones]) valid_keys = ValidKeysField(source='*', read_only=True) + best_availability_state = serializers.IntegerField(allow_null=True, read_only=True) class Meta: model = Event @@ -163,12 +168,14 @@ class EventSerializer(I18nAwareModelSerializer): 'date_to', 'date_admission', 'is_public', 'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan', 'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys', - 'sales_channels') + 'sales_channels', 'best_availability_state') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not hasattr(self.context['request'], 'event'): self.fields.pop('valid_keys') + if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET: + self.fields.pop('best_availability_state') def validate(self, data): data = super().validate(data) @@ -441,13 +448,19 @@ class SubEventSerializer(I18nAwareModelSerializer): seat_category_mapping = SeatCategoryMappingField(source='*', required=False) event = SlugRelatedField(slug_field='slug', read_only=True) meta_data = MetaDataField(source='*') + best_availability_state = serializers.IntegerField(allow_null=True, read_only=True) class Meta: model = SubEvent fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission', 'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public', 'frontpage_text', 'seating_plan', 'item_price_overrides', 'variation_price_overrides', - 'meta_data', 'seat_category_mapping', 'last_modified') + 'meta_data', 'seat_category_mapping', 'last_modified', 'best_availability_state') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET: + self.fields.pop('best_availability_state') def validate(self, data): data = super().validate(data) diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index d93b67ed5e..155dd803bb 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -34,7 +34,7 @@ import django_filters from django.db import transaction -from django.db.models import ProtectedError, Q +from django.db.models import Prefetch, ProtectedError, Q from django.utils.timezone import now from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled @@ -49,9 +49,10 @@ from pretix.api.serializers.event import ( ) from pretix.api.views import ConditionalListView from pretix.base.models import ( - CartPosition, Device, Event, TaxRule, TeamAPIToken, + CartPosition, Device, Event, SeatCategoryMapping, TaxRule, TeamAPIToken, ) from pretix.base.models.event import SubEvent +from pretix.base.services.quotas import QuotaAvailability from pretix.base.settings import SETTINGS_AFFECTING_CSS from pretix.helpers.dicts import merge_dicts from pretix.presale.style import regenerate_css @@ -136,10 +137,43 @@ class EventViewSet(viewsets.ModelViewSet): ) qs = filter_qs_by_attr(qs, self.request) + + if 'with_availability_for' in self.request.GET: + qs = Event.annotated(qs, channel=self.request.GET.get('with_availability_for')) + return qs.prefetch_related( - 'meta_values', 'meta_values__property', 'seat_category_mappings' + 'organizer', + 'meta_values', + 'meta_values__property', + 'item_meta_properties', + Prefetch( + 'seat_category_mappings', + to_attr='_seat_category_mappings', + queryset=SeatCategoryMapping.objects.filter(subevent=None) + ), ) + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + + if 'with_availability_for' in self.request.GET: + quotas_to_compute = [] + qcache = {} + for se in page: + se._quota_cache = qcache + quotas_to_compute += se.active_quotas + + if quotas_to_compute: + qa = QuotaAvailability() + qa.queue(*quotas_to_compute) + qa.compute(allow_cache=True) + qcache.update(qa.results) + + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + def perform_update(self, serializer): current_live_value = serializer.instance.live updated_live_value = serializer.validated_data.get('live', None) @@ -197,7 +231,6 @@ class EventViewSet(viewsets.ModelViewSet): except Event.DoesNotExist: raise ValidationError('Event to copy from was not found') - print(copy_from, self.request.GET) new_event = serializer.save(organizer=self.request.organizer) if copy_from: @@ -336,8 +369,18 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): qs = filter_qs_by_attr(qs, self.request) + if 'with_availability_for' in self.request.GET: + qs = SubEvent.annotated(qs, channel=self.request.GET.get('with_availability_for')) + return qs.prefetch_related( - 'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings', 'meta_values' + 'event', + 'subeventitem_set', + 'subeventitemvariation_set', + 'meta_values', + Prefetch( + 'seat_category_mappings', + to_attr='_seat_category_mappings', + ), ) def list(self, request, **kwargs): @@ -345,14 +388,24 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - resp = self.get_paginated_response(serializer.data) - resp['X-Page-Generated'] = date - return resp - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data, headers={'X-Page-Generated': date}) + if 'with_availability_for' in self.request.GET: + quotas_to_compute = [] + qcache = {} + for se in page: + se._quota_cache = qcache + quotas_to_compute += se.active_quotas + + if quotas_to_compute: + qa = QuotaAvailability() + qa.queue(*quotas_to_compute) + qa.compute(allow_cache=True) + qcache.update(qa.results) + + serializer = self.get_serializer(page, many=True) + resp = self.get_paginated_response(serializer.data) + resp['X-Page-Generated'] = date + return resp def perform_update(self, serializer): original_data = self.get_serializer(instance=serializer.instance).data diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index d33fc83751..7f766a29a0 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -177,6 +177,10 @@ def test_event_list(token_client, organizer, event): assert resp.status_code == 200 assert [TEST_EVENT_RES] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/?with_availability_for=web'.format(organizer.slug)) + assert resp.status_code == 200 + assert resp.data['results'][0]['best_availability_state'] is None + @pytest.mark.django_db def test_event_list_filter(token_client, organizer, event): diff --git a/src/tests/api/test_subevents.py b/src/tests/api/test_subevents.py index fa11f38a8b..bc9176c676 100644 --- a/src/tests/api/test_subevents.py +++ b/src/tests/api/test_subevents.py @@ -152,6 +152,10 @@ def test_subevent_list(token_client, organizer, event, subevent): '/api/v1/organizers/{}/events/{}/subevents/?ends_after=2017-12-27T10:01:01Z'.format(organizer.slug, event.slug)) assert [] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/?with_availability_for=web'.format(organizer.slug)) + assert resp.status_code == 200 + assert resp.data['results'][0]['best_availability_state'] is None + @pytest.mark.django_db def test_subevent_list_filter(token_client, organizer, event, subevent):