Files
pretix_cgo/src/pretix/plugins/sendmail/signals.py
2022-08-04 17:01:04 +02:00

222 lines
11 KiB
Python

#
# 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/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: FlaviaBastos
#
# 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.timezone import now
from django.utils.translation import gettext_lazy as _
from django_scopes import scope, scopes_disabled
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 and subevent:
sm = ScheduledMail(rule=rule, event=event, subevent=subevent)
sm.recompute()
to_create.append(sm)
ScheduledMail.objects.bulk_create(to_create)
@receiver(nav_event, dispatch_uid="sendmail_nav")
def control_nav_import(sender, request=None, **kwargs):
url = resolve(request.path_info)
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request):
return []
return [
{
'label': _('Send out emails'),
'url': reverse('plugins:sendmail:send', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'icon': 'envelope',
'children': [
{
'label': _('Send email'),
'url': reverse('plugins:sendmail:send', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'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={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': (url.namespace == 'plugins:sendmail' and url.url_name == 'history'),
},
]
},
]
@receiver(signal=logentry_display)
def pretixcontrol_logentry_display(sender, logentry, **kwargs):
plains = {
'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.added': _('An email rule was created'),
'pretix.plugins.sendmail.rule.changed': _('An email rule was updated'),
'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()
unchanged = []
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'])
else:
unchanged.append(m.pk)
if unchanged:
# Theoretically, we don't need to write back the unchanged ones to the database… but that will cause us to
# recompute them on every run until eternity. So we want to set their last_computed date to something more
# recent... but not for all of them at once, in case it's millions, so we don't stress the database without
# cause
batch_size = max(connection.ops.bulk_batch_size(['id'], unchanged) - 2, 100)
for i in range(max(1, 5000 // batch_size)):
ScheduledMail.objects.filter(pk__in=unchanged[i * batch_size:batch_size]).update(last_computed=now())
mails.filter(
state=ScheduledMail.STATE_SCHEDULED,
computed_datetime__lte=timezone.now() - datetime.timedelta(days=2),
).update(
state=ScheduledMail.STATE_MISSED
)
for m_id in mails.filter(
state__in=(ScheduledMail.STATE_SCHEDULED, ScheduledMail.STATE_FAILED),
rule__enabled=True,
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 like 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])