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 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

View File

@@ -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.'),

View File

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

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.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):

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.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.'),

View File

@@ -74,6 +74,13 @@
{% bootstrap_field form.comment layout="control" %}
{% bootstrap_field form.show_hidden_items layout="control" %}
</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 %}
<div class="form-group submit-group">
<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.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.vouchers import VoucherBulkForm, VoucherForm
from pretix.control.permissions import EventPermissionRequiredMixin
@@ -314,12 +315,26 @@ class VoucherBulkCreate(EventPermissionRequiredMixin, CreateView):
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
# other than PostgreSQL
voucherids = []
for v in self.request.event.vouchers.filter(code__in=form.cleaned_data['codes']):
log_entries.append(
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)
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())
def get_form_class(self):

View File

@@ -273,13 +273,13 @@ var form_handlers = function (el) {
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),
dependency = $($(this).attr("data-display-dependency")),
update = function (ev) {
var enabled = (dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val();
var $toggling = dependent;
if (dependent.get(0).tagName.toLowerCase() === "input") {
if (dependent.get(0).tagName.toLowerCase() !== "div") {
$toggling = dependent.closest('.form-group');
}
if (ev) {