forked from CGM_Public/pretix_original
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:
co-authored by
Richard Schreiber
parent
b241adb7d0
commit
f8cc31b120
+112
-72
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user