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

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