From 04df1c2032cfc38e8777209838551dcde21c8a37 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 5 Dec 2022 12:42:46 +0100 Subject: [PATCH] Introduce country-specific address validation (#2945) Co-authored-by: Richard Schreiber --- src/pretix/base/addressvalidation.py | 226 +++++++++++++++++++++++ src/pretix/base/forms/questions.py | 9 +- src/pretix/presale/forms/checkout.py | 1 + src/pretix/settings.py | 1 + src/tests/base/test_addressvalidation.py | 120 ++++++++++++ src/tests/presale/test_checkout.py | 94 ++++++++-- src/tests/presale/test_orders.py | 37 +++- 7 files changed, 467 insertions(+), 21 deletions(-) create mode 100644 src/pretix/base/addressvalidation.py create mode 100644 src/tests/base/test_addressvalidation.py diff --git a/src/pretix/base/addressvalidation.py b/src/pretix/base/addressvalidation.py new file mode 100644 index 0000000000..4dd1fc8cf6 --- /dev/null +++ b/src/pretix/base/addressvalidation.py @@ -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 . +# +# 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 +# . +# +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 diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index e8bafaef7a..13633278ab 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -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') diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index 19b2d997b2..f57f79919f 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -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) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index fdae85244d..8328aaada1 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -377,6 +377,7 @@ INSTALLED_APPS = [ 'django_countries', 'hijack', 'oauth2_provider', + 'localflavor', 'phonenumber_field' ] diff --git a/src/tests/base/test_addressvalidation.py b/src/tests/base/test_addressvalidation.py new file mode 100644 index 0000000000..b330a751c4 --- /dev/null +++ b/src/tests/base/test_addressvalidation.py @@ -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 . +# +# 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 +# . +# +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 diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 09819513ea..02fa5ea97b 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -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' diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index a5efe8ac11..0da61b36f3 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -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,