From e4167380b92e98e3d00c344dca700fa6da47ad8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ture=20Gj=C3=B8rup?= Date: Thu, 16 Nov 2017 18:39:10 +0100 Subject: [PATCH] API: Write methods for quotas (#657) * MKBDIGI-183: Added quotas API write methods * MKBDIGI-183: Fixed code formatting * MKBDIGI-183: Added test for permission requirements * MKBDIGI-183: Documentation corrections * MKBDIGI-183: Removed redundant create/update locks * MKBDIGI-183: Added quota validation to check that items and variations corresponds to each other * MKBDIGI-183: Added quota validation to check that item belong to the same event as the endpoint * MKBDIGI-183: Added subevent validation to check that subevent belong to the same event as the endpoint * MKBDIGI-183: Added subevent validation to check that subevent is null for non-series events * MKBDIGI-183: Changed validation error text * MKBDIGI-183: Added logging for subevents * MKBDIGI-183: Fixed code formatting * MKBDIGI-183: Fixed validation error in API test * MKBDIGI-183: Fixed documentation errors * MKBDIGI-183: Fixed typos in validation messages * MKBDIGI-183: Refactored validation loop vars check * MKBDIGI-183: Updated error strings in test assersions * MKBDIGI-183: Fixed logging for API quota update to account changing subevents --- doc/api/resources/quotas.rst | 127 ++++++++++++++++++- doc/api/resources/vouchers.rst | 4 +- src/pretix/api/serializers/item.py | 13 ++ src/pretix/api/views/item.py | 73 ++++++++++- src/pretix/base/models/items.py | 27 ++++ src/tests/api/conftest.py | 35 +++++- src/tests/api/test_items.py | 193 +++++++++++++++++++++++++++++ src/tests/api/test_permissions.py | 4 + 8 files changed, 470 insertions(+), 6 deletions(-) diff --git a/doc/api/resources/quotas.rst b/doc/api/resources/quotas.rst index 06a9b7b49..ed0374aeb 100644 --- a/doc/api/resources/quotas.rst +++ b/doc/api/resources/quotas.rst @@ -4,7 +4,7 @@ Quotas Resource description -------------------- -Questions define how many times an item can be sold. +Quotas define how many times an item can be sold. The quota resource contains the following public fields: .. rst-class:: rest-resource-table @@ -106,6 +106,131 @@ Endpoints :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/quotas/ + + Creates a new quota + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/quotas/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "name": "Ticket Quota", + "size": 200, + "items": [1, 2], + "variations": [1, 4, 5, 7], + "subevent": null + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "name": "Ticket Quota", + "size": 200, + "items": [1, 2], + "variations": [1, 4, 5, 7], + "subevent": null + } + + :param organizer: The ``slug`` field of the organizer of the event/item to create a quota for + :param event: The ``slug`` field of the event to create a quota for + :statuscode 201: no error + :statuscode 400: The quota could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/quotas/(id)/ + + Update a quota. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be resetted to default. With ``PATCH``, you only need to provide the fields that you + want to change. + + You can change all fields of the resource except the ``id`` field. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/quotas/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "name": "New Ticket Quota", + "size": 100, + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 2, + "name": "New Ticket Quota", + "size": 100, + "items": [ + 1, + 2 + ], + "variations": [ + 1, + 2 + ], + "subevent": null + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the quota rule to modify + :statuscode 200: no error + :statuscode 400: The quota could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. + +.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/quota/(id)/ + + Delete a quota. Note that if you delete a quota the items the quota acts on might no longer be available for sale. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/quotas/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the quotas to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. + .. http:get:: /api/v1/organizers/(organizer)/events/(event)/quotas/(id)/availability/ Returns availability information on one quota, identified by its ID. diff --git a/doc/api/resources/vouchers.rst b/doc/api/resources/vouchers.rst index 4e9266e06..411f99f9f 100644 --- a/doc/api/resources/vouchers.rst +++ b/doc/api/resources/vouchers.rst @@ -282,7 +282,7 @@ Endpoints :param organizer: The ``slug`` field of the organizer to modify :param event: The ``slug`` field of the event to modify - :param id: The ``id`` field of the tax rule to modify + :param id: The ``id`` field of the voucher to modify :statuscode 200: no error :statuscode 400: The voucher could not be modified due to invalid submitted data :statuscode 401: Authentication failure @@ -310,7 +310,7 @@ Endpoints :param organizer: The ``slug`` field of the organizer to modify :param event: The ``slug`` field of the event to modify - :param id: The ``id`` field of the tax rule to delete + :param id: The ``id`` field of the voucher to delete :statuscode 204: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index bb86f1300..bebf50f59 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -73,3 +73,16 @@ class QuotaSerializer(I18nAwareModelSerializer): class Meta: model = Quota fields = ('id', 'name', 'size', 'items', 'variations', 'subevent') + + def validate(self, data): + data = super().validate(data) + event = self.context['event'] + + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} + full_data.update(data) + + Quota.clean_variations(full_data.get('items'), full_data.get('variations')) + Quota.clean_items(event, full_data.get('items'), full_data.get('variations')) + Quota.clean_subevent(event, full_data.get('subevent')) + + return data diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 0273f5c88..0818c2722 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -11,6 +11,7 @@ from pretix.api.serializers.item import ( QuotaSerializer, ) from pretix.base.models import Item, ItemCategory, Question, Quota +from pretix.base.models.organizer import TeamAPIToken class ItemFilter(FilterSet): @@ -77,7 +78,7 @@ class QuotaFilter(FilterSet): fields = ['subevent'] -class QuotaViewSet(viewsets.ReadOnlyModelViewSet): +class QuotaViewSet(viewsets.ModelViewSet): serializer_class = QuotaSerializer queryset = Quota.objects.none() filter_backends = (DjangoFilterBackend, OrderingFilter,) @@ -85,10 +86,80 @@ class QuotaViewSet(viewsets.ReadOnlyModelViewSet): ordering_fields = ('id', 'size') ordering = ('id',) permission = 'can_change_items' + write_permission = 'can_change_items' def get_queryset(self): return self.request.event.quotas.all() + def perform_create(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.quota.added', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + if serializer.instance.subevent: + serializer.instance.subevent.log_action( + 'pretix.subevent.quota.added', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + return ctx + + def perform_update(self, serializer): + current_subevent = serializer.instance.subevent + serializer.save(event=self.request.event) + request_subevent = serializer.instance.subevent + serializer.instance.log_action( + 'pretix.event.quota.changed', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + if current_subevent == request_subevent: + if current_subevent is not None: + current_subevent.log_action( + 'pretix.subevent.quota.changed', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + else: + if request_subevent is not None: + request_subevent.log_action( + 'pretix.subevent.quota.added', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + if current_subevent is not None: + current_subevent.log_action( + 'pretix.subevent.quota.deleted', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + ) + serializer.instance.rebuild_cache() + + def perform_destroy(self, instance): + instance.log_action( + 'pretix.event.quota.deleted', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + ) + if instance.subevent: + instance.subevent.log_action( + 'pretix.subevent.quota.deleted', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + ) + super().perform_destroy(instance) + @detail_route(methods=['get']) def availability(self, request, *args, **kwargs): quota = self.get_object() diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 3ec1d1d4d..aa65b264f 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -897,3 +897,30 @@ class Quota(LoggedModel): class QuotaExceededException(Exception): pass + + @staticmethod + def clean_variations(items, variations): + for variation in variations: + if variation.item not in items: + raise ValidationError(_('All variations must belong to an item contained in the items list.')) + break + + @staticmethod + def clean_items(event, items, variations): + for item in items: + if event != item.event: + raise ValidationError(_('One or more items do not belong to this event.')) + if item.has_variations: + if not any(var.item == item for var in variations): + raise ValidationError(_('One or more items has variations but none of these are in the variations list.')) + + @staticmethod + def clean_subevent(event, subevent): + if event.has_subevents: + if not subevent: + raise ValidationError(_('Subevent cannot be null for event series.')) + if event != subevent.event: + raise ValidationError(_('The subevent does not belong to this event.')) + else: + if subevent: + raise ValidationError(_('The subevent does not belong to this event.')) diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index c245b1c61..30035c2a8 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -33,6 +33,28 @@ def event(organizer, meta_prop): return e +@pytest.fixture +def event2(organizer, meta_prop): + e = Event.objects.create( + organizer=organizer, name='Dummy2', slug='dummy2', + date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC), + plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf' + ) + e.meta_values.create(property=meta_prop, value="Conference") + return e + + +@pytest.fixture +def event3(organizer, meta_prop): + e = Event.objects.create( + organizer=organizer, name='Dummy3', slug='dummy3', + date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC), + plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf' + ) + e.meta_values.create(property=meta_prop, value="Conference") + return e + + @pytest.fixture def team(organizer): return Team.objects.create( @@ -65,8 +87,17 @@ def token_client(client, team): def subevent(event, meta_prop): event.has_subevents = True event.save() - se = event.subevents.create(name="Foobar", - date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + se = event.subevents.create(name="Foobar", date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + + se.meta_values.create(property=meta_prop, value="Workshop") + return se + + +@pytest.fixture +def subevent2(event2, meta_prop): + event2.has_subevents = True + event2.save() + se = event2.subevents.create(name="Foobar", date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) se.meta_values.create(property=meta_prop, value="Workshop") return se diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 3a74c8955..9d466f874 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -2,6 +2,8 @@ from decimal import Decimal import pytest +from pretix.base.models import Quota + @pytest.fixture def category(event): @@ -55,6 +57,16 @@ def item(event): return event.items.create(name="Budget Ticket", default_price=23) +@pytest.fixture +def item2(event2): + return event2.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def item3(event): + return event.items.create(name="Budget Ticket", default_price=23) + + TEST_ITEM_RES = { "name": {"en": "Budget Ticket"}, "default_price": "23.00", @@ -199,6 +211,22 @@ TEST_QUOTA_RES = { } +@pytest.fixture +def variations(item): + v = list() + v.append(item.variations.create(value="ChildA1")) + v.append(item.variations.create(value="ChildA2")) + return v + + +@pytest.fixture +def variations2(item2): + v = list() + v.append(item2.variations.create(value="ChildB1")) + v.append(item2.variations.create(value="ChildB2")) + return v + + @pytest.mark.django_db def test_quota_list(token_client, organizer, event, quota, item, subevent): res = dict(TEST_QUOTA_RES) @@ -232,6 +260,171 @@ def test_quota_detail(token_client, organizer, event, quota, item): assert res == resp.data +@pytest.mark.django_db +def test_quota_create(token_client, organizer, event, event2, item): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event.slug), + { + "name": "Ticket Quota", + "size": 200, + "items": [item.pk], + "variations": [], + "subevent": None + }, + format='json' + ) + assert resp.status_code == 201 + quota = Quota.objects.get(pk=resp.data['id']) + assert quota.name == "Ticket Quota" + assert quota.size == 200 + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event2.slug), + { + "name": "Ticket Quota", + "size": 200, + "items": [item.pk], + "variations": [], + "subevent": None + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["One or more items do not belong to this event."]}' + + +@pytest.mark.django_db +def test_quota_create_with_variations(token_client, organizer, event, item, variations, variations2): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event.slug), + { + "name": "Ticket Quota", + "size": 200, + "items": [item.pk], + "variations": [variations[0].pk], + "subevent": None + }, + format='json' + ) + assert resp.status_code == 201 + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event.slug), + { + "name": "Ticket Quota", + "size": 200, + "items": [item.pk], + "variations": [100], + "subevent": None + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"variations":["Invalid pk \\"100\\" - object does not exist."]}' + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event.slug), + { + "name": "Ticket Quota", + "size": 200, + "items": [item.pk], + "variations": [variations[0].pk, variations2[0].pk], + "subevent": None + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["All variations must belong to an item contained in the items list."]}' + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event.slug), + { + "name": "Ticket Quota", + "size": 200, + "items": [item.pk], + "variations": [], + "subevent": None + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["One or more items has variations but none of these are in the variations list."]}' + + +@pytest.mark.django_db +def test_quota_create_with_subevent(token_client, organizer, event, event3, item, variations, subevent, subevent2): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event.slug), + { + "name": "Ticket Quota", + "size": 200, + "items": [item.pk], + "variations": [variations[0].pk], + "subevent": subevent.pk + }, + format='json' + ) + assert resp.status_code == 201 + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event.slug), + { + "name": "Ticket Quota", + "size": 200, + "items": [item.pk], + "variations": [variations[0].pk], + "subevent": None + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["Subevent cannot be null for event series."]}' + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event.slug), + { + "name": "Ticket Quota", + "size": 200, + "items": [item.pk], + "variations": [variations[0].pk], + "subevent": subevent2.pk + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["The subevent does not belong to this event."]}' + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event3.slug), + { + "name": "Ticket Quota", + "size": 200, + "items": [], + "variations": [], + "subevent": subevent2.pk + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["The subevent does not belong to this event."]}' + + +@pytest.mark.django_db +def test_quota_update(token_client, organizer, event, quota, item): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/quotas/{}/'.format(organizer.slug, event.slug, quota.pk), + { + "name": "Ticket Quota Update", + "size": 111, + }, + format='json' + ) + assert resp.status_code == 200 + quota = Quota.objects.get(pk=resp.data['id']) + assert quota.name == "Ticket Quota Update" + assert quota.size == 111 + + @pytest.mark.django_db def test_quota_availability(token_client, organizer, event, quota, item): resp = token_client.get('/api/v1/organizers/{}/events/{}/quotas/{}/availability/'.format( diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 26c70d1cc..f06875be4 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -34,6 +34,10 @@ event_permission_urls = [ ('put', 'can_change_vouchers', 'vouchers/1/', 404), ('patch', 'can_change_vouchers', 'vouchers/1/', 404), ('delete', 'can_change_vouchers', 'vouchers/1/', 404), + ('post', 'can_change_items', 'quotas/', 400), + ('put', 'can_change_items', 'quotas/1/', 404), + ('patch', 'can_change_items', 'quotas/1/', 404), + ('delete', 'can_change_items', 'quotas/1/', 404), ('post', 'can_change_orders', 'orders/ABC12/mark_paid/', 404), ('post', 'can_change_orders', 'orders/ABC12/mark_pending/', 404), ('post', 'can_change_orders', 'orders/ABC12/mark_expired/', 404),