From 31e5091b36ce830244be8bcc816965f733cff18e Mon Sep 17 00:00:00 2001 From: Mira Weller Date: Tue, 11 Aug 2026 12:36:11 +0200 Subject: [PATCH] Order-level questions --- src/pretix/api/serializers/item.py | 2 +- src/pretix/api/views/item.py | 2 +- src/pretix/base/forms/questions.py | 597 ++++++++++-------- src/pretix/base/forms/widgets.py | 18 +- src/pretix/base/models/customers.py | 12 + src/pretix/base/views/mixins.py | 179 +++--- .../pretixcontrol/order/change_questions.html | 12 + .../templates/pretixcontrol/order/index.html | 8 + src/pretix/control/views/orders.py | 4 + src/pretix/presale/checkoutflow.py | 1 + src/pretix/presale/forms/checkout.py | 4 +- .../event/checkout_questions.html | 12 + src/pretix/presale/views/__init__.py | 10 + src/pretix/presale/views/order.py | 12 +- src/pretix/presale/views/questions.py | 6 +- 15 files changed, 507 insertions(+), 372 deletions(-) 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 %} +
+ +

+ {% trans "Additional order information" %} +

+
+
+ {% bootstrap_form order_questions_form layout="horizontal" %} +
+
+ {% endif %} {% for pos, forms in formgroups %}
diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 88276398e3..c013d455b8 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -427,6 +427,14 @@ {% endif %} + + {% if order.answers.all %} +
+ {% endif %} + {% for ans in order.answers.all %} +
{{ ans.question.internal_name|default:ans.question.question }}
+
{{ ans.answer }}
+ {% endfor %} diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index fec2113359..3f0e8867f3 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -2226,6 +2226,10 @@ class OrderModifyInformation(OrderQuestionsViewMixin, OrderView): only_user_visible = False all_optional = True + @property + def order_question_container(self): + return self.order + def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['other_form'] = self.other_form diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 57b3ca7f08..7e206a9e86 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -1125,6 +1125,7 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) + ctx['order_questions_form'] = self.order_questions_form ctx['formgroups'] = self.formdict.items() ctx['contact_form'] = self.contact_form ctx['invoice_form'] = self.invoice_form diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index b25a6f556c..dfe5261385 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -41,7 +41,7 @@ from django.utils.translation import gettext_lazy as _ from phonenumber_field.formfields import PhoneNumberField from pretix.base.forms.questions import ( - BaseInvoiceAddressForm, BaseQuestionsForm, WrappedPhoneNumberPrefixWidget, + BaseInvoiceAddressForm, TicketQuestionsForm, WrappedPhoneNumberPrefixWidget, guess_phone_prefix_from_request, ) from pretix.base.templatetags.rich_text import rich_text @@ -139,7 +139,7 @@ class InvoiceNameForm(InvoiceAddressForm): del self.fields[f] -class CustomerAwareQuestionsForm(BaseQuestionsForm): +class CustomerAwareQuestionsForm(TicketQuestionsForm): """ The base class is responsible for asking order-related questions. This includes the attendee name for admission tickets, if the corresponding setting is enabled, diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html index d69c6bea44..6af8a57e38 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html @@ -68,6 +68,18 @@
{% endif %} + {% if order_questions_form %} +
+ +

+ {% trans "Additional order information" %} +

+
+
+ {% bootstrap_form order_questions_form layout="checkout" %} +
+
+ {% endif %} {% for pos, forms in formgroups %}
diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index c38220a04c..a6a85d812b 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -54,6 +54,7 @@ from pretix.base.models import ( CartPosition, Customer, InvoiceAddress, ItemAddOn, OrderFee, Question, QuestionAnswer, QuestionOption, TaxRule, ) +from pretix.base.models.orders import CheckoutSession from pretix.base.services.cart import get_fees from pretix.base.services.pricing import apply_rounding from pretix.base.templatetags.money import money_filter @@ -101,6 +102,15 @@ class CartMixin: from pretix.presale.views.cart import cart_session return cart_session(self.request) + @cached_property + def checkout_session(self): + from pretix.presale.views.cart import get_or_create_cart_id + + return CheckoutSession.objects.filter( + event=self.request.event, + cart_id=get_or_create_cart_id(self.request, create=False), + ).first() + @cached_property def cart_customer(self): if self.cart_session.get('customer_mode', 'guest') == 'login': diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index cfded12e7b..a4c20ae54b 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -806,10 +806,14 @@ class OrderInvoiceCreate(EventViewMixin, OrderDetailMixin, View): @method_decorator(xframe_options_exempt, 'dispatch') class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, TemplateView): - form_class = CustomerAwareQuestionsForm + orderposition_form_class = CustomerAwareQuestionsForm invoice_form_class = InvoiceAddressForm template_name = "pretixpresale/event/order_modify.html" + @property + def order_question_container(self): + return self.order + @cached_property def positions(self): if self.request.GET.get('generate_invoice') == 'true': @@ -940,7 +944,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem @method_decorator(xframe_options_exempt, 'dispatch') class OrderPositionModify(EventViewMixin, OrderPositionDetailMixin, OrderQuestionsViewMixin, TemplateView): - form_class = CustomerAwareQuestionsForm + orderposition_form_class = CustomerAwareQuestionsForm invoice_form_class = None template_name = "pretixpresale/event/position_modify.html" @@ -948,6 +952,10 @@ class OrderPositionModify(EventViewMixin, OrderPositionDetailMixin, OrderQuestio def invoice_form(self): return None + @property + def order_question_container(self): + return None + @cached_property def positions(self): return [p for p in super().positions if p.pk == self.position.pk or p.addon_to_id == self.position.pk] diff --git a/src/pretix/presale/views/questions.py b/src/pretix/presale/views/questions.py index e4541e22c1..a5c0411a7e 100644 --- a/src/pretix/presale/views/questions.py +++ b/src/pretix/presale/views/questions.py @@ -40,9 +40,13 @@ from pretix.presale.views import get_cart class CartQuestionsViewMixin(BaseQuestionsViewMixin): - form_class = CustomerAwareQuestionsForm + orderposition_form_class = CustomerAwareQuestionsForm only_user_visible = True + @property + def order_question_container(self): + return self.checkout_session + @cached_property def _positions_for_questions(self): cart = get_cart(self.request)