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.html import escape, format_html
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_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
@@ -56,11 +57,14 @@ from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import (
Item, ItemCategory, ItemProgramTime, ItemVariation, Question,
QuestionOption, Quota,
Item, ItemCategory, ItemProgramTime, ItemVariation, Order, OrderPosition,
Question, QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data
from pretix.base.subevent import (
SubeventSelectionField, SubEventSelectionWrapper,
)
from pretix.control.forms import (
ButtonGroupRadioSelect, ExtFileField, ItemMultipleChoiceField,
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):
itemvars = forms.MultipleChoiceField(
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 %}
{% block title %}{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}{% endblock %}
{% block inside %}
{% for e in form.errors.values %}
<div class="alert alert-danger has-error">
{{ e }}
</div>
{% endfor %}
<h1>
{% 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 %}"
@@ -20,35 +25,24 @@
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
<div class="col-lg-2 col-sm-6 col-xs-6">
<select name="status" class="form-control">
<option value="" {% if request.GET.status == "" %}selected="selected"{% endif %}>{% trans "All orders" %}</option>
<option value="p" {% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "Paid" %}</option>
<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>
<option value="np" {% if request.GET.status == "np" or "status" not in request.GET %}selected="selected"{% endif %}>{% trans "Pending or paid" %}</option>
<option value="o" {% if request.GET.status == "o" %}selected="selected"{% endif %}>{% trans "Pending (overdue)" %}</option>
<option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option>
<option value="ne" {% if request.GET.status == "ne" %}selected="selected"{% endif %}>{% trans "Pending or expired" %}</option>
<option value="c" {% if request.GET.status == "c" %}selected="selected"{% endif %}>{% trans "Canceled" %}</option>
</select>
</div>
<div class="col-lg-5 col-sm-6 col-xs-6">
<select name="item" class="form-control">
<option value="">{% trans "All products" %}</option>
{% 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 class="col-lg-4 col-sm-6 col-xs-6">
{% bootstrap_label form.status.label %}
{% bootstrap_field form.status layout="inline" %}
</div>
<div class="col-lg-8 col-sm-6 col-xs-6">
{% bootstrap_label form.item.label %}
{% bootstrap_field form.item layout="inline" %}
</div>
<div class="col-lg-12 col-sm-6 col-xs-6">
{% bootstrap_label form.subevent_selection.label %}
{{ form.subevent_selection }}
<div class="help-block">
{{ form.subevent_selection.help_text }}
</div>
{% endif %}
</div>
</div>
<div class="text-right">
<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 json.decoder import JSONDecodeError
from django import forms
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.files import File
@@ -45,6 +46,7 @@ from django.db import transaction
from django.db.models import (
Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q,
)
from django.dispatch import receiver
from django.forms.models import inlineformset_factory
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
@@ -63,9 +65,10 @@ from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemProgramTimeSerializer,
ItemVariationSerializer,
)
from pretix.base.exporter import ListExporter
from pretix.base.forms import I18nFormSet
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, Order,
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
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.services.quotas import QuotaAvailability
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 (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
ItemVariationsFormSet, QuestionFilterForm, QuestionForm,
QuestionOptionForm, QuotaForm,
)
from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required,
@@ -660,46 +664,73 @@ class QuestionMixin:
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
template_name = 'pretixcontrol/items/question.html'
permission = 'can_change_items'
template_name_field = 'question'
def get_answer_statistics(self):
opqs = OrderPosition.objects.filter(
order__event=self.request.event,
)
def get_answer_statistics(self, opqs: OrderPosition):
qs = QuestionAnswer.objects.filter(
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)
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):
ctx = super().get_context_data()
ctx['items'] = self.object.items.all()
stats = self.get_answer_statistics()
ctx['stats'], ctx['total'] = stats
if self.request.GET:
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
def get_object(self, queryset=None) -> Question:

View File

@@ -216,6 +216,20 @@ td > .form-group > .checkbox {
.input-group-btn .btn {
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 {
input[type=text], select {
display: inline-block;