diff --git a/doc/api/resources/seats.rst b/doc/api/resources/seats.rst index 47dbf1aae9..b040f1f8bd 100644 --- a/doc/api/resources/seats.rst +++ b/doc/api/resources/seats.rst @@ -249,7 +249,7 @@ Endpoints "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 @@ -260,3 +260,114 @@ Endpoints :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. + + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_block/ +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_block/ + + Set the ``blocked`` attribute to ``true`` for a large number of seats at once. + You can pass either a list of ``id`` values or a list of ``seat_guid`` values. + You can pass up to 10,000 seats in one request. + + The endpoint will return an error if you pass a seat ID that does not exist. + However, it will not return an error if one of the passed seats is already blocked or sold. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "ids": [12, 45, 56] + } + + or + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + {} + + :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 + :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. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_unblock/ +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_unblock/ + + Set the ``blocked`` attribute to ``false`` for a large number of seats at once. + You can pass either a list of ``id`` values or a list of ``seat_guid`` values. + You can pass up to 10,000 seats in one request. + + The endpoint will return an error if you pass a seat ID that does not exist. + However, it will not return an error if one of the passed seat is already unblocked or is sold. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "ids": [12, 45, 56] + } + + or + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + {} + + :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 + :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 dce96a09ff..7c8213d1eb 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -989,6 +989,40 @@ def prefetch_by_id(items, qs, id_attr, target_attr): setattr(item, target_attr, result.get(getattr(item, id_attr))) +class SeatBulkBlockInputSerializer(serializers.Serializer): + ids = serializers.ListField(child=serializers.IntegerField(), required=False, allow_empty=True) + seat_guids = serializers.ListField(child=serializers.CharField(), required=False, allow_empty=True) + + def to_internal_value(self, data): + data = super().to_internal_value(data) + + if data.get("seat_guids") and data.get("ids"): + raise ValidationError("Please pass either seat_guids or ids.") + + if data.get("seat_guids"): + seat_ids = data["seat_guids"] + if len(seat_ids) > 10000: + raise ValidationError({"seat_guids": ["Please do not pass over 10000 seats."]}) + + seats = {s.seat_guid: s for s in self.context["queryset"].filter(seat_guid__in=seat_ids)} + for s in seat_ids: + if s not in seats: + raise ValidationError({"seat_guids": [f"The seat '{s}' does not exist."]}) + elif data.get("ids"): + seat_ids = data["ids"] + if len(seat_ids) > 10000: + raise ValidationError({"ids": ["Please do not pass over 10000 seats."]}) + + seats = self.context["queryset"].in_bulk(seat_ids) + for s in seat_ids: + if s not in seats: + raise ValidationError({"ids": [f"The seat '{s}' does not exist."]}) + else: + raise ValidationError("Please pass either seat_guids or ids.") + + return {"seats": seats.values()} + + class SeatSerializer(I18nAwareModelSerializer): orderposition = serializers.IntegerField(source='orderposition_id') cartposition = serializers.IntegerField(source='cartposition_id') diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index 757a1f691f..659d41e3c1 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -40,6 +40,7 @@ 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.decorators import action from rest_framework.exceptions import ( NotFound, PermissionDenied, ValidationError, ) @@ -50,8 +51,9 @@ from pretix.api.auth.permission import EventCRUDPermission from pretix.api.pagination import TotalOrderingFilter from pretix.api.serializers.event import ( CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer, - EventSettingsSerializer, ItemMetaPropertiesSerializer, SeatSerializer, - SubEventSerializer, TaxRuleSerializer, + EventSettingsSerializer, ItemMetaPropertiesSerializer, + SeatBulkBlockInputSerializer, SeatSerializer, SubEventSerializer, + TaxRuleSerializer, ) from pretix.api.views import ConditionalListView from pretix.base.models import ( @@ -237,9 +239,9 @@ class EventViewSet(viewsets.ModelViewSet): disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value} changed = merge_dicts(enabled, disabled) - for module, action in changed.items(): + for module, operation in changed.items(): serializer.instance.log_action( - 'pretix.event.plugins.' + action, + 'pretix.event.plugins.' + operation, user=self.request.user, auth=self.request.auth, data={'plugin': module} @@ -744,3 +746,24 @@ class SeatViewSet(ConditionalListView, viewsets.ModelViewSet): auth=self.request.auth, data={"seats": [serializer.instance.pk]}, ) + + def bulk_change_blocked(self, blocked): + s = SeatBulkBlockInputSerializer( + data=self.request.data, + context={"event": self.request.event, "queryset": self.get_queryset()}, + ) + s.is_valid(raise_exception=True) + + seats = s.validated_data["seats"] + for seat in seats: + seat.blocked = blocked + Seat.objects.bulk_update(seats, ["blocked"], batch_size=1000) + return Response({}) + + @action(methods=["POST"], detail=False) + def bulk_block(self, request, *args, **kwargs): + return self.bulk_change_blocked(True) + + @action(methods=["POST"], detail=False) + def bulk_unblock(self, request, *args, **kwargs): + return self.bulk_change_blocked(False) diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index 8140f3ab39..6f416dfb77 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -1601,6 +1601,80 @@ def test_event_block_unblock_seat(token_client, organizer, event, seatingplan, i assert resp.data['blocked'] is False +@pytest.mark.django_db +def test_event_block_unblock_seat_bulk(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() + + s1 = event.seats.first() + s2 = event.seats.last() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/seats/bulk_block/'.format(organizer.slug, event.slug), + { + "ids": [s1.pk, s2.pk], + }, + format='json' + ) + assert resp.status_code == 200 + + s1.refresh_from_db() + s2.refresh_from_db() + assert s1.blocked + assert s2.blocked + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/seats/bulk_unblock/'.format(organizer.slug, event.slug), + { + "ids": [s1.pk, s2.pk], + }, + format='json' + ) + assert resp.status_code == 200 + + s1.refresh_from_db() + s2.refresh_from_db() + assert not s1.blocked + assert not s2.blocked + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/seats/bulk_block/'.format(organizer.slug, event.slug), + { + "seat_guids": [s1.seat_guid, s2.seat_guid], + }, + format='json' + ) + assert resp.status_code == 200 + + s1.refresh_from_db() + s2.refresh_from_db() + assert s1.blocked + assert s2.blocked + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/seats/bulk_unblock/'.format(organizer.slug, event.slug), + { + "seat_guids": [s1.seat_guid, s2.seat_guid], + }, + format='json' + ) + assert resp.status_code == 200 + + s1.refresh_from_db() + s2.refresh_from_db() + assert not s1.blocked + assert not s2.blocked + + @pytest.mark.django_db def test_event_expand_seat_filter_and_querycount(token_client, organizer, event, seatingplan, item): event.settings.seating_minimal_distance = 2