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
This commit is contained in:
Martin Gross
2019-11-13 12:52:07 +01:00
committed by Raphael Michel
parent 06a744ea2d
commit 54091b9721
10 changed files with 83 additions and 17 deletions

View File

@@ -31,6 +31,7 @@ type string The expected ty
* ``H`` time * ``H`` time
* ``W`` date and time * ``W`` date and time
* ``CC`` country code (ISO 3666-1 alpha-2) * ``CC`` country code (ISO 3666-1 alpha-2)
* ``TEL`` telephone number
required boolean If ``true``, the question needs to be filled out. required boolean If ``true``, the question needs to be filled out.
position integer An integer, used for sorting position integer An integer, used for sorting
items list of integers List of item IDs this question is assigned to. items list of integers List of item IDs this question is assigned to.

View File

@@ -267,6 +267,9 @@ class QuestionSerializer(I18nAwareModelSerializer):
seen_ids.add(dep.pk) seen_ids.add(dep.pk)
dep = dep.dependency_question 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')) Question.clean_items(event, full_data.get('items'))
return data return data

View File

@@ -9,6 +9,7 @@ import pycountry
import pytz import pytz
import vat_moss.errors import vat_moss.errors
import vat_moss.id import vat_moss.id
from babel import localedata
from django import forms from django import forms
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -21,11 +22,16 @@ from django.utils.translation import (
) )
from django_countries import countries from django_countries import countries
from django_countries.fields import Country, CountryField 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 ( from pretix.base.forms.widgets import (
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget, BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
TimePickerWidget, UploadedFileWidget, TimePickerWidget, UploadedFileWidget,
) )
from pretix.base.i18n import language
from pretix.base.models import InvoiceAddress, Question, QuestionOption from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
from pretix.base.settings import ( from pretix.base.settings import (
@@ -179,6 +185,34 @@ class NamePartsFormField(forms.MultiValueField):
return value 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 '<div class="nameparts-form-group">%s</div>' % ''.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): class BaseQuestionsForm(forms.Form):
""" """
This form class is responsible for asking order-related questions. This includes 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, 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')), 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 field.question = q
if answers: if answers:
# Cache the answer object for later use # Cache the answer object for later use
@@ -433,23 +489,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
kwargs.setdefault('initial', {}) kwargs.setdefault('initial', {})
if not kwargs.get('instance') or not kwargs['instance'].country: if not kwargs.get('instance') or not kwargs['instance'].country:
# Try to guess the initial country from either the country of the merchant kwargs['initial']['country'] = guess_country(self.event)
# 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
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid: if not event.settings.invoice_address_vatid:

View File

@@ -977,6 +977,7 @@ class Question(LoggedModel):
TYPE_TIME = "H" TYPE_TIME = "H"
TYPE_DATETIME = "W" TYPE_DATETIME = "W"
TYPE_COUNTRYCODE = "CC" TYPE_COUNTRYCODE = "CC"
TYPE_PHONENUMBER = "TEL"
TYPE_CHOICES = ( TYPE_CHOICES = (
(TYPE_NUMBER, _("Number")), (TYPE_NUMBER, _("Number")),
(TYPE_STRING, _("Text (one line)")), (TYPE_STRING, _("Text (one line)")),
@@ -989,8 +990,10 @@ class Question(LoggedModel):
(TYPE_TIME, _("Time")), (TYPE_TIME, _("Time")),
(TYPE_DATETIME, _("Date and time")), (TYPE_DATETIME, _("Date and time")),
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")), (TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
(TYPE_PHONENUMBER, _("Phone number")),
) )
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME] UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE, TYPE_PHONENUMBER]
event = models.ForeignKey( event = models.ForeignKey(
Event, Event,

View File

@@ -30,6 +30,7 @@ from django_countries.fields import Country, CountryField
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField from jsonfallback.fields import FallbackJSONField
from phonenumber_field.phonenumber import PhoneNumber
from pretix.base.banlist import banned from pretix.base.banlist import banned
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
@@ -922,6 +923,8 @@ class QuestionAnswer(models.Model):
return self.answer return self.answer
elif self.question.type == Question.TYPE_COUNTRYCODE and self.answer: elif self.question.type == Question.TYPE_COUNTRYCODE and self.answer:
return Country(self.answer).name or 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: else:
return self.answer return self.answer

View File

@@ -77,6 +77,14 @@ class QuestionForm(I18nModelForm):
dep = dep.dependency_question dep = dep.dependency_question
return val 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): def clean(self):
d = super().clean() d = super().clean()
if d.get('dependency_question') and not d.get('dependency_values'): if d.get('dependency_question') and not d.get('dependency_values'):

View File

@@ -1,5 +1,6 @@
from django.core.files import File from django.core.files import File
from i18nfield.utils import I18nJSONEncoder from i18nfield.utils import I18nJSONEncoder
from phonenumber_field.phonenumber import PhoneNumber
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
@@ -10,6 +11,8 @@ class CustomJSONEncoder(I18nJSONEncoder):
return obj.to_string() return obj.to_string()
elif isinstance(obj, File): elif isinstance(obj, File):
return obj.name return obj.name
if isinstance(obj, PhoneNumber):
return str(obj)
else: else:
return super().default(obj) return super().default(obj)

View File

@@ -292,6 +292,7 @@ INSTALLED_APPS = [
'hijack', 'hijack',
'compat', 'compat',
'oauth2_provider', 'oauth2_provider',
'phonenumber_field'
] ]
try: try:

View File

@@ -60,3 +60,5 @@ django-localflavor>=2.2
urllib3==1.24.* urllib3==1.24.*
django-redis==4.10.* django-redis==4.10.*
redis==3.2.* redis==3.2.*
django-phonenumber-field==3.0.*
phonenumberslite==8.10.*

View File

@@ -146,6 +146,8 @@ setup(
'django-oauth-toolkit==1.2.*', 'django-oauth-toolkit==1.2.*',
'oauthlib==2.1.*', 'oauthlib==2.1.*',
'urllib3==1.24.*', # required by current requests 'urllib3==1.24.*', # required by current requests
'django-phonenumber-field==3.0.*',
'phonenumberslite==8.10.*',
], ],
extras_require={ extras_require={
'dev': [ 'dev': [