diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index fd8e075118..036fb76dce 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -13,7 +13,7 @@ from rest_framework.relations import SlugRelatedField from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.settings import SettingsSerializer -from pretix.base.models import Event, TaxRule +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.services.seating import ( @@ -174,9 +174,12 @@ class EventSerializer(I18nAwareModelSerializer): } def validate_meta_data(self, value): - for key in value['meta_data'].keys(): + for key, v in value['meta_data'].items(): if key not in self.meta_properties: raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) + if self.meta_properties[key].allowed_values: + if v not in [_v.strip() for _v in self.meta_properties[key].allowed_values.splitlines()]: + raise ValidationError(_('Meta data property \'{name}\' does not allow value \'{value}\'.').format(name=key, value=v)) return value @cached_property @@ -223,6 +226,14 @@ class EventSerializer(I18nAwareModelSerializer): return value + @cached_property + def ignored_meta_properties(self): + perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken)) + else self.context['request'].user) + if perm_holder.has_organizer_permission('can_change_organizer_settings', request=self.context['request']): + return [] + return [k for k, p in self.meta_properties.items() if p.protected] + @transaction.atomic def create(self, validated_data): meta_data = validated_data.pop('meta_data', None) @@ -238,10 +249,11 @@ class EventSerializer(I18nAwareModelSerializer): # Meta data if meta_data is not None: for key, value in meta_data.items(): - event.meta_values.create( - property=self.meta_properties.get(key), - value=value - ) + if key not in self.ignored_meta_properties: + event.meta_values.create( + property=self.meta_properties.get(key), + value=value + ) # Item Meta properties if item_meta_properties is not None: @@ -279,19 +291,21 @@ class EventSerializer(I18nAwareModelSerializer): if meta_data is not None: current = {mv.property: mv for mv in event.meta_values.select_related('property')} for key, value in meta_data.items(): - prop = self.meta_properties.get(key) - if prop in current: - current[prop].value = value - current[prop].save() - else: - event.meta_values.create( - property=self.meta_properties.get(key), - value=value - ) + if key not in self.ignored_meta_properties: + prop = self.meta_properties.get(key) + if prop in current: + current[prop].value = value + current[prop].save() + else: + event.meta_values.create( + property=self.meta_properties.get(key), + value=value + ) for prop, current_object in current.items(): - if prop.name not in meta_data: - current_object.delete() + if prop.name not in self.ignored_meta_properties: + if prop.name not in meta_data: + current_object.delete() # Item Meta properties if item_meta_properties is not None: @@ -444,11 +458,22 @@ class SubEventSerializer(I18nAwareModelSerializer): } def validate_meta_data(self, value): - for key in value['meta_data'].keys(): + for key, v in value['meta_data'].items(): if key not in self.meta_properties: raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) + if self.meta_properties[key].allowed_values: + if v not in [_v.strip() for _v in self.meta_properties[key].allowed_values.splitlines()]: + raise ValidationError(_('Meta data property \'{name}\' does not allow value \'{value}\'.').format(name=key, value=v)) return value + @cached_property + def ignored_meta_properties(self): + perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken)) + else self.context['request'].user) + if perm_holder.has_organizer_permission('can_change_organizer_settings', request=self.context['request']): + return [] + return [k for k, p in self.meta_properties.items() if p.protected] + @transaction.atomic def create(self, validated_data): item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {} @@ -465,10 +490,11 @@ class SubEventSerializer(I18nAwareModelSerializer): # Meta data if meta_data is not None: for key, value in meta_data.items(): - subevent.meta_values.create( - property=self.meta_properties.get(key), - value=value - ) + if key not in self.ignored_meta_properties: + subevent.meta_values.create( + property=self.meta_properties.get(key), + value=value + ) # Seats if subevent.seating_plan: @@ -514,19 +540,21 @@ class SubEventSerializer(I18nAwareModelSerializer): if meta_data is not None: current = {mv.property: mv for mv in subevent.meta_values.select_related('property')} for key, value in meta_data.items(): - prop = self.meta_properties.get(key) - if prop in current: - current[prop].value = value - current[prop].save() - else: - subevent.meta_values.create( - property=self.meta_properties.get(key), - value=value - ) + if key not in self.ignored_meta_properties: + prop = self.meta_properties.get(key) + if prop in current: + current[prop].value = value + current[prop].save() + else: + subevent.meta_values.create( + property=self.meta_properties.get(key), + value=value + ) for prop, current_object in current.items(): - if prop.name not in meta_data: - current_object.delete() + if prop.name not in self.ignored_meta_properties: + if prop.name not in meta_data: + current_object.delete() # Seats if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None): diff --git a/src/pretix/base/migrations/0176_auto_20210205_1512.py b/src/pretix/base/migrations/0176_auto_20210205_1512.py new file mode 100644 index 0000000000..d9c7d85abd --- /dev/null +++ b/src/pretix/base/migrations/0176_auto_20210205_1512.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.11 on 2021-02-05 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0175_orderrefund_comment'), + ] + + operations = [ + migrations.AddField( + model_name='eventmetaproperty', + name='allowed_values', + field=models.TextField(null=True), + ), + migrations.AddField( + model_name='eventmetaproperty', + name='protected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='eventmetaproperty', + name='required', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 53ecdf34b2..78e37108e7 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -16,11 +16,12 @@ from django.core.validators import ( from django.db import models from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery, Value from django.template.defaultfilters import date as _date +from django.urls import reverse from django.utils.crypto import get_random_string from django.utils.formats import date_format from django.utils.functional import cached_property from django.utils.timezone import make_aware, now -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext, gettext_lazy as _ from django_scopes import ScopedManager, scopes_disabled from i18nfield.fields import I18nCharField, I18nTextField @@ -953,6 +954,18 @@ class Event(EventMixin, LoggedModel): if not self.quotas.exists(): issues.append(_('You need to configure at least one quota to sell anything.')) + for mp in self.organizer.meta_properties.all(): + if mp.required and not self.meta_data.get(mp.name): + issues.append( + ('' + gettext('You need to fill the meta parameter "{property}".') + '').format( + property=mp.name, + a_attr='href="%s#id_prop-%d-value"' % ( + reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}), + mp.pk + ) + ) + ) + responses = event_live_issues.send(self) for receiver, response in sorted(responses, key=lambda r: str(r[0])): if response: @@ -1363,7 +1376,26 @@ class EventMetaProperty(LoggedModel): ], verbose_name=_("Name"), ) - default = models.TextField(blank=True) + default = models.TextField(blank=True, verbose_name=_("Default value")) + protected = models.BooleanField(default=False, + verbose_name=_("Can only be changed by organizer-level administrators")) + required = models.BooleanField( + default=False, verbose_name=_("Required for events"), + help_text=_("If checked, an event can only be taken live if the property is set. In event series, its always " + "optional to set a value for individual dates") + ) + 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.") + ) + + def full_clean(self, exclude=None, validate_unique=True): + super().full_clean(exclude, validate_unique) + if self.default and self.required: + raise ValidationError(_("A property can either be required or have a default value, not both.")) + if self.default and self.allowed_values and self.default not in self.allowed_values.splitlines(): + raise ValidationError(_("You cannot set a default value that is not a valid value.")) class EventMetaValue(LoggedModel): diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 44f294afa1..5317443394 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -265,15 +265,32 @@ class EventMetaValueForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.property = kwargs.pop('property') + self.disabled = kwargs.pop('disabled') super().__init__(*args, **kwargs) + 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:events.meta.typeahead') + '?' + urlencode({ + 'property': self.property.name, + 'organizer': self.property.organizer.slug, + }) + ) self.fields['value'].required = False - self.fields['value'].widget.attrs['placeholder'] = self.property.default - self.fields['value'].widget.attrs['data-typeahead-url'] = ( - reverse('control:events.meta.typeahead') + '?' + urlencode({ - 'property': self.property.name, - 'organizer': self.property.organizer.slug, - }) - ) + if self.disabled: + self.fields['value'].widget.attrs['readonly'] = 'readonly' + + def clean_slug(self): + if self.disabled: + return self.instance.value if self.instance else None + return self.cleaned_data['slug'] class Meta: model = EventMetaValue diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index cf72b8bed6..f7031adca4 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -13,7 +13,9 @@ from pretix.api.models import WebHook from pretix.api.webhooks import get_all_webhook_events from pretix.base.forms import I18nModelForm, SettingsForm from pretix.base.forms.widgets import SplitDateTimePickerWidget -from pretix.base.models import Device, Gate, GiftCard, Organizer, Team +from pretix.base.models import ( + Device, EventMetaProperty, Gate, GiftCard, Organizer, Team, +) from pretix.control.forms import ExtFileField, SplitDateTimeField from pretix.control.forms.event import SafeEventMultipleChoiceField from pretix.multidomain.models import KnownDomain @@ -125,7 +127,8 @@ class OrganizerUpdateForm(OrganizerForm): class EventMetaPropertyForm(forms.ModelForm): class Meta: - fields = ['name', 'default'] + model = EventMetaProperty + fields = ['name', 'default', 'required', 'protected', 'allowed_values'] widgets = { 'default': forms.TextInput() } diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py index d90d6c82b7..1bbcb7ac44 100644 --- a/src/pretix/control/forms/subevents.py +++ b/src/pretix/control/forms/subevents.py @@ -162,15 +162,32 @@ class SubEventMetaValueForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.property = kwargs.pop('property') self.default = kwargs.pop('default', None) + self.disabled = kwargs.pop('disabled') super().__init__(*args, **kwargs) + if self.property.allowed_values: + self.fields['value'] = forms.ChoiceField( + label=self.property.name, + choices=[ + ('', _('Default ({value})').format(value=self.default or self.property.default) if self.default or 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.default or self.property.default + self.fields['value'].widget.attrs['data-typeahead-url'] = ( + reverse('control:events.meta.typeahead') + '?' + urlencode({ + 'property': self.property.name, + 'organizer': self.property.organizer.slug, + }) + ) self.fields['value'].required = False - self.fields['value'].widget.attrs['placeholder'] = self.default or self.property.default - self.fields['value'].widget.attrs['data-typeahead-url'] = ( - reverse('control:events.meta.typeahead') + '?' + urlencode({ - 'property': self.property.name, - 'organizer': self.property.organizer.slug, - }) - ) + if self.disabled: + self.fields['value'].widget.attrs['readonly'] = 'readonly' + + def clean_slug(self): + if self.disabled: + return self.instance.value if self.instance else None + return self.cleaned_data['slug'] class Meta: model = SubEventMetaValue diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index e7ff71f281..d5ed5cd1c5 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -427,6 +427,13 @@ def get_organizer_navigation(request): }), 'active': url.url_name == 'organizer.edit', }, + { + 'label': _('Event metadata'), + 'url': reverse('control:organizer.properties', kwargs={ + 'organizer': request.organizer.slug + }), + 'active': url.url_name.startswith('organizer.propert'), + }, ] }) if 'can_change_teams' in request.orgapermset: diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index e5bba833de..7bae6c27d6 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -70,7 +70,7 @@
- {% bootstrap_form form layout="inline" %} + {% bootstrap_form form layout="inline" error_types="all" %}
{% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html index 4b0eb4c99c..363e86c5d4 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -70,65 +70,6 @@ {% bootstrap_field sform.giftcard_expiry_years layout="control" %} {% bootstrap_field sform.giftcard_length layout="control" %} -
- {% trans "Event metadata" %} -

- {% blocktrans trimmed %} - You can here define a set of metadata properties (i.e. variables) that you can later set for your - events and re-use in places like ticket layouts. This is an useful timesaver if you create lots and - lots of events. - {% endblocktrans %} -

-
- {{ formset.management_form }} - {% bootstrap_formset_errors formset %} -
- {% for form in formset %} -
-
- {{ form.id }} - {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} -
-
- {% bootstrap_form_errors form %} - {% bootstrap_field form.name layout='inline' form_group_class="" %} -
-
- {% bootstrap_field form.default layout='inline' form_group_class="" %} -
-
- -
-
- {% endfor %} -
- -

- -

-
-
+
+ +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/property_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/property_edit.html new file mode 100644 index 0000000000..f4f2ffc841 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/property_edit.html @@ -0,0 +1,20 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} + {% if gate %} +

{% trans "Property:" %} {{ property.name }}

+ {% else %} +

{% trans "Create a new property" %}

+ {% endif %} +
+ {% csrf_token %} + {% bootstrap_form form layout="control" %} +
+ +
+ +
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html index d7adc21f90..67abffe443 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html @@ -422,7 +422,7 @@
- {% bootstrap_form form layout="inline" %} + {% bootstrap_form form layout="inline" error_types="all" %}
{% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/subevents/detail.html b/src/pretix/control/templates/pretixcontrol/subevents/detail.html index 1f387c788d..82739eb71d 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/detail.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/detail.html @@ -64,7 +64,7 @@
- {% bootstrap_form form layout="inline" %} + {% bootstrap_form form layout="inline" error_types="all" %}
{% endfor %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index cd64c8d801..cae129e67e 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -77,6 +77,13 @@ urlpatterns = [ url(r'^organizer/(?P[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'), url(r'^organizer/(?P[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(), name='organizer.display'), + url(r'^organizer/(?P[^/]+)/properties$', organizer.EventMetaPropertyListView.as_view(), name='organizer.properties'), + url(r'^organizer/(?P[^/]+)/property/add$', organizer.EventMetaPropertyCreateView.as_view(), + name='organizer.property.add'), + url(r'^organizer/(?P[^/]+)/property/(?P[^/]+)/edit$', organizer.EventMetaPropertyUpdateView.as_view(), + name='organizer.property.edit'), + url(r'^organizer/(?P[^/]+)/property/(?P[^/]+)/delete$', organizer.EventMetaPropertyDeleteView.as_view(), + name='organizer.property.delete'), url(r'^organizer/(?P[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'), url(r'^organizer/(?P[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'), url(r'^organizer/(?P[^/]+)/giftcard/(?P[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'), diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 979beb2c6a..93d0179db0 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -91,6 +91,10 @@ class MetaDataEditorMixin: return self.meta_form( prefix='prop-{}'.format(p.pk), property=p, + disabled=( + p.protected and + not self.request.user.has_organizer_permission(self.request.organizer, 'can_change_organizer_settings', request=self.request) + ), instance=val_instances.get(p.pk, self.meta_model(property=p, event=self.object)), data=(self.request.POST if self.request.method == "POST" else None) ) diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 28641cc5b0..b40344e97a 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -12,7 +12,7 @@ from django.db.models import ( Count, Max, Min, OuterRef, Prefetch, ProtectedError, Subquery, Sum, ) from django.db.models.functions import Coalesce, Greatest -from django.forms import DecimalField, inlineformset_factory +from django.forms import DecimalField from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse @@ -269,12 +269,10 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView): def get_context_data(self, *args, **kwargs) -> dict: context = super().get_context_data(*args, **kwargs) context['sform'] = self.sform - context['formset'] = self.formset return context @transaction.atomic def form_valid(self, form): - self.save_formset(self.object) self.sform.save() change_css = False if self.sform.has_changed(): @@ -321,38 +319,11 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView): def post(self, request, *args, **kwargs): self.object = self.get_object() form = self.get_form() - if form.is_valid() and self.sform.is_valid() and self.formset.is_valid(): + if form.is_valid() and self.sform.is_valid(): return self.form_valid(form) else: return self.form_invalid(form) - @cached_property - def formset(self): - formsetclass = inlineformset_factory( - Organizer, EventMetaProperty, - form=EventMetaPropertyForm, can_order=False, can_delete=True, extra=0 - ) - return formsetclass(self.request.POST if self.request.method == "POST" else None, - instance=self.object, queryset=self.object.meta_properties.all()) - - def save_formset(self, obj): - for form in self.formset.initial_forms: - if form in self.formset.deleted_forms: - if not form.instance.pk: - continue - form.instance.delete() - form.instance.pk = None - elif form.has_changed(): - form.save() - - for form in self.formset.extra_forms: - if not form.has_changed(): - continue - if self.formset._should_delete_form(form): - continue - form.instance.organizer = obj - form.save() - class OrganizerCreate(CreateView): model = Organizer @@ -1365,3 +1336,94 @@ class GateDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, self.object.delete() messages.success(request, _('The selected gate has been deleted.')) return redirect(success_url) + + +class EventMetaPropertyListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView): + model = EventMetaProperty + template_name = 'pretixcontrol/organizers/properties.html' + permission = 'can_change_organizer_settings' + context_object_name = 'properties' + + def get_queryset(self): + return self.request.organizer.meta_properties.all() + + +class EventMetaPropertyCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView): + model = EventMetaProperty + template_name = 'pretixcontrol/organizers/property_edit.html' + permission = 'can_change_organizer_settings' + form_class = EventMetaPropertyForm + + def get_object(self, queryset=None): + return get_object_or_404(EventMetaProperty, organizer=self.request.organizer, pk=self.kwargs.get('property')) + + def get_success_url(self): + return reverse('control:organizer.properties', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + def form_valid(self, form): + messages.success(self.request, _('The property has been created.')) + form.instance.organizer = self.request.organizer + ret = super().form_valid(form) + form.instance.log_action('pretix.property.created', user=self.request.user, data={ + k: getattr(self.object, k) for k in form.changed_data + }) + return ret + + def form_invalid(self, form): + messages.error(self.request, _('Your changes could not be saved.')) + return super().form_invalid(form) + + +class EventMetaPropertyUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView): + model = EventMetaProperty + template_name = 'pretixcontrol/organizers/property_edit.html' + permission = 'can_change_organizer_settings' + context_object_name = 'property' + form_class = EventMetaPropertyForm + + def get_object(self, queryset=None): + return get_object_or_404(EventMetaProperty, organizer=self.request.organizer, pk=self.kwargs.get('property')) + + def get_success_url(self): + return reverse('control:organizer.properties', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + def form_valid(self, form): + if form.has_changed(): + self.object.log_action('pretix.property.changed', user=self.request.user, data={ + k: getattr(self.object, k) + for k in form.changed_data + }) + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) + + def form_invalid(self, form): + messages.error(self.request, _('Your changes could not be saved.')) + return super().form_invalid(form) + + +class EventMetaPropertyDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView): + model = EventMetaProperty + template_name = 'pretixcontrol/organizers/property_delete.html' + permission = 'can_change_organizer_settings' + context_object_name = 'property' + + def get_object(self, queryset=None): + return get_object_or_404(EventMetaProperty, organizer=self.request.organizer, pk=self.kwargs.get('property')) + + def get_success_url(self): + return reverse('control:organizer.properties', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + success_url = self.get_success_url() + self.object = self.get_object() + self.object.log_action('pretix.property.deleted', user=self.request.user) + self.object.delete() + messages.success(request, _('The selected property has been deleted.')) + return redirect(success_url) diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index f3028b0403..4d1fb40511 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -167,6 +167,10 @@ class SubEventEditorMixin(MetaDataEditorMixin): return self.meta_form( prefix='prop-{}'.format(p.pk), property=p, + disabled=( + p.protected and + not self.request.user.has_organizer_permission(self.request.organizer, 'can_change_organizer_settings', request=self.request) + ), default=self._default_meta.get(p.name, ''), instance=val_instances.get(p.pk, self.meta_model(property=p, subevent=self.object)), data=(self.request.POST if self.request.method == "POST" else None) diff --git a/src/setup.cfg b/src/setup.cfg index 2887e0dfff..c83dfcf59f 100644 --- a/src/setup.cfg +++ b/src/setup.cfg @@ -1,5 +1,5 @@ [flake8] -ignore = N802,W503,E402,C901,E722,W504,E252,N812,N806 +ignore = N802,W503,E402,C901,E722,W504,E252,N812,N806,E741 max-line-length = 160 exclude = migrations,.ropeproject,static,mt940.py,_static,build,make_testdata.py,*/testutils/settings.py,tests/settings.py,pretix/base/models/__init__.py,pretix/base/secretgenerators/pretix_sig1_pb2.py max-complexity = 11 diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index f3661b3f98..0146980f99 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -164,7 +164,14 @@ def test_event_get(token_client, organizer, event): @pytest.mark.django_db -def test_event_create(token_client, organizer, event, meta_prop): +def test_event_create(team, token_client, organizer, event, meta_prop): + meta_prop.allowed_values = "Conference\nWorkshop" + meta_prop.save() + team.can_change_organizer_settings = False + team.save() + organizer.meta_properties.create( + name="protected", protected=True + ) resp = token_client.post( '/api/v1/organizers/{}/events/'.format(organizer.slug), { @@ -183,7 +190,8 @@ def test_event_create(token_client, organizer, event, meta_prop): "location": None, "slug": "2030", "meta_data": { - meta_prop.name: "Conference" + meta_prop.name: "Conference", + "protected": "ignored", }, "seat_category_mapping": {}, "timezone": "Europe/Amsterdam" @@ -196,6 +204,9 @@ def test_event_create(token_client, organizer, event, meta_prop): assert organizer.events.get(slug="2030").meta_values.filter( property__name=meta_prop.name, value="Conference" ).exists() + assert not organizer.events.get(slug="2030").meta_values.filter( + property__name="protected" + ).exists() assert organizer.events.get(slug="2030").plugins == settings.PRETIX_PLUGINS_DEFAULT assert organizer.events.get(slug="2030").settings.timezone == "Europe/Amsterdam" @@ -225,6 +236,32 @@ def test_event_create(token_client, organizer, event, meta_prop): assert resp.status_code == 400 assert resp.content.decode() == '{"meta_data":["Meta data property \'foo\' does not exist."]}' + resp = token_client.post( + '/api/v1/organizers/{}/events/'.format(organizer.slug), + { + "name": { + "de": "Demo Konference 2020 Test", + "en": "Demo Conference 2020 Test" + }, + "live": False, + "currency": "EUR", + "date_from": "2017-12-27T10:00:00Z", + "date_to": "2017-12-28T10:00:00Z", + "date_admission": None, + "is_public": False, + "presale_start": None, + "presale_end": None, + "location": None, + "slug": "2020", + "meta_data": { + meta_prop.name: "bar" + } + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"meta_data":["Meta data property \'type\' does not allow value \'bar\'."]}' + resp = token_client.post( '/api/v1/organizers/{}/events/'.format(organizer.slug), { diff --git a/src/tests/api/test_subevents.py b/src/tests/api/test_subevents.py index 080de1b9be..f7692af915 100644 --- a/src/tests/api/test_subevents.py +++ b/src/tests/api/test_subevents.py @@ -143,7 +143,14 @@ def test_subevent_list_filter(token_client, organizer, event, subevent): @pytest.mark.django_db -def test_subevent_create(token_client, organizer, event, subevent, meta_prop, item): +def test_subevent_create(team, token_client, organizer, event, subevent, meta_prop, item): + meta_prop.allowed_values = "Conference\nWorkshop" + meta_prop.save() + team.can_change_organizer_settings = False + team.save() + organizer.meta_properties.create( + name="protected", protected=True + ) resp = token_client.post( '/api/v1/organizers/{}/events/{}/subevents/'.format(organizer.slug, event.slug), { @@ -161,7 +168,8 @@ def test_subevent_create(token_client, organizer, event, subevent, meta_prop, it "item_price_overrides": [], "variation_price_overrides": [], "meta_data": { - "type": "Workshop" + "type": "Workshop", + "protected": "ignored", }, }, format='json' @@ -172,6 +180,9 @@ def test_subevent_create(token_client, organizer, event, subevent, meta_prop, it assert subevent.meta_values.filter( property__name=meta_prop.name, value="Workshop" ).exists() + assert not subevent.meta_values.filter( + property__name="ignored", + ).exists() resp = token_client.post( '/api/v1/organizers/{}/events/{}/subevents/'.format(organizer.slug, event.slug), @@ -198,6 +209,31 @@ def test_subevent_create(token_client, organizer, event, subevent, meta_prop, it assert resp.status_code == 400 assert resp.content.decode() == '{"meta_data":["Meta data property \'foo\' does not exist."]}' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/subevents/'.format(organizer.slug, event.slug), + { + "name": { + "de": "Demo Subevent 2020 Test", + "en": "Demo Subevent 2020 Test" + }, + "active": False, + "date_from": "2017-12-27T10:00:00Z", + "date_to": "2017-12-28T10:00:00Z", + "date_admission": None, + "presale_start": None, + "presale_end": None, + "location": None, + "item_price_overrides": [], + "variation_price_overrides": [], + "meta_data": { + meta_prop.name: "bar" + }, + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"meta_data":["Meta data property \'type\' does not allow value \'bar\'."]}' + resp = token_client.post( '/api/v1/organizers/{}/events/{}/subevents/'.format(organizer.slug, event.slug), { diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 9c763d3ff9..fcc3a1d1e8 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -148,6 +148,10 @@ organizer_urls = [ 'organizer/abc/gate/add', 'organizer/abc/gate/1/edit', 'organizer/abc/gate/1/delete', + 'organizer/abc/properties', + 'organizer/abc/property/add', + 'organizer/abc/property/1/edit', + 'organizer/abc/property/1/delete', 'organizer/abc/webhooks', 'organizer/abc/webhook/add', 'organizer/abc/webhook/1/edit', @@ -428,6 +432,10 @@ organizer_permission_urls = [ ("can_change_organizer_settings", "organizer/dummy/gate/add", 200), ("can_change_organizer_settings", "organizer/dummy/gate/1/edit", 404), ("can_change_organizer_settings", "organizer/dummy/gate/1/delete", 404), + ("can_change_organizer_settings", "organizer/dummy/properties", 200), + ("can_change_organizer_settings", "organizer/dummy/property/add", 200), + ("can_change_organizer_settings", "organizer/dummy/property/1/edit", 404), + ("can_change_organizer_settings", "organizer/dummy/property/1/delete", 404), ("can_manage_gift_cards", "organizer/dummy/giftcards", 200), ("can_manage_gift_cards", "organizer/dummy/giftcard/add", 200), ("can_manage_gift_cards", "organizer/dummy/giftcard/1/", 404),