From 0aee73a9bd28127283e16f5404d86f021e9c4ba9 Mon Sep 17 00:00:00 2001
From: Raphael Michel
Date: Mon, 4 May 2026 12:44:22 +0200
Subject: [PATCH] Quotas: Add bulk-edit, bulk-delete and filter form (#6080)
* Quotas: Add bulk-edit, bulk-delete and filter form
* Fix GroupConcat
* Apply suggestions from code review
Co-authored-by: Richard Schreiber
* Review notes
* Fix handling of required fields
---------
Co-authored-by: Richard Schreiber
---
src/pretix/control/forms/filter.py | 127 ++++++++++
src/pretix/control/forms/item.py | 55 +++++
.../pretixcontrol/items/quota_bulk_edit.html | 49 ++++
.../items/quota_delete_bulk.html | 34 +++
.../templates/pretixcontrol/items/quotas.html | 227 ++++++++++++------
src/pretix/control/urls.py | 2 +
src/pretix/control/views/item.py | 227 +++++++++++++++---
src/pretix/helpers/database.py | 7 +-
8 files changed, 622 insertions(+), 106 deletions(-)
create mode 100644 src/pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html
create mode 100644 src/pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html
diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py
index 806c5e2ea2..fda7545a03 100644
--- a/src/pretix/control/forms/filter.py
+++ b/src/pretix/control/forms/filter.py
@@ -1528,6 +1528,133 @@ class SubEventFilterForm(FilterForm):
return self.event.organizer.meta_properties.filter(filter_allowed=True)
+class QuotaFilterForm(FilterForm):
+ orders = {
+ '-date': ('-subevent__date_from', 'name', 'pk'),
+ 'date': ('subevent__date_from', '-name', '-pk'),
+ 'size': ('size', 'name', 'pk'),
+ '-size': ('-size', '-name', '-pk'),
+ 'name': ('name', 'pk'),
+ '-name': ('-name', '-pk'),
+ }
+ subevent = forms.ModelChoiceField(
+ label=pgettext_lazy('subevent', 'Date'),
+ queryset=SubEvent.objects.none(),
+ required=False,
+ empty_label=pgettext_lazy('subevent', 'All dates')
+ )
+ date_from = forms.DateField(
+ label=_('Date from'),
+ required=False,
+ widget=DatePickerWidget({
+ 'placeholder': _('Date from'),
+ }),
+ )
+ date_until = forms.DateField(
+ label=_('Date until'),
+ required=False,
+ widget=DatePickerWidget({
+ 'placeholder': _('Date until'),
+ }),
+ )
+ time_from = forms.TimeField(
+ label=_('Start time from'),
+ required=False,
+ widget=TimePickerWidget({}),
+ )
+ time_until = forms.TimeField(
+ label=_('Start time until'),
+ required=False,
+ widget=TimePickerWidget({}),
+ )
+ weekday = forms.MultipleChoiceField(
+ label=_('Weekday'),
+ choices=(
+ ('2', _('Monday')),
+ ('3', _('Tuesday')),
+ ('4', _('Wednesday')),
+ ('5', _('Thursday')),
+ ('6', _('Friday')),
+ ('7', _('Saturday')),
+ ('1', _('Sunday')),
+ ),
+ widget=forms.CheckboxSelectMultiple,
+ required=False
+ )
+ query = forms.CharField(
+ label=_('Quota name'),
+ widget=forms.TextInput(),
+ required=False
+ )
+
+ def __init__(self, *args, **kwargs):
+ self.event = kwargs.pop('event')
+ super().__init__(*args, **kwargs)
+ if self.event.has_subevents:
+ self.fields['date_from'].widget = DatePickerWidget()
+ self.fields['date_until'].widget = DatePickerWidget()
+ self.fields['subevent'].queryset = self.event.subevents.all()
+ self.fields['subevent'].widget = Select2(
+ attrs={
+ 'data-model-select2': 'event',
+ 'data-select2-url': reverse('control:event.subevents.select2', kwargs={
+ 'event': self.event.slug,
+ 'organizer': self.event.organizer.slug,
+ }),
+ 'data-placeholder': pgettext_lazy('subevent', 'All dates')
+ }
+ )
+ self.fields['subevent'].widget.choices = self.fields['subevent'].choices
+ else:
+ del self.fields['subevent']
+ del self.fields['date_from']
+ del self.fields['date_until']
+ del self.fields['time_from']
+ del self.fields['time_until']
+ del self.fields['weekday']
+
+ def filter_qs(self, qs):
+ fdata = self.cleaned_data
+
+ if fdata.get('weekday'):
+ qs = qs.annotate(wday=ExtractWeekDay('subevent__date_from')).filter(wday__in=fdata.get('weekday'))
+
+ if fdata.get('subevent'):
+ qs = qs.filter(subevent=fdata["subevent"])
+
+ if fdata.get('query'):
+ query = fdata.get('query')
+ qs = qs.filter(name__icontains=query)
+
+ if fdata.get('date_until'):
+ date_end = make_aware(datetime.combine(
+ fdata.get('date_until') + timedelta(days=1),
+ time(hour=0, minute=0, second=0, microsecond=0)
+ ), get_current_timezone())
+ qs = qs.filter(
+ Q(subevent__date_to__isnull=True, subevent__date_from__lt=date_end) |
+ Q(subevent__date_to__isnull=False, subevent__date_to__lt=date_end)
+ )
+ if fdata.get('date_from'):
+ date_start = make_aware(datetime.combine(
+ fdata.get('date_from'),
+ time(hour=0, minute=0, second=0, microsecond=0)
+ ), get_current_timezone())
+ qs = qs.filter(subevent__date_from__gte=date_start)
+
+ if fdata.get('time_until'):
+ qs = qs.filter(subevent__date_from__time__lte=fdata.get('time_until'))
+ if fdata.get('time_from'):
+ qs = qs.filter(subevent__date_from__time__gte=fdata.get('time_from'))
+
+ if fdata.get('ordering'):
+ qs = qs.order_by(*get_deterministic_ordering(Quota, self.get_order_by()))
+ else:
+ qs = qs.order_by('-subevent__date_from', 'name', 'pk')
+
+ return qs
+
+
class OrganizerFilterForm(FilterForm):
orders = {
'slug': 'slug',
diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py
index 6829907cd4..6fd90da244 100644
--- a/src/pretix/control/forms/item.py
+++ b/src/pretix/control/forms/item.py
@@ -43,6 +43,7 @@ from django.core.exceptions import ValidationError
from django.db.models import Max, Q
from django.forms import ChoiceField, RadioSelect
from django.forms.formsets import DELETION_FIELD_NAME
+from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape, format_html
@@ -375,6 +376,60 @@ class QuotaForm(I18nModelForm):
return inst
+class QuotaBulkEditForm(QuotaForm):
+
+ def __init__(self, *args, **kwargs):
+ self.mixed_values = kwargs.pop('mixed_values')
+ self.queryset = kwargs.pop('queryset')
+ super().__init__(**kwargs)
+ self.fields.pop("subevent", None) # Would add extra complexity and it's hard to imagine a use case for that
+ self.fields["name"].required = False
+ self.fields["itemvars"].required = False
+
+ def clean(self):
+ d = super().clean()
+ if self.prefix + "name" in self.data.getlist('_bulk') and not d.get("name"):
+ raise ValidationError({"name": _("This field is required.")})
+ if self.prefix + "itemvars" in self.data.getlist('_bulk') and not d.get("itemvars"):
+ raise ValidationError({"itemvars": _("This field is required.")})
+ return d
+
+ def save(self, commit=True):
+ objs = list(self.queryset)
+ fields = set()
+
+ for k in self.fields:
+ cb_val = self.prefix + k
+ if cb_val not in self.data.getlist('_bulk'):
+ continue
+
+ fields.add(k)
+ if k == 'itemvars':
+ selected_items = set(list(self.event.items.filter(id__in=[
+ i.split('-')[0] for i in self.cleaned_data['itemvars']
+ ])))
+ selected_variations = list(ItemVariation.objects.filter(item__event=self.event, id__in=[
+ i.split('-')[1] for i in self.cleaned_data['itemvars'] if '-' in i
+ ]))
+ for obj in objs:
+ obj.items.set(selected_items)
+ obj.variations.set(selected_variations)
+ else:
+ for obj in objs:
+ setattr(obj, k, self.cleaned_data[k])
+
+ fields = [f for f in fields if f != 'itemvars']
+ if fields:
+ Quota.objects.bulk_update(objs, fields, 200)
+
+ def full_clean(self):
+ if len(self.data) == 0:
+ # form wasn't submitted
+ self._errors = ErrorDict()
+ return
+ super().full_clean()
+
+
class ItemCreateForm(I18nModelForm):
NONE = 'none'
EXISTING = 'existing'
diff --git a/src/pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html b/src/pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html
new file mode 100644
index 0000000000..080f312cde
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html
@@ -0,0 +1,49 @@
+{% extends "pretixcontrol/event/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block content %}
+
+ {% trans "Change multiple quotas" %}
+
+ {% blocktrans trimmed with number=quotas.count %}
+ {{ number }} selected
+ {% endblocktrans %}
+
+
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html b/src/pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html
new file mode 100644
index 0000000000..2420d8bf12
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html
@@ -0,0 +1,34 @@
+{% extends "pretixcontrol/event/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block title %}{% trans "Delete quotas" %}{% endblock %}
+{% block content %}
+ {% trans "Delete quotas" %}
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/items/quotas.html b/src/pretix/control/templates/pretixcontrol/items/quotas.html
index 5e2729bfca..0da36c9be5 100644
--- a/src/pretix/control/templates/pretixcontrol/items/quotas.html
+++ b/src/pretix/control/templates/pretixcontrol/items/quotas.html
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load urlreplace %}
+{% load bootstrap3 %}
{% block title %}{% trans "Quotas" %}{% endblock %}
{% block inside %}
{% trans "Quotas" %}
@@ -13,21 +14,12 @@
number of a specific ticket type at the same time.
{% endblocktrans %}
- {% if request.event.has_subevents %}
-
- {% endif %}
- {% if quotas|length == 0 %}
+ {% if quotas|length == 0 and not filter_form.filtered %}
- {% if request.GET.subevent %}
- {% trans "Your search did not match any quotas." %}
- {% else %}
- {% blocktrans trimmed %}
- You haven't created any quotas yet.
- {% endblocktrans %}
- {% endif %}
+ {% blocktrans trimmed %}
+ You haven't created any quotas yet.
+ {% endblocktrans %}
{% if 'event.items:write' in request.eventpermset %}
@@ -36,79 +28,160 @@
{% endif %}
{% else %}
+
+
+
+
+ {% trans "Filter" %}
+
+
+
+
{% if 'event.items:write' in request.eventpermset %}
{% trans "Create a new quota" %}
{% endif %}
-
-
-
-
- {% trans "Quota name" %}
-
-
-
- {% trans "Products" %}
- {% if request.event.has_subevents %}
- {% trans "Date" context "subevent" %}
-
-
-
- {% endif %}
- {% trans "Total capacity" %}
-
-
-
- {% trans "Capacity left" %}
-
-
-
-
- {% for q in quotas %}
+
+
+ {% if "event.items:write" in request.eventpermset %}
+
+
+ {% trans "Delete selected" %}
+
+
+ {% trans "Edit selected" %}
+
+
+ {% endif %}
+
{% endif %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py
index 35c659fd37..1d95761265 100644
--- a/src/pretix/control/urls.py
+++ b/src/pretix/control/urls.py
@@ -349,6 +349,8 @@ urlpatterns = [
name='event.items.questions.edit'),
re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
+ re_path(r'^quotas/bulk_action$', item.QuotaBulkAction.as_view(), name='event.items.quotas.bulkaction'),
+ re_path(r'^quotas/bulk_edit$', item.QuotaBulkUpdateView.as_view(), name='event.items.quotas.bulkedit'),
re_path(r'^quotas/(?P\d+)/$', item.QuotaView.as_view(), name='event.items.quotas.show'),
re_path(r'^quotas/select$', typeahead.quotas_select2, name='event.items.quotas.select2'),
re_path(r'^quotas/(?P\d+)/change$', item.QuotaUpdate.as_view(), name='event.items.quotas.edit'),
diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py
index f55f678c0e..624999f4f1 100644
--- a/src/pretix/control/views/item.py
+++ b/src/pretix/control/views/item.py
@@ -41,21 +41,22 @@ from json.decoder import JSONDecodeError
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.files import File
-from django.db import transaction
+from django.db import models, transaction
from django.db.models import (
- Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q,
+ Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q, Subquery, Value,
)
+from django.db.models.functions import Cast, Concat
from django.forms.models import inlineformset_factory
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
)
-from django.shortcuts import redirect
+from django.shortcuts import redirect, render
from django.urls import resolve, reverse
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods
-from django.views.generic import ListView
+from django.views.generic import FormView, ListView, View
from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country
@@ -65,7 +66,7 @@ from pretix.api.serializers.item import (
)
from pretix.base.forms import I18nFormSet
from pretix.base.models import (
- CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation,
+ CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
SeatCategoryMapping, Voucher,
)
@@ -74,12 +75,15 @@ from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability
-from pretix.control.forms.filter import QuestionAnswerFilterForm
+from pretix.control.forms.filter import (
+ QuestionAnswerFilterForm, QuotaFilterForm,
+)
from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm,
- ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
+ ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaBulkEditForm,
+ QuotaForm,
)
from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required,
@@ -87,6 +91,7 @@ from pretix.control.permissions import (
from pretix.control.signals import item_forms, item_formsets
from pretix.helpers.models import modelcopy
+from ...helpers import GroupConcat
from ...helpers.compat import CompatDeleteView
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
@@ -831,13 +836,38 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret
-class QuotaList(PaginationMixin, ListView):
+class QuotaQueryMixin:
+
+ @cached_property
+ def request_data(self):
+ if self.request.method == "POST":
+ return self.request.POST
+ return self.request.GET
+
+ def get_queryset(self):
+ qs = self.request.event.quotas
+ if self.filter_form.is_valid():
+ qs = self.filter_form.filter_qs(qs)
+
+ if 'quota' in self.request_data and '__ALL' not in self.request_data:
+ qs = qs.filter(
+ id__in=self.request_data.getlist('quota')
+ )
+
+ return qs
+
+ @cached_property
+ def filter_form(self):
+ return QuotaFilterForm(data=self.request_data, prefix='filter', event=self.request.event)
+
+
+class QuotaList(PaginationMixin, QuotaQueryMixin, ListView):
model = Quota
context_object_name = 'quotas'
template_name = 'pretixcontrol/items/quotas.html'
def get_queryset(self):
- qs = self.request.event.quotas.prefetch_related(
+ return super().get_queryset().prefetch_related(
Prefetch(
"items",
queryset=Item.objects.annotate(
@@ -852,28 +882,10 @@ class QuotaList(PaginationMixin, ListView):
queryset=self.request.event.subevents.all()
)
)
- if self.request.GET.get("subevent", "") != "":
- s = self.request.GET.get("subevent", "")
- qs = qs.filter(subevent_id=s)
-
- valid_orders = {
- '-date': ('-subevent__date_from', 'name', 'pk'),
- 'date': ('subevent__date_from', '-name', '-pk'),
- 'size': ('size', 'name', 'pk'),
- '-size': ('-size', '-name', '-pk'),
- 'name': ('name', 'pk'),
- '-name': ('-name', '-pk'),
- }
-
- if self.request.GET.get("ordering", "-date") in valid_orders:
- qs = qs.order_by(*valid_orders[self.request.GET.get("ordering", "-date")])
- else:
- qs = qs.order_by('name', 'subevent__date_from', 'pk')
-
- return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
+ ctx['filter_form'] = self.filter_form
qa = QuotaAvailability()
qa.queue(*ctx['quotas'])
@@ -884,6 +896,165 @@ class QuotaList(PaginationMixin, ListView):
return ctx
+class QuotaBulkAction(QuotaQueryMixin, EventPermissionRequiredMixin, View):
+ permission = 'event.items:write'
+
+ @transaction.atomic
+ def post(self, request, *args, **kwargs):
+ if request.POST.get('action') == 'delete':
+ return render(request, 'pretixcontrol/items/quota_delete_bulk.html', {
+ 'allowed': self.get_queryset().select_related("subevent"),
+ })
+ elif request.POST.get('action') == 'delete_confirm':
+ log_entries = []
+ to_delete = []
+ for obj in self.get_queryset():
+ log_entries.append(obj.log_action('pretix.event.quota.deleted', user=self.request.user, save=False))
+ to_delete.append(obj.pk)
+
+ if to_delete:
+ LogEntry.bulk_create_and_postprocess(log_entries)
+ Quota.objects.filter(pk__in=to_delete).delete()
+ messages.success(request, _('The selected quotas have been deleted or disabled.'))
+ return redirect(self.get_success_url())
+
+ def get_success_url(self) -> str:
+ return reverse('control:event.items.quotas', kwargs={
+ 'organizer': self.request.event.organizer.slug,
+ 'event': self.request.event.slug,
+ })
+
+
+class QuotaBulkUpdateView(QuotaQueryMixin, EventPermissionRequiredMixin, FormView):
+ template_name = 'pretixcontrol/items/quota_bulk_edit.html'
+ permission = 'event.items:write'
+ context_object_name = 'quota'
+ form_class = QuotaBulkEditForm
+
+ def get_queryset(self):
+ return super().get_queryset().prefetch_related(None).order_by()
+
+ def get(self, request, *args, **kwargs):
+ return HttpResponse(status=405)
+
+ @cached_property
+ def is_submitted(self):
+ # Usually, django considers a form "bound" / "submitted" on every POST request. However, this view is always
+ # called with POST method, even if just to pass the selection of objects to work on, so we want to modify
+ # that behaviour
+ return '_bulk' in self.request.POST
+
+ def get_form_kwargs(self):
+ initial = {}
+ mixed_values = set()
+ qs = self.get_queryset().annotate(
+ items_list=Subquery(
+ Quota.items.through.objects.filter(
+ quota_id=OuterRef('pk'),
+ item__variations__isnull=True,
+ ).order_by().values('quota_id').annotate(
+ g=GroupConcat('item_id', separator=',', ordered=True)
+ ).values('g')
+ ),
+ vars_list=Subquery(
+ Quota.variations.through.objects.filter(
+ quota_id=OuterRef('pk')
+ ).order_by().values('quota_id').annotate(
+ g=GroupConcat(
+ Concat(
+ Cast(F('itemvariation__item_id'), output_field=models.TextField()),
+ Value('-', output_field=models.TextField()),
+ Cast(F('itemvariation_id'), output_field=models.TextField()),
+ ),
+ separator=',',
+ ordered=True
+ )
+ ).values('g')
+ ),
+ )
+
+ fields = {
+ 'name': 'name',
+ 'size': 'size',
+ 'subevent': 'subevent',
+ 'close_when_sold_out': 'close_when_sold_out',
+ 'release_after_exit': 'release_after_exit',
+ 'ignore_for_event_availability': 'ignore_for_event_availability',
+ }
+ for k, f in fields.items():
+ existing_values = list(qs.order_by(f).values(f).annotate(c=Count('*')))
+ if len(existing_values) == 1:
+ initial[k] = existing_values[0][f]
+ elif len(existing_values) > 1:
+ mixed_values.add(k)
+ initial[k] = None
+
+ item_values = list(qs.order_by("items_list").values("items_list").annotate(c=Count('*')))
+ var_values = list(qs.order_by("vars_list").values("vars_list").annotate(c=Count('*')))
+ if len(item_values) > 1 or len(var_values) > 1:
+ mixed_values.add("itemvars")
+ else:
+ initial["itemvars"] = [iv for iv in (item_values[0]["items_list"] or "").split(",") + (var_values[0]["vars_list"] or "").split(",") if iv]
+
+ kwargs = super().get_form_kwargs()
+ kwargs['event'] = self.request.event
+ kwargs['prefix'] = 'bulkedit'
+ kwargs['initial'] = initial
+ kwargs['queryset'] = self.get_queryset()
+ kwargs['mixed_values'] = mixed_values
+ if not self.is_submitted:
+ kwargs['data'] = None
+ kwargs['files'] = None
+ return kwargs
+
+ def get_success_url(self):
+ return reverse('control:event.items.quotas', kwargs={
+ 'organizer': self.request.event.organizer.slug,
+ 'event': self.request.event.slug,
+ })
+
+ @transaction.atomic()
+ def form_valid(self, form):
+ log_entries = []
+
+ # Main form
+ form.save()
+ data = {
+ k: v
+ for k, v in form.cleaned_data.items()
+ if k in form.changed_data
+ }
+ data['_raw_bulk_data'] = self.request.POST.dict()
+ for obj in self.get_queryset():
+ log_entries.append(
+ obj.log_action('pretix.event.quota.changed', data=data, user=self.request.user, save=False)
+ )
+
+ LogEntry.bulk_create_and_postprocess(log_entries)
+
+ messages.success(self.request, _('Your changes have been saved.'))
+ return super().form_valid(form)
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx['quotas'] = self.get_queryset()
+ ctx['bulk_selected'] = self.request.POST.getlist("_bulk")
+ return ctx
+
+ def post(self, request, *args, **kwargs):
+ form = self.get_form()
+ is_valid = (
+ self.is_submitted and
+ form.is_valid()
+ )
+ if is_valid:
+ return self.form_valid(form)
+ else:
+ if self.is_submitted:
+ messages.error(self.request, _('We could not save your changes. See below for details.'))
+ return self.form_invalid(form)
+
+
class QuotaCreate(EventPermissionRequiredMixin, CreateView):
model = Quota
form_class = QuotaForm
diff --git a/src/pretix/helpers/database.py b/src/pretix/helpers/database.py
index 1289f4f1af..d9d8ffb16d 100644
--- a/src/pretix/helpers/database.py
+++ b/src/pretix/helpers/database.py
@@ -117,12 +117,17 @@ class GroupConcat(Aggregate):
template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s' ORDER BY %(field)s::text ASC)"
else:
template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s')"
- return super().as_sql(
+
+ template, params = super().as_sql(
compiler, connection,
function='string_agg',
template=template,
**extra_context,
)
+ if self.ordered:
+ # ordered statement requires field parameters twice
+ params = params + params
+ return template, params
class ReplicaRouter: