forked from CGM_Public/pretix_original
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:
@@ -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``
|
||||
|
||||
|
||||
Reference in New Issue
Block a user