diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 81ccaa7b5..827c1292a 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -10,6 +10,8 @@ Resources and endpoints taxrules categories items + item_variations + item_add-ons questions quotas orders diff --git a/doc/api/resources/item_add-ons.rst b/doc/api/resources/item_add-ons.rst new file mode 100644 index 000000000..2e861dafd --- /dev/null +++ b/doc/api/resources/item_add-ons.rst @@ -0,0 +1,246 @@ +Item add-ons +===== + +Resource description +-------------------- + +With add-ons, you can specify products that can be bought as an addition to this specific product. For example, if you +host a conference with a base conference ticket and a number of workshops, you could define the workshops as add-ons to +the conference ticket. With this configuration, the workshops cannot be bought on their own but only in combination with +a conference ticket. You can here specify categories of products that can be used as add-ons to this product. You can +also specify the minimum and maximum number of add-ons of the given category that can or need to be chosen. The user can +buy every add-on from the category at most once. If an add-on product has multiple variations, only one of them can be +bought. +The add-ons resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the add-on +addon_category integer Internal ID of the item category the add-on can be + chosen from. +min_count integer The minimal number of add-ons that need to be chosen. +max_count integer The maximal number of add-ons that can be chosen. +position integer An integer, used for sorting +price_included boolean Adding this add-on to the item is free +===================================== ========================== ======================================================= + +.. versionchanged:: 1.12 + + This resource has been added. + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/ + + Returns a list of all add-ons for a given item. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/items/11/addons/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 3, + "addon_category": 1, + "min_count": 0, + "max_count": 10, + "position": 0, + "price_included": true + }, + { + "id": 4, + "addon_category": 2, + "min_count": 0, + "max_count": 10, + "position": 1, + "price_included": true + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param item: The ``id`` field of the item to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/(id)/ + + Returns information on one add-on, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/items/1/addons/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 3, + "addon_category": 1, + "min_count": 0, + "max_count": 10, + "position": 1, + "price_included": true + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param item: The ``id`` field of the item to fetch + :param id: The ``id`` field of the add-on to fetch + :statuscode 200: no error + :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/bigevents/events/sampleconf/items/1/addons/ + + Creates a new add-on + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "addon_category": 1, + "min_count": 0, + "max_count": 10, + "position": 1, + "price_included": true + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 3, + "addon_category": 1, + "min_count": 0, + "max_count": 10, + "position": 1, + "price_included": true + } + + :param organizer: The ``slug`` field of the organizer of the event/item to create a add-on for + :param event: The ``slug`` field of the event to create a add-on for + :param item: The ``id`` field of the item to create a add-on for + :statuscode 201: no error + :statuscode 400: The add-on 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)/items/(item)/addon/(id)/ + + Update an add-on. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset 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/items/1/addons/3/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "min_count": 0, + "max_count": 10, + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 3, + "addon_category": 1, + "min_count": 0, + "max_count": 10, + "position": 1, + "price_included": true + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param item: The ``id`` field of the item to modify + :param id: The ``id`` field of the add-on to modify + :statuscode 200: no error + :statuscode 400: The add-on 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)/items/(id)/addons/(id)/ + + Delete an add-on. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/addons/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 item to modify + :param id: The ``id`` field of the add-on 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/doc/api/resources/item_variations.rst b/doc/api/resources/item_variations.rst new file mode 100644 index 000000000..546f2a006 --- /dev/null +++ b/doc/api/resources/item_variations.rst @@ -0,0 +1,258 @@ +Item variations +===== + +Resource description +-------------------- + +Variations of items can be use for products (items) that are available in different sizes, colors or other variations +of the same product. +The addons resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the variation +default_price money (string) The price set directly for this variation or ``null`` +price money (string) The price used for this variation. This is either the + same as ``default_price`` if that value is set or equal + to the item's ``default_price`` (read-only). +active boolean If ``False``, this variation will not be sold or shown. +description multi-lingual string A public description of the variation. May contain + Markdown syntax or can be ``null``. +position integer An integer, used for sorting +===================================== ========================== ======================================================= + +.. versionchanged:: 1.12 + + This resource has been added. + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/variations/ + + Returns a list of all variations for a given item. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/items/11/variations/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "value": { + "en": "S" + }, + "active": true, + "description": { + "en": "Test2" + }, + "position": 0, + "default_price": "223.00", + "price": 223.0 + }, + { + "id": 3, + "value": { + "en": "L" + }, + "active": true, + "description": {}, + "position": 1, + "default_price": null, + "price": 15.0 + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query boolean active: If set to ``true`` or ``false``, only items with this value for the field ``active`` will be + returned. + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param item: The ``id`` field of the item to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/variations/(id)/ + + Returns information on one variation, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 3, + "value": { + "en": "Student" + }, + "default_price": "10.00", + "price": "10.00", + "active": true, + "description": null, + "position": 0 + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param item: The ``id`` field of the item to fetch + :param id: The ``id`` field of the variation to fetch + :statuscode 200: no error + :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)/items/(item)/variations/ + + Creates a new variation + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "value": {"en": "Student"}, + "default_price": "10.00", + "active": true, + "description": null, + "position": 0 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "value": {"en": "Student"}, + "default_price": "10.00", + "price": "10.00", + "active": true, + "description": null, + "position": 0 + } + + :param organizer: The ``slug`` field of the organizer of the event/item to create a variation for + :param event: The ``slug`` field of the event to create a variation for + :param item: The ``id`` field of the item to create a variation for + :statuscode 201: no error + :statuscode 400: The variation 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)/items/(item)/variations/(id)/ + + Update a variation. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset 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`` and the ``price`` field. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "active": false, + "position": 1 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "value": {"en": "Student"}, + "default_price": "10.00", + "price": "10.00", + "active": false, + "description": null, + "position": 1 + } + + :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 item to modify + :param id: The ``id`` field of the variation to modify + :statuscode 200: no error + :statuscode 400: The variation 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)/items/(id)/variations/(id)/ + + Delete a variation. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/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 item to modify + :param id: The ``id`` field of the variation 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/doc/api/resources/items.rst b/doc/api/resources/items.rst index 7e2c72a14..480437fff 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -33,6 +33,7 @@ admission boolean ``True`` for it (such as add-ons or merchandise). position integer An integer, used for sorting picture string A product picture to be displayed in the shop + (read-only). available_from datetime The first date time at which this item can be bought (or ``null``). available_until datetime The last date time at which this item can be bought @@ -53,10 +54,9 @@ max_per_order integer This product ca checkin_attention boolean If ``True``, the check-in app should show a warning that this ticket requires special attention if such a product is being scanned. -has_variations boolean Shows whether or not this item has variations - (read-only). +has_variations boolean Shows whether or not this item has variations. variations list of objects A list with one object for each variation of this item. - Can be empty. + Can be empty. Only writable on POST. ├ id integer Internal ID of the variation ├ default_price money (string) The price set directly for this variation or ``null`` ├ price money (string) The price used for this variation. This is either the @@ -66,12 +66,14 @@ variations list of objects A list with one ├ description multi-lingual string A public description of the variation. May contain Markdown syntax or can be ``null``. └ position integer An integer, used for sorting -addons list of objects Definition of add-ons that can be chosen for this item +addons list of objects Definition of add-ons that can be chosen for this item. + Only writable on POST. ├ addon_category integer Internal ID of the item category the add-on can be chosen from. ├ min_count integer The minimal number of add-ons that need to be chosen. ├ max_count integer The maximal number of add-ons that can be chosen. └ position integer An integer, used for sorting +└ price_included boolean Adding this add-on to the item is free ===================================== ========================== ======================================================= .. versionchanged:: 1.7 @@ -79,6 +81,20 @@ addons list of objects Definition of a The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility. The attribute ``checkin_attention`` has been added. +.. versionchanged:: 1.12 + + The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. + The attribute ``price_included`` has been added to ``addons``. + +Notes +----- +Please note that an item either always has variations or never has. Once created with variations the item can never +change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least +one variation. + +Also note that ``variations`` and ``addons`` are only supported on ``POST``. To update/delete variations and add-ons please +use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT`` with nested +``variations`` and/or ``addons``. Endpoints --------- @@ -239,3 +255,226 @@ Endpoints :statuscode 200: no error :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)/items/(item)/ + + Creates a new item + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/items/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "id": 1, + "name": {"en": "Standard ticket"}, + "default_price": "23.00", + "category": null, + "active": true, + "description": null, + "free_price": false, + "tax_rate": "0.00", + "tax_rule": 1, + "admission": false, + "position": 0, + "picture": null, + "available_from": null, + "available_until": null, + "require_voucher": false, + "hide_without_voucher": false, + "allow_cancel": true, + "min_per_order": null, + "max_per_order": null, + "checkin_attention": false, + "variations": [ + { + "value": {"en": "Student"}, + "default_price": "10.00", + "price": "10.00", + "active": true, + "description": null, + "position": 0 + }, + { + "value": {"en": "Regular"}, + "default_price": null, + "price": "23.00", + "active": true, + "description": null, + "position": 1 + } + ], + "addons": [] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "name": {"en": "Standard ticket"}, + "default_price": "23.00", + "category": null, + "active": true, + "description": null, + "free_price": false, + "tax_rate": "0.00", + "tax_rule": 1, + "admission": false, + "position": 0, + "picture": null, + "available_from": null, + "available_until": null, + "require_voucher": false, + "hide_without_voucher": false, + "allow_cancel": true, + "min_per_order": null, + "max_per_order": null, + "checkin_attention": false, + "has_variations": true, + "variations": [ + { + "value": {"en": "Student"}, + "default_price": "10.00", + "price": "10.00", + "active": true, + "description": null, + "position": 0 + }, + { + "value": {"en": "Regular"}, + "default_price": null, + "price": "23.00", + "active": true, + "description": null, + "position": 1 + } + ], + "addons": [] + } + + :param organizer: The ``slug`` field of the organizer of the event to create an item for + :param event: The ``slug`` field of the event to create an item for + :statuscode 201: no error + :statuscode 400: The item 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)/items/(item)/ + + Update an item. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset 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 ``has_variations``, ``variations`` and the ``addon`` field. If + you need to update/delete variations or add-ons please use the nested dedicated endpoints. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "name": {"en": "Ticket"}, + "default_price": "25.00" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "name": {"en": "Ticket"}, + "default_price": "25.00", + "category": null, + "active": true, + "description": null, + "free_price": false, + "tax_rate": "0.00", + "tax_rule": 1, + "admission": false, + "position": 0, + "picture": null, + "available_from": null, + "available_until": null, + "require_voucher": false, + "hide_without_voucher": false, + "allow_cancel": true, + "min_per_order": null, + "max_per_order": null, + "checkin_attention": false, + "has_variations": true, + "variations": [ + { + "value": {"en": "Student"}, + "default_price": "10.00", + "price": "10.00", + "active": true, + "description": null, + "position": 0 + }, + { + "value": {"en": "Regular"}, + "default_price": null, + "price": "23.00", + "active": true, + "description": null, + "position": 1 + } + ], + "addons": [] + } + + :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 item to modify + :statuscode 200: no error + :statuscode 400: The item 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)/items/(id)/ + + Delete an item. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/items/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 item 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 a7bc28fcc..4bdde2a18 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -1,5 +1,8 @@ from decimal import Decimal +from django.core.exceptions import ValidationError +from django.db import transaction +from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers from pretix.api.serializers.i18n import I18nAwareModelSerializer @@ -16,11 +19,44 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer): 'position', 'default_price', 'price') +class ItemVariationSerializer(I18nAwareModelSerializer): + class Meta: + model = ItemVariation + fields = ('id', 'value', 'active', 'description', + 'position', 'default_price', 'price') + + class InlineItemAddOnSerializer(serializers.ModelSerializer): class Meta: model = ItemAddOn fields = ('addon_category', 'min_count', 'max_count', - 'position') + 'position', 'price_included') + + +class ItemAddOnSerializer(serializers.ModelSerializer): + class Meta: + model = ItemAddOn + fields = ('id', 'addon_category', 'min_count', 'max_count', + 'position', 'price_included') + + def validate(self, data): + data = super().validate(data) + + ItemAddOn.clean_max_min_count(data.get('max_count'), data.get('min_count')) + + return data + + def validate_min_count(self, value): + ItemAddOn.clean_min_count(value) + return value + + def validate_max_count(self, value): + ItemAddOn.clean_max_count(value) + return value + + def validate_addon_category(self, value): + ItemAddOn.clean_categories(self.context['event'], self.context['item'], self.instance, value) + return value class ItemTaxRateField(serializers.Field): @@ -32,8 +68,8 @@ class ItemTaxRateField(serializers.Field): class ItemSerializer(I18nAwareModelSerializer): - addons = InlineItemAddOnSerializer(many=True) - variations = InlineItemVariationSerializer(many=True) + addons = InlineItemAddOnSerializer(many=True, required=False) + variations = InlineItemVariationSerializer(many=True, required=False) tax_rate = ItemTaxRateField(source='*', read_only=True) class Meta: @@ -44,6 +80,55 @@ class ItemSerializer(I18nAwareModelSerializer): 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'addons') + read_only_fields = ('has_variations', 'picture') + + def get_serializer_context(self): + return {"has_variations": self.kwargs['has_variations']} + + def validate(self, data): + data = super().validate(data) + + Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order')) + Item.clean_available(data.get('available_from'), data.get('available_until')) + + return data + + def validate_category(self, value): + Item.clean_category(value, self.context['event']) + return value + + def validate_tax_rule(self, value): + Item.clean_tax_rule(value, self.context['event']) + return value + + def validate_variations(self, value): + if self.instance is not None: + raise ValidationError(_('Updating variations via PATCH/PUT is not supported. Please use the dedicated' + ' nested endpoint.')) + return value + + def validate_addons(self, value): + if self.instance is not None: + raise ValidationError(_('Updating add-ons via PATCH/PUT is not supported. Please use the dedicated' + ' nested endpoint.')) + else: + for addon_data in value: + ItemAddOn.clean_categories(self.context['event'], None, self.instance, addon_data['addon_category']) + ItemAddOn.clean_min_count(addon_data['min_count']) + ItemAddOn.clean_max_count(addon_data['max_count']) + ItemAddOn.clean_max_min_count(addon_data['max_count'], addon_data['min_count']) + return value + + @transaction.atomic + def create(self, validated_data): + variations_data = validated_data.pop('variations') if 'variations' in validated_data else {} + addons_data = validated_data.pop('addons') if 'addons' in validated_data else {} + item = Item.objects.create(**validated_data) + for variation_data in variations_data: + ItemVariation.objects.create(item=item, **variation_data) + for addon_data in addons_data: + ItemAddOn.objects.create(base_item=item, **addon_data) + return item class ItemCategorySerializer(I18nAwareModelSerializer): diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 0a8e3b297..cae126957 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -29,6 +29,10 @@ event_router.register(r'checkinlists', checkin.CheckinListViewSet) checkinlist_router = routers.DefaultRouter() checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet) +item_router = routers.DefaultRouter() +item_router.register(r'variations', item.ItemVariationViewSet) +item_router.register(r'addons', item.ItemAddOnViewSet) + # Force import of all plugins to give them a chance to register URLs with the router for app in apps.get_app_configs(): if hasattr(app, 'PretixPluginMeta'): @@ -39,6 +43,7 @@ urlpatterns = [ url(r'^', include(router.urls)), url(r'^organizers/(?P[^/]+)/', include(orga_router.urls)), url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/', include(event_router.urls)), + url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/items/(?P[^/]+)/', include(item_router.urls)), url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/checkinlists/(?P[^/]+)/', include(checkinlist_router.urls)), ] diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 0818c2722..db12510a3 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -1,17 +1,22 @@ import django_filters from django.db.models import Q +from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend, FilterSet from rest_framework import viewsets from rest_framework.decorators import detail_route +from rest_framework.exceptions import PermissionDenied from rest_framework.filters import OrderingFilter from rest_framework.response import Response from pretix.api.serializers.item import ( - ItemCategorySerializer, ItemSerializer, QuestionSerializer, - QuotaSerializer, + ItemAddOnSerializer, ItemCategorySerializer, ItemSerializer, + ItemVariationSerializer, QuestionSerializer, QuotaSerializer, +) +from pretix.base.models import ( + Item, ItemAddOn, ItemCategory, ItemVariation, Question, Quota, ) -from pretix.base.models import Item, ItemCategory, Question, Quota from pretix.base.models.organizer import TeamAPIToken +from pretix.helpers.dicts import merge_dicts class ItemFilter(FilterSet): @@ -28,7 +33,7 @@ class ItemFilter(FilterSet): fields = ['active', 'category', 'admission', 'tax_rate', 'free_price'] -class ItemViewSet(viewsets.ReadOnlyModelViewSet): +class ItemViewSet(viewsets.ModelViewSet): serializer_class = ItemSerializer queryset = Item.objects.none() filter_backends = (DjangoFilterBackend, OrderingFilter) @@ -36,10 +41,159 @@ class ItemViewSet(viewsets.ReadOnlyModelViewSet): ordering = ('position', 'id') filter_class = ItemFilter permission = 'can_change_items' + write_permission = 'can_change_items' def get_queryset(self): return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons').all() + def perform_create(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.item.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 + ctx['has_variations'] = self.request.data.get('has_variations') + return ctx + + def perform_update(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.item.changed', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + + def perform_destroy(self, instance): + if not instance.allow_delete(): + raise PermissionDenied('This item cannot be deleted because it has already been ordered ' + 'by a user or currently is in a users\'s cart. Please set the item as ' + '"inactive" instead.') + + instance.log_action( + 'pretix.event.item.deleted', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + ) + super().perform_destroy(instance) + + +class ItemVariationViewSet(viewsets.ModelViewSet): + serializer_class = ItemVariationSerializer + queryset = ItemVariation.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter,) + ordering_fields = ('id', 'position') + ordering = ('id',) + permission = 'can_change_items' + write_permission = 'can_change_items' + + def get_queryset(self): + item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + return item.variations.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + return ctx + + def perform_create(self, serializer): + item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + if not item.has_variations: + raise PermissionDenied('This variation cannot be created because the item does not have variations. ' + 'Changing a product without variations to a product with variations is not allowed.') + serializer.save(item=item) + item.log_action( + 'pretix.event.item.variation.added', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}, + {'value': serializer.instance.value}) + ) + + def perform_update(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.item.log_action( + 'pretix.event.item.variation.changed', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}, + {'value': serializer.instance.value}) + ) + + def perform_destroy(self, instance): + if not instance.allow_delete(): + raise PermissionDenied('This variation cannot be deleted because it has already been ordered ' + 'by a user or currently is in a users\'s cart. Please set the variation as ' + '\'inactive\' instead.') + if instance.is_only_variation(): + raise PermissionDenied('This variation cannot be deleted because it is the only variation. Changing a ' + 'product with variations to a product without variations is not allowed.') + super().perform_destroy(instance) + instance.item.log_action( + 'pretix.event.item.variation.deleted', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data={ + 'value': instance.value, + 'id': self.kwargs['pk'] + } + ) + + +class ItemAddOnViewSet(viewsets.ModelViewSet): + serializer_class = ItemAddOnSerializer + queryset = ItemAddOn.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter,) + ordering_fields = ('id', 'position') + ordering = ('id',) + permission = 'can_change_items' + write_permission = 'can_change_items' + + def get_queryset(self): + item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + return item.addons.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + return ctx + + def perform_create(self, serializer): + item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category']) + serializer.save(base_item=item, addon_category=category) + item.log_action( + 'pretix.event.item.addons.added', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) + ) + + def perform_update(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.base_item.log_action( + 'pretix.event.item.addons.changed', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) + ) + + def perform_destroy(self, instance): + super().perform_destroy(instance) + instance.base_item.log_action( + 'pretix.event.item.addons.removed', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data={'category': instance.addon_category.pk} + ) + class ItemCategoryFilter(FilterSet): class Meta: diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 5df12d133..e7828c2e8 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -372,10 +372,41 @@ class Item(LoggedModel): return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas], key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize)) + def allow_delete(self): + from pretix.base.models.orders import CartPosition, OrderPosition + + return ( + not OrderPosition.objects.filter(item=self).exists() + and not CartPosition.objects.filter(item=self).exists() + ) + @cached_property def has_variations(self): return self.variations.exists() + @staticmethod + def clean_per_order(min_per_order, max_per_order): + if min_per_order is not None and max_per_order is not None: + if min_per_order > max_per_order: + raise ValidationError(_('The maximum number per order can not be lower than the minimum number per ' + 'order.')) + + @staticmethod + def clean_category(category, event): + if category is not None and category.event is not None and category.event != event: + raise ValidationError(_('The item\'s category must belong to the same event as the item.')) + + @staticmethod + def clean_tax_rule(tax_rule, event): + if tax_rule is not None and tax_rule.event is not None and tax_rule.event != event: + raise ValidationError(_('The item\'s tax rule must belong to the same event as the item.')) + + @staticmethod + def clean_available(from_date, until_date): + if from_date is not None and until_date is not None: + if from_date > until_date: + raise ValidationError(_('The item\'s availability cannot end before it starts.')) + class ItemVariation(models.Model): """ @@ -479,6 +510,17 @@ class ItemVariation(models.Model): return self.id < other.id return self.position < other.position + def allow_delete(self): + from pretix.base.models.orders import CartPosition, OrderPosition + + return ( + not OrderPosition.objects.filter(variation=self).exists() + and not CartPosition.objects.filter(variation=self).exists() + ) + + def is_only_variation(self): + return ItemVariation.objects.filter(item=self.item).count() == 1 + class ItemAddOn(models.Model): """ @@ -530,8 +572,34 @@ class ItemAddOn(models.Model): ordering = ('position', 'pk') def clean(self): - if self.max_count < self.min_count: - raise ValidationError(_('The minimum number needs to be lower than the maximum number.')) + self.clean_min_count(self.min_count) + self.clean_max_count(self.max_count) + self.clean_max_min_count(self.max_count, self.min_count) + + @staticmethod + def clean_categories(event, item, addon, new_category): + if event != new_category.event: + raise ValidationError(_('The add-on\'s category must belong to the same event as the item.')) + if item is not None: + if addon is None or addon.addon_category != new_category: + for addon in item.addons.all(): + if addon.addon_category == new_category: + raise ValidationError(_('The item already has an add-on of this category.')) + + @staticmethod + def clean_min_count(min_count): + if min_count < 0: + raise ValidationError(_('The minimum count needs to be equal to or greater than zero.')) + + @staticmethod + def clean_max_count(max_count): + if max_count < 0: + raise ValidationError(_('The maximum count needs to be equal to or greater than zero.')) + + @staticmethod + def clean_max_min_count(max_count, min_count): + if max_count < min_count: + raise ValidationError(_('The maximum count needs to be greater than the minimum count.')) class Question(LoggedModel): diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index 47cc9adb3..bd887bb10 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -1031,7 +1031,8 @@ class ItemDelete(EventPermissionRequiredMixin, DeleteView): @transaction.atomic def delete(self, request, *args, **kwargs): success_url = self.get_success_url() - if self.is_allowed(): + o = self.get_object() + if o.allow_delete(): self.get_object().cartposition_set.all().delete() self.get_object().log_action('pretix.event.item.deleted', user=self.request.user) self.get_object().delete() diff --git a/src/pretix/helpers/dicts.py b/src/pretix/helpers/dicts.py new file mode 100644 index 000000000..614283340 --- /dev/null +++ b/src/pretix/helpers/dicts.py @@ -0,0 +1,9 @@ +def merge_dicts(*dict_args): + """ + Given any number of dicts, shallow copy and merge into a new dict, + precedence goes to key value pairs in latter dicts. + """ + result = {} + for dictionary in dict_args: + result.update(dictionary) + return result diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index dfbf679a0..5c13e5811 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -117,3 +117,8 @@ def subevent2(event2, meta_prop): @pytest.fixture def taxrule(event): return event.tax_rules.create(name="VAT", rate=19) + + +@pytest.fixture +def taxrule2(event2): + return event2.tax_rules.create(name="VAT", rate=25) diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 943f60e53..610d1c066 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -1,8 +1,16 @@ +from datetime import datetime, timedelta from decimal import Decimal +from unittest import mock import pytest +from django_countries.fields import Country +from pytz import UTC -from pretix.base.models import Quota +from pretix.base.models import ( + CartPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Order, + OrderPosition, Quota, +) +from pretix.base.models.orders import OrderFee @pytest.fixture @@ -10,6 +18,64 @@ def category(event): return event.categories.create(name="Tickets") +@pytest.fixture +def category2(event2): + return event2.categories.create(name="Tickets2") + + +@pytest.fixture +def order(event, item, taxrule): + testtime = datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + o = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, secret="k24fiuwvu8kxz3y1", + datetime=datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC), + expires=datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC), + total=23, payment_provider='banktransfer', locale='en' + ) + o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'), + tax_value=Decimal('0.05'), tax_rule=taxrule) + InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ')) + return o + + +@pytest.fixture +def order_position(item, order, taxrule, variations): + op = OrderPosition.objects.create( + order=order, + item=item, + variation=variations[0], + tax_rule=taxrule, + tax_rate=taxrule.rate, + tax_value=Decimal("3"), + price=Decimal("23"), + attendee_name="Peter", + secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + ) + return op + + +@pytest.fixture +def cart_position(event, item, variations): + testtime = datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + c = CartPosition.objects.create( + event=event, + item=item, + datetime=datetime.now(), + expires=datetime.now() + timedelta(days=1), + variation=variations[0], + price=Decimal("23"), + cart_id="z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + ) + return c + + TEST_CATEGORY_RES = { "name": {"en": "Tickets"}, "description": {"en": ""}, @@ -187,7 +253,8 @@ def test_item_detail_addons(token_client, organizer, event, team, item, category "addon_category": category.pk, "min_count": 0, "max_count": 1, - "position": 0 + "position": 0, + "price_included": False }] resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk)) @@ -195,20 +262,390 @@ def test_item_detail_addons(token_client, organizer, event, team, item, category assert res == resp.data -@pytest.fixture -def quota(event, item): - q = event.quotas.create(name="Budget Quota", size=200) - q.items.add(item) - return q +@pytest.mark.django_db +def test_item_create(token_client, organizer, event, item, category, taxrule): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug), + { + "category": category.pk, + "name": { + "en": "Ticket" + }, + "active": True, + "description": None, + "default_price": "23.00", + "free_price": False, + "tax_rate": "19.00", + "tax_rule": taxrule.pk, + "admission": True, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "checkin_attention": False, + "has_variations": True + }, + format='json' + ) + assert resp.status_code == 201 -TEST_QUOTA_RES = { - "name": "Budget Quota", - "size": 200, - "items": [], - "variations": [], - "subevent": None -} +@pytest.mark.django_db +def test_item_create_with_variation(token_client, organizer, event, item, category, taxrule): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug), + { + "category": category.pk, + "name": { + "en": "Ticket" + }, + "active": True, + "description": None, + "default_price": "23.00", + "free_price": False, + "tax_rate": "19.00", + "tax_rule": taxrule.pk, + "admission": True, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "checkin_attention": False, + "has_variations": True, + "variations": [ + { + "value": { + "de": "Kommentar", + "en": "Comment" + }, + "active": True, + "description": None, + "position": 0, + "default_price": None, + "price": 23.0 + } + ] + }, + format='json' + ) + assert resp.status_code == 201 + new_item = Item.objects.get(pk=resp.data['id']) + assert new_item.variations.first().value.localize('de') == "Kommentar" + assert new_item.variations.first().value.localize('en') == "Comment" + + +@pytest.mark.django_db +def test_item_create_with_addon(token_client, organizer, event, item, category, category2, taxrule): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug), + { + "category": category.pk, + "name": { + "en": "Ticket" + }, + "active": True, + "description": None, + "default_price": "23.00", + "free_price": False, + "tax_rate": "19.00", + "tax_rule": taxrule.pk, + "admission": True, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "checkin_attention": False, + "has_variations": True, + "addons": [ + { + "addon_category": category.pk, + "min_count": 0, + "max_count": 10, + "position": 0, + "price_included": True + } + ] + }, + format='json' + ) + assert resp.status_code == 201 + item = Item.objects.get(pk=resp.data['id']) + assert item.addons.first().addon_category == category + assert item.addons.first().max_count == 10 + assert 2 == Item.objects.all().count() + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug), + { + "category": category.pk, + "name": { + "en": "Ticket" + }, + "active": True, + "description": None, + "default_price": "23.00", + "free_price": False, + "tax_rate": "19.00", + "tax_rule": taxrule.pk, + "admission": True, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "checkin_attention": False, + "has_variations": True, + "addons": [ + { + "addon_category": category2.pk, + "min_count": 0, + "max_count": 10, + "position": 0, + "price_included": True + } + ] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"addons":["The add-on\'s category must belong to the same event as the item."]}' + assert 2 == Item.objects.all().count() + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug), + { + "category": category.pk, + "name": { + "en": "Ticket" + }, + "active": True, + "description": None, + "default_price": "23.00", + "free_price": False, + "tax_rate": "19.00", + "tax_rule": taxrule.pk, + "admission": True, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "checkin_attention": False, + "has_variations": True, + "addons": [ + { + "addon_category": category.pk, + "min_count": 110, + "max_count": 10, + "position": 0, + "price_included": True + } + ] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"addons":["The maximum count needs to be greater than the minimum count."]}' + assert 2 == Item.objects.all().count() + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug), + { + "category": category.pk, + "name": { + "en": "Ticket" + }, + "active": True, + "description": None, + "default_price": "23.00", + "free_price": False, + "tax_rate": "19.00", + "tax_rule": taxrule.pk, + "admission": True, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "checkin_attention": False, + "has_variations": True, + "addons": [ + { + "addon_category": category.pk, + "min_count": -1, + "max_count": 10, + "position": 0, + "price_included": True + } + ] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"addons":["The minimum count needs to be equal to or greater than zero."]}' + assert 2 == Item.objects.all().count() + + +@pytest.mark.django_db +def test_item_update(token_client, organizer, event, item, category2, taxrule2): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk), + { + "min_per_order": 10, + "max_per_order": 2 + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["The maximum number per order can not be lower than the ' \ + 'minimum number per order."]}' + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk), + { + "available_from": "2017-12-30T12:00", + "available_until": "2017-12-29T12:00" + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["The item\'s availability cannot end before it starts."]}' + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk), + { + "category": category2.pk + }, + format='json' + + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"category":["The item\'s category must belong to the same event as the item."]}' + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk), + { + "tax_rule": taxrule2.pk + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"tax_rule":["The item\'s tax rule must belong to the same event as the item."]}' + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk), + { + "addons": [ + { + "addon_category": 1, + "min_count": 0, + "max_count": 10, + "position": 0, + "price_included": True + } + ] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"addons":["Updating add-ons via PATCH/PUT is not supported. Please use ' \ + 'the dedicated nested endpoint."]}' + + +@pytest.mark.django_db +def test_item_update_with_variation(token_client, organizer, event, item): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk), + { + "variations": [ + { + "value": { + "de": "Kommentar", + "en": "Comment" + }, + "active": True, + "description": None, + "position": 0, + "default_price": None, + "price": 23.0 + } + ] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"variations":["Updating variations via PATCH/PUT is not supported. Please use ' \ + 'the dedicated nested endpoint."]}' + + +@pytest.mark.django_db +def test_item_update_with_addon(token_client, organizer, event, item, category): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk), + { + "addons": [ + { + "addon_category": category.pk, + "min_count": 0, + "max_count": 10, + "position": 0, + "price_included": True + } + ] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"addons":["Updating add-ons via PATCH/PUT is not supported. Please use ' \ + 'the dedicated nested endpoint."]}' + + +@pytest.mark.django_db +def test_items_delete(token_client, organizer, event, item): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk)) + assert resp.status_code == 204 + assert not event.items.filter(pk=item.id).exists() + + +@pytest.mark.django_db +def test_items_with_order_position_not_delete(token_client, organizer, event, item, order_position): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk)) + assert resp.status_code == 403 + assert event.items.filter(pk=item.id).exists() + + +@pytest.mark.django_db +def test_items_with_cart_position_not_delete(token_client, organizer, event, item, cart_position): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk)) + assert resp.status_code == 403 + assert event.items.filter(pk=item.id).exists() @pytest.fixture @@ -227,6 +664,304 @@ def variations2(item2): return v +@pytest.fixture +def variation(item): + return item.variations.create(value="ChildC1") + + +TEST_VARIATIONS_RES = { + "value": { + "en": "ChildC1" + }, + "active": True, + "description": None, + "position": 0, + "default_price": None, + "price": 23.0 +} + +TEST_VARIATIONS_UPDATE = { + "value": { + "en": "ChildC2" + }, + "active": True, + "description": None, + "position": 1, + "default_price": None, + "price": 23.0 +} + + +@pytest.mark.django_db +def test_variations_list(token_client, organizer, event, item, variation): + res = dict(TEST_VARIATIONS_RES) + res["id"] = variation.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/variations/'.format(organizer.slug, event.slug, item.pk)) + assert resp.status_code == 200 + assert res['value'] == resp.data['results'][0]['value'] + assert res['position'] == resp.data['results'][0]['position'] + assert res['price'] == resp.data['results'][0]['price'] + + +@pytest.mark.django_db +def test_variations_detail(token_client, organizer, event, item, variation): + res = dict(TEST_VARIATIONS_RES) + res["id"] = variation.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/variations/{}/'.format(organizer.slug, event.slug, item.pk, variation.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_variations_create(token_client, organizer, event, item, variation): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/variations/'.format(organizer.slug, event.slug, item.pk), + { + "value": { + "en": "ChildC2" + }, + "active": True, + "description": None, + "position": 1, + "default_price": None, + "price": 23.0 + }, + format='json' + ) + assert resp.status_code == 201 + var = ItemVariation.objects.get(pk=resp.data['id']) + assert var.position == 1 + assert var.price == 23.0 + + +@pytest.mark.django_db +def test_variations_create_not_allowed(token_client, organizer, event, item): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/variations/'.format(organizer.slug, event.slug, item.pk), + { + "value": { + "en": "ChildC2" + }, + "active": True, + "description": None, + "position": 1, + "default_price": None, + "price": 23.0 + }, + format='json' + ) + assert resp.status_code == 403 + assert resp.content.decode() == '{"detail":"This variation cannot be created because the item does ' \ + 'not have variations. Changing a product without variations to a product with ' \ + 'variations is not allowed."}' + + +@pytest.mark.django_db +def test_variations_update(token_client, organizer, event, item, item3, variation): + res = dict(TEST_VARIATIONS_UPDATE) + res["id"] = variation.pk + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/variations/{}/'.format(organizer.slug, event.slug, item.pk, variation.pk), + { + "value": { + "en": "ChildC2" + }, + "position": 1 + }, + format='json' + ) + assert resp.status_code == 200 + assert res == resp.data + + # Variation exists but do not belong to item + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/variations/{}/'.format(organizer.slug, event.slug, item3.pk, variation.pk), + { + "position": 1 + }, + format='json' + ) + assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_variations_delete(token_client, organizer, event, item, variations, order): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/items/{}/variations/{}/'.format(organizer.slug, event.slug, item.pk, variations[0].pk)) + assert resp.status_code == 204 + assert not item.variations.filter(pk=variations[0].pk).exists() + + +@pytest.mark.django_db +def test_variations_with_order_position_not_delete(token_client, organizer, event, item, order, variations, order_position): + assert item.variations.filter(pk=variations[0].id).exists() + resp = token_client.delete('/api/v1/organizers/{}/events/{}/items/{}/variations/{}/'.format(organizer.slug, event.slug, item.pk, variations[0].pk)) + assert resp.status_code == 403 + assert resp.content.decode() == '{"detail":"This variation cannot be deleted because it has already been ordered ' \ + 'by a user or currently is in a users\'s cart. Please set the variation as ' \ + '\'inactive\' instead."}' + assert item.variations.filter(pk=variations[0].id).exists() + + +@pytest.mark.django_db +def test_variations_with_cart_position_not_delete(token_client, organizer, event, item, variations, cart_position): + assert item.variations.filter(pk=variations[0].id).exists() + resp = token_client.delete('/api/v1/organizers/{}/events/{}/items/{}/variations/{}/'.format(organizer.slug, event.slug, item.pk, variations[0].pk)) + assert resp.status_code == 403 + assert resp.content.decode() == '{"detail":"This variation cannot be deleted because it has already been ordered ' \ + 'by a user or currently is in a users\'s cart. Please set the variation as ' \ + '\'inactive\' instead."}' + assert item.variations.filter(pk=variations[0].id).exists() + + +@pytest.mark.django_db +def test_only_variation_not_delete(token_client, organizer, event, item, variation): + assert item.variations.filter(pk=variation.id).exists() + resp = token_client.delete('/api/v1/organizers/{}/events/{}/items/{}/variations/{}/'.format(organizer.slug, event.slug, item.pk, variation.pk)) + assert resp.status_code == 403 + assert resp.content.decode() == '{"detail":"This variation cannot be deleted because it is the only variation. ' \ + 'Changing a product with variations to a product without variations is not ' \ + 'allowed."}' + assert item.variations.filter(pk=variation.id).exists() + + +@pytest.fixture +def addon(item, category): + return item.addons.create(addon_category=category, min_count=0, max_count=10, position=1) + + +TEST_ADDONS_RES = { + "min_count": 0, + "max_count": 10, + "position": 1, + "price_included": False +} + + +@pytest.mark.django_db +def test_addons_list(token_client, organizer, event, item, addon, category): + res = dict(TEST_ADDONS_RES) + res["id"] = addon.pk + res["addon_category"] = category.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/addons/'.format(organizer.slug, event.slug, + item.pk)) + assert resp.status_code == 200 + assert res['addon_category'] == resp.data['results'][0]['addon_category'] + assert res['min_count'] == resp.data['results'][0]['min_count'] + assert res['max_count'] == resp.data['results'][0]['max_count'] + assert res['position'] == resp.data['results'][0]['position'] + + +@pytest.mark.django_db +def test_addons_detail(token_client, organizer, event, item, addon, category): + res = dict(TEST_ADDONS_RES) + res["id"] = addon.pk + res["addon_category"] = category.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/addons/{}/'.format(organizer.slug, event.slug, + item.pk, addon.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_addons_create(token_client, organizer, event, item, category, category2): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/addons/'.format(organizer.slug, event.slug, item.pk), + { + "addon_category": category.pk, + "min_count": 0, + "max_count": 10, + "position": 1, + "price_included": False + }, + format='json' + ) + assert resp.status_code == 201 + addon = ItemAddOn.objects.get(pk=resp.data['id']) + assert addon.position == 1 + assert addon.addon_category == category + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/addons/'.format(organizer.slug, event.slug, item.pk), + { + "addon_category": category.pk, + "min_count": 10, + "max_count": 20, + "position": 2, + "price_included": False + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"addon_category":["The item already has an add-on of this category."]}' + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/addons/'.format(organizer.slug, event.slug, item.pk), + { + "addon_category": category2.pk, + "min_count": 10, + "max_count": 20, + "position": 2, + "price_included": False + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"addon_category":["The add-on\'s category must belong to the same event as ' \ + 'the item."]}' + + +@pytest.mark.django_db +def test_addons_update(token_client, organizer, event, item, addon): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/addons/{}/'.format(organizer.slug, event.slug, item.pk, addon.pk), + { + "min_count": 100, + "max_count": 101 + }, + format='json' + ) + assert resp.status_code == 200 + a = ItemAddOn.objects.get(pk=addon.pk) + assert a.min_count == 100 + assert a.max_count == 101 + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/addons/{}/'.format(organizer.slug, event.slug, item.pk, a.pk), + { + "min_count": 10, + "max_count": 1 + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["The maximum count needs to be greater than the minimum ' \ + 'count."]}' + + +@pytest.mark.django_db +def test_addons_delete(token_client, organizer, event, item, addon): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/items/{}/addons/{}/'.format(organizer.slug, event.slug, + item.pk, addon.pk)) + assert resp.status_code == 204 + assert not item.addons.filter(pk=addon.id).exists() + + +@pytest.fixture +def quota(event, item): + q = event.quotas.create(name="Budget Quota", size=200) + q.items.add(item) + return q + + +TEST_QUOTA_RES = { + "name": "Budget Quota", + "size": 200, + "items": [], + "variations": [], + "subevent": None +} + + @pytest.mark.django_db def test_quota_list(token_client, organizer, event, quota, item, subevent): res = dict(TEST_QUOTA_RES) @@ -425,6 +1160,13 @@ def test_quota_update(token_client, organizer, event, quota, item): assert quota.size == 111 +@pytest.mark.django_db +def test_quota_delete(token_client, organizer, event, quota): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/quotas/{}/'.format(organizer.slug, event.slug, quota.pk)) + assert resp.status_code == 204 + assert not event.quotas.filter(pk=quota.id).exists() + + @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 de1249fde..3e4d056b6 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -30,6 +30,18 @@ event_permission_urls = [ ('get', 'can_change_items', 'items/', 200), ('get', 'can_change_items', 'questions/', 200), ('get', 'can_change_items', 'quotas/', 200), + ('post', 'can_change_items', 'items/', 400), + ('put', 'can_change_items', 'items/1/', 404), + ('patch', 'can_change_items', 'items/1/', 404), + ('delete', 'can_change_items', 'items/1/', 404), + ('post', 'can_change_items', 'items/1/variations/', 404), + ('put', 'can_change_items', 'items/1/variations/1/', 404), + ('patch', 'can_change_items', 'items/1/variations/1/', 404), + ('delete', 'can_change_items', 'items/1/variations/1/', 404), + ('post', 'can_change_items', 'items/1/addons/', 404), + ('put', 'can_change_items', 'items/1/addons/1/', 404), + ('patch', 'can_change_items', 'items/1/addons/1/', 404), + ('delete', 'can_change_items', 'items/1/addons/1/', 404), ('post', 'can_change_event_settings', 'taxrules/', 400), ('put', 'can_change_event_settings', 'taxrules/1/', 404), ('patch', 'can_change_event_settings', 'taxrules/1/', 404),