From ec68ca6a5459c149a801550a2842b33d475145c0 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 7 Aug 2026 18:54:44 +0200 Subject: [PATCH] Organizer-level notifications --- src/pretix/base/models/organizer.py | 21 ++++++++- src/pretix/base/notifications.py | 43 ++++++++++++----- src/pretix/base/services/notifications.py | 57 +++++++++++++++-------- src/pretix/base/signals.py | 7 ++- src/pretix/control/views/user.py | 1 + 5 files changed, 94 insertions(+), 35 deletions(-) diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index 37608d3da..20c22e678 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -42,7 +42,7 @@ from django.conf import settings from django.core.mail import get_connection from django.core.validators import MinLengthValidator, RegexValidator from django.db import models -from django.db.models import Q +from django.db.models import Exists, OuterRef, Q from django.urls import reverse from django.utils.crypto import get_random_string from django.utils.functional import cached_property @@ -312,6 +312,25 @@ class Organizer(LoggedModel): ) i += 1 + def get_users_with_permission(self, permission): + """ + Returns a queryset of users who have a specific permission to this organizer. + + :return: Iterable of User + """ + from .auth import User + + if permission: + qs = Team.objects.with_organizer_permission(permission) + else: + qs = Team.objects.all() + + team_with_perm = qs.filter( + members__pk=OuterRef('pk'), + organizer=self, + ) + return User.objects.annotate(twp=Exists(team_with_perm)).filter(twp=True) + def generate_invite_token(): return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) diff --git a/src/pretix/base/notifications.py b/src/pretix/base/notifications.py index b25430476..eccf7f5be 100644 --- a/src/pretix/base/notifications.py +++ b/src/pretix/base/notifications.py @@ -35,12 +35,13 @@ import logging from collections import OrderedDict, namedtuple from itertools import groupby +from typing import Union from django.dispatch import receiver from django.utils.formats import date_format from django.utils.translation import gettext_lazy as _, pgettext_lazy -from pretix.base.models import Event, LogEntry +from pretix.base.models import Event, LogEntry, Organizer from pretix.base.signals import register_notification_types from pretix.base.templatetags.money import money_filter from pretix.helpers.urls import mainreverse_absolute @@ -57,7 +58,8 @@ class Notification: """ Represents a notification that is sent/shown to a user. A notification consists of: - * one ``event`` reference + * one ``event`` reference (can be ``Ǹone``) + * one ``organizer`` 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) @@ -67,8 +69,10 @@ class Notification: 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): + def __init__(self, event: Event, title: str, detail: str=None, url: str=None, organizer: Organizer=None): + assert event or organizer self.title = title + self.organizer = organizer or event.organizer self.event = event self.detail = detail self.url = url @@ -91,8 +95,16 @@ class Notification: class NotificationType: - def __init__(self, event: Event = None): - self.event = event + def __init__(self, event_or_organizer: Union[Event, Organizer] = None): + if isinstance(event_or_organizer, Event): + self.event = event_or_organizer + self.organizer = event_or_organizer.organizer + elif isinstance(event_or_organizer, Organizer): + self.event = None + self.organizer = event_or_organizer + else: + self.event = None + self.organizer = None def __repr__(self): return ''.format(self.action_type) @@ -121,31 +133,39 @@ class NotificationType: """ raise NotImplementedError() # NOQA + @property + def is_event_level(self) -> bool: + """ + Return `True` if this notification type can be configured per event (the default). + """ + return True + 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() + event=logentry.event, + title=logentry.display(), + organizer=logentry.organizer, ) -def get_all_notification_types(event=None): +def get_all_notification_types(event_or_organizer=None): global _ALL_TYPES - if event is None and _ALL_TYPES: + if event_or_organizer is None and _ALL_TYPES: return _ALL_TYPES types = OrderedDict() - for recv, ret in register_notification_types.send(event): + for recv, ret in register_notification_types.send(event_or_organizer): if isinstance(ret, (list, tuple)): for r in ret: types[r.action_type] = r else: types[ret.action_type] = ret - if event is None: + if event_or_organizer is None: _ALL_TYPES = types return types @@ -181,6 +201,7 @@ class ParametrizedOrderNotificationType(NotificationType): n = Notification( event=logentry.event, + organizer=logentry.organizer, title=self._title.format(order=order, event=logentry.event), url=order_url ) diff --git a/src/pretix/base/services/notifications.py b/src/pretix/base/services/notifications.py index 950e111c6..079e13e70 100644 --- a/src/pretix/base/services/notifications.py +++ b/src/pretix/base/services/notifications.py @@ -47,14 +47,14 @@ def notify(logentry_ids: list): logentry_ids = [logentry_ids] qs = LogEntry.all.select_related( - 'event', 'event__organizer' + 'event', 'event__organizer', 'organizer' ).order_by( - 'action_type', 'event_id', + 'action_type', 'event_id', 'organizer_id', ).filter(id__in=logentry_ids) - _event, _at, notify_specific, notify_global = None, None, None, None + _event, _organizer, _at, notify_specific, notify_global = None, None, None, None, None for logentry in qs: - if not logentry.event: + if not logentry.event and not logentry.organizer: break # Ignore, we only have event-related notifications right now notification_type = logentry.notification_type @@ -62,25 +62,36 @@ def notify(logentry_ids: list): if not notification_type: break # No suitable plugin - if _event != logentry.event or _at != logentry.action_type or notify_global is None: + if _event != logentry.event or _organizer != logentry.organizer or _at != logentry.action_type or notify_global is None: _event = logentry.event + _organizer = logentry.organizer _at = logentry.action_type - # 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, is_active=True) + + if logentry.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, is_active=True) + else: + users = logentry.organizer.get_users_with_permission( + notification_type.required_permission + ).filter(notifications_send=True, is_active=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=notification_type.action_type, - user__pk__in=users.values_list('pk', flat=True) - ) - } + if logentry.event: + notify_specific = { + (ns.user, ns.method): ns.enabled + for ns in NotificationSetting.objects.filter( + event=logentry.event, + action_type=notification_type.action_type, + user__pk__in=users.values_list('pk', flat=True) + ) + } + else: + notify_specific = {} notify_global = { (ns.user, ns.method): ns.enabled for ns in NotificationSetting.objects.filter( @@ -106,7 +117,9 @@ def notify(logentry_ids: list): priority=get_task_priority("notifications", logentry.organizer_id), ) - notification.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type) + if logentry.event: + # FIXME: Signal is currently event-only + notification.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type) @app.task(base=ProfiledTask, acks_late=True, max_retries=9, default_retry_delay=900) @@ -158,13 +171,19 @@ def send_notification_mail(notification: Notification, user: User): body_plain = tpl_plain.render(ctx) guid = uuid.uuid4() + settings_holder = notification.event or notification.organizer + prefix = settings_holder.settings.mail_prefix + if not prefix and notification.event: + prefix = notification.event.slug.upper() + elif notification.organizer: + prefix = notification.organizer.name m = OutgoingMail.objects.create( guid=guid, user=user, to=[user.email], subject='[{}] {}: {}'.format( settings.PRETIX_INSTANCE_NAME, - notification.event.settings.mail_prefix or notification.event.slug.upper(), + prefix, notification.title ), body_plain=body_plain, diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index a9d1c3f6e..eda0a8a18 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -605,15 +605,14 @@ subclass of pretix.base.ticketoutput.BaseTicketOutput As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ -register_notification_types = EventPluginSignal() +register_notification_types = GlobalSignal() """ 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! +When called for actually sending notifications, ``sender`` will be the event or organizer, +depending on context. """ register_event_permission_groups = GlobalSignal() diff --git a/src/pretix/control/views/user.py b/src/pretix/control/views/user.py index 2d33b4180..ebd5d1035 100644 --- a/src/pretix/control/views/user.py +++ b/src/pretix/control/views/user.py @@ -771,6 +771,7 @@ class UserNotificationsEditView(TemplateView): {k: a.get(t) for k, a in self.global_set.items()}, ) for t, tv in self.types.items() + if tv.is_event_level or not self.event ] ctx['event'] = self.event if self.event: