diff --git a/doc/api/resources/event_meta_properties.rst b/doc/api/resources/event_meta_properties.rst new file mode 100644 index 000000000..138108111 --- /dev/null +++ b/doc/api/resources/event_meta_properties.rst @@ -0,0 +1,241 @@ +Event Meta Properties +===================== + +Resource description +-------------------- + +An event meta property is used to to define meta information fields for its events. +This information can be re-used, for example, in ticket layouts. + +The event meta property resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Unique ID for this property +name string Name of the property +default string Value of the default option +required boolean If ``true``, an event can only be taken live if the + property is set. In event series, it's always optional + to set a value for individual dates +protected boolean If ``true``, the value for an event can only be changed + by organizer-level administrators +filter_public boolean If ``true``, this property will be shown to filter + events in the public event list and calendar +public_label string Public name of the property +filter_allowed boolean If ``true``, this property will be shown to filter + events or reports in the backend, and it can also be + used for hidden filter parameters in the frontend +choices list of objects List of JSON objects representing all permitted values + for this property, or ``null`` for no limitation. + Each choice object has a required internal name named + ``key`` and optional public name named ``label`` + consisting of a dictionary of i18n string translations +===================================== ========================== ======================================================= + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/ + + Returns a list of all meta properties for the organizer. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/meta_properties/ 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": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "name": "Color", + "default": "blue", + "required": false, + "protected": false, + "filter_public": false, + "public_label": {}, + "filter_allowed": true, + "choices": [ + { + "key": "blue", + "label": { + "en": "Blue" + }, + } + ] + } + ] + } + + :param organizer: The ``slug`` field of the organizer + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/ + + Returns information on one property, identified by its id. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/event_meta_properties/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + { + "id": 1, + "name": "Color", + "default": "blue", + "required": false, + "protected": false, + "filter_public": false, + "public_label": {}, + "filter_allowed": true, + "choices": null + } + + :param organizer: The ``slug`` field of the organizer + :param id: The ``id`` field of the meta property to retrieve + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/event_meta_properties/ + + Creates a new meta property + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/event_meta_properties/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "name": "ref-code", + "default": "abcde", + "required": true, + "choices": null + } + + + **Example response**: + + .. sourcecode:: http + + { + "id": 2, + "name": "reference", + "default": "abcde", + "required": true, + "protected": false, + "filter_public": false, + "public_label": null, + "filter_allowed": true, + "choices": null + } + + :param organizer: The ``slug`` field of the organizer + :statuscode 201: no error + :statuscode 400: The meta property could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/ + + Update a meta property. 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/event_meta_properties/2/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "required": false + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 3, + "name": "reference", + "default": "abcde", + "required": false, + "protected": false, + "filter_public": false, + "public_label": null, + "filter_allowed": true, + "choices": null + } + + :param organizer: The ``slug`` field of the organizer + :param id: The ``id`` field of the meta property to modify + :statuscode 200: no error + :statuscode 400: The property could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource. + +.. http:delete:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/ + + Delete a meta property. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/event_meta_properties/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 + :param id: The ``id`` field of the meta property to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource. diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 8b9d96cdd..0018889a2 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -12,6 +12,7 @@ at :ref:`plugin-docs`. organizers events subevents + event_meta_properties taxrules categories items diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 671c25d8e..fc77cc5d2 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -28,6 +28,7 @@ from django.db import transaction from django.db.models import Q from django.utils.crypto import get_random_string from django.utils.translation import gettext, gettext_lazy as _ +from i18nfield.rest_framework import I18nField from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -40,9 +41,10 @@ from pretix.api.serializers.settings import SettingsSerializer from pretix.base.auth import get_auth_backends from pretix.base.i18n import get_language_without_region from pretix.base.models import ( - Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction, - Membership, MembershipType, OrderPosition, Organizer, ReusableMedium, - SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User, + Customer, Device, EventMetaProperty, GiftCard, GiftCardAcceptance, + GiftCardTransaction, Membership, MembershipType, OrderPosition, Organizer, + ReusableMedium, SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, + User, ) from pretix.base.models.seating import SeatingPlanLayoutValidator from pretix.base.permissions import ( @@ -640,3 +642,88 @@ class OrganizerSettingsSerializer(SettingsSerializer): ) # TODO: make sure pub is always correct return 'pub/' + fname + + +class MetaPropertyListField(serializers.ListField): + def __init__(self, *args, **kwargs): + kwargs["validators"] = kwargs.pop("validators", []) + + def validate_keys_unique(choices): + if not choices: + return + keys = [c.get("key") for c in choices] + if len(set(keys)) < len(keys): + raise ValidationError("The key for each meta property value option must be unique.") + + kwargs["validators"].append( + validate_keys_unique + ) + super().__init__(*args, **kwargs) + + +class MetaPropertyDictField(serializers.DictField): + + def __init__(self, **kwargs): + self.label_child = kwargs.pop("label_child", I18nField()) + super().__init__(**kwargs) + + def to_representation(self, value): + # django added unneccessary keys DELETE, ORDER through formsets, filter them here for backwards compat + d = { + "key": value["key"] + } + if "label" in value: + d["label"] = self.label_child.to_representation(value["label"]) + + return super().to_representation(d) + + def to_internal_value(self, data): + if not isinstance(data, dict): + raise ValidationError("Meta property value options must be a dict.") + + if not isinstance(data.get("key"), str): + raise ValidationError("Meta property value options must have a key of type string.") + + if any(k not in {"key", "label"} for k in data.keys()): + raise ValidationError("Meta property value options may only have a key and optionally a label.") + + if "label" in data: + try: + data["label"] = self.label_child.to_internal_value(data["label"]) + except ValidationError as e: + raise ValidationError({"label": e.detail}) + + return super().to_internal_value(data) + + +class EventMetaPropertiesSerializer(I18nAwareModelSerializer): + choices = MetaPropertyListField( + child=MetaPropertyDictField( + label_child=I18nField() + ), + allow_null=True, + ) + + class Meta: + model = EventMetaProperty + fields = ( + 'id', 'name', 'default', 'required', 'protected', 'filter_public', 'public_label', 'filter_allowed', + 'choices' + ) + + def validate(self, data): + data = super().validate(data) + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} + full_data.update(data) + + choices = full_data.get("choices") + default = full_data.get("default") + if choices and default: + choice_keys = [c.get("key") for c in choices] + if default not in choice_keys: + raise ValidationError("You cannot set a default value that is not a valid value.") + + if not choices and "choices" in data: + # normalize empty dict to None + data["choices"] = None + return data diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 911ce8c9e..a42c98a0f 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -68,6 +68,7 @@ orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportVie orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') orga_router.register(r'transactions', order.OrganizerTransactionViewSet) orga_router.register(r'orderpositions', order.OrganizerOrderPositionViewSet, basename='orderpositions') +orga_router.register(r'event_meta_properties', organizer.EventMetaPropertiesViewSet) team_router = routers.DefaultRouter() team_router.register(r'members', organizer.TeamMemberViewSet) diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index db28044b3..a712d37da 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -44,15 +44,16 @@ from pretix.api.models import OAuthAccessToken from pretix.api.pagination import TotalOrderingFilter from pretix.api.serializers.organizer import ( CustomerCreateSerializer, CustomerSerializer, DeviceSerializer, - GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer, + EventMetaPropertiesSerializer, GiftCardSerializer, + GiftCardTransactionSerializer, MembershipSerializer, MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer, SalesChannelSerializer, SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer, TeamSerializer, ) from pretix.base.models import ( - Customer, Device, Event, GiftCard, GiftCardTransaction, LogEntry, - Membership, MembershipType, Organizer, SalesChannel, SeatingPlan, Team, - TeamAPIToken, TeamInvite, User, + Customer, Device, Event, EventMetaProperty, GiftCard, GiftCardTransaction, + LogEntry, Membership, MembershipType, Organizer, SalesChannel, SeatingPlan, + Team, TeamAPIToken, TeamInvite, User, ) from pretix.base.plugins import ( PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, @@ -846,3 +847,49 @@ class SalesChannelViewSet(viewsets.ModelViewSet): data={'id': instance.pk} ) instance.delete() + + +class EventMetaPropertiesViewSet(viewsets.ModelViewSet): + serializer_class = EventMetaPropertiesSerializer + queryset = EventMetaProperty.objects.none() + write_permission = 'organizer.settings.general:write' + + def get_queryset(self): + return self.request.organizer.meta_properties.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + return ctx + + @transaction.atomic() + def perform_destroy(self, instance): + instance.log_action( + 'pretix.property.deleted', + user=self.request.user, + auth=self.request.auth, + data={'id': instance.pk} + ) + instance.delete() + + @transaction.atomic() + def perform_create(self, serializer): + inst = serializer.save(organizer_id=self.request.organizer.pk) + serializer.instance.log_action( + 'pretix.property.created', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst + + @transaction.atomic() + def perform_update(self, serializer): + inst = serializer.save(organizer_id=self.request.organizer.pk) + serializer.instance.log_action( + 'pretix.property.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 5639ace52..ddf90d6d4 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -717,6 +717,10 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType): 'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'), 'pretix.organizer.outgoingmails.retried': _('Failed emails have been scheduled to be retried.'), 'pretix.organizer.outgoingmails.aborted': _('Queued emails have been aborted.'), + 'pretix.property.created': _('An organizer meta property has been created.'), + 'pretix.property.deleted': _('An organizer meta property has been deleted.'), + 'pretix.property.changed': _('An organizer meta property has been changed.'), + 'pretix.property.reordered': _('An organizer meta property has been reordered.'), 'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'), 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), 'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'), diff --git a/src/tests/api/test_event_meta_properties.py b/src/tests/api/test_event_meta_properties.py new file mode 100644 index 000000000..173a198a3 --- /dev/null +++ b/src/tests/api/test_event_meta_properties.py @@ -0,0 +1,229 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-today pretix GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import pytest +from django_scopes import scopes_disabled +from i18nfield.strings import LazyI18nString + + +@pytest.fixture +def event_meta_property(organizer): + return organizer.meta_properties.create( + name="Color", + default="Red", + required=False, + choices=[ + { + "key": "Red", + "label": LazyI18nString("Rot"), + "DELETE": False, + "ORDER": 1, + } + ], + ) + + +TEST_TYPE_RES = { + "name": "Color", + "default": "Red", + "required": False, + "choices": [{"key": "Red", "label": {"en": "Rot"}}], + 'filter_allowed': True, + 'filter_public': False, + 'protected': False, + 'public_label': None, +} + + +@pytest.mark.django_db +def test_meta_property_list(token_client, organizer, event_meta_property): + res = dict(TEST_TYPE_RES) + + resp = token_client.get('/api/v1/organizers/{}/event_meta_properties/'.format(organizer.slug)) + assert resp.status_code == 200 + event_meta_property.refresh_from_db() + res["id"] = event_meta_property.pk + assert res in resp.data['results'] + assert len(resp.data['results']) == 1 + + +@pytest.mark.django_db +def test_meta_property_detail(token_client, organizer, event_meta_property): + res = TEST_TYPE_RES + resp = token_client.get('/api/v1/organizers/{}/event_meta_properties/{}/'.format(organizer.slug, event_meta_property.pk)) + assert resp.status_code == 200 + event_meta_property.refresh_from_db() + res["id"] = event_meta_property.pk + assert res == resp.data + + +@pytest.mark.django_db +def test_meta_property_create(token_client, organizer): + url = '/api/v1/organizers/{}/event_meta_properties/'.format(organizer.slug) + resp = token_client.post( + url, + format='json', + data={ + "name": "Color", + "default": "", + "required": False, + "choices": [ + {"key": {"foo": "bar"}}, + "blabla", + {"label": "Green"}, + {"key": "g", "label": "Green", "foo": "bar"}, + ], + } + ) + assert resp.status_code == 400 + assert str(resp.data["choices"][0][0]) == "Meta property value options must have a key of type string." + assert str(resp.data["choices"][1][0]) == "Meta property value options must be a dict." + assert str(resp.data["choices"][2][0]) == "Meta property value options must have a key of type string." + assert str(resp.data["choices"][3][0]) == "Meta property value options may only have a key and optionally a label." + + resp = token_client.post( + url, + format='json', + data={ + "name": "Color", + "default": "Red", + "required": False, + "choices": {"key": "r", "label": "Red"}, + } + ) + assert resp.status_code == 400 + assert str(resp.data["choices"][0]) == 'Expected a list of items but got type "dict".' + + resp = token_client.post( + url, + format='json', + data={ + "name": "Color", + "default": "r", + "required": False, + "choices": [ + {"key": "r", "label": {"en": "Red"}}, + {"key": "r", "label": {"en": "Razzmatazz"}}, + ], + } + ) + assert resp.status_code == 400 + assert str(resp.data["choices"][0]) == "The key for each meta property value option must be unique." + + choices = [ + {"key": "r", "label": "Red"}, + {"key": "g", "label": "Green"}, + {"key": "b", "label": "Blue"}, + ] + resp = token_client.post( + url, + format='json', + data={ + "name": "Color", + "default": "k", + "required": False, + "choices": choices, + } + ) + assert resp.status_code == 400 + assert str(resp.data["non_field_errors"][0]) == "You cannot set a default value that is not a valid value." + + resp = token_client.post( + url, + format='json', + data={ + "name": "Color", + "default": "r", + "required": False, + "choices": choices, + } + ) + assert resp.status_code == 201 + with scopes_disabled(): + event_meta_property = organizer.meta_properties.get(id=resp.data['id']) + assert event_meta_property.name == "Color" + assert event_meta_property.default == "r" + assert event_meta_property.choices == choices + assert not event_meta_property.required + assert len(organizer.meta_properties.all()) == 1 + + +@pytest.mark.django_db +def test_meta_property_patch(token_client, organizer, event_meta_property): + url = '/api/v1/organizers/{}/event_meta_properties/{}/'.format(organizer.slug, event_meta_property.pk) + resp = token_client.patch( + url, + format='json', + data={ + # existing default is not in choices + "choices": [{'key': 'k', 'label': 'Black'}], + } + ) + assert resp.status_code == 400 + assert str(resp.data["non_field_errors"][0]) == "You cannot set a default value that is not a valid value." + + resp = token_client.patch( + "/api/v1/organizers/{}/event_meta_properties/{}/" + .format(organizer.slug, event_meta_property.pk), + format="json", + data={ + "choices": [ + {"key": "r", "label": ["wrong"]}, + {"key": "g", "label": {"de": {"de": 123, "en": "wrong"}}} + ], + } + ) + assert resp.status_code == 400 + assert str(resp.data["choices"][0]["label"][0]) == "Invalid data type." + assert str(resp.data["choices"][1]["label"][0]) == "All entries must be strings." + + resp = token_client.patch( + url, + format='json', + data={ + "choices": [], + } + ) + assert resp.status_code == 200 + event_meta_property.refresh_from_db() + assert event_meta_property.choices is None + + resp = token_client.patch( + url, + format='json', + data={ + "required": True, + "choices": None, + } + ) + assert resp.status_code == 200 + event_meta_property.refresh_from_db() + assert event_meta_property.required + assert event_meta_property.choices is None + + +@pytest.mark.django_db +def test_meta_property_delete(token_client, organizer, event_meta_property): + resp = token_client.delete( + '/api/v1/organizers/{}/event_meta_properties/{}/'.format(organizer.slug, event_meta_property.pk), + ) + assert resp.status_code == 204 + assert len(organizer.meta_properties.all()) == 0 diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index d25ec2d1a..d1b1804a3 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -223,6 +223,8 @@ org_permission_sub_urls = [ ('post', 'organizer.customers:write', 'customers/1/anonymize/', 404), ('put', 'organizer.customers:write', 'customers/1/', 404), ('delete', 'organizer.customers:write', 'customers/1/', 404), + ('post', 'organizer.settings.general:write', 'event_meta_properties/', 400), + ('patch', 'organizer.settings.general:write', 'event_meta_properties/0/', 404), ('get', 'organizer.customers:read', 'memberships/', 200), ('post', 'organizer.customers:write', 'memberships/', 400), ('get', 'organizer.customers:read', 'memberships/1/', 404),