diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 827c1292a8..265f9f8764 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -13,6 +13,7 @@ Resources and endpoints item_variations item_add-ons questions + question_options quotas orders invoices diff --git a/doc/api/resources/item_add-ons.rst b/doc/api/resources/item_add-ons.rst index 78417220b2..cae647f9ba 100644 --- a/doc/api/resources/item_add-ons.rst +++ b/doc/api/resources/item_add-ons.rst @@ -148,7 +148,7 @@ Endpoints .. sourcecode:: http - HTTP/1.1 200 OK + HTTP/1.1 201 Created Vary: Accept Content-Type: application/json diff --git a/doc/api/resources/item_variations.rst b/doc/api/resources/item_variations.rst index 24f0edf86a..677bf5008c 100644 --- a/doc/api/resources/item_variations.rst +++ b/doc/api/resources/item_variations.rst @@ -6,7 +6,7 @@ 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: +The variations resource contains the following public fields: .. rst-class:: rest-resource-table @@ -158,7 +158,7 @@ Endpoints .. sourcecode:: http - HTTP/1.1 200 OK + HTTP/1.1 201 Created Vary: Accept Content-Type: application/json diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 480437fff9..6e81db49be 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -56,7 +56,8 @@ checkin_attention boolean If ``True``, th a product is being scanned. 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. Only writable on POST. + Can be empty. Only writable during creation, + use separate endpoint to modify this later. ├ 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 @@ -67,7 +68,8 @@ variations list of objects A list with one 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. - Only writable on POST. + Only writable during creation, + use separate endpoint to modify this later. ├ 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. @@ -256,7 +258,7 @@ 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)/items/(item)/ +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/items/ Creates a new item @@ -315,7 +317,7 @@ Endpoints .. sourcecode:: http - HTTP/1.1 200 OK + HTTP/1.1 201 Created Vary: Accept Content-Type: application/json @@ -369,7 +371,7 @@ Endpoints :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)/ +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/ 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 diff --git a/doc/api/resources/question_options.rst b/doc/api/resources/question_options.rst new file mode 100644 index 0000000000..fa1d3f6bac --- /dev/null +++ b/doc/api/resources/question_options.rst @@ -0,0 +1,233 @@ +Question options +================ + +Resource description +-------------------- + +Questions of type "choice" or "multiple choice" can have different options attached. +The options resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the option +position integer An integer, used for sorting +identifier string An arbitrary string that can be used for matching with + other sources. +answer multi-lingual string The displayed value of this option +===================================== ========================== ======================================================= + +.. versionchanged:: 1.12 + + This resource has been added. + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questions/(question)/options/ + + Returns a list of all options for a given question. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/questions/11/options/ 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, + "identifier": "LVETRWVU", + "position": 1, + "answer": {"en": "S"} + }, + { + "id": 2, + "identifier": "DFEMJWMJ", + "position": 2, + "answer": {"en": "M"} + }, + { + "id": 3, + "identifier": "W9AH7RDE", + "position": 3, + "answer": {"en": "L"} + } + ] + } + + :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 questions 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 question: The ``id`` field of the question to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event/question does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questions/(question)/options/(id)/ + + Returns information on one option, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/questions/1/options/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": 1, + "identifier": "LVETRWVU", + "position": 1, + "answer": {"en": "S"} + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param question: The ``id`` field of the question to fetch + :param id: The ``id`` field of the option 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)/questions/(question)/options/ + + Creates a new option + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/questions/1/options/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "identifier": "LVETRWVU", + "position": 1, + "answer": {"en": "S"} + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "identifier": "LVETRWVU", + "position": 1, + "answer": {"en": "S"} + } + + :param organizer: The ``slug`` field of the organizer of the event/question to create a option for + :param event: The ``slug`` field of the event to create a option for + :param question: The ``id`` field of the question to create a option for + :statuscode 201: no error + :statuscode 400: The option 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)/questions/(question)/options/(id)/ + + Update an option. 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/questions/1/options/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "position": 3 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "identifier": "LVETRWVU", + "position": 1, + "answer": {"en": "S"} + } + + :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 question to modify + :param id: The ``id`` field of the option to modify + :statuscode 200: no error + :statuscode 400: The option 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)/questions/(id)/options/(id)/ + + Delete an option. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/questions/1/options/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 question to modify + :param id: The ``id`` field of the option 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/questions.rst b/doc/api/resources/questions.rst index 740622c2e3..167d037993 100644 --- a/doc/api/resources/questions.rst +++ b/doc/api/resources/questions.rst @@ -37,8 +37,10 @@ ask_during_checkin boolean If ``True``, th buying the ticket, but will show up when redeeming the ticket instead. options list of objects In case of question type ``C`` or ``M``, this lists the - available objects. + available objects. Only writable during creation, + use separate endpoint to modify this later. ├ id integer Internal ID of the option +├ position integer An integer, used for sorting ├ identifier string An arbitrary string that can be used for matching with other sources. └ answer multi-lingual string The displayed value of this option @@ -51,7 +53,8 @@ options list of objects In case of ques .. versionchanged:: 1.14 - The attribute ``identifier`` has been added to both the resource itself and the ``options`` subresource. + Write methods have been added. The attribute ``identifier`` has been added to both the resource itself and the + options resource. The ``position`` attribute has been added to the options resource. Endpoints --------- @@ -94,16 +97,19 @@ Endpoints { "id": 1, "identifier": "LVETRWVU", + "position": 0, "answer": {"en": "S"} }, { "id": 2, "identifier": "DFEMJWMJ", + "position": 1, "answer": {"en": "M"} }, { "id": 3, "identifier": "W9AH7RDE", + "position": 2, "answer": {"en": "L"} } ] @@ -153,16 +159,19 @@ Endpoints { "id": 1, "identifier": "LVETRWVU", + "position": 1, "answer": {"en": "S"} }, { "id": 2, "identifier": "DFEMJWMJ", + "position": 2, "answer": {"en": "M"} }, { "id": 3, "identifier": "W9AH7RDE", + "position": 3, "answer": {"en": "L"} } ] @@ -174,3 +183,179 @@ 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)/questions/ + + Creates a new question + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/questions/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "question": {"en": "T-Shirt size"}, + "type": "C", + "required": false, + "items": [1, 2], + "position": 1, + "ask_during_checkin": false, + "options": [ + { + "answer": {"en": "S"} + }, + { + "answer": {"en": "M"} + }, + { + "answer": {"en": "L"} + } + ] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + + { + "id": 1, + "question": {"en": "T-Shirt size"}, + "type": "C", + "required": false, + "items": [1, 2], + "position": 1, + "identifier": "WY3TP9SL", + "ask_during_checkin": false, + "options": [ + { + "id": 1, + "identifier": "LVETRWVU", + "position": 1, + "answer": {"en": "S"} + }, + { + "id": 2, + "identifier": "DFEMJWMJ", + "position": 2, + "answer": {"en": "M"} + }, + { + "id": 3, + "identifier": "W9AH7RDE", + "position": 3, + "answer": {"en": "L"} + } + ] + } + + :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)/questions/(id)/ + + Update a question. 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 ``options`` field. If + you need to update/delete options 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 + + { + "position": 2 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "question": {"en": "T-Shirt size"}, + "type": "C", + "required": false, + "items": [1, 2], + "position": 2, + "identifier": "WY3TP9SL", + "ask_during_checkin": false, + "options": [ + { + "id": 1, + "identifier": "LVETRWVU", + "position": 1, + "answer": {"en": "S"} + }, + { + "id": 2, + "identifier": "DFEMJWMJ", + "position": 2, + "answer": {"en": "M"} + }, + { + "id": 3, + "identifier": "W9AH7RDE", + "position": 3, + "answer": {"en": "L"} + } + ] + } + + :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 question 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)/questions/(id)/ + + Delete a question. + + **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/doc/api/resources/quotas.rst b/doc/api/resources/quotas.rst index 768c0205b7..30a2d5bb77 100644 --- a/doc/api/resources/quotas.rst +++ b/doc/api/resources/quotas.rst @@ -135,7 +135,7 @@ Endpoints .. sourcecode:: http - HTTP/1.1 200 OK + HTTP/1.1 201 Created Vary: Accept Content-Type: application/json diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 56a923debc..9378fcff93 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -132,21 +132,73 @@ class ItemCategorySerializer(I18nAwareModelSerializer): fields = ('id', 'name', 'description', 'position', 'is_addon') -class InlineQuestionOptionSerializer(I18nAwareModelSerializer): +class QuestionOptionSerializer(I18nAwareModelSerializer): + identifier = serializers.CharField(allow_null=True) class Meta: model = QuestionOption - fields = ('id', 'identifier', 'answer') + fields = ('id', 'identifier', 'answer', 'position') + + def validate_identifier(self, value): + QuestionOption.clean_identifier(self.context['event'], value, self.instance) + return value + + +class InlineQuestionOptionSerializer(I18nAwareModelSerializer): + identifier = serializers.CharField(allow_null=True) + + class Meta: + model = QuestionOption + fields = ('id', 'identifier', 'answer', 'position') class QuestionSerializer(I18nAwareModelSerializer): - options = InlineQuestionOptionSerializer(many=True) + options = InlineQuestionOptionSerializer(many=True, required=False) + identifier = serializers.CharField(allow_null=True) class Meta: model = Question fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position', 'ask_during_checkin', 'identifier') + def validate_identifier(self, value): + Question._clean_identifier(self.context['event'], value, self.instance) + return value + + def validate(self, data): + data = super().validate(data) + if self.instance and 'options' in data: + raise ValidationError(_('Updating options via PATCH/PUT is not supported. Please use the dedicated' + ' nested endpoint.')) + + event = self.context['event'] + + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} + full_data.update(data) + + Question.clean_items(event, full_data.get('items')) + return data + + def validate_options(self, value): + if not self.instance: + known = [] + for opt_data in value: + if opt_data.get('identifier'): + QuestionOption.clean_identifier(self.context['event'], opt_data.get('identifier'), self.instance, + known) + known.append(opt_data.get('identifier')) + return value + + @transaction.atomic + def create(self, validated_data): + options_data = validated_data.pop('options') if 'options' in validated_data else [] + items = validated_data.pop('items') + question = Question.objects.create(**validated_data) + question.items.set(items) + for opt_data in options_data: + QuestionOption.objects.create(question=question, **opt_data) + return question + class QuotaSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index cae126957a..0aedd5fbdf 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -29,6 +29,9 @@ event_router.register(r'checkinlists', checkin.CheckinListViewSet) checkinlist_router = routers.DefaultRouter() checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet) +question_router = routers.DefaultRouter() +question_router.register(r'options', item.QuestionOptionViewSet) + item_router = routers.DefaultRouter() item_router.register(r'variations', item.ItemVariationViewSet) item_router.register(r'addons', item.ItemAddOnViewSet) @@ -44,6 +47,8 @@ urlpatterns = [ 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[^/]+)/questions/(?P[^/]+)/', + include(question_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 db12510a33..0c10260a6e 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -10,10 +10,12 @@ from rest_framework.response import Response from pretix.api.serializers.item import ( ItemAddOnSerializer, ItemCategorySerializer, ItemSerializer, - ItemVariationSerializer, QuestionSerializer, QuotaSerializer, + ItemVariationSerializer, QuestionOptionSerializer, QuestionSerializer, + QuotaSerializer, ) from pretix.base.models import ( - Item, ItemAddOn, ItemCategory, ItemVariation, Question, Quota, + Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption, + Quota, ) from pretix.base.models.organizer import TeamAPIToken from pretix.helpers.dicts import merge_dicts @@ -214,7 +216,7 @@ class ItemCategoryViewSet(viewsets.ReadOnlyModelViewSet): return self.request.event.categories.all() -class QuestionViewSet(viewsets.ReadOnlyModelViewSet): +class QuestionViewSet(viewsets.ModelViewSet): serializer_class = QuestionSerializer queryset = Question.objects.none() filter_backends = (OrderingFilter,) @@ -225,6 +227,85 @@ class QuestionViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return self.request.event.questions.prefetch_related('options').all() + def perform_create(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.question.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): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.question.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): + instance.log_action( + 'pretix.event.question.deleted', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + ) + super().perform_destroy(instance) + + +class QuestionOptionViewSet(viewsets.ModelViewSet): + serializer_class = QuestionOptionSerializer + queryset = QuestionOption.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter,) + ordering_fields = ('id', 'position') + ordering = ('position',) + permission = 'can_change_items' + write_permission = 'can_change_items' + + def get_queryset(self): + q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event) + return q.options.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + ctx['question'] = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event) + return ctx + + def perform_create(self, serializer): + q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event) + serializer.save(question=q) + q.log_action( + 'pretix.event.question.option.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.question.log_action( + 'pretix.event.question.option.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): + instance.question.log_action( + 'pretix.event.question.option.deleted', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data={'id': instance.pk} + ) + super().perform_destroy(instance) + class QuotaFilter(FilterSet): class Meta: diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index ad9a19f227..c0ec110d4d 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -716,7 +716,14 @@ class Question(LoggedModel): self.event.cache.clear() def clean_identifier(self, code): - if Question.objects.filter(event=self.event, identifier=code).exclude(pk=self.pk).exists(): + Question._clean_identifier(self.event, code, self) + + @staticmethod + def _clean_identifier(event, code, instance=None): + qs = Question.objects.filter(event=event, identifier=code) + if instance: + qs = qs.exclude(pk=instance.pk) + if qs.exists(): raise ValidationError(_('This identifier is already used for a different question.')) def save(self, *args, **kwargs): @@ -796,6 +803,12 @@ class Question(LoggedModel): return answer + @staticmethod + def clean_items(event, items): + for item in items: + if event != item.event: + raise ValidationError(_('One or more items do not belong to this event.')) + class QuestionOption(models.Model): question = models.ForeignKey('Question', related_name='options') @@ -816,6 +829,14 @@ class QuestionOption(models.Model): break super().save(*args, **kwargs) + @staticmethod + def clean_identifier(event, code, instance=None, known=[]): + qs = QuestionOption.objects.filter(question__event=event, identifier=code) + if instance: + qs = qs.exclude(pk=instance.pk) + if qs.exists() or code in known: + raise ValidationError(_('The identifier "{}" is already used for a different option.').format(code)) + class Meta: verbose_name = _("Question option") verbose_name_plural = _("Question options") diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index d0dd5ac858..07e9b5bae9 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -8,7 +8,7 @@ from pytz import UTC from pretix.base.models import ( CartPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Order, - OrderPosition, Quota, + OrderPosition, Question, QuestionOption, Quota, ) from pretix.base.models.orders import OrderFee @@ -843,6 +843,11 @@ def addon(item, category): return item.addons.create(addon_category=category, min_count=0, max_count=10, position=1) +@pytest.fixture +def option(question): + return question.options.create(answer='XL', identifier='LVETRWVU') + + TEST_ADDONS_RES = { "min_count": 0, "max_count": 10, @@ -1200,7 +1205,7 @@ def test_quota_availability(token_client, organizer, event, quota, item): def question(event, item): q = event.questions.create(question="T-Shirt size", type="C", identifier="ABC") q.items.add(item) - q.options.create(answer="XL", identifier="FOO") + q.options.create(answer="XL", identifier="LVETRWVU") return q @@ -1215,7 +1220,8 @@ TEST_QUESTION_RES = { "options": [ { "id": 0, - "identifier": "FOO", + "position": 0, + "identifier": "LVETRWVU", "answer": {"en": "XL"} } ] @@ -1246,3 +1252,240 @@ def test_question_detail(token_client, organizer, event, question, item): question.pk)) assert resp.status_code == 200 assert res == resp.data + + +@pytest.mark.django_db +def test_question_create(token_client, organizer, event, event2, item): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/questions/'.format(organizer.slug, event.slug), + { + "question": "What's your name?", + "type": "S", + "required": True, + "items": [item.pk], + "position": 0, + "ask_during_checkin": False, + "identifier": None + }, + format='json' + ) + assert resp.status_code == 201 + question = Question.objects.get(pk=resp.data['id']) + assert question.question == "What's your name?" + assert question.type == "S" + assert question.identifier is not None + assert len(question.items.all()) == 1 + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/questions/'.format(organizer.slug, event2.slug), + { + "question": "What's your name?", + "type": "S", + "required": True, + "items": [item.pk], + "position": 0, + "ask_during_checkin": False, + "identifier": 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."]}' + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/questions/'.format(organizer.slug, event.slug), + { + "question": "What's your name?", + "type": "S", + "required": True, + "items": [item.pk], + "position": 0, + "ask_during_checkin": False, + "identifier": question.identifier + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"identifier":["This identifier is already used for a different question."]}' + + +@pytest.mark.django_db +def test_question_update(token_client, organizer, event, question): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/questions/{}/'.format(organizer.slug, event.slug, question.pk), + { + "question": "What's your shoe size?", + "type": "N", + }, + format='json' + ) + print(resp.content) + assert resp.status_code == 200 + question = Question.objects.get(pk=resp.data['id']) + assert question.question == "What's your shoe size?" + assert question.type == "N" + + +@pytest.mark.django_db +def test_question_update_options(token_client, organizer, event, question, item): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/questions/{}/'.format(organizer.slug, event.slug, question.pk), + { + "options": [ + ] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["Updating options via PATCH/PUT is not supported. Please use the dedicated nested endpoint."]}' + + +@pytest.mark.django_db +def test_question_delete(token_client, organizer, event, question): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/questions/{}/'.format(organizer.slug, event.slug, question.pk)) + assert resp.status_code == 204 + assert not event.questions.filter(pk=question.id).exists() + + +TEST_OPTIONS_RES = { + "identifier": "LVETRWVU", + "answer": {"en": "XL"}, + "position": 0 +} + + +@pytest.mark.django_db +def test_options_list(token_client, organizer, event, question, option): + res = dict(TEST_OPTIONS_RES) + res["id"] = option.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/questions/{}/options/'.format( + organizer.slug, event.slug, question.pk) + ) + assert resp.status_code == 200 + assert res['identifier'] == resp.data['results'][0]['identifier'] + assert res['answer'] == resp.data['results'][0]['answer'] + + +@pytest.mark.django_db +def test_options_detail(token_client, organizer, event, question, option): + res = dict(TEST_OPTIONS_RES) + res["id"] = option.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/questions/{}/options/{}/'.format( + organizer.slug, event.slug, question.pk, option.pk + )) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_options_create(token_client, organizer, event, question): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/questions/{}/options/'.format(organizer.slug, event.slug, question.pk), + { + "identifier": "DFEMJWMJ", + "answer": "A", + "position": 0 + }, + format='json' + ) + assert resp.status_code == 201 + option = QuestionOption.objects.get(pk=resp.data['id']) + assert option.answer == "A" + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/questions/{}/options/'.format(organizer.slug, event.slug, question.pk), + { + "identifier": "DFEMJWMJ", + "answer": "A", + "position": 0 + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"identifier":["The identifier \\"DFEMJWMJ\\" is already used for a different option."]}' + + +@pytest.mark.django_db +def test_options_update(token_client, organizer, event, question, option): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/questions/{}/options/{}/'.format(organizer.slug, event.slug, question.pk, option.pk), + { + "answer": "B", + }, + format='json' + ) + assert resp.status_code == 200 + a = QuestionOption.objects.get(pk=option.pk) + assert a.answer == "B" + + +@pytest.mark.django_db +def test_options_delete(token_client, organizer, event, question, option): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/questions/{}/options/{}/'.format( + organizer.slug, event.slug, question.pk, option.pk + )) + assert resp.status_code == 204 + assert not question.options.filter(pk=option.id).exists() + + +@pytest.mark.django_db +def test_question_create_with_option(token_client, organizer, event, item): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/questions/'.format(organizer.slug, event.slug), + { + "question": "What's your name?", + "type": "S", + "required": True, + "items": [item.pk], + "position": 0, + "ask_during_checkin": False, + "identifier": None, + "options": [ + { + "identifier": None, + "answer": {"en": "A"}, + "position": 0, + }, + { + "identifier": None, + "answer": {"en": "B"}, + "position": 1, + }, + ] + }, + format='json' + ) + assert resp.status_code == 201 + question = Question.objects.get(pk=resp.data['id']) + assert str(question.options.first().answer) == "A" + assert question.options.first().identifier is not None + assert str(question.options.last().answer) == "B" + assert 2 == question.options.count() + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/questions/'.format(organizer.slug, event.slug), + { + "question": "What's your name?", + "type": "S", + "required": True, + "items": [item.pk], + "position": 0, + "ask_during_checkin": False, + "identifier": None, + "options": [ + { + "identifier": "ABC", + "answer": {"en": "A"}, + "position": 0, + }, + { + "identifier": "ABC", + "answer": {"en": "B"}, + "position": 1, + }, + ] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"options":["The identifier \\"ABC\\" is already used for a different option."]}'