diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index e7e6b6a4e..e7402d8cb 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -54,6 +54,7 @@ from django.core.validators import ( from django.db.models import QuerySet from django.forms import Select, widgets from django.forms.widgets import FILE_INPUT_CONTRADICTION +from django.urls import reverse from django.utils.formats import date_format from django.utils.html import escape from django.utils.safestring import mark_safe @@ -77,7 +78,7 @@ from pretix.base.i18n import ( get_babel_locale, get_language_without_region, language, ) from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption -from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id +from pretix.base.models.tax import ask_for_vat_id from pretix.base.services.tax import ( VATIDFinalError, VATIDTemporaryError, validate_vat_id, ) @@ -602,6 +603,7 @@ class BaseQuestionsForm(forms.Form): questions = pos.item.questions_to_ask event = kwargs.pop('event') self.all_optional = kwargs.pop('all_optional', False) + self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional super().__init__(*args, **kwargs) @@ -676,7 +678,7 @@ class BaseQuestionsForm(forms.Form): if item.ask_attendee_data and event.settings.attendee_addresses_asked: add_fields['street'] = forms.CharField( - required=event.settings.attendee_addresses_required and not self.all_optional, + required=self.attendee_addresses_required, label=_('Address'), widget=forms.Textarea(attrs={ 'rows': 2, @@ -686,7 +688,7 @@ class BaseQuestionsForm(forms.Form): initial=(cartpos.street if cartpos else orderpos.street), ) add_fields['zipcode'] = forms.CharField( - required=event.settings.attendee_addresses_required and not self.all_optional, + required=False, max_length=30, label=_('ZIP code'), initial=(cartpos.zipcode if cartpos else orderpos.zipcode), @@ -695,7 +697,7 @@ class BaseQuestionsForm(forms.Form): }), ) add_fields['city'] = forms.CharField( - required=event.settings.attendee_addresses_required and not self.all_optional, + required=False, label=_('City'), max_length=255, initial=(cartpos.city if cartpos else orderpos.city), @@ -707,11 +709,12 @@ class BaseQuestionsForm(forms.Form): add_fields['country'] = CountryField( countries=CachedCountries ).formfield( - required=event.settings.attendee_addresses_required and not self.all_optional, + required=self.attendee_addresses_required, label=_('Country'), initial=country, widget=forms.Select(attrs={ 'autocomplete': 'country', + 'data-country-information-url': reverse('js_helpers.states'), }), ) c = [('', pgettext_lazy('address', 'Select state'))] @@ -946,9 +949,9 @@ class BaseQuestionsForm(forms.Form): d = super().clean() if self.address_validation: - self.cleaned_data = d = validate_address(d, True) + self.cleaned_data = d = validate_address(d, all_optional=not self.attendee_addresses_required) - if d.get('city') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS: + if d.get('street') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS: if not d.get('state'): self.add_error('state', _('This field is required.')) @@ -1005,7 +1008,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): 'street': forms.Textarea(attrs={ 'rows': 2, 'placeholder': _('Street and Number'), - 'autocomplete': 'street-address' + 'autocomplete': 'street-address', }), 'beneficiary': forms.Textarea(attrs={'rows': 3}), 'country': forms.Select(attrs={ @@ -1021,7 +1024,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): 'data-display-dependency': '#id_is_business_1', 'autocomplete': 'organization', }), - 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-with-vat-id': ','.join(VAT_ID_COUNTRIES)}), + 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}), 'internal_reference': forms.TextInput, } labels = { @@ -1055,6 +1058,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): ]) self.fields['country'].choices = CachedCountries() + self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states') c = [('', pgettext_lazy('address', 'Select state'))] fprefix = self.prefix + '-' if self.prefix else '' @@ -1083,6 +1087,10 @@ class BaseInvoiceAddressForm(forms.ModelForm): ) self.fields['state'].widget.is_required = True + self.fields['street'].required = False + self.fields['zipcode'].required = False + self.fields['city'].required = False + # Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected. if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data: self.data = self.data.copy() @@ -1142,9 +1150,11 @@ class BaseInvoiceAddressForm(forms.ModelForm): data['vat_id'] = '' if self.event.settings.invoice_address_required: if data.get('is_business') and not data.get('company'): - raise ValidationError(_('You need to provide a company name.')) + raise ValidationError({"company": _('You need to provide a company name.')}) if not data.get('is_business') and not data.get('name_parts'): raise ValidationError(_('You need to provide your name.')) + if not data.get('street') and not data.get('zipcode') and not data.get('city'): + raise ValidationError({"street": _('This field is required.')}) if 'vat_id' in self.changed_data or not data.get('vat_id'): self.instance.vat_id_validated = False diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index d0786d8d9..f151dbb34 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -3204,9 +3204,9 @@ class InvoiceAddress(models.Model): company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name')) name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True) name_parts = models.JSONField(default=dict) - street = models.TextField(verbose_name=_('Address'), blank=False) - zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False) - city = models.CharField(max_length=255, verbose_name=_('City'), blank=False) + street = models.TextField(verbose_name=_('Address'), blank=True) + zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True) + city = models.CharField(max_length=255, verbose_name=_('City'), blank=True) country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False) country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'), countries=CachedCountries) diff --git a/src/pretix/base/views/js_helpers.py b/src/pretix/base/views/js_helpers.py index 458f55d44..be180e9cd 100644 --- a/src/pretix/base/views/js_helpers.py +++ b/src/pretix/base/views/js_helpers.py @@ -22,16 +22,30 @@ import pycountry from django.http import JsonResponse +from pretix.base.addressvalidation import ( + COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED, +) +from pretix.base.models.tax import VAT_ID_COUNTRIES from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS def states(request): cc = request.GET.get("country", "DE") + info = { + 'street': {'required': True}, + 'zipcode': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED}, + 'city': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED}, + 'state': {'visible': cc in COUNTRIES_WITH_STATE_IN_ADDRESS, 'required': cc in COUNTRIES_WITH_STATE_IN_ADDRESS}, + 'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False}, + } if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS: - return JsonResponse({'data': []}) + return JsonResponse({'data': [], **info, }) types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc] statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types] - return JsonResponse({'data': [ - {'name': s.name, 'code': s.code[3:]} - for s in sorted(statelist, key=lambda s: s.name) - ]}) + return JsonResponse({ + 'data': [ + {'name': s.name, 'code': s.code[3:]} + for s in sorted(statelist, key=lambda s: s.name) + ], + **info, + }) diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index 65c4a9235..79d9c5f52 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -61,6 +61,7 @@ + {% endcompress %} {{ html_head|safe }} diff --git a/src/pretix/control/templates/pretixcontrol/order/change_questions.html b/src/pretix/control/templates/pretixcontrol/order/change_questions.html index 02b4c7fc0..df298d912 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change_questions.html +++ b/src/pretix/control/templates/pretixcontrol/order/change_questions.html @@ -46,11 +46,13 @@
{% for form in forms %} - {% if form.pos.item != pos.item %} - {# Add-Ons #} - + {{ form.pos.item }} - {% endif %} - {% bootstrap_form form layout="control" %} +
+ {% if form.pos.item != pos.item %} + {# Add-Ons #} + + {{ form.pos.item }} + {% endif %} + {% bootstrap_form form layout="control" %} +
{% endfor %}
diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index e58716f5a..44a1e10f0 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -1076,8 +1076,8 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): if warn: messages.warning(request, _('Please fill in answers to all required questions.')) return False - if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_attendees_required', as_type=bool) \ - and (cp.street is None or cp.city is None or cp.country is None): + if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_addresses_required', as_type=bool) \ + and (cp.street is None and cp.city is None and cp.country is None): if warn: messages.warning(request, _('Please fill in answers to all required questions.')) return False diff --git a/src/pretix/presale/templates/pretixpresale/fragment_js.html b/src/pretix/presale/templates/pretixpresale/fragment_js.html index f79c396da..304452adc 100644 --- a/src/pretix/presale/templates/pretixpresale/fragment_js.html +++ b/src/pretix/presale/templates/pretixpresale/fragment_js.html @@ -20,4 +20,5 @@ + {% endcompress %} diff --git a/src/pretix/static/pretixbase/js/addressform.js b/src/pretix/static/pretixbase/js/addressform.js new file mode 100644 index 000000000..be93c6945 --- /dev/null +++ b/src/pretix/static/pretixbase/js/addressform.js @@ -0,0 +1,56 @@ +$(function () { + "use strict"; + + $("select[data-country-information-url]").each(function () { + let xhr; + const dependency = $(this), + loader = $("").hide().prependTo(dependency.closest(".form-group").find("label")), + url = this.getAttribute('data-country-information-url'), + form = dependency.closest(".panel-body, form, .profile-scope"), + isRequired = dependency.closest(".form-group").is(".required"), + dependents = { + 'city': form.find("input[name$=city]"), + 'zipcode': form.find("input[name$=zipcode]"), + 'street': form.find("textarea[name$=street]"), + 'state': form.find("select[name$=state]"), + 'vat_id': form.find("input[name$=vat_id]"), + }, + update = function (ev) { + if (xhr) { + xhr.abort(); + } + for (var k in dependents) dependents[k].prop("disabled", true); + loader.fadeIn(); + xhr = $.getJSON(url + '?country=' + dependency.val(), function (data) { + var selected_value = dependents.state.prop("data-selected-value"); + if (selected_value) dependents.state.prop("data-selected-value", ""); + dependents.state.find("option:not([value=''])").remove(); + if (data.data.length > 0) { + $.each(data.data, function (k, s) { + var o = $("