Add public filters based on meta data (#3673)

* Add public filters based on meta data

* Fix licenseheaders

* ignore empty values

* Fix tests

* Full non-widget implementation

* Widget support

* Add a few tests

* Allow to reorder properties

* Fix isort

* Allow to opt-out for specific events

* Fix name clash between new and old field to make migration feasible
This commit is contained in:
Raphael Michel
2023-11-10 12:10:01 +01:00
committed by GitHub
parent c0007a9566
commit d7aa94d6ae
34 changed files with 829 additions and 82 deletions

View File

@@ -316,12 +316,12 @@ class EventMetaValueForm(forms.ModelForm):
self.property = kwargs.pop('property')
self.disabled = kwargs.pop('disabled')
super().__init__(*args, **kwargs)
if self.property.allowed_values:
if self.property.choices:
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()],
] + [(a.strip(), a.strip()) for a in self.property.choice_keys],
)
else:
self.fields['value'].label = self.property.name
@@ -558,6 +558,7 @@ class EventSettingsForm(EventSettingsValidationMixin, SettingsForm):
'low_availability_percentage',
'event_list_type',
'event_list_available_only',
'event_list_filters',
'event_calendar_future_only',
'frontpage_text',
'event_info_text',
@@ -645,6 +646,7 @@ class EventSettingsForm(EventSettingsValidationMixin, SettingsForm):
del self.fields['frontpage_subevent_ordering']
del self.fields['event_list_type']
del self.fields['event_list_available_only']
del self.fields['event_list_filters']
del self.fields['event_calendar_future_only']
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields

View File

@@ -39,7 +39,7 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.forms import inlineformset_factory
from django.forms import formset_factory, inlineformset_factory
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.crypto import get_random_string
@@ -48,7 +48,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField
from i18nfield.forms import (
I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
)
from phonenumber_field.formfields import PhoneNumberField
from pytz import common_timezones
@@ -195,14 +195,50 @@ class SafeOrderPositionChoiceField(forms.ModelChoiceField):
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
class EventMetaPropertyForm(forms.ModelForm):
class EventMetaPropertyForm(I18nModelForm):
class Meta:
model = EventMetaProperty
fields = ['name', 'default', 'required', 'protected', 'allowed_values', 'filter_allowed']
fields = ['name', 'default', 'required', 'protected', 'filter_public', 'public_label', 'filter_allowed']
widgets = {
'default': forms.TextInput()
'default': forms.TextInput(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['public_label'].widget.attrs['data-display-dependency'] = '#id_filter_public'
class EventMetaPropertyAllowedValueForm(I18nForm):
key = forms.CharField(
label=_('Internal name'),
max_length=250,
required=True
)
label = I18nFormField(
label=_('Public name'),
required=False,
widget=I18nTextInput,
widget_kwargs=dict(attrs={
'placeholder': _('Public name'),
})
)
class I18nBaseFormSet(I18nFormSetMixin, forms.BaseFormSet):
# compatibility shim for django-i18nfield library
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer', None)
if self.organizer:
kwargs['locales'] = self.organizer.settings.get('locales')
super().__init__(*args, **kwargs)
EventMetaPropertyAllowedValueFormSet = formset_factory(
EventMetaPropertyAllowedValueForm, formset=I18nBaseFormSet,
can_order=True, can_delete=True, extra=0
)
class MembershipTypeForm(I18nModelForm):
class Meta:

View File

@@ -393,12 +393,12 @@ class SubEventMetaValueForm(forms.ModelForm):
self.default = kwargs.pop('default', None)
self.disabled = kwargs.pop('disabled', False)
super().__init__(*args, **kwargs)
if self.property.allowed_values:
if self.property.choices:
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()],
] + [(a.strip(), a.strip()) for a in self.property.choice_keys],
)
else:
self.fields['value'].label = self.property.name

View File

@@ -316,6 +316,9 @@
{% if sform.event_list_available_only %}
{% bootstrap_field sform.event_list_available_only layout="control" %}
{% endif %}
{% if sform.event_list_filters %}
{% bootstrap_field sform.event_list_filters layout="control" %}
{% endif %}
{% if sform.event_calendar_future_only %}
{% bootstrap_field sform.event_calendar_future_only layout="control" %}
{% endif %}

View File

@@ -14,29 +14,56 @@
<span class="fa fa-plus"></span>
{% trans "Create a new property" %}
</a>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Property" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for p in properties %}
<form method="post">
{% csrf_token %}
<table class="table table-condensed table-hover">
<thead>
<tr>
<td><strong>
<a href="{% url "control:organizer.property.edit" organizer=request.organizer.slug property=p.id %}">
{{ p.name }}
</a>
</strong></td>
<td class="text-right flip">
<a href="{% url "control:organizer.property.edit" organizer=request.organizer.slug property=p.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:organizer.property.delete" organizer=request.organizer.slug property=p.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
<th>{% trans "Property" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr>
{% endfor %}
</tbody>
</table>
</thead>
<tbody data-dnd-url="{% url "control:organizer.properties.reorder" organizer=request.organizer.slug %}">
{% for p in properties %}
<tr data-dnd-id="{{ p.pk }}">
<td><strong>
<a href="{% url "control:organizer.property.edit" organizer=request.organizer.slug property=p.id %}">
{{ p.name }}
</a>
</strong></td>
<td>
{% if p.filter_allowed %}
<span class="fa fa-filter text-muted" data-toggle="tooltip" title="{% trans "Can be used for filtering" %}"></span>
{% endif %}
</td>
<td>
{% if p.filter_public %}
<span class="fa fa-eye text-muted" data-toggle="tooltip" title="{% trans "Show filter option to customers" %}"></span>
{% endif %}
</td>
<td>
{% if p.protected %}
<span class="fa fa-lock text-muted" data-toggle="tooltip" title="{% trans "Can only be changed by organizer-level administrators" %}"></span>
{% endif %}
</td>
<td>
<button formaction="{% url "control:organizer.property.up" organizer=request.organizer.slug property=p.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:organizer.property.down" organizer=request.organizer.slug property=p.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span>
</td>
<td class="text-right flip">
<a href="{% url "control:organizer.property.edit" organizer=request.organizer.slug property=p.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:organizer.property.delete" organizer=request.organizer.slug property=p.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load formset_tags %}
{% load bootstrap3 %}
{% block inner %}
{% if property %}
@@ -10,6 +11,78 @@
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Allowed values" %}<br>
<span class="optional">{% trans "Optional" %}</span>
</label>
<div class="col-md-9">
<p class="help-block">{% trans "If you keep this empty, all input will be allowed." %}</p>
<div class="formset tax-rules-formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row tax-rule-line" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="col-sm-6 col-md-4 col-lg-5">
{% bootstrap_field formset.empty_form.key layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-4 col-lg-5">
{% bootstrap_field formset.empty_form.label layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-2 text-right flip">
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endescapescript %}
</script>
<div data-formset-body class="tax-rule-lines">
{% for form in formset %}
{% bootstrap_form_errors form %}
<div class="row tax-rule-line" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="col-sm-6 col-md-4 col-lg-5">
{% bootstrap_field form.key layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-4 col-lg-5">
{% bootstrap_field form.label layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-2 text-right flip">
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<div class="row tax-rule-line" data-formset-form>
<div class="col-sm-12">
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new value" %}</button>
</div>
</div>
</div>
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}

View File

@@ -133,6 +133,11 @@
{{ s.name }}</a></strong><br>
<small class="text-muted">
#{{ s.pk }}
{% for k, v in s.meta_data.items %}
{% if v %}
<small class="text-muted">&middot; {{ k }}: {{ v }}</small>
{% endif %}
{% endfor %}
</small>
</td>
<td>

View File

@@ -126,6 +126,12 @@ urlpatterns = [
name='organizer.property.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/property/(?P<property>[^/]+)/delete$', organizer.EventMetaPropertyDeleteView.as_view(),
name='organizer.property.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/property/(?P<property>[^/]+)/up$', organizer.meta_property_move_up,
name='organizer.property.up'),
re_path(r'^organizer/(?P<organizer>[^/]+)/property/(?P<property>[^/]+)/down$', organizer.meta_property_move_down,
name='organizer.property.down'),
re_path(r'^organizer/(?P<organizer>[^/]+)/property/reorder$', organizer.reorder_meta_properties,
name='organizer.properties.reorder'),
re_path(r'^organizer/(?P<organizer>[^/]+)/membershiptypes$', organizer.MembershipTypeListView.as_view(), name='organizer.membershiptypes'),
re_path(r'^organizer/(?P<organizer>[^/]+)/membershiptype/add$', organizer.MembershipTypeCreateView.as_view(),
name='organizer.membershiptype.add'),

View File

@@ -37,6 +37,7 @@ import re
from datetime import time, timedelta
from decimal import Decimal
from hashlib import sha1
from json import JSONDecodeError
import bleach
import dateutil
@@ -52,7 +53,9 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce, Greatest
from django.forms import DecimalField
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.formats import date_format
@@ -60,6 +63,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext, gettext_lazy as _
from django.views import View
from django.views.decorators.http import require_http_methods
from django.views.generic import (
CreateView, DetailView, FormView, ListView, TemplateView, UpdateView,
)
@@ -97,17 +101,19 @@ from pretix.control.forms.filter import (
from pretix.control.forms.orders import ExporterForm
from pretix.control.forms.organizer import (
CustomerCreateForm, CustomerUpdateForm, DeviceBulkEditForm, DeviceForm,
EventMetaPropertyForm, GateForm, GiftCardAcceptanceInviteForm,
GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm,
MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm,
OrganizerFooterLinkFormset, OrganizerForm, OrganizerSettingsForm,
OrganizerUpdateForm, ReusableMediumCreateForm, ReusableMediumUpdateForm,
SSOClientForm, SSOProviderForm, TeamForm, WebHookForm,
EventMetaPropertyAllowedValueFormSet, EventMetaPropertyForm, GateForm,
GiftCardAcceptanceInviteForm, GiftCardCreateForm, GiftCardUpdateForm,
MailSettingsForm, MembershipTypeForm, MembershipUpdateForm,
OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm,
OrganizerSettingsForm, OrganizerUpdateForm, ReusableMediumCreateForm,
ReusableMediumUpdateForm, SSOClientForm, SSOProviderForm, TeamForm,
WebHookForm,
)
from pretix.control.forms.rrule import RRuleForm
from pretix.control.logdisplay import OVERVIEW_BANLIST
from pretix.control.permissions import (
AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin,
organizer_permission_required,
)
from pretix.control.signals import nav_organizer
from pretix.control.views import PaginationMixin
@@ -2043,14 +2049,54 @@ class EventMetaPropertyListView(OrganizerDetailViewMixin, OrganizerPermissionReq
return self.request.organizer.meta_properties.all()
class EventMetaPropertyCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
model = EventMetaProperty
class EventMetaPropertyEditorMixin:
template_name = 'pretixcontrol/organizers/property_edit.html'
permission = 'can_change_organizer_settings'
form_class = EventMetaPropertyForm
@cached_property
def formset(self):
return EventMetaPropertyAllowedValueFormSet(
data=self.request.POST if self.request.method == "POST" else None,
organizer=self.request.organizer,
initial=(self.object.choices or []) if self.object else [],
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['formset'] = self.formset
return ctx
def get_form_kwargs(self):
return {
**super().get_form_kwargs(),
'event': self.request.organizer,
}
def is_default_valid(self):
choice_keys = [
f.cleaned_data.get("key") for f in self.formset.ordered_forms if f not in self.formset.deleted_forms
]
default = self.form.cleaned_data["default"]
if default and choice_keys and default not in choice_keys:
messages.error(self.request, _("You cannot set a default value that is not a valid value."))
return False
return True
def post(self, request, *args, **kwargs):
self.object = self.get_object(self.get_queryset())
self.form = self.get_form()
if self.form.is_valid() and self.formset.is_valid() and self.is_default_valid():
return self.form_valid(self.form)
else:
return self.form_invalid(self.form)
class EventMetaPropertyCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, EventMetaPropertyEditorMixin, CreateView):
model = EventMetaProperty
permission = 'can_change_organizer_settings'
def get_object(self, queryset=None):
return get_object_or_404(EventMetaProperty, organizer=self.request.organizer, pk=self.kwargs.get('property'))
return EventMetaProperty()
def get_success_url(self):
return reverse('control:organizer.properties', kwargs={
@@ -2060,6 +2106,9 @@ class EventMetaPropertyCreateView(OrganizerDetailViewMixin, OrganizerPermissionR
def form_valid(self, form):
messages.success(self.request, _('The property has been created.'))
form.instance.organizer = self.request.organizer
form.instance.choices = [
f.cleaned_data for f in self.formset.ordered_forms if f not in self.formset.deleted_forms
]
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
@@ -2071,12 +2120,10 @@ class EventMetaPropertyCreateView(OrganizerDetailViewMixin, OrganizerPermissionR
return super().form_invalid(form)
class EventMetaPropertyUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
class EventMetaPropertyUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, EventMetaPropertyEditorMixin, 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'))
@@ -2087,7 +2134,10 @@ class EventMetaPropertyUpdateView(OrganizerDetailViewMixin, OrganizerPermissionR
})
def form_valid(self, form):
if form.has_changed():
form.instance.choices = [
f.cleaned_data for f in self.formset.ordered_forms if f not in self.formset.deleted_forms
]
if form.has_changed() or self.formset.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
@@ -2124,6 +2174,75 @@ class EventMetaPropertyDeleteView(OrganizerDetailViewMixin, OrganizerPermissionR
return redirect(success_url)
def meta_property_move(request, property, up=True):
property = get_object_or_404(request.organizer.meta_properties, id=property)
properties = list(request.organizer.meta_properties.order_by("position"))
index = properties.index(property)
if index != 0 and up:
properties[index - 1], properties[index] = properties[index], properties[index - 1]
elif index != len(properties) - 1 and not up:
properties[index + 1], properties[index] = properties[index], properties[index + 1]
for i, prop in enumerate(properties):
if prop.position != i:
prop.position = i
prop.save()
prop.log_action(
'pretix.property.reordered', user=request.user, data={
'position': i,
}
)
messages.success(request, _('The order of properties has been updated.'))
@organizer_permission_required("can_change_organizer_settings")
@require_http_methods(["POST"])
def meta_property_move_up(request, organizer, property):
meta_property_move(request, property, up=True)
return redirect('control:organizer.properties',
organizer=request.organizer.slug)
@organizer_permission_required("can_change_organizer_settings")
@require_http_methods(["POST"])
def meta_property_move_down(request, organizer, property):
meta_property_move(request, property, up=False)
return redirect('control:organizer.properties',
organizer=request.organizer.slug)
@transaction.atomic
@organizer_permission_required("can_change_items")
@require_http_methods(["POST"])
def reorder_meta_properties(request, organizer):
try:
ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
input_meta_properties = list(request.organizer.meta_properties.filter(id__in=[i for i in ids if i.isdigit()]))
if len(input_meta_properties) != len(ids):
raise Http404(_("Some of the provided object ids are invalid."))
if len(input_meta_properties) != request.organizer.meta_properties.count():
raise Http404(_("Not all objects have been selected."))
for c in input_meta_properties:
pos = ids.index(str(c.pk))
if pos != c.position: # Save unneccessary UPDATE queries
c.position = pos
c.save(update_fields=['position'])
c.log_action(
'pretix.property.reordered', user=request.user, data={
'position': pos,
}
)
return HttpResponse()
class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
template_name = 'pretixcontrol/organizers/logs.html'
permission = 'can_change_organizer_settings'

View File

@@ -120,7 +120,10 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryM
permission = 'can_change_settings'
def get_queryset(self):
return super().get_queryset(True)
return super().get_queryset(True).prefetch_related(
'meta_values',
'meta_values__property',
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)