Sendmail: Abstract away to allow more types of recipients (#2994)

Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2023-01-10 12:03:50 +01:00
committed by GitHub
parent d07948613a
commit 9b1d7cc522
17 changed files with 780 additions and 284 deletions

View File

@@ -38,15 +38,17 @@ import logging
import bleach
import dateutil
from django.contrib import messages
from django.contrib.humanize.templatetags.humanize import intcomma
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.template.loader import get_template
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.views.generic import DeleteView, FormView, ListView
from django.utils.translation import gettext_lazy as _, ngettext
from django.views.generic import DeleteView, FormView, ListView, TemplateView
from pretix.base.email import get_available_placeholders
from pretix.base.i18n import LazyI18nString, language
@@ -55,7 +57,9 @@ from pretix.base.models.event import SubEvent
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 pretix.plugins.sendmail.tasks import (
send_mails_to_orders, send_mails_to_waitinglist,
)
from ...helpers.format import format_map
from . import forms
@@ -64,59 +68,104 @@ from .models import Rule, ScheduledMail
logger = logging.getLogger('pretix.plugins.sendmail')
class SenderView(EventPermissionRequiredMixin, FormView):
class IndexView(EventPermissionRequiredMixin, TemplateView):
template_name = 'pretixplugins/sendmail/index.html'
permission = 'can_change_orders'
def get_context_data(self, **kwargs):
from .signals import sendmail_view_classes
classes = []
for recv, resp in sendmail_view_classes.send(self.request.event):
if isinstance(resp, (list, tuple)):
classes += resp
else:
classes.append(resp)
return super().get_context_data(**kwargs, views=[
{
'title': cls.TITLE,
'description': cls.DESCRIPTION,
'url': cls.get_url(self.request.event)
} for cls in classes
])
class BaseSenderView(EventPermissionRequiredMixin, FormView):
# These parameters usually SHOULD NOT be overridden
template_name = 'pretixplugins/sendmail/send_form.html'
permission = 'can_change_orders'
form_class = forms.MailForm
# These parameters MUST be overridden by subclasses
form_fragment_name = None
context_parameters = ['event']
task = None
# These parameters MUST be overriden by subclasses in a way that allows static access
ACTION_TYPE = None
TITLE = ""
DESCRIPTION = ""
# The following methods MUST be overridden by subclasses
@staticmethod
def get_url(self, event):
"""Returns the URL for this view for a given event."""
raise NotImplementedError
def get_object_queryset(self, form):
"""Returns a queryset of objects that will become recipients."""
return Order.objects.none()
def describe_match_size(self, cnt):
"""Returns a short human-readable description of the recipient set, such as '3 attendees'."""
raise NotImplementedError
@classmethod
def show_history_meta_data(cls, logentry, _cache_store):
"""Returns an HTML component for the history view."""
raise NotImplementedError
# The following methods MAY be overridden by subclasses
def initial_from_logentry(self, logentry):
return {
'message': LazyI18nString(logentry.parsed_data['message']),
'subject': LazyI18nString(logentry.parsed_data['subject']),
}
def get_success_url(self):
return self.request.get_full_path()
def get_task_kwargs(self, form, objects):
kwargs = {
'event': self.request.event.pk,
'user': self.request.user.pk,
'subject': form.cleaned_data['subject'].data,
'message': form.cleaned_data['message'].data,
'objects': [o.pk for o in objects],
}
attachment = form.cleaned_data.get('attachment')
if attachment is not None and attachment is not False:
kwargs['attachments'] = [form.cleaned_data['attachment'].id]
return kwargs
# The following methods SHOULD NOT Be overridden by subclasses, but in some cases it may be necessary
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs['context_parameters'] = self.context_parameters
if 'from_log' in self.request.GET:
try:
from_log_id = self.request.GET.get('from_log')
logentry = LogEntry.objects.get(
id=from_log_id,
event=self.request.event,
action_type='pretix.plugins.sendmail.sent'
action_type=self.ACTION_TYPE
)
kwargs['initial'] = {
'recipients': logentry.parsed_data.get('recipients', 'orders'),
'message': LazyI18nString(logentry.parsed_data['message']),
'subject': LazyI18nString(logentry.parsed_data['subject']),
'sendto': logentry.parsed_data['sendto'],
**self.initial_from_logentry(logentry),
}
if 'items' in logentry.parsed_data:
kwargs['initial']['items'] = self.request.event.items.filter(
id__in=[a['id'] for a in logentry.parsed_data['items']]
)
elif logentry.parsed_data.get('item'):
kwargs['initial']['items'] = self.request.event.items.filter(
id=logentry.parsed_data['item']['id']
)
if 'checkin_lists' in logentry.parsed_data:
kwargs['initial']['checkin_lists'] = self.request.event.checkin_lists.filter(
id__in=[c['id'] for c in logentry.parsed_data['checkin_lists']]
)
kwargs['initial']['filter_checkins'] = logentry.parsed_data.get('filter_checkins', False)
kwargs['initial']['not_checked_in'] = logentry.parsed_data.get('not_checked_in', False)
if logentry.parsed_data.get('subevents_from'):
kwargs['initial']['subevents_from'] = dateutil.parser.parse(logentry.parsed_data['subevents_from'])
if logentry.parsed_data.get('subevents_to'):
kwargs['initial']['subevents_to'] = dateutil.parser.parse(logentry.parsed_data['subevents_to'])
if logentry.parsed_data.get('created_from'):
kwargs['initial']['created_from'] = dateutil.parser.parse(logentry.parsed_data['created_from'])
if logentry.parsed_data.get('created_to'):
kwargs['initial']['created_to'] = dateutil.parser.parse(logentry.parsed_data['created_to'])
if logentry.parsed_data.get('attach_tickets'):
kwargs['initial']['attach_tickets'] = logentry.parsed_data['attach_tickets']
if logentry.parsed_data.get('subevent'):
try:
kwargs['initial']['subevent'] = self.request.event.subevents.get(
pk=logentry.parsed_data['subevent']['id']
)
except SubEvent.DoesNotExist:
pass
except LogEntry.DoesNotExist:
raise Http404(_('You supplied an invalid log entry ID'))
return kwargs
@@ -126,6 +175,170 @@ class SenderView(EventPermissionRequiredMixin, FormView):
return super().form_invalid(form)
def form_valid(self, form):
objects = self.get_object_queryset(form)
ocnt = objects.count()
self.output = {}
if not ocnt:
messages.error(self.request, _('There are no matching recipients for your selection.'))
self.request.POST = self.request.POST.copy()
self.request.POST.pop("action", "")
return self.get(self.request, *self.args, **self.kwargs)
if self.request.POST.get("action") != "send":
for l in self.request.event.settings.locales:
with language(l, self.request.event.settings.region):
context_dict = {}
for k, v in get_available_placeholders(self.request.event, self.context_parameters).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 = format_map(subject, context_dict)
message = form.cleaned_data['message'].localize(l)
preview_text = markdown_compile_email(format_map(message, context_dict))
self.output[l] = {
'subject': _('Subject: {subject}').format(subject=preview_subject),
'html': preview_text,
'attachment': form.cleaned_data.get('attachment')
}
self.object_count = ocnt
return self.get(self.request, *self.args, **self.kwargs)
self.task.apply_async(
kwargs=self.get_task_kwargs(form, objects)
)
self.request.event.log_action(
self.ACTION_TYPE,
user=self.request.user,
data=dict(form.cleaned_data)
)
messages.success(self.request, _('Your message has been queued and will be sent to the contact addresses of %s '
'in the next few minutes.') % self.describe_match_size(len(objects)))
return redirect(self.get_success_url())
def get_context_data(self, *args, **kwargs):
ctx = super().get_context_data(*args, **kwargs)
ctx['output'] = getattr(self, 'output', None)
ctx['match_size'] = self.describe_match_size(getattr(self, 'object_count', None))
ctx['form_fragment_name'] = self.form_fragment_name
ctx['is_preview'] = self.request.method == 'POST' and self.request.POST.get('action') == 'preview' and ctx['form'].is_valid()
ctx['view_title'] = self.TITLE
return ctx
def get_form(self, form_class=None):
f = super().get_form(form_class)
if self.request.method == 'POST' and self.request.POST.get('action') == 'preview':
if f.is_valid():
for fname, field in f.fields.items():
field.widget.attrs['disabled'] = 'disabled'
return f
class OrderSendView(BaseSenderView):
form_class = forms.OrderMailForm
form_fragment_name = "pretixplugins/sendmail/send_form_fragment_orders.html"
context_parameters = ['event', 'order', 'position_or_address']
task = send_mails_to_orders
ACTION_TYPE = 'pretix.plugins.sendmail.sent'
TITLE = _("Orders or attendees")
DESCRIPTION = _("Send an email to every customer, or to every person a ticket has been "
"purchased for, or a combination of both.")
@classmethod
def show_history_meta_data(cls, logentry, _cache_store):
if 'itemcache' not in _cache_store:
_cache_store['itemcache'] = {
i.pk: str(i) for i in logentry.event.items.all()
}
if 'checkin_list_cache' not in _cache_store:
_cache_store['checkin_list_cache'] = {
i.pk: str(i) for i in logentry.event.checkin_lists.all()
}
if 'status' not in _cache_store:
status = dict(Order.STATUS_CHOICE)
status['overdue'] = _('pending with payment overdue')
status['na'] = _('payment pending (except unapproved)')
status['pa'] = _('approval pending')
status['r'] = status['c']
_cache_store['status'] = status
tpl = get_template('pretixplugins/sendmail/history_fragment_orders.html')
logentry.pdata['sendto'] = [
_cache_store['status'][s] for s in logentry.pdata['sendto']
]
logentry.pdata['items'] = [
_cache_store['itemcache'].get(i['id'], '?') for i in logentry.pdata.get('items', [])
]
logentry.pdata['checkin_lists'] = [
_cache_store['checkin_list_cache'].get(i['id'], '?')
for i in logentry.pdata.get('checkin_lists', []) if i['id'] in _cache_store['checkin_list_cache']
]
if logentry.pdata.get('subevent'):
try:
logentry.pdata['subevent_obj'] = logentry.event.subevents.get(pk=logentry.pdata['subevent']['id'])
except SubEvent.DoesNotExist:
pass
return tpl.render({
'log': logentry,
})
@classmethod
def get_url(cls, event):
return reverse(
'plugins:sendmail:send.orders',
kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}
)
def initial_from_logentry(self, logentry: LogEntry):
initial = super().initial_from_logentry(logentry)
if 'recipients' in logentry.parsed_data:
initial['recipients'] = logentry.parsed_data.get('recipients', 'orders')
if 'sendto' in logentry.parsed_data:
initial['sendto'] = logentry.parsed_data.get('recipients', 'sendto')
if 'items' in logentry.parsed_data:
initial['items'] = self.request.event.items.filter(
id__in=[a['id'] for a in logentry.parsed_data['items']]
)
elif logentry.parsed_data.get('item'):
initial['items'] = self.request.event.items.filter(
id=logentry.parsed_data['item']['id']
)
if 'checkin_lists' in logentry.parsed_data:
initial['checkin_lists'] = self.request.event.checkin_lists.filter(
id__in=[c['id'] for c in logentry.parsed_data['checkin_lists']]
)
initial['filter_checkins'] = logentry.parsed_data.get('filter_checkins', False)
initial['not_checked_in'] = logentry.parsed_data.get('not_checked_in', False)
if logentry.parsed_data.get('subevents_from'):
initial['subevents_from'] = dateutil.parser.parse(logentry.parsed_data['subevents_from'])
if logentry.parsed_data.get('subevents_to'):
initial['subevents_to'] = dateutil.parser.parse(logentry.parsed_data['subevents_to'])
if logentry.parsed_data.get('created_from'):
initial['created_from'] = dateutil.parser.parse(logentry.parsed_data['created_from'])
if logentry.parsed_data.get('created_to'):
initial['created_to'] = dateutil.parser.parse(logentry.parsed_data['created_to'])
if logentry.parsed_data.get('attach_tickets'):
initial['attach_tickets'] = logentry.parsed_data['attach_tickets']
if logentry.parsed_data.get('subevent'):
try:
initial['subevent'] = self.request.event.subevents.get(
pk=logentry.parsed_data['subevent']['id']
)
except SubEvent.DoesNotExist:
pass
return initial
def get_object_queryset(self, form):
qs = Order.objects.filter(event=self.request.event)
statusq = Q(status__in=form.cleaned_data['sendto'])
if 'overdue' in form.cleaned_data['sendto']:
@@ -189,88 +402,111 @@ class SenderView(EventPermissionRequiredMixin, FormView):
if form.cleaned_data.get('created_to'):
opq = opq.filter(order__datetime__lt=form.cleaned_data.get('created_to'))
orders = orders.annotate(match_pos=Exists(opq)).filter(match_pos=True).distinct()
return orders.annotate(match_pos=Exists(opq)).filter(match_pos=True).distinct()
ocnt = orders.count()
def describe_match_size(self, cnt):
return ngettext(
'%(number)s matching order',
'%(number)s matching orders',
cnt or 0,
) % {
'number': intcomma(cnt or 0),
}
self.output = {}
if not ocnt:
messages.error(self.request, _('There are no orders matching this selection.'))
self.request.POST = self.request.POST.copy()
self.request.POST.pop("action", "")
return self.get(self.request, *self.args, **self.kwargs)
if self.request.POST.get("action") != "send":
for l in self.request.event.settings.locales:
with language(l, self.request.event.settings.region):
context_dict = {}
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 = format_map(subject, context_dict)
message = form.cleaned_data['message'].localize(l)
preview_text = markdown_compile_email(format_map(message, context_dict))
self.output[l] = {
'subject': _('Subject: {subject}').format(subject=preview_subject),
'html': preview_text,
'attachment': form.cleaned_data.get('attachment')
}
self.order_count = ocnt
return self.get(self.request, *self.args, **self.kwargs)
kwargs = {
def get_task_kwargs(self, form, objects):
kwargs = super().get_task_kwargs(form, objects)
kwargs.update({
'recipients': form.cleaned_data['recipients'],
'event': self.request.event.pk,
'user': self.request.user.pk,
'subject': form.cleaned_data['subject'].data,
'message': form.cleaned_data['message'].data,
'orders': [o.pk for o in orders],
'items': [i.pk for i in form.cleaned_data.get('items')],
'not_checked_in': form.cleaned_data.get('not_checked_in'),
'checkin_lists': [i.pk for i in form.cleaned_data.get('checkin_lists')],
'filter_checkins': form.cleaned_data.get('filter_checkins'),
'attach_tickets': form.cleaned_data.get('attach_tickets'),
})
return kwargs
class WaitinglistSendView(BaseSenderView):
form_class = forms.WaitinglistMailForm
form_fragment_name = "pretixplugins/sendmail/send_form_fragment_waitinglist.html"
context_parameters = ['event', 'waiting_list_entry', 'event_or_subevent']
task = send_mails_to_waitinglist
ACTION_TYPE = 'pretix.plugins.sendmail.sent.waitinglist'
TITLE = _("Waiting list")
DESCRIPTION = _("Send an email to every person currently waiting to receive a voucher through the waiting "
"list feature.")
@classmethod
def show_history_meta_data(cls, logentry, _cache_store):
if 'itemcache' not in _cache_store:
_cache_store['itemcache'] = {
i.pk: str(i) for i in logentry.event.items.all()
}
tpl = get_template('pretixplugins/sendmail/history_fragment_waitinglist.html')
logentry.pdata['items'] = [
_cache_store['itemcache'].get(i['id'], '?') for i in logentry.pdata.get('items', [])
]
if logentry.pdata.get('subevent'):
try:
logentry.pdata['subevent_obj'] = logentry.event.subevents.get(pk=logentry.pdata['subevent']['id'])
except SubEvent.DoesNotExist:
pass
return tpl.render({
'log': logentry,
})
@classmethod
def get_url(cls, event):
return reverse(
'plugins:sendmail:send.waitinglist',
kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}
)
def initial_from_logentry(self, logentry: LogEntry):
initial = super().initial_from_logentry(logentry)
if 'items' in logentry.parsed_data:
initial['items'] = self.request.event.items.filter(
id__in=[a['id'] for a in logentry.parsed_data['items']]
)
if logentry.parsed_data.get('subevents_from'):
initial['subevents_from'] = dateutil.parser.parse(logentry.parsed_data['subevents_from'])
if logentry.parsed_data.get('subevents_to'):
initial['subevents_to'] = dateutil.parser.parse(logentry.parsed_data['subevents_to'])
if logentry.parsed_data.get('subevent'):
try:
initial['subevent'] = self.request.event.subevents.get(
pk=logentry.parsed_data['subevent']['id']
)
except SubEvent.DoesNotExist:
pass
return initial
def get_object_queryset(self, form):
qs = self.request.event.waitinglistentries.filter(voucher__isnull=True)
qs = qs.filter(item__in=[i.pk for i in form.cleaned_data.get('items')])
if form.cleaned_data.get('subevent'):
qs = qs.filter(subevent=form.cleaned_data.get('subevent'))
if form.cleaned_data.get('subevents_from'):
qs = qs.filter(subevent__date_from__gte=form.cleaned_data.get('subevents_from'))
if form.cleaned_data.get('subevents_to'):
qs = qs.filter(subevent__date_from__lt=form.cleaned_data.get('subevents_to'))
return qs
def describe_match_size(self, cnt):
return ngettext(
'%(number)s waiting list entry',
'%(number)s waiting list entries',
cnt or 0,
) % {
'number': intcomma(cnt or 0),
}
attachment = form.cleaned_data.get('attachment')
if attachment is not None and attachment is not False:
kwargs['attachments'] = [form.cleaned_data['attachment'].id]
send_mails.apply_async(
kwargs=kwargs
)
self.request.event.log_action('pretix.plugins.sendmail.sent',
user=self.request.user,
data=dict(form.cleaned_data))
messages.success(self.request, _('Your message has been queued and will be sent to the contact addresses of %d '
'orders in the next few minutes.') % len(orders))
return redirect(
'plugins:sendmail:send',
event=self.request.event.slug,
organizer=self.request.event.organizer.slug
)
def get_context_data(self, *args, **kwargs):
ctx = super().get_context_data(*args, **kwargs)
ctx['output'] = getattr(self, 'output', None)
ctx['order_count'] = getattr(self, 'order_count', None)
ctx['is_preview'] = self.request.method == 'POST' and self.request.POST.get('action') == 'preview' and ctx['form'].is_valid()
return ctx
def get_form(self, form_class=None):
f = super().get_form(form_class)
if self.request.method == 'POST' and self.request.POST.get('action') == 'preview':
if f.is_valid():
for fname, field in f.fields.items():
field.widget.attrs['disabled'] = 'disabled'
return f
class EmailHistoryView(EventPermissionRequiredMixin, ListView):
@@ -280,27 +516,30 @@ class EmailHistoryView(EventPermissionRequiredMixin, ListView):
context_object_name = 'logs'
paginate_by = 5
@cached_property
def type_map(self):
from .signals import sendmail_view_classes
classes = []
for recv, resp in sendmail_view_classes.send(self.request.event):
if isinstance(resp, (list, tuple)):
classes += resp
else:
classes.append(resp)
return {
cls.ACTION_TYPE: cls
for cls in classes
}
def get_queryset(self):
qs = LogEntry.objects.filter(
event=self.request.event,
action_type='pretix.plugins.sendmail.sent'
action_type__in=self.type_map.keys(),
).select_related('event', 'user')
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
itemcache = {
i.pk: str(i) for i in self.request.event.items.all()
}
checkin_list_cache = {
i.pk: str(i) for i in self.request.event.checkin_lists.all()
}
status = dict(Order.STATUS_CHOICE)
status['overdue'] = _('pending with payment overdue')
status['na'] = _('payment pending (except unapproved)')
status['pa'] = _('approval pending')
status['r'] = status['c']
_cache = {}
for log in ctx['logs']:
log.pdata = log.parsed_data
log.pdata['locales'] = {}
@@ -309,21 +548,11 @@ class EmailHistoryView(EventPermissionRequiredMixin, ListView):
'message': msg,
'subject': log.pdata['subject'][locale]
}
log.pdata['sendto'] = [
status[s] for s in log.pdata['sendto']
]
log.pdata['items'] = [
itemcache.get(i['id'], '?') for i in log.pdata.get('items', [])
]
log.pdata['checkin_lists'] = [
checkin_list_cache.get(i['id'], '?')
for i in log.pdata.get('checkin_lists', []) if i['id'] in checkin_list_cache
]
if log.pdata.get('subevent'):
try:
log.pdata['subevent_obj'] = self.request.event.subevents.get(pk=log.pdata['subevent']['id'])
except SubEvent.DoesNotExist:
pass
log.view = {
'url': self.type_map[log.action_type].get_url(self.request.event),
'title': self.type_map[log.action_type].TITLE,
'rendered_data': self.type_map[log.action_type].show_history_meta_data(log, _cache)
}
return ctx