mirror of
https://github.com/pretix/pretix.git
synced 2026-08-29 13:44:40 +00:00
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 <michel@rami.io>
* 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 f32b532cc6.
* improve permission handling (minimal version)
* formatting
* add permission tests
* fix bug
* update permission checks
* Apply suggestions from code review
Co-authored-by: Raphael Michel <michel@rami.io>
* 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 <michel@rami.io>
This commit is contained in:
@@ -46,4 +46,5 @@ at :ref:`plugin-docs`.
|
|||||||
sendmail_rules
|
sendmail_rules
|
||||||
auto_checkin_rules
|
auto_checkin_rules
|
||||||
billing_invoices
|
billing_invoices
|
||||||
billing_var
|
billing_var
|
||||||
|
seats
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
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.db import transaction
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.functional import cached_property
|
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.i18n import I18nAwareModelSerializer
|
||||||
from pretix.api.serializers.settings import SettingsSerializer
|
from pretix.api.serializers.settings import SettingsSerializer
|
||||||
from pretix.base.models import (
|
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.event import SubEvent
|
||||||
from pretix.base.models.items import (
|
from pretix.base.models.items import (
|
||||||
@@ -970,3 +971,77 @@ class ItemMetaPropertiesSerializer(I18nAwareModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ItemMetaProperty
|
model = ItemMetaProperty
|
||||||
fields = ('id', 'name', 'default', 'required', 'allowed_values')
|
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']
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ event_router.register(r'invoices', order.InvoiceViewSet)
|
|||||||
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
|
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
|
||||||
event_router.register(r'blockedsecrets', order.BlockedSecretViewSet, basename='blockedsecrets')
|
event_router.register(r'blockedsecrets', order.BlockedSecretViewSet, basename='blockedsecrets')
|
||||||
event_router.register(r'taxrules', event.TaxRuleViewSet)
|
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'waitinglistentries', waitinglist.WaitingListViewSet)
|
||||||
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||||
event_router.register(r'cartpositions', cart.CartPositionViewSet)
|
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'shredders', shredders.EventShreddersViewSet, basename='shredders')
|
||||||
event_router.register(r'item_meta_properties', event.ItemMetaPropertiesViewSet)
|
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 = routers.DefaultRouter()
|
||||||
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')
|
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')
|
||||||
|
|
||||||
@@ -132,6 +136,7 @@ urlpatterns = [
|
|||||||
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/settings/$', event.EventSettingsView.as_view(),
|
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/settings/$', event.EventSettingsView.as_view(),
|
||||||
name="event.settings"),
|
name="event.settings"),
|
||||||
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)),
|
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)),
|
||||||
|
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/subevents/(?P<subevent>\d+)/', include(subevent_router.urls)),
|
||||||
re_path(r'^organizers/(?P<organizer>[^/]+)/teams/(?P<team>[^/]+)/', include(team_router.urls)),
|
re_path(r'^organizers/(?P<organizer>[^/]+)/teams/(?P<team>[^/]+)/', include(team_router.urls)),
|
||||||
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/items/(?P<item>[^/]+)/', include(item_router.urls)),
|
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/items/(?P<item>[^/]+)/', include(item_router.urls)),
|
||||||
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/questions/(?P<question>[^/]+)/',
|
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/questions/(?P<question>[^/]+)/',
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ from django.utils.timezone import now
|
|||||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
from rest_framework import serializers, views, viewsets
|
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.generics import get_object_or_404
|
||||||
from rest_framework.response import Response
|
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.pagination import TotalOrderingFilter
|
||||||
from pretix.api.serializers.event import (
|
from pretix.api.serializers.event import (
|
||||||
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
|
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
|
||||||
EventSettingsSerializer, ItemMetaPropertiesSerializer, SubEventSerializer,
|
EventSettingsSerializer, ItemMetaPropertiesSerializer, SeatSerializer,
|
||||||
TaxRuleSerializer,
|
SubEventSerializer, TaxRuleSerializer,
|
||||||
)
|
)
|
||||||
from pretix.api.views import ConditionalListView
|
from pretix.api.views import ConditionalListView
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, Device, Event, ItemMetaProperty, SeatCategoryMapping,
|
CartPosition, Device, Event, ItemMetaProperty, Seat, SeatCategoryMapping,
|
||||||
TaxRule, TeamAPIToken,
|
TaxRule, TeamAPIToken,
|
||||||
)
|
)
|
||||||
from pretix.base.models.event import SubEvent
|
from pretix.base.models.event import SubEvent
|
||||||
@@ -667,3 +669,44 @@ class EventSettingsView(views.APIView):
|
|||||||
'request': request
|
'request': request
|
||||||
})
|
})
|
||||||
return Response(s.data)
|
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]},
|
||||||
|
)
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ class Seat(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def annotated(cls, qs, event_id, subevent, ignore_voucher_id=None, minimal_distance=0,
|
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
|
from . import CartPosition, Order, OrderPosition, Voucher
|
||||||
|
|
||||||
vqs = Voucher.objects.filter(
|
vqs = Voucher.objects.filter(
|
||||||
@@ -214,17 +214,24 @@ class Seat(models.Model):
|
|||||||
)
|
)
|
||||||
if ignore_cart_id:
|
if ignore_cart_id:
|
||||||
cqs = cqs.exclude(cart_id=ignore_cart_id)
|
cqs = cqs.exclude(cart_id=ignore_cart_id)
|
||||||
qs_annotated = qs.annotate(
|
if annotate_ids:
|
||||||
has_order=Exists(
|
qs_annotated = qs.annotate(
|
||||||
opqs
|
orderposition_id=Subquery(opqs.values('id')),
|
||||||
),
|
cartposition_id=Subquery(cqs.values('id')),
|
||||||
has_cart=Exists(
|
voucher_id=Subquery(vqs.values('id')),
|
||||||
cqs
|
)
|
||||||
),
|
else:
|
||||||
has_voucher=Exists(
|
qs_annotated = qs.annotate(
|
||||||
vqs
|
has_order=Exists(
|
||||||
|
opqs
|
||||||
|
),
|
||||||
|
has_cart=Exists(
|
||||||
|
cqs
|
||||||
|
),
|
||||||
|
has_voucher=Exists(
|
||||||
|
vqs
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
if minimal_distance > 0:
|
if minimal_distance > 0:
|
||||||
# TODO: Is there a more performant implementation on PostgreSQL using
|
# TODO: Is there a more performant implementation on PostgreSQL using
|
||||||
|
|||||||
@@ -685,6 +685,7 @@ def seat(event, organizer, item):
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_cartpos_create_with_seat(token_client, organizer, event, item, quota, seat, question):
|
def test_cartpos_create_with_seat(token_client, organizer, event, item, quota, seat, question):
|
||||||
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
|
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
|
||||||
|
res['expires'] = now() + datetime.timedelta(hours=1)
|
||||||
res['item'] = item.pk
|
res['item'] = item.pk
|
||||||
res['seat'] = seat.seat_guid
|
res['seat'] = seat.seat_guid
|
||||||
resp = token_client.post(
|
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'])
|
p = CartPosition.objects.get(pk=resp.data['id'])
|
||||||
assert p.seat == seat
|
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
|
@pytest.mark.django_db
|
||||||
def test_cartpos_create_with_blocked_seat(token_client, organizer, event, item, quota, seat, question):
|
def test_cartpos_create_with_blocked_seat(token_client, organizer, event, item, quota, seat, question):
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ from django.conf import settings
|
|||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django_countries.fields import Country
|
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 tests.const import SAMPLE_PNG
|
||||||
|
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
@@ -999,6 +1000,10 @@ def seatingplan(event, organizer, item):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_event_update_seating(token_client, organizer, event, item, seatingplan):
|
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(
|
resp = token_client.patch(
|
||||||
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
|
'/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.layout_category == 'Stalls'
|
||||||
assert m.product == item
|
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
|
@pytest.mark.django_db
|
||||||
def test_event_update_seating_invalid_product(token_client, organizer, event, item, seatingplan):
|
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.status_code == 200
|
||||||
assert resp.data['logo_image'] is None
|
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
|
||||||
|
|||||||
@@ -2010,6 +2010,14 @@ def test_order_create_with_seat(token_client, organizer, event, item, quota, sea
|
|||||||
p = o.positions.first()
|
p = o.positions.first()
|
||||||
assert p.seat == seat
|
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
|
@pytest.mark.django_db
|
||||||
def test_order_create_with_blocked_seat_allowed(token_client, organizer, event, item, quota, seat, question):
|
def test_order_create_with_blocked_seat_allowed(token_client, organizer, event, item, quota, seat, question):
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ event_urls = [
|
|||||||
(None, 'taxrules/'),
|
(None, 'taxrules/'),
|
||||||
('can_view_orders', 'waitinglistentries/'),
|
('can_view_orders', 'waitinglistentries/'),
|
||||||
('can_view_orders', 'checkinlists/'),
|
('can_view_orders', 'checkinlists/'),
|
||||||
|
(None, 'seats/'),
|
||||||
]
|
]
|
||||||
|
|
||||||
event_permission_sub_urls = [
|
event_permission_sub_urls = [
|
||||||
@@ -191,6 +192,12 @@ event_permission_sub_urls = [
|
|||||||
('post', 'can_change_event_settings', 'item_meta_properties/', 400),
|
('post', 'can_change_event_settings', 'item_meta_properties/', 400),
|
||||||
('patch', 'can_change_event_settings', 'item_meta_properties/0/', 404),
|
('patch', 'can_change_event_settings', 'item_meta_properties/0/', 404),
|
||||||
('delete', '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 = [
|
org_permission_sub_urls = [
|
||||||
@@ -254,6 +261,7 @@ org_permission_sub_urls = [
|
|||||||
('get', 'can_change_teams', 'teams/{team_id}/tokens/0/', 404),
|
('get', 'can_change_teams', 'teams/{team_id}/tokens/0/', 404),
|
||||||
('delete', '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),
|
('post', 'can_change_teams', 'teams/{team_id}/tokens/', 400),
|
||||||
|
('get', 'can_manage_reusable_media', 'reusablemedia/1/', 404),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1210,6 +1210,14 @@ def test_set_seat_ok(token_client, organizer, event, seatingplan, seat1, item):
|
|||||||
v.refresh_from_db()
|
v.refresh_from_db()
|
||||||
assert v.seat == seat1
|
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
|
@pytest.mark.django_db
|
||||||
def test_save_set_seat(token_client, organizer, event, seatingplan, seat1, item):
|
def test_save_set_seat(token_client, organizer, event, seatingplan, seat1, item):
|
||||||
|
|||||||
Reference in New Issue
Block a user