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

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