# # This file is part of pretix (Community Edition). # # Copyright (C) 2014-2020 Raphael Michel and contributors # Copyright (C) 2020-today pretix GmbH and contributors # # This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General # Public License as published by the Free Software Foundation in version 3 of the License. # # ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are # applicable granting you additional permissions and placing additional restrictions on your usage of this software. # Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive # this file, see . # # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied # warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more # details. # # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # import inspect import logging from datetime import timedelta from decimal import Decimal from django.dispatch import receiver from django.utils.formats import date_format from django.utils.html import escape from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from pretix.base.forms import PlaceholderValidator from pretix.base.forms.widgets import format_placeholders_help_text from pretix.base.i18n import ( LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber, ) from pretix.base.reldate import RelativeDateWrapper from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized from pretix.base.signals import ( register_mail_placeholders, register_text_placeholders, ) from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.helpers.format import PlainHtmlAlternativeString, SafeFormatter logger = logging.getLogger('pretix.base.services.placeholders') class BaseTextPlaceholder: """ This is the base class for all email text placeholders. """ @property def required_context(self): """ This property should return a list of all attribute names that need to be contained in the base context so that this placeholder is available. By default, it returns a list containing the string "event". """ return ["event"] @property def identifier(self): """ This should return the identifier of this placeholder in the email. """ raise NotImplementedError() def render(self, context): """ This method is called to generate the actual text that is being used in the email. You will be passed a context dictionary with the base context attributes specified in ``required_context``. You are expected to return a plain-text string. """ raise NotImplementedError() def render_sample(self, event): """ This method is called to generate a text to be used in email previews. This may only depend on the event. """ raise NotImplementedError() class SimpleFunctionalTextPlaceholder(BaseTextPlaceholder): def __init__(self, identifier, args, func, sample): self._identifier = identifier self._args = args self._func = func self._sample = sample @property def identifier(self): return self._identifier @property def required_context(self): return self._args def render(self, context): return self._func(**{k: context[k] for k in self._args}) def render_sample(self, event): if callable(self._sample): return self._sample(event) else: return self._sample class BaseRichTextPlaceholder(BaseTextPlaceholder): """ This is the base class for all placeholders which can render either to plain text or to a rich HTML element. """ def __init__(self, identifier, args): self._identifier = identifier self._args = args @property def identifier(self): return self._identifier @property def required_context(self): return self._args @property def is_block(self): return False def render(self, context): return PlainHtmlAlternativeString( self.render_plain(**{k: context[k] for k in self._args}), self.render_html(**{k: context[k] for k in self._args}), self.is_block, ) def render_html(self, **kwargs): """ HTML rendering of the placeholder. Should return "safe" HTML, i.e. everything needs to be escaped. """ raise NotImplementedError def render_plain(self, **kwargs): """ Plain text rendering of the placeholder. """ raise NotImplementedError def render_sample(self, event): return PlainHtmlAlternativeString( self.render_sample_plain(event=event), self.render_sample_html(event=event), self.is_block, ) def render_sample_html(self, event): raise NotImplementedError def render_sample_plain(self, event): raise NotImplementedError class SimpleButtonPlaceholder(BaseRichTextPlaceholder): def __init__(self, identifier, args, url_func, text_func, sample_url_func, sample_text_func): super().__init__(identifier, args) self._url_func = url_func self._text_func = text_func self._sample_url_func = sample_url_func self._sample_text_func = sample_text_func def render_html(self, **context): text = self._text_func(**{k: context[k] for k in self._args}) url = self._url_func(**{k: context[k] for k in self._args}) return f'{escape(text)}' def render_plain(self, **context): text = self._text_func(**{k: context[k] for k in self._args}) url = self._url_func(**{k: context[k] for k in self._args}) return f'{text}: {url}' def render_sample_html(self, event): text = self._sample_text_func(event) url = self._sample_url_func(event) return f'{escape(text)}' def render_sample_plain(self, event): text = self._sample_text_func(event) url = self._sample_url_func(event) return f'{text}: {url}' class PlaceholderContext(SafeFormatter): """ Holds the contextual arguments and corresponding list of available placeholders for formatting an email or other templated text. Example: context = PlaceholderContext(event=my_event, order=my_order) formatted_doc = context.format(input_doc) """ def __init__(self, **kwargs): super().__init__({}) self.context_args = kwargs self._extend_context_args() self.placeholders = {} self.cache = {} event = kwargs['event'] for r, val in [ *register_mail_placeholders.send(sender=event), *register_text_placeholders.send(sender=event) ]: if not isinstance(val, (list, tuple)): val = [val] for v in val: if all(rp in kwargs for rp in v.required_context): self.placeholders[v.identifier] = v def _extend_context_args(self): from pretix.base.models import InvoiceAddress if 'position' in self.context_args: self.context_args.setdefault("position_or_address", self.context_args['position']) if 'order' in self.context_args: try: if not self.context_args.get('invoice_address'): self.context_args['invoice_address'] = self.context_args['order'].invoice_address except InvoiceAddress.DoesNotExist: self.context_args['invoice_address'] = InvoiceAddress(order=self.context_args['order']) finally: self.context_args.setdefault("position_or_address", self.context_args['invoice_address']) def render_placeholder(self, placeholder): try: return self.cache[placeholder.identifier] except KeyError: try: value = self.cache[placeholder.identifier] = placeholder.render(self.context_args) return value except: logger.exception(f'Failed to process template placeholder {placeholder.identifier}.') return '(error)' def render_all(self): return {identifier: self.render_placeholder(placeholder) for (identifier, placeholder) in self.placeholders.items()} def get_value(self, key, args, kwargs): if key not in self.placeholders: return '{' + str(key) + '}' return self.render_placeholder(self.placeholders[key]) def _placeholder_payments(order, payments): d = [] for payment in payments: if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters: d.append(str(payment.payment_provider.order_pending_mail_render(order, payment))) else: d.append(str(payment.payment_provider.order_pending_mail_render(order))) d = [line for line in d if line.strip()] if d: return '\n\n'.join(d) else: return '' def get_best_name(position_or_address, parts=False): """ Return the best name we got for either an invoice address or an order position, falling back to the respective other """ from pretix.base.models import InvoiceAddress, OrderPosition if isinstance(position_or_address, InvoiceAddress): if position_or_address.name: return position_or_address.name_parts if parts else position_or_address.name elif position_or_address.order: position_or_address = position_or_address.order.positions.exclude(attendee_name_cached="").exclude(attendee_name_cached__isnull=True).first() if isinstance(position_or_address, OrderPosition): if position_or_address.attendee_name: return position_or_address.attendee_name_parts if parts else position_or_address.attendee_name elif position_or_address.order: try: return position_or_address.order.invoice_address.name_parts if parts else position_or_address.order.invoice_address.name except InvoiceAddress.DoesNotExist: pass return {} if parts else "" @receiver(register_text_placeholders, dispatch_uid="pretixbase_register_text_placeholders") def base_placeholders(sender, **kwargs): from pretix.multidomain.urlreverse import build_absolute_uri def _event_sample(event): if event.has_subevents: se = event.subevents.first() if se: return se.name return event.name ph = [ SimpleFunctionalTextPlaceholder( 'event', ['event'], lambda event: event.name, lambda event: event.name ), SimpleFunctionalTextPlaceholder( 'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name, _event_sample, ), SimpleFunctionalTextPlaceholder( 'event_series_name', ['event', 'event_or_subevent'], lambda event, event_or_subevent: event.name, lambda event: event.name ), SimpleFunctionalTextPlaceholder( 'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug ), SimpleFunctionalTextPlaceholder( 'code', ['order'], lambda order: order.code, 'F8VVL' ), SimpleFunctionalTextPlaceholder( 'total', ['order'], lambda order: LazyNumber(order.total), lambda event: LazyNumber(Decimal('42.23')) ), SimpleFunctionalTextPlaceholder( 'currency', ['event'], lambda event: event.currency, lambda event: event.currency ), SimpleFunctionalTextPlaceholder( 'order_email', ['order'], lambda order: order.email, 'john@example.org' ), SimpleFunctionalTextPlaceholder( 'invoice_number', ['invoice'], lambda invoice: invoice.full_invoice_no, f'{sender.settings.invoice_numbers_prefix or (sender.slug.upper() + "-")}00000' ), SimpleFunctionalTextPlaceholder( 'refund_amount', ['event_or_subevent', 'refund_amount'], lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency), lambda event_or_subevent: LazyCurrencyNumber(Decimal('42.23'), event_or_subevent.currency) ), SimpleFunctionalTextPlaceholder( 'pending_sum', ['event', 'pending_sum'], lambda event, pending_sum: LazyCurrencyNumber(pending_sum, event.currency), lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency) ), SimpleFunctionalTextPlaceholder( 'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total, event.currency), lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency) ), SimpleFunctionalTextPlaceholder( 'expire_date', ['event', 'order'], lambda event, order: LazyExpiresDate(order.expires.astimezone(event.timezone)), lambda event: LazyDate(now() + timedelta(days=15)) ), SimpleFunctionalTextPlaceholder( 'url', ['order', 'event'], lambda order, event: build_absolute_uri( event, 'presale:event.order.open', kwargs={ 'order': order.code, 'secret': order.secret, 'hash': order.email_confirm_secret() } ), lambda event: build_absolute_uri( event, 'presale:event.order.open', kwargs={ 'order': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': '98kusd8ofsj8dnkd' } ), ), SimpleButtonPlaceholder( 'url_button', ['order', 'event'], url_func=lambda order, event: build_absolute_uri( event, 'presale:event.order.open', kwargs={ 'order': order.code, 'secret': order.secret, 'hash': order.email_confirm_secret() } ), text_func=lambda order, event: _("View order details"), sample_url_func=lambda event: build_absolute_uri( event, 'presale:event.order.open', kwargs={ 'order': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': '98kusd8ofsj8dnkd' } ), sample_text_func=lambda event: _("View order details"), ), SimpleFunctionalTextPlaceholder( 'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri( event, 'presale:event.order.modify', kwargs={ 'order': order.code, 'secret': order.secret, } ), lambda event: build_absolute_uri( event, 'presale:event.order.modify', kwargs={ 'order': 'F8VVL', 'secret': '6zzjnumtsx136ddy', } ), ), SimpleFunctionalTextPlaceholder( 'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri( event, 'presale:event.order.change', kwargs={ 'order': order.code, 'secret': order.secret, } ), lambda event: build_absolute_uri( event, 'presale:event.order.change', kwargs={ 'order': 'F8VVL', 'secret': '6zzjnumtsx136ddy', } ), ), SimpleFunctionalTextPlaceholder( 'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri( event, 'presale:event.order.cancel', kwargs={ 'order': order.code, 'secret': order.secret, } ), lambda event: build_absolute_uri( event, 'presale:event.order.cancel', kwargs={ 'order': 'F8VVL', 'secret': '6zzjnumtsx136ddy', } ), ), SimpleFunctionalTextPlaceholder( 'url', ['event', 'position'], lambda event, position: build_absolute_uri( event, 'presale:event.order.position', kwargs={ 'order': position.order.code, 'secret': position.web_secret, 'position': position.positionid } ), lambda event: build_absolute_uri( event, 'presale:event.order.position', kwargs={ 'order': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'position': '123' } ), ), SimpleButtonPlaceholder( 'url_button', ['event', 'position'], url_func=lambda event, position: build_absolute_uri( event, 'presale:event.order.position', kwargs={ 'order': position.order.code, 'secret': position.web_secret, 'position': position.positionid } ), text_func=lambda event, position: _("View registration details"), sample_url_func=lambda event: build_absolute_uri( event, 'presale:event.order.position', kwargs={ 'order': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'position': '123' } ), sample_text_func=lambda event: _("View registration details"), ), SimpleFunctionalTextPlaceholder( 'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri( event, 'presale:event.order.position.modify', kwargs={ 'order': position.order.code, 'secret': position.web_secret, 'position': position.positionid } ), lambda event: build_absolute_uri( event, 'presale:event.order.position.modify', kwargs={ 'order': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'position': '123', } ), ), SimpleFunctionalTextPlaceholder( 'url_products_change', ['position', 'event'], lambda position, event: build_absolute_uri( event, 'presale:event.order.position.change', kwargs={ 'order': position.order.code, 'secret': position.web_secret, 'position': position.positionid } ), lambda event: build_absolute_uri( event, 'presale:event.order.position.change', kwargs={ 'order': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'position': '123' } ), ), SimpleFunctionalTextPlaceholder( 'order_modification_deadline_date_and_time', ['order', 'event'], lambda order, event: date_format(order.modify_deadline.astimezone(event.timezone), 'SHORT_DATETIME_FORMAT') if order.modify_deadline else '', lambda event: date_format( event.settings.get( 'last_order_modification_date', as_type=RelativeDateWrapper ).datetime(event).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT' ) if event.settings.get('last_order_modification_date') else '', ), SimpleFunctionalTextPlaceholder( 'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''), lambda event: str(event.location or ''), ), SimpleFunctionalTextPlaceholder( 'event_admission_time', ['event_or_subevent'], lambda event_or_subevent: date_format(event_or_subevent.date_admission.astimezone(event_or_subevent.timezone), 'TIME_FORMAT') if event_or_subevent.date_admission else '', lambda event: date_format(event.date_admission.astimezone(event.timezone), 'TIME_FORMAT') if event.date_admission else '', ), SimpleFunctionalTextPlaceholder( 'subevent', ['waiting_list_entry', 'event'], lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event), lambda event: str(event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()) ), SimpleFunctionalTextPlaceholder( 'subevent_date_from', ['waiting_list_entry', 'event'], lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(), lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display() ), SimpleFunctionalTextPlaceholder( 'url_remove', ['waiting_list_voucher', 'event'], lambda waiting_list_voucher, event: build_absolute_uri( event, 'presale:event.waitinglist.remove' ) + '?voucher=' + waiting_list_voucher.code, lambda event: build_absolute_uri( event, 'presale:event.waitinglist.remove', ) + '?voucher=68CYU2H6ZTP3WLK5', ), SimpleFunctionalTextPlaceholder( 'url', ['waiting_list_voucher', 'event'], lambda waiting_list_voucher, event: build_absolute_uri( event, 'presale:event.redeem' ) + '?voucher=' + waiting_list_voucher.code, lambda event: build_absolute_uri( event, 'presale:event.redeem', ) + '?voucher=68CYU2H6ZTP3WLK5', ), SimpleFunctionalTextPlaceholder( 'invoice_name', ['invoice_address'], lambda invoice_address: invoice_address.name or '', _('John Doe') ), SimpleFunctionalTextPlaceholder( 'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '', _('Sample Corporation') ), SimpleFunctionalTextPlaceholder( 'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join( '* {} - {}'.format( order.full_code, build_absolute_uri(event, 'presale:event.order.open', kwargs={ 'event': event.slug, 'organizer': event.organizer.slug, 'order': order.code, 'secret': order.secret, 'hash': order.email_confirm_secret(), }), ) for order in orders ), lambda event: '\n' + '\n\n'.join( '* {} - {}'.format( '{}-{}'.format(event.slug.upper(), order['code']), build_absolute_uri(event, 'presale:event.order.open', kwargs={ 'event': event.slug, 'organizer': event.organizer.slug, 'order': order['code'], 'secret': order['secret'], 'hash': order['hash'], }), ) for order in [ {'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': 'abcdefghi'}, {'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd', 'hash': 'jklmnopqr'}, {'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'} ] ), ), SimpleFunctionalTextPlaceholder( 'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry: event.settings.waiting_list_hours, lambda event: event.settings.waiting_list_hours ), SimpleFunctionalTextPlaceholder( 'product', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.item.name, _('Sample Admission Ticket') ), SimpleFunctionalTextPlaceholder( 'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code, '68CYU2H6ZTP3WLK5' ), SimpleFunctionalTextPlaceholder( # join vouchers with two spaces at end of line so markdown-parser inserts a
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list), ' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2' ), SimpleFunctionalTextPlaceholder( # join vouchers with two spaces at end of line so markdown-parser inserts a
'voucher_url_list', ['event', 'voucher_list'], lambda event, voucher_list: ' \n'.join([ build_absolute_uri( event, 'presale:event.redeem' ) + '?voucher=' + c for c in voucher_list ]), lambda event: ' \n'.join([ build_absolute_uri( event, 'presale:event.redeem' ) + '?voucher=' + c for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2'] ]), ), SimpleFunctionalTextPlaceholder( 'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={ 'event': event.slug, 'organizer': event.organizer.slug, }), lambda event: build_absolute_uri(event, 'presale:event.index', kwargs={ 'event': event.slug, 'organizer': event.organizer.slug, }) ), SimpleFunctionalTextPlaceholder( 'name', ['name'], lambda name: name, _('John Doe') ), SimpleFunctionalTextPlaceholder( 'comment', ['comment'], lambda comment: comment, _('An individual text with a reason can be inserted here.'), ), SimpleFunctionalTextPlaceholder( 'payment_info', ['order', 'payments'], _placeholder_payments, _('The amount has been charged to your card.'), ), SimpleFunctionalTextPlaceholder( 'payment_info', ['payment_info'], lambda payment_info: payment_info, _('Please transfer money to this bank account: 9999-9999-9999-9999'), ), SimpleFunctionalTextPlaceholder( 'attendee_name', ['position'], lambda position: position.attendee_name, _('John Doe'), ), SimpleFunctionalTextPlaceholder( 'positionid', ['position'], lambda position: str(position.positionid), '1' ), SimpleFunctionalTextPlaceholder( 'name', ['position_or_address'], get_best_name, _('John Doe'), ), ] name_scheme = PERSON_NAME_SCHEMES[sender.settings.name_scheme] if "concatenation_for_salutation" in name_scheme: concatenation_for_salutation = name_scheme["concatenation_for_salutation"] else: concatenation_for_salutation = name_scheme["concatenation"] ph.append(SimpleFunctionalTextPlaceholder( "name_for_salutation", ["waiting_list_entry"], lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts), lambda event: concatenation_for_salutation(name_scheme['sample']), )) ph.append(SimpleFunctionalTextPlaceholder( "name", ["waiting_list_entry"], lambda waiting_list_entry: waiting_list_entry.name or "", _("Mr Doe"), )) ph.append(SimpleFunctionalTextPlaceholder( "name_for_salutation", ["position_or_address"], lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)), lambda event: concatenation_for_salutation(name_scheme['sample']), )) for f, l, w in name_scheme['fields']: if f == 'full_name': continue ph.append(SimpleFunctionalTextPlaceholder( 'name_%s' % f, ['waiting_list_entry'], lambda waiting_list_entry, f=f: get_name_parts_localized(waiting_list_entry.name_parts, f), name_scheme['sample'][f] )) ph.append(SimpleFunctionalTextPlaceholder( 'attendee_name_%s' % f, ['position'], lambda position, f=f: get_name_parts_localized(position.attendee_name_parts, f), name_scheme['sample'][f] )) ph.append(SimpleFunctionalTextPlaceholder( 'name_%s' % f, ['position_or_address'], lambda position_or_address, f=f: get_name_parts_localized(get_best_name(position_or_address, parts=True), f), name_scheme['sample'][f] )) for k, v in sender.meta_data.items(): ph.append(SimpleFunctionalTextPlaceholder( 'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k], v )) ph.append(SimpleFunctionalTextPlaceholder( 'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k], v )) return ph class FormPlaceholderMixin: def _set_field_placeholders(self, fn, base_parameters, rich=False): placeholders = get_available_placeholders(self.event, base_parameters, rich=rich) ht = format_placeholders_help_text(placeholders, self.event) if self.fields[fn].help_text: self.fields[fn].help_text += ' ' + str(ht) else: self.fields[fn].help_text = ht self.fields[fn].validators.append( PlaceholderValidator(['{%s}' % p for p in placeholders.keys()]) ) def get_available_placeholders(event, base_parameters, rich=False): if 'order' in base_parameters: base_parameters.append('invoice_address') base_parameters.append('position_or_address') params = {} for r, val in [*register_mail_placeholders.send(sender=event), *register_text_placeholders.send(sender=event)]: if not isinstance(val, (list, tuple)): val = [val] for v in val: if isinstance(v, BaseRichTextPlaceholder) and not rich: continue if all(rp in base_parameters for rp in v.required_context): params[v.identifier] = v return params def get_sample_context(event, context_parameters, rich=True): context_dict = {} lbl = _('This value will be replaced based on dynamic parameters.') for k, v in get_available_placeholders(event, context_parameters, rich=rich).items(): sample = v.render_sample(event) if isinstance(sample, PlainHtmlAlternativeString): context_dict[k] = PlainHtmlAlternativeString( sample.plain, '<{el} class="placeholder placeholder-html" title="{title}">{html}'.format( el='div' if sample.is_block else 'span', title=lbl, html=sample.html, ) ) elif str(sample).strip().startswith('* ') or str(sample).startswith(' '): context_dict[k] = '
{}
'.format( lbl, markdown_compile_email(str(sample)) ) else: context_dict[k] = '{}'.format( lbl, escape(sample) ) return context_dict