diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 4c9f5e8b44..881c7746be 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -53,7 +53,9 @@ invoice_address object Invoice address ├ street string Customer street ├ zipcode string Customer ZIP code ├ city string Customer city -├ country string Customer country +├ country string Customer country code +├ state string Customer state (ISO 3166-2 code). Only supported in + AU, BR, CA, CN, MY, MX, and US. ├ internal_reference string Customer's internal reference to be printed on the invoice ├ vat_id string Customer VAT ID └ vat_id_validated string ``true``, if the VAT ID has been validated against the @@ -137,6 +139,10 @@ last_modified datetime Last modificati The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders. +.. versionchanged:: 3.1: + + The ``invoice_address.state`` attribute has been added. + .. _order-position-resource: Order position resource @@ -310,7 +316,8 @@ List of all orders "street": "Test street 12", "zipcode": "12345", "city": "Testington", - "country": "Testikistan", + "country": "DE", + "state": "", "internal_reference": "", "vat_id": "EU123456789", "vat_id_validated": false @@ -453,7 +460,8 @@ Fetching individual orders "street": "Test street 12", "zipcode": "12345", "city": "Testington", - "country": "Testikistan", + "country": "DE", + "state": "", "internal_reference": "", "vat_id": "EU123456789", "vat_id_validated": false @@ -770,6 +778,7 @@ Creating orders * ``zipcode`` * ``city`` * ``country`` + * ``state`` * ``internal_reference`` * ``vat_id`` @@ -837,6 +846,7 @@ Creating orders "zipcode": "12345", "city": "Sample City", "country": "UK", + "state": "", "internal_reference": "", "vat_id": "" }, diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 164ca6aa3e..6a24eb5d41 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1,6 +1,7 @@ import json from decimal import Decimal +import pycountry from django.utils.timezone import now from django.utils.translation import ugettext_lazy from django_countries.fields import Country @@ -20,6 +21,7 @@ from pretix.base.models.orders import ( CartPosition, OrderFee, OrderPayment, OrderRefund, ) from pretix.base.pdf import get_variables +from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS from pretix.base.signals import register_ticket_outputs @@ -41,7 +43,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer): class Meta: model = InvoiceAddress fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country', - 'vat_id', 'vat_id_validated', 'internal_reference') + 'state', 'vat_id', 'vat_id_validated', 'internal_reference') read_only_fields = ('last_modified', 'vat_id_validated') def __init__(self, *args, **kwargs): @@ -57,6 +59,24 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer): ) if data.get('name_parts') and '_scheme' not in data.get('name_parts'): data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme + + if data.get('country'): + if not pycountry.countries.get(alpha_2=data.get('country')): + raise ValidationError( + {'country': ['Invalid country code.']} + ) + + if data.get('state'): + cc = str(data.get('country') or self.instance.country or '') + if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS: + raise ValidationError( + {'state': ['States are not supported in country "{}".'.format(cc)]} + ) + if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')): + raise ValidationError( + {'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]} + ) + return data diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index 4b9c240997..43e542297f 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -96,7 +96,7 @@ class OrderListExporter(MultiSheetListExporter): for k, label, w in name_scheme['fields']: headers.append(label) headers += [ - _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'), + _('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'), _('Date of last payment'), _('Fees'), _('Order locale') ] @@ -153,10 +153,11 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.city, order.invoice_address.country if order.invoice_address.country else order.invoice_address.country_old, + order.invoice_address.state, order.invoice_address.vat_id, ] except InvoiceAddress.DoesNotExist: - row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) + row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) row += [ order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '', @@ -208,7 +209,7 @@ class OrderListExporter(MultiSheetListExporter): for k, label, w in name_scheme['fields']: headers.append(_('Invoice address name') + ': ' + str(label)) headers += [ - _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'), + _('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'), ] yield headers @@ -243,10 +244,11 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.city, order.invoice_address.country if order.invoice_address.country else order.invoice_address.country_old, + order.invoice_address.state, order.invoice_address.vat_id, ] except InvoiceAddress.DoesNotExist: - row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) + row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) yield row def iterate_positions(self, form_data: dict): @@ -301,7 +303,7 @@ class OrderListExporter(MultiSheetListExporter): for k, label, w in name_scheme['fields']: headers.append(_('Invoice address name') + ': ' + str(label)) headers += [ - _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'), + _('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'), ] headers.append(_('Sales channel')) @@ -358,10 +360,11 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.city, order.invoice_address.country if order.invoice_address.country else order.invoice_address.country_old, + order.invoice_address.state, order.invoice_address.vat_id, ] except InvoiceAddress.DoesNotExist: - row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) + row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) row.append(order.sales_channel) yield row @@ -503,6 +506,7 @@ class InvoiceDataExporter(MultiSheetListExporter): _('Invoice recipient:') + ' ' + _('ZIP code'), _('Invoice recipient:') + ' ' + _('City'), _('Invoice recipient:') + ' ' + _('Country'), + _('Invoice recipient:') + ' ' + pgettext('address', 'State'), _('Invoice recipient:') + ' ' + _('VAT ID'), _('Invoice recipient:') + ' ' + _('Beneficiary'), _('Invoice recipient:') + ' ' + _('Internal reference'), @@ -552,6 +556,7 @@ class InvoiceDataExporter(MultiSheetListExporter): i.invoice_to_zipcode, i.invoice_to_city, i.invoice_to_country, + i.invoice_to_state, i.invoice_to_vat_id, i.invoice_to_beneficiary, i.internal_reference, @@ -591,6 +596,7 @@ class InvoiceDataExporter(MultiSheetListExporter): _('Invoice recipient:') + ' ' + _('ZIP code'), _('Invoice recipient:') + ' ' + _('City'), _('Invoice recipient:') + ' ' + _('Country'), + _('Invoice recipient:') + ' ' + pgettext('address', 'State'), _('Invoice recipient:') + ' ' + _('VAT ID'), _('Invoice recipient:') + ' ' + _('Beneficiary'), _('Invoice recipient:') + ' ' + _('Internal reference'), @@ -630,6 +636,7 @@ class InvoiceDataExporter(MultiSheetListExporter): i.invoice_to_zipcode, i.invoice_to_city, i.invoice_to_country, + i.invoice_to_state, i.invoice_to_vat_id, i.invoice_to_beneficiary, i.internal_reference, diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index 6c07201131..c3599a2323 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -5,6 +5,7 @@ from decimal import Decimal from urllib.error import HTTPError import dateutil.parser +import pycountry import pytz import vat_moss.errors import vat_moss.id @@ -15,7 +16,9 @@ from django.db.models import QuerySet from django.forms import Select from django.utils.html import escape from django.utils.safestring import mark_safe -from django.utils.translation import get_language, ugettext_lazy as _ +from django.utils.translation import ( + get_language, pgettext_lazy, ugettext_lazy as _, +) from django_countries import countries from django_countries.fields import Country, CountryField @@ -25,7 +28,10 @@ from pretix.base.forms.widgets import ( ) from pretix.base.models import InvoiceAddress, Question, QuestionOption from pretix.base.models.tax import EU_COUNTRIES -from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS +from pretix.base.settings import ( + COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SCHEMES, + PERSON_NAME_TITLE_GROUPS, +) from pretix.base.templatetags.rich_text import rich_text from pretix.control.forms import SplitDateTimeField from pretix.helpers.escapejson import escapejson_attr @@ -356,8 +362,8 @@ class BaseInvoiceAddressForm(forms.ModelForm): class Meta: model = InvoiceAddress - fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id', - 'internal_reference', 'beneficiary') + fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'state', + 'vat_id', 'internal_reference', 'beneficiary') widgets = { 'is_business': BusinessBooleanRadio, 'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}), @@ -400,6 +406,29 @@ class BaseInvoiceAddressForm(forms.ModelForm): if not event.settings.invoice_address_vatid: del self.fields['vat_id'] + c = [('', pgettext_lazy('address', 'Select state'))] + cc = None + if 'country' in self.data: + cc = str(self.data['country']) + elif 'country' in self.initial: + cc = str(self.initial['country']) + elif self.instance and self.instance.country: + cc = str(self.instance.country) + if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS: + types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc] + statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types] + c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1]) + elif 'state' in self.data: + self.data = self.data.copy() + del self.data['state'] + + self.fields['state'] = forms.ChoiceField( + label=pgettext_lazy('address', 'State'), + required=False, + choices=c + ) + self.fields['state'].widget.is_required = True + if not event.settings.invoice_address_required or self.all_optional: for k, f in self.fields.items(): f.required = False diff --git a/src/pretix/base/migrations/0132_auto_20190808_1253.py b/src/pretix/base/migrations/0132_auto_20190808_1253.py new file mode 100644 index 0000000000..aba7ea9e46 --- /dev/null +++ b/src/pretix/base/migrations/0132_auto_20190808_1253.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.1 on 2019-08-08 12:53 + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0131_auto_20190729_1422'), + ] + + operations = [ + migrations.AddField( + model_name='invoice', + name='invoice_to_state', + field=models.CharField(max_length=190, null=True), + ), + migrations.AddField( + model_name='invoiceaddress', + name='state', + field=models.CharField(default='', max_length=255), + preserve_default=False, + ), + ] diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index 5013be6251..aa931fbeb4 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -1,6 +1,7 @@ import string from decimal import Decimal +import pycountry from django.db import DatabaseError, models, transaction from django.db.models import Max from django.db.models.functions import Cast @@ -11,6 +12,8 @@ from django.utils.translation import pgettext from django_countries.fields import CountryField from django_scopes import ScopedManager +from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS + def invoice_filename(instance, filename: str) -> str: secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits) @@ -90,6 +93,7 @@ class Invoice(models.Model): invoice_to_street = models.TextField(null=True) invoice_to_zipcode = models.CharField(max_length=190, null=True) invoice_to_city = models.TextField(null=True) + invoice_to_state = models.CharField(max_length=190, null=True) invoice_to_country = CountryField(null=True) invoice_to_vat_id = models.TextField(null=True) invoice_to_beneficiary = models.TextField(null=True) @@ -140,11 +144,21 @@ class Invoice(models.Model): def address_invoice_to(self): if self.invoice_to and not self.invoice_to_company and not self.invoice_to_name: return self.invoice_to + + state_name = "" + if self.invoice_to_state: + state_name = self.invoice_to_state + if str(self.invoice_to_country) in COUNTRIES_WITH_STATE_IN_ADDRESS: + if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_to_country)][1] == 'long': + state_name = pycountry.subdivisions.get( + code='{}-{}'.format(self.invoice_to_country, self.invoice_to_state) + ) + parts = [ self.invoice_to_company, self.invoice_to_name, self.invoice_to_street, - (self.invoice_to_zipcode or "") + " " + (self.invoice_to_city or ""), + ((self.invoice_to_zipcode or "") + " " + (self.invoice_to_city or "") + " " + (state_name or "")).strip(), self.invoice_to_country.name if self.invoice_to_country else "", ] return '\n'.join([p.strip() for p in parts if p and p.strip()]) diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index ef3c8eb6d9..3dbfe06d63 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -9,6 +9,7 @@ from decimal import Decimal from typing import Any, Dict, List, Union import dateutil +import pycountry import pytz from django.conf import settings from django.db import models, transaction @@ -2019,6 +2020,7 @@ class InvoiceAddress(models.Model): city = models.CharField(max_length=255, verbose_name=_('City'), blank=False) country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False) country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country')) + state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True) vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'), help_text=_('Only for business customers within the EU.')) vat_id_validated = models.BooleanField(default=False) @@ -2045,6 +2047,22 @@ class InvoiceAddress(models.Model): self.name_parts = {} super().save(**kwargs) + @property + def state_name(self): + sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state)) + if sd: + return sd.name + return self.state + + @property + def state_for_address(self): + from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS + if not self.state or str(self.country) not in COUNTRIES_WITH_STATE_IN_ADDRESS: + return "" + if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.country)][1] == 'long': + return self.state_name + return self.state + @property def name(self): if not self.name_parts: diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index b1791e3f8a..44c4c193d3 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -73,12 +73,15 @@ def build_invoice(invoice: Invoice) -> Invoice: addr_template = pgettext("invoice", """{i.company} {i.name} {i.street} -{i.zipcode} {i.city} +{i.zipcode} {i.city} {state} {country}""") - invoice.invoice_to = addr_template.format( - i=ia, - country=ia.country.name if ia.country else ia.country_old - ).strip() + invoice.invoice_to = "\n".join( + a.strip() for a in addr_template.format( + i=ia, + country=ia.country.name if ia.country else ia.country_old, + state=ia.state_for_address + ).split("\n") if a.strip() + ) invoice.internal_reference = ia.internal_reference invoice.invoice_to_company = ia.company invoice.invoice_to_name = ia.name @@ -86,6 +89,7 @@ def build_invoice(invoice: Invoice) -> Invoice: invoice.invoice_to_zipcode = ia.zipcode invoice.invoice_to_city = ia.city invoice.invoice_to_country = ia.country + invoice.invoice_to_state = ia.state invoice.invoice_to_beneficiary = ia.beneficiary if ia.vat_id: diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index bd901d288c..6f7eab5391 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -880,6 +880,19 @@ PERSON_NAME_SCHEMES = OrderedDict([ }, }), ]) +COUNTRIES_WITH_STATE_IN_ADDRESS = { + # Source: http://www.bitboost.com/ref/international-address-formats.html + # This is not a list of countries that *have* states, this is a list of countries where states + # are actually *used* in postal addresses. This is obviously not complete and opinionated. + # Country: [(List of subdivision types as defined by pycountry), (short or long form to be used)] + 'AU': (['State', 'Territory'], 'short'), + 'BR': (['State'], 'short'), + 'CA': (['Province', 'Territory'], 'short'), + 'CN': (['Province', 'Autonomous region', 'Munincipality'], 'long'), + 'MY': (['State'], 'long'), + 'MX': (['State', 'Federal District'], 'short'), + 'US': (['State', 'Outlying area', 'District'], 'short'), +} settings_hierarkey = Hierarkey(attribute_name='settings') diff --git a/src/pretix/base/views/js_helpers.py b/src/pretix/base/views/js_helpers.py new file mode 100644 index 0000000000..e1fb6d8908 --- /dev/null +++ b/src/pretix/base/views/js_helpers.py @@ -0,0 +1,16 @@ +import pycountry +from django.http import JsonResponse + +from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS + + +def states(request): + cc = request.GET.get("country", "DE") + if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS: + return JsonResponse({'data': []}) + 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) + ]}) diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 95dd6d36b8..79061f9e8b 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -663,6 +663,10 @@
{{ order.invoice_address.zipcode }} {{ order.invoice_address.city }}
{% trans "Country" %}
{{ order.invoice_address.country.name|default:order.invoice_address.country_old }}
+ {% if order.invoice_address.state %} +
{% trans "State" context "address" %}
+
{{ order.invoice_address.state_name }}
+ {% endif %} {% if request.event.settings.invoice_address_vatid %}
{% trans "VAT ID" %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html index 63e739d862..94387d2f7c 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html @@ -100,6 +100,10 @@
{{ addr.zipcode }} {{ addr.city }}
{% trans "Country" %}
{{ addr.country.name }}
+ {% if addr.state %} +
{% trans "State" context "address" %}
+
{{ addr.state_name }}
+ {% endif %} {% if request.event.settings.invoice_address_vatid %}
{% trans "VAT ID" %}
{{ addr.vat_id }}
diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index 6a67c3ef7b..f33815a7e2 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -208,6 +208,10 @@
{{ order.invoice_address.zipcode }} {{ order.invoice_address.city }}
{% trans "Country" %}
{{ order.invoice_address.country.name|default:order.invoice_address.country_old }}
+ {% if order.invoice_address.state %} +
{% trans "State" context "address" %}
+
{{ order.invoice_address.state_name }}
+ {% endif %} {% if request.event.settings.invoice_address_vatid %}
{% trans "VAT ID" %}
{{ order.invoice_address.vat_id }}
diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js index 8934b7c8bb..7ab1bc2dca 100644 --- a/src/pretix/static/pretixcontrol/js/ui/main.js +++ b/src/pretix/static/pretixcontrol/js/ui/main.js @@ -311,6 +311,42 @@ var form_handlers = function (el) { dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("dp.change", update); }); + $("select[name$=state]").each(function () { + var dependent = $(this), + counter = 0, + dependency = $(this).closest("form").find('select[name$=country]'), + update = function (ev) { + counter++; + var curCounter = counter; + dependent.prop("disabled", true); + dependency.closest(".form-group").find("label").prepend(" "); + $.getJSON('/js_helpers/states/?country=' + dependency.val(), function (data) { + if (counter > curCounter) { + return; // Lost race + } + dependent.find("option").filter(function (t) {return !!$(this).attr("value")}).remove(); + if (data.data.length > 0) { + $.each(data.data, function (k, s) { + dependent.append($("