diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py
index dacf056b6a..3f1bc55d50 100644
--- a/src/pretix/api/serializers/item.py
+++ b/src/pretix/api/serializers/item.py
@@ -619,7 +619,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
options_data = validated_data.pop('options') if 'options' in validated_data else []
items = validated_data.pop('items', [])
- question = Question.objects.create(**validated_data)
+ question = Question.objects.create(**validated_data, container_type=Question.CONTAINER_TYPE_ORDERPOSITION)
question.items.set(items)
for opt_data in options_data:
QuestionOption.objects.create(question=question, **opt_data)
diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py
index 8250600a0a..975478e200 100644
--- a/src/pretix/api/views/item.py
+++ b/src/pretix/api/views/item.py
@@ -475,7 +475,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
write_permission = 'event.items:write'
def get_queryset(self):
- return self.request.event.questions.prefetch_related('options').all()
+ return self.request.event.questions.filter(container_type=Question.CONTAINER_TYPE_ORDERPOSITION).prefetch_related('options').all()
@transaction.atomic()
def perform_create(self, serializer):
diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py
index df21d6044e..812511f7fe 100644
--- a/src/pretix/base/forms/questions.py
+++ b/src/pretix/base/forms/questions.py
@@ -82,7 +82,7 @@ from pretix.base.i18n import (
from pretix.base.invoicing.transmission import (
get_transmission_types, transmission_types,
)
-from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
+from pretix.base.models import InvoiceAddress, Item, Question, QuestionAnswer, QuestionOption
from pretix.base.models.tax import ask_for_vat_id
from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id,
@@ -666,12 +666,327 @@ def get_fake_questions(settings):
class BaseQuestionsForm(forms.Form):
+ """
+ This form class is responsible for asking order- and ticket-related questions.
+ """
+ address_validation = False
+
+ def build_user_question_field(self, request, event, answerlist, container, q):
+ # Do we already have an answer? Provide it as the initial value
+ answers = [a for a in answerlist if a.question_id == q.id]
+ if answers:
+ initial = answers[0]
+ else:
+ initial = None
+ tz = ZoneInfo(event.settings.timezone)
+ help_text = rich_text(q.help_text)
+ label = escape(q.question) # django-bootstrap3 calls mark_safe
+ required = q.required and not self.all_optional
+ if q.type == Question.TYPE_BOOLEAN:
+ if required:
+ # For some reason, django-bootstrap3 does not set the required attribute
+ # itself.
+ widget = forms.CheckboxInput(attrs={'required': 'required'})
+ else:
+ widget = forms.CheckboxInput()
+
+ if initial:
+ initialbool = (initial.answer == "True")
+ else:
+ initialbool = False
+
+ field = forms.BooleanField(
+ label=label, required=required,
+ help_text=help_text,
+ initial=initialbool, widget=widget,
+ )
+ elif q.type == Question.TYPE_NUMBER:
+ field = forms.DecimalField(
+ label=label, required=required,
+ min_value=q.valid_number_min or Decimal('0.00'),
+ max_value=q.valid_number_max,
+ help_text=help_text,
+ initial=initial.answer if initial else None,
+ )
+ elif q.type == Question.TYPE_STRING:
+ field = forms.CharField(
+ label=label, required=required,
+ max_length=q.valid_string_length_max,
+ help_text=help_text,
+ initial=initial.answer if initial else None,
+ )
+ elif q.type == Question.TYPE_TEXT:
+ field = forms.CharField(
+ label=label, required=required,
+ max_length=q.valid_string_length_max,
+ help_text=help_text,
+ widget=forms.Textarea,
+ initial=initial.answer if initial else None,
+ )
+ elif q.type == Question.TYPE_COUNTRYCODE:
+ field = CountryField(
+ countries=CachedCountries,
+ blank=True, null=True, blank_label=' ',
+ ).formfield(
+ label=label, required=required,
+ help_text=help_text,
+ widget=forms.Select,
+ empty_label=' ',
+ initial=initial.answer if initial else (
+ guess_country_from_request(request, event) if required else None),
+ )
+ elif q.type == Question.TYPE_CHOICE:
+ field = forms.ModelChoiceField(
+ queryset=q.options,
+ label=label, required=required,
+ help_text=help_text,
+ widget=forms.Select,
+ to_field_name='identifier',
+ empty_label='',
+ initial=initial.options.first() if initial else None,
+ )
+ elif q.type == Question.TYPE_CHOICE_MULTIPLE:
+ field = forms.ModelMultipleChoiceField(
+ queryset=q.options,
+ label=label, required=required,
+ help_text=help_text,
+ to_field_name='identifier',
+ widget=QuestionCheckboxSelectMultiple,
+ initial=initial.options.all() if initial else None,
+ )
+ elif q.type == Question.TYPE_FILE:
+ if q.valid_file_portrait:
+ field = PortraitImageField(
+ label=label, required=required,
+ help_text=help_text,
+ initial=initial.file if initial else None,
+ widget=PortraitImageWidget(container=container, event=event, answer=initial,
+ attrs={'data-portrait-photo': 'true'}),
+ )
+ else:
+ field = ExtFileField(
+ label=label, required=required,
+ help_text=help_text,
+ initial=initial.file if initial else None,
+ widget=UploadedFileWidget(container=container, event=event, answer=initial),
+ ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
+ max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
+ )
+ elif q.type == Question.TYPE_DATE:
+ attrs = {}
+ if q.valid_date_min:
+ attrs['data-min'] = q.valid_date_min.isoformat()
+ if q.valid_date_max:
+ attrs['data-max'] = q.valid_date_max.isoformat()
+ if not help_text:
+ if q.valid_date_min and q.valid_date_max:
+ help_text = format_lazy(
+ _('Please enter a date between {min} and {max}.'),
+ min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
+ max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
+ )
+ elif q.valid_date_min:
+ help_text = format_lazy(
+ _('Please enter a date no earlier than {min}.'),
+ min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
+ )
+ elif q.valid_date_max:
+ help_text = format_lazy(
+ _('Please enter a date no later than {max}.'),
+ max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
+ )
+ if initial and initial.answer:
+ try:
+ _initial = dateutil.parser.parse(initial.answer).date()
+ except dateutil.parser.ParserError:
+ _initial = None
+ else:
+ _initial = None
+ field = forms.DateField(
+ label=label, required=required,
+ help_text=help_text,
+ initial=_initial,
+ widget=DatePickerWidget(attrs),
+ )
+ if q.valid_date_min:
+ field.validators.append(MinDateValidator(q.valid_date_min))
+ if q.valid_date_max:
+ field.validators.append(MaxDateValidator(q.valid_date_max))
+ elif q.type == Question.TYPE_TIME:
+ if initial and initial.answer:
+ try:
+ _initial = dateutil.parser.parse(initial.answer).time()
+ except dateutil.parser.ParserError:
+ _initial = None
+ else:
+ _initial = None
+ field = forms.TimeField(
+ label=label, required=required,
+ help_text=help_text,
+ initial=_initial,
+ widget=TimePickerWidget(without_seconds=True),
+ )
+ elif q.type == Question.TYPE_DATETIME:
+ if not help_text:
+ if q.valid_datetime_min and q.valid_datetime_max:
+ help_text = format_lazy(
+ _('Please enter a date and time between {min} and {max}.'),
+ min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
+ max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
+ )
+ elif q.valid_datetime_min:
+ help_text = format_lazy(
+ _('Please enter a date and time no earlier than {min}.'),
+ min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
+ )
+ elif q.valid_datetime_max:
+ help_text = format_lazy(
+ _('Please enter a date and time no later than {max}.'),
+ max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
+ )
+
+ if initial and initial.answer:
+ try:
+ _initial = dateutil.parser.parse(initial.answer).astimezone(tz)
+ except dateutil.parser.ParserError:
+ _initial = None
+ else:
+ _initial = None
+
+ field = SplitDateTimeField(
+ label=label, required=required,
+ help_text=help_text,
+ initial=_initial,
+ widget=SplitDateTimePickerWidget(
+ time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
+ min_date=q.valid_datetime_min,
+ max_date=q.valid_datetime_max
+ ),
+ )
+ if q.valid_datetime_min:
+ field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
+ if q.valid_datetime_max:
+ field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
+ elif q.type == Question.TYPE_PHONENUMBER:
+ if initial:
+ try:
+ initial = PhoneNumber().from_string(initial.answer)
+ except NumberParseException:
+ initial = None
+
+ if not initial:
+ phone_prefix = guess_phone_prefix_from_request(request, event)
+ if phone_prefix:
+ initial = "+{}.".format(phone_prefix)
+
+ field = PhoneNumberField(
+ label=label, required=required,
+ help_text=help_text,
+ # We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
+ # a country code but no number as an initial value. It's a bit hacky, but should be stable for
+ # the future.
+ initial=initial,
+ widget=WrappedPhoneNumberPrefixWidget()
+ )
+ field.question = q
+ if answers:
+ # Cache the answer object for later use
+ field.answer = answers[0]
+
+ if q.dependency_question_id:
+ field.widget.attrs['data-question-dependency'] = q.dependency_question_id
+ field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
+ if q.type != 'M':
+ field.widget.attrs['required'] = q.required and not self.all_optional
+ field._required = q.required and not self.all_optional
+ field.required = False
+ return field
+
+ def check_user_questions(self, d):
+ question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
+
+ def question_is_visible(parentid, qvals):
+ if parentid not in question_cache:
+ return False
+ parentq = question_cache[parentid]
+ if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
+ return False
+ if 'question_%d' % parentid not in d:
+ return False
+ dval = d.get('question_%d' % parentid)
+ return (
+ ('True' in qvals and dval)
+ or ('False' in qvals and not dval)
+ or (isinstance(dval, QuestionOption) and dval.identifier in qvals)
+ or (isinstance(dval, (list, QuerySet)) and any(qval in [o.identifier for o in dval] for qval in qvals))
+ )
+
+ def question_is_required(q):
+ return (
+ q.required and
+ (not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values))
+ )
+
+ if not self.all_optional:
+ for q in question_cache.values():
+ answer = d.get('question_%d' % q.pk)
+ field = self['question_%d' % q.pk]
+ if question_is_required(q) and not answer and answer != 0 and not field.errors:
+ raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
+
+ # Strip invisible question from cleaned_data so they don't end up in the database
+ for q in question_cache.values():
+ answer = d.get('question_%d' % q.pk)
+ if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None:
+ d['question_%d' % q.pk] = None
+
+ # Strip False answers to required yes/no questions even if all_optional is set, as our data model assumes that
+ # required yes/no questions can only be answered with yes
+ for q in question_cache.values():
+ if q.required and q.type == Question.TYPE_BOOLEAN:
+ if 'question_%d' % q.pk in d and d['question_%d' % q.pk] is False:
+ d['question_%d' % q.pk] = None
+
+
+class OrderLevelQuestionsForm(BaseQuestionsForm):
+ def __init__(self, *args, **kwargs):
+ """
+ Takes two additional keyword arguments:
+
+ :param checkoutsession: The checkout session the form should be for
+ :param order: The order the form should be for
+ :param event: The event this belongs to
+ """
+ request = kwargs.pop('request', None)
+ checkoutsession = self.checkoutsession = kwargs.pop('checkoutsession', None)
+ order = self.order = kwargs.pop('order', None)
+ container = checkoutsession or order
+ event = kwargs.pop('event')
+ self.all_optional = kwargs.pop('all_optional', False)
+
+ super().__init__(*args, **kwargs)
+
+ questions = Question.objects.filter(
+ event=event, container_type=Question.CONTAINER_TYPE_ORDER,
+ ask_during_checkin=False, hidden=False,
+ ).order_by('position')
+ answerlist = container.answers.prefetch_related('options')
+
+ for q in questions:
+ self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, answerlist, container, q)
+
+ def clean(self):
+ d = super().clean()
+ self.check_user_questions(d)
+ return d
+
+
+class TicketQuestionsForm(BaseQuestionsForm):
"""
This form class is responsible for asking ticket-related questions. This includes
the attendee name for admission tickets, if the corresponding setting is enabled,
as well as additional questions defined by the organizer.
"""
- address_validation = False
def __init__(self, *args, **kwargs):
"""
@@ -695,7 +1010,6 @@ class BaseQuestionsForm(forms.Form):
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
self.fields['requested_valid_from'] = self.build_requested_valid_from_field(event, pos, item)
- add_fields = {}
if item.ask_attendee_data:
questions = questions + get_fake_questions(event.settings)
@@ -705,7 +1019,7 @@ class BaseQuestionsForm(forms.Form):
if isinstance(q, FakeQuestion):
self.fields[q.system_question] = self.build_system_question_field(request, event, pos, q)
else:
- self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos, q)
+ self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos.answerlist, pos, q)
responses = question_form_fields.send(sender=event, position=pos)
data = pos.meta_info_data
@@ -882,237 +1196,6 @@ class BaseQuestionsForm(forms.Form):
field.widget.is_required = True
return field
- def build_user_question_field(self, request, event, pos, q):
- # Do we already have an answer? Provide it as the initial value
- answers = [a for a in pos.answerlist if a.question_id == q.id]
- if answers:
- initial = answers[0]
- else:
- initial = None
- tz = ZoneInfo(event.settings.timezone)
- help_text = rich_text(q.help_text)
- label = escape(q.question) # django-bootstrap3 calls mark_safe
- required = q.required and not self.all_optional
- if q.type == Question.TYPE_BOOLEAN:
- if required:
- # For some reason, django-bootstrap3 does not set the required attribute
- # itself.
- widget = forms.CheckboxInput(attrs={'required': 'required'})
- else:
- widget = forms.CheckboxInput()
-
- if initial:
- initialbool = (initial.answer == "True")
- else:
- initialbool = False
-
- field = forms.BooleanField(
- label=label, required=required,
- help_text=help_text,
- initial=initialbool, widget=widget,
- )
- elif q.type == Question.TYPE_NUMBER:
- field = forms.DecimalField(
- label=label, required=required,
- min_value=q.valid_number_min or Decimal('0.00'),
- max_value=q.valid_number_max,
- help_text=help_text,
- initial=initial.answer if initial else None,
- )
- elif q.type == Question.TYPE_STRING:
- field = forms.CharField(
- label=label, required=required,
- max_length=q.valid_string_length_max,
- help_text=help_text,
- initial=initial.answer if initial else None,
- )
- elif q.type == Question.TYPE_TEXT:
- field = forms.CharField(
- label=label, required=required,
- max_length=q.valid_string_length_max,
- help_text=help_text,
- widget=forms.Textarea,
- initial=initial.answer if initial else None,
- )
- elif q.type == Question.TYPE_COUNTRYCODE:
- field = CountryField(
- countries=CachedCountries,
- blank=True, null=True, blank_label=' ',
- ).formfield(
- label=label, required=required,
- help_text=help_text,
- widget=forms.Select,
- empty_label=' ',
- initial=initial.answer if initial else (
- guess_country_from_request(request, event) if required else None),
- )
- elif q.type == Question.TYPE_CHOICE:
- field = forms.ModelChoiceField(
- queryset=q.options,
- label=label, required=required,
- help_text=help_text,
- widget=forms.Select,
- to_field_name='identifier',
- empty_label='',
- initial=initial.options.first() if initial else None,
- )
- elif q.type == Question.TYPE_CHOICE_MULTIPLE:
- field = forms.ModelMultipleChoiceField(
- queryset=q.options,
- label=label, required=required,
- help_text=help_text,
- to_field_name='identifier',
- widget=QuestionCheckboxSelectMultiple,
- initial=initial.options.all() if initial else None,
- )
- elif q.type == Question.TYPE_FILE:
- if q.valid_file_portrait:
- field = PortraitImageField(
- label=label, required=required,
- help_text=help_text,
- initial=initial.file if initial else None,
- widget=PortraitImageWidget(position=pos, event=event, answer=initial,
- attrs={'data-portrait-photo': 'true'}),
- )
- else:
- field = ExtFileField(
- label=label, required=required,
- help_text=help_text,
- initial=initial.file if initial else None,
- widget=UploadedFileWidget(position=pos, event=event, answer=initial),
- ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
- max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
- )
- elif q.type == Question.TYPE_DATE:
- attrs = {}
- if q.valid_date_min:
- attrs['data-min'] = q.valid_date_min.isoformat()
- if q.valid_date_max:
- attrs['data-max'] = q.valid_date_max.isoformat()
- if not help_text:
- if q.valid_date_min and q.valid_date_max:
- help_text = format_lazy(
- _('Please enter a date between {min} and {max}.'),
- min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
- max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
- )
- elif q.valid_date_min:
- help_text = format_lazy(
- _('Please enter a date no earlier than {min}.'),
- min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
- )
- elif q.valid_date_max:
- help_text = format_lazy(
- _('Please enter a date no later than {max}.'),
- max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
- )
- if initial and initial.answer:
- try:
- _initial = dateutil.parser.parse(initial.answer).date()
- except dateutil.parser.ParserError:
- _initial = None
- else:
- _initial = None
- field = forms.DateField(
- label=label, required=required,
- help_text=help_text,
- initial=_initial,
- widget=DatePickerWidget(attrs),
- )
- if q.valid_date_min:
- field.validators.append(MinDateValidator(q.valid_date_min))
- if q.valid_date_max:
- field.validators.append(MaxDateValidator(q.valid_date_max))
- elif q.type == Question.TYPE_TIME:
- if initial and initial.answer:
- try:
- _initial = dateutil.parser.parse(initial.answer).time()
- except dateutil.parser.ParserError:
- _initial = None
- else:
- _initial = None
- field = forms.TimeField(
- label=label, required=required,
- help_text=help_text,
- initial=_initial,
- widget=TimePickerWidget(without_seconds=True),
- )
- elif q.type == Question.TYPE_DATETIME:
- if not help_text:
- if q.valid_datetime_min and q.valid_datetime_max:
- help_text = format_lazy(
- _('Please enter a date and time between {min} and {max}.'),
- min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
- max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
- )
- elif q.valid_datetime_min:
- help_text = format_lazy(
- _('Please enter a date and time no earlier than {min}.'),
- min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
- )
- elif q.valid_datetime_max:
- help_text = format_lazy(
- _('Please enter a date and time no later than {max}.'),
- max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
- )
-
- if initial and initial.answer:
- try:
- _initial = dateutil.parser.parse(initial.answer).astimezone(tz)
- except dateutil.parser.ParserError:
- _initial = None
- else:
- _initial = None
-
- field = SplitDateTimeField(
- label=label, required=required,
- help_text=help_text,
- initial=_initial,
- widget=SplitDateTimePickerWidget(
- time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
- min_date=q.valid_datetime_min,
- max_date=q.valid_datetime_max
- ),
- )
- if q.valid_datetime_min:
- field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
- if q.valid_datetime_max:
- field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
- elif q.type == Question.TYPE_PHONENUMBER:
- if initial:
- try:
- initial = PhoneNumber().from_string(initial.answer)
- except NumberParseException:
- initial = None
-
- if not initial:
- phone_prefix = guess_phone_prefix_from_request(request, event)
- if phone_prefix:
- initial = "+{}.".format(phone_prefix)
-
- field = PhoneNumberField(
- label=label, required=required,
- help_text=help_text,
- # We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
- # a country code but no number as an initial value. It's a bit hacky, but should be stable for
- # the future.
- initial=initial,
- widget=WrappedPhoneNumberPrefixWidget()
- )
- field.question = q
- if answers:
- # Cache the answer object for later use
- field.answer = answers[0]
-
- if q.dependency_question_id:
- field.widget.attrs['data-question-dependency'] = q.dependency_question_id
- field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
- if q.type != 'M':
- field.widget.attrs['required'] = q.required and not self.all_optional
- field._required = q.required and not self.all_optional
- field.required = False
- return field
-
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
@@ -1126,49 +1209,7 @@ class BaseQuestionsForm(forms.Form):
if not d.get('state'):
self.add_error('state', _('This field is required.'))
- question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
-
- def question_is_visible(parentid, qvals):
- if parentid not in question_cache:
- return False
- parentq = question_cache[parentid]
- if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
- return False
- if 'question_%d' % parentid not in d:
- return False
- dval = d.get('question_%d' % parentid)
- return (
- ('True' in qvals and dval)
- or ('False' in qvals and not dval)
- or (isinstance(dval, QuestionOption) and dval.identifier in qvals)
- or (isinstance(dval, (list, QuerySet)) and any(qval in [o.identifier for o in dval] for qval in qvals))
- )
-
- def question_is_required(q):
- return (
- q.required and
- (not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values))
- )
-
- if not self.all_optional:
- for q in question_cache.values():
- answer = d.get('question_%d' % q.pk)
- field = self['question_%d' % q.pk]
- if question_is_required(q) and not answer and answer != 0 and not field.errors:
- raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
-
- # Strip invisible question from cleaned_data so they don't end up in the database
- for q in question_cache.values():
- answer = d.get('question_%d' % q.pk)
- if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None:
- d['question_%d' % q.pk] = None
-
- # Strip False answers to required yes/no questions even if all_optional is set, as our data model assumes that
- # required yes/no questions can only be answered with yes
- for q in question_cache.values():
- if q.required and q.type == Question.TYPE_BOOLEAN:
- if 'question_%d' % q.pk in d and d['question_%d' % q.pk] is False:
- d['question_%d' % q.pk] = None
+ self.check_user_questions(d)
return d
diff --git a/src/pretix/base/forms/widgets.py b/src/pretix/base/forms/widgets.py
index 598527a16d..01e7040cc4 100644
--- a/src/pretix/base/forms/widgets.py
+++ b/src/pretix/base/forms/widgets.py
@@ -163,9 +163,9 @@ class UploadedFileWidget(forms.ClearableFileInput):
super().__init__(*args, **kwargs)
class FakeFile:
- def __init__(self, file, position, event, answer):
+ def __init__(self, file, container, event, answer):
self.file = file
- self.position = position
+ self.container = container
self.event = event
self.answer = answer
@@ -174,13 +174,19 @@ class UploadedFileWidget(forms.ClearableFileInput):
@property
def url(self):
- from pretix.base.models import OrderPosition
+ from pretix.base.models import Order, OrderPosition
from pretix.multidomain.urlreverse import eventreverse
- if isinstance(self.position, OrderPosition):
+ order = None
+ if isinstance(self.container, OrderPosition):
+ order = self.container.order
+ elif isinstance(self.container, Order):
+ order = self.container
+
+ if order:
return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={
- 'order': self.position.order.code,
- 'secret': self.position.order.secret,
+ 'order': order.code,
+ 'secret': order.secret,
'answer': self.answer.pk,
})
else:
diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py
index 4dd436d63f..e53e771278 100644
--- a/src/pretix/base/models/customers.py
+++ b/src/pretix/base/models/customers.py
@@ -33,6 +33,7 @@ from django.db.models.aggregates import Sum
from django.db.models.expressions import OuterRef, Subquery
from django.db.models.functions.comparison import Coalesce
from django.utils.crypto import get_random_string, salted_hmac
+from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled
@@ -409,6 +410,17 @@ class AttendeeProfile(models.Model):
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
+ @cached_property
+ def answers_key_to_index(self):
+ return {a.get('field_name'): i for i, a in enumerate(self.answers)}
+
+ def store_answer(self, answer_dict):
+ k = answer_dict['field_name']
+ if k in self.answers_key_to_index:
+ self.answers[self.answers_key_to_index[k]] = answer_dict
+ else:
+ self.answers.append(answer_dict)
+
def generate_client_id():
return get_random_string(40)
diff --git a/src/pretix/base/views/mixins.py b/src/pretix/base/views/mixins.py
index c87368078c..63a0154a61 100644
--- a/src/pretix/base/views/mixins.py
+++ b/src/pretix/base/views/mixins.py
@@ -32,18 +32,20 @@ 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, BaseQuestionsForm, OrderLevelQuestionsForm, TicketQuestionsForm,
)
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 = TicketQuestionsForm
all_optional = False
@cached_property
@@ -62,19 +64,23 @@ class BaseQuestionsViewMixin:
@cached_property
def order_questions_form(self):
- checkoutsession =
+ c = self.order_question_container
+ if c is None:
+ return None
+ checkoutsession = c if isinstance(c, CheckoutSession) else None
+ order = c if isinstance(c, Order) else None
kwargs = {} # self.question_form_kwargs(cr)
- form = self.form_class(event=self.request.event,
- prefix='order',
- request=self.request,
- cartpos=None,
- orderpos=None,
- checkoutsession=,
- order=,
- 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.order_form_class(
+ event=self.request.event,
+ prefix='order',
+ request=self.request,
+ checkoutsession=checkoutsession,
+ order=order,
+ 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
@@ -90,15 +96,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
@@ -151,6 +159,32 @@ class BaseQuestionsViewMixin:
def save(self):
failed = False
+ if self.order_questions_form:
+ if not self.order_questions_form.is_valid():
+ failed = True
+ else:
+ 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:
+ answer = self._upsert_answer(
+ field, v,
+ checkoutsession=self.order_questions_form.checkoutsession,
+ order=self.order_questions_form.order,
+ question=field.question,
+ )
+
for form in self.forms:
meta_info = form.pos.meta_info_data
# Every form represents a CartPosition or OrderPosition with questions attached
@@ -161,10 +195,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
@@ -202,64 +234,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(
+ answer = self._upsert_answer(
+ field, v,
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
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]
@@ -278,10 +265,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()
@@ -292,6 +276,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])
@@ -315,6 +316,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
@@ -418,6 +434,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
diff --git a/src/pretix/control/templates/pretixcontrol/order/change_questions.html b/src/pretix/control/templates/pretixcontrol/order/change_questions.html
index 62feb089e3..ff44c6421a 100644
--- a/src/pretix/control/templates/pretixcontrol/order/change_questions.html
+++ b/src/pretix/control/templates/pretixcontrol/order/change_questions.html
@@ -35,6 +35,18 @@
{% endif %}
+ {% if order_questions_form %}
+
+
+