diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py new file mode 100644 index 0000000000..1b427cce58 --- /dev/null +++ b/src/pretix/base/forms/questions.py @@ -0,0 +1,236 @@ +import logging +from decimal import Decimal + +import dateutil.parser +import pytz +import vat_moss.errors +import vat_moss.id +from django import forms +from django.contrib import messages +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.forms.widgets import ( + BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget, + TimePickerWidget, UploadedFileWidget, +) +from pretix.base.models import InvoiceAddress, Question +from pretix.base.models.tax import EU_COUNTRIES +from pretix.helpers.i18n import get_format_without_seconds +from pretix.presale.signals import question_form_fields + +logger = logging.getLogger(__name__) + + +class BaseQuestionsForm(forms.Form): + """ + This form class is responsible for asking order-related questions. This includes + the attendee name for admission tickets, if the corresponding setting is enabled, + as well as additional questions defined by the organizer. + """ + + def __init__(self, *args, **kwargs): + """ + Takes two additional keyword arguments: + + :param cartpos: The cart position the form should be for + :param event: The event this belongs to + """ + cartpos = self.cartpos = kwargs.pop('cartpos', None) + orderpos = self.orderpos = kwargs.pop('orderpos', None) + pos = cartpos or orderpos + item = pos.item + questions = pos.item.questions_to_ask + event = kwargs.pop('event') + + super().__init__(*args, **kwargs) + + if item.admission and event.settings.attendee_names_asked: + self.fields['attendee_name'] = forms.CharField( + max_length=255, required=event.settings.attendee_names_required, + label=_('Attendee name'), + initial=(cartpos.attendee_name if cartpos else orderpos.attendee_name), + ) + if item.admission and event.settings.attendee_emails_asked: + self.fields['attendee_email'] = forms.EmailField( + required=event.settings.attendee_emails_required, + label=_('Attendee email'), + initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email) + ) + + 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] + else: + initial = None + tz = pytz.timezone(event.settings.timezone) + if q.type == Question.TYPE_BOOLEAN: + if q.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=q.question, required=q.required, + help_text=q.help_text, + initial=initialbool, widget=widget, + ) + elif q.type == Question.TYPE_NUMBER: + field = forms.DecimalField( + label=q.question, required=q.required, + help_text=q.help_text, + initial=initial.answer if initial else None, + min_value=Decimal('0.00'), + ) + elif q.type == Question.TYPE_STRING: + field = forms.CharField( + label=q.question, required=q.required, + help_text=q.help_text, + initial=initial.answer if initial else None, + ) + elif q.type == Question.TYPE_TEXT: + field = forms.CharField( + label=q.question, required=q.required, + help_text=q.help_text, + widget=forms.Textarea, + initial=initial.answer if initial else None, + ) + elif q.type == Question.TYPE_CHOICE: + field = forms.ModelChoiceField( + queryset=q.options, + label=q.question, required=q.required, + help_text=q.help_text, + widget=forms.Select, + empty_label='', + initial=initial.options.first() if initial else None, + ) + elif q.type == Question.TYPE_CHOICE_MULTIPLE: + field = forms.ModelMultipleChoiceField( + queryset=q.options, + label=q.question, required=q.required, + help_text=q.help_text, + widget=forms.CheckboxSelectMultiple, + initial=initial.options.all() if initial else None, + ) + elif q.type == Question.TYPE_FILE: + field = forms.FileField( + label=q.question, required=q.required, + help_text=q.help_text, + initial=initial.file if initial else None, + widget=UploadedFileWidget(position=pos, event=event, answer=initial), + ) + elif q.type == Question.TYPE_DATE: + field = forms.DateField( + label=q.question, required=q.required, + help_text=q.help_text, + initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None, + widget=DatePickerWidget(), + ) + elif q.type == Question.TYPE_TIME: + field = forms.TimeField( + label=q.question, required=q.required, + help_text=q.help_text, + initial=dateutil.parser.parse(initial.answer).astimezone(tz).time() if initial and initial.answer else None, + widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')), + ) + elif q.type == Question.TYPE_DATETIME: + field = forms.SplitDateTimeField( + label=q.question, required=q.required, + help_text=q.help_text, + initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None, + widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')), + ) + field.question = q + if answers: + # Cache the answer object for later use + field.answer = answers[0] + self.fields['question_%s' % q.id] = field + + responses = question_form_fields.send(sender=event, position=pos) + data = pos.meta_info_data + for r, response in sorted(responses, key=lambda r: str(r[0])): + for key, value in response.items(): + # We need to be this explicit, since OrderedDict.update does not retain ordering + self.fields[key] = value + value.initial = data.get('question_form_data', {}).get(key) + + +class BaseInvoiceAddressForm(forms.ModelForm): + vat_warning = False + + class Meta: + model = InvoiceAddress + fields = ('is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id', + 'internal_reference') + widgets = { + 'is_business': BusinessBooleanRadio, + 'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}), + 'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}), + 'name': forms.TextInput(attrs={}), + 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}), + 'internal_reference': forms.TextInput, + } + labels = { + 'is_business': '' + } + + def __init__(self, *args, **kwargs): + self.event = event = kwargs.pop('event') + self.request = kwargs.pop('request', None) + self.validate_vat_id = kwargs.pop('validate_vat_id') + super().__init__(*args, **kwargs) + if not event.settings.invoice_address_vatid: + del self.fields['vat_id'] + if not event.settings.invoice_address_required: + for k, f in self.fields.items(): + f.required = False + f.widget.is_required = False + if 'required' in f.widget.attrs: + del f.widget.attrs['required'] + + if event.settings.invoice_name_required: + self.fields['name'].required = True + else: + self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1' + self.fields['name'].widget.attrs['data-required-if'] = '#id_is_business_0' + + def clean(self): + data = self.cleaned_data + if not data.get('name') and not data.get('company') and self.event.settings.invoice_address_required: + raise ValidationError(_('You need to provide either a company name or your name.')) + + if 'vat_id' in self.changed_data or not data.get('vat_id'): + self.instance.vat_id_validated = False + + if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data: + pass + elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'): + if data.get('vat_id')[:2] != str(data.get('country')): + raise ValidationError(_('Your VAT ID does not match the selected country.')) + try: + result = vat_moss.id.validate(data.get('vat_id')) + if result: + country_code, normalized_id, company_name = result + self.instance.vat_id_validated = True + self.instance.vat_id = normalized_id + except vat_moss.errors.InvalidError: + raise ValidationError(_('This VAT ID is not valid. Please re-check your input.')) + except vat_moss.errors.WebServiceUnavailableError: + logger.exception('VAT ID checking failed for country {}'.format(data.get('country'))) + self.instance.vat_id_validated = False + if self.request and self.vat_warning: + messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of ' + 'your country is currently not available. We will therefore ' + 'need to charge VAT on your invoice. You can get the tax amount ' + 'back via the VAT reimbursement process.')) + else: + self.instance.vat_id_validated = False diff --git a/src/pretix/base/forms/widgets.py b/src/pretix/base/forms/widgets.py new file mode 100644 index 0000000000..003c2a5dec --- /dev/null +++ b/src/pretix/base/forms/widgets.py @@ -0,0 +1,135 @@ +import os + +from django import forms +from django.utils.formats import get_format +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.models import OrderPosition +from pretix.multidomain.urlreverse import eventreverse + + +class DatePickerWidget(forms.DateInput): + def __init__(self, attrs=None, date_format=None): + attrs = attrs or {} + if 'placeholder' in attrs: + del attrs['placeholder'] + date_attrs = dict(attrs) + date_attrs.setdefault('class', 'form-control') + date_attrs['class'] += ' datepickerfield' + + df = date_format or get_format('DATE_INPUT_FORMATS')[0] + date_attrs['placeholder'] = now().replace( + year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0 + ).strftime(df) + + forms.DateInput.__init__(self, date_attrs, date_format) + + +class TimePickerWidget(forms.TimeInput): + def __init__(self, attrs=None, time_format=None): + attrs = attrs or {} + if 'placeholder' in attrs: + del attrs['placeholder'] + time_attrs = dict(attrs) + time_attrs.setdefault('class', 'form-control') + time_attrs['class'] += ' timepickerfield' + + tf = time_format or get_format('TIME_INPUT_FORMATS')[0] + time_attrs['placeholder'] = now().replace( + year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0 + ).strftime(tf) + + forms.TimeInput.__init__(self, time_attrs, time_format) + + +class UploadedFileWidget(forms.ClearableFileInput): + def __init__(self, *args, **kwargs): + self.position = kwargs.pop('position') + self.event = kwargs.pop('event') + self.answer = kwargs.pop('answer') + super().__init__(*args, **kwargs) + + class FakeFile: + def __init__(self, file, position, event, answer): + self.file = file + self.position = position + self.event = event + self.answer = answer + + def __str__(self): + return os.path.basename(self.file.name).split('.', 1)[-1] + + @property + def url(self): + if isinstance(self.position, OrderPosition): + return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={ + 'order': self.position.order.code, + 'secret': self.position.order.secret, + 'answer': self.answer.pk, + }) + else: + return eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={ + 'answer': self.answer.pk, + }) + + def format_value(self, value): + if self.is_initial(value): + return self.FakeFile(value, self.position, self.event, self.answer) + + +class SplitDateTimePickerWidget(forms.SplitDateTimeWidget): + template_name = 'pretixbase/forms/widgets/splitdatetime.html' + + def __init__(self, attrs=None, date_format=None, time_format=None): + attrs = attrs or {} + if 'placeholder' in attrs: + del attrs['placeholder'] + date_attrs = dict(attrs) + time_attrs = dict(attrs) + date_attrs.setdefault('class', 'form-control splitdatetimepart') + time_attrs.setdefault('class', 'form-control splitdatetimepart') + date_attrs['class'] += ' datepickerfield' + time_attrs['class'] += ' timepickerfield' + + df = date_format or get_format('DATE_INPUT_FORMATS')[0] + date_attrs['placeholder'] = now().replace( + year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0 + ).strftime(df) + tf = time_format or get_format('TIME_INPUT_FORMATS')[0] + time_attrs['placeholder'] = now().replace( + year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0 + ).strftime(tf) + + widgets = ( + forms.DateInput(attrs=date_attrs, format=date_format), + forms.TimeInput(attrs=time_attrs, format=time_format), + ) + # Skip one hierarchy level + forms.MultiWidget.__init__(self, widgets, attrs) + + +class BusinessBooleanRadio(forms.RadioSelect): + def __init__(self, attrs=None): + choices = ( + ('individual', _('Individual customer')), + ('business', _('Business customer')), + ) + super().__init__(attrs, choices) + + def format_value(self, value): + try: + return {True: 'business', False: 'individual'}[value] + except KeyError: + return 'individual' + + def value_from_datadict(self, data, files, name): + value = data.get(name) + return { + 'business': True, + True: True, + 'True': True, + 'individual': False, + 'False': False, + False: False, + }.get(value) diff --git a/src/pretix/base/views/mixins.py b/src/pretix/base/views/mixins.py new file mode 100644 index 0000000000..1c592b19aa --- /dev/null +++ b/src/pretix/base/views/mixins.py @@ -0,0 +1,194 @@ +import json +from collections import OrderedDict + +from django import forms +from django.core.files.uploadedfile import UploadedFile +from django.db.models import Prefetch +from django.utils.functional import cached_property + +from pretix.base.forms.questions import ( + BaseInvoiceAddressForm, BaseQuestionsForm, +) +from pretix.base.models import ( + CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer, + QuestionOption, +) + + +class BaseQuestionsViewMixin: + form_class = BaseQuestionsForm + + @staticmethod + def _keyfunc(pos): + # Sort addons after the item they are an addon to + if isinstance(pos, OrderPosition): + i = pos.addon_to.positionid if pos.addon_to else pos.positionid + else: + i = pos.addon_to.pk if pos.addon_to else pos.pk + addon_penalty = 1 if pos.addon_to else 0 + return i, addon_penalty, pos.pk + + @cached_property + def _positions_for_questions(self): + raise NotImplementedError() + + @cached_property + def forms(self): + """ + A list of forms with one form for each cart position that has questions + the user can answer. All forms have a custom prefix, so that they can all be + submitted at once. + """ + formlist = [] + for cr in self._positions_for_questions: + cartpos = cr if isinstance(cr, CartPosition) else None + orderpos = cr if isinstance(cr, OrderPosition) else None + form = self.form_class(event=self.request.event, + prefix=cr.id, + cartpos=cartpos, + orderpos=orderpos, + data=(self.request.POST if self.request.method == 'POST' else None), + files=(self.request.FILES if self.request.method == 'POST' else None)) + form.pos = cartpos or orderpos + if len(form.fields) > 0: + formlist.append(form) + return formlist + + @cached_property + def formdict(self): + storage = OrderedDict() + for f in self.forms: + pos = f.cartpos or f.orderpos + if pos.addon_to_id: + if pos.addon_to not in storage: + storage[pos.addon_to] = [] + storage[pos.addon_to].append(f) + else: + if pos not in storage: + storage[pos] = [] + storage[pos].append(f) + return storage + + def save(self): + failed = False + for form in self.forms: + meta_info = form.pos.meta_info_data + # Every form represents a CartPosition or OrderPosition with questions attached + if not form.is_valid(): + failed = True + else: + # This form was correctly filled, so we store the data as + # answers to the questions / in the CartPosition object + for k, v in form.cleaned_data.items(): + if k == 'attendee_name': + form.pos.attendee_name = v if v != '' else None + form.pos.save() + elif k == 'attendee_email': + form.pos.attendee_email = v if v != '' else None + form.pos.save() + elif k.startswith('question_') and v is not None: + field = form.fields[k] + if hasattr(field, 'answer'): + # We already have a cached answer object, so we don't + # have to create a new one + if v == '' or v is None or (isinstance(field, forms.FileField) and v is False): + if field.answer.file: + field.answer.file.delete() + field.answer.delete() + else: + self._save_to_answer(field, field.answer, v) + field.answer.save() + elif v != '': + answer = QuestionAnswer( + cartposition=(form.pos if isinstance(form.pos, CartPosition) else None), + orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None), + question=field.question, + ) + self._save_to_answer(field, answer, v) + answer.save() + else: + meta_info.setdefault('question_form_data', {}) + if v is None: + if k in meta_info['question_form_data']: + del meta_info['question_form_data'][k] + else: + meta_info['question_form_data'][k] = v + + form.pos.meta_info = json.dumps(meta_info) + form.pos.save(update_fields=['meta_info']) + return not failed + + def _save_to_answer(self, field, answer, value): + if isinstance(field, forms.ModelMultipleChoiceField): + answstr = ", ".join([str(o) for o in value]) + if not answer.pk: + answer.save() + else: + answer.options.clear() + answer.answer = answstr + answer.options.add(*value) + elif isinstance(field, forms.ModelChoiceField): + if not answer.pk: + answer.save() + else: + answer.options.clear() + answer.options.add(value) + answer.answer = value.answer + elif isinstance(field, forms.FileField): + if isinstance(value, UploadedFile): + answer.file.save(value.name, value) + answer.answer = 'file://' + value.name + else: + answer.answer = value + + +class OrderQuestionsViewMixin(BaseQuestionsViewMixin): + invoice_form_class = BaseInvoiceAddressForm + + @cached_property + def _positions_for_questions(self): + return self.positions + + @cached_property + def positions(self): + return list(self.order.positions.select_related( + 'item', 'variation' + ).prefetch_related( + Prefetch('answers', + QuestionAnswer.objects.prefetch_related('options'), + to_attr='answerlist'), + Prefetch('item__questions', + Question.objects.filter(ask_during_checkin=False).prefetch_related( + Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch( + # This prefetch statement is utter bullshit, but it actually prevents Django from doing + # a lot of queries since ModelChoiceIterator stops trying to be clever once we have + # a prefetch lookup on this query... + 'question', + Question.objects.none(), + to_attr='dummy' + ))) + ), + to_attr='questions_to_ask') + )) + + @cached_property + def invoice_address(self): + try: + return self.order.invoice_address + except InvoiceAddress.DoesNotExist: + return InvoiceAddress(order=self.order) + + @cached_property + def invoice_form(self): + return self.invoice_form_class( + data=self.request.POST if self.request.method == "POST" else None, + event=self.request.event, + instance=self.invoice_address, validate_vat_id=False + ) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['order'] = self.order + ctx['formgroups'] = self.formdict.items() + ctx['invoice_form'] = self.invoice_form + return ctx diff --git a/src/pretix/control/context.py b/src/pretix/control/context.py index d38eb72499..2c64969932 100644 --- a/src/pretix/control/context.py +++ b/src/pretix/control/context.py @@ -6,8 +6,8 @@ from django.core.urlresolvers import Resolver404, get_script_prefix, resolve from pretix.base.settings import GlobalSettingsObject +from ..helpers.i18n import get_javascript_format, get_moment_locale from .signals import html_head, nav_event, nav_global, nav_topbar -from .utils.i18n import get_javascript_format, get_moment_locale SessionStore = import_module(settings.SESSION_ENGINE).SessionStore diff --git a/src/pretix/control/forms/__init__.py b/src/pretix/control/forms/__init__.py index 43c2deeda1..f2565b34c8 100644 --- a/src/pretix/control/forms/__init__.py +++ b/src/pretix/control/forms/__init__.py @@ -1,12 +1,14 @@ import os from django import forms -from django.utils.formats import get_format from django.utils.html import conditional_escape -from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from ...base.forms import I18nModelForm +# Import for backwards compatibility with okd import paths +from ...base.forms.widgets import ( # noqa + DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget, +) class TolerantFormsetModelForm(I18nModelForm): @@ -100,70 +102,3 @@ class SlugWidget(forms.TextInput): ctx = super().get_context(name, value, attrs) ctx['pre'] = self.prefix return ctx - - -class SplitDateTimePickerWidget(forms.SplitDateTimeWidget): - template_name = 'pretixbase/forms/widgets/splitdatetime.html' - - def __init__(self, attrs=None, date_format=None, time_format=None): - attrs = attrs or {} - if 'placeholder' in attrs: - del attrs['placeholder'] - date_attrs = dict(attrs) - time_attrs = dict(attrs) - date_attrs.setdefault('class', 'form-control splitdatetimepart') - time_attrs.setdefault('class', 'form-control splitdatetimepart') - date_attrs['class'] += ' datepickerfield' - time_attrs['class'] += ' timepickerfield' - - df = date_format or get_format('DATE_INPUT_FORMATS')[0] - date_attrs['placeholder'] = now().replace( - year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0 - ).strftime(df) - tf = time_format or get_format('TIME_INPUT_FORMATS')[0] - time_attrs['placeholder'] = now().replace( - year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0 - ).strftime(tf) - - widgets = ( - forms.DateInput(attrs=date_attrs, format=date_format), - forms.TimeInput(attrs=time_attrs, format=time_format), - ) - # Skip one hierarchy level - forms.MultiWidget.__init__(self, widgets, attrs) - - -class DatePickerWidget(forms.DateInput): - - def __init__(self, attrs=None, date_format=None): - attrs = attrs or {} - if 'placeholder' in attrs: - del attrs['placeholder'] - date_attrs = dict(attrs) - date_attrs.setdefault('class', 'form-control') - date_attrs['class'] += ' datepickerfield' - - df = date_format or get_format('DATE_INPUT_FORMATS')[0] - date_attrs['placeholder'] = now().replace( - year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0 - ).strftime(df) - - forms.DateInput.__init__(self, date_attrs, date_format) - - -class TimePickerWidget(forms.TimeInput): - - def __init__(self, attrs=None, time_format=None): - attrs = attrs or {} - if 'placeholder' in attrs: - del attrs['placeholder'] - time_attrs = dict(attrs) - time_attrs.setdefault('class', 'form-control') - time_attrs['class'] += ' timepickerfield' - - tf = time_format or get_format('TIME_INPUT_FORMATS')[0] - time_attrs['placeholder'] = now().replace( - year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0 - ).strftime(tf) - - forms.TimeInput.__init__(self, time_attrs, time_format) diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 54010fb77e..1805755f06 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -9,8 +9,8 @@ from pretix.base.models import ( Event, Invoice, Item, Order, OrderPosition, Organizer, SubEvent, ) from pretix.base.signals import register_payment_providers -from pretix.control.utils.i18n import i18ncomp from pretix.helpers.database import FixedOrderBy, rolledback_transaction +from pretix.helpers.i18n import i18ncomp PAYMENT_PROVIDERS = [] diff --git a/src/pretix/control/templates/pretixcontrol/order/change_questions.html b/src/pretix/control/templates/pretixcontrol/order/change_questions.html new file mode 100644 index 0000000000..25c7f57813 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/order/change_questions.html @@ -0,0 +1,78 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %} + {% trans "Change contact information" %} +{% endblock %} +{% block content %} +
[0-9A-Z]+)/change$', orders.OrderChange.as_view(),
name='event.order.change'),
+ url(r'^orders/(?P[0-9A-Z]+)/info', orders.OrderModifyInformation.as_view(),
+ name='event.order.info'),
url(r'^orders/(?P[0-9A-Z]+)/sendmail$', orders.OrderSendMail.as_view(),
name='event.order.sendmail'),
url(r'^orders/(?P[0-9A-Z]+)/mail_history$', orders.OrderEmailHistory.as_view(),
diff --git a/src/pretix/control/utils/__init__.py b/src/pretix/control/utils/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py
index 57948a4c80..3cdbc031e2 100644
--- a/src/pretix/control/views/orders.py
+++ b/src/pretix/control/views/orders.py
@@ -43,6 +43,7 @@ from pretix.base.services.orders import (
from pretix.base.services.stats import order_overview
from pretix.base.signals import register_data_exporters
from pretix.base.views.async import AsyncAction
+from pretix.base.views.mixins import OrderQuestionsViewMixin
from pretix.control.forms.filter import EventOrderFilterForm
from pretix.control.forms.orders import (
CommentForm, ExporterForm, ExtendForm, OrderContactForm, OrderLocaleForm,
@@ -625,6 +626,28 @@ class OrderChange(OrderView):
return self.get(*args, **kwargs)
+class OrderModifyInformation(OrderQuestionsViewMixin, OrderView):
+ permission = 'can_change_orders'
+ template_name = 'pretixcontrol/order/change_questions.html'
+
+ def post(self, request, *args, **kwargs):
+ failed = not self.save() or not self.invoice_form.is_valid()
+ if failed:
+ messages.error(self.request,
+ _("We had difficulties processing your input. Please review the errors below."))
+ return self.get(request, *args, **kwargs)
+ self.invoice_form.save()
+ self.order.log_action('pretix.event.order.modified', user=request.user)
+ if self.invoice_form.has_changed():
+ success_message = ('The invoice address has been updated. If you want to generate a new invoice, '
+ 'you need to do this manually.')
+ messages.success(self.request, _(success_message))
+
+ CachedTicket.objects.filter(order_position__order=self.order).delete()
+ CachedCombinedTicket.objects.filter(order=self.order).delete()
+ return redirect(self.get_order_url())
+
+
class OrderContactChange(OrderView):
permission = 'can_change_orders'
template_name = 'pretixcontrol/order/change_contact.html'
diff --git a/src/pretix/control/views/typeahead.py b/src/pretix/control/views/typeahead.py
index 8265dc0f80..fc313c82f4 100644
--- a/src/pretix/control/views/typeahead.py
+++ b/src/pretix/control/views/typeahead.py
@@ -5,8 +5,8 @@ from django.http import JsonResponse
from django.urls import reverse
from django.utils.translation import ugettext as _
-from pretix.control.utils.i18n import i18ncomp
from pretix.helpers.daterange import daterange
+from pretix.helpers.i18n import i18ncomp
def event_list(request):
diff --git a/src/pretix/control/utils/i18n.py b/src/pretix/helpers/i18n.py
similarity index 100%
rename from src/pretix/control/utils/i18n.py
rename to src/pretix/helpers/i18n.py
diff --git a/src/pretix/presale/context.py b/src/pretix/presale/context.py
index f922d969d3..c71bd5017e 100644
--- a/src/pretix/presale/context.py
+++ b/src/pretix/presale/context.py
@@ -4,7 +4,7 @@ from django.utils.translation import get_language_info
from i18nfield.strings import LazyI18nString
from pretix.base.settings import GlobalSettingsObject
-from pretix.control.utils.i18n import (
+from pretix.helpers.i18n import (
get_javascript_format_without_seconds, get_moment_locale,
)
diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py
index 5e0bc3b61f..b121b01a39 100644
--- a/src/pretix/presale/forms/checkout.py
+++ b/src/pretix/presale/forms/checkout.py
@@ -1,14 +1,6 @@
-import logging
-import os
-from decimal import Decimal
from itertools import chain
-import dateutil
-import pytz
-import vat_moss.errors
-import vat_moss.id
from django import forms
-from django.contrib import messages
from django.core.exceptions import ValidationError
from django.db.models import Count, Prefetch, Q
from django.utils.encoding import force_text
@@ -16,18 +8,13 @@ from django.utils.formats import number_format
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
-from pretix.base.models import ItemVariation, Question
-from pretix.base.models.orders import InvoiceAddress, OrderPosition
-from pretix.base.models.tax import EU_COUNTRIES, TAXED_ZERO
-from pretix.base.templatetags.rich_text import rich_text
-from pretix.control.forms import (
- DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget,
+from pretix.base.forms.questions import (
+ BaseInvoiceAddressForm, BaseQuestionsForm,
)
-from pretix.control.utils.i18n import get_format_without_seconds
-from pretix.multidomain.urlreverse import eventreverse
-from pretix.presale.signals import contact_form_fields, question_form_fields
-
-logger = logging.getLogger(__name__)
+from pretix.base.models import ItemVariation
+from pretix.base.models.tax import TAXED_ZERO
+from pretix.base.templatetags.rich_text import rich_text
+from pretix.presale.signals import contact_form_fields
class ContactForm(forms.Form):
@@ -60,140 +47,12 @@ class ContactForm(forms.Form):
raise ValidationError(_('Please enter the same email address twice.'))
-class BusinessBooleanRadio(forms.RadioSelect):
- def __init__(self, attrs=None):
- choices = (
- ('individual', _('Individual customer')),
- ('business', _('Business customer')),
- )
- super().__init__(attrs, choices)
-
- def format_value(self, value):
- try:
- return {True: 'business', False: 'individual'}[value]
- except KeyError:
- return 'individual'
-
- def value_from_datadict(self, data, files, name):
- value = data.get(name)
- return {
- 'business': True,
- True: True,
- 'True': True,
- 'individual': False,
- 'False': False,
- False: False,
- }.get(value)
-
-
-class InvoiceAddressForm(forms.ModelForm):
+class InvoiceAddressForm(BaseInvoiceAddressForm):
required_css_class = 'required'
-
- class Meta:
- model = InvoiceAddress
- fields = ('is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
- 'internal_reference')
- widgets = {
- 'is_business': BusinessBooleanRadio,
- 'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
- 'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
- 'name': forms.TextInput(attrs={}),
- 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
- 'internal_reference': forms.TextInput,
- }
- labels = {
- 'is_business': ''
- }
-
- def __init__(self, *args, **kwargs):
- self.event = event = kwargs.pop('event')
- self.request = kwargs.pop('request', None)
- self.validate_vat_id = kwargs.pop('validate_vat_id')
- super().__init__(*args, **kwargs)
- if not event.settings.invoice_address_vatid:
- del self.fields['vat_id']
- if not event.settings.invoice_address_required:
- for k, f in self.fields.items():
- f.required = False
- f.widget.is_required = False
- if 'required' in f.widget.attrs:
- del f.widget.attrs['required']
-
- if event.settings.invoice_name_required:
- self.fields['name'].required = True
- else:
- self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
- self.fields['name'].widget.attrs['data-required-if'] = '#id_is_business_0'
-
- def clean(self):
- data = self.cleaned_data
- if not data.get('name') and not data.get('company') and self.event.settings.invoice_address_required:
- raise ValidationError(_('You need to provide either a company name or your name.'))
-
- if 'vat_id' in self.changed_data or not data.get('vat_id'):
- self.instance.vat_id_validated = False
-
- if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
- pass
- elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
- if data.get('vat_id')[:2] != str(data.get('country')):
- raise ValidationError(_('Your VAT ID does not match the selected country.'))
- try:
- result = vat_moss.id.validate(data.get('vat_id'))
- if result:
- country_code, normalized_id, company_name = result
- self.instance.vat_id_validated = True
- self.instance.vat_id = normalized_id
- except vat_moss.errors.InvalidError:
- raise ValidationError(_('This VAT ID is not valid. Please re-check your input.'))
- except vat_moss.errors.WebServiceUnavailableError:
- logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
- self.instance.vat_id_validated = False
- if self.request:
- messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of '
- 'your country is currently not available. We will therefore '
- 'need to charge VAT on your invoice. You can get the tax amount '
- 'back via the VAT reimbursement process.'))
- else:
- self.instance.vat_id_validated = False
+ vat_warning = True
-class UploadedFileWidget(forms.ClearableFileInput):
- def __init__(self, *args, **kwargs):
- self.position = kwargs.pop('position')
- self.event = kwargs.pop('event')
- self.answer = kwargs.pop('answer')
- super().__init__(*args, **kwargs)
-
- class FakeFile:
- def __init__(self, file, position, event, answer):
- self.file = file
- self.position = position
- self.event = event
- self.answer = answer
-
- def __str__(self):
- return os.path.basename(self.file.name).split('.', 1)[-1]
-
- @property
- def url(self):
- if isinstance(self.position, OrderPosition):
- return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={
- 'order': self.position.order.code,
- 'secret': self.position.order.secret,
- 'answer': self.answer.pk,
- })
- else:
- return eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={
- 'answer': self.answer.pk,
- })
-
- def format_value(self, value):
- if self.is_initial(value):
- return self.FakeFile(value, self.position, self.event, self.answer)
-
-
-class QuestionsForm(forms.Form):
+class QuestionsForm(BaseQuestionsForm):
"""
This form class is responsible for asking order-related questions. This includes
the attendee name for admission tickets, if the corresponding setting is enabled,
@@ -201,140 +60,6 @@ class QuestionsForm(forms.Form):
"""
required_css_class = 'required'
- def __init__(self, *args, **kwargs):
- """
- Takes two additional keyword arguments:
-
- :param cartpos: The cart position the form should be for
- :param event: The event this belongs to
- """
- cartpos = self.cartpos = kwargs.pop('cartpos', None)
- orderpos = self.orderpos = kwargs.pop('orderpos', None)
- pos = cartpos or orderpos
- item = pos.item
- questions = pos.item.questions_to_ask
- event = kwargs.pop('event')
-
- super().__init__(*args, **kwargs)
-
- if item.admission and event.settings.attendee_names_asked:
- self.fields['attendee_name'] = forms.CharField(
- max_length=255, required=event.settings.attendee_names_required,
- label=_('Attendee name'),
- initial=(cartpos.attendee_name if cartpos else orderpos.attendee_name),
- )
- if item.admission and event.settings.attendee_emails_asked:
- self.fields['attendee_email'] = forms.EmailField(
- required=event.settings.attendee_emails_required,
- label=_('Attendee email'),
- initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email)
- )
-
- 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]
- else:
- initial = None
- tz = pytz.timezone(event.settings.timezone)
- if q.type == Question.TYPE_BOOLEAN:
- if q.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=q.question, required=q.required,
- help_text=q.help_text,
- initial=initialbool, widget=widget,
- )
- elif q.type == Question.TYPE_NUMBER:
- field = forms.DecimalField(
- label=q.question, required=q.required,
- help_text=q.help_text,
- initial=initial.answer if initial else None,
- min_value=Decimal('0.00'),
- )
- elif q.type == Question.TYPE_STRING:
- field = forms.CharField(
- label=q.question, required=q.required,
- help_text=q.help_text,
- initial=initial.answer if initial else None,
- )
- elif q.type == Question.TYPE_TEXT:
- field = forms.CharField(
- label=q.question, required=q.required,
- help_text=q.help_text,
- widget=forms.Textarea,
- initial=initial.answer if initial else None,
- )
- elif q.type == Question.TYPE_CHOICE:
- field = forms.ModelChoiceField(
- queryset=q.options,
- label=q.question, required=q.required,
- help_text=q.help_text,
- widget=forms.Select,
- empty_label='',
- initial=initial.options.first() if initial else None,
- )
- elif q.type == Question.TYPE_CHOICE_MULTIPLE:
- field = forms.ModelMultipleChoiceField(
- queryset=q.options,
- label=q.question, required=q.required,
- help_text=q.help_text,
- widget=forms.CheckboxSelectMultiple,
- initial=initial.options.all() if initial else None,
- )
- elif q.type == Question.TYPE_FILE:
- field = forms.FileField(
- label=q.question, required=q.required,
- help_text=q.help_text,
- initial=initial.file if initial else None,
- widget=UploadedFileWidget(position=pos, event=event, answer=initial),
- )
- elif q.type == Question.TYPE_DATE:
- field = forms.DateField(
- label=q.question, required=q.required,
- help_text=q.help_text,
- initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
- widget=DatePickerWidget(),
- )
- elif q.type == Question.TYPE_TIME:
- field = forms.TimeField(
- label=q.question, required=q.required,
- help_text=q.help_text,
- initial=dateutil.parser.parse(initial.answer).astimezone(tz).time() if initial and initial.answer else None,
- widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
- )
- elif q.type == Question.TYPE_DATETIME:
- field = forms.SplitDateTimeField(
- label=q.question, required=q.required,
- help_text=q.help_text,
- initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
- widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
- )
- field.question = q
- if answers:
- # Cache the answer object for later use
- field.answer = answers[0]
- self.fields['question_%s' % q.id] = field
-
- responses = question_form_fields.send(sender=event, position=pos)
- data = pos.meta_info_data
- for r, response in sorted(responses, key=lambda r: str(r[0])):
- for key, value in response.items():
- # We need to be this explicit, since OrderedDict.update does not retain ordering
- self.fields[key] = value
- value.initial = data.get('question_form_data', {}).get(key)
-
class AddOnRadioSelect(forms.RadioSelect):
option_template_name = 'pretixpresale/forms/addon_choice_option.html'
diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py
index 7a6ba380ae..f784c633ab 100644
--- a/src/pretix/presale/views/order.py
+++ b/src/pretix/presale/views/order.py
@@ -4,7 +4,7 @@ from decimal import Decimal
from django.contrib import messages
from django.db import transaction
-from django.db.models import Prefetch, Sum
+from django.db.models import Sum
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator
@@ -14,11 +14,9 @@ from django.utils.translation import ugettext_lazy as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.generic import TemplateView, View
-from pretix.base.models import (
- CachedTicket, Invoice, Order, OrderPosition, Question, QuestionOption,
-)
+from pretix.base.models import CachedTicket, Invoice, Order, OrderPosition
from pretix.base.models.orders import (
- CachedCombinedTicket, InvoiceAddress, OrderFee, QuestionAnswer,
+ CachedCombinedTicket, OrderFee, QuestionAnswer,
)
from pretix.base.payment import PaymentException
from pretix.base.services.invoices import (
@@ -29,12 +27,12 @@ from pretix.base.services.tickets import (
get_cachedticket_for_order, get_cachedticket_for_position,
)
from pretix.base.signals import allow_ticket_download, register_ticket_outputs
+from pretix.base.views.mixins import OrderQuestionsViewMixin
from pretix.helpers.safedownload import check_token
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
-from pretix.presale.forms.checkout import InvoiceAddressForm
+from pretix.presale.forms.checkout import InvoiceAddressForm, QuestionsForm
from pretix.presale.views import CartMixin, EventViewMixin
from pretix.presale.views.async import AsyncAction
-from pretix.presale.views.questions import QuestionsViewMixin
from pretix.presale.views.robots import NoSearchIndexViewMixin
@@ -425,48 +423,11 @@ class OrderInvoiceCreate(EventViewMixin, OrderDetailMixin, View):
@method_decorator(xframe_options_exempt, 'dispatch')
-class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, TemplateView):
+class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, TemplateView):
+ form_class = QuestionsForm
+ invoice_form_class = InvoiceAddressForm
template_name = "pretixpresale/event/order_modify.html"
- @cached_property
- def _positions_for_questions(self):
- return self.positions
-
- @cached_property
- def positions(self):
- return list(self.order.positions.select_related(
- 'item', 'variation'
- ).prefetch_related(
- Prefetch('answers',
- QuestionAnswer.objects.prefetch_related('options'),
- to_attr='answerlist'),
- Prefetch('item__questions',
- Question.objects.filter(ask_during_checkin=False).prefetch_related(
- Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
- # This prefetch statement is utter bullshit, but it actually prevents Django from doing
- # a lot of queries since ModelChoiceIterator stops trying to be clever once we have
- # a prefetch lookup on this query...
- 'question',
- Question.objects.none(),
- to_attr='dummy'
- )))
- ),
- to_attr='questions_to_ask')
- ))
-
- @cached_property
- def invoice_address(self):
- try:
- return self.order.invoice_address
- except InvoiceAddress.DoesNotExist:
- return InvoiceAddress(order=self.order)
-
- @cached_property
- def invoice_form(self):
- return InvoiceAddressForm(data=self.request.POST if self.request.method == "POST" else None,
- event=self.request.event,
- instance=self.invoice_address, validate_vat_id=False)
-
def post(self, request, *args, **kwargs):
failed = not self.save() or not self.invoice_form.is_valid()
if failed:
@@ -497,13 +458,6 @@ class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, Template
return redirect(self.get_order_url())
return super().dispatch(request, *args, **kwargs)
- def get_context_data(self, **kwargs):
- ctx = super().get_context_data(**kwargs)
- ctx['order'] = self.order
- ctx['formgroups'] = self.formdict.items()
- ctx['invoice_form'] = self.invoice_form
- return ctx
-
@method_decorator(xframe_options_exempt, 'dispatch')
class OrderCancel(EventViewMixin, OrderDetailMixin, TemplateView):
diff --git a/src/pretix/presale/views/questions.py b/src/pretix/presale/views/questions.py
index 2f6163afdc..a03c1ebfb6 100644
--- a/src/pretix/presale/views/questions.py
+++ b/src/pretix/presale/views/questions.py
@@ -1,29 +1,14 @@
-import json
-from collections import OrderedDict
-
-from django import forms
-from django.core.files.uploadedfile import UploadedFile
from django.db.models import Prefetch
from django.utils.functional import cached_property
-from pretix.base.models import (
- CartPosition, OrderPosition, Question, QuestionAnswer, QuestionOption,
-)
+from pretix.base.models import Question, QuestionAnswer, QuestionOption
+from pretix.base.views.mixins import BaseQuestionsViewMixin
from pretix.presale.forms.checkout import QuestionsForm
from pretix.presale.views import get_cart
-class QuestionsViewMixin:
-
- @staticmethod
- def _keyfunc(pos):
- # Sort addons after the item they are an addon to
- if isinstance(pos, OrderPosition):
- i = pos.addon_to.positionid if pos.addon_to else pos.positionid
- else:
- i = pos.addon_to.pk if pos.addon_to else pos.pk
- addon_penalty = 1 if pos.addon_to else 0
- return i, addon_penalty, pos.pk
+class QuestionsViewMixin(BaseQuestionsViewMixin):
+ form_class = QuestionsForm
@cached_property
def _positions_for_questions(self):
@@ -48,112 +33,3 @@ class QuestionsViewMixin:
to_attr='questions_to_ask')
)
return sorted(list(cart), key=self._keyfunc)
-
- @cached_property
- def forms(self):
- """
- A list of forms with one form for each cart position that has questions
- the user can answer. All forms have a custom prefix, so that they can all be
- submitted at once.
- """
- formlist = []
- for cr in self._positions_for_questions:
- cartpos = cr if isinstance(cr, CartPosition) else None
- orderpos = cr if isinstance(cr, OrderPosition) else None
- form = QuestionsForm(event=self.request.event,
- prefix=cr.id,
- cartpos=cartpos,
- orderpos=orderpos,
- data=(self.request.POST if self.request.method == 'POST' else None),
- files=(self.request.FILES if self.request.method == 'POST' else None))
- form.pos = cartpos or orderpos
- if len(form.fields) > 0:
- formlist.append(form)
- return formlist
-
- @cached_property
- def formdict(self):
- storage = OrderedDict()
- for f in self.forms:
- pos = f.cartpos or f.orderpos
- if pos.addon_to_id:
- if pos.addon_to not in storage:
- storage[pos.addon_to] = []
- storage[pos.addon_to].append(f)
- else:
- if pos not in storage:
- storage[pos] = []
- storage[pos].append(f)
- return storage
-
- def save(self):
- failed = False
- for form in self.forms:
- meta_info = form.pos.meta_info_data
- # Every form represents a CartPosition or OrderPosition with questions attached
- if not form.is_valid():
- failed = True
- else:
- # This form was correctly filled, so we store the data as
- # answers to the questions / in the CartPosition object
- for k, v in form.cleaned_data.items():
- if k == 'attendee_name':
- form.pos.attendee_name = v if v != '' else None
- form.pos.save()
- elif k == 'attendee_email':
- form.pos.attendee_email = v if v != '' else None
- form.pos.save()
- elif k.startswith('question_') and v is not None:
- field = form.fields[k]
- if hasattr(field, 'answer'):
- # We already have a cached answer object, so we don't
- # have to create a new one
- if v == '' or v is None or (isinstance(field, forms.FileField) and v is False):
- if field.answer.file:
- field.answer.file.delete()
- field.answer.delete()
- else:
- self._save_to_answer(field, field.answer, v)
- field.answer.save()
- elif v != '':
- answer = QuestionAnswer(
- cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
- orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
- question=field.question,
- )
- self._save_to_answer(field, answer, v)
- answer.save()
- else:
- meta_info.setdefault('question_form_data', {})
- if v is None:
- if k in meta_info['question_form_data']:
- del meta_info['question_form_data'][k]
- else:
- meta_info['question_form_data'][k] = v
-
- form.pos.meta_info = json.dumps(meta_info)
- form.pos.save(update_fields=['meta_info'])
- return not failed
-
- def _save_to_answer(self, field, answer, value):
- if isinstance(field, forms.ModelMultipleChoiceField):
- answstr = ", ".join([str(o) for o in value])
- if not answer.pk:
- answer.save()
- else:
- answer.options.clear()
- answer.answer = answstr
- answer.options.add(*value)
- elif isinstance(field, forms.ModelChoiceField):
- if not answer.pk:
- answer.save()
- else:
- answer.options.clear()
- answer.options.add(value)
- answer.answer = value.answer
- elif isinstance(field, forms.FileField):
- if isinstance(value, UploadedFile):
- answer.file.save(value.name, value)
- answer.answer = 'file://' + value.name
- else:
- answer.answer = value
diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js
index d61ba35914..698e3c9cba 100644
--- a/src/pretix/static/pretixcontrol/js/ui/main.js
+++ b/src/pretix/static/pretixcontrol/js/ui/main.js
@@ -206,16 +206,32 @@ var form_handlers = function (el) {
dependency.on("change", update);
});
- el.find("input[data-display-dependency]").each(function () {
+ $("input[data-display-dependency]").each(function () {
var dependent = $(this),
dependency = $($(this).attr("data-display-dependency")),
- update = function () {
- var enabled = (dependency.attr("type") === 'checkbox') ? dependency.prop('checked') : !!dependency.val();
- dependent.prop('disabled', !enabled).parents('.form-group').toggleClass('disabled', !enabled);
+ update = function (ev) {
+ var enabled = (dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val();
+ if (ev) {
+ dependent.closest('.form-group').slideToggle(enabled);
+ } else {
+ dependent.closest('.form-group').toggle(enabled);
+ }
};
update();
- dependency.on("change", update);
- dependency.on("dp.change", update);
+ dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("change", update);
+ dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("dp.change", update);
+ });
+
+ el.find("input[data-required-if]").each(function () {
+ var dependent = $(this),
+ dependency = $($(this).attr("data-required-if")),
+ update = function (ev) {
+ var enabled = (dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val();
+ dependent.prop('required', enabled).closest('.form-group').toggleClass('required', enabled);
+ };
+ update();
+ dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("change", update);
+ dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("dp.change", update);
});
el.find(".scrolling-multiple-choice").each(function () {
diff --git a/src/tests/control/test_utils.py b/src/tests/helpers/test_i18n.py
similarity index 84%
rename from src/tests/control/test_utils.py
rename to src/tests/helpers/test_i18n.py
index 14c5ef8c89..d214f49423 100644
--- a/src/tests/control/test_utils.py
+++ b/src/tests/helpers/test_i18n.py
@@ -1,5 +1,5 @@
from pretix.base.i18n import language
-from pretix.control.utils.i18n import get_javascript_format, get_moment_locale
+from pretix.helpers.i18n import get_javascript_format, get_moment_locale
def test_js_formats():