From 1e0e8184c857d6f6d1e9ba2bc814f0940d932872 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 29 Oct 2019 11:53:59 +0100 Subject: [PATCH] Fix #312 -- Bulk-send vouchers via email (#1469) * Allow to directly bulk-send vouchers via email * Send mails * Log messages * Fix test failures * Add new test cases --- src/pretix/base/__init__.py | 2 +- src/pretix/base/email.py | 17 +++ src/pretix/base/services/mail.py | 2 +- src/pretix/base/services/vouchers.py | 46 +++++++ src/pretix/control/forms/vouchers.py | 111 ++++++++++++++++- src/pretix/control/logdisplay.py | 1 + .../pretixcontrol/vouchers/bulk.html | 7 ++ src/pretix/control/views/vouchers.py | 17 ++- src/pretix/static/pretixcontrol/js/ui/main.js | 4 +- src/tests/control/test_vouchers.py | 116 ++++++++++++++++++ 10 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 src/pretix/base/services/vouchers.py diff --git a/src/pretix/base/__init__.py b/src/pretix/base/__init__.py index ed61669342..d4f6aa0562 100644 --- a/src/pretix/base/__init__.py +++ b/src/pretix/base/__init__.py @@ -13,7 +13,7 @@ class PretixBaseConfig(AppConfig): from . import invoice # NOQA from . import notifications # NOQA from . import email # NOQA - from .services import auth, checkin, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA + from .services import auth, checkin, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA try: from .celery_app import app as celery_app # NOQA diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index 287055dd4f..e5913f7a75 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -377,6 +377,23 @@ def base_placeholders(sender, **kwargs): 'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code, '68CYU2H6ZTP3WLK5' ), + SimpleFunctionalMailTextPlaceholder( + 'voucher_list', ['voucher_list'], lambda voucher_list: '\n'.join(voucher_list), + ' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2' + ), + SimpleFunctionalMailTextPlaceholder( + '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, + }) + ), + SimpleFunctionalMailTextPlaceholder( + 'name', ['name'], lambda name: name, + _('John Doe') + ), SimpleFunctionalMailTextPlaceholder( 'comment', ['comment'], lambda comment: comment, _('An individual text with a reason can be inserted here.'), diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 86d99c4489..2cc958cd20 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -121,7 +121,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString], }) renderer = ClassicMailRenderer(None) content_plain = body_plain = render_mail(template, context) - subject = str(subject).format_map(context) + subject = str(subject).format_map(TolerantDict(context)) sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM) if event: sender_name = event.settings.mail_from_name or str(event.name) diff --git a/src/pretix/base/services/vouchers.py b/src/pretix/base/services/vouchers.py new file mode 100644 index 0000000000..de4bf6ff0e --- /dev/null +++ b/src/pretix/base/services/vouchers.py @@ -0,0 +1,46 @@ +from django.utils.translation import gettext +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, User, Voucher +from pretix.base.services.mail import mail +from pretix.base.services.tasks import ProfiledEventTask +from pretix.celery_app import app + + +@app.task(base=ProfiledEventTask) +def vouchers_send(event: Event, vouchers: list, subject: str, message: str, recipients: list, user: int) -> None: + vouchers = list(Voucher.objects.filter(id__in=vouchers).order_by('id')) + user = User.objects.get(pk=user) + for r in recipients: + voucher_list = [] + for i in range(r['number']): + voucher_list.append(vouchers.pop()) + with language(event.settings.locale): + email_context = get_email_context(event=event, name=r.get('name') or '', voucher_list=[v.code for v in voucher_list]) + mail( + r['email'], + subject, + LazyI18nString(message), + email_context, + event, + locale=event.settings.locale, + ) + for v in voucher_list: + if r.get('tag') and r.get('tag') != v.tag: + v.tag = r.get('tag') + if v.comment: + v.comment += '\n\n' + v.comment = gettext('The voucher has been sent to {recipient}.').format(recipient=r['email']) + v.save(update_fields=['tag', 'comment']) + v.log_action( + 'pretix.voucher.sent', + user=user, + data={ + 'recipient': r['email'], + 'name': r.get('name'), + 'subject': subject, + 'message': message, + } + ) diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index 1056d9eb87..ba72ba560f 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -1,11 +1,17 @@ +import csv +from collections import namedtuple +from io import StringIO + from django import forms from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.validators import EmailValidator from django.db.models.functions import Lower from django.urls import reverse from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django_scopes.forms import SafeModelChoiceField -from pretix.base.forms import I18nModelForm +from pretix.base.email import get_available_placeholders +from pretix.base.forms import I18nModelForm, PlaceholderValidator from pretix.base.models import Item, Voucher from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget from pretix.control.forms.widgets import Select2, Select2ItemVarQuota @@ -191,6 +197,54 @@ class VoucherBulkForm(VoucherForm): ), required=True ) + send = forms.BooleanField( + label=_("Send vouchers via email"), + required=False + ) + send_subject = forms.CharField( + label=_("Subject"), + widget=forms.TextInput(attrs={'data-display-dependency': '#id_send'}), + required=False, + initial=_('Your voucher for {event}') + ) + send_message = forms.CharField( + label=_("Message"), + widget=forms.Textarea(attrs={'data-display-dependency': '#id_send'}), + required=False, + initial=_('Hello,\n\n' + 'with this email, we\'re sending you one or more vouchers for {event}:\n\n{voucher_list}\n\n' + 'You can redeem them here in our ticket shop:\n\n{url}\n\nBest regards,\n\n' + 'Your {event} team') + ) + send_recipients = forms.CharField( + label=_('Recipients'), + widget=forms.Textarea(attrs={ + 'data-display-dependency': '#id_send', + 'placeholder': 'email,number,name,tag\njohn@example.org,3,John,example\n\n-- {} --\n\njohn@example.org\njane@example.net'.format( + _('or') + ) + }), + required=False, + help_text=_('You can either supply a list of email addresses with one email address per line, or a CSV file with a title column ' + 'and one or more of the columns "email", "number", "name", or "tag".') + ) + Recipient = namedtuple('Recipient', 'email number name tag') + + def _set_field_placeholders(self, fn, base_parameters): + phs = [ + '{%s}' % p + for p in sorted(get_available_placeholders(self.instance.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) + ) class Meta: model = Voucher @@ -213,6 +267,51 @@ class VoucherBulkForm(VoucherForm): 'max_usages': _('Number of times times EACH of these vouchers can be redeemed.') } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._set_field_placeholders('send_subject', ['event', 'name']) + self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name']) + + def clean_send_recipients(self): + raw = self.cleaned_data['send_recipients'] + if not raw: + return [] + r = raw.split('\n') + res = [] + if ',' in raw or ';' in raw: + if '@' in r[0]: + raise ValidationError(_('CSV input needs to contain a header row in the first line.')) + dialect = csv.Sniffer().sniff(raw[:1024]) + reader = csv.DictReader(StringIO(raw), dialect=dialect) + if 'email' not in reader.fieldnames: + raise ValidationError(_('CSV input needs to contain a field with the header "{header}".').format(header="email")) + unknown_fields = [f for f in reader.fieldnames if f not in ('email', 'name', 'tag', 'number')] + if unknown_fields: + raise ValidationError(_('CSV input contains an unknown field with the header "{header}".').format(header=unknown_fields[0])) + for i, row in enumerate(reader): + try: + EmailValidator()(row['email']) + except ValidationError as err: + raise ValidationError(_('{value} is not a valid email address.').format(value=row['email'])) from err + try: + res.append(self.Recipient( + name=row.get('name', ''), + email=row['email'], + number=int(row.get('number', 1)), + tag=row.get('tag', None) + )) + except ValueError as err: + raise ValidationError(_('Invalid value in row {number}.').format(number=i + 1)) from err + else: + for e in r: + try: + EmailValidator()(e.strip()) + except ValidationError as err: + raise ValidationError(_('{value} is not a valid email address.').format(value=e.strip())) from err + else: + res.append(self.Recipient(email=e, number=1, tag=None, name='')) + return res + def clean(self): data = super().clean() @@ -222,6 +321,16 @@ class VoucherBulkForm(VoucherForm): if vouchers.exists(): raise ValidationError(_('A voucher with one of these codes already exists.')) + if data.get('send') and not all([data.get('send_subject'), data.get('send_message'), data.get('send_recipients')]): + raise ValidationError(_('If vouchers should be sent by email, subject, message and recipients need to be specified.')) + + if data.get('codes') and data.get('send'): + recp = self.cleaned_data.get('send_recipients', []) + code_len = len(data.get('codes')) + recp_len = sum(r.number for r in recp) + if code_len != recp_len: + raise ValidationError(_('You generated {codes} vouchers, but entered recipients for {recp} vouchers.').format(codes=code_len, recp=recp_len)) + return data def save(self, event, *args, **kwargs): diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 685c0a652d..fab771dbee 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -244,6 +244,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'), 'pretix.organizer.deleted': _('The organizer "{name}" has been deleted.'), 'pretix.voucher.added': _('The voucher has been created.'), + 'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'), 'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'), 'pretix.voucher.changed': _('The voucher has been changed.'), 'pretix.voucher.deleted': _('The voucher has been deleted.'), diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html index 04997f53f5..f5a9f634fb 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html @@ -74,6 +74,13 @@ {% bootstrap_field form.comment layout="control" %} {% bootstrap_field form.show_hidden_items layout="control" %} +
+ {% trans "Send out emails" %} + {% bootstrap_field form.send layout="control" %} + {% bootstrap_field form.send_subject layout="horizontal" %} + {% bootstrap_field form.send_message layout="horizontal" %} + {% bootstrap_field form.send_recipients layout="horizontal" %} +
{% eventsignal request.event "pretix.control.signals.voucher_form_html" form=form %}