Add order-level telephone field to core (#1872)

Co-authored-by: Martin Gross <gross@rami.io>
This commit is contained in:
Raphael Michel
2020-12-15 09:20:44 +01:00
committed by GitHub
parent c47e41ac8a
commit 4240ad43d0
27 changed files with 507 additions and 69 deletions

View File

@@ -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``

View File

@@ -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',

View File

@@ -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')

View File

@@ -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',

View File

@@ -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'),
]

View File

@@ -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 '<div class="nameparts-form-group">%s</div>' % ''.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

View File

@@ -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,
)
]

View File

@@ -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')

View File

@@ -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"),

View File

@@ -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,

View File

@@ -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 '

View File

@@ -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,

View File

@@ -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

View File

@@ -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',

View File

@@ -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):

View File

@@ -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.'),

View File

@@ -86,20 +86,113 @@
{% bootstrap_field sform.region layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Attendee data" %}</legend>
{% bootstrap_field sform.attendee_names_asked layout="control" %}
{% bootstrap_field sform.attendee_names_required layout="control" %}
<legend>{% trans "Customer and attendee data" %}</legend>
{% trans "Asked" context "attendee_data" as asked %}
{% trans "Required" context "attendee_data" as required %}
<h4>{% trans "Customer data (once per order)" %}</h4>
<div class="form-group">
<label class="control-label col-md-3">
{% trans "E-mail" %}
</label>
<div class="col-md-3">
<div class="checkbox">
<label><input type="checkbox" checked="checked" disabled="disabled"> {{ asked }}</label>
</div>
</div>
<div class="col-md-3">
<div class="checkbox">
<label><input type="checkbox" checked="checked" disabled="disabled"> {{ required }}</label>
</div>
</div>
</div>
{% bootstrap_field sform.order_email_asked_twice layout="control" %}
<div class="form-group">
<label class="control-label col-md-3">
{% trans "Phone number" %}
</label>
<div class="col-md-3 form-field-boundary">
{% bootstrap_field sform.order_phone_asked layout="inline" form_group_class="" label=asked %}
</div>
<div class="col-md-3 form-field-boundary">
{% bootstrap_field sform.order_phone_required layout="inline" form_group_class="" label=required %}
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3">
{% trans "Name and address" %}
</label>
<div class="col-md-9 static-form-row">
<p>
<a href="{% url "control:event.settings.invoice" event=request.event.slug organizer=request.organizer.slug %}#tab-0-1-open" target="_blank">
{% trans "See invoice settings" %}
</a>
</p>
</div>
</div>
<h4>{% trans "Attendee data (once per admission ticket)" %}</h4>
<div class="form-group">
<label class="control-label col-md-3">
{% trans "Name" %}
</label>
<div class="col-md-3 form-field-boundary">
{% bootstrap_field sform.attendee_names_asked layout="inline" form_group_class="" label=asked %}
</div>
<div class="col-md-3 form-field-boundary">
{% bootstrap_field sform.attendee_names_required layout="inline" form_group_class="" label=required %}
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3">
{% trans "E-mail" %}
</label>
<div class="col-md-3 form-field-boundary">
{% bootstrap_field sform.attendee_emails_asked layout="inline" form_group_class="" label=asked %}
</div>
<div class="col-md-3 form-field-boundary">
{% bootstrap_field sform.attendee_emails_required layout="inline" form_group_class="" label=required %}
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3">
{% trans "Company" %}
</label>
<div class="col-md-3 form-field-boundary">
{% bootstrap_field sform.attendee_company_asked layout="inline" form_group_class="" label=asked %}
</div>
<div class="col-md-3 form-field-boundary">
{% bootstrap_field sform.attendee_company_required layout="inline" form_group_class="" label=required %}
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3">
{% trans "Address" %}
</label>
<div class="col-md-3 form-field-boundary">
{% bootstrap_field sform.attendee_addresses_asked layout="inline" form_group_class="" label=asked %}
</div>
<div class="col-md-3 form-field-boundary">
{% bootstrap_field sform.attendee_addresses_required layout="inline" form_group_class="" label=required %}
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3">
{% trans "Custom fields" %}
</label>
<div class="col-md-9 static-form-row">
<p>
<a href="{% url "control:event.items.questions" event=request.event.slug organizer=request.organizer.slug %}" target="_blank">
{% trans "Manage questions" %}
</a>
</p>
</div>
</div>
{% bootstrap_field sform.attendee_data_explanation_text layout="control" %}
<h4>{% trans "Other settings" %}</h4>
{% bootstrap_field sform.name_scheme layout="control" %}
{% bootstrap_field sform.name_scheme_titles layout="control" %}
{% bootstrap_field sform.order_email_asked_twice layout="control" %}
{% bootstrap_field sform.attendee_emails_asked layout="control" %}
{% bootstrap_field sform.attendee_emails_required layout="control" %}
{% bootstrap_field sform.attendee_company_asked layout="control" %}
{% bootstrap_field sform.attendee_company_required layout="control" %}
{% bootstrap_field sform.attendee_addresses_asked layout="control" %}
{% bootstrap_field sform.attendee_addresses_required layout="control" %}
{% bootstrap_field sform.checkout_show_copy_answers_button layout="control" %}
{% bootstrap_field sform.attendee_data_explanation_text layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Texts" %}</legend>
@@ -178,6 +271,7 @@
</div>
{% bootstrap_field sform.checkout_email_helptext layout="control" %}
{% bootstrap_field sform.checkout_phone_helptext layout="control" %}
{% bootstrap_field sform.banner_text layout="control" %}
{% bootstrap_field sform.banner_text_bottom layout="control" %}
</fieldset>

View File

@@ -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 %}
</dd>
{% if order.phone or request.event.settings.order_phone_asked %}
<dt>{% trans "Phone number" %}</dt>
<dd>
{{ order.phone|default_if_none:""|phone_format }}
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
</dd>
{% endif %}
{% if invoices %}
<dt>{% trans "Invoices" %}</dt>
<dd>

View File

@@ -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()

View File

@@ -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():

View File

@@ -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:

View File

@@ -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 %}
<div class="row">
{% if invoices %}
<div class="col-xs-12 {% if invoice_address_asked or request.event.settings.invoice_name_required %}col-md-6{% endif %}">
<div class="col-xs-12 col-md-6">
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">
@@ -226,7 +227,7 @@
</div>
</div>
{% elif can_generate_invoice %}
<div class="col-xs-12 {% if invoice_address_asked or request.event.settings.invoice_name_required %}col-md-6{% endif %}">
<div class="col-xs-12 col-md-6">
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">
@@ -256,10 +257,10 @@
</div>
</div>
{% endif %}
{% if invoice_address_asked or request.event.settings.invoice_name_required %}
<div class="col-xs-12 {% if invoices or can_generate_invoice %}col-md-6{% endif %}">
<div class="panel panel-primary">
<div class="panel-heading">
<div class="col-xs-12 {% if invoices or can_generate_invoice %}col-md-6{% endif %}">
<div class="panel panel-primary">
<div class="panel-heading">
{% if invoice_address_asked or request.event.settings.invoice_name_required %}
{% if order.can_modify_answers %}
<div class="pull-right flip">
<a href="{% eventurl event "presale:event.order.modify" secret=order.secret order=order.code %}">
@@ -268,49 +269,53 @@
</a>
</div>
{% endif %}
<h3 class="panel-title">
{% if request.event.settings.invoice_address_asked %}
{% trans "Invoice information" %}
{% else %}
{% trans "Contact information" %}
{% endif %}
<h3 class="panel-title">
{% trans "Your information" %}
</h3>
</div>
<div class="panel-body">
<dl class="dl-horizontal">
{% if order.email %}
<dt>{% trans "E-mail" %}</dt>
<dd>{{ order.email }}</dd>
{% endif %}
{% if order.phone %}
<dt>{% trans "Phone number" %}</dt>
<dd>{{ order.phone|phone_format }}</dd>
{% endif %}
{% if invoice_address_asked %}
<dt>{% trans "Company" %}</dt>
<dd>{{ order.invoice_address.company }}</dd>
{% endif %}
<dt>{% trans "Name" %}</dt>
<dd>{{ order.invoice_address.name }}</dd>
{% if invoice_address_asked %}
<dt>{% trans "Address" %}</dt>
<dd>{{ order.invoice_address.street|linebreaksbr }}</dd>
<dt>{% trans "ZIP code and city" %}</dt>
<dd>{{ order.invoice_address.zipcode }} {{ order.invoice_address.city }}</dd>
<dt>{% trans "Country" %}</dt>
<dd>{{ order.invoice_address.country.name|default:order.invoice_address.country_old }}</dd>
{% if order.invoice_address.state %}
<dt>{% trans "State" context "address" %}</dt>
<dd>{{ order.invoice_address.state_name }}</dd>
{% endif %}
</h3>
</div>
<div class="panel-body">
<dl class="dl-horizontal">
{% if invoice_address_asked %}
<dt>{% trans "Company" %}</dt>
<dd>{{ order.invoice_address.company }}</dd>
{% if request.event.settings.invoice_address_vatid %}
<dt>{% trans "VAT ID" %}</dt>
<dd>{{ order.invoice_address.vat_id }}</dd>
{% endif %}
<dt>{% trans "Name" %}</dt>
<dd>{{ order.invoice_address.name }}</dd>
{% if invoice_address_asked %}
<dt>{% trans "Address" %}</dt>
<dd>{{ order.invoice_address.street|linebreaksbr }}</dd>
<dt>{% trans "ZIP code and city" %}</dt>
<dd>{{ order.invoice_address.zipcode }} {{ order.invoice_address.city }}</dd>
<dt>{% trans "Country" %}</dt>
<dd>{{ order.invoice_address.country.name|default:order.invoice_address.country_old }}</dd>
{% if order.invoice_address.state %}
<dt>{% trans "State" context "address" %}</dt>
<dd>{{ order.invoice_address.state_name }}</dd>
{% endif %}
{% if request.event.settings.invoice_address_vatid %}
<dt>{% trans "VAT ID" %}</dt>
<dd>{{ order.invoice_address.vat_id }}</dd>
{% endif %}
{% if request.event.settings.invoice_address_custom_field and order.invoice_address.custom_field %}
<dt>{{ request.event.settings.invoice_address_custom_field }}</dt>
<dd>{{ order.invoice_address.custom_field }}</dd>
{% endif %}
<dt>{% trans "Internal Reference" %}</dt>
<dd>{{ order.invoice_address.internal_reference }}</dd>
{% if request.event.settings.invoice_address_custom_field and order.invoice_address.custom_field %}
<dt>{{ request.event.settings.invoice_address_custom_field }}</dt>
<dd>{{ order.invoice_address.custom_field }}</dd>
{% endif %}
</dl>
</div>
<dt>{% trans "Internal Reference" %}</dt>
<dd>{{ order.invoice_address.internal_reference }}</dd>
{% endif %}
</dl>
</div>
</div>
{% endif %}
</div>
<div class="clearfix"></div>
</div>
{% if user_change_allowed or user_cancel_allowed %}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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')

View File

@@ -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'):

View File

@@ -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)