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

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

View File

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

View File

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

View File

@@ -44,18 +44,18 @@
<tr>
<th>{% trans "Quota name" %}
<a href="?{% url_replace request 'ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a></th>
<a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Products" %}</th>
{% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}
<a href="?{% url_replace request 'ordering' '-date' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'date' %}"><i class="fa fa-caret-up"></i></a></th>
<a href="?{% url_replace request 'ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% endif %}
<th>{% trans "Total capacity" %}
<a href="?{% url_replace request 'ordering' '-size' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'size' %}"><i class="fa fa-caret-up"></i></a></th>
<a href="?{% url_replace request 'ordering' 'size' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Capacity left" %}</th>
<th class="action-col-2"></th>

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)

View File

@@ -318,7 +318,7 @@ var form_handlers = function (el) {
var dependent = $(this),
dependency = $($(this).attr("data-display-dependency")),
update = function (ev) {
var enabled = (dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val();
var enabled = dependency.toArray().some(function(d) {return (d.type === 'checkbox' || d.type === 'radio') ? d.checked : !!d.value;});
if (dependent.is("[data-inverse]")) {
enabled = !enabled;
}

View File

@@ -0,0 +1,21 @@
#
# 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/>.
#

View File

@@ -0,0 +1,90 @@
#
# 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/>.
#
import datetime
import pytest
from django.utils.timezone import now
from pretix.base.models import Event, Item, Order, OrderPosition, Organizer
@pytest.fixture
def event():
"""Returns an event instance"""
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(),
plugins='pretix.plugins.sendmail,tests.testdummy',
)
return event
@pytest.fixture
def item(event):
"""Returns an item instance"""
return Item.objects.create(name='Test item', event=event, default_price=13)
@pytest.fixture
def item2(event):
return Item.objects.create(name='Test item 2', event=event, default_price=11)
@pytest.fixture
def checkin_list(event):
"""Returns an checkin list instance"""
return event.checkin_lists.create(name="Test Checkinlist", all_products=True)
@pytest.fixture
def order(item):
"""Returns an order instance"""
o = Order.objects.create(event=item.event, status=Order.STATUS_PENDING,
expires=now() + datetime.timedelta(hours=1),
total=13, code='DUMMY', email='dummy@dummy.test',
datetime=now(), locale='en')
return o
@pytest.fixture
def pos(order, item):
return OrderPosition.objects.create(order=order, item=item, price=13)
@pytest.fixture
def event_series(event):
event.has_subevents = True
event.save()
return event
@pytest.fixture
def subevent1(event_series):
se1 = event_series.subevents.create(name='Meow', date_from=now() + datetime.timedelta(days=1))
return se1
@pytest.fixture
def subevent2(event_series):
se2 = event_series.subevents.create(name='Foo', date_from=now() + datetime.timedelta(days=3))
return se2

View File

@@ -0,0 +1,352 @@
#
# 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/>.
#
import datetime
import pytest
import pytz
from django.core import mail as djmail
from django.utils.timezone import now, utc
from django_scopes import scopes_disabled
from pretix.base.models import Order
from pretix.plugins.sendmail.models import Rule, ScheduledMail
from pretix.plugins.sendmail.signals import sendmail_run_rules
@pytest.mark.django_db
def test_sendmail_rule_create_single(event):
dt = now()
r = Rule.objects.create(event=event, subject='dummy mail', template='mail body', send_date=dt)
mails = ScheduledMail.objects.filter(rule=r)
assert mails.count() == 1
mail = mails.get()
assert mail.computed_datetime == dt
dt_now = now()
NZ = pytz.timezone('NZ')
Berlin = pytz.timezone('Europe/Berlin')
@pytest.mark.django_db
@pytest.mark.parametrize(
"event_from,event_to,event_tz,rule,expected",
[
# Tests for all possible configurations of relative times
( # "Absolute"
None,
None,
'UTC',
Rule(date_is_absolute=True, send_date=dt_now),
dt_now
),
( # "Relative, after event start"
None,
None,
'UTC',
Rule(date_is_absolute=False, offset_is_after=True, send_offset_days=1, send_offset_time=datetime.time(hour=9)),
(dt_now + datetime.timedelta(days=1)).replace(hour=9, minute=0, second=0, microsecond=0)
),
( # "Relative, before event start"
datetime.datetime(2021, 5, 17, 12, 14, 0, tzinfo=utc),
None,
'UTC',
Rule(date_is_absolute=False, send_offset_days=2, send_offset_time=datetime.time(hour=0)),
datetime.datetime(2021, 5, 15, 0, tzinfo=utc)
),
( # "Relative, after event end"
datetime.datetime(2021, 5, 17, 18, tzinfo=utc),
datetime.datetime(2021, 5, 18, 5, tzinfo=utc),
'UTC',
Rule(date_is_absolute=False, offset_to_event_end=True, offset_is_after=True, send_offset_days=1, send_offset_time=datetime.time(hour=10)),
datetime.datetime(2021, 5, 19, 10, tzinfo=utc)
),
( # "Relative, before event end"
datetime.datetime(2021, 5, 17, 18, tzinfo=utc),
datetime.datetime(2021, 5, 22, 5, tzinfo=utc),
'UTC',
Rule(date_is_absolute=False, offset_to_event_end=True, offset_is_after=False, send_offset_days=1, send_offset_time=datetime.time(hour=10)),
datetime.datetime(2021, 5, 21, 10, tzinfo=utc)
),
# Tests for timezone quirks
( # Test sending on leap day
datetime.datetime(2020, 2, 27, 9, tzinfo=utc),
None,
'UTC',
Rule(date_is_absolute=False, offset_is_after=True, send_offset_days=2, send_offset_time=datetime.time(hour=9)),
datetime.datetime(2020, 2, 29, 9, tzinfo=utc)
),
( # Test timezone far off from UTC
NZ.localize(datetime.datetime(2021, 5, 17, 22)),
None,
'NZ',
Rule(date_is_absolute=False, offset_is_after=True, send_offset_days=1, send_offset_time=datetime.time(hour=9)),
NZ.localize(datetime.datetime(2021, 5, 18, 9))
),
( # Test across DST change
Berlin.localize(datetime.datetime(2021, 10, 29, 16, 30)),
None,
'Europe/Berlin',
Rule(date_is_absolute=False, offset_is_after=True, send_offset_days=4, send_offset_time=datetime.time(hour=2, minute=30)),
Berlin.localize(datetime.datetime(2021, 11, 2, 2, 30))
),
( # Test ambiguous time at DST change
Berlin.localize(datetime.datetime(2021, 10, 29, 18, 30)),
None,
'Europe/Berlin',
Rule(date_is_absolute=False, offset_is_after=True, send_offset_days=2, send_offset_time=datetime.time(hour=2, minute=30)),
datetime.datetime(2021, 10, 31, 1, 30, tzinfo=utc)
),
( # Test non-existing time at DST change
Berlin.localize(datetime.datetime(2021, 3, 29, 14, 30)),
None,
'Europe/Berlin',
Rule(date_is_absolute=False, offset_is_after=False, send_offset_days=1, send_offset_time=datetime.time(hour=2, minute=30)),
datetime.datetime(2021, 3, 28, 1, 30, tzinfo=utc)
),
])
def test_sendmail_rule_send_time(event_from, event_to, event_tz, rule, expected, event):
if event_from:
event.date_from = event_from
event.save()
if event_to:
event.date_to = event_to
event.save()
event.settings.timezone = event_tz
rule.event = event
rule.save()
m = ScheduledMail.objects.filter(rule=rule).get()
assert m.computed_datetime.astimezone(event.timezone) == expected
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_recompute(event):
event.has_subevents = True
event.save()
se1 = event.subevents.create(name="meow", date_from=dt_now)
rule = event.sendmail_rules.create(date_is_absolute=False, offset_is_after=False, send_offset_days=1,
send_offset_time=datetime.time(4, 30))
se1.date_from += datetime.timedelta(days=1)
se1.save()
expected = dt_now.replace(hour=4, minute=30, second=0, microsecond=0)
sendmail_run_rules(None)
m = ScheduledMail.objects.filter(rule=rule).first()
assert m.computed_datetime.astimezone(utc) == expected
@pytest.mark.django_db
@pytest.mark.parametrize('send_to,amount_mails,recipients', [
(Rule.CUSTOMERS, 1, ['dummy@dummy.test']),
(Rule.ATTENDEES, 1, ['meow@dummy.test']),
(Rule.BOTH, 2, ['dummy@dummy.test', 'meow@dummy.test']),
])
@scopes_disabled()
def test_sendmail_rule_send_order_vs_pos(send_to, amount_mails, recipients, order, event, item):
djmail.outbox = []
order.status = order.STATUS_PAID
order.save()
order.event.sendmail_rules.create(date_is_absolute=True, send_date=dt_now - datetime.timedelta(hours=1),
send_to=send_to,
subject='meow', template='meow meow meow')
order.all_positions.create(item=item, price=0, attendee_email='meow@dummy.test')
sendmail_run_rules(None)
assert len(djmail.outbox) == amount_mails
_recipients = [mail.to[0] for mail in djmail.outbox]
assert set(recipients) == set(_recipients)
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_send_attendees_unset_mail(order, event, item):
djmail.outbox = []
order.status = order.STATUS_PAID
order.save()
order.all_positions.create(item=item, price=13)
order.event.sendmail_rules.create(date_is_absolute=True, send_date=dt_now - datetime.timedelta(hours=1),
send_to=Rule.ATTENDEES,
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
mail = djmail.outbox[0]
assert mail.to[0] == 'dummy@dummy.test'
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_send_both_same_email(order, event, item):
djmail.outbox = []
order.status = order.STATUS_PAID
order.save()
order.all_positions.create(item=item, price=13, attendee_email='dummy@dummy.test')
order.event.sendmail_rules.create(date_is_absolute=True, send_date=dt_now - datetime.timedelta(hours=1),
send_to=Rule.BOTH,
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_send_correct_subevent(order, event_series, subevent1, subevent2, item):
djmail.outbox = []
order.status = order.STATUS_PAID
order.save()
event_series.sendmail_rules.create(date_is_absolute=False, offset_is_after=False, send_offset_days=2,
send_offset_time=datetime.time(9, 30), send_to=Rule.ATTENDEES,
subject='meow', template='meow meow meow')
p1 = order.all_positions.create(item=item, price=13, attendee_email='se1@dummy.test', subevent=subevent1)
order.all_positions.create(item=item, price=23, attendee_email='se2@dummy.test', subevent=subevent2)
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
assert djmail.outbox[0].to[0] == p1.attendee_email
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_send_correct_products(event, order, item, item2):
djmail.outbox = []
order.status = order.STATUS_PAID
order.save()
rule = event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), send_to=Rule.ATTENDEES,
subject='meow', template='meow meow meow', all_products=False)
rule.limit_products.set([item])
rule.save()
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
order.all_positions.create(item=item2, price=13, attendee_email='item2@dummy.test')
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
assert djmail.outbox[0].to[0] == p1.attendee_email
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_send_order_pending(event, order):
djmail.outbox = []
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), include_pending=True,
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_send_order_pending_excluded(event, order):
djmail.outbox = []
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), include_pending=False,
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 0
@pytest.mark.django_db
@pytest.mark.parametrize('status', [
Order.STATUS_EXPIRED,
Order.STATUS_CANCELED,
])
@scopes_disabled()
def test_sendmail_rule_send_order_status(status, event, order):
djmail.outbox = []
order.status = status
order.save()
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), include_pending=True,
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 0
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_send_order_approval(event, order):
djmail.outbox = []
order.require_approval = True
order.save()
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), include_pending=True,
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 0
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_only_send_once(event, order):
djmail.outbox = []
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), include_pending=True,
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
sendmail_run_rules(None)
assert len(djmail.outbox) == 1

View File

@@ -40,48 +40,7 @@ from django.core import mail as djmail
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import (
Checkin, Event, Item, Order, OrderPosition, Organizer, Team, User,
)
@pytest.fixture
def event():
"""Returns an event instance"""
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(),
plugins='pretix.plugins.sendmail,tests.testdummy',
)
return event
@pytest.fixture
def item(event):
"""Returns an item instance"""
return Item.objects.create(name='Test item', event=event, default_price=13)
@pytest.fixture
def checkin_list(event):
"""Returns an checkin list instance"""
return event.checkin_lists.create(name="Test Checkinlist", all_products=True)
@pytest.fixture
def order(item):
"""Returns an order instance"""
o = Order.objects.create(event=item.event, status=Order.STATUS_PENDING,
expires=now() + datetime.timedelta(hours=1),
total=13, code='DUMMY', email='dummy@dummy.test',
datetime=now(), locale='en')
return o
@pytest.fixture
def pos(order, item):
return OrderPosition.objects.create(order=order, item=item, price=13)
from pretix.base.models import Checkin, Item, Order, OrderPosition, Team, User
@pytest.fixture
@@ -104,6 +63,15 @@ def sendmail_url(event):
return url
@pytest.fixture
def subevent(event):
event.has_subevents = True
event.save()
se = event.subevents.create(name='se1', date_from=now())
return se
@pytest.mark.django_db
def test_sendmail_view(logged_in_client, sendmail_url, expected=200):
response = logged_in_client.get(sendmail_url)