diff --git a/doc/development/api/plugins.rst b/doc/development/api/plugins.rst index 42a0354e6..ddc05d30f 100644 --- a/doc/development/api/plugins.rst +++ b/doc/development/api/plugins.rst @@ -121,6 +121,7 @@ This will automatically make pretix discover this plugin as soon as it is instal through ``pip``. During development, you can just run ``python setup.py develop`` inside your plugin source directory to make it discoverable. +.. _`Signals`: Signals ------- @@ -153,6 +154,28 @@ in the ``installed`` method: Note that ``installed`` will *not* be called if the plugin is indirectly activated for an event because the event is created with settings copied from another event. +.. _`Registries`: +Registries +---------- + +Many signals in pretix are used so that plugins can "register" a class, e.g. a payment provider or a +ticket renderer. + +However, for some of them (types of :ref:`Log Entries `) we use a different method to keep track of them: +In a ``Registry``, classes are collected at application startup, along with a unique key (in case +of LogEntryType, the action_type) as well as which plugin registered them. + +To register a class, you can use one of several decorator provided by the Registry object: + +.. code-block:: python + + @log_entry_types.new('my_pretix_plugin.some.action', _('Some action in My Pretix Plugin occured.')) + class MyPretixPluginLogEntryType(EventLogEntryType): + pass + +All files in which classes are registered need to be imported in the ``AppConfig.ready`` as explained +in `Signals`_ above. + Views ----- diff --git a/doc/development/implementation/logging.rst b/doc/development/implementation/logging.rst index 7202c098e..42af697ee 100644 --- a/doc/development/implementation/logging.rst +++ b/doc/development/implementation/logging.rst @@ -20,7 +20,8 @@ To actually log an action, you can just call the ``log_action`` method on your o .. code-block:: python - order.log_action('pretix.event.order.canceled', user=user, data={}) + order.log_action('pretix.event.order.comment', user=user, + data={"new_comment": "Hello, world."}) The positional ``action`` argument should represent the type of action and should be globally unique, we recommend to prefix it with your package name, e.g. ``paypal.payment.rejected``. The ``user`` argument is @@ -72,24 +73,87 @@ following ready-to-include template:: {% include "pretixcontrol/includes/logs.html" with obj=order %} We now need a way to translate the action codes like ``pretix.event.changed`` into human-readable -strings. The :py:attr:`pretix.base.signals.logentry_display` signals allows you to do so. A simple +strings. The :py:attr:`pretix.base.logentrytypes.log_entry_types` :ref:`Registry ` allows you to do so. A simple implementation could look like: .. code-block:: python from django.utils.translation import gettext as _ - from pretix.base.signals import logentry_display + from pretix.base.logentrytypes import log_entry_types - @receiver(signal=logentry_display) - def pretixcontrol_logentry_display(sender, logentry, **kwargs): - plains = { - 'pretix.event.order.paid': _('The order has been marked as paid.'), - 'pretix.event.order.refunded': _('The order has been refunded.'), - 'pretix.event.order.canceled': _('The order has been canceled.'), - ... - } - if logentry.action_type in plains: - return plains[logentry.action_type] + + @log_entry_types.new_from_dict({ + 'pretix.event.order.comment': _('The order\'s internal comment has been updated to: {new_comment}'), + 'pretix.event.order.paid': _('The order has been marked as paid.'), + # ... + }) + class CoreOrderLogEntryType(OrderLogEntryType): + pass + +Please note that you always need to define your own inherited LogEntryType class in your plugin. If you would just +register an instance of a LogEntryType class defined in pretix core, it is not correctly registered as belonging to +your plugin, leading to confusing user interface situations. + + +Customizing log entry display +"""""""""""""""""""""""""""""""""" + +The base LogEntryType classes allows for varying degree of customization in their descendants. + +If you want to add another log message for an existing core object (e.g. an Order, Item or Voucher), you can inherit +from its predefined LogEntryType, e.g. `OrderLogEntryType` and just specify a new plaintext string. You can use format +strings to insert information from the LogEntry's `data` object as shown in the section above. + +If you defined a new model object in your plugin, you should make sure proper object links in the user interface are +displayed for it. If your model object belongs logically to a pretix `Event`, you can inherit from `EventLogEntryType`, +and set the `object_link_*` fields accordingly. `object_link_viewname` refers to a django url name, which needs to +accept the arguments `organizer` and `event`, containing the respective slugs, and an argument named by `object_link_argname`. +The latter will contain the ID of the model object, if not customized by overriding `object_link_argvalue`. +If you want to customize the name displayed for the object (instead of the result of calling `str()` on it), +overwrite `object_link_display_name`. + +.. code-block:: python + + class OrderLogEntryType(EventLogEntryType): + object_link_wrapper = _('Order {val}') + object_link_viewname = 'control:event.order' + object_link_argname = 'code' + + def object_link_argvalue(self, order): + return order.code + + def object_link_display_name(self, order): + return order.code + +To show more sophisticated message strings, e.g. varying the message depending on information from the LogEntry's +`data` object, overwrite the `display` method: + +.. code-block:: python + + @log_entry_types.new() + class PaypalEventLogEntryType(EventLogEntryType): + action_type = 'pretix.plugins.paypal.event' + + def display(self, logentry): + event_type = logentry.parsed_data.get('event_type') + text = { + 'PAYMENT.SALE.COMPLETED': _('Payment completed.'), + 'PAYMENT.SALE.DENIED': _('Payment denied.'), + # ... + }.get(event_type, f"({event_type})") + return _('PayPal reported an event: {}').format(text) + +.. automethod:: pretix.base.logentrytypes.LogEntryType.display + +If your new model object does not belong to an `Event`, you need to implement + +meow + +.. autoclass:: pretix.base.logentrytypes.Registry + :members: new + +.. autoclass:: pretix.base.logentrytypes.LogEntryTypeRegistry + :members: new, new_from_dict Sending notifications --------------------- diff --git a/src/pretix/base/logentrytypes.py b/src/pretix/base/logentrytypes.py index 018d73432..7e0c40977 100644 --- a/src/pretix/base/logentrytypes.py +++ b/src/pretix/base/logentrytypes.py @@ -33,16 +33,45 @@ def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None): class LogEntryTypeRegistry(EventPluginRegistry): def new_from_dict(self, data): + """ + Register multiple instance of a LogEntryType class with different action_type + and plain text strings, as given by the items of the specified data dictionary. + + This method is designed to be used as a decorator as follows: + + .. code-block:: python + + @log_entry_types.new_from_dict({ + 'pretix.event.item.added': _('The product has been created.'), + 'pretix.event.item.changed': _('The product has been changed.'), + # ... + }) + class CoreItemLogEntryType(ItemLogEntryType): + # ... + + :param data: action types and descriptions + ``{"some_action_type": "Plain text description", ...}`` + """ def reg(clz): for action_type, plain in data.items(): self.register(clz(action_type=action_type, plain=plain)) return reg +""" +Registry for LogEntry types. + +Each entry in this registry should be an instance of a subclass of ``LogEntryType``. +They are annotated with their ``action_type`` and the defining ``plugin``. +""" log_entry_types = LogEntryTypeRegistry({'action_type': lambda o: getattr(o, 'action_type')}) class LogEntryType: + """ + Base class for a type of LogEntry, identified by its action_type. + """ + def __init__(self, action_type=None, plain=None): assert self.__module__ != LogEntryType.__module__ # must not instantiate base classes, only derived ones if action_type: @@ -51,6 +80,11 @@ class LogEntryType: self.plain = plain def display(self, logentry): + """ + Returns the message to be displayed for a given logentry of this type. + + :return: `str` or `LazyI18nString` + """ if hasattr(self, 'plain'): plain = str(self.plain) if '{' in plain: @@ -60,6 +94,14 @@ class LogEntryType: return plain def get_object_link_info(self, logentry) -> dict: + """ + Return information to generate a link to the content_object of a given logentry. + + Not implemented in the base class, causing the object link to be omitted. + + :return: `dict` with the keys `href` (containing a URL to view/edit the object) and `val` (containing the + escaped text for the anchor element) + """ pass def get_object_link(self, logentry): @@ -69,10 +111,18 @@ class LogEntryType: object_link_wrapper = '{val}' def shred_pii(self, logentry): + """ + To be used for shredding personally identified information contained in the data field of a LogEntry of this + type. + """ raise NotImplementedError class EventLogEntryType(LogEntryType): + """ + Base class for any LogEntry type whose content_object is either an `Event` itself or belongs to a specific `Event`. + """ + def get_object_link_info(self, logentry) -> dict: if hasattr(self, 'object_link_viewname') and hasattr(self, 'object_link_argname') and logentry.content_object: return { @@ -85,9 +135,11 @@ class EventLogEntryType(LogEntryType): } def object_link_argvalue(self, content_object): + """Return the identifier used in a link to content_object.""" return content_object.id def object_link_display_name(self, content_object): + """Return the display name to refer to content_object in the user interface.""" return str(content_object) @@ -108,8 +160,8 @@ class VoucherLogEntryType(EventLogEntryType): object_link_viewname = 'control:event.voucher' object_link_argname = 'voucher' - def object_link_display_name(self, order): - return order.code[:6] + def object_link_display_name(self, voucher): + return voucher.code[:6] class ItemLogEntryType(EventLogEntryType): diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index eeb003d5e..81477de24 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -93,7 +93,7 @@ class LogEntry(models.Model): indexes = [models.Index(fields=["datetime", "id"])] def display(self): - log_entry_type, meta = log_entry_types.find(action_type=self.action_type) + log_entry_type, meta = log_entry_types.get(action_type=self.action_type) if log_entry_type: return log_entry_type.display(self) @@ -134,7 +134,7 @@ class LogEntry(models.Model): Discount, Event, Item, Order, Question, Quota, SubEvent, Voucher, ) - log_entry_type, meta = log_entry_types.find(action_type=self.action_type) + log_entry_type, meta = log_entry_types.get(action_type=self.action_type) if log_entry_type: link_info = log_entry_type.get_object_link_info(self) if is_app_active(self.event, meta['plugin']): diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index a841fb702..f98406115 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -220,32 +220,110 @@ class DeprecatedSignal(django.dispatch.Signal): class Registry: + """ + A Registry is a collection of objects (entries), annotated with metadata. Entries can be searched and filtered by + metadata keys, and metadata is returned as part of the result. + + Entry metadata is generated during registration using to the accessor functions given to the Registry + constructor. + + Example: + + .. code-block:: python + + animal_sound_registry = Registry({"animal": lambda s: s.animal}) + + @animal_sound_registry.new("dog", "woof") + @animal_sound_registry.new("cricket", "chirp") + class AnimalSound: + def __init__(self, animal, sound): + self.animal = animal + self.sound = sound + + def make_sound(self): + return self.sound + + @animal_sound_registry.new() + class CatSound(AnimalSound): + def __init__(self): + super().__init__(animal="cat", sound=["meow", "meww", "miaou"]) + + def make_sound(self): + return random.choice(self.sound) + """ + def __init__(self, keys): - self.registered_items = list() + """ + :param keys: dictionary {key: accessor_function} + When a new entry is registered, all accessor functions are called with the new entry as parameter. + Their return value is stored as the metadata value for that key. + """ + self.registered_entries = list() self.keys = keys self.by_key = {key: {} for key in self.keys.keys()} def register(self, *objs): + """ + Register one or more entries in this registry. + + Usable as a regular method or as decorator on a class or function. If used on a class, the class type object + itself is registered, not an instance of the class. To register an instance, use the ``new`` method. + + .. code-block:: python + + @some_registry.register + def my_new_entry(foo): + # ... + """ for obj in objs: meta = {k: accessor(obj) for k, accessor in self.keys.items()} tup = (obj, meta) - for key, accessor in self.keys.items(): - self.by_key[key][accessor(obj)] = tup - self.registered_items.append(tup) + for key, value in meta.items(): + self.by_key[key][value] = tup + self.registered_entries.append(tup) def new(self, *args, **kwargs): + """ + Instantiate the decorated class with the given *args and **kwargs, and register the instance in this registry. + May be used multiple times. + + .. code-block:: python + + @animal_sound_registry.new("meow") + @animal_sound_registry.new("woof") + class AnimalSound: + def __init__(self, sound): + # ... + """ def reg(clz): obj = clz(*args, **kwargs) self.register(obj) return clz return reg - def find(self, **kwargs): + def get(self, **kwargs): (key, value), = kwargs.items() return self.by_key.get(key).get(value, (None, None)) + def filter(self, **kwargs): + return ((entry, meta) + for entry, meta in self.registered_entries + if all(value == meta[key] for key, value in kwargs.items()) + ) + class EventPluginRegistry(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. + + .. code-block:: python + + logtype, meta = my_registry.find(action_type="foo.bar.baz") + # meta["plugin"] contains the django app name of the defining plugin + """ + def __init__(self, keys): super().__init__({"plugin": lambda o: get_defining_app(o), **keys})