diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index df0f158ed5..df21d6044e 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,9 +637,37 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel super().__init__(*args, **kwargs) +FakeQuestion = namedtuple( + 'FakeQuestion', 'system_question label position required help_text' +) + + +def get_fake_questions(settings): + def b(s): + return s == 'True' + fq = [] + sqo = settings.system_question_order + + if b(settings.get('attendee_names_asked', 'True')): + fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), b(settings.get('attendee_names_required')), '')) + + if b(settings.get('attendee_emails_asked')): + fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), b(settings.get('attendee_emails_required')), '')) + + if b(settings.get('attendee_company_asked')): + fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), b(settings.get('attendee_company_required')), '')) + + if b(settings.get('attendee_addresses_asked')): + fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required')), '')) + fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required')), '')) + fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required')), '')) + fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required')), '')) + return fq + + class BaseQuestionsForm(forms.Form): """ - This form class is responsible for asking order-related questions. This includes + 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. """ @@ -664,384 +693,19 @@ class BaseQuestionsForm(forms.Form): 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() - 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())] - ) - 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)] - ) + self.fields['requested_valid_from'] = self.build_requested_valid_from_field(event, pos, item) add_fields = {} + if item.ask_attendee_data: + questions = questions + get_fake_questions(event.settings) - 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), - ) - 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' - } - ) - ) - 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), - ) - - 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), - ) - 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 - ).formfield( - required=self.attendee_addresses_required, - label=_('Country'), - initial=country, - widget=forms.Select(attrs={ - 'autocomplete': 'country', - 'data-trigger-address-info': 'on', - }), - ) - 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', - }), - ) - 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() - ] - ) + questions.sort(key=lambda q: q.position) 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] + if isinstance(q, FakeQuestion): + self.fields[q.system_question] = self.build_system_question_field(request, event, pos, q) else: - initial = None - tz = ZoneInfo(event.settings.timezone) - help_text = rich_text(q.help_text) - label = escape(q.question) # django-bootstrap3 calls mark_safe - required = q.required and not self.all_optional - if q.type == Question.TYPE_BOOLEAN: - if required: - # For some reason, django-bootstrap3 does not set the required attribute - # itself. - widget = forms.CheckboxInput(attrs={'required': 'required'}) - else: - widget = forms.CheckboxInput() - - if initial: - initialbool = (initial.answer == "True") - else: - initialbool = False - - field = forms.BooleanField( - label=label, required=required, - help_text=help_text, - initial=initialbool, widget=widget, - ) - elif q.type == Question.TYPE_NUMBER: - field = forms.DecimalField( - label=label, required=required, - min_value=q.valid_number_min or Decimal('0.00'), - max_value=q.valid_number_max, - help_text=help_text, - initial=initial.answer if initial else None, - ) - elif q.type == Question.TYPE_STRING: - field = forms.CharField( - label=label, required=required, - max_length=q.valid_string_length_max, - help_text=help_text, - initial=initial.answer if initial else None, - ) - elif q.type == Question.TYPE_TEXT: - field = forms.CharField( - label=label, required=required, - max_length=q.valid_string_length_max, - help_text=help_text, - widget=forms.Textarea, - initial=initial.answer if initial else None, - ) - elif q.type == Question.TYPE_COUNTRYCODE: - field = CountryField( - countries=CachedCountries, - blank=True, null=True, blank_label=' ', - ).formfield( - label=label, required=required, - help_text=help_text, - widget=forms.Select, - empty_label=' ', - initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None), - ) - elif q.type == Question.TYPE_CHOICE: - field = forms.ModelChoiceField( - queryset=q.options, - label=label, required=required, - help_text=help_text, - widget=forms.Select, - to_field_name='identifier', - empty_label='', - initial=initial.options.first() if initial else None, - ) - elif q.type == Question.TYPE_CHOICE_MULTIPLE: - field = forms.ModelMultipleChoiceField( - queryset=q.options, - label=label, required=required, - help_text=help_text, - to_field_name='identifier', - widget=QuestionCheckboxSelectMultiple, - initial=initial.options.all() if initial else None, - ) - elif q.type == Question.TYPE_FILE: - if q.valid_file_portrait: - field = PortraitImageField( - label=label, required=required, - help_text=help_text, - initial=initial.file if initial else None, - widget=PortraitImageWidget(position=pos, event=event, answer=initial, attrs={'data-portrait-photo': 'true'}), - ) - else: - field = ExtFileField( - label=label, required=required, - help_text=help_text, - initial=initial.file if initial else None, - widget=UploadedFileWidget(position=pos, event=event, answer=initial), - ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER, - max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER, - ) - elif q.type == Question.TYPE_DATE: - attrs = {} - if q.valid_date_min: - attrs['data-min'] = q.valid_date_min.isoformat() - if q.valid_date_max: - attrs['data-max'] = q.valid_date_max.isoformat() - if not help_text: - if q.valid_date_min and q.valid_date_max: - help_text = format_lazy( - _('Please enter a date between {min} and {max}.'), - min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), - max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), - ) - elif q.valid_date_min: - help_text = format_lazy( - _('Please enter a date no earlier than {min}.'), - min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), - ) - elif q.valid_date_max: - help_text = format_lazy( - _('Please enter a date no later than {max}.'), - max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), - ) - if initial and initial.answer: - try: - _initial = dateutil.parser.parse(initial.answer).date() - except dateutil.parser.ParserError: - _initial = None - else: - _initial = None - field = forms.DateField( - label=label, required=required, - help_text=help_text, - initial=_initial, - widget=DatePickerWidget(attrs), - ) - if q.valid_date_min: - field.validators.append(MinDateValidator(q.valid_date_min)) - if q.valid_date_max: - field.validators.append(MaxDateValidator(q.valid_date_max)) - elif q.type == Question.TYPE_TIME: - if initial and initial.answer: - try: - _initial = dateutil.parser.parse(initial.answer).time() - except dateutil.parser.ParserError: - _initial = None - else: - _initial = None - field = forms.TimeField( - label=label, required=required, - help_text=help_text, - initial=_initial, - widget=TimePickerWidget(without_seconds=True), - ) - elif q.type == Question.TYPE_DATETIME: - if not help_text: - if q.valid_datetime_min and q.valid_datetime_max: - help_text = format_lazy( - _('Please enter a date and time between {min} and {max}.'), - min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), - max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), - ) - elif q.valid_datetime_min: - help_text = format_lazy( - _('Please enter a date and time no earlier than {min}.'), - min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), - ) - elif q.valid_datetime_max: - help_text = format_lazy( - _('Please enter a date and time no later than {max}.'), - max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), - ) - - if initial and initial.answer: - try: - _initial = dateutil.parser.parse(initial.answer).astimezone(tz) - except dateutil.parser.ParserError: - _initial = None - else: - _initial = None - - field = SplitDateTimeField( - label=label, required=required, - help_text=help_text, - initial=_initial, - widget=SplitDateTimePickerWidget( - time_format=get_format_without_seconds('TIME_INPUT_FORMATS'), - min_date=q.valid_datetime_min, - max_date=q.valid_datetime_max - ), - ) - if q.valid_datetime_min: - field.validators.append(MinDateTimeValidator(q.valid_datetime_min)) - if q.valid_datetime_max: - field.validators.append(MaxDateTimeValidator(q.valid_datetime_max)) - elif q.type == Question.TYPE_PHONENUMBER: - if initial: - try: - initial = PhoneNumber().from_string(initial.answer) - except NumberParseException: - initial = None - - if not initial: - phone_prefix = guess_phone_prefix_from_request(request, event) - if phone_prefix: - initial = "+{}.".format(phone_prefix) - - field = PhoneNumberField( - label=label, required=required, - help_text=help_text, - # We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just - # a country code but no number as an initial value. It's a bit hacky, but should be stable for - # the future. - initial=initial, - widget=WrappedPhoneNumberPrefixWidget() - ) - field.question = q - if answers: - # Cache the answer object for later use - field.answer = answers[0] - - if q.dependency_question_id: - field.widget.attrs['data-question-dependency'] = q.dependency_question_id - field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values)) - if q.type != 'M': - field.widget.attrs['required'] = q.required and not self.all_optional - field._required = q.required and not self.all_optional - field.required = False - - 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] + self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos, q) responses = question_form_fields.send(sender=event, position=pos) data = pos.meta_info_data @@ -1066,6 +730,389 @@ class BaseQuestionsForm(forms.Form): 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.system_question + 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=qc.label, + help_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=qc.label, + help_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=qc.label, + help_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=qc.label, + help_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=qc.label, + help_text=qc.help_text, + initial=pos.zipcode, + widget=forms.TextInput(attrs={ + 'autocomplete': 'postal-code', + }), + ) + if field_name == 'city': + return forms.CharField( + required=False, + label=qc.label, + help_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=qc.label, + help_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=qc.label, + help_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 build_user_question_field(self, request, event, pos, q): + # Do we already have an answer? Provide it as the initial value + answers = [a for a in pos.answerlist if a.question_id == q.id] + if answers: + initial = answers[0] + else: + initial = None + tz = ZoneInfo(event.settings.timezone) + help_text = rich_text(q.help_text) + label = escape(q.question) # django-bootstrap3 calls mark_safe + required = q.required and not self.all_optional + if q.type == Question.TYPE_BOOLEAN: + if required: + # For some reason, django-bootstrap3 does not set the required attribute + # itself. + widget = forms.CheckboxInput(attrs={'required': 'required'}) + else: + widget = forms.CheckboxInput() + + if initial: + initialbool = (initial.answer == "True") + else: + initialbool = False + + field = forms.BooleanField( + label=label, required=required, + help_text=help_text, + initial=initialbool, widget=widget, + ) + elif q.type == Question.TYPE_NUMBER: + field = forms.DecimalField( + label=label, required=required, + min_value=q.valid_number_min or Decimal('0.00'), + max_value=q.valid_number_max, + help_text=help_text, + initial=initial.answer if initial else None, + ) + elif q.type == Question.TYPE_STRING: + field = forms.CharField( + label=label, required=required, + max_length=q.valid_string_length_max, + help_text=help_text, + initial=initial.answer if initial else None, + ) + elif q.type == Question.TYPE_TEXT: + field = forms.CharField( + label=label, required=required, + max_length=q.valid_string_length_max, + help_text=help_text, + widget=forms.Textarea, + initial=initial.answer if initial else None, + ) + elif q.type == Question.TYPE_COUNTRYCODE: + field = CountryField( + countries=CachedCountries, + blank=True, null=True, blank_label=' ', + ).formfield( + label=label, required=required, + help_text=help_text, + widget=forms.Select, + empty_label=' ', + initial=initial.answer if initial else ( + guess_country_from_request(request, event) if required else None), + ) + elif q.type == Question.TYPE_CHOICE: + field = forms.ModelChoiceField( + queryset=q.options, + label=label, required=required, + help_text=help_text, + widget=forms.Select, + to_field_name='identifier', + empty_label='', + initial=initial.options.first() if initial else None, + ) + elif q.type == Question.TYPE_CHOICE_MULTIPLE: + field = forms.ModelMultipleChoiceField( + queryset=q.options, + label=label, required=required, + help_text=help_text, + to_field_name='identifier', + widget=QuestionCheckboxSelectMultiple, + initial=initial.options.all() if initial else None, + ) + elif q.type == Question.TYPE_FILE: + if q.valid_file_portrait: + field = PortraitImageField( + label=label, required=required, + help_text=help_text, + initial=initial.file if initial else None, + widget=PortraitImageWidget(position=pos, event=event, answer=initial, + attrs={'data-portrait-photo': 'true'}), + ) + else: + field = ExtFileField( + label=label, required=required, + help_text=help_text, + initial=initial.file if initial else None, + widget=UploadedFileWidget(position=pos, event=event, answer=initial), + ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER, + max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER, + ) + elif q.type == Question.TYPE_DATE: + attrs = {} + if q.valid_date_min: + attrs['data-min'] = q.valid_date_min.isoformat() + if q.valid_date_max: + attrs['data-max'] = q.valid_date_max.isoformat() + if not help_text: + if q.valid_date_min and q.valid_date_max: + help_text = format_lazy( + _('Please enter a date between {min} and {max}.'), + min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), + max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), + ) + elif q.valid_date_min: + help_text = format_lazy( + _('Please enter a date no earlier than {min}.'), + min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), + ) + elif q.valid_date_max: + help_text = format_lazy( + _('Please enter a date no later than {max}.'), + max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), + ) + if initial and initial.answer: + try: + _initial = dateutil.parser.parse(initial.answer).date() + except dateutil.parser.ParserError: + _initial = None + else: + _initial = None + field = forms.DateField( + label=label, required=required, + help_text=help_text, + initial=_initial, + widget=DatePickerWidget(attrs), + ) + if q.valid_date_min: + field.validators.append(MinDateValidator(q.valid_date_min)) + if q.valid_date_max: + field.validators.append(MaxDateValidator(q.valid_date_max)) + elif q.type == Question.TYPE_TIME: + if initial and initial.answer: + try: + _initial = dateutil.parser.parse(initial.answer).time() + except dateutil.parser.ParserError: + _initial = None + else: + _initial = None + field = forms.TimeField( + label=label, required=required, + help_text=help_text, + initial=_initial, + widget=TimePickerWidget(without_seconds=True), + ) + elif q.type == Question.TYPE_DATETIME: + if not help_text: + if q.valid_datetime_min and q.valid_datetime_max: + help_text = format_lazy( + _('Please enter a date and time between {min} and {max}.'), + min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), + max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), + ) + elif q.valid_datetime_min: + help_text = format_lazy( + _('Please enter a date and time no earlier than {min}.'), + min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), + ) + elif q.valid_datetime_max: + help_text = format_lazy( + _('Please enter a date and time no later than {max}.'), + max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), + ) + + if initial and initial.answer: + try: + _initial = dateutil.parser.parse(initial.answer).astimezone(tz) + except dateutil.parser.ParserError: + _initial = None + else: + _initial = None + + field = SplitDateTimeField( + label=label, required=required, + help_text=help_text, + initial=_initial, + widget=SplitDateTimePickerWidget( + time_format=get_format_without_seconds('TIME_INPUT_FORMATS'), + min_date=q.valid_datetime_min, + max_date=q.valid_datetime_max + ), + ) + if q.valid_datetime_min: + field.validators.append(MinDateTimeValidator(q.valid_datetime_min)) + if q.valid_datetime_max: + field.validators.append(MaxDateTimeValidator(q.valid_datetime_max)) + elif q.type == Question.TYPE_PHONENUMBER: + if initial: + try: + initial = PhoneNumber().from_string(initial.answer) + except NumberParseException: + initial = None + + if not initial: + phone_prefix = guess_phone_prefix_from_request(request, event) + if phone_prefix: + initial = "+{}.".format(phone_prefix) + + field = PhoneNumberField( + label=label, required=required, + help_text=help_text, + # We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just + # a country code but no number as an initial value. It's a bit hacky, but should be stable for + # the future. + initial=initial, + widget=WrappedPhoneNumberPrefixWidget() + ) + field.question = q + if answers: + # Cache the answer object for later use + field.answer = answers[0] + + if q.dependency_question_id: + field.widget.attrs['data-question-dependency'] = q.dependency_question_id + field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values)) + if q.type != 'M': + field.widget.attrs['required'] = q.required and not self.all_optional + field._required = q.required and not self.all_optional + field.required = False + return field + def clean(self): from pretix.base.addressvalidation import \ validate_address # local import to prevent impact on startup time diff --git a/src/pretix/base/views/mixins.py b/src/pretix/base/views/mixins.py index f85228d8a9..c87368078c 100644 --- a/src/pretix/base/views/mixins.py +++ b/src/pretix/base/views/mixins.py @@ -56,6 +56,27 @@ class BaseQuestionsViewMixin: def question_form_kwargs(self, cr): return {} + @property + def order_question_container(self): + raise NotImplementedError() + + @cached_property + def order_questions_form(self): + checkoutsession = + kwargs = {} # self.question_form_kwargs(cr) + form = self.form_class(event=self.request.event, + prefix='order', + request=self.request, + cartpos=None, + orderpos=None, + checkoutsession=, + order=, + all_optional=self.all_optional, + data=(self.request.POST if self.request.method == 'POST' else None), + files=(self.request.FILES if self.request.method == 'POST' else None), + **kwargs) + return form + @cached_property def forms(self): """ @@ -309,7 +330,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.CONTAINER_TYPE_ORDERPOSITION) return list(self.order.positions.select_related( 'item', 'variation' ).prefetch_related( diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index ecd13c7a28..8b905aac91 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -153,11 +153,15 @@ 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.CONTAINER_TYPE_ORDERPOSITION: + self.fields['items'].queryset = self.instance.event.items.all() + self.fields['items'].required = True + else: + del self.fields['items'] 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_edit.html b/src/pretix/control/templates/pretixcontrol/items/question_edit.html index e42b084cf7..5cc04407c2 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 %} diff --git a/src/pretix/control/templates/pretixcontrol/items/questions.html b/src/pretix/control/templates/pretixcontrol/items/questions.html index ff5924517a..475c1c4436 100644 --- a/src/pretix/control/templates/pretixcontrol/items/questions.html +++ b/src/pretix/control/templates/pretixcontrol/items/questions.html @@ -12,10 +12,15 @@ {% csrf_token %} {% if 'event.items:write' in request.eventpermset %}

- {% trans "Create a new question" %} + {% 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 possible multiple times in the same order." %}

@@ -32,8 +37,8 @@ - - {% for q in questions %} + + {% for q in questions %}{% if q.container_type == "P" %} - {% endfor %} + {% endif %}{% endfor %} + +
@@ -102,7 +107,86 @@ {% endif %}
+
+ +

{% trans "Per-order questions" %}

+

{% 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" %}
+ + {% if q.pk %} + + {% endif %} + {{ q.question }} + {% if q.pk %} + + {% endif %} +
+ {{ 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 %} +
diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index a899800f1e..8a0cea48e9 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -426,7 +426,7 @@ def reorder_categories(request, organizer, event): FakeQuestion = namedtuple( - 'FakeQuestion', 'id question position required' + 'FakeQuestion', 'id question position required container_type' ) @@ -442,83 +442,64 @@ class QuestionList(ListView): ctx = super().get_context_data(**kwargs) questions = [] + sys_order = self.request.event.settings.system_question_order + 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, - ) - ) + questions.append(FakeQuestion( + id='attendee_name_parts', + question=_('Attendee name'), + position=sys_order.get('attendee_name_parts', 0), + required=self.request.event.settings.attendee_names_required, + container_type=Question.CONTAINER_TYPE_ORDERPOSITION, + )) 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, - ) - ) + questions.append(FakeQuestion( + id='attendee_email', + question=_('Attendee email'), + position=sys_order.get('attendee_email', 0), + required=self.request.event.settings.attendee_emails_required, + container_type=Question.CONTAINER_TYPE_ORDERPOSITION, + )) 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, - ) - ) + questions.append(FakeQuestion( + id='company', + question=_('Company'), + position=sys_order.get('company', 0), + required=self.request.event.settings.attendee_company_required, + container_type=Question.CONTAINER_TYPE_ORDERPOSITION, + )) 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.append(FakeQuestion( + id='street', + question=_('Street'), + position=sys_order.get('street', 0), + required=self.request.event.settings.attendee_addresses_required, + container_type=Question.CONTAINER_TYPE_ORDERPOSITION, + )) + questions.append(FakeQuestion( + id='zipcode', + question=_('ZIP code'), + position=sys_order.get('zipcode', 0), + required=self.request.event.settings.attendee_addresses_required, + container_type=Question.CONTAINER_TYPE_ORDERPOSITION, + )) + questions.append(FakeQuestion( + id='city', + question=_('City'), + position=sys_order.get('city', 0), + required=self.request.event.settings.attendee_addresses_required, + container_type=Question.CONTAINER_TYPE_ORDERPOSITION, + )) + questions.append(FakeQuestion( + id='country', + question=_('Country'), + position=sys_order.get('country', 0), + required=self.request.event.settings.attendee_addresses_required, + container_type=Question.CONTAINER_TYPE_ORDERPOSITION, + )) questions += list(ctx['questions']) questions.sort(key=lambda q: q.position) @@ -535,14 +516,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 +539,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.CONTAINER_TYPE_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 +789,7 @@ 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.CONTAINER_TYPE_ORDERPOSITION) return kwargs def get_success_url(self) -> str: diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 41a77a7686..57b3ca7f08 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" diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index 20740fadc9..b25a6f556c 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -139,11 +139,14 @@ class InvoiceNameForm(InvoiceAddressForm): del self.fields[f] -class QuestionsForm(BaseQuestionsForm): +class CustomerAwareQuestionsForm(BaseQuestionsForm): """ - 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/views/__init__.py b/src/pretix/presale/views/__init__.py index 46822dd46b..c38220a04c 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -398,7 +398,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.CONTAINER_TYPE_ORDERPOSITION) request._cart_cache = CartPosition.objects.filter( cart_id=cart_id, event=request.event ).annotate( diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index c131644631..cfded12e7b 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -90,7 +90,7 @@ 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 InvoiceAddressForm, CustomerAwareQuestionsForm 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 @@ -806,7 +806,7 @@ class OrderInvoiceCreate(EventViewMixin, OrderDetailMixin, View): @method_decorator(xframe_options_exempt, 'dispatch') class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, TemplateView): - form_class = QuestionsForm + form_class = CustomerAwareQuestionsForm invoice_form_class = InvoiceAddressForm template_name = "pretixpresale/event/order_modify.html" @@ -940,7 +940,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem @method_decorator(xframe_options_exempt, 'dispatch') class OrderPositionModify(EventViewMixin, OrderPositionDetailMixin, OrderQuestionsViewMixin, TemplateView): - form_class = QuestionsForm + form_class = CustomerAwareQuestionsForm invoice_form_class = None template_name = "pretixpresale/event/position_modify.html" diff --git a/src/pretix/presale/views/questions.py b/src/pretix/presale/views/questions.py index fad52d0b6f..e4541e22c1 100644 --- a/src/pretix/presale/views/questions.py +++ b/src/pretix/presale/views/questions.py @@ -35,12 +35,12 @@ 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): + form_class = CustomerAwareQuestionsForm only_user_visible = True @cached_property