diff --git a/doc/development/api/index.rst b/doc/development/api/index.rst index c97bd7d200..6340479e4c 100644 --- a/doc/development/api/index.rst +++ b/doc/development/api/index.rst @@ -12,6 +12,7 @@ Contents: payment payment_2.0 email + placeholder invoice shredder customview diff --git a/doc/development/api/placeholder.rst b/doc/development/api/placeholder.rst new file mode 100644 index 0000000000..860c7b9585 --- /dev/null +++ b/doc/development/api/placeholder.rst @@ -0,0 +1,79 @@ +.. highlight:: python + :linenothreshold: 5 + +Writing an HTML e-mail placeholder plugin +========================================= + +An email placeholder is a dynamic value that pretix users can use in their email templates. + +Please read :ref:`Creating a plugin ` first, if you haven't already. + +Placeholder registration +------------------------ + +The placeholder API does not make a lot of usage from signals, however, it +does use a signal to get a list of all available email placeholders. Your plugin +should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``:: + + from django.dispatch import receiver + + from pretix.base.signals import register_mail_placeholders + + + @receiver(register_mail_placeholders, dispatch_uid="placeholder_custom") + def register_mail_renderers(sender, **kwargs): + from .email import MyPlaceholderClass + return MyPlaceholder() + + +Context mechanism +----------------- + +Emails are sent in different "contexts" within pretix. For example, many emails are sent in the +the context of an order, but some are not, such as the notification of a waiting list voucher. + +Not all placeholders make sense in every email, and placeholders usually depend some parameters +themselves, such as the ``Order`` object. Therefore, placeholders are expected to explicitly declare +what values they depend on and they will only be available in an email if all those dependencies are +met. Currently, placeholders can depend on the following context parameters: + +* ``event`` +* ``order`` +* ``position`` +* ``waiting_list_entry`` +* ``invoice_address`` +* ``payment`` + +There are a few more that are only to be used internally but not by plugins. + +The placeholder class +--------------------- + +.. class:: pretix.base.email.BaseMailTextPlaceholder + + .. autoattribute:: identifier + + This is an abstract attribute, you **must** override this! + + .. autoattribute:: required_context + + This is an abstract attribute, you **must** override this! + + .. automethod:: render + + This is an abstract method, you **must** implement this! + + .. automethod:: render_sample + + This is an abstract method, you **must** implement this! + +Helper class for simple placeholders +------------------------------------ + +pretix ships with a helper class that makes it easy to provide placeholders based on simple +functions:: + + placeholder = SimpleFunctionalMailTextPlaceholder( + 'code', ['order'], lambda order: order.code, sample='F8VVL' + ) + diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index 0cd68c2fa0..4dea0ab56c 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -1,15 +1,23 @@ +import inspect import logging +from datetime import timedelta +from decimal import Decimal from smtplib import SMTPResponseException from django.conf import settings from django.core.mail.backends.smtp import EmailBackend from django.dispatch import receiver from django.template.loader import get_template +from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from inlinestyler.utils import inline_css -from pretix.base.models import Event, Order, OrderPosition -from pretix.base.signals import register_html_mail_renderers +from pretix.base.i18n import LazyCurrencyNumber, LazyDate, LazyNumber +from pretix.base.models import Event +from pretix.base.settings import PERSON_NAME_SCHEMES +from pretix.base.signals import ( + register_html_mail_renderers, register_mail_placeholders, +) from pretix.base.templatetags.rich_text import markdown_compile_email logger = logging.getLogger('pretix.base.email') @@ -44,8 +52,8 @@ class BaseHTMLMailRenderer: def __str__(self): return self.identifier - def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None, - position: OrderPosition=None) -> str: + def render(self, plain_body: str, plain_signature: str, subject: str, order=None, + position=None) -> str: """ This method should generate the HTML part of the email. @@ -97,7 +105,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer): def template_name(self): raise NotImplementedError() - def render(self, plain_body: str, plain_signature: str, subject: str, order: Order, position: OrderPosition) -> str: + def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str: body_md = markdown_compile_email(plain_body) htmlctx = { 'site': settings.PRETIX_INSTANCE_NAME, @@ -136,3 +144,285 @@ class ClassicMailRenderer(TemplateBasedMailRenderer): @receiver(register_html_mail_renderers, dispatch_uid="pretixbase_email_renderers") def base_renderers(sender, **kwargs): return [ClassicMailRenderer] + + +class BaseMailTextPlaceholder: + """ + This is the base class for 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 SimpleFunctionalMailTextPlaceholder(BaseMailTextPlaceholder): + 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 + + +def get_available_placeholders(event, base_parameters): + if 'order' in base_parameters: + base_parameters.append('invoice_address') + params = {} + for r, val in register_mail_placeholders.send(sender=event): + if not isinstance(val, (list, tuple)): + val = [val] + for v in val: + if all(rp in base_parameters for rp in v.required_context): + params[v.identifier] = v + return params + + +def get_email_context(**kwargs): + from pretix.base.models import InvoiceAddress + + event = kwargs['event'] + if 'order' in kwargs: + try: + kwargs['invoice_address'] = kwargs['order'].invoice_address + except InvoiceAddress.DoesNotExist: + kwargs['invoice_address'] = InvoiceAddress() + ctx = {} + for r, val in register_mail_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): + ctx[v.identifier] = v.render(kwargs) + return ctx + + +def _placeholder_payment(order, payment): + if not payment: + return None + if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters: + return str(payment.payment_provider.order_pending_mail_render(order, payment)) + else: + return str(payment.payment_provider.order_pending_mail_render(order)) + + +@receiver(register_mail_placeholders, dispatch_uid="pretixbase_register_mail_placeholders") +def base_placeholders(sender, **kwargs): + from pretix.base.models import InvoiceAddress + from pretix.multidomain.urlreverse import build_absolute_uri + + ph = [ + SimpleFunctionalMailTextPlaceholder( + 'event', ['event'], lambda event: event.name, lambda event: event.name + ), + SimpleFunctionalMailTextPlaceholder( + 'code', ['order'], lambda order: order.code, 'F8VVL' + ), + SimpleFunctionalMailTextPlaceholder( + 'total', ['order'], lambda order: LazyNumber(order.total), lambda event: LazyNumber(Decimal('42.23')) + ), + SimpleFunctionalMailTextPlaceholder( + 'currency', ['event'], lambda event: event.currency, lambda event: event.currency + ), + SimpleFunctionalMailTextPlaceholder( + 'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total, + event.currency), + lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency) + ), + SimpleFunctionalMailTextPlaceholder( + 'expire_date', ['event', 'order'], lambda event, order: LazyDate(order.expires.astimezone(event.timezone)), + lambda event: LazyDate(now() + timedelta(days=15)) + # TODO: This used to be "date" in some placeholders, add a migration! + ), + SimpleFunctionalMailTextPlaceholder( + '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_hash() + } + ), lambda event: build_absolute_uri( + event, + 'presale:event.order.open', kwargs={ + 'order': 'F8VVL', + 'secret': '6zzjnumtsx136ddy', + 'hash': '98kusd8ofsj8dnkd' + } + ), + ), + SimpleFunctionalMailTextPlaceholder( + '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' + } + ), + ), + SimpleFunctionalMailTextPlaceholder( + 'url', ['waiting_list_entry', 'event'], + lambda waiting_list_entry, event: build_absolute_uri( + event, 'presale:event.redeem' + ) + '?voucher=' + waiting_list_entry.voucher.code, + lambda event: build_absolute_uri( + event, + 'presale:event.redeem', + ) + '?voucher=68CYU2H6ZTP3WLK5', + ), + SimpleFunctionalMailTextPlaceholder( + 'invoice_name', ['invoice_address'], lambda invoice_address: invoice_address.name or '', + _('John Doe') + ), + SimpleFunctionalMailTextPlaceholder( + 'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '', + _('Sample Corporation') + ), + SimpleFunctionalMailTextPlaceholder( + 'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join( + '* {} - {}'.format( + order.full_code, + build_absolute_uri(event, 'presale:event.order', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + 'order': order.code, + 'secret': order.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', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + 'order': order['code'], + 'secret': order['secret'] + }), + ) + for order in [ + {'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy'}, + {'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd'}, + {'code': 'OPKSB', 'secret': '09pjdksflosk3njd'} + ] + ), + ), + SimpleFunctionalMailTextPlaceholder( + 'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry: + event.settings.waiting_list_hours, + lambda event: event.settings.waiting_list_hours + ), + SimpleFunctionalMailTextPlaceholder( + 'product', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.item.name, + _('Sample Admission Ticket') + ), + SimpleFunctionalMailTextPlaceholder( + 'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code, + '68CYU2H6ZTP3WLK5' + ), + SimpleFunctionalMailTextPlaceholder( + 'comment', ['comment'], lambda comment: comment, + _('An individual text with a reason can be inserted here.'), + ), + SimpleFunctionalMailTextPlaceholder( + 'payment_info', ['order', 'payment'], _placeholder_payment, + _('The amount has been charged to your card.'), + ), + SimpleFunctionalMailTextPlaceholder( + 'payment_info', ['payment_info'], lambda payment_info: payment_info, + _('Please transfer money to this bank account: 9999-9999-9999-9999'), + ), + SimpleFunctionalMailTextPlaceholder( + 'attendee_name', ['position'], lambda position: position.attendee_name, + _('John Doe'), + ), + SimpleFunctionalMailTextPlaceholder( + 'name', ['position_or_address'], + lambda position_or_address: ( + position_or_address.name + if isinstance(position_or_address, InvoiceAddress) + else position_or_address.attendee_name + ), + _('John Doe'), + ), + ] + + name_scheme = PERSON_NAME_SCHEMES[sender.settings.name_scheme] + for f, l, w in name_scheme['fields']: + if f == 'full_name': + continue + ph.append(SimpleFunctionalMailTextPlaceholder( + 'attendee_name_%s' % f, ['position'], lambda position, f=f: position.attendee_name_parts.get(f, ''), + name_scheme['sample'][f] + )) + ph.append(SimpleFunctionalMailTextPlaceholder( + 'name_%s' % f, ['position_or_address'], + lambda position_or_address, f=f: ( + position_or_address.name_parts.get(f, '') + if isinstance(position_or_address, InvoiceAddress) + else position_or_address.attendee_name_parts.get(f, '') + ), + name_scheme['sample'][f] + )) + + for k, v in sender.meta_data.items(): + ph.append(SimpleFunctionalMailTextPlaceholder( + 'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k], + v + )) + + return ph diff --git a/src/pretix/base/forms/validators.py b/src/pretix/base/forms/validators.py index 258de35b4b..b219e3d8db 100644 --- a/src/pretix/base/forms/validators.py +++ b/src/pretix/base/forms/validators.py @@ -26,7 +26,7 @@ class PlaceholderValidator(BaseValidator): if value.count('{') != value.count('}'): raise ValidationError( _('Invalid placeholder syntax: You used a different number of "{" than of "}".'), - code='invalid', + code='invalid_placeholder_syntax', ) data_placeholders = list(re.findall(r'({[^}]*})', value, re.X)) @@ -37,7 +37,7 @@ class PlaceholderValidator(BaseValidator): if invalid_placeholders: raise ValidationError( _('Invalid placeholder(s): %(value)s'), - code='invalid', + code='invalid_placeholders', params={'value': ", ".join(invalid_placeholders,)}) def clean(self, x): diff --git a/src/pretix/base/migrations/0135_auto_20191007_0803.py b/src/pretix/base/migrations/0135_auto_20191007_0803.py new file mode 100644 index 0000000000..73226e6652 --- /dev/null +++ b/src/pretix/base/migrations/0135_auto_20191007_0803.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.4 on 2019-10-07 08:03 +from django.core.cache import cache +from django.db import migrations + + +def mail_migrator(app, schema_editor): + Event_SettingsStore = app.get_model('pretixbase', 'Event_SettingsStore') + + for ss in Event_SettingsStore.objects.filter( + key__in=['mail_text_order_approved', 'mail_text_order_placed', 'mail_text_order_placed_require_approval'] + ): + chgd = ss.value.replace("{date}", "{expire_date}") + if chgd != ss.value: + ss.value = chgd + ss.save() + cache.delete('hierarkey_{}_{}'.format('event', ss.object_id)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0134_auto_20190909_1042'), + ] + + operations = [ + migrations.RunPython(mail_migrator, migrations.RunPython.noop) + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 30a94afc63..80fd815b06 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -32,6 +32,7 @@ from i18nfield.strings import LazyI18nString from jsonfallback.fields import FallbackJSONField from pretix.base.decimal import round_decimal +from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import User from pretix.base.reldate import RelativeDateWrapper @@ -757,26 +758,9 @@ class Order(LockModel, LoggedModel): ) def resend_link(self, user=None, auth=None): - from pretix.multidomain.urlreverse import build_absolute_uri - with language(self.locale): - try: - invoice_name = self.invoice_address.name - invoice_company = self.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" email_template = self.event.settings.mail_text_resend_link - email_context = { - 'event': self.event.name, - 'url': build_absolute_uri(self.event, 'presale:event.order.open', kwargs={ - 'order': self.code, - 'secret': self.secret, - 'hash': self.email_confirm_hash() - }), - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - } + email_context = get_email_context(event=self.event, order=self) email_subject = _('Your order: %(code)s') % {'code': self.code} self.send_mail( email_subject, email_template, email_context, @@ -1313,24 +1297,10 @@ class OrderPayment(models.Model): def _send_paid_mail_attendee(self, position, user): from pretix.base.services.mail import SendMailException - from pretix.multidomain.urlreverse import build_absolute_uri with language(self.order.locale): - name_scheme = PERSON_NAME_SCHEMES[self.order.event.settings.name_scheme] email_template = self.order.event.settings.mail_text_order_paid_attendee - email_context = { - 'event': self.order.event.name, - 'downloads': self.order.event.settings.get('ticket_download', as_type=bool), - 'url': build_absolute_uri(self.order.event, 'presale:event.order.position', kwargs={ - 'order': self.order.code, - 'secret': position.web_secret, - 'position': position.positionid - }), - 'attendee_name': position.attendee_name, - } - for f, l, w in name_scheme['fields']: - email_context['attendee_name_%s' % f] = position.attendee_name_parts.get(f, '') - + email_context = get_email_context(event=self.order.event, order=self.order, position=position) email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code} try: self.order.send_mail( @@ -1344,28 +1314,10 @@ class OrderPayment(models.Model): def _send_paid_mail(self, invoice, user, mail_text): from pretix.base.services.mail import SendMailException - from pretix.multidomain.urlreverse import build_absolute_uri with language(self.order.locale): - try: - invoice_name = self.order.invoice_address.name - invoice_company = self.order.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" email_template = self.order.event.settings.mail_text_order_paid - email_context = { - 'event': self.order.event.name, - 'url': build_absolute_uri(self.order.event, 'presale:event.order.open', kwargs={ - 'order': self.order.code, - 'secret': self.order.secret, - 'hash': self.order.email_confirm_hash() - }), - 'downloads': self.order.event.settings.get('ticket_download', as_type=bool), - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - 'payment_info': mail_text - } + email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text) email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code} try: self.order.send_mail( @@ -1947,32 +1899,10 @@ class OrderPosition(AbstractPosition): ) def resend_link(self, user=None, auth=None): - from pretix.multidomain.urlreverse import build_absolute_uri with language(self.order.locale): - try: - invoice_name = self.order.invoice_address.name - invoice_company = self.order.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" - if self.attendee_name: - invoice_name = self.attendee_name email_template = self.event.settings.mail_text_resend_link - email_context = { - 'event': self.event.name, - 'url': build_absolute_uri(self.event, 'presale:event.order.position', kwargs={ - 'order': self.order.code, - 'secret': self.web_secret, - 'position': self.positionid - }), - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - 'attendee_name': self.attendee_name, - } - name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] - for f, l, w in name_scheme['fields']: - email_context['attendee_name_%s' % f] = self.attendee_name_parts.get(f, '') + email_context = get_email_context(event=self.order.event, order=self.order, position=self) email_subject = _('Your event registration: %(code)s') % {'code': self.order.code} self.send_mail( email_subject, email_template, email_context, diff --git a/src/pretix/base/models/waitinglist.py b/src/pretix/base/models/waitinglist.py index a68ada92e2..13c78a69c1 100644 --- a/src/pretix/base/models/waitinglist.py +++ b/src/pretix/base/models/waitinglist.py @@ -6,10 +6,10 @@ from django.utils.timezone import now from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django_scopes import ScopedManager +from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import Voucher from pretix.base.services.mail import mail -from pretix.multidomain.urlreverse import build_absolute_uri from .base import LoggedModel from .event import Event, SubEvent @@ -130,13 +130,7 @@ class WaitingListEntry(LoggedModel): self.email, _('You have been selected from the waitinglist for {event}').format(event=str(self.event)), self.event.settings.mail_text_waiting_list, - { - 'event': self.event.name, - 'url': build_absolute_uri(self.event, 'presale:event.redeem') + '?voucher=' + self.voucher.code, - 'code': self.voucher.code, - 'product': str(self.item) + (' - ' + str(self.variation) if self.variation else ''), - 'hours': self.event.settings.waiting_list_hours, - }, + get_email_context(event=self.event, waiting_list_entry=self), self.event, locale=self.locale ) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 7ad24f460e..608bb531e5 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1,4 +1,3 @@ -import inspect import json import logging from collections import Counter, namedtuple @@ -6,23 +5,20 @@ from datetime import datetime, time, timedelta from decimal import Decimal from typing import List, Optional -import pytz from celery.exceptions import MaxRetriesExceededError from django.conf import settings from django.db import transaction from django.db.models import Exists, F, Max, OuterRef, Q, Sum from django.db.models.functions import Greatest from django.dispatch import receiver -from django.utils.formats import date_format from django.utils.functional import cached_property from django.utils.timezone import make_aware, now from django.utils.translation import ugettext as _ from django_scopes import scopes_disabled from pretix.api.models import OAuthApplication -from pretix.base.i18n import ( - LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language, -) +from pretix.base.email import get_email_context +from pretix.base.i18n import LazyLocaleException, language from pretix.base.models import ( CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher, @@ -45,7 +41,6 @@ from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.mail import SendMailException from pretix.base.services.pricing import get_price from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask -from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.signals import ( allow_ticket_download, order_approved, order_canceled, order_changed, order_denied, order_expired, order_fee_calculation, order_placed, @@ -53,7 +48,6 @@ from pretix.base.signals import ( ) from pretix.celery_app import app from pretix.helpers.models import modelcopy -from pretix.multidomain.urlreverse import build_absolute_uri error_messages = { 'unavailable': _('Some of the products you selected were no longer available. ' @@ -206,13 +200,6 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False # send_mail will trigger PDF generation later if send_mail: - try: - invoice_name = order.invoice_address.name - invoice_company = order.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" - with language(order.locale): if order.total == Decimal('0.00'): email_template = order.event.settings.mail_text_order_free @@ -221,20 +208,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False email_template = order.event.settings.mail_text_order_approved email_subject = _('Order approved and awaiting payment: %(code)s') % {'code': order.code} - email_context = { - 'total': LazyNumber(order.total), - 'currency': order.event.currency, - 'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency), - 'date': LazyDate(order.expires), - 'event': order.event.name, - 'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={ - 'order': order.code, - 'secret': order.secret, - 'hash': order.email_confirm_hash() - }), - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - } + email_context = get_email_context(event=order.event, order=order) try: order.send_mail( email_subject, email_template, email_context, @@ -275,28 +249,8 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None): order_denied.send(order.event, order=order) if send_mail: - try: - invoice_name = order.invoice_address.name - invoice_company = order.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" email_template = order.event.settings.mail_text_order_denied - email_context = { - 'total': LazyNumber(order.total), - 'currency': order.event.currency, - 'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency), - 'date': LazyDate(order.expires), - 'event': order.event.name, - 'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={ - 'order': order.code, - 'secret': order.secret, - 'hash': order.email_confirm_hash() - }), - 'comment': comment, - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - } + email_context = get_email_context(event=order.event, order=order, comment=comment) with language(order.locale): email_subject = _('Order denied: %(code)s') % {'code': order.code} try: @@ -379,16 +333,8 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device if send_mail: email_template = order.event.settings.mail_text_order_canceled - email_context = { - 'event': order.event.name, - 'code': order.code, - 'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={ - 'order': order.code, - 'secret': order.secret, - 'hash': order.email_confirm_hash() - }) - } with language(order.locale): + email_context = get_email_context(event=order.event, order=order) email_subject = _('Order canceled: %(code)s') % {'code': order.code} try: order.send_mail( @@ -682,36 +628,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str, invoice, payment: OrderPayment): - try: - invoice_name = order.invoice_address.name - invoice_company = order.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" - - if pprov: - if 'payment' in inspect.signature(pprov.order_pending_mail_render).parameters: - payment_info = str(pprov.order_pending_mail_render(order, payment)) - else: - payment_info = str(pprov.order_pending_mail_render(order)) - else: - payment_info = None - - email_context = { - 'total': LazyNumber(order.total), - 'currency': event.currency, - 'total_with_currency': LazyCurrencyNumber(order.total, event.currency), - 'date': LazyDate(order.expires), - 'event': event.name, - 'url': build_absolute_uri(event, 'presale:event.order.open', kwargs={ - 'order': order.code, - 'secret': order.secret, - 'hash': order.email_confirm_hash() - }), - 'payment_info': payment_info, - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - } + email_context = get_email_context(event=event, order=order, payment=payment if pprov else None) email_subject = _('Your order: %(code)s') % {'code': order.code} try: order.send_mail( @@ -725,19 +642,7 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str): - name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme] - email_context = { - 'event': event.name, - 'url': build_absolute_uri(event, 'presale:event.order.position', kwargs={ - 'order': order.code, - 'secret': position.web_secret, - 'position': position.positionid - }), - 'attendee_name': position.attendee_name, - } - for f, l, w in name_scheme['fields']: - email_context['attendee_name_%s' % f] = position.attendee_name_parts.get(f, '') - + email_context = get_email_context(event=event, order=order, position=position) email_subject = _('Your event registration: %(code)s') % {'code': order.code} try: @@ -884,29 +789,12 @@ def send_expiry_warnings(sender, **kwargs): eventcache[o.event.pk] = eventsettings days = eventsettings.get('mail_days_order_expire_warning', as_type=int) - tz = pytz.timezone(eventsettings.get('timezone', settings.TIME_ZONE)) if days and (o.expires - today).days <= days: with language(o.locale): o.expiry_reminder_sent = True o.save(update_fields=['expiry_reminder_sent']) - try: - invoice_name = o.invoice_address.name - invoice_company = o.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" email_template = eventsettings.mail_text_order_expire_warning - email_context = { - 'event': o.event.name, - 'url': build_absolute_uri(o.event, 'presale:event.order.open', kwargs={ - 'order': o.code, - 'secret': o.secret, - 'hash': o.email_confirm_hash() - }), - 'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'), - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - } + email_context = get_email_context(event=o.event, order=o) if eventsettings.payment_term_expire_automatically: email_subject = _('Your order is about to expire: %(code)s') % {'code': o.code} else: @@ -949,14 +837,7 @@ def send_download_reminders(sender, **kwargs): o.download_reminder_sent = True o.save(update_fields=['download_reminder_sent']) email_template = e.settings.mail_text_download_reminder - email_context = { - 'event': o.event.name, - 'url': build_absolute_uri(o.event, 'presale:event.order.open', kwargs={ - 'order': o.code, - 'secret': o.secret, - 'hash': o.email_confirm_hash() - }), - } + email_context = get_email_context(event=e, order=o) email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code} try: o.send_mail( @@ -968,21 +849,10 @@ def send_download_reminders(sender, **kwargs): logger.exception('Reminder email could not be sent') if e.settings.mail_send_download_reminder_attendee: - name_scheme = PERSON_NAME_SCHEMES[e.settings.name_scheme] for p in o.positions.all(): if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email: email_template = e.settings.mail_text_download_reminder_attendee - email_context = { - 'event': e.name, - 'url': build_absolute_uri(e, 'presale:event.order.position', kwargs={ - 'order': o.code, - 'secret': p.web_secret, - 'position': p.positionid - }), - 'attendee_name': p.attendee_name, - } - for f, l, w in name_scheme['fields']: - email_context['attendee_name_%s' % f] = p.attendee_name_parts.get(f, '') + email_context = get_email_context(event=e, order=o, position=p) try: o.send_mail( email_subject, email_template, email_context, @@ -995,23 +865,8 @@ def send_download_reminders(sender, **kwargs): def notify_user_changed_order(order, user=None, auth=None): with language(order.locale): - try: - invoice_name = order.invoice_address.name - invoice_company = order.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" email_template = order.event.settings.mail_text_order_changed - email_context = { - 'event': order.event.name, - 'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={ - 'order': order.code, - 'secret': order.secret, - 'hash': order.email_confirm_hash() - }), - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - } + email_context = get_email_context(event=order.event, order=order) email_subject = _('Your order has been changed: %(code)s') % {'code': order.code} try: order.send_mail( diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 6f7eab5391..94ae8fbef5 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -386,7 +386,7 @@ Your {event} team""")) 'default': LazyI18nString.from_gettext(ugettext_noop("""Hello, we successfully received your order for {event} with a total value -of {total_with_currency}. Please complete your payment before {date}. +of {total_with_currency}. Please complete your payment before {expire_date}. {payment_info} @@ -514,7 +514,7 @@ Your {event} team""")) we approved your order for {event} and will be happy to welcome you at our event. -Please continue by paying for your order before {date}. +Please continue by paying for your order before {expire_date}. You can select a payment method and perform the payment here: diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index b1843a8bc1..a6324aec89 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -186,6 +186,16 @@ subclass of pretix.base.payment.BasePaymentProvider or a list of these As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ +register_mail_placeholders = EventPluginSignal( + providing_args=[] +) +""" +This signal is sent out to get all known email text placeholders. Receivers should return +an instance of a subclass of pretix.base.email.BaseMailTextPlaceholder or a list of these. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + register_html_mail_renderers = EventPluginSignal( providing_args=[] ) diff --git a/src/pretix/base/templatetags/rich_text.py b/src/pretix/base/templatetags/rich_text.py index fac04063d2..3987b02522 100644 --- a/src/pretix/base/templatetags/rich_text.py +++ b/src/pretix/base/templatetags/rich_text.py @@ -54,7 +54,7 @@ ALLOWED_ATTRIBUTES = { 'td': ['width', 'align'], 'div': ['class'], 'p': ['class'], - 'span': ['class'], + 'span': ['class', 'title'], # Update doc/user/markdown.rst if you change this! } diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 6ecbd024a4..066ea13cf0 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -20,6 +20,7 @@ from i18nfield.forms import ( from pytz import common_timezones, timezone from pretix.base.channels import get_all_sales_channels +from pretix.base.email import get_available_placeholders from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm from pretix.base.models import Event, Organizer, TaxRule from pretix.base.models.event import EventMetaValue, SubEvent @@ -1002,10 +1003,6 @@ class MailSettingsForm(SettingsForm): label=_("Text sent to order contact address"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {total_with_currency}, {total}, {currency}, {date}, " - "{payment_info}, {url}, {invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}', - '{payment_info}', '{url}', '{invoice_name}', '{invoice_company}'])] ) mail_send_order_placed_attendee = forms.BooleanField( label=_("Send an email to attendees"), @@ -1017,16 +1014,12 @@ class MailSettingsForm(SettingsForm): label=_("Text sent to attendees"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {url}, {attendee_name}"), - validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])], ) mail_text_order_paid = I18nFormField( label=_("Text sent to order contact address"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}, {payment_info}"), - validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}', '{payment_info}'])] ) mail_send_order_paid_attendee = forms.BooleanField( label=_("Send an email to attendees"), @@ -1038,16 +1031,12 @@ class MailSettingsForm(SettingsForm): label=_("Text sent to attendees"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {url}, {attendee_name}"), - validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])], ) mail_text_order_free = I18nFormField( label=_("Text sent to order contact address"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])] ) mail_send_order_free_attendee = forms.BooleanField( label=_("Send an email to attendees"), @@ -1059,30 +1048,22 @@ class MailSettingsForm(SettingsForm): label=_("Text sent to attendees"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {url}, {attendee_name}"), - validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])], ) mail_text_order_changed = I18nFormField( label=_("Text"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])] ) mail_text_resend_link = I18nFormField( label=_("Text (sent by admin)"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])] ) mail_text_resend_all_links = I18nFormField( label=_("Text (requested by user)"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {orders}"), - validators=[PlaceholderValidator(['{event}', '{orders}'])] ) mail_days_order_expire_warning = forms.IntegerField( label=_("Number of days"), @@ -1095,38 +1076,26 @@ class MailSettingsForm(SettingsForm): label=_("Text"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{event}', '{url}', '{expire_date}', '{invoice_name}', '{invoice_company}'])] ) mail_text_waiting_list = I18nFormField( label=_("Text"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {url}, {product}, {hours}, {code}"), - validators=[PlaceholderValidator(['{event}', '{url}', '{product}', '{hours}', '{code}'])] ) mail_text_order_canceled = I18nFormField( label=_("Text"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {code}, {url}"), - validators=[PlaceholderValidator(['{event}', '{code}', '{url}'])] ) mail_text_order_custom_mail = I18nFormField( label=_("Text"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {expire_date}, {event}, {code}, {date}, {url}, " - "{invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{expire_date}', '{event}', '{code}', '{date}', '{url}', - '{invoice_name}', '{invoice_company}'])] ) mail_text_download_reminder = I18nFormField( label=_("Text sent to order contact address"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {url}"), - validators=[PlaceholderValidator(['{event}', '{url}'])] ) mail_send_download_reminder_attendee = forms.BooleanField( label=_("Send an email to attendees"), @@ -1138,8 +1107,6 @@ class MailSettingsForm(SettingsForm): label=_("Text sent to attendees"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {attendee_name}, {event}, {url}"), - validators=[PlaceholderValidator(['{attendee_name}', '{event}', '{url}'])] ) mail_days_download_reminder = forms.IntegerField( label=_("Number of days"), @@ -1152,29 +1119,18 @@ class MailSettingsForm(SettingsForm): label=_("Received order"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {total_with_currency}, {total}, {currency}, {date}, " - "{url}, {invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}', - '{url}', '{invoice_name}', '{invoice_company}'])] ) mail_text_order_approved = I18nFormField( label=_("Approved order"), required=False, widget=I18nTextarea, help_text=_("This will only be sent out for non-free orders. Free orders will receive the free order " - "template from above instead. Available placeholders: {event}, {total_with_currency}, {total}, " - "{currency}, {date}, {payment_info}, {url}, {invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}', - '{url}', '{invoice_name}', '{invoice_company}'])] + "template from above instead."), ) mail_text_order_denied = I18nFormField( label=_("Denied order"), required=False, widget=I18nTextarea, - help_text=_("Available placeholders: {event}, {total_with_currency}, {total}, {currency}, {date}, " - "{comment}, {url}, {invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}', - '{comment}', '{url}', '{invoice_name}', '{invoice_company}'])] ) smtp_use_custom = forms.BooleanField( label=_("Use custom SMTP server"), @@ -1213,29 +1169,53 @@ class MailSettingsForm(SettingsForm): help_text=_("Commonly enabled on port 465."), required=False ) + base_context = { + 'mail_text_order_placed': ['event', 'order', 'payment'], + 'mail_text_order_placed_attendee': ['event', 'position'], + 'mail_text_order_placed_require_approval': ['event', 'order'], + 'mail_text_order_approved': ['event', 'order'], + 'mail_text_order_denied': ['event', 'order', 'comment'], + 'mail_text_order_paid': ['event', 'order', 'payment_info'], + 'mail_text_order_paid_attendee': ['event', 'position'], + 'mail_text_order_free': ['event', 'order'], + 'mail_text_order_free_attendee': ['event', 'position'], + 'mail_text_order_changed': ['event', 'order'], + 'mail_text_order_canceled': ['event', 'order'], + 'mail_text_order_expire_warning': ['event', 'order'], + 'mail_text_order_custom_mail': ['event', 'order'], + 'mail_text_download_reminder': ['event', 'order'], + 'mail_text_download_reminder_attendee': ['event', 'position'], + 'mail_text_resend_link': ['event', 'order'], + 'mail_text_waiting_list': ['event', 'waiting_list_entry'], + 'mail_text_resend_all_links': ['event', 'orders'] + } + + def _set_field_placeholders(self, fn, base_parameters): + phs = [ + '{%s}' % p + for p in sorted(get_available_placeholders(self.event, base_parameters).keys()) + ] + ht = _('Available placeholders: {list}').format( + list=', '.join(phs) + ) + 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(phs) + ) def __init__(self, *args, **kwargs): - event = kwargs.get('obj') + self.event = event = kwargs.get('obj') super().__init__(*args, **kwargs) self.fields['mail_html_renderer'].choices = [ (r.identifier, r.verbose_name) for r in event.get_html_mail_renderers().values() ] - keys = list(event.meta_data.keys()) - name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme] + for k, v in self.base_context.items(): + self._set_field_placeholders(k, v) + for k, v in list(self.fields.items()): - if k.startswith('mail_text_'): - v.help_text = str(v.help_text) + ', ' + ', '.join({ - '{meta_' + p + '}' for p in keys - }) - v.validators[0].limit_value += ['{meta_' + p + '}' for p in keys] - - if '{attendee_name}' in v.validators[0].limit_value: - for f, l, w in name_scheme['fields']: - if f == 'full_name': - continue - v.help_text = str(v.help_text) + ', ' + '{attendee_name_%s}' % f - v.validators[0].limit_value += ['{attendee_name_' + f + '}'] - if k.endswith('_attendee') and not event.settings.attendee_emails_asked: # If we don't ask for attendee emails, we can't send them anything and we don't need to clutter # the user interface with it diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index 202e981353..e4b34340a2 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -8,6 +8,7 @@ from django.urls import reverse from django.utils.timezone import now from django.utils.translation import pgettext_lazy, ugettext_lazy as _ +from pretix.base.email import get_available_placeholders from pretix.base.forms import I18nModelForm, PlaceholderValidator from pretix.base.forms.widgets import DatePickerWidget from pretix.base.models import ( @@ -408,8 +409,24 @@ class OrderMailForm(forms.Form): required=True ) + def _set_field_placeholders(self, fn, base_parameters): + phs = [ + '{%s}' % p + for p in sorted(get_available_placeholders(self.order.event, base_parameters).keys()) + ] + ht = _('Available placeholders: {list}').format( + list=', '.join(phs) + ) + 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(phs) + ) + def __init__(self, *args, **kwargs): - order = kwargs.pop('order') + order = self.order = kwargs.pop('order') super().__init__(*args, **kwargs) self.fields['sendto'] = forms.EmailField( label=_("Recipient"), @@ -422,11 +439,8 @@ class OrderMailForm(forms.Form): required=True, widget=forms.Textarea, initial=order.event.settings.mail_text_order_custom_mail.localize(order.locale), - help_text=_("Available placeholders: {expire_date}, {event}, {code}, {date}, {url}, " - "{invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{expire_date}', '{event}', '{code}', '{date}', '{url}', - '{invoice_name}', '{invoice_company}'])] ) + self._set_field_placeholders('message', ['event', 'order']) class OrderRefundForm(forms.Form): diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 21fcd67397..b7326f5fcc 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -1,7 +1,6 @@ import json import re from collections import OrderedDict -from datetime import timedelta from decimal import Decimal from urllib.parse import urlsplit @@ -18,7 +17,6 @@ from django.http import ( from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import translation -from django.utils.formats import date_format from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import ugettext, ugettext_lazy as _ @@ -29,7 +27,7 @@ from i18nfield.strings import LazyI18nString from pytz import timezone from pretix.base.channels import get_all_sales_channels -from pretix.base.i18n import LazyCurrencyNumber +from pretix.base.email import get_available_placeholders from pretix.base.models import ( Event, LogEntry, Order, RequiredAction, TaxRule, Voucher, ) @@ -37,7 +35,6 @@ from pretix.base.models.event import EventMetaValue from pretix.base.services import tickets from pretix.base.services.invoices import build_preview_invoice_pdf from pretix.base.signals import register_ticket_outputs -from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.control.forms.event import ( CancelSettingsForm, CommentForm, EventDeleteForm, EventMetaValueForm, @@ -48,7 +45,6 @@ from pretix.control.forms.event import ( ) from pretix.control.permissions import EventPermissionRequiredMixin from pretix.helpers.database import rolledback_transaction -from pretix.helpers.urls import build_absolute_uri from pretix.multidomain.urlreverse import get_domain from pretix.plugins.stripe.payment import StripeSettingsHolder from pretix.presale.style import regenerate_css @@ -538,20 +534,6 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View): def __missing__(self, key): return '{' + key + '}' - @staticmethod - def generate_order_fullname(slug, code): - return '{event}-{code}'.format(event=slug.upper(), code=code) - - # create data which depend on locale - def localized_data(self): - return { - 'date': date_format(now() + timedelta(days=7), 'SHORT_DATE_FORMAT'), - 'expire_date': date_format(now() + timedelta(days=15), 'SHORT_DATE_FORMAT'), - 'payment_info': _('{} has been transferred to account <9999-9999-9999-9999> at {}').format( - money_filter(Decimal('42.23'), self.request.event.currency), - date_format(now(), 'SHORT_DATETIME_FORMAT')) - } - # create index-language mapping @cached_property def supported_locale(self): @@ -561,91 +543,23 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View): locales[str(idx)] = val[0] return locales - @cached_property - def meta_properties(self): - return [p.name for p in self.request.organizer.meta_properties.all()] - - @cached_property - def items(self): - kv = { - 'mail_text_order_placed': ['total', 'currency', 'date', 'invoice_company', 'total_with_currency', - 'event', 'payment_info', 'url', 'invoice_name'], - 'mail_text_order_placed_attendee': ['event', 'url', 'attendee_name'], - 'mail_text_order_paid': ['event', 'url', 'invoice_name', 'invoice_company', 'payment_info'], - 'mail_text_order_paid_attendee': ['event', 'url', 'attendee_name'], - 'mail_text_order_free': ['event', 'url', 'invoice_name', 'invoice_company'], - 'mail_text_order_free_attendee': ['event', 'url', 'attendee_name'], - 'mail_text_resend_link': ['event', 'url', 'invoice_name', 'invoice_company'], - 'mail_text_resend_all_links': ['event', 'orders'], - 'mail_text_order_changed': ['event', 'url', 'invoice_name', 'invoice_company'], - 'mail_text_order_expire_warning': ['event', 'url', 'expire_date', 'invoice_name', 'invoice_company'], - 'mail_text_waiting_list': ['event', 'url', 'product', 'hours', 'code'], - 'mail_text_order_canceled': ['code', 'event', 'url'], - 'mail_text_order_custom_mail': ['expire_date', 'event', 'code', 'date', 'url', - 'invoice_name', 'invoice_company'], - 'mail_text_download_reminder': ['event', 'url'], - 'mail_text_download_reminder_attendee': ['attendee_name', 'event', 'url'], - 'mail_text_order_placed_require_approval': ['total', 'currency', 'date', 'invoice_company', - 'total_with_currency', 'event', 'url', 'invoice_name'], - 'mail_text_order_approved': ['total', 'currency', 'date', 'invoice_company', - 'total_with_currency', 'event', 'url', 'invoice_name'], - 'mail_text_order_denied': ['total', 'currency', 'date', 'invoice_company', - 'total_with_currency', 'event', 'url', 'invoice_name'], - } - for v in kv.values(): - for p in self.meta_properties: - v.append('meta_' + p) - return kv - - @cached_property - def base_data(self): - user_orders = [ - {'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy'}, - {'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd'}, - {'code': 'OPKSB', 'secret': '09pjdksflosk3njd'} - ] - orders = [' - {} - {}'.format(self.generate_order_fullname(self.request.event.slug, order['code']), - self.generate_order_url(order['code'], order['secret'])) - for order in user_orders] - d = { - 'event': self.request.event.name, - 'total': 42.23, - 'total_with_currency': LazyCurrencyNumber(42.23, self.request.event.currency), - 'currency': self.request.event.currency, - 'url': self.generate_order_url(user_orders[0]['code'], user_orders[0]['secret']), - 'orders': '\n'.join(orders), - 'hours': self.request.event.settings.waiting_list_hours, - 'product': _('Sample Admission Ticket'), - 'code': '68CYU2H6ZTP3WLK5', - 'invoice_name': _('John Doe'), - 'invoice_company': _('Sample Corporation'), - 'common': _('An individual text with a reason can be inserted here.'), - 'payment_info': _('Please transfer money to this bank account: 9999-9999-9999-9999'), - 'attendee_name': _('John Doe'), - } - for k, v in self.request.event.meta_data.items(): - d['meta_' + k] = v - return d - - def generate_order_url(self, code, secret): - return build_absolute_uri('presale:event.order', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - 'order': code, - 'secret': secret - }) - # get all supported placeholders with dummy values def placeholders(self, item): - supported = {} - local_data = self.localized_data() - for key in self.items.get(item): - supported[key] = self.base_data.get(key) if key in self.base_data else local_data.get(key) - return self.SafeDict(supported) + ctx = {} + for p in get_available_placeholders(self.request.event, MailSettingsForm.base_context[item]).values(): + s = str(p.render_sample(self.request.event)) + if s.strip().startswith('*'): + ctx[p.identifier] = s + else: + ctx[p.identifier] = '{}'.format( + _('This value will be replaced based on dynamic parameters.'), + s + ) + return self.SafeDict(ctx) def post(self, request, *args, **kwargs): preview_item = request.POST.get('item', '') - if preview_item not in self.items: + if preview_item not in MailSettingsForm.base_context: return HttpResponseBadRequest(_('invalid item')) regex = r"^" + re.escape(preview_item) + r"_(?P[\d+])$" diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 394be9c1d3..e26868275a 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -6,7 +6,6 @@ import re from datetime import datetime, time, timedelta from decimal import Decimal, DecimalException -import pytz import vat_moss.id from django.conf import settings from django.contrib import messages @@ -21,7 +20,6 @@ from django.http import ( from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils import formats -from django.utils.formats import date_format from django.utils.functional import cached_property from django.utils.http import is_safe_url from django.utils.timezone import make_aware, now @@ -32,6 +30,7 @@ from django.views.generic import ( from i18nfield.strings import LazyI18nString from pretix.base.channels import get_all_sales_channels +from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import ( CachedCombinedTicket, CachedFile, CachedTicket, Invoice, InvoiceAddress, @@ -77,7 +76,6 @@ from pretix.control.forms.orders import ( from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.views import PaginationMixin from pretix.helpers.safedownload import check_token -from pretix.multidomain.urlreverse import build_absolute_uri from pretix.presale.signals import question_form_fields logger = logging.getLogger(__name__) @@ -1490,32 +1488,13 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView): return super().form_invalid(form) def form_valid(self, form): - tz = pytz.timezone(self.request.event.settings.timezone) order = Order.objects.get( event=self.request.event, code=self.kwargs['code'].upper() ) self.preview_output = {} - try: - invoice_name = order.invoice_address.name - invoice_company = order.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" with language(order.locale): - email_context = { - 'event': order.event, - 'code': order.code, - 'date': date_format(order.datetime.astimezone(tz), 'SHORT_DATETIME_FORMAT'), - 'expire_date': date_format(order.expires, 'SHORT_DATE_FORMAT'), - 'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={ - 'order': order.code, - 'secret': order.secret, - 'hash': order.email_confirm_hash() - }), - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - } + email_context = get_email_context(event=order.event, order=order) email_template = LazyI18nString(form.cleaned_data['message']) email_content = render_mail(email_template, email_context) if self.request.POST.get('action') == 'preview': diff --git a/src/pretix/plugins/banktransfer/tasks.py b/src/pretix/plugins/banktransfer/tasks.py index 4b41e11307..343106dbea 100644 --- a/src/pretix/plugins/banktransfer/tasks.py +++ b/src/pretix/plugins/banktransfer/tasks.py @@ -2,25 +2,21 @@ import logging import re from decimal import Decimal -import pytz from celery.exceptions import MaxRetriesExceededError from django.conf import settings from django.db import transaction from django.db.models import Q -from django.utils.formats import date_format from django.utils.translation import ugettext, ugettext_noop from django_scopes import scope, scopes_disabled +from pretix.base.email import get_email_context from pretix.base.i18n import language -from pretix.base.models import ( - Event, InvoiceAddress, Order, OrderPayment, Organizer, Quota, -) +from pretix.base.models import Event, Order, OrderPayment, Organizer, Quota from pretix.base.services.locking import LockTimeoutException from pretix.base.services.mail import SendMailException from pretix.base.services.orders import change_payment_provider from pretix.base.services.tasks import TransactionAwareTask from pretix.celery_app import app -from pretix.multidomain.urlreverse import build_absolute_uri from .models import BankImportJob, BankTransaction @@ -29,25 +25,8 @@ logger = logging.getLogger(__name__) def notify_incomplete_payment(o: Order): with language(o.locale): - tz = pytz.timezone(o.event.settings.get('timezone', settings.TIME_ZONE)) - try: - invoice_name = o.invoice_address.name - invoice_company = o.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" email_template = o.event.settings.mail_text_order_expire_warning - email_context = { - 'event': o.event.name, - 'url': build_absolute_uri(o.event, 'presale:event.order.open', kwargs={ - 'order': o.code, - 'secret': o.secret, - 'hash': o.email_confirm_hash() - }), - 'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'), - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - } + email_context = get_email_context(event=o.event, order=o) email_subject = ugettext('Your order received an incomplete payment: %(code)s') % {'code': o.code} try: diff --git a/src/pretix/plugins/sendmail/forms.py b/src/pretix/plugins/sendmail/forms.py index 16e1da5b11..f70a5b9b2e 100644 --- a/src/pretix/plugins/sendmail/forms.py +++ b/src/pretix/plugins/sendmail/forms.py @@ -3,6 +3,7 @@ from django.urls import reverse from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput +from pretix.base.email import get_available_placeholders from pretix.base.forms import PlaceholderValidator from pretix.base.models import Item, Order, SubEvent from pretix.control.forms.widgets import Select2 @@ -33,8 +34,24 @@ class MailForm(forms.Form): empty_label=pgettext_lazy('subevent', 'All dates') ) + def _set_field_placeholders(self, fn, base_parameters): + phs = [ + '{%s}' % p + for p in sorted(get_available_placeholders(self.event, base_parameters).keys()) + ] + ht = _('Available placeholders: {list}').format( + list=', '.join(phs) + ) + 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(phs) + ) + def __init__(self, *args, **kwargs): - event = kwargs.pop('event') + event = self.event = kwargs.pop('event') super().__init__(*args, **kwargs) recp_choices = [ @@ -52,20 +69,14 @@ class MailForm(forms.Form): label=_('Subject'), widget=I18nTextInput, required=True, locales=event.settings.get('locales'), - help_text=_("Available placeholders: {expire_date}, {event}, {code}, {date}, {url}, " - "{invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{expire_date}', '{event}', '{code}', '{date}', '{url}', - '{invoice_name}', '{invoice_company}'])] ) self.fields['message'] = I18nFormField( label=_('Message'), widget=I18nTextarea, required=True, locales=event.settings.get('locales'), - help_text=_("Available placeholders: {expire_date}, {event}, {code}, {date}, {url}, " - "{invoice_name}, {invoice_company}"), - validators=[PlaceholderValidator(['{expire_date}', '{event}', '{code}', '{date}', '{url}', - '{invoice_name}', '{invoice_company}'])] ) + self._set_field_placeholders('subject', ['event', 'order', 'position_or_address']) + self._set_field_placeholders('message', ['event', 'order', 'position_or_address']) choices = list(Order.STATUS_CHOICE) if not event.settings.get('payment_term_expire_automatically', as_type=bool): choices.append( diff --git a/src/pretix/plugins/sendmail/tasks.py b/src/pretix/plugins/sendmail/tasks.py index f56b3f245a..bb5e7e3e52 100644 --- a/src/pretix/plugins/sendmail/tasks.py +++ b/src/pretix/plugins/sendmail/tasks.py @@ -1,13 +1,11 @@ -import pytz -from django.utils.formats import date_format from i18nfield.strings import LazyI18nString +from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import Event, InvoiceAddress, Order, User from pretix.base.services.mail import SendMailException, mail from pretix.base.services.tasks import ProfiledEventTask from pretix.celery_app import app -from pretix.multidomain.urlreverse import build_absolute_uri @app.task(base=ProfiledEventTask) @@ -17,17 +15,15 @@ def send_mails(event: Event, user: int, subject: dict, message: dict, orders: li orders = Order.objects.filter(pk__in=orders, event=event) subject = LazyI18nString(subject) message = LazyI18nString(message) - tz = pytz.timezone(event.settings.timezone) for o in orders: - try: - invoice_name = o.invoice_address.name - invoice_company = o.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" - send_to_order = recipients in ('both', 'orders') + + try: + ia = o.invoice_address + except InvoiceAddress.DoesNotExist: + ia = InvoiceAddress() + if recipients in ('both', 'attendees'): for p in o.positions.prefetch_related('addons'): if p.addon_to_id is not None: @@ -46,19 +42,7 @@ def send_mails(event: Event, user: int, subject: dict, message: dict, orders: li try: with language(o.locale): - email_context = { - 'event': event, - 'code': o.code, - 'date': date_format(o.datetime.astimezone(tz), 'SHORT_DATETIME_FORMAT'), - 'expire_date': date_format(o.expires, 'SHORT_DATE_FORMAT'), - 'url': build_absolute_uri(event, 'presale:event.order.position', kwargs={ - 'order': o.code, - 'secret': p.web_secret, - 'position': p.positionid - }), - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - } + email_context = get_email_context(event=event, order=o, position_or_address=p, position=p) mail( p.attendee_email, subject, @@ -85,19 +69,7 @@ def send_mails(event: Event, user: int, subject: dict, message: dict, orders: li if send_to_order and o.email: try: with language(o.locale): - email_context = { - 'event': event, - 'code': o.code, - 'date': date_format(o.datetime.astimezone(tz), 'SHORT_DATETIME_FORMAT'), - 'expire_date': date_format(o.expires, 'SHORT_DATE_FORMAT'), - 'url': build_absolute_uri(event, 'presale:event.order.open', kwargs={ - 'order': o.code, - 'secret': o.secret, - 'hash': o.email_confirm_hash() - }), - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - } + email_context = get_email_context(event=event, order=o, position_or_address=ia) mail( o.email, subject, diff --git a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html index 8c74341571..f6f43ac959 100644 --- a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html +++ b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html @@ -21,7 +21,7 @@
{% for locale, out in output.items %}
- {{ out.subject }}

+ {{ out.subject|safe }}

{{ out.html|safe }}
{% endfor %} diff --git a/src/pretix/plugins/sendmail/views.py b/src/pretix/plugins/sendmail/views.py index 4bcd1bb1df..1f7b1e5521 100644 --- a/src/pretix/plugins/sendmail/views.py +++ b/src/pretix/plugins/sendmail/views.py @@ -1,21 +1,21 @@ import logging -from datetime import timedelta +import bleach from django.contrib import messages from django.db.models import Q from django.http import Http404 from django.shortcuts import redirect -from django.utils.formats import date_format from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from django.views.generic import FormView, ListView +from pretix.base.email import get_available_placeholders from pretix.base.i18n import LazyI18nString, language from pretix.base.models import LogEntry, Order from pretix.base.models.event import SubEvent +from pretix.base.services.mail import TolerantDict from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.control.permissions import EventPermissionRequiredMixin -from pretix.multidomain.urlreverse import build_absolute_uri from pretix.plugins.sendmail.tasks import send_mails from . import forms @@ -90,22 +90,15 @@ class SenderView(EventPermissionRequiredMixin, FormView): for l in self.request.event.settings.locales: with language(l): + context_dict = TolerantDict() + for k, v in get_available_placeholders(self.request.event, ['event', 'order', + 'position_or_address']).items(): + context_dict[k] = '{}'.format( + _('This value will be replaced based on dynamic parameters.'), + v.render_sample(self.request.event) + ) - context_dict = { - 'code': 'ORDER1234', - 'event': self.request.event.name, - 'date': date_format(now(), 'SHORT_DATE_FORMAT'), - 'expire_date': date_format(now() + timedelta(days=7), 'SHORT_DATE_FORMAT'), - 'url': build_absolute_uri(self.request.event, 'presale:event.order.open', kwargs={ - 'order': 'ORDER1234', - 'secret': 'longrandomsecretabcdef123456', - 'hash': 'abcdef', - }), - 'invoice_name': _('John Doe'), - 'invoice_company': _('Sample Company LLC') - } - - subject = form.cleaned_data['subject'].localize(l) + subject = bleach.clean(form.cleaned_data['subject'].localize(l), tags=[]) preview_subject = subject.format_map(context_dict) message = form.cleaned_data['message'].localize(l) preview_text = markdown_compile_email(message.format_map(context_dict)) diff --git a/src/pretix/presale/views/user.py b/src/pretix/presale/views/user.py index b21b044c1c..ab1e71db5e 100644 --- a/src/pretix/presale/views/user.py +++ b/src/pretix/presale/views/user.py @@ -7,8 +7,9 @@ from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from django.views.generic import TemplateView +from pretix.base.email import get_email_context from pretix.base.services.mail import INVALID_ADDRESS, SendMailException, mail -from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse +from pretix.multidomain.urlreverse import eventreverse from pretix.presale.forms.user import ResendLinkForm from pretix.presale.views import EventViewMixin @@ -37,25 +38,13 @@ class ResendLinkView(EventViewMixin, TemplateView): rc.setex('pretix_resend_{}'.format(user), 3600 * 24, '1') orders = self.request.event.orders.filter(email__iexact=user) - order_context = [] - - for order in orders: - url = build_absolute_uri( - self.request.event, - 'presale:event.order', - kwargs={'order': order.code, 'secret': order.secret} - ) - order_context.append(' - {} - {}'.format(order, url)) if not orders: user = INVALID_ADDRESS subject = _('Your orders for {}'.format(self.request.event)) template = self.request.event.settings.mail_text_resend_all_links - context = { - 'orders': '\n'.join(order_context), - 'event': self.request.event, - } + context = get_email_context(event=self.request.event, orders=orders) try: mail(user, subject, template, context, event=self.request.event, locale=self.request.LANGUAGE_CODE) except SendMailException: diff --git a/src/pretix/static/pretixcontrol/js/ui/mail.js b/src/pretix/static/pretixcontrol/js/ui/mail.js index fcfa90f545..65c1df0214 100644 --- a/src/pretix/static/pretixcontrol/js/ui/mail.js +++ b/src/pretix/static/pretixcontrol/js/ui/mail.js @@ -6,6 +6,7 @@ function preview_task_callback(data, jqXHR, status) { var target = $('div[for=' + data.item + '][lang=' + m +']'); if (target.length === 1){ target.html(data.msgs[m]); + target.find('.placeholder').tooltip(); } } } @@ -32,6 +33,7 @@ function preview_task_error(item) { $(function () { "use strict"; + $('.mail-preview .placeholder').tooltip(); $('a[type=preview]').on('click', function () { var itemName = $(this).closest('.preview-panel').attr('for'); if ($('#' + itemName + '_panel').data('ajaxing') || $(this).parent('.active').length !== 0) { @@ -64,4 +66,4 @@ $(function () { ); }); -}); \ No newline at end of file +}); diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index f7674b1095..debbf666da 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -141,6 +141,10 @@ div.mail-preview { border: 1px solid #ccc; border-top-width: 1px; border-radius: 3px; + + .placeholder { + background: transparentize($brand-warning, 0.6); + } } .mail-preview-group div[lang] { diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index 88c32f2b5c..d77ec66140 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -464,6 +464,7 @@ class EventsTest(SoupTest): data['test'] = '1' doc = self.post_doc('/control/event/%s/%s/settings/email' % (self.orga1.slug, self.event1.slug), data, follow=True) + print(doc) assert doc.select('.alert-success') self.event1.settings.flush() assert mocked.called diff --git a/src/tests/control/test_settings.py b/src/tests/control/test_settings.py index 6080e80050..5fb20d7e34 100644 --- a/src/tests/control/test_settings.py +++ b/src/tests/control/test_settings.py @@ -144,7 +144,7 @@ class MailSettingPreviewTest(SoupTest): assert self.locale_event.name['en'] in res['msgs']['en'] def test_mail_text_order_placed(self): - text = '{event}{total}{currency}{date}{payment_info}{url}{invoice_name}{invoice_company}' + text = '{event}{total}{currency}{expire_date}{payment_info}{url}{invoice_name}{invoice_company}' response = self.client.post(self.target.format( self.orga1.slug, self.event1.slug), { 'item': 'mail_text_order_placed', @@ -273,8 +273,8 @@ class MailSettingPreviewTest(SoupTest): assert len(res['msgs']) == 1 assert text in res['msgs']['en'] - def test_localised_date(self): - dummy_text = '{date}' + def test_localized_date(self): + dummy_text = '{expire_date}' response = self.client.post(self.target.format( self.orga1.slug, self.locale_event.slug), { 'item': 'mail_text_order_placed', @@ -287,7 +287,7 @@ class MailSettingPreviewTest(SoupTest): assert len(res['msgs']) == 2 assert res['msgs']['en'] != res['msgs']['de-informal'] - def test_localised_expire_date(self): + def test_localized_expire_date(self): dummy_text = '{expire_date}' response = self.client.post(self.target.format( self.orga1.slug, self.locale_event.slug), { @@ -301,7 +301,7 @@ class MailSettingPreviewTest(SoupTest): assert len(res['msgs']) == 2 assert res['msgs']['en'] != res['msgs']['de-informal'] - def test_localised_payment_info(self): + def test_localized_payment_info(self): dummy_text = '{payment_info}' response = self.client.post(self.target.format( self.orga1.slug, self.locale_event.slug), { diff --git a/src/tests/plugins/test_sendmail.py b/src/tests/plugins/test_sendmail.py index 04d024cd21..0c7c984b2f 100644 --- a/src/tests/plugins/test_sendmail.py +++ b/src/tests/plugins/test_sendmail.py @@ -244,7 +244,7 @@ def test_sendmail_placeholder(logged_in_client, sendmail_url, event, order, pos) follow=True) assert response.status_code == 200 - assert 'ORDER1234' in response.rendered_content + assert 'F8VVL' in response.rendered_content assert len(djmail.outbox) == 0