Organizer-level plugins (#5305)

* Add version notes to the docs

* Adapt signal handling

* Add UI

* Add API

* API and tests

* Fix registry

* Update doc/development/api/plugins.rst

Co-authored-by: Felix Rindt <felix@rindt.me>

* Fix failing tests

* Apply suggestions from code review

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Update src/pretix/control/templates/pretixcontrol/organizers/plugin_events.html

Co-authored-by: luelista <weller@rami.io>

* Update src/pretix/control/templates/pretixcontrol/organizers/plugins.html

Co-authored-by: luelista <weller@rami.io>

* Update src/pretix/control/templates/pretixcontrol/organizers/plugins.html

Co-authored-by: luelista <weller@rami.io>

* Update src/pretix/control/navigation.py

Co-authored-by: luelista <weller@rami.io>

* Update src/pretix/control/urls.py

Co-authored-by: luelista <weller@rami.io>

* Apply suggestion from @wiffbi

* REbase migration

* Fix review note

* Fix test cases

* Remove plugin from all events if disabled on org level

* Update doc/development/api/plugins.rst

* Unify registries

* Rebase migration

---------

Co-authored-by: Felix Rindt <felix@rindt.me>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: luelista <weller@rami.io>
This commit is contained in:
Raphael Michel
2025-08-19 11:33:34 +02:00
committed by GitHub
parent 56964b6764
commit a51a6123f5
50 changed files with 1623 additions and 192 deletions

View File

@@ -33,16 +33,23 @@
# License for the specific language governing permissions and limitations under the License.
import warnings
from typing import Any, Callable, List, Tuple
from typing import Any, Callable, Generic, List, Tuple, TypeVar
import django.dispatch
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.dispatch.dispatcher import NO_RECEIVERS
from .models import Event
from .models.event import Event
from .models.organizer import Organizer
from .plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
)
app_cache = {}
T = TypeVar('T')
def _populate_app_cache():
@@ -56,6 +63,9 @@ def get_defining_app(o):
if "sentry" in o.__module__:
o = o.__wrapped__
if hasattr(o, "__mocked_app"):
return o.__mocked_app
# Find the Django application this belongs to
searchpath = o.__module__
@@ -74,43 +84,71 @@ def get_defining_app(o):
return app
def is_app_active(sender, app):
def is_app_active(sender, app, allow_legacy_plugins=False):
if app == 'CORE':
return True
excluded = settings.PRETIX_PLUGINS_EXCLUDE
if sender and app and app.name in sender.get_plugins() and app.name not in excluded:
if not sender or not app or app.name in excluded:
return False
level = getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if isinstance(sender, Event):
enabled = app.name in sender.get_plugins()
elif isinstance(sender, Organizer) and allow_legacy_plugins:
# Deprecated behaviour: Event plugins that are registered on organizer level are considered active for
# all organizers in the context of signals that used to be global signals before the introduction of
# organizer-level plugin. A deprecation warning is emitted at .connect() time.
enabled = True
else:
raise ImproperlyConfigured(f"Cannot check if event-level plugin is active on {type(sender)}")
elif level == PLUGIN_LEVEL_ORGANIZER:
if isinstance(sender, Organizer):
enabled = app.name in sender.get_plugins()
elif isinstance(sender, Event):
enabled = app.name in sender.organizer.get_plugins()
else:
raise ImproperlyConfigured(f"Cannot check if organizer-level plugin is active on {type(sender)}")
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
if isinstance(sender, Organizer):
enabled = app.name in sender.get_plugins()
elif isinstance(sender, Event):
enabled = app.name in sender.get_plugins() and app.name in sender.organizer.get_plugins()
else:
raise ImproperlyConfigured(f"Cannot check if hybrid event/organizer-level plugin is active on {type(sender)}")
else:
raise ImproperlyConfigured("Unknown plugin level")
if enabled:
if not hasattr(app, 'compatibility_errors') or not app.compatibility_errors:
return True
return False
def is_receiver_active(sender, receiver):
def is_receiver_active(sender, receiver, allow_legacy_plugins=False):
if sender is None:
# Send to all events!
return True
app = get_defining_app(receiver)
return is_app_active(sender, app)
return is_app_active(sender, app, allow_legacy_plugins)
class EventPluginSignal(django.dispatch.Signal):
"""
This is an extension to Django's built-in signals which differs in a way that it sends
out it's events only to receivers which belong to plugins that are enabled for the given
Event.
"""
class PluginSignal(Generic[T], django.dispatch.Signal):
type = None
def send(self, sender: Event, **named) -> List[Tuple[Callable, Any]]:
def _is_receiver_active(self, sender, receiver):
return is_receiver_active(sender, receiver)
def send(self, sender: T, **named) -> List[Tuple[Callable, Any]]:
"""
Send signal from sender to all connected receivers that belong to
plugins enabled for the given Event.
sender is required to be an instance of ``pretix.base.models.Event``.
plugins enabled for the given event / organizer.
"""
if sender and not isinstance(sender, Event):
raise ValueError("Sender needs to be an event.")
if sender and not isinstance(sender, self.type):
raise ValueError(f"Sender needs to be of type {self.type}.")
responses = []
if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS:
@@ -120,20 +158,18 @@ class EventPluginSignal(django.dispatch.Signal):
_populate_app_cache()
for receiver in self._sorted_receivers(sender):
if is_receiver_active(sender, receiver):
if self._is_receiver_active(sender, receiver):
response = receiver(signal=self, sender=sender, **named)
responses.append((receiver, response))
return responses
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
def send_chained(self, sender: T, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
"""
Send signal from sender to all connected receivers. The return value of the first receiver
will be used as the keyword argument specified by ``chain_kwarg_name`` in the input to the
second receiver and so on. The return value of the last receiver is returned by this method.
sender is required to be an instance of ``pretix.base.models.Event``.
"""
if sender and not isinstance(sender, Event):
if sender and not isinstance(sender, self.type):
raise ValueError("Sender needs to be an event.")
response = named.get(chain_kwarg_name)
@@ -144,20 +180,18 @@ class EventPluginSignal(django.dispatch.Signal):
_populate_app_cache()
for receiver in self._sorted_receivers(sender):
if is_receiver_active(sender, receiver):
if self._is_receiver_active(sender, receiver):
named[chain_kwarg_name] = response
response = receiver(signal=self, sender=sender, **named)
return response
def send_robust(self, sender: Event, **named) -> List[Tuple[Callable, Any]]:
def send_robust(self, sender: T, **named) -> List[Tuple[Callable, Any]]:
"""
Send signal from sender to all connected receivers. If a receiver raises an exception
instead of returning a value, the exception is included as the result instead of
stopping the response chain at the offending receiver.
sender is required to be an instance of ``pretix.base.models.Event``.
"""
if sender and not isinstance(sender, Event):
if sender and not isinstance(sender, self.type):
raise ValueError("Sender needs to be an event.")
responses = []
@@ -171,7 +205,7 @@ class EventPluginSignal(django.dispatch.Signal):
_populate_app_cache()
for receiver in self._sorted_receivers(sender):
if is_receiver_active(sender, receiver):
if self._is_receiver_active(sender, receiver):
try:
response = receiver(signal=self, sender=sender, **named)
except Exception as err:
@@ -193,6 +227,67 @@ class EventPluginSignal(django.dispatch.Signal):
return sorted_list
class EventPluginSignal(PluginSignal[Event]):
"""
This is an extension to Django's built-in signals which differs in a way that it sends
out it's events only to receivers which belong to plugins that are enabled for the given
Event.
"""
type = Event
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
app = get_defining_app(receiver)
if app != "CORE":
if not hasattr(app, "PretixPluginMeta"):
raise ImproperlyConfigured(
f"{app} uses an EventPluginSignal but is not a pretix plugin"
)
allowed_levels = (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_EVENT)
if getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT) not in allowed_levels:
# This check is redundant for now, but will be useful if we ever add other levels
raise ImproperlyConfigured(
f"{app} uses an EventPluginSignal but is not a plugin that can be active on event or organizer level"
)
return super().connect(receiver, sender, weak, dispatch_uid)
class OrganizerPluginSignal(PluginSignal[Organizer]):
"""
This is an extension to Django's built-in signals which differs in a way that it sends
out it's events only to receivers which belong to plugins that are enabled for the given
Organizer.
"""
type = Organizer
def __init__(self, allow_legacy_plugins=False):
self.allow_legacy_plugins = allow_legacy_plugins
super().__init__()
def _is_receiver_active(self, sender, receiver):
return is_receiver_active(sender, receiver, allow_legacy_plugins=self.allow_legacy_plugins)
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
app = get_defining_app(receiver)
if app != "CORE":
if not hasattr(app, "PretixPluginMeta"):
raise ImproperlyConfigured(
f"{app} uses an OrganizerPluginSignal but is not a pretix plugin"
)
allowed_levels = (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
if getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT) not in allowed_levels:
if getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT) == PLUGIN_LEVEL_EVENT and self.allow_legacy_plugins:
warnings.warn(
'This signal will soon be only available for plugins that declare to be organizer-level',
stacklevel=3,
category=DeprecationWarning,
)
else:
raise ImproperlyConfigured(
f"{app} uses an OrganizerPluginSignal but is not a plugin that can be active on organizer level"
)
return super().connect(receiver, sender, weak, dispatch_uid)
class GlobalSignal(django.dispatch.Signal):
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
"""
@@ -211,10 +306,14 @@ class GlobalSignal(django.dispatch.Signal):
return response
class DeprecatedSignal(django.dispatch.Signal):
class DeprecatedSignal(GlobalSignal):
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
warnings.warn('This signal is deprecated and will soon be removed', stacklevel=3)
warnings.warn(
'This signal is deprecated and will soon be removed',
stacklevel=3,
category=DeprecationWarning,
)
super().connect(receiver, sender=None, weak=True, dispatch_uid=None)
@@ -324,20 +423,39 @@ class Registry:
)
class EventPluginRegistry(Registry):
class PluginAwareRegistry(Registry):
"""
A Registry which automatically annotates entries with a "plugin" key, specifying which plugin
the entry is defined in. This allows the consumer of entries to determine whether an entry is
enabled for a given event, or filter only for entries defined by enabled plugins.
enabled for a given event or organizer, or filter only for entries defined by enabled plugins.
.. code-block:: python
logtype, meta = my_registry.find(action_type="foo.bar.baz")
# meta["plugin"] contains the django app name of the defining plugin
"""
allowed_levels = [
PLUGIN_LEVEL_EVENT,
PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
]
def __init__(self, keys):
super().__init__({"plugin": lambda o: get_defining_app(o), **keys})
def get_plugin(o):
app = get_defining_app(o)
if app != "CORE":
if not hasattr(app, "PretixPluginMeta"):
raise ImproperlyConfigured(
f"{app} uses an PluginAwareRegistry but is not a pretix plugin"
)
level = getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT)
if level not in self.allowed_levels:
raise ImproperlyConfigured(
f"{app} has level {level} but should have one of {self.allowed_levels} to use this registry"
)
return app
super().__init__({"plugin": get_plugin, **keys})
def filter(self, active_in=None, **kwargs):
result = super().filter(**kwargs)
@@ -357,6 +475,9 @@ class EventPluginRegistry(Registry):
return item, meta
EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
event_live_issues = EventPluginSignal()
"""
This signal is sent out to determine whether an event can be taken live. If you want to
@@ -449,7 +570,7 @@ This signal is sent out when a notification is sent.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_sales_channel_types = django.dispatch.Signal()
register_sales_channel_types = GlobalSignal()
"""
This signal is sent out to get all known sales channels types. Receivers should return an
instance of a subclass of ``pretix.base.channels.SalesChannelType`` or a list of such
@@ -467,10 +588,8 @@ subclass of pretix.base.exporter.BaseExporter
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_multievent_data_exporters = django.dispatch.Signal()
register_multievent_data_exporters = OrganizerPluginSignal(allow_legacy_plugins=True)
"""
Arguments: ``event``
This signal is sent out to get all known data exporters, which support exporting data for
multiple events. Receivers should return a subclass of pretix.base.exporter.BaseExporter
@@ -742,7 +861,7 @@ The ``sender`` keyword argument will contain the event. The ``target`` will cont
copy to, the ``source`` keyword argument will contain the product to **copy from**.
"""
periodic_task = django.dispatch.Signal()
periodic_task = GlobalSignal()
"""
This is a regular django signal (no pretix event signal) that we send out every
time the periodic task cronjob runs. This interval is not sharply defined, it can
@@ -751,13 +870,13 @@ idempotent, i.e. it should not make a difference if this is sent out more often
than expected.
"""
register_global_settings = django.dispatch.Signal()
register_global_settings = GlobalSignal()
"""
All plugins that are installed may send fields for the global settings form, as
an OrderedDict of (setting name, form field).
"""
gift_card_transaction_display = django.dispatch.Signal()
gift_card_transaction_display = GlobalSignal() # todo: replace with OrganizerPluginSignal?
"""
Arguments: ``transaction``, ``customer_facing``
@@ -969,7 +1088,7 @@ return a dictionary mapping names of attributes in the settings store to DRF ser
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
customer_created = GlobalSignal()
customer_created = OrganizerPluginSignal(allow_legacy_plugins=True)
"""
Arguments: ``customer``
@@ -979,7 +1098,7 @@ object is given as the first argument.
The ``sender`` keyword argument will contain the organizer.
"""
customer_signed_in = GlobalSignal()
customer_signed_in = OrganizerPluginSignal(allow_legacy_plugins=True)
"""
Arguments: ``customer``
@@ -989,7 +1108,7 @@ is given as the first argument.
The ``sender`` keyword argument will contain the organizer.
"""
device_info_updated = django.dispatch.Signal()
device_info_updated = GlobalSignal() # todo: replace with OrganizerPluginSignal?
"""
Arguments: ``old_device``, ``new_device``