diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py
index dacf056b6a..b34858c49b 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.ContainerType.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..eb46964e9c 100644
--- a/src/pretix/api/views/item.py
+++ b/src/pretix/api/views/item.py
@@ -475,7 +475,10 @@ 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(
+ # the container_type parameter is undocumented, this API is going to change in a later release
+ container_type=self.request.GET.get('container_type', Question.ContainerType.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 df0f158ed5..e9ddc76588 100644
--- a/src/pretix/base/forms/questions.py
+++ b/src/pretix/base/forms/questions.py
@@ -36,6 +36,7 @@ import copy
import json
import logging
import re
+from collections import namedtuple
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
@@ -636,449 +637,270 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
super().__init__(*args, **kwargs)
+FakeQuestion = namedtuple(
+ 'FakeQuestion', 'id question position required help_text container_type', defaults=('', Question.ContainerType.ORDERPOSITION)
+)
+
+
+def get_fake_attendee_questions(settings):
+ fq = []
+ sqo = settings.system_question_order
+
+ if settings.attendee_names_asked:
+ fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), settings.attendee_names_required))
+
+ if settings.attendee_emails_asked:
+ fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), settings.attendee_emails_required))
+
+ if settings.attendee_company_asked:
+ fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), settings.attendee_company_required))
+
+ if settings.attendee_addresses_asked:
+ fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), settings.attendee_addresses_required))
+ fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), settings.attendee_addresses_required))
+ fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), settings.attendee_addresses_required))
+ fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), settings.attendee_addresses_required))
+ return fq
+
+
class BaseQuestionsForm(forms.Form):
"""
- This form class is responsible for asking order-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.
+ This is the base form class responsible for asking order- or ticket-related questions.
"""
address_validation = False
- def __init__(self, *args, **kwargs):
- """
- Takes two additional keyword arguments:
-
- :param cartpos: The cart position the form should be for
- :param event: The event this belongs to
- """
- request = kwargs.pop('request', None)
- cartpos = self.cartpos = kwargs.pop('cartpos', None)
- orderpos = self.orderpos = kwargs.pop('orderpos', None)
- pos = cartpos or orderpos
- item = pos.item
- questions = pos.item.questions_to_ask
- event = kwargs.pop('event')
- self.all_optional = kwargs.pop('all_optional', False)
- self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
-
- super().__init__(*args, **kwargs)
-
- if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
- if item.validity_dynamic_start_choice_day_limit:
- max_date = time_machine_now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
- else:
- max_date = None
- min_date = time_machine_now()
+ def build_user_question_field(self, request, event, answerlist, 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
- if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership:
- if pos.used_membership.date_start >= time_machine_now():
- initial = min_date = pos.used_membership.date_start
- max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end
- if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
- attrs = {}
- if max_date:
- attrs['data-max'] = max_date.date().isoformat()
- if min_date:
- attrs['data-min'] = min_date.date().isoformat()
- self.fields['requested_valid_from'] = forms.DateField(
- label=_('Start date'),
- help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
- required=bool(initial),
- initial=pos.requested_valid_from or initial,
- widget=DatePickerWidget(attrs),
- validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())]
- )
+ tz = ZoneInfo(event.settings.timezone)
+ 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:
- self.fields['requested_valid_from'] = forms.SplitDateTimeField(
- label=_('Start date'),
- help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
- required=bool(initial),
- initial=pos.requested_valid_from or initial,
- widget=SplitDateTimePickerWidget(
- time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
- min_date=min_date,
- max_date=max_date
- ),
- validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)]
- )
+ widget = forms.CheckboxInput()
- add_fields = {}
+ if initial:
+ initialbool = (initial.answer == "True")
+ else:
+ initialbool = False
- if item.ask_attendee_data and event.settings.attendee_names_asked:
- add_fields['attendee_name_parts'] = NamePartsFormField(
- max_length=255,
- required=event.settings.attendee_names_required and not self.all_optional,
- scheme=event.settings.name_scheme,
- titles=event.settings.name_scheme_titles,
- label=_('Attendee name'),
- initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
+ field = forms.BooleanField(
+ label=escape(q.question), required=required,
+ help_text=rich_text(q.help_text),
+ initial=initialbool, widget=widget,
)
- if item.ask_attendee_data and event.settings.attendee_emails_asked:
- add_fields['attendee_email'] = forms.EmailField(
- required=event.settings.attendee_emails_required and not self.all_optional,
- label=_('Attendee email'),
- initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
- widget=forms.EmailInput(
- attrs={
- 'autocomplete': 'email'
- }
- )
+ elif q.type == Question.TYPE_NUMBER:
+ field = forms.DecimalField(
+ label=escape(q.question), required=required,
+ min_value=q.valid_number_min or Decimal('0.00'),
+ max_value=q.valid_number_max,
+ help_text=rich_text(q.help_text),
+ initial=initial.answer if initial else None,
)
- if item.ask_attendee_data and event.settings.attendee_company_asked:
- add_fields['company'] = forms.CharField(
- required=event.settings.attendee_company_required and not self.all_optional,
- label=_('Company'),
- max_length=255,
- initial=(cartpos.company if cartpos else orderpos.company),
+ elif q.type == Question.TYPE_STRING:
+ field = forms.CharField(
+ label=escape(q.question), required=required,
+ max_length=q.valid_string_length_max,
+ help_text=rich_text(q.help_text),
+ initial=initial.answer if initial else None,
)
-
- if item.ask_attendee_data and event.settings.attendee_addresses_asked:
- add_fields['street'] = forms.CharField(
- required=self.attendee_addresses_required,
- label=_('Address'),
- widget=forms.Textarea(attrs={
- 'rows': 2,
- 'placeholder': _('Street and Number'),
- 'autocomplete': 'street-address'
- }),
- initial=(cartpos.street if cartpos else orderpos.street),
+ elif q.type == Question.TYPE_TEXT:
+ field = forms.CharField(
+ label=escape(q.question), required=required,
+ max_length=q.valid_string_length_max,
+ help_text=rich_text(q.help_text),
+ widget=forms.Textarea,
+ initial=initial.answer if initial else None,
)
- add_fields['zipcode'] = forms.CharField(
- required=False,
- max_length=30,
- label=_('ZIP code'),
- initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
- widget=forms.TextInput(attrs={
- 'autocomplete': 'postal-code',
- }),
- )
- add_fields['city'] = forms.CharField(
- required=False,
- label=_('City'),
- max_length=255,
- initial=(cartpos.city if cartpos else orderpos.city),
- widget=forms.TextInput(attrs={
- 'autocomplete': 'address-level2',
- }),
- )
- country = (cartpos.country if cartpos else orderpos.country) or guess_country_from_request(request, event)
- add_fields['country'] = CountryField(
- countries=CachedCountries
+ elif q.type == Question.TYPE_COUNTRYCODE:
+ field = CountryField(
+ countries=CachedCountries,
+ blank=True, null=True, blank_label=' ',
).formfield(
- required=self.attendee_addresses_required,
- label=_('Country'),
- initial=country,
- widget=forms.Select(attrs={
- 'autocomplete': 'country',
- 'data-trigger-address-info': 'on',
- }),
+ label=escape(q.question), required=required,
+ help_text=rich_text(q.help_text),
+ widget=forms.Select,
+ empty_label=' ',
+ initial=initial.answer if initial else (
+ guess_country_from_request(request, event) if required else None),
)
- c = [('', '---')]
- fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
- cc = None
- state = None
- if fprefix + 'country' in self.data:
- cc = str(self.data[fprefix + 'country'])
- elif country:
- cc = str(country)
- if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
- types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
- statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
- c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
- state = (cartpos.state if cartpos else orderpos.state)
- elif fprefix + 'state' in self.data:
- self.data = self.data.copy()
- del self.data[fprefix + 'state']
-
- add_fields['state'] = forms.ChoiceField(
- label=pgettext_lazy('address', 'State'),
- required=False,
- choices=c,
- initial=state,
- widget=forms.Select(attrs={
- 'autocomplete': 'address-level1',
- }),
+ elif q.type == Question.TYPE_CHOICE:
+ field = forms.ModelChoiceField(
+ queryset=q.options,
+ label=escape(q.question), required=required,
+ help_text=rich_text(q.help_text),
+ widget=forms.Select,
+ to_field_name='identifier',
+ empty_label='',
+ initial=initial.options.first() if initial else None,
)
- add_fields['state'].widget.is_required = True
-
- field_positions = list(
- [
- (n, event.settings.system_question_order.get(n if n != 'state' else 'country', 0))
- for n in add_fields.keys()
- ]
- )
-
- for q in questions:
- # 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]
+ elif q.type == Question.TYPE_CHOICE_MULTIPLE:
+ field = forms.ModelMultipleChoiceField(
+ queryset=q.options,
+ label=escape(q.question), required=required,
+ help_text=rich_text(q.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=escape(q.question), required=required,
+ help_text=rich_text(q.help_text),
+ initial=initial.file if initial else None,
+ widget=PortraitImageWidget(answer=initial, request=request,
+ attrs={'data-portrait-photo': 'true'}),
+ )
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,
+ field = ExtFileField(
+ label=escape(q.question), required=required,
+ help_text=rich_text(q.help_text),
+ initial=initial.file if initial else None,
+ widget=UploadedFileWidget(answer=initial, request=request),
+ ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
+ max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
)
- 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'}),
+ 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()
+ help_text = q.help_text
+ 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"),
)
- 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.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.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:
+ 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
- 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:
+ else:
+ _initial = None
+ field = forms.DateField(
+ label=escape(q.question), required=required,
+ help_text=rich_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
- 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"),
- )
+ else:
+ _initial = None
+ field = forms.TimeField(
+ label=escape(q.question), required=required,
+ help_text=rich_text(q.help_text),
+ initial=_initial,
+ widget=TimePickerWidget(without_seconds=True),
+ )
+ elif q.type == Question.TYPE_DATETIME:
+ help_text = q.help_text
+ 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:
+ 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
+ field = SplitDateTimeField(
+ label=escape(q.question), required=required,
+ help_text=rich_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)
+ 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]
+ field = PhoneNumberField(
+ label=escape(q.question), required=required,
+ help_text=rich_text(q.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
-
- add_fields['question_%s' % q.id] = field
- field_positions.append(('question_%s' % q.id, q.position))
-
- field_positions.sort(key=lambda e: e[1])
- for fname, p in field_positions:
- self.fields[fname] = add_fields[fname]
-
- responses = question_form_fields.send(sender=event, position=pos)
- data = pos.meta_info_data
- for r, response in sorted(responses, key=lambda r: str(r[0])):
- for key, value in response.items():
- # We need to be this explicit, since OrderedDict.update does not retain ordering
- self.fields[key] = value
- value.initial = data.get('question_form_data', {}).get(key)
-
- for k, v in self.fields.items():
- if isinstance(v.widget, forms.MultiWidget):
- for w in v.widget.widgets:
- autocomplete = w.attrs.get('autocomplete', '')
- if autocomplete.strip() == "off":
- w.attrs['autocomplete'] = 'off'
- else:
- w.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
- if v.widget.attrs.get('autocomplete') or k == 'attendee_name_parts':
- autocomplete = v.widget.attrs.get('autocomplete', '')
- if autocomplete.strip() == "off":
- v.widget.attrs['autocomplete'] = 'off'
- else:
- v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
-
- def clean(self):
- from pretix.base.addressvalidation import \
- validate_address # local import to prevent impact on startup time
-
- d = super().clean()
-
- if self.address_validation:
- self.cleaned_data = d = validate_address(d, all_optional=not self.attendee_addresses_required)
-
- if d.get('street') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
- if not d.get('state'):
- self.add_error('state', _('This field is required.'))
+ 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):
@@ -1123,6 +945,268 @@ class BaseQuestionsForm(forms.Form):
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, container, *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)
+ 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.ContainerType.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, q)
+
+ def clean(self):
+ d = super().clean()
+ self.check_user_questions(d)
+ return d
+
+
+class TicketLevelQuestionsForm(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.
+ """
+
+ def __init__(self, *args, **kwargs):
+ """
+ Takes two additional keyword arguments:
+
+ :param cartpos: The cart position the form should be for
+ :param event: The event this belongs to
+ """
+ request = kwargs.pop('request', None)
+ cartpos = self.cartpos = kwargs.pop('cartpos', None)
+ orderpos = self.orderpos = kwargs.pop('orderpos', None)
+ pos = cartpos or orderpos
+ item = pos.item
+ event = kwargs.pop('event')
+ self.all_optional = kwargs.pop('all_optional', False)
+ self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
+
+ super().__init__(*args, **kwargs)
+
+ 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)
+
+ questions = []
+ if item.ask_attendee_data:
+ questions += get_fake_attendee_questions(event.settings)
+ questions += pos.item.questions_to_ask
+
+ questions.sort(key=lambda q: q.position)
+
+ for q in questions:
+ if isinstance(q, FakeQuestion):
+ self.fields[q.id] = self.build_system_question_field(request, event, pos, q)
+ else:
+ self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos.answerlist, q)
+
+ responses = question_form_fields.send(sender=event, position=pos)
+ data = pos.meta_info_data
+ for r, response in sorted(responses, key=lambda r: str(r[0])):
+ for key, value in response.items():
+ # We need to be this explicit, since OrderedDict.update does not retain ordering
+ self.fields[key] = value
+ value.initial = data.get('question_form_data', {}).get(key)
+
+ for k, v in self.fields.items():
+ if isinstance(v.widget, forms.MultiWidget):
+ for w in v.widget.widgets:
+ autocomplete = w.attrs.get('autocomplete', '')
+ if autocomplete.strip() == "off":
+ w.attrs['autocomplete'] = 'off'
+ else:
+ w.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
+ if v.widget.attrs.get('autocomplete') or k == 'attendee_name_parts':
+ autocomplete = v.widget.attrs.get('autocomplete', '')
+ if autocomplete.strip() == "off":
+ v.widget.attrs['autocomplete'] = 'off'
+ else:
+ v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
+
+ def build_requested_valid_from_field(self, event, pos, item):
+ if item.validity_dynamic_start_choice_day_limit:
+ max_date = time_machine_now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
+ else:
+ max_date = None
+ min_date = time_machine_now()
+ initial = None
+ if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership:
+ if pos.used_membership.date_start >= time_machine_now():
+ initial = min_date = pos.used_membership.date_start
+ max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end
+ if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
+ attrs = {}
+ if max_date:
+ attrs['data-max'] = max_date.date().isoformat()
+ if min_date:
+ attrs['data-min'] = min_date.date().isoformat()
+ return forms.DateField(
+ label=_('Start date'),
+ help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
+ required=bool(initial),
+ initial=pos.requested_valid_from or initial,
+ widget=DatePickerWidget(attrs),
+ validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())]
+ )
+ else:
+ return forms.SplitDateTimeField(
+ label=_('Start date'),
+ help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
+ required=bool(initial),
+ initial=pos.requested_valid_from or initial,
+ widget=SplitDateTimePickerWidget(
+ time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
+ min_date=min_date,
+ max_date=max_date
+ ),
+ validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)]
+ )
+
+ def build_system_question_field(self, request, event, pos, qc):
+ field_name = qc.id
+ if field_name == 'attendee_name_parts':
+ return NamePartsFormField(
+ max_length=255,
+ required=qc.required and not self.all_optional,
+ scheme=event.settings.name_scheme,
+ titles=event.settings.name_scheme_titles,
+ label=escape(qc.question),
+ help_text=rich_text(qc.help_text),
+ initial=pos.attendee_name_parts,
+ )
+ if field_name == 'attendee_email':
+ return forms.EmailField(
+ required=qc.required and not self.all_optional,
+ label=escape(qc.question),
+ help_text=rich_text(qc.help_text),
+ initial=pos.attendee_email,
+ widget=forms.EmailInput(
+ attrs={
+ 'autocomplete': 'email'
+ }
+ )
+ )
+ if field_name == 'company':
+ return forms.CharField(
+ required=qc.required and not self.all_optional,
+ label=escape(qc.question),
+ help_text=rich_text(qc.help_text),
+ max_length=255,
+ initial=pos.company,
+ )
+
+ if field_name == 'street':
+ return forms.CharField(
+ required=qc.required and not self.all_optional,
+ label=escape(qc.question),
+ help_text=rich_text(qc.help_text),
+ widget=forms.Textarea(attrs={
+ 'rows': 2,
+ 'placeholder': _('Street and Number'),
+ 'autocomplete': 'street-address'
+ }),
+ initial=pos.street,
+ )
+ if field_name == 'zipcode':
+ return forms.CharField(
+ required=False,
+ max_length=30,
+ label=escape(qc.question),
+ help_text=rich_text(qc.help_text),
+ initial=pos.zipcode,
+ widget=forms.TextInput(attrs={
+ 'autocomplete': 'postal-code',
+ }),
+ )
+ if field_name == 'city':
+ return forms.CharField(
+ required=False,
+ label=escape(qc.question),
+ help_text=rich_text(qc.help_text),
+ max_length=255,
+ initial=pos.city,
+ widget=forms.TextInput(attrs={
+ 'autocomplete': 'address-level2',
+ }),
+ )
+ if field_name == 'country':
+ country = pos.country or guess_country_from_request(request, event)
+ return CountryField(
+ countries=CachedCountries
+ ).formfield(
+ required=qc.required and not self.all_optional,
+ label=escape(qc.question),
+ help_text=rich_text(qc.help_text),
+ initial=country,
+ widget=forms.Select(attrs={
+ 'autocomplete': 'country',
+ 'data-trigger-address-info': 'on',
+ }),
+ )
+ if field_name == 'state':
+ country = pos.country or guess_country_from_request(request, event)
+ c = [('', '---')]
+ fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
+ cc = None
+ state = None
+ if fprefix + 'country' in self.data:
+ cc = str(self.data[fprefix + 'country'])
+ elif country:
+ cc = str(country)
+ if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
+ types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
+ statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
+ c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
+ state = (pos.state)
+ elif fprefix + 'state' in self.data:
+ self.data = self.data.copy()
+ del self.data[fprefix + 'state']
+
+ field = forms.ChoiceField(
+ label=escape(qc.question),
+ help_text=rich_text(qc.help_text),
+ required=False,
+ choices=c,
+ initial=state,
+ widget=forms.Select(attrs={
+ 'autocomplete': 'address-level1',
+ }),
+ )
+ field.widget.is_required = True
+ return field
+
+ def clean(self):
+ from pretix.base.addressvalidation import \
+ validate_address # local import to prevent impact on startup time
+
+ d = super().clean()
+
+ if self.address_validation:
+ self.cleaned_data = d = validate_address(d, all_optional=not self.attendee_addresses_required)
+
+ if d.get('street') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
+ if not d.get('state'):
+ self.add_error('state', _('This field is required.'))
+
+ self.check_user_questions(d)
+
return d
@@ -1201,15 +1285,15 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if not self.ask_vat_id:
del self.fields['vat_id']
elif self.validate_vat_id:
- self.fields['vat_id'].help_text = '
'.join([
- str(_('Optional, but depending on the country you reside in we might need to charge you '
- 'additional taxes if you do not enter it.')),
- ])
+ self.fields['vat_id'].help_text = _(
+ 'Optional, but depending on the country you reside in we might need to charge you '
+ 'additional taxes if you do not enter it.'
+ )
else:
- self.fields['vat_id'].help_text = '
'.join([
- str(_('Optional, but it might be required for you to claim tax benefits on your invoice '
- 'depending on your and the seller’s country of residence.')),
- ])
+ self.fields['vat_id'].help_text = _(
+ 'Optional, but it might be required for you to claim tax benefits on your invoice '
+ 'depending on your and the seller’s country of residence.'
+ )
transmission_type_choices = [
(t.identifier, t.public_name) for t in get_transmission_types()
@@ -1295,8 +1379,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
del self.fields['beneficiary']
if event.settings.invoice_address_custom_field:
- self.fields['custom_field'].label = event.settings.invoice_address_custom_field
- self.fields['custom_field'].help_text = event.settings.invoice_address_custom_field_helptext
+ self.fields['custom_field'].label = escape(event.settings.invoice_address_custom_field)
+ self.fields['custom_field'].help_text = rich_text(event.settings.invoice_address_custom_field_helptext)
else:
del self.fields['custom_field']
diff --git a/src/pretix/base/forms/widgets.py b/src/pretix/base/forms/widgets.py
index ccca7a8160..937b850980 100644
--- a/src/pretix/base/forms/widgets.py
+++ b/src/pretix/base/forms/widgets.py
@@ -47,6 +47,7 @@ from pretix.helpers.i18n import (
get_format_without_seconds, get_javascript_format,
get_javascript_format_without_seconds,
)
+from pretix.helpers.safedownload import get_token
def replace_arabic_numbers(inp):
@@ -157,36 +158,26 @@ class TimePickerWidget(forms.TimeInput):
class UploadedFileWidget(forms.ClearableFileInput):
def __init__(self, *args, **kwargs):
- self.position = kwargs.pop('position')
- self.event = kwargs.pop('event')
self.answer = kwargs.pop('answer')
+ self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
class FakeFile:
- def __init__(self, file, position, event, answer):
+ def __init__(self, file, answer, request):
self.file = file
- self.position = position
- self.event = event
self.answer = answer
+ self.request = request
def __str__(self):
return os.path.basename(self.file.name).split('.', 1)[-1]
@property
def url(self):
- from pretix.base.models import OrderPosition
- from pretix.multidomain.urlreverse import eventreverse
-
- if isinstance(self.position, OrderPosition):
- return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={
- 'order': self.position.order.code,
- 'secret': self.position.order.secret,
- 'answer': self.answer.pk,
- })
+ token = get_token(self.request, self.answer)
+ if self.request.resolver_match.namespace == 'control':
+ return self.answer.backend_file_url + '?token=' + token
else:
- return eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={
- 'answer': self.answer.pk,
- })
+ return self.answer.frontend_file_url + '?token=' + token
def get_context(self, name, value, attrs):
# Browsers can't recognize that the server already has a file uploaded
@@ -199,7 +190,7 @@ class UploadedFileWidget(forms.ClearableFileInput):
def format_value(self, value):
if self.is_initial(value):
- return self.FakeFile(value, self.position, self.event, self.answer)
+ return self.FakeFile(value, self.answer, self.request)
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
diff --git a/src/pretix/base/migrations/0308_checkoutsession_invoiceaddress_checkout_session.py b/src/pretix/base/migrations/0308_checkoutsession_invoiceaddress_checkout_session.py
new file mode 100644
index 0000000000..3a644e032d
--- /dev/null
+++ b/src/pretix/base/migrations/0308_checkoutsession_invoiceaddress_checkout_session.py
@@ -0,0 +1,63 @@
+# Generated by Django 4.2.17 on 2025-01-01 20:25
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("pretixbase", "0307_devicelastseen"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="CheckoutSession",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True, primary_key=True, serialize=False
+ ),
+ ),
+ ("cart_id", models.CharField(max_length=255, unique=True)),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("testmode", models.BooleanField(default=False)),
+ ("session_data", models.JSONField(default=dict)),
+ (
+ "customer",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="checkout_sessions",
+ to="pretixbase.customer",
+ ),
+ ),
+ (
+ "event",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="checkout_sessions",
+ to="pretixbase.event",
+ ),
+ ),
+ (
+ "sales_channel",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="pretixbase.saleschannel",
+ ),
+ ),
+ ],
+ ),
+ migrations.AddField(
+ model_name="invoiceaddress",
+ name="checkout_session",
+ field=models.OneToOneField(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="invoice_address",
+ to="pretixbase.checkoutsession",
+ ),
+ ),
+ ]
diff --git a/src/pretix/base/migrations/0309_alter_questionanswer_unique_together_and_more.py b/src/pretix/base/migrations/0309_alter_questionanswer_unique_together_and_more.py
new file mode 100644
index 0000000000..ad1dea6386
--- /dev/null
+++ b/src/pretix/base/migrations/0309_alter_questionanswer_unique_together_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.15 on 2026-08-07 20:01
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pretixbase', '0308_checkoutsession_invoiceaddress_checkout_session'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='question',
+ name='container_type',
+ field=models.CharField(default='P', max_length=5),
+ ),
+ migrations.AddField(
+ model_name='questionanswer',
+ name='checkoutsession',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.checkoutsession'),
+ ),
+ migrations.AddField(
+ model_name='questionanswer',
+ name='order',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.order'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='questionanswer',
+ unique_together={('cartposition', 'question'), ('checkoutsession', 'question'), ('order', 'question'), ('orderposition', 'question')},
+ ),
+ ]
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/models/items.py b/src/pretix/base/models/items.py
index 272a263976..088c84789c 100644
--- a/src/pretix/base/models/items.py
+++ b/src/pretix/base/models/items.py
@@ -1606,6 +1606,9 @@ class Question(LoggedModel):
:param dependency_values: The values that `dependency_question` needs to be set to for this question to be applicable.
:type dependency_values: list[str]
"""
+ class ContainerType(models.TextChoices):
+ ORDER = "O", _("Order")
+ ORDERPOSITION = "P", _("Order position")
TYPE_NUMBER = "N"
TYPE_STRING = "S"
TYPE_TEXT = "T"
@@ -1641,6 +1644,12 @@ class Question(LoggedModel):
related_name="questions",
on_delete=models.CASCADE
)
+ container_type = models.CharField(
+ max_length=5,
+ choices=ContainerType.choices,
+ verbose_name=_("Asked on"),
+ default=ContainerType.ORDERPOSITION,
+ )
question = I18nTextField(
verbose_name=_("Question")
)
diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py
index 01844c82f8..34300993b8 100644
--- a/src/pretix/base/models/orders.py
+++ b/src/pretix/base/models/orders.py
@@ -1304,10 +1304,9 @@ class Order(LockModel, LoggedModel):
def answerfile_name(instance, filename: str) -> str:
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
- event = (instance.cartposition if instance.cartposition else instance.orderposition.order).event
return 'cachedfiles/answers/{org}/{ev}/{secret}.{filename}'.format(
- org=event.organizer.slug,
- ev=event.slug,
+ org=instance.event.organizer.slug,
+ ev=instance.event.slug,
secret=secret,
filename=escape_uri_path(filename),
)
@@ -1336,6 +1335,14 @@ class QuestionAnswer(models.Model):
'CartPosition', null=True, blank=True,
related_name='answers', on_delete=models.CASCADE
)
+ order = models.ForeignKey(
+ 'Order', null=True, blank=True,
+ related_name='answers', on_delete=models.CASCADE
+ )
+ checkoutsession = models.ForeignKey(
+ 'CheckoutSession', null=True, blank=True,
+ related_name='answers', on_delete=models.CASCADE
+ )
question = models.ForeignKey(
Question, related_name='answers', on_delete=models.CASCADE
)
@@ -1351,16 +1358,21 @@ class QuestionAnswer(models.Model):
objects = ScopedManager(organizer='question__event__organizer')
class Meta:
- unique_together = [['orderposition', 'question'], ['cartposition', 'question']]
+ unique_together = [
+ ['orderposition', 'question'],
+ ['cartposition', 'question'],
+ ['order', 'question'],
+ ['checkoutsession', 'question'],
+ ]
@property
def backend_file_url(self):
if self.file:
- if self.orderposition:
+ if self.associated_order:
return reverse('control:event.order.download.answer', kwargs={
- 'code': self.orderposition.order.code,
- 'event': self.orderposition.order.event.slug,
- 'organizer': self.orderposition.order.event.organizer.slug,
+ 'code': self.associated_order.code,
+ 'event': self.associated_order.event.slug,
+ 'organizer': self.associated_order.event.organizer.slug,
'answer': self.pk,
})
return ""
@@ -1370,14 +1382,14 @@ class QuestionAnswer(models.Model):
from pretix.multidomain.urlreverse import eventreverse
if self.file:
- if self.orderposition:
- url = eventreverse(self.orderposition.order.event, 'presale:event.order.download.answer', kwargs={
- 'order': self.orderposition.order.code,
- 'secret': self.orderposition.order.secret,
+ if self.associated_order:
+ url = eventreverse(self.associated_order.event, 'presale:event.order.download.answer', kwargs={
+ 'order': self.associated_order.code,
+ 'secret': self.associated_order.secret,
'answer': self.pk,
})
else:
- url = eventreverse(self.cartposition.event, 'presale:event.cart.download.answer', kwargs={
+ url = eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={
'answer': self.pk,
})
@@ -1392,6 +1404,24 @@ class QuestionAnswer(models.Model):
def file_name(self):
return self.file.name.split('.', 1)[-1]
+ @property
+ def associated_order(self):
+ if self.orderposition:
+ return self.orderposition.order
+ elif self.order:
+ return self.order
+
+ @property
+ def event(self):
+ if self.orderposition:
+ return self.orderposition.order.event
+ elif self.cartposition:
+ return self.cartposition.event
+ elif self.order:
+ return self.order.event
+ elif self.checkoutsession:
+ return self.checkoutsession.event
+
def __str__(self):
return self.to_string(use_cached=True)
@@ -3177,6 +3207,39 @@ class Transaction(models.Model):
return self.tax_value_includes_rounding_correction * self.count
+class CheckoutSession(models.Model):
+ """
+ A checkout session optionally bundles cart positions with additional information. This is historically
+ not required in pretix and currently only used in the Storefront API.
+ """
+ event = models.ForeignKey(
+ Event,
+ verbose_name=_("Event"),
+ related_name="checkout_sessions",
+ on_delete=models.CASCADE,
+ )
+ cart_id = models.CharField(
+ max_length=255, unique=True,
+ verbose_name=_("Cart ID (e.g. session key)"),
+ )
+ created = models.DateTimeField(
+ verbose_name=_("Date"),
+ auto_now_add=True,
+ )
+ customer = models.ForeignKey(
+ Customer,
+ related_name='checkout_sessions',
+ null=True, blank=True,
+ on_delete=models.SET_NULL,
+ )
+ sales_channel = models.ForeignKey(
+ "SalesChannel",
+ on_delete=models.CASCADE,
+ )
+ testmode = models.BooleanField(default=False)
+ session_data = models.JSONField(default=dict)
+
+
class CartPosition(AbstractPosition):
"""
A cart position is similar to an order line, except that it is not
@@ -3381,6 +3444,13 @@ class CartPosition(AbstractPosition):
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)
+ checkout_session = models.OneToOneField(
+ CheckoutSession,
+ null=True,
+ blank=True,
+ related_name='invoice_address',
+ on_delete=models.CASCADE
+ )
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
customer = models.ForeignKey(
Customer,
diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py
index 66eaa0b1fc..03e5ad36f2 100644
--- a/src/pretix/base/services/cart.py
+++ b/src/pretix/base/services/cart.py
@@ -61,7 +61,7 @@ from pretix.base.models import (
Seat, SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
-from pretix.base.models.orders import OrderFee
+from pretix.base.models.orders import CheckoutSession, OrderFee
from pretix.base.models.tax import TaxRule
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers
@@ -472,6 +472,16 @@ class CartManager:
if term_last < time_machine_now(self.real_now_dt):
raise CartError(error_messages['payment_ended'])
+ def _ensure_checkout_session(self):
+ CheckoutSession.objects.get_or_create(
+ event=self.event,
+ cart_id=self.cart_id,
+ defaults={
+ "sales_channel": self._sales_channel,
+ "testmode": self.event.testmode,
+ },
+ )
+
def _extend_expiry_of_valid_existing_positions(self):
# real_now_dt is initialized at CartManager instantiation, so it's slightly in the past. Add a small
# delta to reduce risk of extending already expired CartPositions.
@@ -1559,6 +1569,7 @@ class CartManager:
def commit(self):
self._check_presale_dates()
+ self._ensure_checkout_session()
self._check_max_cart_size()
err = self._delete_out_of_timeframe()
diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py
index 039f438d47..796c358edb 100644
--- a/src/pretix/base/services/checkin.py
+++ b/src/pretix/base/services/checkin.py
@@ -892,6 +892,7 @@ def _save_answers(op, answers, given_answers):
qa.answer = answer
qa.save(update_fields=['answer'])
qa.options.clear()
+ return qa
written = False
for q, a in given_answers.items():
diff --git a/src/pretix/base/services/cleanup.py b/src/pretix/base/services/cleanup.py
index 41376b920f..b6f4715b0e 100644
--- a/src/pretix/base/services/cleanup.py
+++ b/src/pretix/base/services/cleanup.py
@@ -33,6 +33,7 @@ from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress
from ..models.auth import UserKnownLoginSource
+from ..models.orders import CheckoutSession
from ..signals import periodic_task
@@ -43,6 +44,10 @@ def clean_cart_positions(sender, **kwargs):
cp.delete()
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
cp.delete()
+ for cs in CheckoutSession.objects.filter(created__lt=now() - timedelta(days=14)).exclude(
+ Exists(CartPosition.objects.filter(cart_id=OuterRef("cart_id")))
+ ):
+ cs.delete()
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
ia.delete()
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index 17a66924b2..bc7b2be971 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -73,8 +73,8 @@ from pretix.base.models import (
)
from pretix.base.models.event import Event_SettingsStore, SubEvent
from pretix.base.models.orders import (
- BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
- generate_secret,
+ BlockedTicketSecret, CheckoutSession, InvoiceAddress, OrderFee,
+ OrderRefund, generate_secret,
)
from pretix.base.models.organizer import SalesChannel, TeamAPIToken
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
@@ -1030,7 +1030,8 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
- customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None):
+ customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None,
+ cart_id: str=None):
payments = []
try:
@@ -1113,6 +1114,14 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
if meta_info:
for msg in meta_info.get('confirm_messages', []):
order.log_action('pretix.event.order.consent', data={'msg': msg})
+ if cart_id:
+ try:
+ session = CheckoutSession.objects.get(event=event, cart_id=cart_id)
+ except CheckoutSession.DoesNotExist:
+ pass
+ else:
+ session.answers.update(order=order, checkoutsession=None)
+ session.delete()
order_placed.send(event, order=order, bulk=False)
return order, payments
@@ -1160,7 +1169,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
- shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None):
+ shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None, cart_id: str=None):
for p in payment_requests:
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
if not p['pprov']:
@@ -1267,6 +1276,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
valid_if_pending=valid_if_pending,
api_meta=api_meta,
tax_rounding_mode=tax_rounding_mode,
+ cart_id=cart_id,
)
try:
@@ -3190,12 +3200,12 @@ class OrderChangeManager:
def perform_order(self, event: Event, payments: List[dict], positions: List[str],
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web', shown_total=None, customer=None, override_now_dt: datetime=None,
- api_meta: dict=None):
+ api_meta: dict=None, cart_id: str=None):
with language(locale), time_machine_now_assigned(override_now_dt):
try:
try:
return _perform_order(event, payments, positions, email, locale, address, meta_info,
- sales_channel, shown_total, customer, api_meta)
+ sales_channel, shown_total, customer, api_meta, cart_id=cart_id)
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
diff --git a/src/pretix/base/views/mixins.py b/src/pretix/base/views/mixins.py
index f85228d8a9..157f805430 100644
--- a/src/pretix/base/views/mixins.py
+++ b/src/pretix/base/views/mixins.py
@@ -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
diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py
index ecd13c7a28..1f2fceca1c 100644
--- a/src/pretix/control/forms/item.py
+++ b/src/pretix/control/forms/item.py
@@ -153,11 +153,19 @@ class QuestionForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields['items'].queryset = self.instance.event.items.all()
- self.fields['items'].required = True
+ if self.instance.container_type == Question.ContainerType.ORDERPOSITION:
+ self.fields['items'].queryset = self.instance.event.items.all()
+ self.fields['items'].required = True
+ else:
+ del self.fields['items']
+ del self.fields['ask_during_checkin']
+ del self.fields['show_during_checkin']
+ del self.fields['print_on_invoice']
+ self.fields['dependency_question'].widget.attrs['data-container-type'] = self.instance.container_type
self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
- ask_during_checkin=False
+ ask_during_checkin=False,
+ container_type=self.instance.container_type,
)
if self.instance.pk:
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
diff --git a/src/pretix/control/templates/pretixcontrol/items/question_delete.html b/src/pretix/control/templates/pretixcontrol/items/question_delete.html
index 5119a58de4..2dfa98a021 100644
--- a/src/pretix/control/templates/pretixcontrol/items/question_delete.html
+++ b/src/pretix/control/templates/pretixcontrol/items/question_delete.html
@@ -13,9 +13,11 @@
{% blocktrans with url=edit_url|add:"#tab-0-1-open" %}If you want to keep the answers, edit the question and set it to hidden.{% endblocktrans %}