Order-level questions (#6471)

* New CheckoutSession model, created and deleted throught cart lifetime but only used for order-level question answers so far
* Order-level QuestionAnswers (relations to CheckoutSession / Order)
* New container_type field on Question model to specify whether Question belongs to order or orderposition
* Order-level questions are currently experimental, UI is hidden behind feature flag

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
This commit is contained in:
luelista
2026-08-14 14:56:45 +02:00
committed by GitHub
co-authored by Richard Schreiber
parent b241adb7d0
commit f8cc31b120
37 changed files with 1229 additions and 716 deletions
+112 -72
View File
@@ -32,18 +32,21 @@ from django.utils.functional import cached_property
from django.utils.timezone import make_aware
from pretix.base.forms.questions import (
BaseInvoiceAddressForm, BaseInvoiceNameForm, BaseQuestionsForm,
BaseInvoiceAddressForm, BaseInvoiceNameForm, OrderLevelQuestionsForm,
TicketLevelQuestionsForm,
)
from pretix.base.models import (
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
QuestionOption,
)
from pretix.base.models.customers import AttendeeProfile
from pretix.base.models.orders import CheckoutSession, Order
from pretix.presale.signals import contact_form_fields_overrides
class BaseQuestionsViewMixin:
form_class = BaseQuestionsForm
order_form_class = OrderLevelQuestionsForm
orderposition_form_class = TicketLevelQuestionsForm
all_optional = False
@cached_property
@@ -56,6 +59,28 @@ class BaseQuestionsViewMixin:
def question_form_kwargs(self, cr):
return {}
@property
def order_question_container(self):
raise NotImplementedError()
@cached_property
def order_questions_form(self):
container = self.order_question_container
if container is None:
return None
kwargs = {} # self.question_form_kwargs(cr)
form = self.order_form_class(
event=self.request.event,
prefix='order',
request=self.request,
container=container,
all_optional=self.all_optional,
data=(self.request.POST if self.request.method == 'POST' else None),
files=(self.request.FILES if self.request.method == 'POST' else None),
**kwargs
)
return form
@cached_property
def forms(self):
"""
@@ -69,15 +94,17 @@ class BaseQuestionsViewMixin:
orderpos = cr if isinstance(cr, OrderPosition) else None
kwargs = self.question_form_kwargs(cr)
form = self.form_class(event=self.request.event,
prefix=cr.id,
request=self.request,
cartpos=cartpos,
orderpos=orderpos,
all_optional=self.all_optional,
data=(self.request.POST if self.request.method == 'POST' else None),
files=(self.request.FILES if self.request.method == 'POST' else None),
**kwargs)
form = self.orderposition_form_class(
event=self.request.event,
prefix=cr.id,
request=self.request,
cartpos=cartpos,
orderpos=orderpos,
all_optional=self.all_optional,
data=(self.request.POST if self.request.method == 'POST' else None),
files=(self.request.FILES if self.request.method == 'POST' else None),
**kwargs
)
form.pos = cartpos or orderpos
form.show_copy_answers_to_addon_button = form.pos.addon_to and (
set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or
@@ -130,8 +157,38 @@ class BaseQuestionsViewMixin:
def save(self):
failed = False
if self.order_questions_form:
if not self.order_questions_form.is_valid():
failed = True
else:
checkoutsession = self.order_question_container if isinstance(self.order_question_container, CheckoutSession) else None
order = self.order_question_container if isinstance(self.order_question_container, Order) else None
for k, v in self.order_questions_form.cleaned_data.items():
if k.startswith('question_'):
field = self.order_questions_form.fields[k]
if hasattr(field, 'answer'):
# We already have a cached answer object, so we don't
# have to create a new one
if v == '' or v is None or (isinstance(field, forms.FileField) and v is False) \
or (isinstance(v, QuerySet) and not v.exists()):
if field.answer.file:
field.answer.file.delete()
field.answer.delete()
else:
self._save_to_answer(field, field.answer, v)
field.answer.save()
elif v != '' and v is not None:
self._upsert_answer(
field, v,
checkoutsession=checkoutsession,
order=order,
question=field.question,
)
for form in self.forms:
meta_info = form.pos.meta_info_data
cartposition = form.pos if isinstance(form.pos, CartPosition) else None
orderposition = form.pos if isinstance(form.pos, OrderPosition) else None
# Every form represents a CartPosition or OrderPosition with questions attached
if not form.is_valid():
failed = True
@@ -140,10 +197,8 @@ class BaseQuestionsViewMixin:
prof = AttendeeProfile.objects.filter(
customer=self.cart_customer, pk=form.cleaned_data.get('saved_id')
).first() or AttendeeProfile(customer=getattr(self, 'cart_customer', None))
answers_key_to_index = {a.get('field_name'): i for i, a in enumerate(prof.answers)}
else:
prof = AttendeeProfile(customer=getattr(self, 'cart_customer', None))
answers_key_to_index = {}
# This form was correctly filled, so we store the data as
# answers to the questions / in the CartPosition object
@@ -181,64 +236,19 @@ class BaseQuestionsViewMixin:
else:
self._save_to_answer(field, field.answer, v)
field.answer.save()
if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
answer_value = {o.identifier: str(o) for o in field.answer.options.all()}
elif isinstance(field, forms.BooleanField):
answer_value = bool(field.answer.answer)
else:
answer_value = str(field.answer.answer)
answer_dict = {
'field_name': k,
'field_label': str(field.label),
'value': answer_value,
'question_type': field.question.type,
'question_identifier': field.question.identifier,
}
if k in answers_key_to_index:
prof.answers[answers_key_to_index[k]] = answer_dict
else:
prof.answers.append(answer_dict)
answer_dict = self._build_answer_dict(field, field.answer, k)
prof.store_answer(answer_dict)
elif v != '' and v is not None:
answer = QuestionAnswer(
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
answer = self._upsert_answer(
field, v,
cartposition=cartposition,
orderposition=orderposition,
question=field.question,
)
try:
self._save_to_answer(field, answer, v)
answer.save()
except IntegrityError:
# Since we prefill ``field.answer`` at form creation time, there's a possible race condition
# here if the users submits their save request a second time while the first one is still running,
# thus leading to duplicate QuestionAnswer objects. Since Django doesn't support UPSERT, the "proper"
# fix would be a transaction with select_for_update(), or at least fetching using get_or_create here
# again. However, both of these approaches have a significant performance overhead for *all* requests,
# while the issue happens very very rarely. So we opt for just catching the error and retrying properly.
answer = QuestionAnswer.objects.get(
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
question=field.question,
)
self._save_to_answer(field, answer, v)
answer.save()
if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
answer_value = {o.identifier: str(o) for o in answer.options.all()}
elif isinstance(field, forms.BooleanField):
answer_value = bool(answer.answer)
else:
answer_value = str(answer.answer)
answer_dict = {
'field_name': k,
'field_label': str(field.label),
'value': answer_value,
'question_type': field.question.type,
'question_identifier': field.question.identifier,
}
if k in answers_key_to_index:
prof.answers[answers_key_to_index[k]] = answer_dict
else:
prof.answers.append(answer_dict)
answer_dict = self._build_answer_dict(field, answer, k)
prof.store_answer(answer_dict)
else:
field = form.fields[k]
@@ -257,10 +267,7 @@ class BaseQuestionsViewMixin:
'question_type': None,
'question_identifier': None,
}
if k in answers_key_to_index:
prof.answers[answers_key_to_index[k]] = answer_dict
else:
prof.answers.append(answer_dict)
prof.store_answer(answer_dict)
form.pos.meta_info = json.dumps(meta_info)
form.pos.save()
@@ -271,6 +278,23 @@ class BaseQuestionsViewMixin:
return not failed
def _upsert_answer(self, field, v, **answer_kwargs):
answer = QuestionAnswer(**answer_kwargs)
try:
self._save_to_answer(field, answer, v)
answer.save()
except IntegrityError:
# Since we prefill ``field.answer`` at form creation time, there's a possible race condition
# here if the users submits their save request a second time while the first one is still running,
# thus leading to duplicate QuestionAnswer objects. Since Django doesn't support UPSERT, the "proper"
# fix would be a transaction with select_for_update(), or at least fetching using get_or_create here
# again. However, both of these approaches have a significant performance overhead for *all* requests,
# while the issue happens very very rarely. So we opt for just catching the error and retrying properly.
answer = QuestionAnswer.objects.get(**answer_kwargs)
self._save_to_answer(field, answer, v)
answer.save()
return answer
def _save_to_answer(self, field, answer, value):
if isinstance(field, forms.ModelMultipleChoiceField):
answstr = ", ".join([str(o) for o in value])
@@ -294,6 +318,21 @@ class BaseQuestionsViewMixin:
else:
answer.answer = value
def _build_answer_dict(self, field, answer, k):
if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
answer_value = {o.identifier: str(o) for o in answer.options.all()}
elif isinstance(field, forms.BooleanField):
answer_value = bool(answer.answer)
else:
answer_value = str(answer.answer)
return {
'field_name': k,
'field_label': str(field.label),
'value': answer_value,
'question_type': field.question.type,
'question_identifier': field.question.identifier,
}
class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
invoice_form_class = BaseInvoiceAddressForm
@@ -309,7 +348,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
def positions(self):
qqs = self.request.event.questions.all()
if self.only_user_visible:
qqs = qqs.filter(ask_during_checkin=False, hidden=False)
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
return list(self.order.positions.select_related(
'item', 'variation'
).prefetch_related(
@@ -397,6 +436,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['order'] = self.order
ctx['order_questions_form'] = self.order_questions_form
ctx['formgroups'] = self.formdict.items()
ctx['invoice_form'] = self.invoice_form
ctx['invoice_address_asked'] = self.address_asked