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 %} +

    {% trans "Create Email Rule" %}

    + {% block inner %} +
    + {% csrf_token %} + {% bootstrap_form_errors form %} + +
    + {% trans "Content" %} + {% bootstrap_field form.subject layout='control' %} + {% bootstrap_field form.template layout='control' %} +
    +
    + {% trans "Recipients" %} + {% 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' %} +
    +
    + {% trans "Time" %} + {% 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' %} +
    + +
    + +
    +
    + {% endblock %} +{% endblock %} diff --git a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_delete.html b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_delete.html new file mode 100644 index 0000000000..100743f4a4 --- /dev/null +++ b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_delete.html @@ -0,0 +1,19 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load static %} +{% block title %}{% trans "Delete Email Rule" %}{% endblock %} +{% block content %} +

    {% trans "Delete Email Rule" %}

    +
    + {% csrf_token %} +

    {% blocktrans with subject=rule.subject %}Are you sure you want to delete the rule {{ subject }}?{% endblocktrans %}

    +
    + + {% trans "Cancel" %} + + +
    +
    +{% 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 %} +

    + + {% if rules %} +

    + + + +

    +
    + + + + + + + + + + + + + {% for r in rules %} + + + + + + + + + {% endfor %} + +
    {% trans "Email subject" %}{% trans "Recipient" %}{% trans "Scheduled time" %}{% trans "Products" %}{% trans "Sent / Total dates" context "subevent" %}
    + {{ r.subject }} + + {{ r.get_send_to_display }} + + {{ r.human_readable_time }} + {% if not r.date_is_absolute %} +
    + {% trans "Next execution:" %} + {% if r.next_execution %} + {{ r.next_execution|date:"SHORT_DATETIME_FORMAT" }} + {% else %} + {% trans "unknown" %} + {% endif %} + {% if r.last_execution %} +
    + {% trans "Last execution:" %} + {{ r.last_execution|date:"SHORT_DATETIME_FORMAT" }} + {% endif %} + {% endif %} +
    + {% if r.all_products %} + {% trans "All" %} + {% else %} +
      + {% for item in r.limit_products.all %} +
    • + {{ item }} +
    • + {% endfor %} +
    + {% endif %} +
    + {{ r.sent_mails }} / {{ r.total_mails }} + + + +
    +
    + {% include "pretixcontrol/pagination.html" %} + {% else %} +
    +

    + {% blocktrans trimmed %} + You haven't created any rules yet. + {% endblocktrans %} +

    + + + +
    + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html new file mode 100644 index 0000000000..114446b21b --- /dev/null +++ b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html @@ -0,0 +1,64 @@ +{% extends 'pretixcontrol/event/base.html' %} +{% load i18n %} +{% load static %} +{% load bootstrap3 %} +{% block title %}{% trans "Update Email Rule" %}{% endblock %} +{% block content %} +

    {% trans "Update Email Rule" %}

    + {% block inner %} +
    + {% csrf_token %} + {% bootstrap_form_errors form %} + +
    + {% trans "Content" %} + {% bootstrap_field form.subject layout='control' %} + {% bootstrap_field form.template layout='control' %} +
    +
    + {% trans "Recipients" %} + {% 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' %} +
    +
    + {% trans "Time" %} + {% 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' %} + +
    +
    +
    + {% 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 %} +
    +
    +
    +
    + +
    + {% trans "E-mail preview" %} +
    + {% for locale, out in output.items %} +
    + {{ out.subject|safe }}

    + {{ out.html|safe }} +
    + {% endfor %} +
    +
    + +
    + +
    +
    + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html index 9aeae04b8f..2cc6383e92 100644 --- a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html +++ b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html @@ -53,7 +53,7 @@ {% endif %}