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
This commit is contained in:
Raphael Michel
2019-10-29 11:53:59 +01:00
committed by GitHub
parent 161f4a8132
commit 1e0e8184c8
10 changed files with 317 additions and 6 deletions

View File

@@ -13,7 +13,7 @@ class PretixBaseConfig(AppConfig):
from . import invoice # NOQA from . import invoice # NOQA
from . import notifications # NOQA from . import notifications # NOQA
from . import email # 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: try:
from .celery_app import app as celery_app # NOQA from .celery_app import app as celery_app # NOQA

View File

@@ -377,6 +377,23 @@ def base_placeholders(sender, **kwargs):
'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code, 'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code,
'68CYU2H6ZTP3WLK5' '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( SimpleFunctionalMailTextPlaceholder(
'comment', ['comment'], lambda comment: comment, 'comment', ['comment'], lambda comment: comment,
_('An individual text with a reason can be inserted here.'), _('An individual text with a reason can be inserted here.'),

View File

@@ -121,7 +121,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
}) })
renderer = ClassicMailRenderer(None) renderer = ClassicMailRenderer(None)
content_plain = body_plain = render_mail(template, context) 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) sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
if event: if event:
sender_name = event.settings.mail_from_name or str(event.name) sender_name = event.settings.mail_from_name or str(event.name)

View File

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

View File

@@ -1,11 +1,17 @@
import csv
from collections import namedtuple
from io import StringIO
from django import forms from django import forms
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import EmailValidator
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.urls import reverse from django.urls import reverse
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes.forms import SafeModelChoiceField 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.base.models import Item, Voucher
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
@@ -191,6 +197,54 @@ class VoucherBulkForm(VoucherForm):
), ),
required=True 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: class Meta:
model = Voucher model = Voucher
@@ -213,6 +267,51 @@ class VoucherBulkForm(VoucherForm):
'max_usages': _('Number of times times EACH of these vouchers can be redeemed.') '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): def clean(self):
data = super().clean() data = super().clean()
@@ -222,6 +321,16 @@ class VoucherBulkForm(VoucherForm):
if vouchers.exists(): if vouchers.exists():
raise ValidationError(_('A voucher with one of these codes already 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 return data
def save(self, event, *args, **kwargs): def save(self, event, *args, **kwargs):

View File

@@ -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.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
'pretix.organizer.deleted': _('The organizer "{name}" has been deleted.'), 'pretix.organizer.deleted': _('The organizer "{name}" has been deleted.'),
'pretix.voucher.added': _('The voucher has been created.'), '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.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.changed': _('The voucher has been changed.'),
'pretix.voucher.deleted': _('The voucher has been deleted.'), 'pretix.voucher.deleted': _('The voucher has been deleted.'),

View File

@@ -74,6 +74,13 @@
{% bootstrap_field form.comment layout="control" %} {% bootstrap_field form.comment layout="control" %}
{% bootstrap_field form.show_hidden_items layout="control" %} {% bootstrap_field form.show_hidden_items layout="control" %}
</fieldset> </fieldset>
<fieldset>
<legend>{% trans "Send out emails" %}</legend>
{% 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" %}
</fieldset>
{% eventsignal request.event "pretix.control.signals.voucher_form_html" form=form %} {% eventsignal request.event "pretix.control.signals.voucher_form_html" form=form %}
<div class="form-group submit-group"> <div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save"> <button type="submit" class="btn btn-primary btn-save">

View File

@@ -21,6 +21,7 @@ from django.views.generic import (
from pretix.base.models import CartPosition, LogEntry, OrderPosition, Voucher from pretix.base.models import CartPosition, LogEntry, OrderPosition, Voucher
from pretix.base.models.vouchers import _generate_random_code from pretix.base.models.vouchers import _generate_random_code
from pretix.base.services.vouchers import vouchers_send
from pretix.control.forms.filter import VoucherFilterForm, VoucherTagFilterForm from pretix.control.forms.filter import VoucherFilterForm, VoucherTagFilterForm
from pretix.control.forms.vouchers import VoucherBulkForm, VoucherForm from pretix.control.forms.vouchers import VoucherBulkForm, VoucherForm
from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.permissions import EventPermissionRequiredMixin
@@ -314,12 +315,26 @@ class VoucherBulkCreate(EventPermissionRequiredMixin, CreateView):
form.save(self.request.event) form.save(self.request.event)
# We need to query them again as form.save() uses bulk_create which does not fill in .pk values on databases # We need to query them again as form.save() uses bulk_create which does not fill in .pk values on databases
# other than PostgreSQL # other than PostgreSQL
voucherids = []
for v in self.request.event.vouchers.filter(code__in=form.cleaned_data['codes']): for v in self.request.event.vouchers.filter(code__in=form.cleaned_data['codes']):
log_entries.append( log_entries.append(
v.log_action('pretix.voucher.added', data=form.cleaned_data, user=self.request.user, save=False) v.log_action('pretix.voucher.added', data=form.cleaned_data, user=self.request.user, save=False)
) )
voucherids.append(v.pk)
LogEntry.objects.bulk_create(log_entries) LogEntry.objects.bulk_create(log_entries)
messages.success(self.request, _('The new vouchers have been created.'))
if form.cleaned_data['send']:
vouchers_send.apply_async(kwargs={
'event': self.request.event.pk,
'vouchers': voucherids,
'subject': form.cleaned_data['send_subject'],
'message': form.cleaned_data['send_message'],
'recipients': [r._asdict() for r in form.cleaned_data['send_recipients']],
'user': self.request.user.pk,
})
messages.success(self.request, _('The new vouchers have been created and will be sent out shortly.'))
else:
messages.success(self.request, _('The new vouchers have been created.'))
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
def get_form_class(self): def get_form_class(self):

View File

@@ -273,13 +273,13 @@ var form_handlers = function (el) {
dependency.on("change", update); dependency.on("change", update);
}); });
el.find("div[data-display-dependency], input[data-display-dependency]").each(function () { el.find("div[data-display-dependency], textarea[data-display-dependency], input[data-display-dependency]").each(function () {
var dependent = $(this), var dependent = $(this),
dependency = $($(this).attr("data-display-dependency")), dependency = $($(this).attr("data-display-dependency")),
update = function (ev) { update = function (ev) {
var enabled = (dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val(); var enabled = (dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val();
var $toggling = dependent; var $toggling = dependent;
if (dependent.get(0).tagName.toLowerCase() === "input") { if (dependent.get(0).tagName.toLowerCase() !== "div") {
$toggling = dependent.closest('.form-group'); $toggling = dependent.closest('.form-group');
} }
if (ev) { if (ev) {

View File

@@ -2,6 +2,7 @@ import datetime
import decimal import decimal
import json import json
from django.core import mail as djmail
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from tests.base import SoupTest, extract_form_fields from tests.base import SoupTest, extract_form_fields
@@ -476,6 +477,121 @@ class VoucherFormTest(SoupTest):
'itemvar': '%d' % self.shirt.pk, 'itemvar': '%d' % self.shirt.pk,
}, expected_failure=True) }, expected_failure=True)
def test_create_bulk_send(self):
self._create_bulk_vouchers({
'codes': 'ABCDE\nDEFGH',
'itemvar': '%d' % self.shirt.pk,
'send': 'on',
'send_subject': 'Your voucher',
'send_message': 'Voucher list: {voucher_list}',
'send_recipients': 'foo@example.com\nfoo@example.net'
})
assert len(djmail.outbox) == 2
assert len([m for m in djmail.outbox if m.to == ['foo@example.com']]) == 1
assert len([m for m in djmail.outbox if m.to == ['foo@example.net']]) == 1
assert len([m for m in djmail.outbox if 'ABCDE' in m.body]) == 1
assert len([m for m in djmail.outbox if 'DEFGH' in m.body]) == 1
def test_create_bulk_send_csv(self):
self._create_bulk_vouchers({
'codes': 'ABCDE\nDEFGH',
'itemvar': '%d' % self.shirt.pk,
'send': 'on',
'send_subject': 'Your voucher',
'send_message': 'Voucher list: {voucher_list}',
'send_recipients': 'email,number\nfoo@example.com,2'
})
assert len(djmail.outbox) == 1
assert 'ABCDE' in djmail.outbox[0].body
assert 'DEFGH' in djmail.outbox[0].body
assert ['foo@example.com'] == djmail.outbox[0].to
def test_create_bulk_send_csv_tag(self):
self._create_bulk_vouchers({
'codes': 'ABCDE\nDEFGH',
'itemvar': '%d' % self.shirt.pk,
'send': 'on',
'send_subject': 'Your voucher',
'send_message': 'Voucher list: {voucher_list}',
'send_recipients': 'email,number,tag\nfoo@example.com,2,mytag'
})
assert len(djmail.outbox) == 1
assert 'ABCDE' in djmail.outbox[0].body
assert 'DEFGH' in djmail.outbox[0].body
assert ['foo@example.com'] == djmail.outbox[0].to
with scopes_disabled():
assert Voucher.objects.get(code="ABCDE").tag == "mytag"
def test_create_bulk_send_invalid_placeholder(self):
self._create_bulk_vouchers({
'codes': 'ABCDE\nDEFGH',
'itemvar': '%d' % self.shirt.pk,
'send': 'on',
'send_subject': 'Your voucher',
'send_message': 'Voucher list: {order}',
'send_recipients': 'foo@example.com\nfoo@example.net'
}, expected_failure=True)
def test_create_bulk_send_empty_subject(self):
self._create_bulk_vouchers({
'codes': 'ABCDE\nDEFGH',
'itemvar': '%d' % self.shirt.pk,
'send': 'on',
'send_subject': '',
'send_message': 'Voucher list: {voucher_list}',
'send_recipients': 'foo@example.com\nfoo@example.net'
}, expected_failure=True)
def test_create_bulk_send_invalid_mail_list(self):
self._create_bulk_vouchers({
'codes': 'ABCDE\nDEFGH',
'itemvar': '%d' % self.shirt.pk,
'send': 'on',
'send_subject': 'Your voucher',
'send_message': 'Voucher list: {voucher_list}',
'send_recipients': 'foooo\nfoo@example.org'
}, expected_failure=True)
def test_create_bulk_send_invalid_mail_count(self):
self._create_bulk_vouchers({
'codes': 'ABCDE\nDEFGH',
'itemvar': '%d' % self.shirt.pk,
'send': 'on',
'send_subject': 'Your voucher',
'send_message': 'Voucher list: {voucher_list}',
'send_recipients': 'foooo@example.org'
}, expected_failure=True)
def test_create_bulk_send_missing_csv_header(self):
self._create_bulk_vouchers({
'codes': 'ABCDE\nDEFGH',
'itemvar': '%d' % self.shirt.pk,
'send': 'on',
'send_subject': 'Your voucher',
'send_message': 'Voucher list: {voucher_list}',
'send_recipients': 'foooo@example.org,bar,baz'
}, expected_failure=True)
def test_create_bulk_send_missing_csv_header_email(self):
self._create_bulk_vouchers({
'codes': 'ABCDE\nDEFGH',
'itemvar': '%d' % self.shirt.pk,
'send': 'on',
'send_subject': 'Your voucher',
'send_message': 'Voucher list: {voucher_list}',
'send_recipients': 'mail,number,tag\nfoooo@example.org,2,baz'
}, expected_failure=True)
def test_create_bulk_send_missing_csv_unknown_header(self):
self._create_bulk_vouchers({
'codes': 'ABCDE\nDEFGH',
'itemvar': '%d' % self.shirt.pk,
'send': 'on',
'send_subject': 'Your voucher',
'send_message': 'Voucher list: {voucher_list}',
'send_recipients': 'email,number,flop\nfoooo@example.org,2,baz'
}, expected_failure=True)
def test_delete_voucher(self): def test_delete_voucher(self):
with scopes_disabled(): with scopes_disabled():
v = self.event.vouchers.create(quota=self.quota_tickets) v = self.event.vouchers.create(quota=self.quota_tickets)