Implement notifications for admin users (#700)

* First stab at notification settings

* Add "global" setting for notification levels

* Trigger notification task

* Get users with permission for event

* Actually send notification emails

* More notifications

* Allow to turn off notifications

* Link in email to pause all notifications

* Add NotificationType to wordlist

* Add notification tests

* Add documentation

* Rebase fixes
This commit is contained in:
Raphael Michel
2017-12-14 22:06:08 +01:00
committed by GitHub
parent f0a1397eea
commit 128203800c
28 changed files with 1363 additions and 172 deletions

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-12-06 16:03
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.auth
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0077_auto_20171124_1629'),
]
operations = [
migrations.CreateModel(
name='NotificationSetting',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action_type', models.CharField(max_length=255)),
('method', models.CharField(choices=[('mail', 'E-mail')], max_length=255)),
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('enabled', models.BooleanField(default=True)),
],
),
migrations.AlterUniqueTogether(
name='notificationsetting',
unique_together=set([('user', 'action_type', 'event', 'method')]),
),
migrations.AddField(
model_name='logentry',
name='visible',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='notificationsetting',
name='event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notification_settings', to='pretixbase.Event'),
),
migrations.AlterField(
model_name='notificationsetting',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_settings', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='user',
name='notifications_send',
field=models.BooleanField(default=True, help_text='If turned off, you will not get any notifications.', verbose_name='Receive notifications according to my settings below'),
),
migrations.AddField(
model_name='user',
name='notifications_token',
field=models.CharField(default=pretix.base.models.auth.generate_notifications_token, max_length=255),
),
]

View File

@@ -12,6 +12,7 @@ from .items import (
Quota, SubEventItem, SubEventItemVariation, itempicture_upload_to,
)
from .log import LogEntry
from .notifications import NotificationSetting
from .orders import (
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
InvoiceAddress, Order, OrderPosition, QuestionAnswer,

View File

@@ -7,6 +7,7 @@ from django.contrib.auth.models import (
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _
from django_otp.models import Device
@@ -40,6 +41,10 @@ class UserManager(BaseUserManager):
return user
def generate_notifications_token():
return get_random_string(length=32)
class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
"""
This is the user model used by pretix for authentication.
@@ -81,6 +86,12 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
default=settings.TIME_ZONE,
verbose_name=_('Timezone'))
require_2fa = models.BooleanField(default=False)
notifications_send = models.BooleanField(
default=True,
verbose_name=_('Receive notifications according to my settings below'),
help_text=_('If turned off, you will not get any notifications.')
)
notifications_token = models.CharField(max_length=255, default=generate_notifications_token)
objects = UserManager()

View File

@@ -47,6 +47,8 @@ class LoggingMixin:
"""
from .log import LogEntry
from .event import Event
from ..notifications import get_all_notification_types
from ..services.notifications import notify
event = None
if isinstance(self, Event):
@@ -60,6 +62,9 @@ class LoggingMixin:
logentry.data = json.dumps(data, cls=CustomJSONEncoder)
logentry.save()
if action in get_all_notification_types():
notify.apply_async(args=(logentry.pk,))
class LoggedModel(models.Model, LoggingMixin):

View File

@@ -10,7 +10,7 @@ from django.core.files.storage import default_storage
from django.core.mail import get_connection
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import Q
from django.db.models import Exists, OuterRef, Q
from django.template.defaultfilters import date as _date
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
@@ -26,7 +26,7 @@ from pretix.helpers.daterange import daterange
from pretix.helpers.json import safe_string
from ..settings import settings_hierarkey
from .organizer import Organizer
from .organizer import Organizer, Team
class EventMixin:
@@ -511,6 +511,39 @@ class Event(EventMixin, LoggedModel):
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
return data
def get_users_with_any_permission(self):
"""
Returns a queryset of users who have any permission to this event.
:return: Iterable of User
"""
return self.get_users_with_permission(None)
def get_users_with_permission(self, permission):
"""
Returns a queryset of users who have a specific permission to this event.
:return: Iterable of User
"""
from .auth import User
if permission:
kwargs = {permission: True}
else:
kwargs = {}
team_with_perm = Team.objects.filter(
members__pk=OuterRef('pk'),
organizer=self.organizer,
**kwargs
).filter(
Q(all_events=True) | Q(limit_events__pk=self.pk)
)
return User.objects.annotate(twp=Exists(team_with_perm)).filter(
Q(is_superuser=True) | Q(twp=True)
)
class SubEvent(EventMixin, LoggedModel):
"""
@@ -665,6 +698,21 @@ class RequiredAction(models.Model):
return response
return self.action_type
def save(self, *args, **kwargs):
created = not self.pk
super().save(*args, **kwargs)
if created:
from .log import LogEntry
from ..services.notifications import notify
logentry = LogEntry.objects.create(
content_object=self,
action_type='pretix.event.action_required',
event=self.event,
visible=False
)
notify.apply_async(args=(logentry.pk,))
class EventMetaProperty(LoggedModel):
"""

View File

@@ -11,6 +11,11 @@ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.signals import logentry_object_link
class VisibleOnlyManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(visible=True)
class LogEntry(models.Model):
"""
Represents a change or action that has been performed on another object
@@ -39,6 +44,10 @@ class LogEntry(models.Model):
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.CASCADE)
action_type = models.CharField(max_length=255)
data = models.TextField(default='{}')
visible = models.BooleanField(default=True)
objects = VisibleOnlyManager()
all = models.Manager()
class Meta:
ordering = ('-datetime',)

View File

@@ -0,0 +1,36 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
class NotificationSetting(models.Model):
"""
Stores that a user wants to get notifications of a certain type via a certain
method for a certain event. If event is None, the notification shall be sent
for all events the user has access to.
:param user: The user to nofify.
:type user: User
:param action_type: The type of action to notify for.
:type action_type: str
:param event: The event to notify for.
:type event: Event
:param method: The method to notify with.
:type method: str
:param enabled: Indicates whether the specified notification is enabled. If no
event is set, this must always be true. If no event is set, setting
this to false is equivalent to deleting the object.
:type enabled: bool
"""
CHANNELS = (
('mail', _('E-mail')),
)
user = models.ForeignKey('User', on_delete=models.CASCADE,
related_name='notification_settings')
action_type = models.CharField(max_length=255)
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.CASCADE,
related_name='notification_settings')
method = models.CharField(max_length=255, choices=CHANNELS)
enabled = models.BooleanField(default=True)
class Meta:
unique_together = ('user', 'action_type', 'event', 'method')

View File

@@ -0,0 +1,233 @@
import logging
from collections import OrderedDict, namedtuple
from django.dispatch import receiver
from django.utils.formats import date_format, localize
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Event, LogEntry
from pretix.base.signals import register_notification_types
from pretix.helpers.urls import build_absolute_uri
logger = logging.getLogger(__name__)
_ALL_TYPES = None
NotificationAttribute = namedtuple('NotificationAttribute', ('title', 'value'))
NotificationAction = namedtuple('NotificationAction', ('label', 'url'))
class Notification:
"""
Represents a notification that is sent/shown to a user. A notification consists of:
* one ``event`` reference
* one ``title`` text that is shown e.g. in the email subject or in a headline
* optionally one ``detail`` text that may or may not be shown depending on the notification method
* optionally one ``url`` that should be absolute and point to the context of an notification (e.g. an order)
* optionally a number of attributes consisting of a title and a value that can be used to add additional details
to the notification (e.g. "Customer: ABC")
* optionally a number of actions that may or may not be shown as buttons depending on the notification method,
each consisting of a button label and an absolute URL to point to.
"""
def __init__(self, event: Event, title: str, detail: str=None, url: str=None):
self.title = title
self.event = event
self.detail = detail
self.url = url
self.attributes = []
self.actions = []
def add_action(self, label, url):
"""
Add an action to the notification, defined by a label and an url. An example could be a label of "View order"
and an url linking to the order detail page.
"""
self.actions.append(NotificationAction(label, url))
def add_attribute(self, title, value):
"""
Add an attribute to the notification, defined by a title and a value. An example could be a title of
"Date" and a value of "2017-12-14".
"""
self.attributes.append(NotificationAttribute(title, value))
class NotificationType:
def __init__(self, event: Event = None):
self.event = event
def __repr__(self):
return '<NotificationType: {}>'.format(self.action_type)
@property
def action_type(self) -> str:
"""
The action_type string that this notification handles, for example
``"pretix.event.order.paid"``. Only one notification type should be registered
per action type.
"""
raise NotImplementedError() # NOQA
@property
def verbose_name(self) -> str:
"""
A human-readable name of this notification type.
"""
raise NotImplementedError() # NOQA
@property
def required_permission(self) -> str:
"""
The permission a user needs to hold for the related event to receive this
notification.
"""
raise NotImplementedError() # NOQA
def build_notification(self, logentry: LogEntry) -> Notification:
"""
This is the main function that you should override. It is supposed to turn a log entry
object into a notification object that can then be rendered e.g. into an email.
"""
return Notification(
logentry.event,
logentry.display()
)
def get_all_notification_types(event=None):
global _ALL_TYPES
if event is None and _ALL_TYPES:
return _ALL_TYPES
types = OrderedDict()
for recv, ret in register_notification_types.send(event):
if isinstance(ret, (list, tuple)):
for r in ret:
types[r.action_type] = r
else:
types[ret.action_type] = ret
if event is None:
_ALL_TYPES = types
return types
class ActionRequiredNotificationType(NotificationType):
required_permission = "can_change_orders"
action_type = "pretix.event.action_required"
verbose_name = _("Administrative action required")
def build_notification(self, logentry: LogEntry):
control_url = build_absolute_uri(
'control:event.requiredactions',
kwargs={
'organizer': logentry.event.organizer.slug,
'event': logentry.event.slug,
}
)
n = Notification(
event=logentry.event,
title=_('Administrative action required'),
detail=_('Something happened in your event that our system cannot handle automatically, e.g. an external '
'refund. You need to resolve it manually or choose to ignore it, depending on the issue at hand.'),
url=control_url
)
n.add_action(_('View all unresolved problems'), control_url)
return n
class ParametrizedOrderNotificationType(NotificationType):
required_permission = "can_view_orders"
def __init__(self, event, action_type, verbose_name, title):
self._action_type = action_type
self._verbose_name = verbose_name
self._title = title
super().__init__(event)
@property
def action_type(self):
return self._action_type
@property
def verbose_name(self):
return self._verbose_name
def build_notification(self, logentry: LogEntry):
order = logentry.content_object
order_url = build_absolute_uri(
'control:event.order',
kwargs={
'organizer': logentry.event.organizer.slug,
'event': logentry.event.slug,
'code': order.code
}
)
n = Notification(
event=logentry.event,
title=self._title.format(order=order, event=logentry.event),
url=order_url
)
n.add_attribute(_('Order code'), order.code)
n.add_attribute(_('Order total'), '{} {}'.format(localize(order.total), logentry.event.currency))
n.add_attribute(_('Order date'), date_format(order.datetime, 'SHORT_DATETIME_FORMAT'))
n.add_attribute(_('Order status'), order.get_status_display())
n.add_attribute(_('Order positions'), str(order.positions.count()))
n.add_action(_('View order details'), order_url)
return n
@receiver(register_notification_types, dispatch_uid="base_register_default_notification_types")
def register_default_notification_types(sender, **kwargs):
return (
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.placed',
_('New order placed'),
_('A new order has been placed: {order.code}'),
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.paid',
_('Order marked as paid'),
_('Order {order.code} has been marked as paid.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.canceled',
_('Order canceled'),
_('Order {order.code} has been canceled.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.modified',
_('Order information changed'),
_('The ticket information of order {order.code} has been changed.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.contact.changed',
_('Order contact address changed'),
_('The contact address of order {order.code} has been changed.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.changed',
_('Order changed'),
_('Order {order.code} has been changed.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.refunded',
_('Order refunded'),
_('Order {order.code} has been refunded.')
),
ActionRequiredNotificationType(
sender,
)
)

View File

@@ -0,0 +1,104 @@
from django.conf import settings
from django.template.loader import get_template
from pretix.base.i18n import language
from pretix.base.models import LogEntry, NotificationSetting, User
from pretix.base.notifications import Notification, get_all_notification_types
from pretix.base.services.async import ProfiledTask, TransactionAwareTask
from pretix.base.services.mail import mail_send_task
from pretix.celery_app import app
from pretix.helpers.urls import build_absolute_uri
@app.task(base=TransactionAwareTask)
def notify(logentry_id: int):
logentry = LogEntry.all.get(id=logentry_id)
if not logentry.event:
return # Ignore, we only have event-related notifications right now
types = get_all_notification_types(logentry.event)
notification_type = types.get(logentry.action_type)
if not notification_type:
return # Ignore, e.g. plugin not active for this event
# All users that have the permission to get the notification
users = logentry.event.get_users_with_permission(
notification_type.required_permission
).filter(notifications_send=True)
if logentry.user:
users = users.exclude(pk=logentry.user.pk)
# Get all notification settings, both specific to this event as well as global
notify_specific = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event=logentry.event,
action_type=logentry.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
notify_global = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
action_type=logentry.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
for um, enabled in notify_specific.items():
user, method = um
if enabled:
send_notification.apply_async(args=(logentry_id, user.pk, method))
for um, enabled in notify_global.items():
user, method = um
if enabled and um not in notify_specific:
send_notification.apply_async(args=(logentry_id, user.pk, method))
@app.task(base=ProfiledTask)
def send_notification(logentry_id: int, user_id: int, method: str):
logentry = LogEntry.all.get(id=logentry_id)
user = User.objects.get(id=user_id)
types = get_all_notification_types(logentry.event)
notification_type = types.get(logentry.action_type)
if not notification_type:
return # Ignore, e.g. plugin not active for this event
with language(user.locale):
notification = notification_type.build_notification(logentry)
if method == "mail":
send_notification_mail(notification, user)
def send_notification_mail(notification: Notification, user: User):
ctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
'color': '#8E44B3',
'notification': notification,
'settings_url': build_absolute_uri(
'control:user.settings.notifications',
),
'disable_url': build_absolute_uri(
'control:user.settings.notifications.off',
kwargs={
'token': user.notifications_token,
'id': user.pk
}
)
}
tpl_html = get_template('pretixbase/email/notification.html')
body_html = tpl_html.render(ctx)
tpl_plain = get_template('pretixbase/email/notification.txt')
body_plain = tpl_plain.render(ctx)
mail_send_task.apply_async(kwargs={
'to': [user.email],
'subject': '[{}] {}'.format(settings.PRETIX_INSTANCE_NAME, notification.title),
'body': body_plain,
'html': body_html,
'sender': settings.MAIL_FROM,
'headers': {},
})

View File

@@ -26,6 +26,10 @@ class EventPluginSignal(django.dispatch.Signal):
"""
def _is_active(self, sender, receiver):
if sender is None:
# Send to all events!
return True
# Find the Django application this belongs to
searchpath = receiver.__module__
core_module = any([searchpath.startswith(cm) for cm in settings.CORE_MODULES])
@@ -140,6 +144,19 @@ subclass of pretix.base.ticketoutput.BaseTicketOutput
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_notification_types = EventPluginSignal(
providing_args=[]
)
"""
This signal is sent out to get all known notification types. Receivers should return an
instance of a subclass of pretix.base.notifications.NotificationType or a list of such
instances.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event,
however for this signal, the ``sender`` **may also be None** to allow creating the general
notification settings!
"""
register_data_exporters = EventPluginSignal(
providing_args=[]
)

View File

@@ -0,0 +1,167 @@
{% load eventurl %}
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=false">
</head>
<style type="text/css">
body {
background-color: #e8e8e8;
background-position: top;
background-repeat: repeat-x;
font-family: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 20px;
color: #333;
margin: 0;
}
.header h1 {
margin-top: 20px;
margin-bottom: 20px;
}
.header h1 a, .content h2 a, .content h3 a {
text-decoration: none;
}
.content h2, .content h3 {
margin-bottom: 20px;
margin-top: 10px;
}
a {
color: {{ color }};
font-weight: bold;
}
a:hover, a:focus {
color: {{ color }};
text-decoration: underline;
}
a:hover, a:active {
outline: 0;
}
p {
margin: 0 0 10px;
/* These are technically the same, but use both */
overflow-wrap: break-word;
word-wrap: break-word;
-ms-word-break: break-all;
/* This is the dangerous one in WebKit, as it breaks things wherever */
word-break: break-all;
/* Instead use this non-standard one: */
word-break: break-word;
/* Adds a hyphen where the word breaks, if supported (No Blink) */
-ms-hyphens: auto;
-moz-hyphens: auto;
-webkit-hyphens: auto;
hyphens: auto;
}
.footer {
padding: 10px;
text-align: center;
font-size: 12px;
}
.content {
padding: 8px 18px 8px;
}
::selection {
background: {{ color }};
color: #FFF;
}
table.layout {
width: 90%;
max-width: 900px;
border-spacing: 0px;
border-collapse: separate;
margin: auto;
}
.content table {
width: 100%;
}
.content table td {
vertical-align: top;
text-align: left;
padding: 5px 0;
}
a.button {
display: inline-block;
padding: 10px 16px;
font-size: 14px;
line-height: 1.33333;
border: 1px solid #cccccc;
border-radius: 6px;
-webkit-border-radius: 6px;
-moz-border-radius: 6px;
margin: 5px;
text-decoration: none;
color: {{ color }};
}
@media (max-width: 480px) {
.header h1 {
font-size: 19px;
line-height: 24px;
margin: 0 9px 3px 0;
border-radius: 5px 5px;
-webkit-border-radius: 5px 5px;
-moz-border-radius: 5px 5px;
}
.header h1 a {
padding: 3px 9px;
display: block;
}
.header {
margin: 0;
padding: 12px 0 8px;
}
}
td.containertd {
background-color: #FFFFFF;
border: 1px solid #cccccc;
}
{% block addcss %}{% endblock %}
</style>
<body>
<table class="layout">
<tr>
<td class="header" background="">
{% if event %}
<h1><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
{% else %}
<h1><a href="{{ site_url }}" target="_blank">{{ site }}</a></h1>
{% endif %}
</td>
</tr>
{% block content %}
{% endblock %}
<tr>
<td class="footer">
<div>
{% include "pretixbase/email/email_footer.html" %}
</div>
</td>
</tr>
</table>
<br/>
<br/>
</body>
</html>

View File

@@ -0,0 +1,53 @@
{% extends "pretixbase/email/base.html" %}
{% load eventurl %}
{% load i18n %}
{% block content %}
<tr>
<td class="containertd">
<div class="content">
<h3>
{% if notification.url %}<a href="{{ notification.url }}">{% endif %}
{{ notification.title }}
{% if notification.url %}</a>{% endif %}
</h3>
{% if notification.detail %}
<p>{{ notification.detail }}</p>
{% endif %}
{% if notification.attributes %}
<table>
{% for attr in notification.attributes %}
<tr>
<td>
<strong>{{ attr.title }}</strong>
</td>
<td>
{{ attr.value }}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% if notification.actions %}
<p class="actions" style="text-align: center">
{% for action in notification.actions %}
<a href="{{ action.url }}" class="button">{{ action.label }}</a>
{% endfor %}
</p>
{% endif %}
</div>
</td>
</tr>
<tr>
<td class="containertd">
<div class="content">
{% trans "You receive these emails based on your notification settings." %}<br>
<a href="{{ settings_url }}">
{% trans "Click here to view and change your notification settings" %}
</a><br>
<a href="{{ disable_url }}">
{% trans "Click here disable all notifications immediately." %}
</a>
</div>
</td>
</tr>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% load i18n %}
{{ notification.title }}{% if notification.detail %}
{{ notification.detail }}
{% endif %}{% if notification.url %}
{{ notification.url }}{% endif %}{% for attr in notification.attributes %}
{{ attr.title }}: {{ attr.value }}{% endfor %}{% for action in notification.actions %}
{{ action.label }}
{{ action.url }}{% endfor %}
{% trans "You receive these emails based on your notification settings." %}
{% trans "Click here to view and change your notification settings:" %}
{{ settings_url }}
{% trans "Click here disable all notifications immediately:" %}
{{ disable_url }}

View File

@@ -1,174 +1,42 @@
{% extends "pretixbase/email/base.html" %}
{% load eventurl %}
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=false">
</head>
<style type="text/css">
body {
background-color: #e8e8e8;
background-position: top;
background-repeat: repeat-x;
font-family: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 20px;
color: #333;
margin: 0;
}
.header h1 {
margin-top: 20px;
margin-bottom: 20px;
}
.header h1 a {
text-decoration: none;
}
a {
color: {{ color }};
font-weight: bold;
}
a:hover, a:focus {
color: {{ color }};
text-decoration: underline;
}
a:hover, a:active {
outline: 0;
}
p {
margin: 0 0 10px;
/* These are technically the same, but use both */
overflow-wrap: break-word;
word-wrap: break-word;
-ms-word-break: break-all;
/* This is the dangerous one in WebKit, as it breaks things wherever */
word-break: break-all;
/* Instead use this non-standard one: */
word-break: break-word;
/* Adds a hyphen where the word breaks, if supported (No Blink) */
-ms-hyphens: auto;
-moz-hyphens: auto;
-webkit-hyphens: auto;
hyphens: auto;
}
.footer {
padding: 10px;
text-align: center;
font-size: 12px;
}
.content {
padding: 8px 18px 8px;
}
::selection {
background: {{ color }};
color: #FFF;
}
table {
width: 90%;
max-width: 900px;
border-spacing: 0px;
border-collapse: separate;
margin: auto;
}
@media (max-width: 480px) {
.header h1 {
font-size: 19px;
line-height: 24px;
margin: 0 9px 3px 0;
border-radius: 5px 5px;
-webkit-border-radius: 5px 5px;
-moz-border-radius: 5px 5px;
}
.header h1 a {
padding: 3px 9px;
display: block;
}
.header {
margin: 0;
padding: 12px 0 8px;
}
}
td.containertd {
background-color: #FFFFFF;
border: 1px solid #cccccc;
}
{% block addcss %}{% endblock %}
</style>
<body>
<table>
{% block content %}
<tr>
<td class="containertd">
<div class="content">
{{ body|safe }}
</div>
</td>
</tr>
{% if order %}
<tr>
<td class="header" background="">
{% if event %}
<h1><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
{% else %}
<h1><a href="{{ site_url }}" target="_blank">{{ site }}</a></h1>
{% endif %}
</td>
<td class="gap"></td>
</tr>
<tr>
<td class="containertd">
<td class="order containertd">
<div class="content">
{{ body|safe }}
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
<a href="{% abseventurl event "presale:event.order" order=order.code secret=order.secret %}">
{% trans "View order details" %}
</a>
</div>
</td>
</tr>
{% if order %}
<tr>
<td class="gap"></td>
</tr>
<tr>
<td class="order containertd">
<div class="content">
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
<a href="{% abseventurl event "presale:event.order" order=order.code secret=order.secret %}">
{% trans "View order details" %}
</a>
</div>
</td>
</tr>
{% endif %}
{% endif %}
{% if signature %}
<tr>
<td class="gap"></td>
</tr>
<tr>
<td class="order containertd">
<div class="content">
{{ signature | safe }}
</div>
</td>
</tr>
{% endif %}
<tr>
<td class="footer">
<div>
{% include "pretixbase/email/email_footer.html" %}
<td class="gap"></td>
</tr>
<tr>
<td class="order containertd">
<div class="content">
{{ signature | safe }}
</div>
</td>
</tr>
</table>
<br/>
<br/>
</body>
</html>
{% endif %}
{% endblock %}

View File

@@ -141,6 +141,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'your account.'),
'pretix.user.settings.2fa.device.deleted': _('The two-factor authentication device "{name}" has been removed '
'from your account.'),
'pretix.user.settings.notifications.enabled': _('Notifications have been enabled.'),
'pretix.user.settings.notifications.disabled': _('Notifications have been disabled.'),
'pretix.user.settings.notifications.changed': _('Your notification settings have been changed.'),
'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'),
'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
'pretix.voucher.added': _('The voucher has been created.'),

View File

@@ -27,6 +27,7 @@ class PermissionMiddleware(MiddlewareMixin):
"auth.forgot",
"auth.forgot.recover",
"auth.invite",
"user.settings.notifications.off",
)
def _login_redirect(self, request):

View File

@@ -0,0 +1,89 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Notification settings" %}{% endblock %}
{% block content %}
<h1>{% trans "Notification settings" %}</h1>
<form method="post">
{% csrf_token %}
<fieldset>
{% if request.user.notifications_send %}
<div class="alert alert-info">
<button name="notifications_send" value="off" type="submit" class="pull-right btn btn-default">
<span class="fa fa-bell-slash"></span>
{% trans "Disable" %}
</button>
{% trans "Notifications are turned on according to the settings below." %}
<div class="clearfix"></div>
</div>
{% else %}
<div class="alert alert-warning">
<button name="notifications_send" value="on" type="submit" class="pull-right btn btn-default">
<span class="fa fa-bell"></span>
{% trans "Enable" %}
</button>
{% trans "All notifications are turned off globally." %}
<div class="clearfix"></div>
</div>
{% endif %}
</fieldset>
</form>
<form class="form-inline" method="get">
<fieldset>
<legend>{% trans "Choose event" %}</legend>
<p>
<select name="event" class="form-control">
<option value="">{% trans "All my events" %}</option>
{% for e in events %}
<option value="{{ e.pk }}"
{% if e.pk|floatformat:0 == request.GET.event %}selected="selected"{% endif %}>
{{ e.name }} {{ e.get_date_range_display }}
</option>
{% endfor %}
</select>
<button class="btn btn-primary" type="submit">{% trans "Choose" %}</button>
<span class="help-block">{% trans "Save your modifications before switching events." %}</span>
</p>
</fieldset>
</form>
<form method="post">
{% csrf_token %}
<fieldset>
<legend>{% trans "Choose notifications to get" %}</legend>
<table class="table">
<thead>
<tr>
<th>{% trans "Notification type" %}</th>
<th class="text-center">{% trans "E-Mail notification" %}</th>
</tr>
</thead>
<tbody>
{% for type, enabled, global in types %}
<tr>
<td>
{{ type.verbose_name }}
</td>
<td class="text-center">
{% if not event or type.required_permission in permset %}
<select name="mail:{{ type.action_type }}" class="form-control">
{% if event %}
<option value="global">{% trans "Global" %} ({% if global.mail %}{% trans "On" %}{% else %}{% trans "Off" %}{% endif %})</option>{% endif %}
<option value="off" {% if "mail" in enabled and enabled.mail == False %}selected{% endif %}>{% trans "Off" %}</option>
<option value="on" {% if enabled.mail %}selected{% endif %}>{% trans "On" %}</option>
</select>
{% else %}
<span class="fa fa-lock" data-toggle="tooltip" title="{% trans "You have no permission to receive this notification" %}"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -12,6 +12,24 @@
{% bootstrap_field form.fullname layout='horizontal' %}
{% bootstrap_field form.locale layout='horizontal' %}
{% bootstrap_field form.timezone layout='horizontal' %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_new_pw_repeat">{% trans "Notifications" %}</label>
<div class="col-md-9 static-form-row">
{% if request.user.notifications_send and request.user.notification_settings.exists %}
<span class="label label-success">
<span class="fa fa-bell-o"></span> {% trans "On" %}
</span>
{% else %}
<span class="label label-warning">
<span class="fa fa-bell-slash-o"></span> {% trans "Off" %}
</span>
{% endif %}
&nbsp;
<a href="{% url "control:user.settings.notifications" %}">
{% trans "Change notification settings" %}
</a>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Login settings" %}</legend>
@@ -23,12 +41,12 @@
<label class="col-md-3 control-label" for="id_new_pw_repeat">{% trans "Two-factor authentication" %}</label>
<div class="col-md-9 static-form-row">
{% if user.require_2fa %}
<span class="label label-success">{% trans "Enabled" %}</span>
<span class="label label-success">{% trans "Enabled" %}</span> &nbsp;
<a href="{% url "control:user.settings.2fa" %}">
{% trans "Change two-factor settings" %}
</a>
{% else %}
<span class="label label-default">{% trans "Disabled" %}</span>
<span class="label label-default">{% trans "Disabled" %}</span> &nbsp;
<a href="{% url "control:user.settings.2fa" %}">
{% trans "Enable" %}
</a>

View File

@@ -18,8 +18,11 @@ urlpatterns = [
url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'),
url(r'^reauth/$', user.ReauthView.as_view(), name='user.reauth'),
url(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
url(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'),
url(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
url(r'^settings/notifications/$', user.UserNotificationsEditView.as_view(), name='user.settings.notifications'),
url(r'^settings/notifications/off/(?P<id>\d+)/(?P<token>[^/]+)/$', user.UserNotificationsDisableView.as_view(),
name='user.settings.notifications.off'),
url(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'),
url(r'^settings/2fa/add$', user.User2FADeviceAddView.as_view(), name='user.settings.2fa.add'),
url(r'^settings/2fa/enable', user.User2FAEnableView.as_view(), name='user.settings.2fa.enable'),
url(r'^settings/2fa/disable', user.User2FADisableView.as_view(), name='user.settings.2fa.disable'),

View File

@@ -1,6 +1,7 @@
import base64
import logging
import time
from collections import defaultdict
from urllib.parse import quote
from django.conf import settings
@@ -19,7 +20,8 @@ from u2flib_server import u2f
from u2flib_server.jsapi import DeviceRegistration
from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm
from pretix.base.models import U2FDevice, User
from pretix.base.models import Event, NotificationSetting, U2FDevice, User
from pretix.base.notifications import get_all_notification_types
from pretix.control.views.auth import get_u2f_appid
REAL_DEVICE_TYPES = (TOTPDevice, U2FDevice)
@@ -352,3 +354,121 @@ class User2FARegenerateEmergencyView(RecentAuthenticationRequiredMixin, Template
messages.success(request, _('Your emergency codes have been newly generated. Remember to store them in a safe '
'place in case you lose access to your devices.'))
return redirect(reverse('control:user.settings.2fa'))
class UserNotificationsDisableView(TemplateView):
def get(self, request, *args, **kwargs):
user = get_object_or_404(User, notifications_token=kwargs.get('token'), pk=kwargs.get('id'))
user.notifications_send = False
user.save()
messages.success(request, _('Your notifications have been disabled.'))
if request.user.is_authenticated:
return redirect(
reverse('control:user.settings.notifications')
)
else:
return redirect(
reverse('control:auth.login')
)
class UserNotificationsEditView(TemplateView):
template_name = 'pretixcontrol/user/notifications.html'
@cached_property
def event(self):
if self.request.GET.get('event'):
try:
return self.request.user.get_events_with_any_permission().select_related(
'organizer'
).get(pk=self.request.GET.get('event'))
except Event.DoesNotExist:
return None
return None
@cached_property
def types(self):
return get_all_notification_types(self.event)
@cached_property
def currently_set(self):
set_per_method = defaultdict(dict)
for n in self.request.user.notification_settings.filter(event=self.event):
set_per_method[n.method][n.action_type] = n.enabled
return set_per_method
@cached_property
def global_set(self):
set_per_method = defaultdict(dict)
for n in self.request.user.notification_settings.filter(event__isnull=True):
set_per_method[n.method][n.action_type] = n.enabled
return set_per_method
def post(self, request, *args, **kwargs):
if "notifications_send" in request.POST:
request.user.notifications_send = request.POST.get("notifications_send", "") == "on"
request.user.save()
messages.success(request, _('Your notification settings have been saved.'))
if request.user.notifications_send:
self.request.user.log_action('pretix.user.settings.notifications.disabled', user=self.request.user)
else:
self.request.user.log_action('pretix.user.settings.notifications.enabled', user=self.request.user)
return redirect(
reverse('control:user.settings.notifications') +
('?event={}'.format(self.event.pk) if self.event else '')
)
else:
for method, __ in NotificationSetting.CHANNELS:
old_enabled = self.currently_set[method]
for at in self.types.keys():
val = request.POST.get('{}:{}'.format(method, at))
# True → False
if old_enabled.get(at) is True and val == 'off':
self.request.user.notification_settings.filter(
event=self.event, action_type=at, method=method
).update(enabled=False)
# True/False → None
if old_enabled.get(at) is not None and val == 'global':
self.request.user.notification_settings.filter(
event=self.event, action_type=at, method=method
).delete()
# None → True/False
if old_enabled.get(at) is None and val in ('on', 'off'):
self.request.user.notification_settings.create(
event=self.event, action_type=at, method=method, enabled=(val == 'on'),
)
# False → True
if old_enabled.get(at) is False and val == 'on':
self.request.user.notification_settings.filter(
event=self.event, action_type=at, method=method
).update(enabled=True)
messages.success(request, _('Your notification settings have been saved.'))
self.request.user.log_action('pretix.user.settings.notifications.changed', user=self.request.user)
return redirect(
reverse('control:user.settings.notifications') +
('?event={}'.format(self.event.pk) if self.event else '')
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['events'] = self.request.user.get_events_with_any_permission().order_by('-date_from')
ctx['types'] = [
(
tv,
{k: a.get(t) for k, a in self.currently_set.items()},
{k: a.get(t) for k, a in self.global_set.items()},
)
for t, tv in self.types.items()
]
ctx['event'] = self.event
if self.event:
ctx['permset'] = self.request.user.get_event_permission_set(self.event.organizer, self.event)
return ctx

View File

@@ -263,3 +263,11 @@ div.scrolling-multiple-choice, div.scrolling-choice {
margin-top: 0;
}
}
table td > .checkbox {
margin: 0;
position: static;
}
table td > .checkbox input[type="checkbox"] {
margin: 0;
position: static;
}

View File

@@ -121,6 +121,9 @@ h1 .btn-sm {
padding-top: 20px;
}
.helper-display-block {
display: block !important;
}
.helper-display-inline {
display: inline !important;
}

View File

@@ -0,0 +1,118 @@
from datetime import timedelta
from decimal import Decimal
import pytest
from django.core import mail as djmail
from django.db import transaction
from django.utils.timezone import now
from pretix.base.models import (
Event, Item, Order, OrderPosition, Organizer, User,
)
@pytest.fixture
def event():
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now()
)
return event
@pytest.fixture
def order(event):
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, locale='en',
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('46.00'), payment_provider='banktransfer'
)
tr19 = event.tax_rules.create(rate=Decimal('19.00'))
ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rule=tr19,
default_price=Decimal('23.00'), admission=True)
OrderPosition.objects.create(
order=o, item=ticket, variation=None,
price=Decimal("23.00"), attendee_name="Peter", positionid=1
)
return o
@pytest.fixture
def team(event):
return event.organizer.teams.create(all_events=True, can_view_orders=True)
@pytest.fixture
def user(team):
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
team.members.add(user)
return user
@pytest.fixture
def monkeypatch_on_commit(monkeypatch):
monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
@pytest.mark.django_db
def test_notification_trigger_event_specific(event, order, user, monkeypatch_on_commit):
djmail.outbox = []
user.notification_settings.create(
method='mail', event=event, action_type='pretix.event.order.paid', enabled=True
)
with transaction.atomic():
order.log_action('pretix.event.order.paid', {})
assert len(djmail.outbox) == 1
@pytest.mark.django_db
def test_notification_trigger_global(event, order, user, monkeypatch_on_commit):
djmail.outbox = []
user.notification_settings.create(
method='mail', event=None, action_type='pretix.event.order.paid', enabled=True
)
with transaction.atomic():
order.log_action('pretix.event.order.paid', {})
assert len(djmail.outbox) == 1
@pytest.mark.django_db
def test_notification_enabled_global_ignored_specific(event, order, user, monkeypatch_on_commit):
djmail.outbox = []
user.notification_settings.create(
method='mail', event=None, action_type='pretix.event.order.paid', enabled=True
)
user.notification_settings.create(
method='mail', event=event, action_type='pretix.event.order.paid', enabled=False
)
with transaction.atomic():
order.log_action('pretix.event.order.paid', {})
assert len(djmail.outbox) == 0
@pytest.mark.django_db
def test_notification_ignore_same_user(event, order, user, monkeypatch_on_commit):
djmail.outbox = []
user.notification_settings.create(
method='mail', event=event, action_type='pretix.event.order.paid', enabled=True
)
with transaction.atomic():
order.log_action('pretix.event.order.paid', {}, user=user)
assert len(djmail.outbox) == 0
@pytest.mark.django_db
def test_notification_ignore_insufficient_permissions(event, order, user, team, monkeypatch_on_commit):
djmail.outbox = []
team.can_view_orders = False
team.save()
user.notification_settings.create(
method='mail', event=event, action_type='pretix.event.order.paid', enabled=True
)
with transaction.atomic():
order.log_action('pretix.event.order.paid', {})
assert len(djmail.outbox) == 0
# TODO: Test email content

View File

@@ -23,6 +23,11 @@ def user():
return User.objects.create_user('dummy@dummy.dummy', 'dummy')
@pytest.fixture
def admin():
return User.objects.create_user('admin@dummy.dummy', 'dummy', is_superuser=True)
@pytest.mark.django_db
def test_invalid_permission(event, user):
team = Team.objects.create(organizer=event.organizer)
@@ -204,7 +209,7 @@ def test_superuser(event, user):
@pytest.mark.django_db
def test_list_of_events(event, user):
def test_list_of_events(event, user, admin):
orga2 = Organizer.objects.create(slug='d2', name='d2')
event2 = Event.objects.create(
organizer=event.organizer, name='Dummy', slug='dummy2',
@@ -223,7 +228,7 @@ def test_list_of_events(event, user):
team1 = Team.objects.create(organizer=event.organizer, can_change_orders=True, all_events=True)
team2 = Team.objects.create(organizer=event.organizer, can_change_vouchers=True)
team3 = Team.objects.create(organizer=event.organizer, can_change_event_settings=True)
team3 = Team.objects.create(organizer=orga2, can_change_event_settings=True)
team1.members.add(user)
team2.members.add(user)
team3.members.add(user)
@@ -235,3 +240,20 @@ def test_list_of_events(event, user):
assert event2 in events
assert event3 in events
assert event4 not in events
events = list(user.get_events_with_permission('can_change_event_settings'))
assert event not in events
assert event2 not in events
assert event3 in events
assert event4 not in events
assert set(event.get_users_with_any_permission()) == {user, admin}
assert set(event2.get_users_with_any_permission()) == {user, admin}
assert set(event3.get_users_with_any_permission()) == {user, admin}
assert set(event4.get_users_with_any_permission()) == {admin}
assert set(event.get_users_with_permission('can_change_event_settings')) == {admin}
assert set(event2.get_users_with_permission('can_change_event_settings')) == {admin}
assert set(event3.get_users_with_permission('can_change_event_settings')) == {user, admin}
assert set(event4.get_users_with_permission('can_change_event_settings')) == {admin}
assert set(event.get_users_with_permission('can_change_orders')) == {admin, user}

View File

@@ -1,13 +1,14 @@
import time
import pytest
from django.utils.timezone import now
from django_otp.oath import TOTP
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice
from tests.base import SoupTest, extract_form_fields
from u2flib_server.jsapi import JSONDict
from pretix.base.models import U2FDevice, User
from pretix.base.models import Event, Organizer, U2FDevice, User
from pretix.testutils.mock import mocker_context
@@ -277,3 +278,123 @@ class UserSettings2FATest(SoupTest):
assert 'alert-success' in r.rendered_content
m.undo()
class UserSettingsNotificationsTest(SoupTest):
def setUp(self):
super().setUp()
self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
self.client.login(email='dummy@dummy.dummy', password='dummy')
o = Organizer.objects.create(name='Dummy', slug='dummy')
self.event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer'
)
t = o.teams.create(can_change_orders=True, all_events=True)
t.members.add(self.user)
def test_toggle_all(self):
assert self.user.notifications_send
self.client.post('/control/settings/notifications/', {
'notifications_send': 'off'
})
self.user.refresh_from_db()
assert not self.user.notifications_send
self.client.post('/control/settings/notifications/', {
'notifications_send': 'on'
})
self.user.refresh_from_db()
assert self.user.notifications_send
def test_global_enable(self):
self.client.post('/control/settings/notifications/', {
'mail:pretix.event.order.placed': 'on'
})
assert self.user.notification_settings.get(
event__isnull=True, method='mail', action_type='pretix.event.order.placed'
).enabled is True
def test_global_disable(self):
self.user.notification_settings.create(
event=None, method='mail', action_type='pretix.event.order.placed', enabled=True
)
self.client.post('/control/settings/notifications/', {
'mail:pretix.event.order.placed': 'off'
})
assert self.user.notification_settings.get(
event__isnull=True, method='mail', action_type='pretix.event.order.placed'
).enabled is False
def test_event_enabled_disable(self):
self.user.notification_settings.create(
event=self.event, method='mail', action_type='pretix.event.order.placed', enabled=True
)
self.client.post('/control/settings/notifications/?event={}'.format(self.event.pk), {
'mail:pretix.event.order.placed': 'off'
})
assert self.user.notification_settings.get(
event=self.event, method='mail', action_type='pretix.event.order.placed'
).enabled is False
def test_event_global_disable(self):
self.client.post('/control/settings/notifications/?event={}'.format(self.event.pk), {
'mail:pretix.event.order.placed': 'off'
})
assert self.user.notification_settings.get(
event=self.event, method='mail', action_type='pretix.event.order.placed'
).enabled is False
def test_event_disabled_enable(self):
self.user.notification_settings.create(
event=self.event, method='mail', action_type='pretix.event.order.placed', enabled=False
)
self.client.post('/control/settings/notifications/?event={}'.format(self.event.pk), {
'mail:pretix.event.order.placed': 'on'
})
assert self.user.notification_settings.get(
event=self.event, method='mail', action_type='pretix.event.order.placed'
).enabled is True
def test_event_global_enable(self):
self.client.post('/control/settings/notifications/?event={}'.format(self.event.pk), {
'mail:pretix.event.order.placed': 'on'
})
assert self.user.notification_settings.get(
event=self.event, method='mail', action_type='pretix.event.order.placed'
).enabled is True
def test_event_enabled_global(self):
self.user.notification_settings.create(
event=self.event, method='mail', action_type='pretix.event.order.placed', enabled=True
)
self.client.post('/control/settings/notifications/?event={}'.format(self.event.pk), {
'mail:pretix.event.order.placed': 'global'
})
assert not self.user.notification_settings.filter(
event=self.event, method='mail', action_type='pretix.event.order.placed'
).exists()
def test_event_disabled_global(self):
self.user.notification_settings.create(
event=self.event, method='mail', action_type='pretix.event.order.placed', enabled=False
)
self.client.post('/control/settings/notifications/?event={}'.format(self.event.pk), {
'mail:pretix.event.order.placed': 'global'
})
assert not self.user.notification_settings.filter(
event=self.event, method='mail', action_type='pretix.event.order.placed'
).exists()
def test_disable_all_via_link(self):
assert self.user.notifications_send
self.client.get('/control/settings/notifications/off/{}/{}/'.format(self.user.pk, self.user.notifications_token))
self.user.refresh_from_db()
assert not self.user.notifications_send
def test_disable_all_via_link_anonymous(self):
self.client.logout()
assert self.user.notifications_send
self.client.get('/control/settings/notifications/off/{}/{}/'.format(self.user.pk, self.user.notifications_token))
self.user.refresh_from_db()
assert not self.user.notifications_send