From 54091b9721276df96324d071a6255900caa1407e Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Wed, 13 Nov 2019 12:52:07 +0100 Subject: [PATCH] Add question type: phone number (#1462) * Add Phonenumber-Field as to Questions * Add setup requirements * Add list of ask-during-checkin restricted question types and enforce it * Fix requirements * Fix crash using custom locales * Re-format phone numbers when outputting to humans * Initialize country code field with a guess for the customer's country * Document TEL type in API docs --- doc/api/resources/questions.rst | 1 + src/pretix/api/serializers/item.py | 3 ++ src/pretix/base/forms/questions.py | 74 +++++++++++++++++++++++------- src/pretix/base/models/items.py | 3 ++ src/pretix/base/models/orders.py | 3 ++ src/pretix/control/forms/item.py | 8 ++++ src/pretix/helpers/json.py | 3 ++ src/pretix/settings.py | 1 + src/requirements/production.txt | 2 + src/setup.py | 2 + 10 files changed, 83 insertions(+), 17 deletions(-) diff --git a/doc/api/resources/questions.rst b/doc/api/resources/questions.rst index 7fed162ef0..b58c3eed05 100644 --- a/doc/api/resources/questions.rst +++ b/doc/api/resources/questions.rst @@ -31,6 +31,7 @@ type string The expected ty * ``H`` – time * ``W`` – date and time * ``CC`` – country code (ISO 3666-1 alpha-2) + * ``TEL`` – telephone number required boolean If ``true``, the question needs to be filled out. position integer An integer, used for sorting items list of integers List of item IDs this question is assigned to. diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 09766fc742..80d9a74bff 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -267,6 +267,9 @@ class QuestionSerializer(I18nAwareModelSerializer): seen_ids.add(dep.pk) dep = dep.dependency_question + if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED: + raise ValidationError(_('This type of question cannot be asked during check-in.')) + Question.clean_items(event, full_data.get('items')) return data diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index 91f3978967..6b9ef0c8d9 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -9,6 +9,7 @@ import pycountry import pytz import vat_moss.errors import vat_moss.id +from babel import localedata from django import forms from django.contrib import messages from django.core.exceptions import ValidationError @@ -21,11 +22,16 @@ from django.utils.translation import ( ) from django_countries import countries from django_countries.fields import Country, CountryField +from phonenumber_field.formfields import PhoneNumberField +from phonenumber_field.phonenumber import PhoneNumber +from phonenumber_field.widgets import PhoneNumberPrefixWidget +from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE from pretix.base.forms.widgets import ( BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget, UploadedFileWidget, ) +from pretix.base.i18n import language from pretix.base.models import InvoiceAddress, Question, QuestionOption from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix from pretix.base.settings import ( @@ -179,6 +185,34 @@ class NamePartsFormField(forms.MultiValueField): return value +class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget): + def render(self, name, value, attrs=None, renderer=None): + output = super().render(name, value, attrs, renderer) + return mark_safe(self.format_output(output)) + + def format_output(self, rendered_widgets) -> str: + return '
%s
' % ''.join(rendered_widgets) + + +def guess_country(event): + # Try to guess the initial country from either the country of the merchant + # or the locale. This will hopefully save at least some users some scrolling :) + locale = get_language() + country = event.settings.invoice_address_from_country + if not country: + valid_countries = countries.countries + if '-' in locale: + parts = locale.split('-') + if parts[1].upper() in valid_countries: + country = Country(parts[1].upper()) + elif parts[0].upper() in valid_countries: + country = Country(parts[0].upper()) + else: + if locale in valid_countries: + country = Country(locale.upper()) + return country + + class BaseQuestionsForm(forms.Form): """ This form class is responsible for asking order-related questions. This includes @@ -328,6 +362,28 @@ class BaseQuestionsForm(forms.Form): 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')), ) + elif q.type == Question.TYPE_PHONENUMBER: + babel_locale = 'en' + # Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal + if localedata.exists(get_language()): + babel_locale = get_language() + elif localedata.exists(get_language()[:2]): + babel_locale = get_language()[:2] + with language(babel_locale): + default_country = guess_country(event) + default_prefix = None + for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items(): + if str(default_country) in values: + default_prefix = prefix + field = PhoneNumberField( + label=label, required=required, + help_text=help_text, + # We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just + # a country code but no number as an initial value. It's a bit hacky, but should be stable for + # the future. + initial=PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix), + widget=WrappedPhoneNumberPrefixWidget() + ) field.question = q if answers: # Cache the answer object for later use @@ -433,23 +489,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): kwargs.setdefault('initial', {}) if not kwargs.get('instance') or not kwargs['instance'].country: - # Try to guess the initial country from either the country of the merchant - # or the locale. This will hopefully save at least some users some scrolling :) - locale = get_language() - country = event.settings.invoice_address_from_country - if not country: - valid_countries = countries.countries - if '-' in locale: - parts = locale.split('-') - if parts[1].upper() in valid_countries: - country = Country(parts[1].upper()) - elif parts[0].upper() in valid_countries: - country = Country(parts[0].upper()) - else: - if locale in valid_countries: - country = Country(locale.upper()) - - kwargs['initial']['country'] = country + kwargs['initial']['country'] = guess_country(self.event) super().__init__(*args, **kwargs) if not event.settings.invoice_address_vatid: diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 31c43522b3..67a4aaae94 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -977,6 +977,7 @@ class Question(LoggedModel): TYPE_TIME = "H" TYPE_DATETIME = "W" TYPE_COUNTRYCODE = "CC" + TYPE_PHONENUMBER = "TEL" TYPE_CHOICES = ( (TYPE_NUMBER, _("Number")), (TYPE_STRING, _("Text (one line)")), @@ -989,8 +990,10 @@ class Question(LoggedModel): (TYPE_TIME, _("Time")), (TYPE_DATETIME, _("Date and time")), (TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")), + (TYPE_PHONENUMBER, _("Phone number")), ) UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME] + ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE, TYPE_PHONENUMBER] event = models.ForeignKey( Event, diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index ddf56b367f..26b682924b 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -30,6 +30,7 @@ from django_countries.fields import Country, CountryField from django_scopes import ScopedManager, scopes_disabled from i18nfield.strings import LazyI18nString from jsonfallback.fields import FallbackJSONField +from phonenumber_field.phonenumber import PhoneNumber from pretix.base.banlist import banned from pretix.base.decimal import round_decimal @@ -922,6 +923,8 @@ class QuestionAnswer(models.Model): return self.answer elif self.question.type == Question.TYPE_COUNTRYCODE and self.answer: return Country(self.answer).name or self.answer + elif self.question.type == Question.TYPE_PHONENUMBER and self.answer: + return PhoneNumber.from_string(self.answer).as_international else: return self.answer diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 21e30a61dd..fb53bf8bc1 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -77,6 +77,14 @@ class QuestionForm(I18nModelForm): dep = dep.dependency_question return val + def clean_ask_during_checkin(self): + val = self.cleaned_data.get('ask_during_checkin') + + if val and self.cleaned_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED: + raise ValidationError(_('This type of question cannot be asked during check-in.')) + + return val + def clean(self): d = super().clean() if d.get('dependency_question') and not d.get('dependency_values'): diff --git a/src/pretix/helpers/json.py b/src/pretix/helpers/json.py index 9dc95fc093..a9c3b9db43 100644 --- a/src/pretix/helpers/json.py +++ b/src/pretix/helpers/json.py @@ -1,5 +1,6 @@ from django.core.files import File from i18nfield.utils import I18nJSONEncoder +from phonenumber_field.phonenumber import PhoneNumber from pretix.base.reldate import RelativeDateWrapper @@ -10,6 +11,8 @@ class CustomJSONEncoder(I18nJSONEncoder): return obj.to_string() elif isinstance(obj, File): return obj.name + if isinstance(obj, PhoneNumber): + return str(obj) else: return super().default(obj) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 6adeaad011..e39f7765f6 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -292,6 +292,7 @@ INSTALLED_APPS = [ 'hijack', 'compat', 'oauth2_provider', + 'phonenumber_field' ] try: diff --git a/src/requirements/production.txt b/src/requirements/production.txt index ef1eeb0420..d99e7404ae 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -60,3 +60,5 @@ django-localflavor>=2.2 urllib3==1.24.* django-redis==4.10.* redis==3.2.* +django-phonenumber-field==3.0.* +phonenumberslite==8.10.* diff --git a/src/setup.py b/src/setup.py index 9d24967df3..4a23e175c5 100644 --- a/src/setup.py +++ b/src/setup.py @@ -146,6 +146,8 @@ setup( 'django-oauth-toolkit==1.2.*', 'oauthlib==2.1.*', 'urllib3==1.24.*', # required by current requests + 'django-phonenumber-field==3.0.*', + 'phonenumberslite==8.10.*', ], extras_require={ 'dev': [