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 %} +

+ {% trans "Change order information" %} + + {% blocktrans trimmed with order=order.code %} + Back to order {{ order }} + {% endblocktrans %} + +

+ +
+ {% csrf_token %} +
+ {% if request.event.settings.invoice_address_asked %} + + {% endif %} + {% for pos, forms in formgroups %} +
+ + +
+ {% endfor %} +
+
+ + {% trans "Cancel" %} + + +
+
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 2b89c0fb8f..4344700173 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -163,6 +163,10 @@
{% if order.changable and 'can_change_orders' in request.eventpermset %} + + + {% trans "Change answers" %} + · {% trans "Change products" %} @@ -368,6 +372,14 @@
+

{% trans "Invoice information" %}

diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 3066f5767f..504017a0ed 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -153,6 +153,8 @@ urlpatterns = [ name='event.order.comment'), url(r'^orders/(?P[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():