diff --git a/src/pretix/base/migrations/0191_event_last_modified.py b/src/pretix/base/migrations/0191_event_last_modified.py
new file mode 100644
index 0000000000..d529f5267a
--- /dev/null
+++ b/src/pretix/base/migrations/0191_event_last_modified.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.2 on 2021-05-24 12:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pretixbase', '0190_quota_ignore_for_event_availability'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='event',
+ name='last_modified',
+ field=models.DateTimeField(auto_now=True, db_index=True),
+ ),
+ ]
diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py
index c75ea206a2..b02c343af8 100644
--- a/src/pretix/base/models/event.py
+++ b/src/pretix/base/models/event.py
@@ -75,7 +75,6 @@ from .organizer import Organizer, Team
class EventMixin:
-
def clean(self):
if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
@@ -494,11 +493,17 @@ class Event(EventMixin, LoggedModel):
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='events')
+
+ last_modified = models.DateTimeField(
+ auto_now=True, db_index=True
+ )
+
sales_channels = MultiStringField(
verbose_name=_('Restrict to specific sales channels'),
help_text=_('Only sell tickets for this event on the following sales channels.'),
default=default_sales_channels,
)
+
objects = ScopedManager(organizer='organizer')
class Meta:
@@ -1255,13 +1260,14 @@ class SubEvent(EventMixin, LoggedModel):
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='subevents')
- last_modified = models.DateTimeField(
- auto_now=True, db_index=True
- )
items = models.ManyToManyField('Item', through='SubEventItem')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
+ last_modified = models.DateTimeField(
+ auto_now=True, db_index=True
+ )
+
objects = ScopedManager(organizer='event__organizer')
class Meta:
diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py
index 5827368105..f2361f4804 100644
--- a/src/pretix/base/models/orders.py
+++ b/src/pretix/base/models/orders.py
@@ -899,7 +899,7 @@ class Order(LockModel, LoggedModel):
return str(e)
return True
- def send_mail(self, subject: str, template: Union[str, LazyI18nString],
+ def send_mail(self, subject: Union[str, LazyI18nString], template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
@@ -942,7 +942,7 @@ class Order(LockModel, LoggedModel):
try:
email_content = render_mail(template, context)
- subject = subject.format_map(TolerantDict(context))
+ subject = str(subject).format_map(TolerantDict(context))
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
diff --git a/src/pretix/control/templates/pretixcontrol/items/quotas.html b/src/pretix/control/templates/pretixcontrol/items/quotas.html
index 8e643f2b5d..f77996662b 100644
--- a/src/pretix/control/templates/pretixcontrol/items/quotas.html
+++ b/src/pretix/control/templates/pretixcontrol/items/quotas.html
@@ -44,18 +44,18 @@
{% trans "Quota name" %}
-
+
{% trans "Products" %}
{% if request.event.has_subevents %}
{% trans "Date" context "subevent" %}
-
+
{% endif %}
{% trans "Total capacity" %}
-
+
{% trans "Capacity left" %}
diff --git a/src/pretix/plugins/sendmail/forms.py b/src/pretix/plugins/sendmail/forms.py
index b79ef9661b..7390c02923 100644
--- a/src/pretix/plugins/sendmail/forms.py
+++ b/src/pretix/plugins/sendmail/forms.py
@@ -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
diff --git a/src/pretix/plugins/sendmail/migrations/0001_initial.py b/src/pretix/plugins/sendmail/migrations/0001_initial.py
new file mode 100644
index 0000000000..2ac1431380
--- /dev/null
+++ b/src/pretix/plugins/sendmail/migrations/0001_initial.py
@@ -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')},
+ },
+ ),
+ ]
diff --git a/src/pretix/plugins/sendmail/migrations/__init__.py b/src/pretix/plugins/sendmail/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/pretix/plugins/sendmail/models.py b/src/pretix/plugins/sendmail/models.py
new file mode 100644
index 0000000000..f5c22d43d5
--- /dev/null
+++ b/src/pretix/plugins/sendmail/models.py
@@ -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 .
+#
+# 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
+# .
+#
+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
diff --git a/src/pretix/plugins/sendmail/signals.py b/src/pretix/plugins/sendmail/signals.py
index c925991733..23452d6d76 100644
--- a/src/pretix/plugins/sendmail/signals.py
+++ b/src/pretix/plugins/sendmail/signals.py
@@ -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])
diff --git a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/history.html b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/history.html
index a958c200d1..57480a9f23 100644
--- a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/history.html
+++ b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/history.html
@@ -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 %}
{% trans "Email history" %}
+
+ {% blocktrans trimmed %}
+ This page shows you all mass emails you sent out manually. It does not include emails sent out
+ automatically.
+ {% endblocktrans %}
+
{% for log in logs %}
diff --git a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_create.html b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_create.html
new file mode 100644
index 0000000000..ab562182c3
--- /dev/null
+++ b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_create.html
@@ -0,0 +1,39 @@
+{% extends "pretixcontrol/event/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block title %}{% trans "Create Email Rule" %}{% endblock %}
+{% block content %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_list.html b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_list.html
new file mode 100644
index 0000000000..06a0cf55a9
--- /dev/null
+++ b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_list.html
@@ -0,0 +1,96 @@
+{% extends "pretixcontrol/event/base.html" %}
+{% load i18n %}
+{% load static %}
+{% block title %}{% trans "Automated email rules" %}{% endblock %}
+{% block content %}
+
{% trans "Automated email rules" %}
+
+ {% blocktrans trimmed %}
+ Email rules allow you to automatically sent emails to your customers at a specific time before or after
+ your event.
+ {% endblocktrans %}
+