Auto-scheduled emails

Co-authored-by: Raphael Michel <michel@rami.io>
This commit is contained in:
Julia Luna
2021-01-21 12:27:11 +01:00
committed by Raphael Michel
parent e4949b6491
commit 64d07a2811
22 changed files with 1479 additions and 81 deletions

View File

@@ -41,14 +41,35 @@ from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from pretix.base.email import get_available_placeholders
from pretix.base.forms import PlaceholderValidator
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.forms import I18nModelForm, PlaceholderValidator
from pretix.base.forms.widgets import (
SplitDateTimePickerWidget, TimePickerWidget,
)
from pretix.base.models import CheckinList, Item, Order, SubEvent
from pretix.control.forms import CachedFileField
from pretix.control.forms import CachedFileField, SplitDateTimeField
from pretix.control.forms.widgets import Select2, Select2Multiple
from pretix.plugins.sendmail.models import Rule
class MailForm(forms.Form):
class FormPlaceholderMixin:
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)
)
class MailForm(FormPlaceholderMixin, forms.Form):
recipients = forms.ChoiceField(
label=_('Send email to'),
widget=forms.RadioSelect,
@@ -119,22 +140,6 @@ class MailForm(forms.Form):
raise ValidationError(pgettext_lazy('subevent', 'If you set a date range, please set both a start and an end.'))
return d
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 = self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
@@ -217,3 +222,104 @@ class MailForm(forms.Form):
del self.fields['subevent']
del self.fields['subevents_from']
del self.fields['subevents_to']
class RuleForm(FormPlaceholderMixin, I18nModelForm):
class Meta:
model = Rule
fields = ['subject', 'template',
'send_date', 'send_offset_days', 'send_offset_time',
'include_pending', 'all_products', 'limit_products',
'send_to']
field_classes = {
'subevent': SafeModelMultipleChoiceField,
'limit_products': SafeModelMultipleChoiceField,
'send_date': SplitDateTimeField,
}
widgets = {
'send_date': SplitDateTimePickerWidget(attrs={
'data-display-dependency': '#id_schedule_type_0',
}),
'send_offset_days': forms.NumberInput(attrs={
'data-display-dependency': '#id_schedule_type_1,#id_schedule_type_2,#id_schedule_type_3,'
'#id_schedule_type_4',
}),
'send_offset_time': TimePickerWidget(attrs={
'data-display-dependency': '#id_schedule_type_1,#id_schedule_type_2,#id_schedule_type_3,'
'#id_schedule_type_4',
}),
'limit_products': forms.CheckboxSelectMultiple(
attrs={'class': 'scrolling-multiple-choice',
'data-inverse-dependency': '#id_all_products'},
),
'send_to': forms.RadioSelect,
}
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
if instance:
if instance.date_is_absolute:
dia = "abs"
else:
dia = "rel"
dia += "_a" if instance.offset_is_after else "_b"
dia += "_e" if instance.offset_to_event_end else "_s"
else:
dia = "abs"
kwargs.setdefault('initial', {})
kwargs['initial']['schedule_type'] = dia
super().__init__(*args, **kwargs)
self.fields['limit_products'].queryset = Item.objects.filter(event=self.event)
self.fields['schedule_type'] = forms.ChoiceField(
label=_('Type of schedule time'),
widget=forms.RadioSelect,
choices=[
('abs', _('Absolute')),
('rel_b_s', _('Relative, before event start')),
('rel_b_e', _('Relative, before event end')),
('rel_a_s', _('Relative, after event start')),
('rel_a_e', _('Relative, after event end'))
]
)
self._set_field_placeholders('subject', ['event', 'order'])
self._set_field_placeholders('template', ['event', 'order'])
def clean(self):
d = super().clean()
dia = d.get('schedule_type')
if dia == 'abs':
if not d.get('send_date'):
raise ValidationError({'send_date': _('Please specify the send date')})
d['date_is_absolute'] = True
d['send_offset_days'] = d['send_offset_time'] = None
else:
if not (d.get('send_offset_days') and d.get('send_offset_time')):
raise ValidationError(_('Please specify the offset days and time'))
d['offset_is_after'] = '_a' in dia
d['offset_to_event_end'] = '_e' in dia
d['date_is_absolute'] = False
d['send_date'] = None
if d.get('all_products'):
# having products checked while the option is ignored is probably counterintuitive
d['limit_products'] = Item.objects.none()
else:
if not d.get('limit_products'):
raise ValidationError({'limit_products': _('Please specify a product')})
self.instance.offset_is_after = d.get('offset_is_after', False)
self.instance.offset_to_event_end = d.get('offset_to_event_end', False)
self.instance.date_is_absolute = d.get('date_is_absolute', False)
return d

View File

@@ -0,0 +1,52 @@
# Generated by Django 3.2.2 on 2021-06-03 09:17
import django.db.models.deletion
import i18nfield.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('pretixbase', '0191_event_last_modified'),
]
operations = [
migrations.CreateModel(
name='Rule',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('subject', i18nfield.fields.I18nCharField(max_length=255)),
('template', i18nfield.fields.I18nTextField()),
('all_products', models.BooleanField(default=True)),
('include_pending', models.BooleanField(default=False)),
('send_date', models.DateTimeField(blank=True, null=True)),
('send_offset_days', models.IntegerField(null=True)),
('send_offset_time', models.TimeField(blank=True, null=True)),
('date_is_absolute', models.BooleanField(default=True)),
('offset_to_event_end', models.BooleanField(default=False)),
('offset_is_after', models.BooleanField(default=False)),
('send_to', models.CharField(default='orders', max_length=10)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sendmail_rules', to='pretixbase.event')),
('limit_products', models.ManyToManyField(to='pretixbase.Item')),
],
),
migrations.CreateModel(
name='ScheduledMail',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('last_computed', models.DateTimeField(auto_now_add=True)),
('computed_datetime', models.DateTimeField(db_index=True)),
('state', models.CharField(default='scheduled', max_length=100)),
('last_successful_order_id', models.BigIntegerField(null=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.event')),
('rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sendmail.rule')),
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.subevent')),
],
options={
'unique_together': {('rule', 'subevent')},
},
),
]

View File

@@ -0,0 +1,274 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from datetime import datetime, time, timedelta
from django.db import models
from django.db.models import Exists, OuterRef
from django.utils import timezone
from django.utils.formats import date_format
from django.utils.timezone import make_aware
from django.utils.translation import gettext_lazy as _, ngettext
from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.email import get_email_context
from pretix.base.models import (
Event, InvoiceAddress, Item, Order, OrderPosition, SubEvent,
)
from pretix.base.services.mail import SendMailException
class ScheduledMail(models.Model):
STATE_SCHEDULED = 'scheduled'
STATE_FAILED = 'failed'
STATE_COMPLETED = 'completed'
STATE_CHOICES = [
(STATE_SCHEDULED, STATE_SCHEDULED),
(STATE_FAILED, STATE_FAILED),
(STATE_COMPLETED, STATE_COMPLETED),
]
id = models.BigAutoField(primary_key=True)
rule = models.ForeignKey("Rule", on_delete=models.CASCADE)
subevent = models.ForeignKey(SubEvent, null=True, on_delete=models.CASCADE)
event = models.ForeignKey(Event, on_delete=models.CASCADE)
last_computed = models.DateTimeField(auto_now_add=True)
computed_datetime = models.DateTimeField(db_index=True)
state = models.CharField(max_length=100, choices=STATE_CHOICES, default=STATE_SCHEDULED)
last_successful_order_id = models.BigIntegerField(null=True)
class Meta:
unique_together = ('rule', 'subevent'),
def save(self, **kwargs):
if not self.computed_datetime:
self.recompute()
super().save(**kwargs)
def recompute(self):
if self.rule.date_is_absolute:
self.computed_datetime = self.rule.send_date
else:
e = self.subevent or self.event
o_days = self.rule.send_offset_days
if not self.rule.offset_is_after:
o_days *= -1
offset = timedelta(days=o_days)
st = self.rule.send_offset_time
base_time = (e.date_to or e.date_from) if self.rule.offset_to_event_end else e.date_from
d = base_time.astimezone(self.event.timezone).date() + offset
self.computed_datetime = make_aware(
datetime.combine(d, time(hour=st.hour, minute=st.minute, second=st.second, microsecond=0)),
self.event.timezone,
is_dst=False, # prevent AmbiguousTimeError
)
self.last_computed = timezone.now()
def send(self):
if self.state not in (ScheduledMail.STATE_SCHEDULED, ScheduledMail.STATE_FAILED):
raise ValueError("Should not be called in this state")
e = self.event
orders = e.orders.all()
limit_products = self.rule.limit_products.values_list('pk', flat=True) if not self.rule.all_products else None
if self.subevent:
orders = orders.filter(
Exists(OrderPosition.objects.filter(order=OuterRef('pk'), subevent=self.subevent))
)
if not self.rule.all_products:
orders = orders.filter(
Exists(OrderPosition.objects.filter(order=OuterRef('pk'), item_id__in=limit_products))
)
status = [Order.STATUS_PENDING, Order.STATUS_PAID] if self.rule.include_pending else [Order.STATUS_PAID]
if self.last_successful_order_id:
orders = orders.filter(
pk__gt=self.last_successful_order_id
)
orders = orders.filter(
status__in=status,
require_approval=False,
).order_by('pk').select_related('invoice_address').prefetch_related('positions')
send_to_orders = self.rule.send_to in (Rule.CUSTOMERS, Rule.BOTH)
send_to_attendees = self.rule.send_to in (Rule.ATTENDEES, Rule.BOTH)
for o in orders:
positions = list(o.positions.all())
o_sent = False
try:
ia = o.invoice_address
except InvoiceAddress.DoesNotExist:
ia = InvoiceAddress(order=o)
if send_to_orders and o.email:
email_ctx = get_email_context(event=e, order=o, position_or_address=ia)
try:
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
o_sent = True
except SendMailException:
... # ¯\_(ツ)_/¯
if send_to_attendees:
if not self.rule.all_products:
positions = [p for p in positions if p.item_id in limit_products]
if self.subevent_id:
positions = [p for p in positions if p.subevent_id == self.subevent_id]
for p in positions:
email_ctx = get_email_context(event=e, order=o, position_or_address=ia, position=p)
try:
if p.attendee_email and (p.attendee_email != o.email or not o_sent):
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
elif not o_sent and o.email:
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
log_entry_type='pretix.plugins.sendmail.rule.order.email.set')
o_sent = True
except SendMailException:
... # ¯\_(ツ)_/¯
self.last_successful_order_id = o.pk
class Rule(models.Model):
CUSTOMERS = "orders"
ATTENDEES = "attendees"
BOTH = "both"
SEND_TO_CHOICES = [
(CUSTOMERS, _("Customers")),
(ATTENDEES, _("Attendees")),
(BOTH, _("Both customers and attendees"))
]
id = models.BigAutoField(primary_key=True)
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='sendmail_rules')
subject = I18nCharField(max_length=255, verbose_name=_('Subject'))
template = I18nTextField(verbose_name=_('Message'))
all_products = models.BooleanField(default=True, verbose_name=_('All products'))
limit_products = models.ManyToManyField(Item, blank=True, verbose_name=_('Limit products'))
include_pending = models.BooleanField(
default=False,
verbose_name=_('Include pending orders'),
help_text=_('By default, only paid orders will receive the email')
)
# either send_date or send_offset_* have to be set
send_date = models.DateTimeField(null=True, blank=True, verbose_name=_('Send date'))
send_offset_days = models.IntegerField(null=True, blank=True, verbose_name=_('Number of days'))
send_offset_time = models.TimeField(null=True, blank=True, verbose_name=_('Time of day'))
date_is_absolute = models.BooleanField(default=True, blank=True)
offset_to_event_end = models.BooleanField(default=False, blank=True) # no verbose name because not actually
offset_is_after = models.BooleanField(default=False, blank=True) # displayed in any forms
send_to = models.CharField(max_length=10, choices=SEND_TO_CHOICES, default=CUSTOMERS)
objects = ScopedManager(organizer='event__organizer')
def save(self, **kwargs):
super().save(**kwargs)
create_sms = []
if self.event.has_subevents:
for se in self.event.subevents.annotate(has_sm=Exists(ScheduledMail.objects.filter(
subevent=OuterRef('pk'), rule=self))).filter(has_sm=False):
sm = ScheduledMail(rule=self, subevent=se, event=self.event)
sm.recompute()
create_sms.append(sm)
ScheduledMail.objects.bulk_create(create_sms)
else:
ScheduledMail.objects.get_or_create(rule=self, event=self.event)
update_sms = []
for sm in self.scheduledmail_set.all():
if sm in create_sms:
continue
previous = sm.computed_datetime
sm.recompute()
if sm.computed_datetime != previous:
update_sms.append(sm)
ScheduledMail.objects.bulk_update(update_sms, ['computed_datetime', 'last_computed'], 100)
@property
def human_readable_time(self):
if self.date_is_absolute:
d = self.send_date.astimezone(self.event.timezone)
return _('on {date} at {time}').format(date=date_format(d, 'SHORT_DATE_FORMAT'),
time=date_format(d, 'TIME_FORMAT'))
else:
if self.offset_to_event_end:
if self.offset_is_after:
s = ngettext(
'%(count)d day after event end at %(time)s',
'%(count)d days after event end at %(time)s',
self.send_offset_days
) % {
'count': self.send_offset_days,
'time': date_format(self.send_offset_time, 'TIME_FORMAT')
}
else:
s = ngettext(
'%(count)d day before event end at %(time)s',
'%(count)d days before event end at %(time)s',
self.send_offset_days
) % {
'count': self.send_offset_days,
'time': date_format(self.send_offset_time, 'TIME_FORMAT')
}
else:
if self.offset_is_after:
s = ngettext(
'%(count)d day after event start at %(time)s',
'%(count)d days after event start at %(time)s',
self.send_offset_days
) % {
'count': self.send_offset_days,
'time': date_format(self.send_offset_time, 'TIME_FORMAT')
}
else:
s = ngettext(
'%(count)d day before event start at %(time)s',
'%(count)d days before event start at %(time)s',
self.send_offset_days
) % {
'count': self.send_offset_days,
'time': date_format(self.send_offset_time, 'TIME_FORMAT')
}
return s

View File

@@ -31,13 +31,41 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import copy
import datetime
import logging
from django.conf import settings
from django.db import connection, transaction
from django.db.models import F, Q
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import resolve, reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_scopes import scope, scopes_disabled
from pretix.base.signals import logentry_display
from pretix.base.models import SubEvent
from pretix.base.signals import (
event_copy_data, logentry_display, periodic_task,
)
from pretix.control.signals import nav_event
from pretix.plugins.sendmail.models import ScheduledMail
logger = logging.getLogger(__name__)
@receiver(post_save, sender=SubEvent)
def scheduled_mail_create(sender, **kwargs):
subevent = kwargs.get('instance')
event = subevent.event
with scope(organizer=event.organizer):
existing_rules = ScheduledMail.objects.filter(subevent=subevent).values_list('rule_id', flat=True)
to_create = []
for rule in event.sendmail_rules.all():
if rule.pk not in existing_rules:
to_create.append(ScheduledMail(rule=rule, event=event, subevent=subevent))
ScheduledMail.objects.bulk_create(to_create)
@receiver(nav_event, dispatch_uid="sendmail_nav")
@@ -52,7 +80,6 @@ def control_nav_import(sender, request=None, **kwargs):
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': (url.namespace == 'plugins:sendmail' and url.url_name == 'send'),
'icon': 'envelope',
'children': [
{
@@ -63,6 +90,14 @@ def control_nav_import(sender, request=None, **kwargs):
}),
'active': (url.namespace == 'plugins:sendmail' and url.url_name == 'send'),
},
{
'label': _('Automated emails'),
'url': reverse('plugins:sendmail:rule.list', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': (url.namespace == 'plugins:sendmail' and url.url_name.startswith('rule.')),
},
{
'label': _('Email history'),
'url': reverse('plugins:sendmail:history', kwargs={
@@ -82,6 +117,81 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs):
'pretix.plugins.sendmail.sent': _('Email was sent'),
'pretix.plugins.sendmail.order.email.sent': _('The order received a mass email.'),
'pretix.plugins.sendmail.order.email.sent.attendee': _('A ticket holder of this order received a mass email.'),
'pretix.plugins.sendmail.rule.order.email.sent': _('A scheduled email was sent to the order'),
'pretix.plugins.sendmail.rule.order.position.email.sent': _('A scheduled email was sent to a ticket holder'),
'pretix.plugins.sendmail.rule.deleted': _('An email rule was deleted'),
}
if logentry.action_type in plains:
return plains[logentry.action_type]
@receiver(periodic_task)
def sendmail_run_rules(sender, **kwargs):
with scopes_disabled():
mails = ScheduledMail.objects.all()
for m in mails.filter(Q(last_computed__isnull=True)
| Q(subevent__last_modified__gt=F('last_computed'))
| Q(event__last_modified__gt=F('last_computed'))):
previous = m.computed_datetime
m.recompute()
if m.computed_datetime != previous:
m.save(update_fields=['last_computed', 'computed_datetime'])
for m_id in mails.filter(
state__in=(ScheduledMail.STATE_SCHEDULED, ScheduledMail.STATE_FAILED),
computed_datetime__gte=timezone.now() - datetime.timedelta(days=2),
computed_datetime__lte=timezone.now(),
).values_list('pk', flat=True):
# We try to send the emails in a "reasonably safe" way.
# - We use PostgreSQL-level locking to prevent to cronjob processes trying to
# work on the same email at the same time if .send() takes a long time.
# - If we fail in between emails due to some kind of pretix-level bug, such as
# an exception during placeholder rendering, we store a ``last_successful_order_id``
# pointer and continue from there in our retry attempt, avoiding to send all the
# previous emails a second time.
# - If we fail due to a system-level failure such as a signal interrupt or a lost
# connection to the database, this won't help us recover and on the next run, all
# emails might be sent a second time. This isn't nice, but any solution would either
# require settings some arbitrary timeout for a process or risk not sending some
# emails at all. Under the assumption that system-level failures are rare and (more
# importantly) usually don't happen multiple times in a row, this seems liek a
# good tradeoff.
# - We never retry for more than two days.
with transaction.atomic(durable=True):
m = ScheduledMail.objects.select_for_update(
skip_locked=connection.features.has_select_for_update_skip_locked
).filter(pk=m_id).first()
if not m or m.state not in (ScheduledMail.STATE_SCHEDULED, ScheduledMail.STATE_FAILED):
# object is currently locked by other thread (currently being sent)
# or has been sent in the meantime
continue
try:
m.send()
m.state = ScheduledMail.STATE_COMPLETED
m.save(update_fields=['state', 'last_successful_order_id'])
except Exception as e:
logger.exception('Could not send emails, will retry')
m.state = ScheduledMail.STATE_FAILED
m.save(update_fields=['state', 'last_successful_order_id'])
if settings.SENTRY_ENABLED:
from sentry_sdk import capture_exception
capture_exception(e)
@receiver(signal=event_copy_data, dispatch_uid="sendmail_copy_event")
def sendmail_copy_data_receiver(sender, other, item_map, **kwargs):
if sender.sendmail_rules.exists(): # idempotency
return
for r in other.sendmail_rules.prefetch_related('limit_products'):
limit_products = list(r.limit_products.all())
r = copy.copy(r)
r.pk = None
r.event = sender
r.save()
if limit_products:
r.limit_products.add(*[item_map[p.id] for p in limit_products if p.id in item_map])

View File

@@ -1,9 +1,15 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Send out emails" %}{% endblock %}
{% block title %}{% trans "Email history" %}{% endblock %}
{% block content %}
<h1>{% trans "Email history" %}</h1>
<p>
{% blocktrans trimmed %}
This page shows you all mass emails you sent out manually. It does not include emails sent out
automatically.
{% endblocktrans %}
</p>
<div>
<ul class="list-group">
{% for log in logs %}

View File

@@ -0,0 +1,39 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Create Email Rule" %}{% endblock %}
{% block content %}
<h1>{% trans "Create Email Rule" %}</h1>
{% block inner %}
<form class="form-horizontal" method="post" action="" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "Content" %}</legend>
{% bootstrap_field form.subject layout='control' %}
{% bootstrap_field form.template layout='control' %}
</fieldset>
<fieldset>
<legend>{% trans "Recipients" %}</legend>
{% bootstrap_field form.send_to layout='control' %}
{% bootstrap_field form.include_pending layout='control' %}
{% bootstrap_field form.all_products layout='control' %}
{% bootstrap_field form.limit_products layout='horizontal' %}
</fieldset>
<fieldset>
<legend>{% trans "Time" %}</legend>
{% bootstrap_field form.schedule_type layout='horizontal' %}
{% bootstrap_field form.send_date layout='horizontal' %}
{% bootstrap_field form.send_offset_days layout='horizontal' %}
{% bootstrap_field form.send_offset_time layout='horizontal' %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Delete Email Rule" %}{% endblock %}
{% block content %}
<h1>{% trans "Delete Email Rule" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans with subject=rule.subject %}Are you sure you want to delete the rule <strong>{{ subject }}</strong>?{% endblocktrans %}</p>
<div class="form-group submit-group">
<a class="btn btn-default btn-cancel" href="{% url "plugins:sendmail:rule.list" organizer=request.organizer.slug event=request.event.slug %}">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,96 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Automated email rules" %}{% endblock %}
{% block content %}
<h1>{% trans "Automated email rules" %}</h1>
<p>
{% blocktrans trimmed %}
Email rules allow you to automatically sent emails to your customers at a specific time before or after
your event.
{% endblocktrans %}
</p>
{% if rules %}
<p>
<a href="{% url "plugins:sendmail:rule.create" organizer=request.organizer.slug event=request.event.slug %}">
<button class="btn btn-default"><span class="fa fa-plus"></span> {% trans "Create a new rule" %}</button>
</a>
</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Email subject" %}</th>
<th>{% trans "Recipient" %}</th>
<th>{% trans "Scheduled time" %}</th>
<th>{% trans "Products" %}</th>
<th>{% trans "Sent / Total dates" context "subevent" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for r in rules %}
<tr>
<td>
<strong><a href="{% url "plugins:sendmail:rule.update" organizer=request.organizer.slug event=request.event.slug rule=r.pk %}">{{ r.subject }}</a></strong>
</td>
<td>
{{ r.get_send_to_display }}
</td>
<td>
{{ r.human_readable_time }}
{% if not r.date_is_absolute %}
<br>
{% trans "Next execution:" %}
{% if r.next_execution %}
{{ r.next_execution|date:"SHORT_DATETIME_FORMAT" }}
{% else %}
{% trans "unknown" %}
{% endif %}
{% if r.last_execution %}
<br>
{% trans "Last execution:" %}
{{ r.last_execution|date:"SHORT_DATETIME_FORMAT" }}
{% endif %}
{% endif %}
</td>
<td>
{% if r.all_products %}
<em>{% trans "All" %}</em>
{% else %}
<ul>
{% for item in r.limit_products.all %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</td>
<td>
{{ r.sent_mails }} / {{ r.total_mails }}
</td>
<td class="text-right flip">
<a class="btn btn-sm btn-default" href="{% url "plugins:sendmail:rule.update" organizer=request.organizer.slug event=request.event.slug rule=r.pk %}"><i class="fa fa-edit"></i></a>
<a class="btn btn-sm btn-danger" href="{% url "plugins:sendmail:rule.delete" organizer=request.organizer.slug event=request.event.slug rule=r.pk %}"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "pretixcontrol/pagination.html" %}
{% else %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any rules yet.
{% endblocktrans %}
</p>
<a href="{% url "plugins:sendmail:rule.create" organizer=request.organizer.slug event=request.event.slug %}">
<button class="btn btn-primary btn-lg">{% trans "Create a new rule" %}</button>
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,64 @@
{% extends 'pretixcontrol/event/base.html' %}
{% load i18n %}
{% load static %}
{% load bootstrap3 %}
{% block title %}{% trans "Update Email Rule" %}{% endblock %}
{% block content %}
<h1>{% trans "Update Email Rule" %}</h1>
{% block inner %}
<form class="form-horizontal" method="post" action="" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "Content" %}</legend>
{% bootstrap_field form.subject layout='control' %}
{% bootstrap_field form.template layout='control' %}
</fieldset>
<fieldset>
<legend>{% trans "Recipients" %}</legend>
{% bootstrap_field form.send_to layout='control' %}
{% bootstrap_field form.include_pending layout='control' %}
{% bootstrap_field form.all_products layout='control' %}
{% bootstrap_field form.limit_products layout='horizontal' %}
</fieldset>
<fieldset>
<legend>{% trans "Time" %}</legend>
{% bootstrap_field form.schedule_type layout='horizontal' %}
{% bootstrap_field form.send_date layout='horizontal' %}
{% bootstrap_field form.send_offset_days layout='horizontal' %}
{% bootstrap_field form.send_offset_time layout='horizontal' %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="alert alert-info">
{% blocktrans trimmed %}
For technical reasons, the email might actually be sent a bit later than your
configured date. Typically, this will not be more than 10 minutes. Your email
will never be sent earlier than the time you configured.
{% endblocktrans %}
</div>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "E-mail preview" %}</legend>
<div class="tab-pane mail-preview-group">
{% for locale, out in output.items %}
<div lang="{{ locale }}" class="mail-preview">
<strong>{{ out.subject|safe }}</strong><br><br>
{{ out.html|safe }}
</div>
{% endfor %}
</div>
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
{% endblock %}

View File

@@ -53,7 +53,7 @@
{% endif %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-default btn-save pull-left" name="action" value="preview">
{% trans "Preview email" %}
{% trans "Preview email" %}
</button>
<button type="submit" class="btn btn-primary btn-save">
{% trans "Send" %}

View File

@@ -39,5 +39,16 @@ from . import views
urlpatterns = [
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/sendmail/$', views.SenderView.as_view(),
name='send'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/sendmail/history/', views.EmailHistoryView.as_view(), name='history')
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/sendmail/history/', views.EmailHistoryView.as_view(),
name='history'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/sendmail/rules/create', views.CreateRule.as_view(),
name='rule.create'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/sendmail/rules/(?P<rule>[^/]+)/delete',
views.DeleteRule.as_view(),
name='rule.delete'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/sendmail/rules/(?P<rule>[^/]+)',
views.UpdateRule.as_view(),
name='rule.update'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/sendmail/rules', views.ListRules.as_view(),
name='rule.list'),
]

View File

@@ -38,12 +38,14 @@ import logging
import bleach
import dateutil
from django.contrib import messages
from django.db.models import Exists, OuterRef, Q
from django.http import Http404
from django.shortcuts import redirect
from django.db import transaction
from django.db.models import Count, Exists, Max, Min, OuterRef, Q
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView, ListView
from django.views.generic import DeleteView, FormView, ListView
from pretix.base.email import get_available_placeholders
from pretix.base.i18n import LazyI18nString, language
@@ -52,9 +54,11 @@ 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.control.views import CreateView, PaginationMixin, UpdateView
from pretix.plugins.sendmail.tasks import send_mails
from . import forms
from .models import Rule
logger = logging.getLogger('pretix.plugins.sendmail')
@@ -280,3 +284,165 @@ class EmailHistoryView(EventPermissionRequiredMixin, ListView):
pass
return ctx
class CreateRule(EventPermissionRequiredMixin, CreateView):
template_name = 'pretixplugins/sendmail/rule_create.html'
permission = 'can_change_event_settings'
form_class = forms.RuleForm
model = Rule
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
return kwargs
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
def form_valid(self, form):
self.output = {}
if self.request.POST.get("action") == "preview":
for l in self.request.event.settings.locales:
with language(l, self.request.event.settings.region):
context_dict = TolerantDict()
for k, v in get_available_placeholders(self.request.event, ['event', 'order',
'position_or_address']).items():
context_dict[k] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
v.render_sample(self.request.event)
)
subject = bleach.clean(form.cleaned_data['subject'].localize(l), tags=[])
preview_subject = subject.format_map(context_dict)
template = form.cleaned_data['template'].localize(l)
preview_text = markdown_compile_email(template.format_map(context_dict))
self.output[l] = {
'subject': _('Subject: {subject}').format(subject=preview_subject),
'html': preview_text,
}
return self.get(self.request, *self.args, **self.kwargs)
messages.success(self.request, _('Your rule has been created.'))
form.instance.event = self.request.event
self.object = form.save()
return redirect(
'plugins:sendmail:rule.update',
event=self.request.event.slug,
organizer=self.request.event.organizer.slug,
rule=self.object.pk,
)
class UpdateRule(EventPermissionRequiredMixin, UpdateView):
model = Rule
form_class = forms.RuleForm
template_name = 'pretixplugins/sendmail/rule_update.html'
permission = 'can_change_event_settings'
def get_object(self, queryset=None) -> Rule:
return get_object_or_404(Rule, event=self.request.event, id=self.kwargs['rule'])
def get_success_url(self):
return reverse('plugins:sendmail:rule.update', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
'rule': self.object.pk,
})
def form_valid(self, form):
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
o = {}
for lang in self.request.event.settings.locales:
with language(lang, self.request.event.settings.region):
placeholders = TolerantDict()
for k, v in get_available_placeholders(self.request.event, ['event', 'order', 'position_or_address']).items():
placeholders[k] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
v.render_sample(self.request.event)
)
subject = bleach.clean(self.object.subject.localize(lang), tags=[])
preview_subject = subject.format_map(placeholders)
template = self.object.template.localize(lang)
preview_text = markdown_compile_email(template.format_map(placeholders))
o[lang] = {
'subject': _('Subject: {subject}'.format(subject=preview_subject)),
'html': preview_text,
}
ctx['output'] = o
return ctx
class ListRules(EventPermissionRequiredMixin, PaginationMixin, ListView):
template_name = 'pretixplugins/sendmail/rule_list.html'
model = Rule
context_object_name = 'rules'
def get_queryset(self):
return self.request.event.sendmail_rules.annotate(
total_mails=Count('scheduledmail'),
sent_mails=Count('scheduledmail', filter=Q(scheduledmail__sent=True)),
last_execution=Max(
'scheduledmail__computed_datetime',
filter=Q(scheduledmail__sent=True)
),
next_execution=Min(
'scheduledmail__computed_datetime',
filter=Q(scheduledmail__sent=False)
),
).prefetch_related(
'limit_products'
)
class DeleteRule(EventPermissionRequiredMixin, DeleteView):
model = Rule
permission = 'can_change_event_settings'
template_name = 'pretixplugins/sendmail/rule_delete.html'
context_object_name = 'rule'
def get_success_url(self):
return reverse("plugins:sendmail:rule.list", kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
def get_object(self, queryset=None) -> Rule:
return get_object_or_404(Rule, event=self.request.event, id=self.kwargs['rule'])
@transaction.atomic
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
success_url = self.get_success_url()
self.request.event.log_action('pretix.plugins.sendmail.rule.deleted',
user=self.request.user,
data={
'subject': self.object.subject,
'text': self.object.template,
})
self.object.delete()
messages.success(self.request, _('The selected rule has been deleted.'))
return HttpResponseRedirect(success_url)