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" %}
- {% trans "Attendee data" %} - {% bootstrap_field sform.attendee_names_asked layout="control" %} - {% bootstrap_field sform.attendee_names_required layout="control" %} + {% trans "Customer and attendee data" %} + {% trans "Asked" context "attendee_data" as asked %} + {% trans "Required" context "attendee_data" as required %} +

{% trans "Customer data (once per order)" %}

+
+ +
+
+ +
+
+
+
+ +
+
+
+ {% bootstrap_field sform.order_email_asked_twice layout="control" %} +
+ +
+ {% bootstrap_field sform.order_phone_asked layout="inline" form_group_class="" label=asked %} +
+
+ {% bootstrap_field sform.order_phone_required layout="inline" form_group_class="" label=required %} +
+
+
+ + +
+ +

{% trans "Attendee data (once per admission ticket)" %}

+
+ +
+ {% bootstrap_field sform.attendee_names_asked layout="inline" form_group_class="" label=asked %} +
+
+ {% bootstrap_field sform.attendee_names_required layout="inline" form_group_class="" label=required %} +
+
+
+ +
+ {% bootstrap_field sform.attendee_emails_asked layout="inline" form_group_class="" label=asked %} +
+
+ {% bootstrap_field sform.attendee_emails_required layout="inline" form_group_class="" label=required %} +
+
+
+ +
+ {% bootstrap_field sform.attendee_company_asked layout="inline" form_group_class="" label=asked %} +
+
+ {% bootstrap_field sform.attendee_company_required layout="inline" form_group_class="" label=required %} +
+
+
+ +
+ {% bootstrap_field sform.attendee_addresses_asked layout="inline" form_group_class="" label=asked %} +
+
+ {% bootstrap_field sform.attendee_addresses_required layout="inline" form_group_class="" label=required %} +
+
+
+ + +
+ {% bootstrap_field sform.attendee_data_explanation_text layout="control" %} + +

{% trans "Other settings" %}

{% 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" %}
{% trans "Texts" %} @@ -178,6 +271,7 @@ {% 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" %}
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 %} -
+

@@ -226,7 +227,7 @@

{% elif can_generate_invoice %} -
+

@@ -256,10 +257,10 @@

{% 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)