mirror of
https://github.com/pretix/pretix.git
synced 2026-08-27 13:24:41 +00:00
wip
This commit is contained in:
+420
-373
@@ -36,6 +36,7 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from collections import namedtuple
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -636,9 +637,37 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
|
|||||||
super().__init__(*args, **kwargs)
|
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):
|
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,
|
the attendee name for admission tickets, if the corresponding setting is enabled,
|
||||||
as well as additional questions defined by the organizer.
|
as well as additional questions defined by the organizer.
|
||||||
"""
|
"""
|
||||||
@@ -664,384 +693,19 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
|
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
|
||||||
if item.validity_dynamic_start_choice_day_limit:
|
self.fields['requested_valid_from'] = self.build_requested_valid_from_field(event, pos, item)
|
||||||
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)]
|
|
||||||
)
|
|
||||||
|
|
||||||
add_fields = {}
|
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:
|
questions.sort(key=lambda q: q.position)
|
||||||
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()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
for q in questions:
|
for q in questions:
|
||||||
# Do we already have an answer? Provide it as the initial value
|
if isinstance(q, FakeQuestion):
|
||||||
answers = [a for a in pos.answerlist if a.question_id == q.id]
|
self.fields[q.system_question] = self.build_system_question_field(request, event, pos, q)
|
||||||
if answers:
|
|
||||||
initial = answers[0]
|
|
||||||
else:
|
else:
|
||||||
initial = None
|
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos, q)
|
||||||
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]
|
|
||||||
|
|
||||||
responses = question_form_fields.send(sender=event, position=pos)
|
responses = question_form_fields.send(sender=event, position=pos)
|
||||||
data = pos.meta_info_data
|
data = pos.meta_info_data
|
||||||
@@ -1066,6 +730,389 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
else:
|
else:
|
||||||
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
|
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):
|
def clean(self):
|
||||||
from pretix.base.addressvalidation import \
|
from pretix.base.addressvalidation import \
|
||||||
validate_address # local import to prevent impact on startup time
|
validate_address # local import to prevent impact on startup time
|
||||||
|
|||||||
@@ -56,6 +56,27 @@ class BaseQuestionsViewMixin:
|
|||||||
def question_form_kwargs(self, cr):
|
def question_form_kwargs(self, cr):
|
||||||
return {}
|
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
|
@cached_property
|
||||||
def forms(self):
|
def forms(self):
|
||||||
"""
|
"""
|
||||||
@@ -309,7 +330,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
|||||||
def positions(self):
|
def positions(self):
|
||||||
qqs = self.request.event.questions.all()
|
qqs = self.request.event.questions.all()
|
||||||
if self.only_user_visible:
|
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(
|
return list(self.order.positions.select_related(
|
||||||
'item', 'variation'
|
'item', 'variation'
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
|
|||||||
@@ -153,11 +153,15 @@ class QuestionForm(I18nModelForm):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['items'].queryset = self.instance.event.items.all()
|
if self.instance.container_type == Question.CONTAINER_TYPE_ORDERPOSITION:
|
||||||
self.fields['items'].required = True
|
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(
|
self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
|
||||||
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
|
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:
|
if self.instance.pk:
|
||||||
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
|
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
<legend>{% trans "General" %}</legend>
|
<legend>{% trans "General" %}</legend>
|
||||||
{% bootstrap_field form.question layout="control" %}
|
{% bootstrap_field form.question layout="control" %}
|
||||||
{% bootstrap_field form.type 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" %}
|
{% bootstrap_field form.required layout="control" %}
|
||||||
<div class="alert alert-info alert-required-boolean">
|
<div class="alert alert-info alert-required-boolean">
|
||||||
{% blocktrans trimmed %}
|
{% blocktrans trimmed %}
|
||||||
|
|||||||
@@ -12,10 +12,15 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if 'event.items:write' in request.eventpermset %}
|
{% if 'event.items:write' in request.eventpermset %}
|
||||||
<p>
|
<p>
|
||||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
|
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket question" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level question" %}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>{% trans "Per-ticket questions" %}</h2>
|
||||||
|
<p>{% trans "These questions are asked for every ticket, so possible multiple times in the same order." %}</p>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover table-quotas">
|
<table class="table table-hover table-quotas">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -32,8 +37,8 @@
|
|||||||
<th class="action-col-2"></th>
|
<th class="action-col-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
|
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P">
|
||||||
{% for q in questions %}
|
{% for q in questions %}{% if q.container_type == "P" %}
|
||||||
<tr data-dnd-id="{{ q.id }}">
|
<tr data-dnd-id="{{ q.id }}">
|
||||||
<td>
|
<td>
|
||||||
<strong>
|
<strong>
|
||||||
@@ -102,7 +107,86 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endif %}{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{% trans "Per-order questions" %}</h2>
|
||||||
|
<p>{% trans "These questions are asked once per order." %}</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-quotas">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Question" %}</th>
|
||||||
|
<th>{% trans "Type" %}</th>
|
||||||
|
<th class="iconcol"></th>
|
||||||
|
<th class="iconcol"></th>
|
||||||
|
<th class="iconcol"></th>
|
||||||
|
{% if 'event.items:write' in request.eventpermset %}
|
||||||
|
<th class="action-col-2"></th>
|
||||||
|
{% endif %}
|
||||||
|
<th class="action-col-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O">
|
||||||
|
{% for q in questions %}{% if q.container_type == "O" %}
|
||||||
|
<tr data-dnd-id="{{ q.id }}">
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
{% if q.pk %}
|
||||||
|
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
|
||||||
|
{% endif %}
|
||||||
|
{{ q.question }}
|
||||||
|
{% if q.pk %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</strong><br>
|
||||||
|
<small class="text-muted">{{ q.identifier }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if q.pk %}
|
||||||
|
{{ q.get_type_display }}
|
||||||
|
{% else %}
|
||||||
|
{% trans "System question" %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if q.required %}
|
||||||
|
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if q.pk and q.ask_during_checkin %}
|
||||||
|
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if q.pk and q.hidden %}
|
||||||
|
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% if 'event.items:write' in request.eventpermset %}
|
||||||
|
<td class="dnd-container">
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
<td class="text-right flip">
|
||||||
|
{% if q.pk %}
|
||||||
|
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
|
||||||
|
{% if 'event.items:write' in request.eventpermset %}
|
||||||
|
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||||
|
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if 'event.settings.general:write' in request.eventpermset %}
|
||||||
|
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
|
||||||
|
class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ def reorder_categories(request, organizer, event):
|
|||||||
|
|
||||||
|
|
||||||
FakeQuestion = namedtuple(
|
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)
|
ctx = super().get_context_data(**kwargs)
|
||||||
questions = []
|
questions = []
|
||||||
|
|
||||||
|
sys_order = self.request.event.settings.system_question_order
|
||||||
|
|
||||||
if self.request.event.settings.attendee_names_asked:
|
if self.request.event.settings.attendee_names_asked:
|
||||||
questions.append(
|
questions.append(FakeQuestion(
|
||||||
FakeQuestion(
|
id='attendee_name_parts',
|
||||||
id='attendee_name_parts',
|
question=_('Attendee name'),
|
||||||
question=_('Attendee name'),
|
position=sys_order.get('attendee_name_parts', 0),
|
||||||
position=self.request.event.settings.system_question_order.get(
|
required=self.request.event.settings.attendee_names_required,
|
||||||
'attendee_name_parts', 0
|
container_type=Question.CONTAINER_TYPE_ORDERPOSITION,
|
||||||
),
|
))
|
||||||
required=self.request.event.settings.attendee_names_required,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.request.event.settings.attendee_emails_asked:
|
if self.request.event.settings.attendee_emails_asked:
|
||||||
questions.append(
|
questions.append(FakeQuestion(
|
||||||
FakeQuestion(
|
id='attendee_email',
|
||||||
id='attendee_email',
|
question=_('Attendee email'),
|
||||||
question=_('Attendee email'),
|
position=sys_order.get('attendee_email', 0),
|
||||||
position=self.request.event.settings.system_question_order.get(
|
required=self.request.event.settings.attendee_emails_required,
|
||||||
'attendee_email', 0
|
container_type=Question.CONTAINER_TYPE_ORDERPOSITION,
|
||||||
),
|
))
|
||||||
required=self.request.event.settings.attendee_emails_required,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.request.event.settings.attendee_company_asked:
|
if self.request.event.settings.attendee_company_asked:
|
||||||
questions.append(
|
questions.append(FakeQuestion(
|
||||||
FakeQuestion(
|
id='company',
|
||||||
id='company',
|
question=_('Company'),
|
||||||
question=_('Company'),
|
position=sys_order.get('company', 0),
|
||||||
position=self.request.event.settings.system_question_order.get(
|
required=self.request.event.settings.attendee_company_required,
|
||||||
'company', 0
|
container_type=Question.CONTAINER_TYPE_ORDERPOSITION,
|
||||||
),
|
))
|
||||||
required=self.request.event.settings.attendee_company_required,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.request.event.settings.attendee_addresses_asked:
|
if self.request.event.settings.attendee_addresses_asked:
|
||||||
questions.append(
|
questions.append(FakeQuestion(
|
||||||
FakeQuestion(
|
id='street',
|
||||||
id='street',
|
question=_('Street'),
|
||||||
question=_('Street'),
|
position=sys_order.get('street', 0),
|
||||||
position=self.request.event.settings.system_question_order.get(
|
required=self.request.event.settings.attendee_addresses_required,
|
||||||
'street', 0
|
container_type=Question.CONTAINER_TYPE_ORDERPOSITION,
|
||||||
),
|
))
|
||||||
required=self.request.event.settings.attendee_addresses_required,
|
questions.append(FakeQuestion(
|
||||||
)
|
id='zipcode',
|
||||||
)
|
question=_('ZIP code'),
|
||||||
questions.append(
|
position=sys_order.get('zipcode', 0),
|
||||||
FakeQuestion(
|
required=self.request.event.settings.attendee_addresses_required,
|
||||||
id='zipcode',
|
container_type=Question.CONTAINER_TYPE_ORDERPOSITION,
|
||||||
question=_('ZIP code'),
|
))
|
||||||
position=self.request.event.settings.system_question_order.get(
|
questions.append(FakeQuestion(
|
||||||
'zipcode', 0
|
id='city',
|
||||||
),
|
question=_('City'),
|
||||||
required=self.request.event.settings.attendee_addresses_required,
|
position=sys_order.get('city', 0),
|
||||||
)
|
required=self.request.event.settings.attendee_addresses_required,
|
||||||
)
|
container_type=Question.CONTAINER_TYPE_ORDERPOSITION,
|
||||||
questions.append(
|
))
|
||||||
FakeQuestion(
|
questions.append(FakeQuestion(
|
||||||
id='city',
|
id='country',
|
||||||
question=_('City'),
|
question=_('Country'),
|
||||||
position=self.request.event.settings.system_question_order.get(
|
position=sys_order.get('country', 0),
|
||||||
'city', 0
|
required=self.request.event.settings.attendee_addresses_required,
|
||||||
),
|
container_type=Question.CONTAINER_TYPE_ORDERPOSITION,
|
||||||
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 += list(ctx['questions'])
|
questions += list(ctx['questions'])
|
||||||
questions.sort(key=lambda q: q.position)
|
questions.sort(key=lambda q: q.position)
|
||||||
@@ -535,14 +516,16 @@ def reorder_questions(request, organizer, event):
|
|||||||
except (JSONDecodeError, KeyError, ValueError):
|
except (JSONDecodeError, KeyError, ValueError):
|
||||||
return HttpResponseBadRequest("expected JSON: {ids:[]}")
|
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
|
# filter system_questions - normal questions are int/digit, system_questions strings
|
||||||
custom_question_ids = [i for i in ids if i.isdigit()]
|
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):
|
if len(input_questions) != len(custom_question_ids):
|
||||||
raise Http404(_("Some of the provided object ids are invalid."))
|
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."))
|
raise Http404(_("Not all objects have been selected."))
|
||||||
|
|
||||||
for q in input_questions:
|
for q in input_questions:
|
||||||
@@ -556,18 +539,19 @@ def reorder_questions(request, organizer, event):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
system_question_order = {}
|
if request.GET['container_type'] == Question.CONTAINER_TYPE_ORDERPOSITION:
|
||||||
for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'):
|
system_question_order = {}
|
||||||
if s in ids:
|
for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'):
|
||||||
system_question_order[s] = ids.index(s)
|
if s in ids:
|
||||||
else:
|
system_question_order[s] = ids.index(s)
|
||||||
system_question_order[s] = -1
|
else:
|
||||||
request.event.settings.system_question_order = system_question_order
|
system_question_order[s] = -1
|
||||||
request.event.log_action(
|
request.event.settings.system_question_order = system_question_order
|
||||||
'pretix.event.settings', user=request.user, data={
|
request.event.log_action(
|
||||||
'system_question_order': system_question_order,
|
'pretix.event.settings', user=request.user, data={
|
||||||
}
|
'system_question_order': system_question_order,
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return HttpResponse()
|
return HttpResponse()
|
||||||
|
|
||||||
@@ -805,6 +789,7 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
|
|||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
kwargs['instance'] = Question(event=self.request.event)
|
kwargs['instance'] = Question(event=self.request.event)
|
||||||
|
kwargs['instance'].container_type = self.request.GET.get('container_type', Question.CONTAINER_TYPE_ORDERPOSITION)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_success_url(self) -> str:
|
def get_success_url(self) -> str:
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ from pretix.presale.views.cart import (
|
|||||||
_items_from_post_data, cart_session, create_empty_cart_id,
|
_items_from_post_data, cart_session, create_empty_cart_id,
|
||||||
get_or_create_cart_id,
|
get_or_create_cart_id,
|
||||||
)
|
)
|
||||||
from pretix.presale.views.questions import QuestionsViewMixin
|
from pretix.presale.views.questions import CartQuestionsViewMixin
|
||||||
|
|
||||||
|
|
||||||
class BaseCheckoutFlowStep:
|
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))
|
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
|
priority = 50
|
||||||
identifier = "questions"
|
identifier = "questions"
|
||||||
template_name = "pretixpresale/event/checkout_questions.html"
|
template_name = "pretixpresale/event/checkout_questions.html"
|
||||||
|
|||||||
@@ -139,11 +139,14 @@ class InvoiceNameForm(InvoiceAddressForm):
|
|||||||
del self.fields[f]
|
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,
|
the attendee name for admission tickets, if the corresponding setting is enabled,
|
||||||
as well as additional questions defined by the organizer.
|
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'
|
required_css_class = 'required'
|
||||||
address_validation = True
|
address_validation = True
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ def get_cart(request):
|
|||||||
request._cart_cache = CartPosition.objects.none()
|
request._cart_cache = CartPosition.objects.none()
|
||||||
else:
|
else:
|
||||||
qqs = request.event.questions.all()
|
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(
|
request._cart_cache = CartPosition.objects.filter(
|
||||||
cart_id=cart_id, event=request.event
|
cart_id=cart_id, event=request.event
|
||||||
).annotate(
|
).annotate(
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ from pretix.base.views.tasks import AsyncAction
|
|||||||
from pretix.helpers.http import redirect_to_url
|
from pretix.helpers.http import redirect_to_url
|
||||||
from pretix.helpers.safedownload import check_token
|
from pretix.helpers.safedownload import check_token
|
||||||
from pretix.multidomain.urlreverse import eventreverse, eventreverse_absolute
|
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.forms.order import OrderPositionChangeForm
|
||||||
from pretix.presale.productlist import prepare_item_list_for_shop
|
from pretix.presale.productlist import prepare_item_list_for_shop
|
||||||
from pretix.presale.signals import question_form_fields_overrides
|
from pretix.presale.signals import question_form_fields_overrides
|
||||||
@@ -806,7 +806,7 @@ class OrderInvoiceCreate(EventViewMixin, OrderDetailMixin, View):
|
|||||||
|
|
||||||
@method_decorator(xframe_options_exempt, 'dispatch')
|
@method_decorator(xframe_options_exempt, 'dispatch')
|
||||||
class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, TemplateView):
|
class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, TemplateView):
|
||||||
form_class = QuestionsForm
|
form_class = CustomerAwareQuestionsForm
|
||||||
invoice_form_class = InvoiceAddressForm
|
invoice_form_class = InvoiceAddressForm
|
||||||
template_name = "pretixpresale/event/order_modify.html"
|
template_name = "pretixpresale/event/order_modify.html"
|
||||||
|
|
||||||
@@ -940,7 +940,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem
|
|||||||
|
|
||||||
@method_decorator(xframe_options_exempt, 'dispatch')
|
@method_decorator(xframe_options_exempt, 'dispatch')
|
||||||
class OrderPositionModify(EventViewMixin, OrderPositionDetailMixin, OrderQuestionsViewMixin, TemplateView):
|
class OrderPositionModify(EventViewMixin, OrderPositionDetailMixin, OrderQuestionsViewMixin, TemplateView):
|
||||||
form_class = QuestionsForm
|
form_class = CustomerAwareQuestionsForm
|
||||||
invoice_form_class = None
|
invoice_form_class = None
|
||||||
template_name = "pretixpresale/event/position_modify.html"
|
template_name = "pretixpresale/event/position_modify.html"
|
||||||
|
|
||||||
|
|||||||
@@ -35,12 +35,12 @@
|
|||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
from pretix.base.views.mixins import BaseQuestionsViewMixin
|
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
|
from pretix.presale.views import get_cart
|
||||||
|
|
||||||
|
|
||||||
class QuestionsViewMixin(BaseQuestionsViewMixin):
|
class CartQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||||
form_class = QuestionsForm
|
form_class = CustomerAwareQuestionsForm
|
||||||
only_user_visible = True
|
only_user_visible = True
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
|
|||||||
Reference in New Issue
Block a user