diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index b5fefc85a..bf40e7fea 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -11,7 +11,7 @@ Core ---- .. automodule:: pretix.base.signals - :members: periodic_task, event_live_issues, event_copy_data, email_filter + :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types Order events """""""""""" diff --git a/doc/development/implementation/logging.rst b/doc/development/implementation/logging.rst index 58b908b84..63001cd4f 100644 --- a/doc/development/implementation/logging.rst +++ b/doc/development/implementation/logging.rst @@ -1,5 +1,5 @@ -Logging -======= +Logging and notifications +========================= As pretix is handling monetary transactions, we are very careful to make it possible to review all changes in the system that lead to the current state. @@ -81,6 +81,61 @@ implementation could look like:: if logentry.action_type in plains: return plains[logentry.action_type] +Sending notifications +--------------------- + +If you think that the logged information might be important or urgent enough to send out a notification to interested +organizers. In this case, you should listen for the :py:attr:`pretix.base.signals.register_notification_types` signal +to register a notification type:: + + @receiver(register_notification_types) + def register_my_notification_types(sender, **kwargs): + return [MyNotificationType(sender)] + +Note that this event is different than other events send out by pretix: ``sender`` may be an event or ``None``. The +latter case is required to let the user define global notification preferences for all events. + +You also need to implement a custom class that specifies how notifications should be handled for your notification type. +You should subclass the base ``NotificationType`` class and implement all its members: + +.. autoclass:: pretix.base.notifications.NotificationType + :members: action_type, verbose_name, required_permission, build_notification + +A simple implementation could look like this:: + + class MyNotificationType(NotificationType): + required_permission = "can_view_orders" + action_type = "pretix.event.order.paid" + verbose_name = _("Order has been paid") + + 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=_('Order {code} has been marked as paid').format(code=order.code), + url=order_url + ) + n.add_attribute(_('Order code'), order.code) + n.add_action(_('View order details'), order_url) + return n + +As you can see, the relevant code is in the ``build_notification`` method that is supposed to create a ``Notification`` +method that has a title, description, URL, attributes, and actions. The full definition of ``Notification`` is the +following: + +.. autoclass:: pretix.base.notifications.Notification + :members: add_action, add_attribute + Logging technical information ----------------------------- diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index 03ec897f8..c66b47001 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -51,6 +51,7 @@ namespaces namespacing natively nginx +NotificationType ons optimizations param diff --git a/src/pretix/base/migrations/0078_auto_20171206_1603.py b/src/pretix/base/migrations/0078_auto_20171206_1603.py new file mode 100644 index 000000000..88f449494 --- /dev/null +++ b/src/pretix/base/migrations/0078_auto_20171206_1603.py @@ -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), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index ec991ab4d..7b465f59f 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -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, diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index 9ef68596c..5c956d383 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -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() diff --git a/src/pretix/base/models/base.py b/src/pretix/base/models/base.py index 3cf4df243..9aaf8dfe5 100644 --- a/src/pretix/base/models/base.py +++ b/src/pretix/base/models/base.py @@ -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): diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 32859c521..aa005b59b 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -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): """ diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index 0241f8987..023cbd8b8 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -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',) diff --git a/src/pretix/base/models/notifications.py b/src/pretix/base/models/notifications.py new file mode 100644 index 000000000..73905cc63 --- /dev/null +++ b/src/pretix/base/models/notifications.py @@ -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') diff --git a/src/pretix/base/notifications.py b/src/pretix/base/notifications.py new file mode 100644 index 000000000..8e937849e --- /dev/null +++ b/src/pretix/base/notifications.py @@ -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 ''.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, + ) + ) diff --git a/src/pretix/base/services/notifications.py b/src/pretix/base/services/notifications.py new file mode 100644 index 000000000..80c002af5 --- /dev/null +++ b/src/pretix/base/services/notifications.py @@ -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': {}, + }) diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 06879f5b0..7f4bfea13 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -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=[] ) diff --git a/src/pretix/base/templates/pretixbase/email/base.html b/src/pretix/base/templates/pretixbase/email/base.html new file mode 100644 index 000000000..a7cfd2569 --- /dev/null +++ b/src/pretix/base/templates/pretixbase/email/base.html @@ -0,0 +1,167 @@ +{% load eventurl %} +{% load i18n %} + + + + + + + + + + + + + {% block content %} + {% endblock %} + + + +
+ {% if event %} +

{{ event.name }}

+ {% else %} +

{{ site }}

+ {% endif %} +
+
+
+ + diff --git a/src/pretix/base/templates/pretixbase/email/notification.html b/src/pretix/base/templates/pretixbase/email/notification.html new file mode 100644 index 000000000..b0b48ecd2 --- /dev/null +++ b/src/pretix/base/templates/pretixbase/email/notification.html @@ -0,0 +1,53 @@ +{% extends "pretixbase/email/base.html" %} +{% load eventurl %} +{% load i18n %} +{% block content %} + + +
+

+ {% if notification.url %}{% endif %} + {{ notification.title }} + {% if notification.url %}{% endif %} +

+ {% if notification.detail %} +

{{ notification.detail }}

+ {% endif %} + {% if notification.attributes %} + + {% for attr in notification.attributes %} + + + + + {% endfor %} +
+ {{ attr.title }} + + {{ attr.value }} +
+ {% endif %} + {% if notification.actions %} +

+ {% for action in notification.actions %} + {{ action.label }} + {% endfor %} +

+ {% endif %} +
+ + + + +
+ {% trans "You receive these emails based on your notification settings." %}
+ + {% trans "Click here to view and change your notification settings" %} +
+ + {% trans "Click here disable all notifications immediately." %} + +
+ + +{% endblock %} diff --git a/src/pretix/base/templates/pretixbase/email/notification.txt b/src/pretix/base/templates/pretixbase/email/notification.txt new file mode 100644 index 000000000..6f9e8ffc2 --- /dev/null +++ b/src/pretix/base/templates/pretixbase/email/notification.txt @@ -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 }} diff --git a/src/pretix/base/templates/pretixbase/email/plainwrapper.html b/src/pretix/base/templates/pretixbase/email/plainwrapper.html index e67d4b86c..db8dc8eb4 100644 --- a/src/pretix/base/templates/pretixbase/email/plainwrapper.html +++ b/src/pretix/base/templates/pretixbase/email/plainwrapper.html @@ -1,174 +1,42 @@ +{% extends "pretixbase/email/base.html" %} {% load eventurl %} {% load i18n %} - - - - - - - - - +{% block content %} + + + + {% if order %} - + - - {% if order %} - - - - - - - {% endif %} + {% endif %} {% if signature %} - - - - - - - {% endif %} - + + + -
+
+ {{ body|safe }} +
+
- {% if event %} -

{{ event.name }}

- {% else %} -

{{ site }}

- {% endif %} -
+
- {{ body|safe }} + {% trans "You are receiving this email because you placed an order for the following event:" %}
+ {% trans "Event:" %} {{ event.name }}
+ {% trans "Order code:" %} {{ order.code }}
+ {% trans "Order date:" %} {{ order.datetime|date:"SHORT_DATE_FORMAT" }}
+ + {% trans "View order details" %} +
-
- {% trans "You are receiving this email because you placed an order for the following event:" %}
- {% trans "Event:" %} {{ event.name }}
- {% trans "Order code:" %} {{ order.code }}
- {% trans "Order date:" %} {{ order.datetime|date:"SHORT_DATE_FORMAT" }}
- - {% trans "View order details" %} - -
-
-
- {{ signature | safe }} -
-
+
+ {{ signature | safe }}
-
-
- - + {% endif %} +{% endblock %} diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 938269256..c7ba7104c 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -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.'), diff --git a/src/pretix/control/middleware.py b/src/pretix/control/middleware.py index d1b7dc22e..90da3cb95 100644 --- a/src/pretix/control/middleware.py +++ b/src/pretix/control/middleware.py @@ -27,6 +27,7 @@ class PermissionMiddleware(MiddlewareMixin): "auth.forgot", "auth.forgot.recover", "auth.invite", + "user.settings.notifications.off", ) def _login_redirect(self, request): diff --git a/src/pretix/control/templates/pretixcontrol/user/notifications.html b/src/pretix/control/templates/pretixcontrol/user/notifications.html new file mode 100644 index 000000000..5529e3098 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/user/notifications.html @@ -0,0 +1,89 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Notification settings" %}{% endblock %} +{% block content %} +

{% trans "Notification settings" %}

+
+ {% csrf_token %} +
+ {% if request.user.notifications_send %} +
+ + {% trans "Notifications are turned on according to the settings below." %} +
+
+ {% else %} +
+ + {% trans "All notifications are turned off globally." %} +
+
+ {% endif %} +
+
+
+
+ {% trans "Choose event" %} +

+ + + {% trans "Save your modifications before switching events." %} +

+
+
+
+ {% csrf_token %} +
+ {% trans "Choose notifications to get" %} + + + + + + + + + {% for type, enabled, global in types %} + + + + + {% endfor %} + +
{% trans "Notification type" %}{% trans "E-Mail notification" %}
+ {{ type.verbose_name }} + + {% if not event or type.required_permission in permset %} + + {% else %} + + {% endif %} +
+
+
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/user/settings.html b/src/pretix/control/templates/pretixcontrol/user/settings.html index c420476d8..cbb155f3f 100644 --- a/src/pretix/control/templates/pretixcontrol/user/settings.html +++ b/src/pretix/control/templates/pretixcontrol/user/settings.html @@ -12,6 +12,24 @@ {% bootstrap_field form.fullname layout='horizontal' %} {% bootstrap_field form.locale layout='horizontal' %} {% bootstrap_field form.timezone layout='horizontal' %} +
+ +
+ {% if request.user.notifications_send and request.user.notification_settings.exists %} + + {% trans "On" %} + + {% else %} + + {% trans "Off" %} + + {% endif %} +   + + {% trans "Change notification settings" %} + +
+
{% trans "Login settings" %} @@ -23,12 +41,12 @@
{% if user.require_2fa %} - {% trans "Enabled" %} + {% trans "Enabled" %}   {% trans "Change two-factor settings" %} {% else %} - {% trans "Disabled" %} + {% trans "Disabled" %}   {% trans "Enable" %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index cf1de1e01..3066f5767 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -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\d+)/(?P[^/]+)/$', 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'), diff --git a/src/pretix/control/views/user.py b/src/pretix/control/views/user.py index b8a8d12bf..6119bf2ae 100644 --- a/src/pretix/control/views/user.py +++ b/src/pretix/control/views/user.py @@ -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 diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index c6045098d..bc1fda08e 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -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; +} \ No newline at end of file diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index 7f32b8e4b..c2dcb7bb6 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -121,6 +121,9 @@ h1 .btn-sm { padding-top: 20px; } +.helper-display-block { + display: block !important; +} .helper-display-inline { display: inline !important; } diff --git a/src/tests/base/test_notifications.py b/src/tests/base/test_notifications.py new file mode 100644 index 000000000..762bc7a5f --- /dev/null +++ b/src/tests/base/test_notifications.py @@ -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 diff --git a/src/tests/base/test_permissions.py b/src/tests/base/test_permissions.py index 330bf0c44..e988aefda 100644 --- a/src/tests/base/test_permissions.py +++ b/src/tests/base/test_permissions.py @@ -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} diff --git a/src/tests/control/test_user.py b/src/tests/control/test_user.py index 97e37b5a7..d54a47eba 100644 --- a/src/tests/control/test_user.py +++ b/src/tests/control/test_user.py @@ -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