mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
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:
committed by
Raphael Michel
parent
06a744ea2d
commit
54091b9721
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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'):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ INSTALLED_APPS = [
|
|||||||
'hijack',
|
'hijack',
|
||||||
'compat',
|
'compat',
|
||||||
'oauth2_provider',
|
'oauth2_provider',
|
||||||
|
'phonenumber_field'
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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.*
|
||||||
|
|||||||
@@ -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': [
|
||||||
|
|||||||
Reference in New Issue
Block a user