Compare commits

...

11 Commits

Author SHA1 Message Date
Lukas Bockstaller
e076f4bbd9 factor out subevent.py 2025-12-08 16:20:20 +01:00
Lukas Bockstaller
1a2ee155bd add SubEventSelectionWrapper and filtering by datetime 2025-12-08 16:12:23 +01:00
Lukas Bockstaller
9743d7ae52 somewhat presentable MultiValueField without validation 2025-12-05 16:49:04 +01:00
Lukas Bockstaller
07f38819a6 uses propper form validation 2025-12-04 10:15:41 +01:00
Lukas Bockstaller
b4dd2145ff move form out of views.py 2025-12-04 09:33:51 +01:00
Lukas Bockstaller
1c26036976 fix linting and formatting 2025-12-04 09:16:02 +01:00
Lukas Bockstaller
a53fc2d256 implements ListExporter for QuestionAnswers 2025-12-03 16:32:09 +01:00
Lukas Bockstaller
8d24696ce3 move QuestionMixin 2025-12-03 16:32:01 +01:00
Lukas Bockstaller
a14c545883 pull out orderPositionQuerySet 2025-12-03 16:31:53 +01:00
Lukas Bockstaller
10829aa2a5 add dummy exporter 2025-12-03 16:31:45 +01:00
Lukas Bockstaller
f645b5a2d9 replace manual form with QuestionFilterForm 2025-12-03 16:31:37 +01:00
6 changed files with 429 additions and 67 deletions

188
src/pretix/base/subevent.py Normal file
View File

@@ -0,0 +1,188 @@
from ast import literal_eval
from collections import namedtuple
from datetime import datetime
from typing import Union
from django import forms
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Event
from pretix.control.forms import SplitDateTimeField
from pretix.control.forms.widgets import Select2
SubEventSelection = namedtuple(
typename='SubEventSelection',
field_names=['selection', 'subevents', 'start', 'end', ],
defaults=['subevent', None, None, None],
)
subeventselectionparts = namedtuple(
typename='subeventselectionparts',
field_names=['selection', 'subevents', 'start', 'end']
)
class SubEventSelectionWrapper:
def __init__(self, data: Union[None, SubEventSelection]):
self.data = data
def get_queryset(self, event: Event):
if self.data.selection == 'subevent':
if self.data.subevents is None:
return event.subevents.all()
else:
return event.subevents.filter(pk=self.data.subevents)
elif self.data.selection == 'timerange':
if self.data.start and self.data.end:
return event.subevents.filter(date_from__lte=self.data.start,
date_from__gte=self.data.end)
elif self.data.start:
return event.subevents.filter(date_from__gte=self.data.start)
elif self.data.end:
return event.subevents.filter(date_from__lte=self.data.end)
return event.subevents.all()
def to_string(self) -> str:
if self.data:
if self.data.selection == 'subevent':
return 'SUBEVENT/pk/{}'.format(self.data.subevents.pk)
elif self.data.selection == 'timerange':
if self.data.start and self.data.end:
return 'SUBEVENT/range/{}/{}'.format(self.data.start.isoformat(), self.data.end.isoformat())
elif self.data.start:
return 'SUBEVENT/from/{}'.format(self.data.start)
elif self.data.end:
return 'SUBEVENT/to/{}'.format(self.data.end)
return 'SUBEVENT'
@classmethod
def from_string(cls, input: str):
data = SubEventSelection(selection='subevent')
if input.startswith('SUBEVENT'):
parts = input.split('/')
if len(parts) == 1:
data = SubEventSelection(selection='subevent')
elif parts[1] == 'pk':
data = SubEventSelection(
selection='subevent',
subevents=literal_eval(parts[2])
)
elif parts[1] == 'range':
data = SubEventSelection(
selection="timerange",
start=datetime.fromisoformat(parts[2]),
end=datetime.fromisoformat(parts[3]),
)
elif parts[1] == 'from':
data = SubEventSelection(
selection="timerange",
start=datetime.fromisoformat(parts[2]),
)
elif parts[1] == 'to':
data = SubEventSelection(
selection="timerange",
end=datetime.fromisoformat(parts[3]),
)
return SubEventSelectionWrapper(
data=data
)
class SubeventSelectionWidget(forms.MultiWidget):
template_name = 'pretixcontrol/forms/widgets/subeventselection.html'
parts = SubEventSelection
def __init__(self, event: Event, status_choices, subevent_choices, *args, **kwargs):
widgets = subeventselectionparts(
selection=forms.RadioSelect(
choices=status_choices,
),
subevents=Select2(
attrs={
'class': 'simple-subevent-choice',
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'All dates')
},
),
start=SplitDateTimePickerWidget(),
end=SplitDateTimePickerWidget(),
)
widgets.subevents.choices = subevent_choices
super().__init__(widgets=widgets, *args, **kwargs)
def decompress(self, value):
if isinstance(value, str):
value = SubEventSelectionWrapper.from_string(value)
if isinstance(value, subeventselectionparts):
return value
return subeventselectionparts(selection='subevent', start=None, end=None, subevents=None)
class SubeventSelectionField(forms.MultiValueField):
widget = SubeventSelectionWidget
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
choices = [
("subevent", _("Subevent")),
("timerange", _("Timerange"))
]
fields = SubEventSelection(
selection=forms.ChoiceField(
choices=choices,
required=True,
),
subevents=forms.ModelChoiceField(
required=False,
queryset=self.event.subevents,
empty_label=pgettext_lazy('subevent', 'All dates')
),
start=SplitDateTimeField(
required=False,
),
end=SplitDateTimeField(
required=False,
),
)
kwargs['widget'] = SubeventSelectionWidget(
event=self.event,
status_choices=choices,
subevent_choices=fields.subevents.widget.choices,
)
super().__init__(
fields=fields, require_all_fields=False, *args, **kwargs
)
def compress(self, data_list):
if not data_list:
return None
return SubEventSelectionWrapper(data=SubEventSelection(*data_list)).to_string()
def clean(self, value):
data = subeventselectionparts(*value)
if data.selection == "timerange":
if (data.start != ["", ""] and data.end != ["", ""]) and data.end < data.start:
raise ValidationError(_("The end date must be after the start date."))
if (data.start == ["", ""]) and (data.end == ["", ""]):
raise ValidationError(_('At least one of start and end must be specified.'))
return super().clean(value)

View File

@@ -47,6 +47,7 @@ from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape, format_html from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext as __, gettext_lazy as _ from django.utils.translation import gettext as __, gettext_lazy as _
from django_scopes.forms import ( from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField, SafeModelChoiceField, SafeModelMultipleChoiceField,
@@ -56,11 +57,14 @@ from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm
from pretix.base.forms.widgets import DatePickerWidget from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import ( from pretix.base.models import (
Item, ItemCategory, ItemProgramTime, ItemVariation, Question, Item, ItemCategory, ItemProgramTime, ItemVariation, Order, OrderPosition,
QuestionOption, Quota, Question, QuestionOption, Quota,
) )
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data from pretix.base.signals import item_copy_data
from pretix.base.subevent import (
SubeventSelectionField, SubEventSelectionWrapper,
)
from pretix.control.forms import ( from pretix.control.forms import (
ButtonGroupRadioSelect, ExtFileField, ItemMultipleChoiceField, ButtonGroupRadioSelect, ExtFileField, ItemMultipleChoiceField,
SalesChannelCheckboxSelectMultiple, SplitDateTimeField, SalesChannelCheckboxSelectMultiple, SplitDateTimeField,
@@ -272,6 +276,87 @@ class QuestionOptionForm(I18nModelForm):
] ]
class QuestionFilterForm(forms.Form):
STATUS_VARIANTS = [
("", _("All orders")),
("p", _("Paid")),
("pv", _("Paid or confirmed")),
("n", _("Pending")),
("np", _("Pending or paid")),
("o", _("Pending (overdue)")),
("e", _("Expired")),
("ne", _("Pending or expired")),
("c", _("Canceled"))
]
status = forms.ChoiceField(
choices=STATUS_VARIANTS,
widget=forms.Select(
attrs={
'class': 'form-control',
}
),
required=False,
label=_("Status"),
initial="np",
)
item = forms.ChoiceField(
choices=[],
widget=forms.Select(
attrs={'class': 'form-control'}
),
required=False,
label=_("Items")
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
if self.event.has_subevents:
self.fields['subevent_selection'] = SubeventSelectionField(
event=self.event,
label=_("Subevents"),
help_text=_("Select the subevents that should be included in the statistics either by subevent or by the timerange in which they occur."),
)
self.fields['item'].choices = [('', _('All products'))] + [(item.id, item.name) for item in
Item.objects.filter(event=self.event)]
def filter_qs(self):
fdata = self.cleaned_data
opqs = OrderPosition.objects.filter(
order__event=self.event,
)
sub_event_qs = SubEventSelectionWrapper.from_string(fdata['subevent_selection']).get_queryset(self.event)
opqs = opqs.filter(subevent__in=sub_event_qs)
s = fdata.get("status", "np")
if s != "":
if s == 'o':
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
order__expires__lt=now().replace(hour=0, minute=0, second=0))
elif s == 'np':
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'pv':
opqs = opqs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
elif s == 'ne':
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
else:
opqs = opqs.filter(order__status=s)
if s not in (Order.STATUS_CANCELED, ""):
opqs = opqs.filter(canceled=False)
if fdata.get("item", "") != "":
i = fdata.get("item", "")
opqs = opqs.filter(item_id__in=(i,))
return opqs
class QuotaForm(I18nModelForm): class QuotaForm(I18nModelForm):
itemvars = forms.MultipleChoiceField( itemvars = forms.MultipleChoiceField(
label=_("Products"), label=_("Products"),

View File

@@ -0,0 +1,38 @@
{% load i18n %}
<div class="subevent-selection col-lg-12">
{% for group_name, group_choices, group-index in widget.subwidgets.0.optgroups %}
{% for selopt in group_choices %}
<div class="radio">
<label class="col-lg-2">
<input type="radio" name="{{ widget.subwidgets.0.name }}" value="{{ selopt.value }}"
{% include "django/forms/widgets/attrs.html" with widget=selopt %} />
{{ selopt.label }}
</label>
{% if selopt.value == "subevent" %}
{% with widget.subwidgets.1 as widget %}
{% include widget.template_name %}
{% endwith %}
{% elif selopt.value == "timerange" %}
{% with widget.subwidgets.2 as widget %}
{% include widget.template_name %}
{% endwith %}
<span class="spacer">{% trans "until" %}</span>
{% with widget.subwidgets.3 as widget %}
{% include widget.template_name %}
{% endwith %}
{% endif %}
</div>
{% endfor %}
{% endfor %}
</div>

View File

@@ -5,6 +5,11 @@
{% load formset_tags %} {% load formset_tags %}
{% block title %}{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}{% endblock %} {% block title %}{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}{% endblock %}
{% block inside %} {% block inside %}
{% for e in form.errors.values %}
<div class="alert alert-danger has-error">
{{ e }}
</div>
{% endfor %}
<h1> <h1>
{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %} {% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}
<a href="{% url "control:event.items.questions.edit" event=request.event.slug organizer=request.event.organizer.slug question=question.pk %}" <a href="{% url "control:event.items.questions.edit" event=request.event.slug organizer=request.event.organizer.slug question=question.pk %}"
@@ -20,35 +25,24 @@
</div> </div>
<form class="panel-body filter-form" action="" method="get"> <form class="panel-body filter-form" action="" method="get">
<div class="row"> <div class="row">
<div class="col-lg-2 col-sm-6 col-xs-6">
<select name="status" class="form-control"> <div class="col-lg-4 col-sm-6 col-xs-6">
<option value="" {% if request.GET.status == "" %}selected="selected"{% endif %}>{% trans "All orders" %}</option> {% bootstrap_label form.status.label %}
<option value="p" {% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "Paid" %}</option> {% bootstrap_field form.status layout="inline" %}
<option value="pv" {% if request.GET.status == "pv" %}selected="selected"{% endif %}>{% trans "Paid or confirmed" %}</option>
<option value="n" {% if request.GET.status == "n" %}selected="selected"{% endif %}>{% trans "Pending" %}</option> </div>
<option value="np" {% if request.GET.status == "np" or "status" not in request.GET %}selected="selected"{% endif %}>{% trans "Pending or paid" %}</option> <div class="col-lg-8 col-sm-6 col-xs-6">
<option value="o" {% if request.GET.status == "o" %}selected="selected"{% endif %}>{% trans "Pending (overdue)" %}</option> {% bootstrap_label form.item.label %}
<option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option> {% bootstrap_field form.item layout="inline" %}
<option value="ne" {% if request.GET.status == "ne" %}selected="selected"{% endif %}>{% trans "Pending or expired" %}</option> </div>
<option value="c" {% if request.GET.status == "c" %}selected="selected"{% endif %}>{% trans "Canceled" %}</option>
</select> <div class="col-lg-12 col-sm-6 col-xs-6">
</div> {% bootstrap_label form.subevent_selection.label %}
<div class="col-lg-5 col-sm-6 col-xs-6"> {{ form.subevent_selection }}
<select name="item" class="form-control"> <div class="help-block">
<option value="">{% trans "All products" %}</option> {{ form.subevent_selection.help_text }}
{% for item in items %}
<option value="{{ item.id }}"
{% if request.GET.item|add:0 == item.id %}selected="selected"{% endif %}>
{{ item.name }}
</option>
{% endfor %}
</select>
</div>
{% if request.event.has_subevents %}
<div class="col-lg-5 col-sm-6 col-xs-6">
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
</div> </div>
{% endif %} </div>
</div> </div>
<div class="text-right"> <div class="text-right">
<button class="btn btn-primary btn-lg" type="submit"> <button class="btn btn-primary btn-lg" type="submit">

View File

@@ -38,6 +38,7 @@ from collections import OrderedDict, namedtuple
from itertools import groupby from itertools import groupby
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from django import forms
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.files import File from django.core.files import File
@@ -45,6 +46,7 @@ from django.db import transaction
from django.db.models import ( from django.db.models import (
Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q, Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q,
) )
from django.dispatch import receiver
from django.forms.models import inlineformset_factory from django.forms.models import inlineformset_factory
from django.http import ( from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
@@ -63,9 +65,10 @@ from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemProgramTimeSerializer, ItemAddOnSerializer, ItemBundleSerializer, ItemProgramTimeSerializer,
ItemVariationSerializer, ItemVariationSerializer,
) )
from pretix.base.exporter import ListExporter
from pretix.base.forms import I18nFormSet from pretix.base.forms import I18nFormSet
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, Order, CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota, OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
SeatCategoryMapping, Voucher, SeatCategoryMapping, Voucher,
) )
@@ -73,12 +76,13 @@ from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tickets import invalidate_cache from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability from pretix.base.signals import quota_availability, register_data_exporters
from pretix.control.forms.item import ( from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm, CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm, ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm, ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm, ItemVariationsFormSet, QuestionFilterForm, QuestionForm,
QuestionOptionForm, QuotaForm,
) )
from pretix.control.permissions import ( from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required, EventPermissionRequiredMixin, event_permission_required,
@@ -660,46 +664,73 @@ class QuestionMixin:
return ctx return ctx
class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingView, DetailView): class QuestionAnswerExporter(ListExporter):
identifier = 'question_answer_exporter'
verbose_name = _('Question answers exporter')
description = _('Download a spreadsheet containing question answers')
category = _('Order data')
@property
def additional_form_fields(self):
form = {
'question':
forms.ModelChoiceField(
label=_('Question'),
queryset=Question.objects.filter(event=self.event),
),
**QuestionFilterForm(event=self.event).fields
}
return form
def iterate_list(self, form_data):
question = Question.objects.filter(event=self.event).get(pk=form_data['question'])
opqs = QuestionFilterForm(event=self.event, data=form_data).order_position_queryset()
qs = QuestionAnswer.objects.filter(
question=question, orderposition__isnull=False,
)
qs = qs.filter(orderposition__in=opqs)
headers = [
_("Subevent"),
_("Event start time"),
_("Order"),
_("Order position"),
question.question
]
yield headers
yield self.ProgressSetTotal(total=qs.count())
for questionAnswer in qs.iterator(chunk_size=1000):
row = [
questionAnswer.orderposition.subevent.name,
questionAnswer.orderposition.subevent.date_from.replace(tzinfo=None),
questionAnswer.orderposition.order.code,
questionAnswer.orderposition.positionid,
questionAnswer.answer
]
yield row
@receiver(register_data_exporters, dispatch_uid="exporter_questions_exporter")
def register_data_exporter(sender, **kwargs):
return QuestionAnswerExporter
class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView):
model = Question model = Question
template_name = 'pretixcontrol/items/question.html' template_name = 'pretixcontrol/items/question.html'
permission = 'can_change_items' permission = 'can_change_items'
template_name_field = 'question' template_name_field = 'question'
def get_answer_statistics(self): def get_answer_statistics(self, opqs: OrderPosition):
opqs = OrderPosition.objects.filter(
order__event=self.request.event,
)
qs = QuestionAnswer.objects.filter( qs = QuestionAnswer.objects.filter(
question=self.object, orderposition__isnull=False, question=self.object, orderposition__isnull=False,
) )
if self.request.GET.get("subevent", "") != "":
opqs = opqs.filter(subevent=self.request.GET["subevent"])
s = self.request.GET.get("status", "np")
if s != "":
if s == 'o':
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
order__expires__lt=now().replace(hour=0, minute=0, second=0))
elif s == 'np':
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'pv':
opqs = opqs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
elif s == 'ne':
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
else:
opqs = opqs.filter(order__status=s)
if s not in (Order.STATUS_CANCELED, ""):
opqs = opqs.filter(canceled=False)
if self.request.GET.get("item", "") != "":
i = self.request.GET.get("item", "")
opqs = opqs.filter(item_id__in=(i,))
qs = qs.filter(orderposition__in=opqs) qs = qs.filter(orderposition__in=opqs)
op_cnt = opqs.filter(item__in=self.object.items.all()).count() op_cnt = opqs.filter(item__in=self.object.items.all()).count()
@@ -747,8 +778,20 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data() ctx = super().get_context_data()
ctx['items'] = self.object.items.all() ctx['items'] = self.object.items.all()
stats = self.get_answer_statistics() if self.request.GET:
ctx['stats'], ctx['total'] = stats ctx['form'] = QuestionFilterForm(
data=self.request.GET,
event=self.request.event,
)
else:
ctx['form'] = QuestionFilterForm(
event=self.request.event,
)
if ctx['form'].is_valid():
opqs = ctx['form'].filter_qs()
stats = self.get_answer_statistics(opqs)
ctx['stats'], ctx['total'] = stats
return ctx return ctx
def get_object(self, queryset=None) -> Question: def get_object(self, queryset=None) -> Question:

View File

@@ -216,6 +216,20 @@ td > .form-group > .checkbox {
.input-group-btn .btn { .input-group-btn .btn {
padding-bottom: 8px; padding-bottom: 8px;
} }
.subevent-selection{
.splitdatetimerow{
max-width: 500px;
display: inline-block;
}
.spacer{
margin-left: 20px;
margin-right: 20px;
}
.select2{
max-width:500px;
display: inline-block;
}
}
.reldatetime { .reldatetime {
input[type=text], select { input[type=text], select {
display: inline-block; display: inline-block;