diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst
index 2b4ab6ef24..02dea7043d 100644
--- a/doc/api/resources/orders.rst
+++ b/doc/api/resources/orders.rst
@@ -30,6 +30,7 @@ testmode boolean If ``true``, th
test mode. Only orders in test mode can be deleted.
secret string The secret contained in the link sent to the customer
email string The customer email address
+phone string The customer phone number
locale string The locale used for communication with this customer
sales_channel string Channel this sale was created through, such as
``"web"``.
@@ -167,6 +168,10 @@ last_modified datetime Last modificati
The ``subevent_before`` query parameter has been added.
+.. versionchanged:: 3.14
+
+ The ``phone`` attribute has been added.
+
.. _order-position-resource:
@@ -372,6 +377,7 @@ List of all orders
"secret": "k24fiuwvu8kxz3y1",
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
"email": "tester@example.org",
+ "phone": "+491234567",
"locale": "en",
"sales_channel": "web",
"datetime": "2017-12-01T10:00:00Z",
@@ -539,6 +545,7 @@ Fetching individual orders
"secret": "k24fiuwvu8kxz3y1",
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
"email": "tester@example.org",
+ "phone": "+491234567",
"locale": "en",
"sales_channel": "web",
"datetime": "2017-12-01T10:00:00Z",
@@ -705,6 +712,8 @@ Updating order fields
* ``email``
+ * ``phone``
+
* ``checkin_attention``
* ``locale``
diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py
index 78ba309b0b..8dd21497cb 100644
--- a/src/pretix/api/serializers/event.py
+++ b/src/pretix/api/serializers/event.py
@@ -601,6 +601,9 @@ class EventSettingsSerializer(serializers.Serializer):
'attendee_data_explanation_text',
'confirm_texts',
'order_email_asked_twice',
+ 'order_phone_asked',
+ 'order_phone_required',
+ 'checkout_phone_helptext',
'payment_term_mode',
'payment_term_days',
'payment_term_weekdays',
diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py
index c7ddc7635a..11e5ca0b84 100644
--- a/src/pretix/api/serializers/order.py
+++ b/src/pretix/api/serializers/order.py
@@ -361,7 +361,7 @@ class OrderSerializer(I18nAwareModelSerializer):
class Meta:
model = Order
fields = (
- 'code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
+ 'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url'
@@ -393,7 +393,7 @@ class OrderSerializer(I18nAwareModelSerializer):
def update(self, instance, validated_data):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
- update_fields = ['comment', 'checkin_attention', 'email', 'locale']
+ update_fields = ['comment', 'checkin_attention', 'email', 'locale', 'phone']
if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address')
@@ -691,7 +691,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
class Meta:
model = Order
- fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
+ fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
'force', 'send_email', 'simulate')
diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py
index 4321f84a3d..616d47ce26 100644
--- a/src/pretix/api/views/order.py
+++ b/src/pretix/api/views/order.py
@@ -674,6 +674,17 @@ class OrderViewSet(viewsets.ModelViewSet):
}
)
+ if 'phone' in self.request.data and serializer.instance.phone != self.request.data.get('phone'):
+ serializer.instance.log_action(
+ 'pretix.event.order.phone.changed',
+ user=self.request.user,
+ auth=self.request.auth,
+ data={
+ 'old_phone': serializer.instance.phone,
+ 'new_phone': self.request.data.get('phone'),
+ }
+ )
+
if 'locale' in self.request.data and serializer.instance.locale != self.request.data.get('locale'):
serializer.instance.log_action(
'pretix.event.order.locale.changed',
diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py
index bf671cffa5..b13a33ef7b 100644
--- a/src/pretix/base/exporters/orderlist.py
+++ b/src/pretix/base/exporters/orderlist.py
@@ -139,7 +139,7 @@ class OrderListExporter(MultiSheetListExporter):
tax_rates = self._get_all_tax_rates(qs)
headers = [
- _('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
+ _('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Phone number'), _('Order date'),
_('Order time'), _('Company'), _('Name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
@@ -215,6 +215,7 @@ class OrderListExporter(MultiSheetListExporter):
order.total,
order.get_status_display(),
order.email,
+ str(order.phone) if order.phone else '',
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
]
@@ -303,6 +304,7 @@ class OrderListExporter(MultiSheetListExporter):
_('Order code'),
_('Status'),
_('Email'),
+ _('Phone number'),
_('Order date'),
_('Order time'),
_('Fee type'),
@@ -334,6 +336,7 @@ class OrderListExporter(MultiSheetListExporter):
order.code,
order.get_status_display(),
order.email,
+ str(order.phone) if order.phone else '',
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
op.get_fee_type_display(),
@@ -402,6 +405,7 @@ class OrderListExporter(MultiSheetListExporter):
_('Position ID'),
_('Status'),
_('Email'),
+ _('Phone number'),
_('Order date'),
_('Order time'),
]
@@ -481,6 +485,7 @@ class OrderListExporter(MultiSheetListExporter):
op.positionid,
order.get_status_display(),
order.email,
+ str(order.phone) if order.phone else '',
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
]
diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py
index fcca924a96..21a89a4679 100644
--- a/src/pretix/base/forms/questions.py
+++ b/src/pretix/base/forms/questions.py
@@ -28,7 +28,7 @@ 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 import NumberParseException
+from phonenumbers import NumberParseException, national_significant_number
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
from pretix.base.forms.widgets import (
@@ -212,6 +212,38 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def format_output(self, rendered_widgets) -> str:
return '
%s
' % ''.join(rendered_widgets)
+ def decompress(self, value):
+ """
+ If an incomplete phone number (e.g. without country prefix) is currently entered,
+ the default implementation just discards the value and shows nothing at all.
+ Let's rather show something invalid, so the user is prompted to fix it, instead of
+ silently deleting data.
+ """
+ if value:
+ if type(value) == PhoneNumber:
+ if value.country_code and value.national_number:
+ return [
+ "+%d" % value.country_code,
+ national_significant_number(value),
+ ]
+ return [
+ None,
+ str(value)
+ ]
+ elif "." in value:
+ return value.split(".")
+ else:
+ return [None, value]
+ return [None, ""]
+
+ def value_from_datadict(self, data, files, name):
+ # In contrast to defualt implementation, do not silently fail if a number without
+ # country prefix is entered
+ values = super(PhoneNumberPrefixWidget, self).value_from_datadict(data, files, name)
+ if values[1]:
+ return "%s.%s" % tuple(values)
+ return ""
+
def guess_country(event):
# Try to guess the initial country from either the country of the merchant
diff --git a/src/pretix/base/migrations/0173_auto_20201211_1648.py b/src/pretix/base/migrations/0173_auto_20201211_1648.py
new file mode 100644
index 0000000000..28766c7af4
--- /dev/null
+++ b/src/pretix/base/migrations/0173_auto_20201211_1648.py
@@ -0,0 +1,51 @@
+# Generated by Django 3.0.11 on 2020-12-11 16:48
+import json
+
+import phonenumber_field.modelfields
+from django.db import migrations
+
+import pretix.base.models.fields
+
+
+def migrate_settings(apps, schema_editor):
+ Order = apps.get_model('pretixbase', 'Order')
+ Event = apps.get_model('pretixbase', 'Event')
+ Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
+ Event_SettingsStore.objects.filter(key='telephone_field_required').update(key='order_phone_required')
+ Event_SettingsStore.objects.filter(key='telephone_field_help_text').update(key='checkout_phone_helptext')
+ for e in Event.objects.filter(plugins__icontains="pretix_telephone"):
+ plugins = e.plugins.split(",")
+ plugins.remove("pretix_telephone")
+ e.plugins = ",".join(plugins)
+ e.save()
+ Event_SettingsStore.objects.create(object=e, key='order_phone_asked', value='True')
+ for o in Order.objects.filter(meta_info__icontains='"telephone"'):
+ mi = json.loads(o.meta_info)
+ if 'telephone' in mi.get('contact_form_data', {}):
+ mi['phone'] = mi['contact_form_data'].pop('telephone')
+ o.phone = mi['phone']
+ o.meta_info = json.dumps(mi)
+ o.save(update_fields=['meta_info', 'phone'])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pretixbase', '0172_event_sales_channels'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='order',
+ name='phone',
+ field=phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='sales_channels',
+ field=pretix.base.models.fields.MultiStringField(default=['web']),
+ ),
+ migrations.RunPython(
+ migrate_settings, migrations.RunPython.noop,
+ )
+ ]
diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py
index abdfb77f0c..3d29afcb28 100644
--- a/src/pretix/base/models/orders.py
+++ b/src/pretix/base/models/orders.py
@@ -31,6 +31,7 @@ from django_countries.fields import Country
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField
+from phonenumber_field.modelfields import PhoneNumberField
from phonenumber_field.phonenumber import PhoneNumber
from phonenumbers import NumberParseException
@@ -86,6 +87,8 @@ class Order(LockModel, LoggedModel):
:type event: Event
:param email: The email of the person who ordered this
:type email: str
+ :param phone: The phone number of the person who ordered this
+ :type phone: str
:param testmode: Whether this is a test mode order
:type testmode: bool
:param locale: The locale of this order
@@ -144,6 +147,10 @@ class Order(LockModel, LoggedModel):
null=True, blank=True,
verbose_name=_('E-mail')
)
+ phone = PhoneNumberField(
+ null=True, blank=True,
+ verbose_name=_('Phone number'),
+ )
locale = models.CharField(
null=True, blank=True, max_length=32,
verbose_name=_('Locale')
diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py
index 5913ab04d5..2eaa45f376 100644
--- a/src/pretix/base/pdf.py
+++ b/src/pretix/base/pdf.py
@@ -39,6 +39,7 @@ from pretix.base.models import Order, OrderPosition
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_text_variables
from pretix.base.templatetags.money import money_filter
+from pretix.base.templatetags.phone_format import phone_format
from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__)
@@ -229,6 +230,11 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Random City"),
"evaluate": lambda op, order, ev: str(ev.location)
}),
+ ("telephone", {
+ "label": _("Phone number"),
+ "editor_sample": "+01 1234 567890",
+ "evaluate": lambda op, order, ev: phone_format(order.phone)
+ }),
("invoice_name", {
"label": _("Invoice address name"),
"editor_sample": _("John Doe"),
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index a498bb6493..c0b8602a6a 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -778,6 +778,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
status=Order.STATUS_PENDING,
event=event,
email=email,
+ phone=(meta_info or {}).get('contact_form_data', {}).get('phone'),
datetime=now_dt,
locale=get_language_without_region(locale),
total=total,
diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py
index 912fd8eaf4..e3e339ea46 100644
--- a/src/pretix/base/settings.py
+++ b/src/pretix/base/settings.py
@@ -193,6 +193,25 @@ DEFAULTS = {
help_text=_("Require customers to fill in the primary email address twice to avoid errors."),
)
},
+ 'order_phone_asked': {
+ 'default': 'False',
+ 'type': bool,
+ 'form_class': forms.BooleanField,
+ 'serializer_class': serializers.BooleanField,
+ 'form_kwargs': dict(
+ label=_("Ask for a phone number per order"),
+ )
+ },
+ 'order_phone_required': {
+ 'default': 'False',
+ 'type': bool,
+ 'form_class': forms.BooleanField,
+ 'serializer_class': serializers.BooleanField,
+ 'form_kwargs': dict(
+ label=_("Require a phone number per order"),
+ widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}),
+ )
+ },
'invoice_address_asked': {
'default': 'True',
'type': bool,
@@ -1859,6 +1878,17 @@ Your {event} team"""))
"why you need information from them.")
)
},
+ 'checkout_phone_helptext': {
+ 'default': '',
+ 'type': LazyI18nString,
+ 'serializer_class': I18nField,
+ 'form_class': I18nFormField,
+ 'form_kwargs': dict(
+ label=_("Help text of the phone number field"),
+ widget_kwargs={'attrs': {'rows': '2'}},
+ widget=I18nTextarea
+ )
+ },
'checkout_email_helptext': {
'default': LazyI18nString.from_gettext(gettext_noop(
'Make sure to enter a valid email address. We will send you an order '
diff --git a/src/pretix/base/shredder.py b/src/pretix/base/shredder.py
index 61b5f3a444..9174c11ea0 100644
--- a/src/pretix/base/shredder.py
+++ b/src/pretix/base/shredder.py
@@ -20,6 +20,7 @@ from pretix.base.models import (
)
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.signals import register_data_shredders
+from pretix.helpers.json import CustomJSONEncoder
class ShredError(LazyLocaleException):
@@ -121,6 +122,31 @@ def shred_log_fields(logentry, banlist=None, whitelist=None):
logentry.save(update_fields=['data', 'shredded'])
+class PhoneNumberShredder(BaseDataShredder):
+ verbose_name = _('Phone numbers')
+ identifier = 'phone_numbers'
+ description = _('This will remove all phone numbers from orders.')
+
+ def generate_files(self) -> List[Tuple[str, str, str]]:
+ yield 'phone-by-order.json', 'application/json', json.dumps({
+ o.code: o.phone for o in self.event.orders.filter(phone__isnull=False)
+ }, cls=CustomJSONEncoder, indent=4)
+
+ @transaction.atomic
+ def shred_data(self):
+ for o in self.event.orders.all():
+ o.phone = None
+ d = o.meta_info_data
+ if d:
+ if 'contact_form_data' in d and 'phone' in d['contact_form_data']:
+ del d['contact_form_data']['phone']
+ o.meta_info = json.dumps(d)
+ o.save(update_fields=['meta_info', 'phone'])
+
+ for le in self.event.logentry_set.filter(action_type="pretix.event.order.phone.changed"):
+ shred_log_fields(le, banlist=['old_phone', 'new_phone'])
+
+
class EmailAddressShredder(BaseDataShredder):
verbose_name = _('E-mails')
identifier = 'order_emails'
@@ -372,9 +398,10 @@ class PaymentInfoShredder(BaseDataShredder):
@receiver(register_data_shredders, dispatch_uid="shredders_builtin")
-def register_payment_provider(sender, **kwargs):
+def register_core_shredders(sender, **kwargs):
return [
EmailAddressShredder,
+ PhoneNumberShredder,
AttendeeInfoShredder,
InvoiceAddressShredder,
QuestionAnswerShredder,
diff --git a/src/pretix/base/templatetags/phone_format.py b/src/pretix/base/templatetags/phone_format.py
new file mode 100644
index 0000000000..8d834c84c0
--- /dev/null
+++ b/src/pretix/base/templatetags/phone_format.py
@@ -0,0 +1,19 @@
+from django import template
+from phonenumber_field.phonenumber import PhoneNumber
+from phonenumbers import NumberParseException
+
+register = template.Library()
+
+
+@register.filter("phone_format")
+def phone_format(value: str):
+ if not value:
+ return ""
+
+ if isinstance(value, PhoneNumber):
+ return value.as_international
+
+ try:
+ return PhoneNumber.from_string(value).as_international
+ except NumberParseException:
+ return value
diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py
index 9eebca1964..ffed86814e 100644
--- a/src/pretix/control/forms/event.py
+++ b/src/pretix/control/forms/event.py
@@ -481,6 +481,9 @@ class EventSettingsForm(SettingsForm):
'attendee_addresses_asked',
'attendee_addresses_required',
'attendee_data_explanation_text',
+ 'order_phone_asked',
+ 'order_phone_required',
+ 'checkout_phone_helptext',
'banner_text',
'banner_text_bottom',
'order_email_asked_twice',
diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py
index 7976907c00..e8b0df0715 100644
--- a/src/pretix/control/forms/orders.py
+++ b/src/pretix/control/forms/orders.py
@@ -16,6 +16,7 @@ from i18nfield.strings import LazyI18nString
from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator
+from pretix.base.forms.questions import WrappedPhoneNumberPrefixWidget
from pretix.base.forms.widgets import (
DatePickerWidget, SplitDateTimePickerWidget,
)
@@ -460,7 +461,15 @@ class OrderContactForm(forms.ModelForm):
class Meta:
model = Order
- fields = ['email', 'email_known_to_work']
+ fields = ['email', 'email_known_to_work', 'phone']
+ widgets = {
+ 'phone': WrappedPhoneNumberPrefixWidget()
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if not self.instance.event.settings.order_phone_asked and not self.instance.phone:
+ del self.fields['phone']
class OrderLocaleForm(forms.ModelForm):
diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py
index 1685fc4d60..34d4e190d4 100644
--- a/src/pretix/control/logdisplay.py
+++ b/src/pretix/control/logdisplay.py
@@ -292,6 +292,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.denied': _('The order has been denied.'),
'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" '
'to "{new_email}".'),
+ 'pretix.event.order.phone.changed': _('The phone number has been changed from "{old_phone}" '
+ 'to "{new_phone}".'),
'pretix.event.order.locale.changed': _('The order locale has been changed.'),
'pretix.event.order.invoice.generated': _('The invoice has been generated.'),
'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'),
diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html
index 72eb5f63c5..40ec206df3 100644
--- a/src/pretix/control/templates/pretixcontrol/event/settings.html
+++ b/src/pretix/control/templates/pretixcontrol/event/settings.html
@@ -86,20 +86,113 @@
{% bootstrap_field sform.region layout="control" %}
diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html
index 6e49c15dd6..a3e9f84d88 100644
--- a/src/pretix/control/templates/pretixcontrol/order/index.html
+++ b/src/pretix/control/templates/pretixcontrol/order/index.html
@@ -6,6 +6,7 @@
{% load rich_text %}
{% load safelink %}
{% load eventsignal %}
+{% load phone_format %}
{% block title %}
{% blocktrans trimmed with code=order.code %}
Order details: {{ code }}
@@ -201,6 +202,15 @@
{% endif %}
{% endif %}
+ {% if order.phone or request.event.settings.order_phone_asked %}
+ {% trans "Phone number" %}
+
+ {{ order.phone|default_if_none:""|phone_format }}
+
+
+
+
+ {% endif %}
{% if invoices %}
{% trans "Invoices" %}
diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py
index 45d7512f8c..b8f05fa59f 100644
--- a/src/pretix/control/views/orders.py
+++ b/src/pretix/control/views/orders.py
@@ -1663,6 +1663,7 @@ class OrderContactChange(OrderView):
def post(self, *args, **kwargs):
old_email = self.order.email
+ old_phone = self.order.phone
changed = False
if self.form.is_valid():
new_email = self.form.cleaned_data['email']
@@ -1677,6 +1678,18 @@ class OrderContactChange(OrderView):
user=self.request.user,
)
+ new_phone = self.form.cleaned_data.get('phone')
+ if new_phone != old_phone:
+ changed = True
+ self.order.log_action(
+ 'pretix.event.order.phone.changed',
+ data={
+ 'old_phone': old_phone,
+ 'new_phone': self.form.cleaned_data['phone'],
+ },
+ user=self.request.user,
+ )
+
if self.form.cleaned_data['regenerate_secrets']:
changed = True
self.order.secret = generate_secret()
diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py
index 98a5590482..06ce274303 100644
--- a/src/pretix/presale/checkoutflow.py
+++ b/src/pretix/presale/checkoutflow.py
@@ -442,7 +442,8 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
'email': (
self.cart_session.get('email', '') or
wd.get('email', '')
- )
+ ),
+ 'phone': wd.get('phone', None)
}
initial.update(self.cart_session.get('contact_form_data', {}))
@@ -549,7 +550,10 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
_("We had difficulties processing your input. Please review the errors below."))
return self.render()
self.cart_session['email'] = self.contact_form.cleaned_data['email']
- self.cart_session['contact_form_data'] = self.contact_form.cleaned_data
+ d = dict(self.contact_form.cleaned_data)
+ if d.get('phone'):
+ d['phone'] = str(d['phone'])
+ self.cart_session['contact_form_data'] = d
if self.address_asked or self.request.event.settings.invoice_name_required:
addr = self.invoice_form.save()
try:
@@ -820,6 +824,9 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
]
else:
ctx['contact_info'] = []
+ phone = self.cart_session.get('contact_form_data', {}).get('phone')
+ if phone:
+ ctx['contact_info'].append((_('Phone number'), phone))
responses = contact_form_fields.send(self.event, request=self.request)
for r, response in sorted(responses, key=lambda r: str(r[0])):
for key, value in response.items():
diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py
index 23600c9448..6a59c52392 100644
--- a/src/pretix/presale/forms/checkout.py
+++ b/src/pretix/presale/forms/checkout.py
@@ -1,13 +1,20 @@
from itertools import chain
+from babel import localedata
from django import forms
from django.core.exceptions import ValidationError
from django.utils.encoding import force_str
-from django.utils.translation import gettext_lazy as _
+from django.utils.translation import get_language, gettext_lazy as _
+from phonenumber_field.formfields import PhoneNumberField
+from phonenumber_field.phonenumber import PhoneNumber
+from phonenumbers import NumberParseException
+from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
from pretix.base.forms.questions import (
- BaseInvoiceAddressForm, BaseQuestionsForm,
+ BaseInvoiceAddressForm, BaseQuestionsForm, WrappedPhoneNumberPrefixWidget,
+ guess_country,
)
+from pretix.base.i18n import language
from pretix.base.validators import EmailBanlistValidator
from pretix.presale.signals import contact_form_fields
@@ -31,6 +38,35 @@ class ContactForm(forms.Form):
help_text=_('Please enter the same email address again to make sure you typed it correctly.'),
)
+ if self.event.settings.order_phone_asked:
+ babel_locale = 'en'
+ # Babel, and therefore django-phonenumberfield, do not support our custom locales such as 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(self.event)
+ default_prefix = None
+ for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
+ if str(default_country) in values:
+ default_prefix = prefix
+ try:
+ initial = self.initial.pop('phone', None)
+ initial = PhoneNumber().from_string(initial) if initial else "+{}.".format(default_prefix)
+ except NumberParseException:
+ initial = None
+ self.fields['phone'] = PhoneNumberField(
+ label=_('Phone number'),
+ required=self.event.settings.order_phone_required,
+ help_text=self.event.settings.checkout_phone_helptext,
+ # 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=initial,
+ widget=WrappedPhoneNumberPrefixWidget()
+ )
+
if not self.request.session.get('iframe_session', False):
# There is a browser quirk in Chrome that leads to incorrect initial scrolling in iframes if there
# is an autofocus field. Who would have thought… See e.g. here:
diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html
index f70746370e..4880010cf8 100644
--- a/src/pretix/presale/templates/pretixpresale/event/order.html
+++ b/src/pretix/presale/templates/pretixpresale/event/order.html
@@ -5,6 +5,7 @@
{% load money %}
{% load expiresformat %}
{% load eventurl %}
+{% load phone_format %}
{% block title %}{% trans "Order details" %}{% endblock %}
{% block content %}
{% if "thanks" in request.GET or "paid" in request.GET %}
@@ -205,7 +206,7 @@
{% eventsignal event "pretix.presale.signals.order_info" order=order request=request %}
{% if invoices %}
-
+
{% elif can_generate_invoice %}
-
+
{% endif %}
- {% if invoice_address_asked or request.event.settings.invoice_name_required %}
-
-
-
+
+
+
+ {% if invoice_address_asked or request.event.settings.invoice_name_required %}
{% if order.can_modify_answers %}
{% endif %}
-
- {% if request.event.settings.invoice_address_asked %}
- {% trans "Invoice information" %}
- {% else %}
- {% trans "Contact information" %}
+ {% endif %}
+
+ {% trans "Your information" %}
+
+
+
+
+ {% if order.email %}
+ - {% trans "E-mail" %}
+ - {{ order.email }}
+ {% endif %}
+ {% if order.phone %}
+ - {% trans "Phone number" %}
+ - {{ order.phone|phone_format }}
+ {% endif %}
+ {% if invoice_address_asked %}
+ - {% trans "Company" %}
+ - {{ order.invoice_address.company }}
+ {% endif %}
+ - {% trans "Name" %}
+ - {{ order.invoice_address.name }}
+ {% if invoice_address_asked %}
+ - {% trans "Address" %}
+ - {{ order.invoice_address.street|linebreaksbr }}
+ - {% trans "ZIP code and city" %}
+ - {{ order.invoice_address.zipcode }} {{ order.invoice_address.city }}
+ - {% trans "Country" %}
+ - {{ order.invoice_address.country.name|default:order.invoice_address.country_old }}
+ {% if order.invoice_address.state %}
+ - {% trans "State" context "address" %}
+ - {{ order.invoice_address.state_name }}
{% endif %}
-
-
-
-
- {% if invoice_address_asked %}
- - {% trans "Company" %}
- - {{ order.invoice_address.company }}
+ {% if request.event.settings.invoice_address_vatid %}
+ - {% trans "VAT ID" %}
+ - {{ order.invoice_address.vat_id }}
{% endif %}
- - {% trans "Name" %}
- - {{ order.invoice_address.name }}
- {% if invoice_address_asked %}
- - {% trans "Address" %}
- - {{ order.invoice_address.street|linebreaksbr }}
- - {% trans "ZIP code and city" %}
- - {{ order.invoice_address.zipcode }} {{ order.invoice_address.city }}
- - {% trans "Country" %}
- - {{ order.invoice_address.country.name|default:order.invoice_address.country_old }}
- {% if order.invoice_address.state %}
- - {% trans "State" context "address" %}
- - {{ order.invoice_address.state_name }}
- {% endif %}
- {% if request.event.settings.invoice_address_vatid %}
- - {% trans "VAT ID" %}
- - {{ order.invoice_address.vat_id }}
- {% endif %}
- {% if request.event.settings.invoice_address_custom_field and order.invoice_address.custom_field %}
- - {{ request.event.settings.invoice_address_custom_field }}
- - {{ order.invoice_address.custom_field }}
- {% endif %}
- - {% trans "Internal Reference" %}
- - {{ order.invoice_address.internal_reference }}
+ {% if request.event.settings.invoice_address_custom_field and order.invoice_address.custom_field %}
+ - {{ request.event.settings.invoice_address_custom_field }}
+ - {{ order.invoice_address.custom_field }}
{% endif %}
-
-
+
{% trans "Internal Reference" %}
+
{{ order.invoice_address.internal_reference }}
+ {% endif %}
+
- {% endif %}
+
{% if user_change_allowed or user_cancel_allowed %}
diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js
index 7da6218d97..23c0a91f9b 100644
--- a/src/pretix/static/pretixcontrol/js/ui/main.js
+++ b/src/pretix/static/pretixcontrol/js/ui/main.js
@@ -258,7 +258,7 @@ var form_handlers = function (el) {
dependency = $($(this).attr("data-checkbox-dependency")),
update = function () {
var enabled = dependency.prop('checked');
- dependent.prop('disabled', !enabled).parents('.form-group').toggleClass('disabled', !enabled);
+ dependent.prop('disabled', !enabled).closest('.form-group, .form-field-boundary').toggleClass('disabled', !enabled);
if (!enabled && !$(this).is('[data-checkbox-dependency-visual]')) {
dependent.prop('checked', false);
}
@@ -278,7 +278,7 @@ var form_handlers = function (el) {
var dependent = $(this),
update = function () {
var enabled = !dependency.prop('checked');
- dependent.prop('disabled', !enabled).parents('.form-group').toggleClass('disabled', !enabled);
+ dependent.prop('disabled', !enabled).closest('.form-group, .form-field-boundary').toggleClass('disabled', !enabled);
};
update();
dependency.on("change", update);
diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss
index c0c88f838c..23c97269cb 100644
--- a/src/pretix/static/pretixcontrol/scss/_forms.scss
+++ b/src/pretix/static/pretixcontrol/scss/_forms.scss
@@ -80,6 +80,19 @@ div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], d
.tabbed-form > .tab-pane {
padding: 10px;
+
+ h4 {
+ // like bs3 legend
+ display: block;
+ width: 100%;
+ padding: 0;
+ margin-bottom: $line-height-computed;
+ font-size: ($font-size-base * 1.5);
+ line-height: inherit;
+ color: $legend-color;
+ border: 0;
+ border-bottom: 1px solid $legend-border-color;
+ }
}
.nav-tabs {
diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py
index ececd20da0..77c63e839a 100644
--- a/src/tests/api/test_orders.py
+++ b/src/tests/api/test_orders.py
@@ -211,6 +211,7 @@ TEST_ORDER_RES = {
"testmode": False,
"secret": "k24fiuwvu8kxz3y1",
"email": "dummy@dummy.test",
+ "phone": None,
"locale": "en",
"datetime": "2017-12-01T10:00:00Z",
"expires": "2017-12-10T10:00:00Z",
@@ -1528,6 +1529,7 @@ def test_order_invalid_state_deny(token_client, organizer, event, order):
ORDER_CREATE_PAYLOAD = {
"email": "dummy@dummy.test",
+ "phone": "+49622112345",
"locale": "en",
"sales_channel": "web",
"fees": [
@@ -1589,6 +1591,7 @@ def test_order_create(token_client, organizer, event, item, quota, question):
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
assert o.email == "dummy@dummy.test"
+ assert o.phone == "+49622112345"
assert o.locale == "en"
assert o.total == Decimal('23.25')
assert o.status == Order.STATUS_PENDING
@@ -1657,6 +1660,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'status': 'n',
'testmode': False,
'email': 'dummy@dummy.test',
+ 'phone': '+49622112345',
'locale': 'en',
'datetime': None,
'payment_date': None,
@@ -4107,6 +4111,7 @@ def test_order_update_allowed_fields(token_client, organizer, event, order):
'comment': 'Here is a comment',
'checkin_attention': True,
'email': 'foo@bar.com',
+ 'phone': '+4962219999',
'locale': 'de',
'invoice_address': {
"is_business": False,
@@ -4128,6 +4133,7 @@ def test_order_update_allowed_fields(token_client, organizer, event, order):
assert order.comment == 'Here is a comment'
assert order.checkin_attention
assert order.email == 'foo@bar.com'
+ assert order.phone == '+4962219999'
assert order.locale == 'de'
assert order.invoice_address.company == "This is my company name"
assert order.invoice_address.name_cached == "John Doe"
@@ -4139,6 +4145,7 @@ def test_order_update_allowed_fields(token_client, organizer, event, order):
assert order.all_logentries().get(action_type='pretix.event.order.comment')
assert order.all_logentries().get(action_type='pretix.event.order.checkin_attention')
assert order.all_logentries().get(action_type='pretix.event.order.contact.changed')
+ assert order.all_logentries().get(action_type='pretix.event.order.phone.changed')
assert order.all_logentries().get(action_type='pretix.event.order.locale.changed')
assert order.all_logentries().get(action_type='pretix.event.order.modified')
diff --git a/src/tests/base/__init__.py b/src/tests/base/__init__.py
index a159e8232b..1fbe325330 100644
--- a/src/tests/base/__init__.py
+++ b/src/tests/base/__init__.py
@@ -33,7 +33,7 @@ def extract_form_fields(soup):
continue
if field['type'] in ('checkbox', 'radio'):
- if field.has_attr('checked'):
+ if field.has_attr('checked') and field.has_attr('name'):
data[field['name']] = field.get('value', 'on')
continue
elif field.has_attr('name'):
diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py
index 7616dde206..109c359d25 100644
--- a/src/tests/presale/test_checkout.py
+++ b/src/tests/presale/test_checkout.py
@@ -568,6 +568,44 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
assert not cr1.answers.exists()
assert not os.path.exists(os.path.join(settings.MEDIA_ROOT, a.file.name))
+ def test_phone_required(self):
+ self.event.settings.set('order_phone_asked', True)
+ self.event.settings.set('order_phone_required', True)
+ 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.rendered_content, "lxml")
+ self.assertEqual(len(doc.select('input[name="phone_1"]')), 1)
+
+ # Not all required fields filled out, expect failure
+ response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
+ 'email': 'admin@localhost',
+ }, follow=True)
+ doc = BeautifulSoup(response.rendered_content, "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), {
+ 'email': 'admin@localhost',
+ 'phone_0': '+49',
+ 'phone_1': '0622199999', # yeah the 0 is wrong but users don't know that so it should work fine
+ }, follow=True)
+ self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
+ target_status_code=200)
+
+ self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
+ 'payment': 'banktransfer',
+ }, follow=True)
+ response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
+ doc = BeautifulSoup(response.rendered_content, "lxml")
+ self.assertEqual(len(doc.select(".thank-you")), 1)
+ with scopes_disabled():
+ o = Order.objects.last()
+ assert o.phone == '+49622199999'
+
def test_attendee_email_required(self):
self.event.settings.set('attendee_emails_asked', True)
self.event.settings.set('attendee_emails_required', True)