From f8cc31b120fd11a0047fe90e5a74c7b14affa289 Mon Sep 17 00:00:00 2001 From: luelista Date: Fri, 14 Aug 2026 14:56:45 +0200 Subject: [PATCH] Order-level questions (#6471) * New CheckoutSession model, created and deleted throught cart lifetime but only used for order-level question answers so far * Order-level QuestionAnswers (relations to CheckoutSession / Order) * New container_type field on Question model to specify whether Question belongs to order or orderposition * Order-level questions are currently experimental, UI is hidden behind feature flag --------- Co-authored-by: Richard Schreiber --- src/pretix/api/serializers/item.py | 2 +- src/pretix/api/views/item.py | 5 +- src/pretix/base/forms/questions.py | 932 ++++++++++-------- src/pretix/base/forms/widgets.py | 27 +- ...session_invoiceaddress_checkout_session.py | 63 ++ ...questionanswer_unique_together_and_more.py | 33 + src/pretix/base/models/customers.py | 12 + src/pretix/base/models/items.py | 9 + src/pretix/base/models/orders.py | 96 +- src/pretix/base/services/cart.py | 13 +- src/pretix/base/services/checkin.py | 1 + src/pretix/base/services/cleanup.py | 5 + src/pretix/base/services/orders.py | 22 +- src/pretix/base/views/mixins.py | 184 ++-- src/pretix/control/forms/item.py | 14 +- .../pretixcontrol/items/question_delete.html | 8 +- .../pretixcontrol/items/question_edit.html | 16 +- .../pretixcontrol/items/questions.html | 114 ++- .../pretixcontrol/order/change_questions.html | 12 + .../order/fragment_question_answer.html | 28 + .../templates/pretixcontrol/order/index.html | 34 +- src/pretix/control/views/item.py | 116 +-- src/pretix/control/views/orders.py | 12 +- src/pretix/helpers/safedownload.py | 8 +- src/pretix/presale/checkoutflow.py | 7 +- src/pretix/presale/forms/checkout.py | 11 +- .../pretixpresale/event/checkout_confirm.html | 20 + .../event/checkout_questions.html | 12 + .../pretixpresale/event/fragment_cart.html | 22 +- .../event/fragment_question_answer.html | 24 + .../templates/pretixpresale/event/order.html | 10 + .../pretixpresale/event/order_modify.html | 12 + src/pretix/presale/views/__init__.py | 12 +- src/pretix/presale/views/cart.py | 12 +- src/pretix/presale/views/order.py | 24 +- src/pretix/presale/views/questions.py | 10 +- .../static/pretixcontrol/js/ui/question.js | 3 +- 37 files changed, 1229 insertions(+), 716 deletions(-) create mode 100644 src/pretix/base/migrations/0308_checkoutsession_invoiceaddress_checkout_session.py create mode 100644 src/pretix/base/migrations/0309_alter_questionanswer_unique_together_and_more.py create mode 100644 src/pretix/control/templates/pretixcontrol/order/fragment_question_answer.html create mode 100644 src/pretix/presale/templates/pretixpresale/event/fragment_question_answer.html 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 %}

- {% for item in dependent %} -
  • {{ item }}
  • - {% endfor %} + {% endif %}
    diff --git a/src/pretix/control/templates/pretixcontrol/items/question_edit.html b/src/pretix/control/templates/pretixcontrol/items/question_edit.html index e42b084cf7..d241be5651 100644 --- a/src/pretix/control/templates/pretixcontrol/items/question_edit.html +++ b/src/pretix/control/templates/pretixcontrol/items/question_edit.html @@ -24,7 +24,9 @@ {% trans "General" %} {% bootstrap_field form.question layout="control" %} {% bootstrap_field form.type layout="control" %} - {% bootstrap_field form.items layout="control" %} + {% if form.items %} + {% bootstrap_field form.items layout="control" %} + {% endif %} {% bootstrap_field form.required layout="control" %}
    {% blocktrans trimmed %} @@ -128,10 +130,16 @@ {% trans "Advanced" %} {% bootstrap_field form.help_text layout="control" %} {% bootstrap_field form.identifier layout="control" %} - {% bootstrap_field form.ask_during_checkin layout="control" %} - {% bootstrap_field form.show_during_checkin layout="control" %} + {% if form.ask_during_checkin %} + {% bootstrap_field form.ask_during_checkin layout="control" %} + {% endif %} + {% if form.show_during_checkin %} + {% bootstrap_field form.show_during_checkin layout="control" %} + {% endif %} {% bootstrap_field form.hidden layout="control" %} - {% bootstrap_field form.print_on_invoice layout="control" %} + {% if form.print_on_invoice %} + {% bootstrap_field form.print_on_invoice layout="control" %} + {% endif %}

    - {% trans "Create a new question" %} - -

    + + {% if request.event.settings.feature_flag_order_level_questions %} + {% if 'event.items:write' in request.eventpermset %} +

    + {% trans "Create a new per-ticket question" %} + + {% trans "Create a new order-level question" %} + +

    + {% endif %} + +

    {% trans "Per-ticket questions" %}

    +

    {% trans "These questions are asked for every ticket, so possibly multiple times in the same order." %}

    + {% else %} + {% if 'event.items:write' in request.eventpermset %} +

    + {% trans "Create a new question" %} + +

    + {% endif %} {% endif %}
    @@ -32,8 +47,8 @@ - - {% for q in questions %} + + {% for q in questions %}{% if q.container_type == "P" %} - {% endfor %} + {% endif %}{% endfor %}
    @@ -63,7 +78,6 @@ {% if q.pk and q.ask_during_checkin %} {% endif %} - {% if q.pk and q.hidden %} @@ -102,8 +116,90 @@ {% endif %}
    + + {% if request.event.settings.feature_flag_order_level_questions %} +

    + {% trans "Per-order questions" %} + + + {% trans "Experimental feature" %} + +

    +

    {% trans "These questions are asked once per order." %}

    +
    + + + + + + + + + {% if 'event.items:write' in request.eventpermset %} + + {% endif %} + + + + + {% for q in questions %}{% if q.container_type == "O" %} + + + + + + + {% if 'event.items:write' in request.eventpermset %} + + {% endif %} + + + {% endif %}{% endfor %} + +
    {% trans "Question" %}{% trans "Type" %}
    + + {{ q.question }} +
    + {{ q.identifier }} +
    + {% if q.pk %} + {{ q.get_type_display }} + {% else %} + {% trans "System question" %} + {% endif %} + + {% if q.required %} + + {% endif %} + + {% if q.pk and q.ask_during_checkin %} + + {% endif %} + + {% if q.pk and q.hidden %} + + {% endif %} + + + {% if q.pk %} + {% if 'event.items:write' in request.eventpermset %} + + + {% endif %} + {% else %} + {% if 'event.settings.general:write' in request.eventpermset %} + + {% endif %} + {% endif %} +
    +
    + {% endif %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/order/change_questions.html b/src/pretix/control/templates/pretixcontrol/order/change_questions.html index 62feb089e3..25436f5a97 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.fields %} +
    + +

    + {% 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/fragment_question_answer.html b/src/pretix/control/templates/pretixcontrol/order/fragment_question_answer.html new file mode 100644 index 0000000000..bd8641452b --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/order/fragment_question_answer.html @@ -0,0 +1,28 @@ +{% load safelink %} +{% load i18n %} +{% load rich_text %} +{% if answer %} + {% if answer.file %} + + + {{ answer.file_name }} + + + {% trans "UNSAFE" %} + + {% if answer.is_image %} +
    + + + + {% endif %} + {% elif question.type == "M" %} + {{ answer.to_string_i18n|rich_text_snippet }} + {% else %} + {{ answer.to_string_i18n|linebreaksbr }} + {% endif %} +{% else %} + {% trans "not answered" %} +{% endif %} \ No newline at end of file diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 88276398e3..94eb286b1c 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 }}
    +
    {% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=ans.question answer=ans %}
    + {% endfor %}
    @@ -664,31 +672,7 @@ {% endif %}
    - {% if q.answer %} - {% if q.answer.file %} - - - {{ q.answer.file_name }} - - - {% trans "UNSAFE" %} - - {% if q.answer.is_image %} -
    - - - - {% endif %} - {% elif q.type == "M" %} - {{ q.answer.to_string_i18n|rich_text_snippet }} - {% else %} - {{ q.answer.to_string_i18n|linebreaksbr }} - {% endif %} - {% else %} - {% trans "not answered" %} - {% endif %} + {% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=q answer=q.answer %}
    {% endfor %} {% for q in line.additional_fields %} diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index a899800f1e..5103564b37 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -65,6 +65,7 @@ from pretix.api.serializers.item import ( ItemVariationSerializer, ) from pretix.base.forms import I18nFormSet +from pretix.base.forms.questions import get_fake_attendee_questions from pretix.base.models import ( CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry, OrderPosition, Question, QuestionAnswer, QuestionOption, Quota, @@ -426,7 +427,7 @@ def reorder_categories(request, organizer, event): FakeQuestion = namedtuple( - 'FakeQuestion', 'id question position required' + 'FakeQuestion', 'id question position required container_type' ) @@ -440,85 +441,8 @@ class QuestionList(ListView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - questions = [] - if self.request.event.settings.attendee_names_asked: - questions.append( - FakeQuestion( - id='attendee_name_parts', - question=_('Attendee name'), - position=self.request.event.settings.system_question_order.get( - 'attendee_name_parts', 0 - ), - required=self.request.event.settings.attendee_names_required, - ) - ) - - if self.request.event.settings.attendee_emails_asked: - questions.append( - FakeQuestion( - id='attendee_email', - question=_('Attendee email'), - position=self.request.event.settings.system_question_order.get( - 'attendee_email', 0 - ), - required=self.request.event.settings.attendee_emails_required, - ) - ) - - if self.request.event.settings.attendee_company_asked: - questions.append( - FakeQuestion( - id='company', - question=_('Company'), - position=self.request.event.settings.system_question_order.get( - 'company', 0 - ), - required=self.request.event.settings.attendee_company_required, - ) - ) - - if self.request.event.settings.attendee_addresses_asked: - questions.append( - FakeQuestion( - id='street', - question=_('Street'), - position=self.request.event.settings.system_question_order.get( - 'street', 0 - ), - required=self.request.event.settings.attendee_addresses_required, - ) - ) - questions.append( - FakeQuestion( - id='zipcode', - question=_('ZIP code'), - position=self.request.event.settings.system_question_order.get( - 'zipcode', 0 - ), - required=self.request.event.settings.attendee_addresses_required, - ) - ) - questions.append( - FakeQuestion( - id='city', - question=_('City'), - position=self.request.event.settings.system_question_order.get( - 'city', 0 - ), - required=self.request.event.settings.attendee_addresses_required, - ) - ) - questions.append( - FakeQuestion( - id='country', - question=_('Country'), - position=self.request.event.settings.system_question_order.get( - 'country', 0 - ), - required=self.request.event.settings.attendee_addresses_required, - ) - ) + questions = get_fake_attendee_questions(self.request.event.settings) questions += list(ctx['questions']) questions.sort(key=lambda q: q.position) @@ -535,14 +459,16 @@ def reorder_questions(request, organizer, event): except (JSONDecodeError, KeyError, ValueError): return HttpResponseBadRequest("expected JSON: {ids:[]}") + qs = request.event.questions.filter(container_type=request.GET['container_type']) + # filter system_questions - normal questions are int/digit, system_questions strings custom_question_ids = [i for i in ids if i.isdigit()] - input_questions = list(request.event.questions.filter(id__in=custom_question_ids)) + input_questions = list(qs.filter(id__in=custom_question_ids)) if len(input_questions) != len(custom_question_ids): raise Http404(_("Some of the provided object ids are invalid.")) - if len(input_questions) != request.event.questions.count(): + if len(input_questions) != qs.count(): raise Http404(_("Not all objects have been selected.")) for q in input_questions: @@ -556,18 +482,19 @@ def reorder_questions(request, organizer, event): } ) - system_question_order = {} - for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'): - if s in ids: - system_question_order[s] = ids.index(s) - else: - system_question_order[s] = -1 - request.event.settings.system_question_order = system_question_order - request.event.log_action( - 'pretix.event.settings', user=request.user, data={ - 'system_question_order': system_question_order, - } - ) + if request.GET['container_type'] == Question.ContainerType.ORDERPOSITION: + system_question_order = {} + for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'): + if s in ids: + system_question_order[s] = ids.index(s) + else: + system_question_order[s] = -1 + request.event.settings.system_question_order = system_question_order + request.event.log_action( + 'pretix.event.settings', user=request.user, data={ + 'system_question_order': system_question_order, + } + ) return HttpResponse() @@ -805,6 +732,9 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs['instance'] = Question(event=self.request.event) + kwargs['instance'].container_type = self.request.GET.get('container_type', Question.ContainerType.ORDERPOSITION) + if kwargs['instance'].container_type not in Question.ContainerType.values: + raise PermissionDenied return kwargs def get_success_url(self) -> str: diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index fec2113359..86c4d5a48d 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 @@ -2587,7 +2591,11 @@ class AnswerDownload(EventPermissionRequiredMixin, OrderViewMixin, ListView): answid = kwargs.get('answer') token = request.GET.get('token', '') - answer = get_object_or_404(QuestionAnswer, orderposition__order=self.order, id=answid) + answer = get_object_or_404( + QuestionAnswer, + Q(orderposition__order=self.order) | Q(order=self.order), + id=answid + ) if not answer.file: raise Http404() if not check_token(request, answer, token): @@ -2597,7 +2605,7 @@ class AnswerDownload(EventPermissionRequiredMixin, OrderViewMixin, ListView): resp = FileResponse(answer.file, content_type=ftype or 'application/binary') resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format( self.request.event.slug.upper(), self.order.code, - answer.orderposition.positionid, + answer.orderposition.positionid if answer.orderposition else '', os.path.basename(answer.file.name).split('.', 1)[1] ) return resp diff --git a/src/pretix/helpers/safedownload.py b/src/pretix/helpers/safedownload.py index 87d1f56a3a..f6362e97b0 100644 --- a/src/pretix/helpers/safedownload.py +++ b/src/pretix/helpers/safedownload.py @@ -24,17 +24,21 @@ import hashlib from django.core.signing import BadSignature, TimestampSigner +class SafeDownloadSigner(TimestampSigner): + pass + + def get_token(request, answer): if not request.session.session_key: request.session.create() payload = '{}:{}'.format(request.session.session_key, answer.pk) - signer = TimestampSigner() + signer = SafeDownloadSigner() return signer.sign(hashlib.sha1(payload.encode()).hexdigest()) def check_token(request, answer, token): payload = hashlib.sha1('{}:{}'.format(request.session.session_key, answer.pk).encode()).hexdigest() - signer = TimestampSigner() + signer = SafeDownloadSigner() try: return payload == signer.unsign(token, max_age=3600 * 24) except BadSignature: diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index b6db5dcd97..0507932a53 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -100,7 +100,7 @@ from pretix.presale.views.cart import ( _items_from_post_data, cart_session, create_empty_cart_id, get_or_create_cart_id, ) -from pretix.presale.views.questions import QuestionsViewMixin +from pretix.presale.views.questions import CartQuestionsViewMixin class BaseCheckoutFlowStep: @@ -772,7 +772,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None)) -class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): +class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep): priority = 50 identifier = "questions" template_name = "pretixpresale/event/checkout_questions.html" @@ -1125,6 +1125,7 @@ class QuestionsStep(QuestionsViewMixin, 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 @@ -1563,6 +1564,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): ctx['addr'] = self.invoice_address ctx['confirm_messages'] = self.confirm_messages ctx['cart_session'] = self.cart_session + ctx['checkout_session'] = self.checkout_session ctx['invoice_address_asked'] = self.address_asked ctx['customer'] = self.cart_customer @@ -1660,6 +1662,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): customer=self.cart_session.get('customer'), override_now_dt=time_machine_now(default=None), api_meta=api_meta, + cart_id=get_or_create_cart_id(request), ) def get_success_message(self, value): diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index 20740fadc9..1f1a2169c4 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -41,8 +41,8 @@ from django.utils.translation import gettext_lazy as _ from phonenumber_field.formfields import PhoneNumberField from pretix.base.forms.questions import ( - BaseInvoiceAddressForm, BaseQuestionsForm, WrappedPhoneNumberPrefixWidget, - guess_phone_prefix_from_request, + BaseInvoiceAddressForm, TicketLevelQuestionsForm, + WrappedPhoneNumberPrefixWidget, guess_phone_prefix_from_request, ) from pretix.base.templatetags.rich_text import rich_text from pretix.base.validators import EmailBanlistValidator @@ -139,11 +139,14 @@ class InvoiceNameForm(InvoiceAddressForm): del self.fields[f] -class QuestionsForm(BaseQuestionsForm): +class CustomerAwareQuestionsForm(TicketLevelQuestionsForm): """ - This form class is responsible for asking order-related questions. This includes + The base 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 class adds support for pre-filling data like name and address from a + customer profile, in case the user is logged-in with a customer account. """ required_css_class = 'required' address_validation = True diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html index b6b7024bcb..effc073e30 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html @@ -162,6 +162,26 @@ {% endfor %} + {% if checkout_session.answers.all %} +
    +
    +

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

    +
    +
    +
    + {% for ans in checkout_session.answers.all %} +
    {{ ans.question.question }}
    +
    {% include "pretixpresale/event/fragment_question_answer.html" with request=request question=ans.question answer=ans %}
    + {% endfor %} +
    +
    +
    + {% endif %} {% if confirm_messages %} diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html index d69c6bea44..4af019301e 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.fields %} +
    + +

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

    +
    +
    + {% bootstrap_form order_questions_form layout="checkout" %} +
    +
    + {% endif %} {% for pos, forms in formgroups %}
    diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 92f388a521..cdffab8282 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -206,27 +206,7 @@ {% for q in line.questions %}
    {{ q.question }}
    - {% if q.answer %} - {% if q.answer.file %} - - - {{ q.answer.file_name }} - - {% if q.answer.is_image %} -
    - - {% trans - - {% endif %} - {% elif q.type == "M" %} - {{ q.answer|rich_text_snippet }} - {% else %} - {{ q.answer|linebreaksbr }} - {% endif %} - {% else %} - {% trans "not answered" %} - {% endif %} + {% include "pretixpresale/event/fragment_question_answer.html" with request=request question=q answer=q.answer %}
    {% endfor %} {% for q in line.additional_answers %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_question_answer.html b/src/pretix/presale/templates/pretixpresale/event/fragment_question_answer.html new file mode 100644 index 0000000000..bb4e0dfd36 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_question_answer.html @@ -0,0 +1,24 @@ +{% load safelink %} +{% load i18n %} +{% load rich_text %} +{% if answer %} + {% if answer.file %} + + + {{ answer.file_name }} + + {% if answer.is_image %} +
    + + {% trans + + {% endif %} + {% elif question.type == "M" %} + {{ answer|rich_text_snippet }} + {% else %} + {{ answer|linebreaksbr }} + {% endif %} +{% else %} + {% trans "not answered" %} +{% endif %} \ No newline at end of file diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index 8c68e4eced..2ee4b89a5b 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -346,6 +346,16 @@ {% endfor %} {% endif %} + + {% if order.answers.all %} +
    +
    + {% for ans in order.answers.all %} +
    {{ ans.question.question }}
    +
    {% include "pretixpresale/event/fragment_question_answer.html" with request=request question=ans.question answer=ans %}
    + {% endfor %} +
    + {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order_modify.html b/src/pretix/presale/templates/pretixpresale/event/order_modify.html index 6685e56f6e..0f6eda02a6 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order_modify.html +++ b/src/pretix/presale/templates/pretixpresale/event/order_modify.html @@ -48,6 +48,18 @@
    {% endif %} + {% if order_questions_form.fields %} +
    + +

    + {% 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 46822dd46b..0b2c355df3 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': @@ -398,7 +408,7 @@ def get_cart(request): request._cart_cache = CartPosition.objects.none() else: qqs = request.event.questions.all() - qqs = qqs.filter(ask_during_checkin=False, hidden=False) + qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION) request._cart_cache = CartPosition.objects.filter( cart_id=cart_id, event=request.event ).annotate( diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index ab84b8401a..6285c67fc1 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -67,6 +67,7 @@ from pretix.base.services.cart import ( from pretix.base.timemachine import time_machine_now from pretix.base.views.tasks import AsyncAction from pretix.helpers.http import redirect_to_url +from pretix.helpers.safedownload import check_token from pretix.multidomain.urlreverse import eventreverse from pretix.presale.productlist import ( item_group_by_category, prepare_item_list_for_shop, @@ -388,6 +389,7 @@ def get_or_create_cart_id(request, create=True): if 'carts' in request.session: request.session['carts'][current_id] = {} else: + # We found a valid, existing cart. return current_id cart_data = {} @@ -398,6 +400,7 @@ def get_or_create_cart_id(request, create=True): cart_data['widget_data'] = cached_widget_data else: if not create: + # There is no existing cart for this request and we're not supposed to create a new one. return None new_id = generate_cart_id(request, prefix=prefix) @@ -846,15 +849,19 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, CartMixin, TemplateView class AnswerDownload(EventViewMixin, View): def get(self, request, *args, **kwargs): answid = kwargs.get('answer') + token = request.GET.get('token', '') + cart_id = get_or_create_cart_id(self.request) answer = get_object_or_404( QuestionAnswer, - cartposition__cart_id=get_or_create_cart_id(self.request), + Q(cartposition__cart_id=cart_id) | Q(checkoutsession__cart_id=cart_id), id=answid ) if not answer.file: return Http404() + if not check_token(request, answer, token): + raise Http404(_("This link is no longer valid. Please go back, refresh the page, and try again.")) - ftype, _ = mimetypes.guess_type(answer.file.name) + ftype, _1 = mimetypes.guess_type(answer.file.name) filename = '{}-cart-{}'.format( self.request.event.slug.upper(), os.path.basename(answer.file.name).split('.', 1)[1] @@ -862,6 +869,7 @@ class AnswerDownload(EventViewMixin, View): resp = FileResponse( answer.file, filename=filename, + as_attachment=True, content_type=ftype or 'application/binary' ) return resp diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index dc130502ed..b160b035da 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -90,7 +90,9 @@ from pretix.base.views.tasks import AsyncAction from pretix.helpers.http import redirect_to_url from pretix.helpers.safedownload import check_token from pretix.multidomain.urlreverse import eventreverse, eventreverse_absolute -from pretix.presale.forms.checkout import InvoiceAddressForm, QuestionsForm +from pretix.presale.forms.checkout import ( + CustomerAwareQuestionsForm, InvoiceAddressForm, +) from pretix.presale.forms.order import OrderPositionChangeForm from pretix.presale.productlist import prepare_item_list_for_shop from pretix.presale.signals import question_form_fields_overrides @@ -807,10 +809,14 @@ class OrderInvoiceCreate(EventViewMixin, OrderDetailMixin, View): @method_decorator(xframe_options_exempt, 'dispatch') class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, TemplateView): - form_class = QuestionsForm + 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': @@ -941,7 +947,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem @method_decorator(xframe_options_exempt, 'dispatch') class OrderPositionModify(EventViewMixin, OrderPositionDetailMixin, OrderQuestionsViewMixin, TemplateView): - form_class = QuestionsForm + orderposition_form_class = CustomerAwareQuestionsForm invoice_form_class = None template_name = "pretixpresale/event/position_modify.html" @@ -949,6 +955,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] @@ -1145,7 +1155,11 @@ class AnswerDownload(EventViewMixin, OrderDetailMixin, View): answid = kwargs.get('answer') token = request.GET.get('token', '') - answer = get_object_or_404(QuestionAnswer, orderposition__order=self.order, id=answid) + answer = get_object_or_404( + QuestionAnswer, + Q(orderposition__order=self.order) | Q(order=self.order), + id=answid, + ) if not answer.file: raise Http404() if not check_token(request, answer, token): @@ -1155,7 +1169,7 @@ class AnswerDownload(EventViewMixin, OrderDetailMixin, View): resp = FileResponse(answer.file, content_type=ftype or 'application/binary') resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format( self.request.event.slug.upper(), self.order.code, - answer.orderposition.positionid, + answer.orderposition.positionid if answer.orderposition else '', os.path.basename(answer.file.name).split('.', 1)[1] ) return resp diff --git a/src/pretix/presale/views/questions.py b/src/pretix/presale/views/questions.py index fad52d0b6f..a5c0411a7e 100644 --- a/src/pretix/presale/views/questions.py +++ b/src/pretix/presale/views/questions.py @@ -35,14 +35,18 @@ from django.utils.functional import cached_property from pretix.base.views.mixins import BaseQuestionsViewMixin -from pretix.presale.forms.checkout import QuestionsForm +from pretix.presale.forms.checkout import CustomerAwareQuestionsForm from pretix.presale.views import get_cart -class QuestionsViewMixin(BaseQuestionsViewMixin): - form_class = QuestionsForm +class CartQuestionsViewMixin(BaseQuestionsViewMixin): + 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) diff --git a/src/pretix/static/pretixcontrol/js/ui/question.js b/src/pretix/static/pretixcontrol/js/ui/question.js index 99dff95d5f..be1c8d114e 100644 --- a/src/pretix/static/pretixcontrol/js/ui/question.js +++ b/src/pretix/static/pretixcontrol/js/ui/question.js @@ -131,7 +131,8 @@ $(function () { $val.parent().append("
    "); - apiGET('/api/v1/organizers/' + $("body").attr("data-organizer") + '/events/' + $("body").attr("data-event") + '/questions/' + val + '/', function (data) { + // the container_type parameter is undocumented. this API is going to change in a later release. + apiGET('/api/v1/organizers/' + $("body").attr("data-organizer") + '/events/' + $("body").attr("data-event") + '/questions/' + val + '/?container_type=' + encodeURIComponent($dq.data('container-type')), function (data) { if (data.type === "B") { $val.append($("