diff --git a/doc/api/resources/item_meta_properties.rst b/doc/api/resources/item_meta_properties.rst new file mode 100644 index 0000000000..c5522f1fe6 --- /dev/null +++ b/doc/api/resources/item_meta_properties.rst @@ -0,0 +1,211 @@ +Item Meta Properties +==================== + +Resource description +-------------------- + +An Item Meta Property is used to include (event internally relevant) meta information with every item (product). This +could be internal categories like booking positions. + +The Item Meta Properties 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``, this property will have to be assigned a + value in all items of the related event +allowed_values list List of all permitted values for this property, + or ``null`` for no limitation +===================================== ========================== ======================================================= + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/item_meta_properties/ + + Returns a list of all Item Meta Properties within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/item_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": "red", + "required": true, + "allowed_values": ["red", "green", "blue"] + } + ] + } + + :param organizer: The ``slug`` field of the organizer + :param event: The ``slug`` field of the event + :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)/events/(event)/item_meta_properties/(id)/ + + Returns information on one property, identified by its id. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/item_meta_properties/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + { + "id": 1, + "name": "Color", + "default": "red", + "required": true, + "allowed_values": ["red", "green", "blue"] + } + + :param organizer: The ``slug`` field of the organizer + :param event: The ``slug`` field of the event + :param id: The ``id`` field of the item 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)/events/(event)/item_meta_properties/ + + Creates a new item meta property + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/item_meta_properties/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "name": "ref-code", + "default": "abcde", + "required": true, + "allowed_values": null + } + + + **Example response**: + + .. sourcecode:: http + + { + "id": 2, + "name": "ref-code", + "default": "abcde", + "required": true, + "allowed_values": null + } + + :param organizer: The ``slug`` field of the organizer + :param event: The ``slug`` field of the event + :statuscode 201: no error + :statuscode 400: The item 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)/events/(event)/item_meta_properties/(id)/ + + Update an item 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/events/sampleconf/item_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": 2, + "name": "ref-code", + "default": "abcde", + "required": false, + "allowed_values": [] + } + + :param organizer: The ``slug`` field of the organizer + :param event: The ``slug`` field of the event + :param id: The ``id`` field of the item 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)/events/(event)/item_meta_properties/(id)/ + + Delete an item meta property. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/item_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 event: The ``slug`` field of the event + :param id: The ``id`` field of the item 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/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index e3a7526bd0..9b0e6444cb 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -50,7 +50,9 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.settings import SettingsSerializer from pretix.base.models import Device, Event, TaxRule, TeamAPIToken from pretix.base.models.event import SubEvent -from pretix.base.models.items import SubEventItem, SubEventItemVariation +from pretix.base.models.items import ( + ItemMetaProperty, SubEventItem, SubEventItemVariation, +) from pretix.base.services.seating import ( SeatProtected, generate_seats, validate_plan_change, ) @@ -904,3 +906,23 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer): else [] ) ) + + +class MultiLineStringField(serializers.Field): + + def to_representation(self, value): + return [v.strip() for v in value.splitlines()] + + def to_internal_value(self, data): + if isinstance(data, list) and len(data) > 0: + return "\n".join(data) + else: + raise ValidationError('Invalid data type.') + + +class ItemMetaPropertiesSerializer(I18nAwareModelSerializer): + allowed_values = MultiLineStringField(allow_null=True) + + class Meta: + model = ItemMetaProperty + fields = ('id', 'name', 'default', 'required', 'allowed_values') diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 2b88b170ea..4ac2f05a06 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -89,6 +89,7 @@ event_router.register(r'checkinlists', checkin.CheckinListViewSet) event_router.register(r'cartpositions', cart.CartPositionViewSet) event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters') event_router.register(r'shredders', shredders.EventShreddersViewSet, basename='shredders') +event_router.register(r'item_meta_properties', event.ItemMetaPropertiesViewSet) checkinlist_router = routers.DefaultRouter() checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos') diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index f8ff850aeb..f60b2b1c98 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -47,11 +47,13 @@ from pretix.api.auth.permission import EventCRUDPermission from pretix.api.pagination import TotalOrderingFilter from pretix.api.serializers.event import ( CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer, - EventSettingsSerializer, SubEventSerializer, TaxRuleSerializer, + EventSettingsSerializer, ItemMetaPropertiesSerializer, SubEventSerializer, + TaxRuleSerializer, ) from pretix.api.views import ConditionalListView from pretix.base.models import ( - CartPosition, Device, Event, SeatCategoryMapping, TaxRule, TeamAPIToken, + CartPosition, Device, Event, ItemMetaProperty, SeatCategoryMapping, + TaxRule, TeamAPIToken, ) from pretix.base.models.event import SubEvent from pretix.base.services.quotas import QuotaAvailability @@ -522,6 +524,54 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): super().perform_destroy(instance) +class ItemMetaPropertiesViewSet(viewsets.ModelViewSet): + serializer_class = ItemMetaPropertiesSerializer + queryset = ItemMetaProperty.objects.none() + write_permission = 'can_change_event_settings' + + def get_queryset(self): + qs = self.request.event.item_meta_properties.all() + return qs + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + ctx['event'] = self.request.event + return ctx + + @transaction.atomic() + def perform_destroy(self, instance): + instance.log_action( + 'pretix.event.item_meta_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(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.item_meta_property.added', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst + + @transaction.atomic() + def perform_update(self, serializer): + inst = serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.item_meta_property.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst + + class EventSettingsView(views.APIView): permission = None write_permission = 'can_change_event_settings' diff --git a/src/pretix/base/migrations/0241_itemmetaproperties_required_values.py b/src/pretix/base/migrations/0241_itemmetaproperties_required_values.py new file mode 100644 index 0000000000..cb8e1d6984 --- /dev/null +++ b/src/pretix/base/migrations/0241_itemmetaproperties_required_values.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.19 on 2023-05-25 15:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0240_auto_20230516_1119'), + ] + + operations = [ + migrations.AddField( + model_name='itemmetaproperty', + name='allowed_values', + field=models.TextField(null=True), + ), + migrations.AddField( + model_name='itemmetaproperty', + name='required', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index b7a2bc4ff6..199b2a2546 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -2001,6 +2001,15 @@ class ItemMetaProperty(LoggedModel): verbose_name=_("Name"), ) default = models.TextField(blank=True) + required = models.BooleanField( + default=False, verbose_name=_("Required for products"), + help_text=_("If checked, this property must be set in each product. Does not apply if a default value is set.") + ) + allowed_values = models.TextField( + null=True, blank=True, + verbose_name=_("Valid values"), + help_text=_("If you keep this empty, any value is allowed. Otherwise, enter one possible value per line.") + ) class Meta: ordering = ("name",) diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 075ead5706..7f9ab3c4fa 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -1674,7 +1674,7 @@ QuickSetupProductFormSet = formset_factory( class ItemMetaPropertyForm(forms.ModelForm): class Meta: - fields = ['name', 'default'] + fields = ['name', 'default', 'required', 'allowed_values'] widgets = { 'default': forms.TextInput() } diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 38cb2c5118..cc5783506d 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -1102,16 +1102,26 @@ class ItemMetaValueForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.property = kwargs.pop('property') super().__init__(*args, **kwargs) - self.fields['value'].required = False - self.fields['value'].widget.attrs['placeholder'] = self.property.default - self.fields['value'].widget.attrs['data-typeahead-url'] = ( - reverse('control:event.items.meta.typeahead', kwargs={ - 'organizer': self.property.event.organizer.slug, - 'event': self.property.event.slug - }) + '?' + urlencode({ - 'property': self.property.name, - }) - ) + if self.property.allowed_values: + self.fields['value'] = forms.ChoiceField( + label=self.property.name, + choices=[( + "", _("Default ({value})").format(value=self.property.default) + if self.property.default else "" + )] + [(a.strip(), a.strip()) for a in self.property.allowed_values.splitlines()] + ) + else: + self.fields['value'].label = self.property.name + self.fields['value'].widget.attrs['placeholder'] = self.property.default + self.fields['value'].widget.attrs['data-typeahead-url'] = ( + reverse('control:event.items.meta.typeahead', kwargs={ + 'organizer': self.property.event.organizer.slug, + 'event': self.property.event.slug + }) + '?' + urlencode({ + 'property': self.property.name, + }) + ) + self.fields['value'].required = self.property.required and not self.property.default class Meta: model = ItemMetaValue diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 678e82294d..18906efc95 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -485,6 +485,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.item.bundles.added': _('A bundled item has been added to this product.'), 'pretix.event.item.bundles.removed': _('A bundled item has been removed from this product.'), 'pretix.event.item.bundles.changed': _('A bundled item has been changed on this product.'), + 'pretix.event.item_meta_property.added': _('A meta property has been added to this event.'), + 'pretix.event.item_meta_property.deleted': _('A meta property has been removed from this event.'), + 'pretix.event.item_meta_property.changed': _('A meta property has been changed on this event.'), 'pretix.event.quota.added': _('The quota has been added.'), 'pretix.event.quota.deleted': _('The quota has been deleted.'), 'pretix.event.quota.changed': _('The quota has been changed.'), diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index a3c165f243..846e6c554f 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -378,41 +378,55 @@ {% bootstrap_formset_errors item_meta_property_formset %}
{% for form in item_meta_property_formset %} -
+
{{ form.id }} {% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
-
+
+
+
+

{% trans "Property" %}

+
+
+ +
+
+
+
{% bootstrap_form_errors form %} - {% bootstrap_field form.name layout='inline' form_group_class="" %} -
-
- {% bootstrap_field form.default layout='inline' form_group_class="" %} -
-
- + {% bootstrap_field form.name layout="control" %} + {% bootstrap_field form.default layout="control" %} + {% bootstrap_field form.required layout="control" %} + {% bootstrap_field form.allowed_values layout="control" %}
{% endfor %}