From dc1973f4ff1957053fbdd93b2c3dd4e3e497211c Mon Sep 17 00:00:00 2001 From: Mira Date: Fri, 2 Aug 2024 09:17:46 +0200 Subject: [PATCH] Add API endpoint /seats to event (Z#23159536) (#4321) * add API endpoint /seats to event * fix logging * add Seat annotations * add seats endpoint for subevents * return ids of occupying objects instead of boolean flags * wip * include orderposition instead of order in seat info * add API documentation * Apply suggestions from code review Co-authored-by: Raphael Michel * Apply suggestions from code review * Clarify API docs * add api examples * add test cases * require can_view_orders permission for retrieving seats * improve permission handling * Revert "improve permission handling" This reverts commit f32b532cc68760a8a4af03208bd17e75e8c5723d. * improve permission handling (minimal version) * formatting * add permission tests * fix bug * update permission checks * Apply suggestions from code review Co-authored-by: Raphael Michel * add tests for permission checks * add tests for expand=voucher and expand=cartposition * remove unused parameter * test query count * codestyle --------- Co-authored-by: Raphael Michel --- doc/api/resources/index.rst | 3 +- doc/api/resources/seats.rst | 261 ++++++++++++++++++++++++++++ src/pretix/api/serializers/event.py | 79 ++++++++- src/pretix/api/urls.py | 5 + src/pretix/api/views/event.py | 51 +++++- src/pretix/base/models/seating.py | 29 ++-- src/tests/api/test_cart.py | 9 + src/tests/api/test_events.py | 106 ++++++++++- src/tests/api/test_order_create.py | 8 + src/tests/api/test_permissions.py | 8 + src/tests/api/test_vouchers.py | 8 + 11 files changed, 548 insertions(+), 19 deletions(-) create mode 100644 doc/api/resources/seats.rst diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 160047e836..b9d5187acf 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -46,4 +46,5 @@ at :ref:`plugin-docs`. sendmail_rules auto_checkin_rules billing_invoices - billing_var \ No newline at end of file + billing_var + seats diff --git a/doc/api/resources/seats.rst b/doc/api/resources/seats.rst new file mode 100644 index 0000000000..d43a11ff37 --- /dev/null +++ b/doc/api/resources/seats.rst @@ -0,0 +1,261 @@ +.. _`rest-reusablemedia`: + +Seats +===== + +The seat resource represents the seats in a seating plan in a specific event or subevent. + +Resource description +-------------------- + +The seat resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of this seat +subevent integer Internal ID of the subevent this seat belongs to +zone_name string Name of the zone the seat is in +row_name string Name/number of the row the seat is in +row_label string Additional label of the row (or ``null``) +seat_number string Number of the seat within the row +seat_label string Additional label of the seat (or ``null``) +seat_guid string Identifier of the seat within the seating plan +product integer Internal ID of the product that is mapped to this seat +blocked boolean Whether this seat is blocked manually. +orderposition integer / object Internal ID of an order position reserving this seat. +cartposition integer / object Internal ID of a cart position reserving this seat. +voucher integer / object Internal ID of a voucher reserving this seat. +===================================== ========================== ======================================================= + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/seats/ +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(subevent_id)/seats/ + + Returns a list of all seats in the specified event or subevent. Depending on whether the event has subevents, the + according endpoint has to be used. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/seats/ HTTP/1.1 + Host: pretix.eu + Accept: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 500, + "next": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/seats/?page=2", + "previous": null, + "results": [ + { + "id": 1633, + "subevent": null, + "zone_name": "Ground floor", + "row_name": "1", + "row_label": null, + "seat_number": "1", + "seat_label": null, + "seat_guid": "b9746230-6f31-4f41-bbc9-d6b60bdb3342", + "product": 104, + "blocked": false, + "orderposition": null, + "cartposition": null, + "voucher": 51 + }, + { + "id": 1634, + "subevent": null, + "zone_name": "Ground floor", + "row_name": "1", + "row_label": null, + "seat_number": "2", + "seat_label": null, + "seat_guid": "1d29fe20-8e1e-4984-b0ee-2773b0d07e07", + "product": 104, + "blocked": true, + "orderposition": 4321, + "cartposition": null, + "voucher": null + }, + // ... + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1. + :query string zone_name: Only show seats with the given zone_name. + :query string row_name: Only show seats with the given row_name. + :query string row_label: Only show seats with the given row_label. + :query string seat_number: Only show seats with the given seat_number. + :query string seat_label: Only show seats with the given seat_label. + :query string seat_guid: Only show seats with the given seat_guid. + :query string blocked: Only show seats with the given blocked status. + :query string expand: If you pass ``"orderposition"``, ``"cartposition"``, or ``"voucher"``, the respective field will be + shown as a nested value instead of just an ID. This requires permission to access that object. + The nested objects are identical to the respective resources, except that order positions + will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make + matching easier, and won't include the `seat` attribute, as that would be redundant. + The parameter can be given multiple times. + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param subevent_id: The ``id`` field of the subevent to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + :statuscode 404: Endpoint without subevent id was used for event with subevents, or vice versa. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/seats/(id)/ +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(subevent_id)/seats/(id)/ + + Returns information on one seat, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/seats/1634/?expand=orderposition HTTP/1.1 + Host: pretix.eu + Accept: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1634, + "subevent": null, + "zone_name": "Ground floor", + "row_name": "1", + "row_label": null, + "seat_number": "2", + "seat_label": null, + "seat_guid": "1d29fe20-8e1e-4984-b0ee-2773b0d07e07", + "product": 104, + "blocked": true, + "orderposition": { + "id": 134, + "order": { + "code": "U0HW7", + "event": "sampleconf" + }, + "positionid": 1, + "item": 104, + "variation": 59, + "price": "60.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "4rfgp263jduratnsvwvy6cc6r6wnptbj", + "addon_to": null, + "subevent": null, + "checkins": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "ZSNYSG3URZ", + "canceled": false, + "valid_from": null, + "valid_until": null, + "blocked": null, + "voucher_budget_use": null + }, + "cartposition": null, + "voucher": null + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param subevent_id: The ``id`` field of the subevent to fetch + :param id: The ``id`` field of the seat to fetch + :query string expand: If you pass ``"orderposition"``, ``"cartposition"``, or ``"voucher"``, the respective field will be + shown as a nested value instead of just an ID. This requires permission to access that object. + The nested objects are identical to the respective resources, except that order positions + will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make + matching easier, and won't include the `seat` attribute, as that would be redundant. + The parameter can be given multiple times. + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + :statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa. + +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/seats/(id)/ +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/(id)/ + + Update a seat. + + You can only change the ``blocked`` field. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/1636/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "blocked": true + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1636, + "subevent": null, + "zone_name": "Ground floor", + "row_name": "1", + "row_label": null, + "seat_number": "4", + "seat_label": null, + "seat_guid": "6c0e29e5-05d6-421f-99f3-afd01478ecad", + "product": 104, + "blocked": true, + "orderposition": null, + "cartposition": null, + "voucher": null + }, + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param subevent_id: The ``id`` field of the subevent to modify + :param id: The ``id`` field of the seat to modify + :statuscode 200: no error + :statuscode 400: The seat could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource. + :statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa. diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index b63045371d..fb61b2c539 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -35,7 +35,7 @@ import logging from django.conf import settings -from django.core.exceptions import ValidationError +from django.core.exceptions import PermissionDenied, ValidationError from django.db import transaction from django.utils.crypto import get_random_string from django.utils.functional import cached_property @@ -52,7 +52,8 @@ from pretix.api.serializers import ( from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.settings import SettingsSerializer from pretix.base.models import ( - Device, Event, SalesChannel, TaxRule, TeamAPIToken, + CartPosition, Device, Event, OrderPosition, SalesChannel, Seat, TaxRule, + TeamAPIToken, Voucher, ) from pretix.base.models.event import SubEvent from pretix.base.models.items import ( @@ -970,3 +971,77 @@ class ItemMetaPropertiesSerializer(I18nAwareModelSerializer): class Meta: model = ItemMetaProperty fields = ('id', 'name', 'default', 'required', 'allowed_values') + + +def prefetch_by_id(items, qs, id_attr, target_attr): + """ + Prefetches a related object on each item in the given list of items by searching by id or another + unique field. The id value is read from the attribute on item specified in `id_attr`, searched on queryset `qs` by + the primary key, and the resulting prefetched model object is stored into `target_attr` on the item. + """ + ids = [getattr(item, id_attr) for item in items if getattr(item, id_attr)] + if ids: + result = qs.in_bulk(id_list=ids) + for item in items: + setattr(item, target_attr, result.get(getattr(item, id_attr))) + + +class SeatSerializer(I18nAwareModelSerializer): + orderposition = serializers.IntegerField(source='orderposition_id') + cartposition = serializers.IntegerField(source='cartposition_id') + voucher = serializers.IntegerField(source='voucher_id') + + class Meta: + model = Seat + read_only_fields = ( + 'id', 'subevent', 'zone_name', 'row_name', 'row_label', + 'seat_number', 'seat_label', 'seat_guid', 'product', + 'orderposition', 'cartposition', 'voucher', + ) + fields = ( + 'id', 'subevent', 'zone_name', 'row_name', 'row_label', + 'seat_number', 'seat_label', 'seat_guid', 'product', 'blocked', + 'orderposition', 'cartposition', 'voucher', + ) + + def prefetch_expanded_data(self, items, request, expand_fields): + if 'orderposition' in expand_fields: + if 'can_view_orders' not in request.eventpermset: + raise PermissionDenied('can_view_orders permission required for expand=orderposition') + prefetch_by_id(items, OrderPosition.objects.prefetch_related('order'), 'orderposition_id', 'orderposition') + if 'cartposition' in expand_fields: + if 'can_view_orders' not in request.eventpermset: + raise PermissionDenied('can_view_orders permission required for expand=cartposition') + prefetch_by_id(items, CartPosition.objects, 'cartposition_id', 'cartposition') + if 'voucher' in expand_fields: + if 'can_view_vouchers' not in request.eventpermset: + raise PermissionDenied('can_view_vouchers permission required for expand=voucher') + prefetch_by_id(items, Voucher.objects, 'voucher_id', 'voucher') + + def __init__(self, instance, *args, **kwargs): + if not kwargs.get('data'): + self.prefetch_expanded_data(instance if hasattr(instance, '__iter__') else [instance], + kwargs['context']['request'], + kwargs['context']['expand_fields']) + + super().__init__(instance, *args, **kwargs) + + if 'orderposition' in self.context['expand_fields']: + from pretix.api.serializers.media import ( + NestedOrderPositionSerializer, + ) + self.fields['orderposition'] = NestedOrderPositionSerializer(read_only=True, context=self.context['order_context']) + try: + del self.fields['orderposition'].fields['seat'] + except KeyError: + pass + + if 'cartposition' in self.context['expand_fields']: + from pretix.api.serializers.cart import CartPositionSerializer + self.fields['cartposition'] = CartPositionSerializer(read_only=True) + del self.fields['cartposition'].fields['seat'] + + if 'voucher' in self.context['expand_fields']: + from pretix.api.serializers.voucher import VoucherSerializer + self.fields['voucher'] = VoucherSerializer(read_only=True) + del self.fields['voucher'].fields['seat'] diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 78d9cb68a8..d853fa1dc6 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -87,6 +87,7 @@ event_router.register(r'invoices', order.InvoiceViewSet) event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets') event_router.register(r'blockedsecrets', order.BlockedSecretViewSet, basename='blockedsecrets') event_router.register(r'taxrules', event.TaxRuleViewSet) +event_router.register(r'seats', event.SeatViewSet) event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet) event_router.register(r'checkinlists', checkin.CheckinListViewSet) event_router.register(r'cartpositions', cart.CartPositionViewSet) @@ -95,6 +96,9 @@ event_router.register(r'exporters', exporters.EventExportersViewSet, basename='e event_router.register(r'shredders', shredders.EventShreddersViewSet, basename='shredders') event_router.register(r'item_meta_properties', event.ItemMetaPropertiesViewSet) +subevent_router = routers.DefaultRouter() +subevent_router.register(r'seats', event.SeatViewSet) + checkinlist_router = routers.DefaultRouter() checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos') @@ -132,6 +136,7 @@ urlpatterns = [ re_path(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/settings/$', event.EventSettingsView.as_view(), name="event.settings"), re_path(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/', include(event_router.urls)), + re_path(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/subevents/(?P\d+)/', include(subevent_router.urls)), re_path(r'^organizers/(?P[^/]+)/teams/(?P[^/]+)/', include(team_router.urls)), re_path(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/items/(?P[^/]+)/', include(item_router.urls)), re_path(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/questions/(?P[^/]+)/', diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index 043785c0d4..ee31e67976 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -40,7 +40,9 @@ from django.utils.timezone import now from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled from rest_framework import serializers, views, viewsets -from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.exceptions import ( + NotFound, PermissionDenied, ValidationError, +) from rest_framework.generics import get_object_or_404 from rest_framework.response import Response @@ -48,12 +50,12 @@ from pretix.api.auth.permission import EventCRUDPermission from pretix.api.pagination import TotalOrderingFilter from pretix.api.serializers.event import ( CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer, - EventSettingsSerializer, ItemMetaPropertiesSerializer, SubEventSerializer, - TaxRuleSerializer, + EventSettingsSerializer, ItemMetaPropertiesSerializer, SeatSerializer, + SubEventSerializer, TaxRuleSerializer, ) from pretix.api.views import ConditionalListView from pretix.base.models import ( - CartPosition, Device, Event, ItemMetaProperty, SeatCategoryMapping, + CartPosition, Device, Event, ItemMetaProperty, Seat, SeatCategoryMapping, TaxRule, TeamAPIToken, ) from pretix.base.models.event import SubEvent @@ -667,3 +669,44 @@ class EventSettingsView(views.APIView): 'request': request }) return Response(s.data) + + +class SeatViewSet(ConditionalListView, viewsets.ModelViewSet): + serializer_class = SeatSerializer + queryset = Seat.objects.none() + write_permission = 'can_change_event_settings' + filter_backends = (DjangoFilterBackend,) + filterset_fields = ('zone_name', 'row_name', 'row_label', 'seat_number', 'seat_label', 'seat_guid', 'blocked',) + + def get_queryset(self): + if self.request.event.has_subevents and 'subevent' in self.request.resolver_match.kwargs: + try: + subevent = self.request.event.subevents.get(pk=self.request.resolver_match.kwargs['subevent']) + except SubEvent.DoesNotExist: + raise NotFound('Subevent not found') + qs = Seat.annotated(event_id=self.request.event.id, subevent=subevent, qs=subevent.seats.all(), annotate_ids=True) + elif not self.request.event.has_subevents and 'subevent' not in self.request.resolver_match.kwargs: + qs = Seat.annotated(event_id=self.request.event.id, subevent=None, qs=self.request.event.seats.all(), annotate_ids=True) + else: + raise NotFound('Please use the subevent-specific endpoint' if self.request.event.has_subevents + else 'This event has no subevents') + + return qs + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['expand_fields'] = self.request.query_params.getlist('expand') + ctx['order_context'] = { + 'event': self.request.event, + 'pdf_data': None, + } + return ctx + + def perform_update(self, serializer): + super().perform_update(serializer) + serializer.instance.event.log_action( + "pretix.event.seats.blocks.changed", + user=self.request.user, + auth=self.request.auth, + data={"seats": [serializer.instance.pk]}, + ) diff --git a/src/pretix/base/models/seating.py b/src/pretix/base/models/seating.py index c55d635508..229f6ea53e 100644 --- a/src/pretix/base/models/seating.py +++ b/src/pretix/base/models/seating.py @@ -185,7 +185,7 @@ class Seat(models.Model): @classmethod def annotated(cls, qs, event_id, subevent, ignore_voucher_id=None, minimal_distance=0, - ignore_order_id=None, ignore_cart_id=None, distance_only_within_row=False): + ignore_order_id=None, ignore_cart_id=None, distance_only_within_row=False, annotate_ids=False): from . import CartPosition, Order, OrderPosition, Voucher vqs = Voucher.objects.filter( @@ -214,17 +214,24 @@ class Seat(models.Model): ) if ignore_cart_id: cqs = cqs.exclude(cart_id=ignore_cart_id) - qs_annotated = qs.annotate( - has_order=Exists( - opqs - ), - has_cart=Exists( - cqs - ), - has_voucher=Exists( - vqs + if annotate_ids: + qs_annotated = qs.annotate( + orderposition_id=Subquery(opqs.values('id')), + cartposition_id=Subquery(cqs.values('id')), + voucher_id=Subquery(vqs.values('id')), + ) + else: + qs_annotated = qs.annotate( + has_order=Exists( + opqs + ), + has_cart=Exists( + cqs + ), + has_voucher=Exists( + vqs + ) ) - ) if minimal_distance > 0: # TODO: Is there a more performant implementation on PostgreSQL using diff --git a/src/tests/api/test_cart.py b/src/tests/api/test_cart.py index 348e189a8d..d39b722e4a 100644 --- a/src/tests/api/test_cart.py +++ b/src/tests/api/test_cart.py @@ -685,6 +685,7 @@ def seat(event, organizer, item): @pytest.mark.django_db def test_cartpos_create_with_seat(token_client, organizer, event, item, quota, seat, question): res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['expires'] = now() + datetime.timedelta(hours=1) res['item'] = item.pk res['seat'] = seat.seat_guid resp = token_client.post( @@ -697,6 +698,14 @@ def test_cartpos_create_with_seat(token_client, organizer, event, item, quota, s p = CartPosition.objects.get(pk=resp.data['id']) assert p.seat == seat + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/{}/'.format(organizer.slug, event.slug, seat.pk)) + assert resp.status_code == 200 + assert resp.data['cartposition'] == p.pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/{}/?expand=cartposition'.format(organizer.slug, event.slug, seat.pk)) + assert resp.status_code == 200 + assert resp.data['cartposition']['id'] == p.pk + @pytest.mark.django_db def test_cartpos_create_with_blocked_seat(token_client, organizer, event, item, quota, seat, question): diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index 586704ad1f..7d11a54c91 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -42,7 +42,8 @@ from django.conf import settings from django.core.files.base import ContentFile from django.utils.timezone import now from django_countries.fields import Country -from django_scopes import scopes_disabled +from django_scopes import scope, scopes_disabled +from tests import assert_num_queries from tests.const import SAMPLE_PNG from pretix.base.models import ( @@ -999,6 +1000,10 @@ def seatingplan(event, organizer, item): @pytest.mark.django_db def test_event_update_seating(token_client, organizer, event, item, seatingplan): + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert len(resp.data['results']) == 0 + resp = token_client.patch( '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), { @@ -1019,6 +1024,11 @@ def test_event_update_seating(token_client, organizer, event, item, seatingplan) assert m.layout_category == 'Stalls' assert m.product == item + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert len(resp.data['results']) == 3 + assert all(seat['product'] == item.pk for seat in resp.data['results']) + @pytest.mark.django_db def test_event_update_seating_invalid_product(token_client, organizer, event, item, seatingplan): @@ -1530,3 +1540,97 @@ def test_patch_event_settings_file(token_client, organizer, event): ) assert resp.status_code == 200 assert resp.data['logo_image'] is None + + +@pytest.mark.django_db +def test_event_block_unblock_seat(token_client, organizer, event, seatingplan, item): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "seating_plan": seatingplan.pk, + "seat_category_mapping": { + "Stalls": item.pk + } + }, + format='json' + ) + assert resp.status_code == 200 + event.refresh_from_db() + + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + + seat_id = resp.data['results'][0]['id'] + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/seats/{}/'.format(organizer.slug, event.slug, seat_id), + { + "blocked": True, + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data['blocked'] is True + + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/{}/' + '?expand=orderposition&expand=cartposition&expand=voucher' + .format(organizer.slug, event.slug, seat_id)) + assert resp.status_code == 200 + assert resp.data['blocked'] is True + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/seats/{}/'.format(organizer.slug, event.slug, seat_id), + { + "blocked": False, + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data['blocked'] is False + + +@pytest.mark.django_db +def test_event_expand_seat_querycount(token_client, organizer, event, seatingplan, item): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "seating_plan": seatingplan.pk, + "seat_category_mapping": { + "Stalls": item.pk + } + }, + format='json' + ) + assert resp.status_code == 200 + event.refresh_from_db() + + with assert_num_queries(9): + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/' + '?expand=orderposition&expand=cartposition&expand=voucher' + .format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert len(resp.data['results']) == 3 + + with scope(organizer=organizer): + v0 = event.vouchers.create(item=item, seat=event.seats.get(seat_guid='0-0')) + + with assert_num_queries(10): + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/' + '?expand=orderposition&expand=cartposition&expand=voucher' + .format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert resp.data['results'][0]['voucher']['id'] == v0.pk + assert resp.data['results'][1]['voucher'] is None + assert resp.data['results'][2]['voucher'] is None + + with scope(organizer=organizer): + v1 = event.vouchers.create(item=item, seat=event.seats.get(seat_guid='0-1')) + v2 = event.vouchers.create(item=item, seat=event.seats.get(seat_guid='0-2')) + + with assert_num_queries(10): + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/' + '?expand=orderposition&expand=cartposition&expand=voucher' + .format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert resp.data['results'][0]['voucher']['id'] == v0.pk + assert resp.data['results'][1]['voucher']['id'] == v1.pk + assert resp.data['results'][2]['voucher']['id'] == v2.pk diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index b52fbad1cb..b8f7c8daff 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -2010,6 +2010,14 @@ def test_order_create_with_seat(token_client, organizer, event, item, quota, sea p = o.positions.first() assert p.seat == seat + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/{}/'.format(organizer.slug, event.slug, seat.pk)) + assert resp.status_code == 200 + assert resp.data['orderposition'] == p.pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/{}/?expand=orderposition'.format(organizer.slug, event.slug, seat.pk)) + assert resp.status_code == 200 + assert resp.data['orderposition']['id'] == p.pk + @pytest.mark.django_db def test_order_create_with_blocked_seat_allowed(token_client, organizer, event, item, quota, seat, question): diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index a96054c206..5171edada9 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -54,6 +54,7 @@ event_urls = [ (None, 'taxrules/'), ('can_view_orders', 'waitinglistentries/'), ('can_view_orders', 'checkinlists/'), + (None, 'seats/'), ] event_permission_sub_urls = [ @@ -191,6 +192,12 @@ event_permission_sub_urls = [ ('post', 'can_change_event_settings', 'item_meta_properties/', 400), ('patch', 'can_change_event_settings', 'item_meta_properties/0/', 404), ('delete', 'can_change_event_settings', 'item_meta_properties/0/', 404), + ('get', None, 'seats/', 200), + ('get', 'can_view_orders', 'seats/?expand=orderposition', 200), + ('get', 'can_view_orders', 'seats/?expand=cartposition', 200), + ('get', 'can_view_vouchers', 'seats/?expand=voucher', 200), + ('get', None, 'seats/1/', 404), + ('patch', 'can_change_event_settings', 'seats/1/', 404), ] org_permission_sub_urls = [ @@ -254,6 +261,7 @@ org_permission_sub_urls = [ ('get', 'can_change_teams', 'teams/{team_id}/tokens/0/', 404), ('delete', 'can_change_teams', 'teams/{team_id}/tokens/0/', 404), ('post', 'can_change_teams', 'teams/{team_id}/tokens/', 400), + ('get', 'can_manage_reusable_media', 'reusablemedia/1/', 404), ] diff --git a/src/tests/api/test_vouchers.py b/src/tests/api/test_vouchers.py index 3d5be809e6..15d37b7c24 100644 --- a/src/tests/api/test_vouchers.py +++ b/src/tests/api/test_vouchers.py @@ -1210,6 +1210,14 @@ def test_set_seat_ok(token_client, organizer, event, seatingplan, seat1, item): v.refresh_from_db() assert v.seat == seat1 + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/{}/'.format(organizer.slug, event.slug, seat1.pk)) + assert resp.status_code == 200 + assert resp.data['voucher'] == v.pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/seats/{}/?expand=voucher'.format(organizer.slug, event.slug, seat1.pk)) + assert resp.status_code == 200 + assert resp.data['voucher']['id'] == v.pk + @pytest.mark.django_db def test_save_set_seat(token_client, organizer, event, seatingplan, seat1, item):