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" %}