Event settings: Extend product metadata (Z#23116647) (#3241)

Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Raphael Michel <michel@rami.io>
This commit is contained in:
Phin Wolkwitz
2023-05-26 14:09:41 +02:00
committed by GitHub
parent 7801d06d17
commit b3298c91c3
16 changed files with 541 additions and 36 deletions

View File

@@ -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')

View File

@@ -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')

View File

@@ -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'

View File

@@ -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),
),
]

View File

@@ -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",)

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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.'),

View File

@@ -378,41 +378,55 @@
{% bootstrap_formset_errors item_meta_property_formset %}
<div data-formset-body>
{% for form in item_meta_property_formset %}
<div class="row formset-row" data-formset-form>
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-5">
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Property" %}</h3>
</div>
<div class="col-sm-4 text-right flip">
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_form_errors form %}
{% bootstrap_field form.name layout='inline' form_group_class="" %}
</div>
<div class="col-md-5 col-lg-6">
{% bootstrap_field form.default layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 col-lg-1 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
{% 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" %}
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row formset-row" data-formset-form>
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ item_meta_property_formset.empty_form.id }}
{% bootstrap_field item_meta_property_formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-5">
{% bootstrap_field item_meta_property_formset.empty_form.name layout='inline' form_group_class="" %}
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Property" %}</h3>
</div>
<div class="col-sm-4 text-right flip">
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="col-md-5 col-lg-6">
{% bootstrap_field item_meta_property_formset.empty_form.default layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 col-lg-1 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
<div class="panel-body form-horizontal">
{% bootstrap_field item_meta_property_formset.empty_form.name layout="control" %}
{% bootstrap_field item_meta_property_formset.empty_form.default layout="control" %}
{% bootstrap_field item_meta_property_formset.empty_form.required layout="control" %}
{% bootstrap_field item_meta_property_formset.empty_form.allowed_values layout="control" %}
</div>
</div>
{% endescapescript %}

View File

@@ -139,6 +139,26 @@
</div>
</div>
{% if meta_forms %}
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Meta data" %}</label>
<div class="col-md-9">
{% for form in meta_forms %}
<div class="row">
<div class="col-md-4">
<label for="{{ form.value.id_for_label }}">
{{ form.property.name }}
</label>
</div>
<div class="col-md-8">
{% bootstrap_form form layout="inline" error_types="all" %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</fieldset>
{% if form.quota_option %}
<fieldset>

View File

@@ -134,7 +134,7 @@
</label>
</div>
<div class="col-md-8">
{% bootstrap_form form layout="inline" %}
{% bootstrap_form form layout="inline" error_types="all" %}
</div>
</div>
{% endfor %}

View File

@@ -282,9 +282,19 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
if form in self.item_meta_property_formset.deleted_forms:
if not form.instance.pk:
continue
form.instance.log_action(
'pretix.event.item_meta_property.deleted',
user=self.request.user,
data=form.cleaned_data
)
form.instance.delete()
form.instance.pk = None
elif form.has_changed():
form.instance.log_action(
'pretix.event.item_meta_property.changed',
user=self.request.user,
data=form.cleaned_data
)
form.save()
for form in self.item_meta_property_formset.extra_forms:
@@ -294,6 +304,11 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
continue
form.instance.event = obj
form.save()
form.instance.log_action(
'pretix.event.item_meta_property.added',
user=self.request.user,
data=form.cleaned_data
)
@cached_property
def confirm_texts_formset(self):

View File

@@ -1213,7 +1213,7 @@ class MetaDataEditorMixin:
f.instance.delete()
class ItemCreate(EventPermissionRequiredMixin, CreateView):
class ItemCreate(EventPermissionRequiredMixin, MetaDataEditorMixin, CreateView):
form_class = ItemCreateForm
template_name = 'pretixcontrol/item/create.html'
permission = 'can_change_items'
@@ -1274,6 +1274,11 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['meta_forms'] = self.meta_forms
return ctx
class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataEditorMixin, UpdateView):
form_class = ItemUpdateForm

View File

@@ -0,0 +1,116 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
import pytest
from django_scopes import scopes_disabled
@pytest.fixture
def item_meta_property(event):
return event.item_meta_properties.create(
name="Color",
default="Red",
required=False,
allowed_values="", # internally, this is a string
)
TEST_TYPE_RES = {
"name": "Color",
"default": "Red",
"required": False,
"allowed_values": [], # the external representation is a list
}
@pytest.mark.django_db
def test_meta_property_list(token_client, organizer, event, item_meta_property):
res = dict(TEST_TYPE_RES)
resp = token_client.get('/api/v1/organizers/{}/events/{}/item_meta_properties/'
.format(organizer.slug, event.slug))
assert resp.status_code == 200
item_meta_property.refresh_from_db()
res["id"] = item_meta_property.pk
assert res in resp.data['results']
assert len(resp.data['results']) == 2
# there is another meta property created in conftest using the old way, we
# should check it still works, so the result should contain 2 entries
@pytest.mark.django_db
def test_meta_property_detail(token_client, organizer, event, item_meta_property):
res = TEST_TYPE_RES
resp = token_client.get('/api/v1/organizers/{}/events/{}/item_meta_properties/{}/'
.format(organizer.slug, event.slug, item_meta_property.pk))
assert resp.status_code == 200
item_meta_property.refresh_from_db()
res["id"] = item_meta_property.pk
assert res == resp.data
@pytest.mark.django_db
def test_meta_property_create(token_client, organizer, event):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/item_meta_properties/'.format(organizer.slug, event.slug),
format='json',
data={
"name": "Color",
"default": "Red",
"required": False,
"allowed_values": ["Red", "Green", "Blue"]
}
)
assert resp.status_code == 201
with scopes_disabled():
item_meta_property = event.item_meta_properties.get(id=resp.data['id'])
assert item_meta_property.name == "Color"
assert item_meta_property.default == "Red"
assert item_meta_property.allowed_values == "Red\nGreen\nBlue"
assert not item_meta_property.required
assert len(event.item_meta_properties.all()) == 2
@pytest.mark.django_db
def test_meta_property_patch(token_client, organizer, event, item_meta_property):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/item_meta_properties/{}/'
.format(organizer.slug, event.slug, item_meta_property.pk),
format='json',
data={
"required": True,
"allowed_values": None,
}
)
assert resp.status_code == 200
item_meta_property.refresh_from_db()
assert item_meta_property.required
assert item_meta_property.allowed_values is None
@pytest.mark.django_db
def test_meta_property_delete(token_client, organizer, event, item_meta_property):
resp = token_client.delete(
'/api/v1/organizers/{}/events/{}/item_meta_properties/{}/'
.format(organizer.slug, event.slug, item_meta_property.pk),
)
assert resp.status_code == 204
assert len(event.item_meta_properties.all()) == 1

View File

@@ -184,7 +184,13 @@ event_permission_sub_urls = [
('post', 'can_change_orders', 'cartpositions/', 400),
('delete', 'can_change_orders', 'cartpositions/1/', 404),
('post', 'can_view_orders', 'exporters/invoicedata/run/', 400),
('get', 'can_view_orders', 'exporters/invoicedata/download/bc3f9884-26ee-425b-8636-80613f84b6fa/3cb49ae6-eda3-4605-814e-099e23777b36/', 404),
('get', 'can_view_orders', 'exporters/invoicedata/download/bc3f9884-26ee-425b-8636-80613f84b6fa/3cb49ae6-eda3'
'-4605-814e-099e23777b36/', 404),
('get', None, 'item_meta_properties/', 200),
('get', None, 'item_meta_properties/0/', 404),
('post', 'can_change_event_settings', 'item_meta_properties/', 400),
('patch', 'can_change_event_settings', 'item_meta_properties/0/', 404),
('delete', 'can_change_event_settings', 'item_meta_properties/0/', 404),
]
org_permission_sub_urls = [