Introduce country-specific address validation (#2945)

Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2022-12-05 12:42:46 +01:00
committed by GitHub
parent 6a8df75a9f
commit 04df1c2032
7 changed files with 467 additions and 21 deletions

View File

@@ -0,0 +1,226 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from collections import defaultdict
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from localflavor.ar.forms import ARPostalCodeField
from localflavor.at.forms import ATZipCodeField
from localflavor.au.forms import AUPostCodeField
from localflavor.be.forms import BEPostalCodeField
from localflavor.br.forms import BRZipCodeField
from localflavor.ca.forms import CAPostalCodeField
from localflavor.ch.forms import CHZipCodeField
from localflavor.cn.forms import CNPostCodeField
from localflavor.cu.forms import CUPostalCodeField
from localflavor.cz.forms import CZPostalCodeField
from localflavor.de.forms import DEZipCodeField
from localflavor.dk.forms import DKPostalCodeField
from localflavor.ee.forms import EEZipCodeField
from localflavor.es.forms import ESPostalCodeField
from localflavor.fi.forms import FIZipCodeField
from localflavor.fr.forms import FRZipCodeField
from localflavor.gb.forms import GBPostcodeField
from localflavor.gr.forms import GRPostalCodeField
from localflavor.hr.forms import HRPostalCodeField
from localflavor.id_.forms import IDPostCodeField
from localflavor.ie.forms import EircodeField
from localflavor.il.forms import ILPostalCodeField
from localflavor.in_.forms import INZipCodeField
from localflavor.ir.forms import IRPostalCodeField
from localflavor.is_.is_postalcodes import IS_POSTALCODES
from localflavor.it.forms import ITZipCodeField
from localflavor.jp.forms import JPPostalCodeField
from localflavor.lt.forms import LTPostalCodeField
from localflavor.lv.forms import LVPostalCodeField
from localflavor.ma.forms import MAPostalCodeField
from localflavor.mt.forms import MTPostalCodeField
from localflavor.mx.forms import MXZipCodeField
from localflavor.nl.forms import NLZipCodeField
from localflavor.no.forms import NOZipCodeField
from localflavor.nz.forms import NZPostCodeField
from localflavor.pk.forms import PKPostCodeField
from localflavor.pl.forms import PLPostalCodeField
from localflavor.pt.forms import PTZipCodeField
from localflavor.ro.forms import ROPostalCodeField
from localflavor.ru.forms import RUPostalCodeField
from localflavor.se.forms import SEPostalCodeField
from localflavor.sg.forms import SGPostCodeField
from localflavor.si.si_postalcodes import SI_POSTALCODES
from localflavor.sk.forms import SKPostalCodeField
from localflavor.tr.forms import TRPostalCodeField
from localflavor.ua.forms import UAPostalCodeField
from localflavor.us.forms import USZipCodeField
from localflavor.za.forms import ZAPostCodeField
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
_validator_classes = defaultdict(list)
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED = {
# We don't presume this for countries we don't have knowledge about, there are countries in the
# world e.g. without zipcodes
'AR', 'AT', 'AU', 'BE', 'BR', 'CA', 'CH', 'CN', 'CU', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR',
'GB', 'GR', 'HR', 'ID', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX',
'NL', 'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA',
}
def validate_address(address: dict):
"""
:param address: A dictionary with at least the entries ``street``, ``zipcode``, ``city``, ``country``,
``state``
:return: The dictionary, possibly with changes
"""
if not address.get('street') and not address.get('zipcode') and not address.get('city'):
# Consider the actual address part to be empty, no further validation necessary, if the
# address should be required, it's the callers job to validate that at least one of these
# fields is filled
return address
if not address.get('country'):
raise ValidationError({'country': [_('This field is required.')]})
if str(address['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS and not address.get('state'):
raise ValidationError({'state': [_('This field is required.')]})
if str(address['country']) in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED:
for f in ('street', 'zipcode', 'city'):
if not address.get(f):
raise ValidationError({f: [_('This field is required.')]})
for klass in _validator_classes[str(address['country'])]:
validator = klass()
try:
address['zipcode'] = validator.validate_zipcode(address['zipcode'])
except ValidationError as e:
raise ValidationError({'zipcode': list(e)})
return address
def register_validator_for(country):
def inner(klass):
_validator_classes[country].append(klass)
return klass
return inner
class BaseValidator:
required_fields = []
def validate_zipcode(self, value):
return value
"""
Currently, mostly have validators that are auto-generated from django-localflavor
but custom ones can be added like this:
@register_validator_for('DE')
class DEValidator(BaseValidator):
def validate_zipcode(value):
return value
In the future, we can also add additional methods to validate that e.g. a city
is plausible for a given zip code.
"""
_zip_code_fields = {
'AR': ARPostalCodeField,
'AT': ATZipCodeField,
'AU': AUPostCodeField,
'BE': BEPostalCodeField,
'BR': BRZipCodeField,
'CA': CAPostalCodeField,
'CH': CHZipCodeField,
'CN': CNPostCodeField,
'CU': CUPostalCodeField,
'CZ': CZPostalCodeField,
'DE': DEZipCodeField,
'DK': DKPostalCodeField,
'EE': EEZipCodeField,
'ES': ESPostalCodeField,
'FI': FIZipCodeField,
'FR': FRZipCodeField,
'GB': GBPostcodeField,
'GR': GRPostalCodeField,
'HR': HRPostalCodeField,
'ID': IDPostCodeField,
'IE': EircodeField,
'IL': ILPostalCodeField,
'IN': INZipCodeField,
'IR': IRPostalCodeField,
'IT': ITZipCodeField,
'JP': JPPostalCodeField,
'LT': LTPostalCodeField,
'LV': LVPostalCodeField,
'MA': MAPostalCodeField,
'MT': MTPostalCodeField,
'MX': MXZipCodeField,
'NL': NLZipCodeField,
'NO': NOZipCodeField,
'NZ': NZPostCodeField,
'PK': PKPostCodeField,
'PL': PLPostalCodeField,
'PT': PTZipCodeField,
'RO': ROPostalCodeField,
'RU': RUPostalCodeField,
'SE': SEPostalCodeField,
'SG': SGPostCodeField,
'SK': SKPostalCodeField,
'TR': TRPostalCodeField,
'UA': UAPostalCodeField,
'US': USZipCodeField,
'ZA': ZAPostCodeField,
}
def _generate_class_from_zipcode_field(field_class):
class _GeneratedValidator(BaseValidator):
def validate_zipcode(self, value):
return field_class().clean(value)
return _GeneratedValidator
for cc, field_class in _zip_code_fields.items():
register_validator_for(cc)(_generate_class_from_zipcode_field(field_class))
@register_validator_for('IS')
class ISValidator(BaseValidator):
def validate_zipcode(self, value):
if value not in [entry[0] for entry in IS_POSTALCODES]:
raise ValidationError(_('Enter a postal code in the format XXX.'), code='invalid')
return value
@register_validator_for('SI')
class SIValidator(BaseValidator):
def validate_zipcode(self, value):
try:
if int(value) not in [entry[0] for entry in SI_POSTALCODES]:
raise ValidationError(_('Enter a postal code in the format XXXX.'), code='invalid')
except ValueError:
raise ValidationError(_('Enter a postal code in the format XXXX.'), code='invalid')
return value

View File

@@ -915,6 +915,7 @@ class BaseQuestionsForm(forms.Form):
class BaseInvoiceAddressForm(forms.ModelForm):
vat_warning = False
address_validation = False
class Meta:
model = InvoiceAddress
@@ -1050,6 +1051,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '')
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
data = self.cleaned_data
if not data.get('is_business'):
data['company'] = ''
@@ -1065,9 +1069,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False
if data.get('city') and data.get('country') and str(data['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not data.get('state'):
self.add_error('state', _('This field is required.'))
if self.address_validation:
self.cleaned_data = data = validate_address(data)
self.instance.name_parts = data.get('name_parts')

View File

@@ -113,6 +113,7 @@ class ContactForm(forms.Form):
class InvoiceAddressForm(BaseInvoiceAddressForm):
required_css_class = 'required'
vat_warning = True
address_validation = True
def __init__(self, *args, **kwargs):
allow_save = kwargs.pop('allow_save', False)

View File

@@ -377,6 +377,7 @@ INSTALLED_APPS = [
'django_countries',
'hijack',
'oauth2_provider',
'localflavor',
'phonenumber_field'
]

View File

@@ -0,0 +1,120 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import pytest
from django.core.exceptions import ValidationError
from pretix.base.addressvalidation import validate_address
@pytest.mark.parametrize(
"input,output",
[
# No address is allowed
({"name": "Peter"}, {"name": "Peter"}),
# Country must be given if any part of the address is filled
({"street": "Main Street"}, {"country": ["This field is required."]}),
# Country without any semantic validation
(
{"street": "Main Street", "country": "CR"},
{"street": "Main Street", "country": "CR"},
),
# Country that requires all fields except state to be filled
(
{"street": "Main Street", "country": "DE"},
{"zipcode": ["This field is required."]},
),
(
{"street": "Main Street", "country": "DE", "zipcode": "12345"},
{"city": ["This field is required."]},
),
(
{"city": "Heidelberg", "country": "DE", "zipcode": "12345"},
{"street": ["This field is required."]},
),
(
{
"street": "Main Street",
"city": "Heidelberg",
"country": "DE",
"zipcode": "12345",
},
True,
),
# Country that requires state to be filled
(
{
"street": "Main street",
"city": "Heidelberg",
"country": "US",
"zipcode": "12345",
},
{"state": ["This field is required."]},
),
# Country with zip code validation inherited from django-localflavor
(
{
"street": "Main street",
"city": "Heidelberg",
"country": "DE",
"zipcode": "ABCDE",
},
{"zipcode": ["Enter a zip code in the format XXXXX."]},
),
# Country with zip code validation implemented directly
(
{
"street": "Main street",
"city": "Heidelberg",
"country": "IS",
"zipcode": "ABCDE",
},
{"zipcode": ["Enter a postal code in the format XXX."]},
),
# Country with zip code normalization inherited from django-localflavor
(
{
"street": "Main street",
"city": "London",
"country": "GB",
"zipcode": "se19de",
},
{
"street": "Main street",
"city": "London",
"country": "GB",
"zipcode": "SE1 9DE",
},
),
],
)
def test_validate_address(input, output):
try:
actual_output = validate_address(input)
except ValidationError as e:
assert {
k: ["".join(s for s in e) for e in v] for k, v in e.error_dict.items()
} == output
else:
if output is True:
assert actual_output == input
else:
assert output == actual_output

View File

@@ -170,7 +170,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
@@ -193,7 +193,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'is_business': 'individual',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'vat_id': '',
@@ -229,7 +229,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
@@ -259,7 +259,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '3000',
'city': 'Here',
'country': 'AU',
'state': 'QLD',
@@ -293,7 +293,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
@@ -324,7 +324,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'FR',
'vat_id': 'AT123456',
@@ -357,7 +357,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
@@ -391,7 +391,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
@@ -428,7 +428,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
@@ -492,7 +492,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
@@ -551,7 +551,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'is_business': 'individual',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'email': 'admin@localhost'
@@ -603,7 +603,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'is_business': 'individual',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'email': 'admin@localhost'
@@ -631,7 +631,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'is_business': 'individual',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '99501',
'city': 'Here',
'country': 'US',
'state': 'CA',
@@ -684,7 +684,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1345',
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
@@ -748,7 +748,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
@@ -1102,6 +1102,57 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
}
assert ia.name_cached == 'Mr John Kennedy'
def test_invoice_address_validated(self):
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = True
self.event.settings.invoice_address_not_asked_free = True
self.event.settings.set('name_scheme', 'title_given_middle_family')
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('input[name="city"]')), 1)
# Not all required fields filled out correctly, expect failure
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': 'Foo',
'name_parts_0': 'Mr',
'name_parts_1': 'John',
'name_parts_2': '',
'name_parts_3': 'Kennedy',
'street': 'Baz',
'zipcode': '123456',
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': 'Foo',
'name_parts_0': 'Mr',
'name_parts_1': 'John',
'name_parts_2': '',
'name_parts_3': 'Kennedy',
'street': 'Baz',
'zipcode': '12345',
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
def test_invoice_address_hidden_for_free(self):
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = True
@@ -1136,10 +1187,19 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('input[name="city"]')), 1)
# Not all required fields filled out, expect failure
# Partial address is not allowed
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'country': 'DE',
'city': 'Musterstadt',
'email': 'admin@localhost'
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
# No address works
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'

View File

@@ -345,6 +345,35 @@ class OrdersTest(BaseOrdersTest):
with scopes_disabled():
assert self.ticket_pos.answers.get(question=self.question).answer == 'ABC'
def test_modify_invoice_address_validated(self):
self.event.settings.set('invoice_reissue_after_modify', True)
self.event.settings.set('invoice_address_asked', True)
with scopes_disabled():
generate_invoice(self.order)
response = self.client.post(
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
'%s-question_%s' % (self.ticket_pos.id, self.question.id): 'ABC',
}, follow=True)
self.assertRedirects(response,
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
self.order.secret),
target_status_code=200)
# Only questions changed
with scopes_disabled():
assert self.order.invoices.count() == 1
response = self.client.post(
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
'%s-question_%s' % (self.ticket_pos.id, self.question.id): 'ABC',
'zipcode': 'XXINVALIDXX',
'street': 'Main Street',
'city': 'Heidelberg',
'country': 'DE',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
def test_modify_invoice_regenerate(self):
self.event.settings.set('invoice_reissue_after_modify', True)
self.event.settings.set('invoice_address_asked', True)
@@ -366,7 +395,10 @@ class OrdersTest(BaseOrdersTest):
response = self.client.post(
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
'%s-question_%s' % (self.ticket_pos.id, self.question.id): 'ABC',
'zipcode': '1234',
'zipcode': '12345',
'street': 'Main Street',
'city': 'Heidelberg',
'country': 'DE',
}, follow=True)
self.assertRedirects(response,
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
@@ -381,6 +413,9 @@ class OrdersTest(BaseOrdersTest):
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
'%s-question_%s' % (self.ticket_pos.id, self.question.id): 'ABC',
'zipcode': '54321',
'street': 'Main Street',
'city': 'Heidelberg',
'country': 'DE',
}, follow=True)
self.assertRedirects(response,
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,