From c8d4815c9e8cf79eee4b0d6d52009d60eaf2a00a Mon Sep 17 00:00:00 2001 From: Mira Date: Thu, 16 Jan 2025 13:05:57 +0100 Subject: [PATCH] LogEntryType registry (#4235) Move display of LogEntry details from the `logentry_display` and `logentry_object_link` signals to a class hierarchy based approach. For each action_type, an instance of a subclass of `LogEntryType` is registered in the `log_entry_types` registry. Analogous to EventPluginSignal, this registry is an `EventPluginRegistry`, so it keeps track of the plugin the LogEntryType is defined in. --------- Co-authored-by: Raphael Michel Co-authored-by: Richard Schreiber --- doc/development/api/plugins.rst | 20 + doc/development/implementation/logging.rst | 104 ++- src/pretix/_base_settings.py | 8 + src/pretix/base/logentrytypes.py | 253 ++++++ src/pretix/base/models/log.py | 132 +-- src/pretix/base/signals.py | 226 +++-- src/pretix/control/logdisplay.py | 819 ++++++++++-------- .../pretixcontrol/event/plugins.html | 2 +- src/pretix/plugins/badges/signals.py | 47 +- src/pretix/plugins/banktransfer/signals.py | 16 +- src/pretix/plugins/paypal/signals.py | 9 +- src/pretix/plugins/paypal2/signals.py | 51 +- src/pretix/plugins/sendmail/signals.py | 44 +- src/pretix/plugins/ticketoutputpdf/signals.py | 45 +- src/pretix/settings.py | 8 - src/tests/base/test_registry.py | 179 ++++ 16 files changed, 1298 insertions(+), 665 deletions(-) create mode 100644 src/pretix/base/logentrytypes.py create mode 100644 src/tests/base/test_registry.py diff --git a/doc/development/api/plugins.rst b/doc/development/api/plugins.rst index 42a0354e63..181feadd7d 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,25 @@ 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 decorators provided by the Registry object: + +.. autoclass:: pretix.base.logentrytypes.LogEntryTypeRegistry + :members: register, new, new_from_dict + +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 7202c098ee..7c378b4561 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,101 @@ 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 + + + @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 cannot be automatically detected as belonging +to your plugin, leading to confusing user interface situations. + + +Customizing log entry display +""""""""""""""""""""""""""""" + +The base ``LogEntryType`` classes allow for varying degree of customization in their descendants. + +If you want to add another log message for an existing core object (e.g. an :class:`Order `, +:class:`Item `, or :class:`Voucher `), you can inherit +from its predefined :class:`LogEntryType `, e.g. +:class:`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 define 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 :class:`Event `, you can inherit from :class:`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 additional arguments provided by +``object_link_args``. The default implementation of ``object_link_args`` will return an argument named by +````object_link_argname``, with a value of ``content_object.pk`` (the primary key of the model object). +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 ItemLogEntryType(EventLogEntryType): + object_link_wrapper = _('Product {val}') + + # link will be generated as reverse('control:event.item', {'organizer': ..., 'event': ..., 'item': item.pk}) + object_link_viewname = 'control:event.item' + object_link_argname = 'item' + + +.. code-block:: python + + class OrderLogEntryType(EventLogEntryType): + object_link_wrapper = _('Order {val}') + + # link will be generated as reverse('control:event.order', {'organizer': ..., 'event': ..., 'code': order.code}) + object_link_viewname = 'control:event.order' + + def object_link_args(self, order): + return {'code': 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 :class:`LogEntry `'s +`data` object, override 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 :class:`Event `, you need to inherit directly from ``LogEntryType`` instead +of ``EventLogEntryType``, providing your own implementation of ``get_object_link_info`` if object links should be +displayed. + +.. autoclass:: pretix.base.logentrytypes.LogEntryType + :members: get_object_link_info + - @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] Sending notifications --------------------- diff --git a/src/pretix/_base_settings.py b/src/pretix/_base_settings.py index c79be4e8fc..e38e844ae9 100644 --- a/src/pretix/_base_settings.py +++ b/src/pretix/_base_settings.py @@ -75,6 +75,14 @@ FORMAT_MODULE_PATH = [ 'pretix.helpers.formats', ] +CORE_MODULES = { + "pretix.base", + "pretix.presale", + "pretix.control", + "pretix.plugins.checkinlists", + "pretix.plugins.reports", +} + ALL_LANGUAGES = [ ('en', _('English')), ('de', _('German')), diff --git a/src/pretix/base/logentrytypes.py b/src/pretix/base/logentrytypes.py new file mode 100644 index 0000000000..c1b704a3fb --- /dev/null +++ b/src/pretix/base/logentrytypes.py @@ -0,0 +1,253 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from collections import defaultdict + +from django.urls import reverse +from django.utils.html import escape +from django.utils.translation import gettext_lazy as _, pgettext_lazy + +from pretix.base.signals import EventPluginRegistry + + +def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None): + if a_map: + if is_active: + a_map['val'] = '{val}'.format_map(a_map) + elif event and plugin_name: + a_map['val'] = ( + '{val} ' + '' + ).format_map({ + **a_map, + "errmes": _("The relevant plugin is currently not active. To activate it, click here to go to the plugin settings."), + "plugin_href": reverse('control:event.settings.plugins', kwargs={ + 'organizer': event.organizer.slug, + 'event': event.slug, + }) + '#plugin_' + plugin_name, + }) + else: + a_map['val'] = '{val} '.format_map({ + **a_map, + "errmes": _("The relevant plugin is currently not active."), + }) + return wrapper.format_map(a_map) + + +class LogEntryTypeRegistry(EventPluginRegistry): + def __init__(self): + super().__init__({'action_type': lambda o: getattr(o, 'action_type')}) + + def register(self, *objs): + for obj in objs: + if not isinstance(obj, LogEntryType): + raise TypeError('Entries must be derived from LogEntryType') + + if obj.__module__ == LogEntryType.__module__: + raise TypeError('Must not register base classes, only derived ones') + + return super().register(*objs) + + 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 clz + 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() + + +class LogEntryType: + """ + Base class for a type of LogEntry, identified by its action_type. + """ + + def __init__(self, action_type=None, plain=None): + if action_type: + self.action_type = action_type + if plain: + 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: + data = defaultdict(lambda: '?', logentry.parsed_data) + return plain.format_map(data) + else: + return plain + + def get_object_link_info(self, logentry) -> dict: + """ + Return information to generate a link to the `content_object` of a given log entry. + + Not implemented in the base class, causing the object link to be omitted. + + :return: Dictionary 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): + a_map = self.get_object_link_info(logentry) + return make_link(a_map, self.object_link_wrapper) + + 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 logentry.content_object: + return { + 'href': reverse(self.object_link_viewname, kwargs={ + 'event': logentry.event.slug, + 'organizer': logentry.event.organizer.slug, + **self.object_link_args(logentry.content_object), + }), + 'val': escape(self.object_link_display_name(logentry.content_object)), + } + + def object_link_args(self, content_object): + """Return the kwargs for the url used in a link to content_object.""" + if hasattr(self, 'object_link_argname'): + return {self.object_link_argname: content_object.pk} + return {} + + 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) + + +class OrderLogEntryType(EventLogEntryType): + object_link_wrapper = _('Order {val}') + object_link_viewname = 'control:event.order' + + def object_link_args(self, order): + return {'code': order.code} + + def object_link_display_name(self, order): + return order.code + + +class VoucherLogEntryType(EventLogEntryType): + object_link_wrapper = _('Voucher {val}…') + object_link_viewname = 'control:event.voucher' + object_link_argname = 'voucher' + + def object_link_display_name(self, voucher): + if len(voucher.code) > 6: + return voucher.code[:6] + "…" + return voucher.code + + +class ItemLogEntryType(EventLogEntryType): + object_link_wrapper = _('Product {val}') + object_link_viewname = 'control:event.item' + object_link_argname = 'item' + + +class SubEventLogEntryType(EventLogEntryType): + object_link_wrapper = pgettext_lazy('subevent', 'Date {val}') + object_link_viewname = 'control:event.subevent' + object_link_argname = 'subevent' + + +class QuotaLogEntryType(EventLogEntryType): + object_link_wrapper = _('Quota {val}') + object_link_viewname = 'control:event.items.quotas.show' + object_link_argname = 'quota' + + +class DiscountLogEntryType(EventLogEntryType): + object_link_wrapper = _('Discount {val}') + object_link_viewname = 'control:event.items.discounts.edit' + object_link_argname = 'discount' + + +class ItemCategoryLogEntryType(EventLogEntryType): + object_link_wrapper = _('Category {val}') + object_link_viewname = 'control:event.items.categories.edit' + object_link_argname = 'category' + + +class QuestionLogEntryType(EventLogEntryType): + object_link_wrapper = _('Question {val}') + object_link_viewname = 'control:event.items.questions.show' + object_link_argname = 'question' + + +class TaxRuleLogEntryType(EventLogEntryType): + object_link_wrapper = _('Tax rule {val}') + object_link_viewname = 'control:event.settings.tax.edit' + object_link_argname = 'rule' + + +class NoOpShredderMixin: + def shred_pii(self, logentry): + pass + + +class ClearDataShredderMixin: + def shred_pii(self, logentry): + logentry.data = None diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index 960f5d26a4..7bbc38c726 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -33,16 +33,15 @@ # License for the specific language governing permissions and limitations under the License. import json +import logging from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models -from django.urls import reverse from django.utils.functional import cached_property -from django.utils.html import escape -from django.utils.translation import gettext_lazy as _, pgettext_lazy -from pretix.base.signals import logentry_object_link +from pretix.base.logentrytypes import log_entry_types, make_link +from pretix.base.signals import is_app_active, logentry_object_link class VisibleOnlyManager(models.Manager): @@ -92,6 +91,10 @@ class LogEntry(models.Model): indexes = [models.Index(fields=["datetime", "id"])] def display(self): + log_entry_type, meta = log_entry_types.get(action_type=self.action_type) + if log_entry_type: + return log_entry_type.display(self) + from ..signals import logentry_display for receiver, response in logentry_display.send(self.event, logentry=self): @@ -126,10 +129,18 @@ class LogEntry(models.Model): @cached_property def display_object(self): from . import ( - Discount, Event, Item, ItemCategory, Order, Question, Quota, - SubEvent, TaxRule, Voucher, + Discount, Event, Item, Order, Question, Quota, SubEvent, Voucher, ) + 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']): + return make_link(link_info, log_entry_type.object_link_wrapper) + else: + return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False, + event=self.event, plugin_name=meta['plugin'] and getattr(meta['plugin'], 'name')) + try: if self.content_type.model_class() is Event: return '' @@ -137,110 +148,15 @@ class LogEntry(models.Model): co = self.content_object except: return '' - a_map = None - a_text = None - if isinstance(co, Order): - a_text = _('Order {val}') - a_map = { - 'href': reverse('control:event.order', kwargs={ - 'event': self.event.slug, - 'organizer': self.event.organizer.slug, - 'code': co.code - }), - 'val': escape(co.code), - } - elif isinstance(co, Voucher): - a_text = _('Voucher {val}…') - a_map = { - 'href': reverse('control:event.voucher', kwargs={ - 'event': self.event.slug, - 'organizer': self.event.organizer.slug, - 'voucher': co.id - }), - 'val': escape(co.code[:6]), - } - elif isinstance(co, Item): - a_text = _('Product {val}') - a_map = { - 'href': reverse('control:event.item', kwargs={ - 'event': self.event.slug, - 'organizer': self.event.organizer.slug, - 'item': co.id - }), - 'val': escape(co.name), - } - elif isinstance(co, SubEvent): - a_text = pgettext_lazy('subevent', 'Date {val}') - a_map = { - 'href': reverse('control:event.subevent', kwargs={ - 'event': self.event.slug, - 'organizer': self.event.organizer.slug, - 'subevent': co.id - }), - 'val': escape(str(co)) - } - elif isinstance(co, Quota): - a_text = _('Quota {val}') - a_map = { - 'href': reverse('control:event.items.quotas.show', kwargs={ - 'event': self.event.slug, - 'organizer': self.event.organizer.slug, - 'quota': co.id - }), - 'val': escape(co.name), - } - elif isinstance(co, Discount): - a_text = _('Discount {val}') - a_map = { - 'href': reverse('control:event.items.discounts.edit', kwargs={ - 'event': self.event.slug, - 'organizer': self.event.organizer.slug, - 'discount': co.id - }), - 'val': escape(co.internal_name), - } - elif isinstance(co, ItemCategory): - a_text = _('Category {val}') - a_map = { - 'href': reverse('control:event.items.categories.edit', kwargs={ - 'event': self.event.slug, - 'organizer': self.event.organizer.slug, - 'category': co.id - }), - 'val': escape(co.name), - } - elif isinstance(co, Question): - a_text = _('Question {val}') - a_map = { - 'href': reverse('control:event.items.questions.show', kwargs={ - 'event': self.event.slug, - 'organizer': self.event.organizer.slug, - 'question': co.id - }), - 'val': escape(co.question), - } - elif isinstance(co, TaxRule): - a_text = _('Tax rule {val}') - a_map = { - 'href': reverse('control:event.settings.tax.edit', kwargs={ - 'event': self.event.slug, - 'organizer': self.event.organizer.slug, - 'rule': co.id - }), - 'val': escape(co.name), - } + for receiver, response in logentry_object_link.send(self.event, logentry=self): + if response: + return response - if a_text and a_map: - a_map['val'] = '{val}'.format_map(a_map) - return a_text.format_map(a_map) - elif a_text: - return a_text - else: - for receiver, response in logentry_object_link.send(self.event, logentry=self): - if response: - return response - return '' + if isinstance(co, (Order, Voucher, Item, SubEvent, Quota, Discount, Question)): + logging.warning("LogEntryType missing or ill-defined: %s", self.action_type) + + return '' @cached_property def parsed_data(self): diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index f6f1c32295..2e776e6840 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -52,6 +52,50 @@ def _populate_app_cache(): app_cache[ac.name] = ac +def get_defining_app(o): + # If sentry packed this in a wrapper, unpack that + if "sentry" in o.__module__: + o = o.__wrapped__ + + # Find the Django application this belongs to + searchpath = o.__module__ + + # Core modules are always active + if any(searchpath.startswith(cm) for cm in settings.CORE_MODULES): + return 'CORE' + + if not app_cache: + _populate_app_cache() + + while True: + app = app_cache.get(searchpath) + if "." not in searchpath or app: + break + searchpath, _ = searchpath.rsplit(".", 1) + return app + + +def is_app_active(sender, app): + 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 hasattr(app, 'compatibility_errors') or not app.compatibility_errors: + return True + return False + + +def is_receiver_active(sender, receiver): + if sender is None: + # Send to all events! + return True + + app = get_defining_app(receiver) + + return is_app_active(sender, app) + + class EventPluginSignal(django.dispatch.Signal): """ This is an extension to Django's built-in signals which differs in a way that it sends @@ -59,33 +103,6 @@ class EventPluginSignal(django.dispatch.Signal): Event. """ - def _is_active(self, sender, receiver): - if sender is None: - # Send to all events! - return True - - # If sentry packed this in a wrapper, unpack that - if "sentry" in receiver.__module__: - receiver = receiver.__wrapped__ - - # Find the Django application this belongs to - searchpath = receiver.__module__ - core_module = any([searchpath.startswith(cm) for cm in settings.CORE_MODULES]) - app = None - if not core_module: - while True: - app = app_cache.get(searchpath) - if "." not in searchpath or app: - break - searchpath, _ = searchpath.rsplit(".", 1) - - # Only fire receivers from active plugins and core modules - excluded = settings.PRETIX_PLUGINS_EXCLUDE - if core_module or (sender and app and app.name in sender.get_plugins() and app.name not in excluded): - if not hasattr(app, 'compatibility_errors') or not app.compatibility_errors: - return True - return False - def send(self, sender: Event, **named) -> List[Tuple[Callable, Any]]: """ Send signal from sender to all connected receivers that belong to @@ -104,7 +121,7 @@ class EventPluginSignal(django.dispatch.Signal): _populate_app_cache() for receiver in self._sorted_receivers(sender): - if self._is_active(sender, receiver): + if is_receiver_active(sender, receiver): response = receiver(signal=self, sender=sender, **named) responses.append((receiver, response)) return responses @@ -128,7 +145,7 @@ class EventPluginSignal(django.dispatch.Signal): _populate_app_cache() for receiver in self._sorted_receivers(sender): - if self._is_active(sender, receiver): + if is_receiver_active(sender, receiver): named[chain_kwarg_name] = response response = receiver(signal=self, sender=sender, **named) return response @@ -155,7 +172,7 @@ class EventPluginSignal(django.dispatch.Signal): _populate_app_cache() for receiver in self._sorted_receivers(sender): - if self._is_active(sender, receiver): + if is_receiver_active(sender, receiver): try: response = receiver(signal=self, sender=sender, **named) except Exception as err: @@ -202,6 +219,122 @@ class DeprecatedSignal(django.dispatch.Signal): super().connect(receiver, sender=None, weak=True, dispatch_uid=None) +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): + """ + :param keys: Dictionary with `{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 = dict() + 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: + if obj in self.registered_entries: + raise RuntimeError('Object already registered: {}'.format(obj)) + + meta = {k: accessor(obj) for k, accessor in self.keys.items()} + tup = (obj, meta) + for key, value in meta.items(): + self.by_key[key][value] = tup + self.registered_entries[obj] = meta + + if len(objs) == 1: + return objs[0] + + 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 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.items() + 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}) + + event_live_issues = EventPluginSignal() """ This signal is sent out to determine whether an event can be taken live. If you want to @@ -507,41 +640,16 @@ logentry_display = EventPluginSignal() """ Arguments: ``logentry`` -To display an instance of the ``LogEntry`` model to a human user, -``pretix.base.signals.logentry_display`` will be sent out with a ``logentry`` argument. - -The first received response that is not ``None`` will be used to display the log entry -to the user. The receivers are expected to return plain text. - -As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +**DEPRECTATION:** Please do not use this signal for new LogEntry types. Use the log_entry_types +registry instead, as described in https://docs.pretix.eu/en/latest/development/implementation/logging.html """ logentry_object_link = EventPluginSignal() """ Arguments: ``logentry`` -To display the relationship of an instance of the ``LogEntry`` model to another model -to a human user, ``pretix.base.signals.logentry_object_link`` will be sent out with a -``logentry`` argument. - -The first received response that is not ``None`` will be used to display the related object -to the user. The receivers are expected to return a HTML link. The internal implementation -builds the links like this:: - - a_text = _('Tax rule {val}') - a_map = { - 'href': reverse('control:event.settings.tax.edit', kwargs={ - 'event': sender.slug, - 'organizer': sender.organizer.slug, - 'rule': logentry.content_object.id - }), - 'val': escape(logentry.content_object.name), - } - a_map['val'] = '{val}'.format_map(a_map) - return a_text.format_map(a_map) - -Make sure that any user content in the HTML code you return is properly escaped! -As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +**DEPRECTATION:** Please do not use this signal for new LogEntry types. Use the log_entry_types +registry instead, as described in https://docs.pretix.eu/en/latest/development/implementation/logging.html """ requiredaction_display = EventPluginSignal() diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index c4176ead45..7c9f9aad8b 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -47,12 +47,20 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _, pgettext_lazy from i18nfield.strings import LazyI18nString +from pretix.base.logentrytypes import ( + DiscountLogEntryType, EventLogEntryType, ItemCategoryLogEntryType, + ItemLogEntryType, LogEntryType, OrderLogEntryType, QuestionLogEntryType, + QuotaLogEntryType, TaxRuleLogEntryType, VoucherLogEntryType, + log_entry_types, +) from pretix.base.models import ( Checkin, CheckinList, Event, ItemVariation, LogEntry, OrderPosition, TaxRule, ) from pretix.base.models.orders import PrintLog -from pretix.base.signals import logentry_display, orderposition_blocked_display +from pretix.base.signals import ( + app_cache, logentry_display, orderposition_blocked_display, +) from pretix.base.templatetags.money import money_filter OVERVIEW_BANLIST = [ @@ -329,278 +337,6 @@ def _display_checkin(event, logentry): @receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display") def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): - plains = { - 'pretix.object.cloned': _('This object has been created by cloning.'), - 'pretix.organizer.changed': _('The organizer has been changed.'), - 'pretix.organizer.settings': _('The organizer settings have been changed.'), - 'pretix.organizer.footerlinks.changed': _('The footer links have been changed.'), - 'pretix.organizer.export.schedule.added': _('A scheduled export has been added.'), - 'pretix.organizer.export.schedule.changed': _('A scheduled export has been changed.'), - 'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'), - 'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'), - 'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'), - 'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'), - 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), - 'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'), - 'pretix.giftcards.acceptance.acceptor.removed': _('A gift card acceptor has been removed.'), - 'pretix.giftcards.acceptance.issuer.removed': _('A gift card issuer has been removed or declined.'), - 'pretix.giftcards.acceptance.issuer.accepted': _('A new gift card issuer has been accepted.'), - 'pretix.webhook.created': _('The webhook has been created.'), - 'pretix.webhook.changed': _('The webhook has been changed.'), - 'pretix.webhook.retries.expedited': _('The webhook call retry jobs have been manually expedited.'), - 'pretix.webhook.retries.dropped': _('The webhook call retry jobs have been dropped.'), - 'pretix.ssoprovider.created': _('The SSO provider has been created.'), - 'pretix.ssoprovider.changed': _('The SSO provider has been changed.'), - 'pretix.ssoprovider.deleted': _('The SSO provider has been deleted.'), - 'pretix.ssoclient.created': _('The SSO client has been created.'), - 'pretix.ssoclient.changed': _('The SSO client has been changed.'), - 'pretix.ssoclient.deleted': _('The SSO client has been deleted.'), - 'pretix.membershiptype.created': _('The membership type has been created.'), - 'pretix.membershiptype.changed': _('The membership type has been changed.'), - 'pretix.membershiptype.deleted': _('The membership type has been deleted.'), - 'pretix.saleschannel.created': _('The sales channel has been created.'), - 'pretix.saleschannel.changed': _('The sales channel has been changed.'), - 'pretix.saleschannel.deleted': _('The sales channel has been deleted.'), - 'pretix.customer.created': _('The account has been created.'), - 'pretix.customer.changed': _('The account has been changed.'), - 'pretix.customer.membership.created': _('A membership for this account has been added.'), - 'pretix.customer.membership.changed': _('A membership of this account has been changed.'), - 'pretix.customer.membership.deleted': _('A membership of this account has been deleted.'), - 'pretix.customer.anonymized': _('The account has been disabled and anonymized.'), - 'pretix.customer.password.resetrequested': _('A new password has been requested.'), - 'pretix.customer.password.set': _('A new password has been set.'), - 'pretix.reusable_medium.created': _('The reusable medium has been created.'), - 'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'), - 'pretix.reusable_medium.changed': _('The reusable medium has been changed.'), - 'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'), - 'pretix.reusable_medium.linked_giftcard.changed': _('The medium has been connected to a new gift card.'), - 'pretix.email.error': _('Sending of an email has failed.'), - 'pretix.event.comment': _('The event\'s internal comment has been updated.'), - 'pretix.event.canceled': _('The event has been canceled.'), - 'pretix.event.deleted': _('An event has been deleted.'), - 'pretix.event.shredder.started': _('A removal process for personal data has been started.'), - 'pretix.event.shredder.completed': _('A removal process for personal data has been completed.'), - 'pretix.event.order.modified': _('The order details have been changed.'), - 'pretix.event.order.unpaid': _('The order has been marked as unpaid.'), - 'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'), - 'pretix.event.order.expirychanged': _('The order\'s expiry date has been changed.'), - 'pretix.event.order.valid_if_pending.set': _('The order has been set to be usable before it is paid.'), - 'pretix.event.order.valid_if_pending.unset': _('The order has been set to require payment before use.'), - 'pretix.event.order.expired': _('The order has been marked as expired.'), - 'pretix.event.order.paid': _('The order has been marked as paid.'), - 'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'), - 'pretix.event.order.refunded': _('The order has been refunded.'), - 'pretix.event.order.reactivated': _('The order has been reactivated.'), - 'pretix.event.order.deleted': _('The test mode order {code} has been deleted.'), - 'pretix.event.order.placed': _('The order has been created.'), - 'pretix.event.order.placed.require_approval': _('The order requires approval before it can continue to be processed.'), - 'pretix.event.order.approved': _('The order has been approved.'), - 'pretix.event.order.denied': _('The order has been denied (comment: "{comment}").'), - 'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" ' - 'to "{new_email}".'), - 'pretix.event.order.contact.confirmed': _('The email address has been confirmed to be working (the user clicked on a link ' - 'in the email for the first time).'), - 'pretix.event.order.phone.changed': _('The phone number has been changed from "{old_phone}" ' - 'to "{new_phone}".'), - 'pretix.event.order.customer.changed': _('The customer account has been changed.'), - 'pretix.event.order.locale.changed': _('The order locale has been changed.'), - 'pretix.event.order.invoice.generated': _('The invoice has been generated.'), - 'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'), - 'pretix.event.order.invoice.reissued': _('The invoice has been reissued.'), - 'pretix.event.order.comment': _('The order\'s internal comment has been updated.'), - 'pretix.event.order.custom_followup_at': _('The order\'s follow-up date has been updated.'), - 'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been ' - 'toggled.'), - 'pretix.event.order.checkin_text': _('The order\'s check-in text has been changed.'), - 'pretix.event.order.pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if ' - 'unpaid has been toggled.'), - 'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'), - 'pretix.event.order.email.sent': _('An unidentified type email has been sent.'), - 'pretix.event.order.email.error': _('Sending of an email has failed.'), - 'pretix.event.order.email.attachments.skipped': _('The email has been sent without attached tickets since they ' - 'would have been too large to be likely to arrive.'), - 'pretix.event.order.email.custom_sent': _('A custom email has been sent.'), - 'pretix.event.order.position.email.custom_sent': _('A custom email has been sent to an attendee.'), - 'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket ' - 'is available for download.'), - 'pretix.event.order.email.expire_warning_sent': _('An email has been sent with a warning that the order is about ' - 'to expire.'), - 'pretix.event.order.email.order_canceled': _('An email has been sent to notify the user that the order has been canceled.'), - 'pretix.event.order.email.event_canceled': _('An email has been sent to notify the user that the event has ' - 'been canceled.'), - 'pretix.event.order.email.order_changed': _('An email has been sent to notify the user that the order has been changed.'), - 'pretix.event.order.email.order_free': _('An email has been sent to notify the user that the order has been received.'), - 'pretix.event.order.email.order_paid': _('An email has been sent to notify the user that payment has been received.'), - 'pretix.event.order.email.order_denied': _('An email has been sent to notify the user that the order has been denied.'), - 'pretix.event.order.email.order_approved': _('An email has been sent to notify the user that the order has ' - 'been approved.'), - 'pretix.event.order.email.order_placed': _('An email has been sent to notify the user that the order has been received and requires payment.'), - 'pretix.event.order.email.order_placed_require_approval': _('An email has been sent to notify the user that ' - 'the order has been received and requires ' - 'approval.'), - 'pretix.event.order.email.resend': _('An email with a link to the order detail page has been resent to the user.'), - 'pretix.event.order.email.payment_failed': _('An email has been sent to notify the user that the payment failed.'), - 'pretix.event.order.payment.confirmed': _('Payment {local_id} has been confirmed.'), - 'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'), - 'pretix.event.order.payment.canceled.failed': _('Canceling payment {local_id} has failed.'), - 'pretix.event.order.payment.started': _('Payment {local_id} has been started.'), - 'pretix.event.order.payment.failed': _('Payment {local_id} has failed.'), - 'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'), - 'pretix.event.order.overpaid': _('The order has been overpaid.'), - 'pretix.event.order.refund.created': _('Refund {local_id} has been created.'), - 'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'), - 'pretix.event.order.refund.requested': _('The customer requested you to issue a refund.'), - 'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'), - 'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'), - 'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'), - 'pretix.event.export.schedule.added': _('A scheduled export has been added.'), - 'pretix.event.export.schedule.changed': _('A scheduled export has been changed.'), - 'pretix.event.export.schedule.deleted': _('A scheduled export has been deleted.'), - 'pretix.event.export.schedule.executed': _('A scheduled export has been executed.'), - 'pretix.event.export.schedule.failed': _('A scheduled export has failed: {reason}.'), - 'pretix.control.auth.user.created': _('The user has been created.'), - 'pretix.control.auth.user.new_source': _('A first login using {agent_type} on {os_type} from {country} has ' - 'been detected.'), - 'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'), - 'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'), - 'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'), - 'pretix.user.settings.2fa.emergency': _('A two-factor emergency code has been generated.'), - 'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to ' - '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.user.anonymized': _('This user has been anonymized.'), - 'pretix.user.oauth.authorized': _('The application "{application_name}" has been authorized to access your ' - 'account.'), - '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.control.auth.user.forgot_password.denied.repeated': _('A repeated password reset has been denied, as ' - 'the last request was less than 24 hours ago.'), - 'pretix.organizer.deleted': _('The organizer "{name}" has been deleted.'), - 'pretix.voucher.added': _('The voucher has been created.'), - 'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'), - 'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'), - 'pretix.voucher.expired.waitinglist': _('The voucher has been set to expire because the recipient removed themselves from the waiting list.'), - 'pretix.voucher.changed': _('The voucher has been changed.'), - 'pretix.voucher.deleted': _('The voucher has been deleted.'), - 'pretix.voucher.redeemed': _('The voucher has been redeemed in order {order_code}.'), - 'pretix.event.item.added': _('The product has been created.'), - 'pretix.event.item.changed': _('The product has been changed.'), - 'pretix.event.item.reordered': _('The product has been reordered.'), - 'pretix.event.item.deleted': _('The product has been deleted.'), - 'pretix.event.item.variation.added': _('The variation "{value}" has been created.'), - 'pretix.event.item.variation.deleted': _('The variation "{value}" has been deleted.'), - 'pretix.event.item.variation.changed': _('The variation "{value}" has been changed.'), - 'pretix.event.item.addons.added': _('An add-on has been added to this product.'), - 'pretix.event.item.addons.removed': _('An add-on has been removed from this product.'), - 'pretix.event.item.addons.changed': _('An add-on has been changed on this product.'), - 'pretix.event.item.bundles.added': _('A bundled item has been added to this product.'), - 'pretix.event.item.bundles.removed': _('A bundled item has been removed from this product.'), - 'pretix.event.item.bundles.changed': _('A bundled item has been changed on this product.'), - 'pretix.event.item_meta_property.added': _('A meta property has been added to this event.'), - 'pretix.event.item_meta_property.deleted': _('A meta property has been removed from this event.'), - 'pretix.event.item_meta_property.changed': _('A meta property has been changed on this event.'), - 'pretix.event.quota.added': _('The quota has been added.'), - 'pretix.event.quota.deleted': _('The quota has been deleted.'), - 'pretix.event.quota.changed': _('The quota has been changed.'), - 'pretix.event.quota.closed': _('The quota has closed.'), - 'pretix.event.quota.opened': _('The quota has been re-opened.'), - 'pretix.event.category.added': _('The category has been added.'), - 'pretix.event.category.deleted': _('The category has been deleted.'), - 'pretix.event.category.changed': _('The category has been changed.'), - 'pretix.event.category.reordered': _('The category has been reordered.'), - 'pretix.event.question.added': _('The question has been added.'), - 'pretix.event.question.deleted': _('The question has been deleted.'), - 'pretix.event.question.changed': _('The question has been changed.'), - 'pretix.event.question.reordered': _('The question has been reordered.'), - 'pretix.event.discount.added': _('The discount has been added.'), - 'pretix.event.discount.deleted': _('The discount has been deleted.'), - 'pretix.event.discount.changed': _('The discount has been changed.'), - 'pretix.event.taxrule.added': _('The tax rule has been added.'), - 'pretix.event.taxrule.deleted': _('The tax rule has been deleted.'), - 'pretix.event.taxrule.changed': _('The tax rule has been changed.'), - 'pretix.event.checkinlist.added': _('The check-in list has been added.'), - 'pretix.event.checkinlist.deleted': _('The check-in list has been deleted.'), - 'pretix.event.checkinlists.deleted': _('The check-in list has been deleted.'), # backwards compatibility - 'pretix.event.checkinlist.changed': _('The check-in list has been changed.'), - 'pretix.event.settings': _('The event settings have been changed.'), - 'pretix.event.tickets.settings': _('The ticket download settings have been changed.'), - 'pretix.event.plugins.enabled': _('A plugin has been enabled.'), - 'pretix.event.plugins.disabled': _('A plugin has been disabled.'), - 'pretix.event.live.activated': _('The shop has been taken live.'), - 'pretix.event.live.deactivated': _('The shop has been taken offline.'), - 'pretix.event.testmode.activated': _('The shop has been taken into test mode.'), - 'pretix.event.testmode.deactivated': _('The test mode has been disabled.'), - 'pretix.event.added': _('The event has been created.'), - 'pretix.event.changed': _('The event details have been changed.'), - 'pretix.event.footerlinks.changed': _('The footer links have been changed.'), - 'pretix.event.question.option.added': _('An answer option has been added to the question.'), - 'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'), - 'pretix.event.question.option.changed': _('An answer option has been changed.'), - 'pretix.event.permissions.added': _('A user has been added to the event team.'), - 'pretix.event.permissions.invited': _('A user has been invited to the event team.'), - 'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'), - 'pretix.event.permissions.deleted': _('A user has been removed from the event team.'), - 'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.'), # legacy - 'pretix.event.orders.waitinglist.voucher_assigned': _('A voucher has been sent to a person on the waiting list.'), - 'pretix.event.orders.waitinglist.deleted': _('An entry has been removed from the waiting list.'), - 'pretix.event.order.waitinglist.transferred': _('An entry has been transferred to another waiting list.'), # legacy - 'pretix.event.orders.waitinglist.changed': _('An entry has been changed on the waiting list.'), - 'pretix.event.orders.waitinglist.added': _('An entry has been added to the waiting list.'), - 'pretix.team.created': _('The team has been created.'), - 'pretix.team.changed': _('The team settings have been changed.'), - 'pretix.team.deleted': _('The team has been deleted.'), - 'pretix.gate.created': _('The gate has been created.'), - 'pretix.gate.changed': _('The gate has been changed.'), - 'pretix.gate.deleted': _('The gate has been deleted.'), - 'pretix.subevent.deleted': pgettext_lazy('subevent', 'The event date has been deleted.'), - 'pretix.subevent.canceled': pgettext_lazy('subevent', 'The event date has been canceled.'), - 'pretix.subevent.changed': pgettext_lazy('subevent', 'The event date has been changed.'), - 'pretix.subevent.added': pgettext_lazy('subevent', 'The event date has been created.'), - 'pretix.subevent.quota.added': pgettext_lazy('subevent', 'A quota has been added to the event date.'), - 'pretix.subevent.quota.changed': pgettext_lazy('subevent', 'A quota has been changed on the event date.'), - 'pretix.subevent.quota.deleted': pgettext_lazy('subevent', 'A quota has been removed from the event date.'), - 'pretix.device.created': _('The device has been created.'), - 'pretix.device.changed': _('The device has been changed.'), - 'pretix.device.revoked': _('Access of the device has been revoked.'), - 'pretix.device.initialized': _('The device has been initialized.'), - 'pretix.device.keyroll': _('The access token of the device has been regenerated.'), - 'pretix.device.updated': _('The device has notified the server of an hardware or software update.'), - 'pretix.giftcards.created': _('The gift card has been created.'), - 'pretix.giftcards.modified': _('The gift card has been changed.'), - 'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'), - } - - data = json.loads(logentry.data) - - if logentry.action_type.startswith('pretix.event.item.variation'): - if 'value' not in data: - # Backwards compatibility - var = ItemVariation.objects.filter(id=data['id']).first() - if var: - data['value'] = str(var.value) - else: - data['value'] = '?' - else: - data['value'] = LazyI18nString(data['value']) - - if logentry.action_type == "pretix.voucher.redeemed": - data = defaultdict(lambda: '?', data) - url = reverse('control:event.order', kwargs={ - 'event': logentry.event.slug, - 'organizer': logentry.event.organizer.slug, - 'code': data['order_code'] - }) - return mark_safe(plains[logentry.action_type].format( - order_code='{}'.format(url, data['order_code']), - )) - - if logentry.action_type in plains: - data = defaultdict(lambda: '?', data) - return plains[logentry.action_type].format_map(data) if logentry.action_type.startswith('pretix.event.order.changed'): return _display_order_changed(sender, logentry) @@ -624,16 +360,16 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): return _('The order has been canceled.') if logentry.action_type in ('pretix.control.views.checkin.reverted', 'pretix.event.checkin.reverted'): - if 'list' in data: + if 'list' in logentry.parsed_data: try: - checkin_list = sender.checkin_lists.get(pk=data.get('list')).name + checkin_list = sender.checkin_lists.get(pk=logentry.parsed_data.get('list')).name except CheckinList.DoesNotExist: checkin_list = _("(unknown)") else: checkin_list = _("(unknown)") return _('The check-in of position #{posid} on list "{list}" has been reverted.').format( - posid=data.get('positionid'), + posid=logentry.parsed_data.get('positionid'), list=checkin_list, ) @@ -642,83 +378,14 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): if logentry.action_type == 'pretix.event.order.print': return _('Position #{posid} has been printed at {datetime} with type "{type}".').format( - posid=data.get('positionid'), + posid=logentry.parsed_data.get('positionid'), datetime=date_format( - dateutil.parser.parse(data["datetime"]).astimezone(sender.timezone), + dateutil.parser.parse(logentry.parsed_data["datetime"]).astimezone(sender.timezone), "SHORT_DATETIME_FORMAT" ), - type=dict(PrintLog.PRINT_TYPES)[data["type"]], + type=dict(PrintLog.PRINT_TYPES)[logentry.parsed_data["type"]], ) - if logentry.action_type == 'pretix.control.views.checkin': - # deprecated - dt = dateutil.parser.parse(data.get('datetime')) - tz = sender.timezone - dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT") - if 'list' in data: - try: - checkin_list = sender.checkin_lists.get(pk=data.get('list')).name - except CheckinList.DoesNotExist: - checkin_list = _("(unknown)") - else: - checkin_list = _("(unknown)") - - if data.get('first'): - return _('Position #{posid} has been checked in manually at {datetime} on list "{list}".').format( - posid=data.get('positionid'), - datetime=dt_formatted, - list=checkin_list, - ) - return _('Position #{posid} has been checked in again at {datetime} on list "{list}".').format( - posid=data.get('positionid'), - datetime=dt_formatted, - list=checkin_list - ) - - if logentry.action_type == 'pretix.team.member.added': - return _('{user} has been added to the team.').format(user=data.get('email')) - - if logentry.action_type == 'pretix.team.member.removed': - return _('{user} has been removed from the team.').format(user=data.get('email')) - - if logentry.action_type == 'pretix.team.member.joined': - return _('{user} has joined the team using the invite sent to {email}.').format( - user=data.get('email'), email=data.get('invite_email') - ) - - if logentry.action_type == 'pretix.team.invite.created': - return _('{user} has been invited to the team.').format(user=data.get('email')) - - if logentry.action_type == 'pretix.team.invite.resent': - return _('Invite for {user} has been resent.').format(user=data.get('email')) - - if logentry.action_type == 'pretix.team.invite.deleted': - return _('The invite for {user} has been revoked.').format(user=data.get('email')) - - if logentry.action_type == 'pretix.team.token.created': - return _('The token "{name}" has been created.').format(name=data.get('name')) - - if logentry.action_type == 'pretix.team.token.deleted': - return _('The token "{name}" has been revoked.').format(name=data.get('name')) - - if logentry.action_type == 'pretix.user.settings.changed': - text = str(_('Your account settings have been changed.')) - if 'email' in data: - text = text + ' ' + str(_('Your email address has been changed to {email}.').format(email=data['email'])) - if 'new_pw' in data: - text = text + ' ' + str(_('Your password has been changed.')) - if data.get('is_active') is True: - text = text + ' ' + str(_('Your account has been enabled.')) - elif data.get('is_active') is False: - text = text + ' ' + str(_('Your account has been disabled.')) - return text - - if logentry.action_type == 'pretix.control.auth.user.impersonated': - return str(_('You impersonated {}.')).format(data['other_email']) - - if logentry.action_type == 'pretix.control.auth.user.impersonate_stopped': - return str(_('You stopped impersonating {}.')).format(data['other_email']) - @receiver(signal=orderposition_blocked_display, dispatch_uid="pretixcontrol_orderposition_blocked_display") def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, block_name, **kwargs): @@ -726,3 +393,459 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl return _('Blocked manually') elif block_name.startswith('api:'): return _('Blocked because of an API integration') + + +@log_entry_types.new_from_dict({ + 'pretix.event.order.modified': _('The order details have been changed.'), + 'pretix.event.order.unpaid': _('The order has been marked as unpaid.'), + 'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'), + 'pretix.event.order.expirychanged': _('The order\'s expiry date has been changed.'), + 'pretix.event.order.valid_if_pending.set': _('The order has been set to be usable before it is paid.'), + 'pretix.event.order.valid_if_pending.unset': _('The order has been set to require payment before use.'), + 'pretix.event.order.expired': _('The order has been marked as expired.'), + 'pretix.event.order.paid': _('The order has been marked as paid.'), + 'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'), + 'pretix.event.order.refunded': _('The order has been refunded.'), + 'pretix.event.order.reactivated': _('The order has been reactivated.'), + 'pretix.event.order.deleted': _('The test mode order {code} has been deleted.'), + 'pretix.event.order.placed': _('The order has been created.'), + 'pretix.event.order.placed.require_approval': _( + 'The order requires approval before it can continue to be processed.'), + 'pretix.event.order.approved': _('The order has been approved.'), + 'pretix.event.order.denied': _('The order has been denied (comment: "{comment}").'), + 'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" ' + 'to "{new_email}".'), + 'pretix.event.order.contact.confirmed': _( + 'The email address has been confirmed to be working (the user clicked on a link ' + 'in the email for the first time).'), + 'pretix.event.order.phone.changed': _('The phone number has been changed from "{old_phone}" ' + 'to "{new_phone}".'), + 'pretix.event.order.customer.changed': _('The customer account has been changed.'), + 'pretix.event.order.locale.changed': _('The order locale has been changed.'), + 'pretix.event.order.invoice.generated': _('The invoice has been generated.'), + 'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'), + 'pretix.event.order.invoice.reissued': _('The invoice has been reissued.'), + 'pretix.event.order.comment': _('The order\'s internal comment has been updated.'), + 'pretix.event.order.custom_followup_at': _('The order\'s follow-up date has been updated.'), + 'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been ' + 'toggled.'), + 'pretix.event.order.checkin_text': _('The order\'s check-in text has been changed.'), + 'pretix.event.order.pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if ' + 'unpaid has been toggled.'), + 'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'), + 'pretix.event.order.email.sent': _('An unidentified type email has been sent.'), + 'pretix.event.order.email.error': _('Sending of an email has failed.'), + 'pretix.event.order.email.attachments.skipped': _('The email has been sent without attached tickets since they ' + 'would have been too large to be likely to arrive.'), + 'pretix.event.order.email.custom_sent': _('A custom email has been sent.'), + 'pretix.event.order.position.email.custom_sent': _('A custom email has been sent to an attendee.'), + 'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket ' + 'is available for download.'), + 'pretix.event.order.email.expire_warning_sent': _('An email has been sent with a warning that the order is about ' + 'to expire.'), + 'pretix.event.order.email.order_canceled': _( + 'An email has been sent to notify the user that the order has been canceled.'), + 'pretix.event.order.email.event_canceled': _('An email has been sent to notify the user that the event has ' + 'been canceled.'), + 'pretix.event.order.email.order_changed': _( + 'An email has been sent to notify the user that the order has been changed.'), + 'pretix.event.order.email.order_free': _( + 'An email has been sent to notify the user that the order has been received.'), + 'pretix.event.order.email.order_paid': _( + 'An email has been sent to notify the user that payment has been received.'), + 'pretix.event.order.email.order_denied': _( + 'An email has been sent to notify the user that the order has been denied.'), + 'pretix.event.order.email.order_approved': _('An email has been sent to notify the user that the order has ' + 'been approved.'), + 'pretix.event.order.email.order_placed': _( + 'An email has been sent to notify the user that the order has been received and requires payment.'), + 'pretix.event.order.email.order_placed_require_approval': _('An email has been sent to notify the user that ' + 'the order has been received and requires ' + 'approval.'), + 'pretix.event.order.email.resend': _('An email with a link to the order detail page has been resent to the user.'), + 'pretix.event.order.email.payment_failed': _('An email has been sent to notify the user that the payment failed.'), +}) +class CoreOrderLogEntryType(OrderLogEntryType): + pass + + +@log_entry_types.new_from_dict({ + 'pretix.voucher.added': _('The voucher has been created.'), + 'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'), + 'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'), + 'pretix.voucher.expired.waitinglist': _( + 'The voucher has been set to expire because the recipient removed themselves from the waiting list.'), + 'pretix.voucher.changed': _('The voucher has been changed.'), + 'pretix.voucher.deleted': _('The voucher has been deleted.'), +}) +class CoreVoucherLogEntryType(VoucherLogEntryType): + pass + + +@log_entry_types.new() +class VoucherRedeemedLogEntryType(VoucherLogEntryType): + action_type = 'pretix.voucher.redeemed' + plain = _('The voucher has been redeemed in order {order_code}.') + + def display(self, logentry): + data = json.loads(logentry.data) + data = defaultdict(lambda: '?', data) + url = reverse('control:event.order', kwargs={ + 'event': logentry.event.slug, + 'organizer': logentry.event.organizer.slug, + 'code': data['order_code'] + }) + return mark_safe(self.plain.format( + order_code='{}'.format(url, data['order_code']), + )) + + +@log_entry_types.new_from_dict({ + 'pretix.event.category.added': _('The category has been added.'), + 'pretix.event.category.deleted': _('The category has been deleted.'), + 'pretix.event.category.changed': _('The category has been changed.'), + 'pretix.event.category.reordered': _('The category has been reordered.'), +}) +class CoreItemCategoryLogEntryType(ItemCategoryLogEntryType): + pass + + +@log_entry_types.new_from_dict({ + 'pretix.event.taxrule.added': _('The tax rule has been added.'), + 'pretix.event.taxrule.deleted': _('The tax rule has been deleted.'), + 'pretix.event.taxrule.changed': _('The tax rule has been changed.'), +}) +class CoreTaxRuleLogEntryType(TaxRuleLogEntryType): + pass + + +class TeamMembershipLogEntryType(LogEntryType): + def display(self, logentry): + return self.plain.format(user=logentry.parsed_data.get('email')) + + +@log_entry_types.new_from_dict({ + 'pretix.team.member.added': _('{user} has been added to the team.'), + 'pretix.team.member.removed': _('{user} has been removed from the team.'), + 'pretix.team.invite.created': _('{user} has been invited to the team.'), + 'pretix.team.invite.resent': _('Invite for {user} has been resent.'), +}) +class CoreTeamMembershipLogEntryType(TeamMembershipLogEntryType): + pass + + +@log_entry_types.new() +class TeamMemberJoinedLogEntryType(LogEntryType): + action_type = 'pretix.team.member.joined' + + def display(self, logentry): + return _('{user} has joined the team using the invite sent to {email}.').format( + user=logentry.parsed_data.get('email'), email=logentry.parsed_data.get('invite_email') + ) + + +@log_entry_types.new() +class UserSettingsChangedLogEntryType(LogEntryType): + action_type = 'pretix.user.settings.changed' + + def display(self, logentry): + text = str(_('Your account settings have been changed.')) + if 'email' in logentry.parsed_data: + text = text + ' ' + str( + _('Your email address has been changed to {email}.').format(email=logentry.parsed_data['email'])) + if 'new_pw' in logentry.parsed_data: + text = text + ' ' + str(_('Your password has been changed.')) + if logentry.parsed_data.get('is_active') is True: + text = text + ' ' + str(_('Your account has been enabled.')) + elif logentry.parsed_data.get('is_active') is False: + text = text + ' ' + str(_('Your account has been disabled.')) + return text + + +class UserImpersonatedLogEntryType(LogEntryType): + def display(self, logentry): + return self.plain.format(logentry.parsed_data['other_email']) + + +@log_entry_types.new_from_dict({ + 'pretix.control.auth.user.impersonated': _('You impersonated {}.'), + 'pretix.control.auth.user.impersonate_stopped': _('You stopped impersonating {}.'), +}) +class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType): + pass + + +@log_entry_types.new_from_dict({ + 'pretix.object.cloned': _('This object has been created by cloning.'), + 'pretix.organizer.changed': _('The organizer has been changed.'), + 'pretix.organizer.settings': _('The organizer settings have been changed.'), + 'pretix.organizer.footerlinks.changed': _('The footer links have been changed.'), + 'pretix.organizer.export.schedule.added': _('A scheduled export has been added.'), + 'pretix.organizer.export.schedule.changed': _('A scheduled export has been changed.'), + 'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'), + 'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'), + 'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'), + 'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'), + 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), + 'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'), + 'pretix.giftcards.acceptance.acceptor.removed': _('A gift card acceptor has been removed.'), + 'pretix.giftcards.acceptance.issuer.removed': _('A gift card issuer has been removed or declined.'), + 'pretix.giftcards.acceptance.issuer.accepted': _('A new gift card issuer has been accepted.'), + 'pretix.webhook.created': _('The webhook has been created.'), + 'pretix.webhook.changed': _('The webhook has been changed.'), + 'pretix.webhook.retries.expedited': _('The webhook call retry jobs have been manually expedited.'), + 'pretix.webhook.retries.dropped': _('The webhook call retry jobs have been dropped.'), + 'pretix.ssoprovider.created': _('The SSO provider has been created.'), + 'pretix.ssoprovider.changed': _('The SSO provider has been changed.'), + 'pretix.ssoprovider.deleted': _('The SSO provider has been deleted.'), + 'pretix.ssoclient.created': _('The SSO client has been created.'), + 'pretix.ssoclient.changed': _('The SSO client has been changed.'), + 'pretix.ssoclient.deleted': _('The SSO client has been deleted.'), + 'pretix.membershiptype.created': _('The membership type has been created.'), + 'pretix.membershiptype.changed': _('The membership type has been changed.'), + 'pretix.membershiptype.deleted': _('The membership type has been deleted.'), + 'pretix.saleschannel.created': _('The sales channel has been created.'), + 'pretix.saleschannel.changed': _('The sales channel has been changed.'), + 'pretix.saleschannel.deleted': _('The sales channel has been deleted.'), + 'pretix.customer.created': _('The account has been created.'), + 'pretix.customer.changed': _('The account has been changed.'), + 'pretix.customer.membership.created': _('A membership for this account has been added.'), + 'pretix.customer.membership.changed': _('A membership of this account has been changed.'), + 'pretix.customer.membership.deleted': _('A membership of this account has been deleted.'), + 'pretix.customer.anonymized': _('The account has been disabled and anonymized.'), + 'pretix.customer.password.resetrequested': _('A new password has been requested.'), + 'pretix.customer.password.set': _('A new password has been set.'), + 'pretix.reusable_medium.created': _('The reusable medium has been created.'), + 'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'), + 'pretix.reusable_medium.changed': _('The reusable medium has been changed.'), + 'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'), + 'pretix.reusable_medium.linked_giftcard.changed': _('The medium has been connected to a new gift card.'), + 'pretix.email.error': _('Sending of an email has failed.'), + 'pretix.event.comment': _('The event\'s internal comment has been updated.'), + 'pretix.event.canceled': _('The event has been canceled.'), + 'pretix.event.deleted': _('An event has been deleted.'), + 'pretix.event.shredder.started': _('A removal process for personal data has been started.'), + 'pretix.event.shredder.completed': _('A removal process for personal data has been completed.'), + 'pretix.event.export.schedule.added': _('A scheduled export has been added.'), + 'pretix.event.export.schedule.changed': _('A scheduled export has been changed.'), + 'pretix.event.export.schedule.deleted': _('A scheduled export has been deleted.'), + 'pretix.event.export.schedule.executed': _('A scheduled export has been executed.'), + 'pretix.event.export.schedule.failed': _('A scheduled export has failed: {reason}.'), + 'pretix.control.auth.user.created': _('The user has been created.'), + 'pretix.control.auth.user.new_source': _('A first login using {agent_type} on {os_type} from {country} has ' + 'been detected.'), + 'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'), + 'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'), + 'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'), + 'pretix.user.settings.2fa.emergency': _('A two-factor emergency code has been generated.'), + 'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to ' + '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.user.anonymized': _('This user has been anonymized.'), + 'pretix.user.oauth.authorized': _('The application "{application_name}" has been authorized to access your ' + 'account.'), + '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.control.auth.user.forgot_password.denied.repeated': _('A repeated password reset has been denied, as ' + 'the last request was less than 24 hours ago.'), + 'pretix.organizer.deleted': _('The organizer "{name}" has been deleted.'), + 'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.'), # legacy + 'pretix.event.orders.waitinglist.voucher_assigned': _('A voucher has been sent to a person on the waiting list.'), + 'pretix.event.orders.waitinglist.deleted': _('An entry has been removed from the waiting list.'), + 'pretix.event.order.waitinglist.transferred': _('An entry has been transferred to another waiting list.'), # legacy + 'pretix.event.orders.waitinglist.changed': _('An entry has been changed on the waiting list.'), + 'pretix.event.orders.waitinglist.added': _('An entry has been added to the waiting list.'), + 'pretix.team.created': _('The team has been created.'), + 'pretix.team.changed': _('The team settings have been changed.'), + 'pretix.team.deleted': _('The team has been deleted.'), + 'pretix.gate.created': _('The gate has been created.'), + 'pretix.gate.changed': _('The gate has been changed.'), + 'pretix.gate.deleted': _('The gate has been deleted.'), + 'pretix.subevent.deleted': pgettext_lazy('subevent', 'The event date has been deleted.'), + 'pretix.subevent.canceled': pgettext_lazy('subevent', 'The event date has been canceled.'), + 'pretix.subevent.changed': pgettext_lazy('subevent', 'The event date has been changed.'), + 'pretix.subevent.added': pgettext_lazy('subevent', 'The event date has been created.'), + 'pretix.subevent.quota.added': pgettext_lazy('subevent', 'A quota has been added to the event date.'), + 'pretix.subevent.quota.changed': pgettext_lazy('subevent', 'A quota has been changed on the event date.'), + 'pretix.subevent.quota.deleted': pgettext_lazy('subevent', 'A quota has been removed from the event date.'), + 'pretix.device.created': _('The device has been created.'), + 'pretix.device.changed': _('The device has been changed.'), + 'pretix.device.revoked': _('Access of the device has been revoked.'), + 'pretix.device.initialized': _('The device has been initialized.'), + 'pretix.device.keyroll': _('The access token of the device has been regenerated.'), + 'pretix.device.updated': _('The device has notified the server of an hardware or software update.'), + 'pretix.giftcards.created': _('The gift card has been created.'), + 'pretix.giftcards.modified': _('The gift card has been changed.'), + 'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'), + 'pretix.team.token.created': _('The token "{name}" has been created.'), + 'pretix.team.token.deleted': _('The token "{name}" has been revoked.'), +}) +class CoreLogEntryType(LogEntryType): + pass + + +@log_entry_types.new_from_dict({ + 'pretix.event.item_meta_property.added': _('A meta property has been added to this event.'), + 'pretix.event.item_meta_property.deleted': _('A meta property has been removed from this event.'), + 'pretix.event.item_meta_property.changed': _('A meta property has been changed on this event.'), + 'pretix.event.checkinlist.added': _('The check-in list has been added.'), + 'pretix.event.checkinlist.deleted': _('The check-in list has been deleted.'), + 'pretix.event.checkinlists.deleted': _('The check-in list has been deleted.'), # backwards compatibility + 'pretix.event.checkinlist.changed': _('The check-in list has been changed.'), + 'pretix.event.settings': _('The event settings have been changed.'), + 'pretix.event.tickets.settings': _('The ticket download settings have been changed.'), + 'pretix.event.live.activated': _('The shop has been taken live.'), + 'pretix.event.live.deactivated': _('The shop has been taken offline.'), + 'pretix.event.testmode.activated': _('The shop has been taken into test mode.'), + 'pretix.event.testmode.deactivated': _('The test mode has been disabled.'), + 'pretix.event.added': _('The event has been created.'), + 'pretix.event.changed': _('The event details have been changed.'), + 'pretix.event.footerlinks.changed': _('The footer links have been changed.'), + 'pretix.event.question.option.added': _('An answer option has been added to the question.'), + 'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'), + 'pretix.event.question.option.changed': _('An answer option has been changed.'), + 'pretix.event.permissions.added': _('A user has been added to the event team.'), + 'pretix.event.permissions.invited': _('A user has been invited to the event team.'), + 'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'), + 'pretix.event.permissions.deleted': _('A user has been removed from the event team.'), +}) +class CoreEventLogEntryType(EventLogEntryType): + pass + + +@log_entry_types.new_from_dict({ + 'pretix.event.plugins.enabled': _('The plugin has been enabled.'), + 'pretix.event.plugins.disabled': _('The plugin has been disabled.'), +}) +class EventPluginStateLogEntryType(EventLogEntryType): + object_link_wrapper = _('Plugin {val}') + + def get_object_link_info(self, logentry) -> dict: + if 'plugin' in logentry.parsed_data: + app = app_cache.get(logentry.parsed_data['plugin']) + if app and hasattr(app, 'PretixPluginMeta'): + return { + 'href': reverse('control:event.settings.plugins', kwargs={ + 'organizer': logentry.event.organizer.slug, + 'event': logentry.event.slug, + }) + '#plugin_' + logentry.parsed_data['plugin'], + 'val': app.PretixPluginMeta.name + } + + +@log_entry_types.new_from_dict({ + 'pretix.event.item.added': _('The product has been created.'), + 'pretix.event.item.changed': _('The product has been changed.'), + 'pretix.event.item.reordered': _('The product has been reordered.'), + 'pretix.event.item.deleted': _('The product has been deleted.'), + 'pretix.event.item.addons.added': _('An add-on has been added to this product.'), + 'pretix.event.item.addons.removed': _('An add-on has been removed from this product.'), + 'pretix.event.item.addons.changed': _('An add-on has been changed on this product.'), + 'pretix.event.item.bundles.added': _('A bundled item has been added to this product.'), + 'pretix.event.item.bundles.removed': _('A bundled item has been removed from this product.'), + 'pretix.event.item.bundles.changed': _('A bundled item has been changed on this product.'), +}) +class CoreItemLogEntryType(ItemLogEntryType): + pass + + +@log_entry_types.new_from_dict({ + 'pretix.event.item.variation.added': _('The variation "{value}" has been created.'), + 'pretix.event.item.variation.deleted': _('The variation "{value}" has been deleted.'), + 'pretix.event.item.variation.changed': _('The variation "{value}" has been changed.'), +}) +class VariationLogEntryType(ItemLogEntryType): + def display(self, logentry): + if 'value' not in logentry.parsed_data: + # Backwards compatibility + var = ItemVariation.objects.filter(id=logentry.parsed_data['id']).first() + if var: + logentry.parsed_data['value'] = str(var.value) + else: + logentry.parsed_data['value'] = '?' + else: + logentry.parsed_data['value'] = LazyI18nString(logentry.parsed_data['value']) + return super().display(logentry) + + +@log_entry_types.new_from_dict({ + 'pretix.event.order.payment.confirmed': _('Payment {local_id} has been confirmed.'), + 'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'), + 'pretix.event.order.payment.canceled.failed': _('Canceling payment {local_id} has failed.'), + 'pretix.event.order.payment.started': _('Payment {local_id} has been started.'), + 'pretix.event.order.payment.failed': _('Payment {local_id} has failed.'), + 'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'), + 'pretix.event.order.overpaid': _('The order has been overpaid.'), + 'pretix.event.order.refund.created': _('Refund {local_id} has been created.'), + 'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'), + 'pretix.event.order.refund.requested': _('The customer requested you to issue a refund.'), + 'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'), + 'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'), + 'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'), +}) +class CoreOrderPaymentLogEntryType(OrderLogEntryType): + pass + + +@log_entry_types.new_from_dict({ + 'pretix.event.quota.added': _('The quota has been added.'), + 'pretix.event.quota.deleted': _('The quota has been deleted.'), + 'pretix.event.quota.changed': _('The quota has been changed.'), + 'pretix.event.quota.closed': _('The quota has closed.'), + 'pretix.event.quota.opened': _('The quota has been re-opened.'), +}) +class CoreQuotaLogEntryType(QuotaLogEntryType): + pass + + +@log_entry_types.new_from_dict({ + 'pretix.event.question.added': _('The question has been added.'), + 'pretix.event.question.deleted': _('The question has been deleted.'), + 'pretix.event.question.changed': _('The question has been changed.'), + 'pretix.event.question.reordered': _('The question has been reordered.'), +}) +class CoreQuestionLogEntryType(QuestionLogEntryType): + pass + + +@log_entry_types.new_from_dict({ + 'pretix.event.discount.added': _('The discount has been added.'), + 'pretix.event.discount.deleted': _('The discount has been deleted.'), + 'pretix.event.discount.changed': _('The discount has been changed.'), +}) +class CoreDiscountLogEntryType(DiscountLogEntryType): + pass + + +@log_entry_types.new() +class LegacyCheckinLogEntryType(OrderLogEntryType): + action_type = 'pretix.control.views.checkin' + + def display(self, logentry): + # deprecated + dt = dateutil.parser.parse(logentry.parsed_data.get('datetime')) + tz = logentry.event.timezone + dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT") + if 'list' in logentry.parsed_data: + try: + checkin_list = logentry.event.checkin_lists.get(pk=logentry.parsed_data.get('list')).name + except CheckinList.DoesNotExist: + checkin_list = _("(unknown)") + else: + checkin_list = _("(unknown)") + + if logentry.parsed_data.get('first'): + return _('Position #{posid} has been checked in manually at {datetime} on list "{list}".').format( + posid=logentry.parsed_data.get('positionid'), + datetime=dt_formatted, + list=checkin_list, + ) + return _('Position #{posid} has been checked in again at {datetime} on list "{list}".').format( + posid=logentry.parsed_data.get('positionid'), + datetime=dt_formatted, + list=checkin_list + ) diff --git a/src/pretix/control/templates/pretixcontrol/event/plugins.html b/src/pretix/control/templates/pretixcontrol/event/plugins.html index 39ca03277b..5575abc89f 100644 --- a/src/pretix/control/templates/pretixcontrol/event/plugins.html +++ b/src/pretix/control/templates/pretixcontrol/event/plugins.html @@ -23,7 +23,7 @@ {{ catlabel }}
{% for plugin in plist %} -
+
{% if plugin.featured %}
diff --git a/src/pretix/plugins/badges/signals.py b/src/pretix/plugins/badges/signals.py index dd315b1d17..2ed30f6d3b 100644 --- a/src/pretix/plugins/badges/signals.py +++ b/src/pretix/plugins/badges/signals.py @@ -26,13 +26,12 @@ from collections import defaultdict from django.dispatch import receiver from django.template.loader import get_template from django.urls import resolve, reverse -from django.utils.html import escape from django.utils.translation import gettext_lazy as _ +from pretix.base.logentrytypes import EventLogEntryType, log_entry_types from pretix.base.models import Event, Order from pretix.base.signals import ( - event_copy_data, item_copy_data, logentry_display, logentry_object_link, - register_data_exporters, + event_copy_data, item_copy_data, register_data_exporters, ) from pretix.control.signals import ( item_forms, nav_event, order_info, order_position_buttons, @@ -173,35 +172,13 @@ def control_order_info(sender: Event, request, order: Order, **kwargs): return template.render(ctx, request=request) -@receiver(signal=logentry_display, dispatch_uid="badges_logentry_display") -def badges_logentry_display(sender, logentry, **kwargs): - if not logentry.action_type.startswith('pretix.plugins.badges'): - return - - plains = { - 'pretix.plugins.badges.layout.added': _('Badge layout created.'), - 'pretix.plugins.badges.layout.deleted': _('Badge layout deleted.'), - 'pretix.plugins.badges.layout.changed': _('Badge layout changed.'), - } - - if logentry.action_type in plains: - return plains[logentry.action_type] - - -@receiver(signal=logentry_object_link, dispatch_uid="badges_logentry_object_link") -def badges_logentry_object_link(sender, logentry, **kwargs): - if not logentry.action_type.startswith('pretix.plugins.badges.layout') or not isinstance(logentry.content_object, - BadgeLayout): - return - - a_text = _('Badge layout {val}') - a_map = { - 'href': reverse('plugins:badges:edit', kwargs={ - 'event': sender.slug, - 'organizer': sender.organizer.slug, - 'layout': logentry.content_object.id - }), - 'val': escape(logentry.content_object.name), - } - a_map['val'] = '{val}'.format_map(a_map) - return a_text.format_map(a_map) +@log_entry_types.new_from_dict({ + 'pretix.plugins.badges.layout.added': _('Badge layout created.'), + 'pretix.plugins.badges.layout.deleted': _('Badge layout deleted.'), + 'pretix.plugins.badges.layout.changed': _('Badge layout changed.'), +}) +class BadgeLogEntryType(EventLogEntryType): + object_type = BadgeLayout + object_link_wrapper = _('Badge layout {val}') + object_link_viewname = 'plugins:badges:edit' + object_link_argname = 'layout' diff --git a/src/pretix/plugins/banktransfer/signals.py b/src/pretix/plugins/banktransfer/signals.py index 0d8d0aa763..8bb3c647e2 100644 --- a/src/pretix/plugins/banktransfer/signals.py +++ b/src/pretix/plugins/banktransfer/signals.py @@ -25,9 +25,12 @@ from django.urls import resolve, reverse from django.utils.translation import gettext_lazy as _, gettext_noop from i18nfield.strings import LazyI18nString -from pretix.base.signals import logentry_display, register_payment_providers +from pretix.base.signals import register_payment_providers from pretix.control.signals import html_head, nav_event, nav_organizer +from ...base.logentrytypes import ( + ClearDataShredderMixin, OrderLogEntryType, log_entry_types, +) from ...base.settings import settings_hierarkey from .payment import BankTransfer @@ -117,13 +120,10 @@ def html_head_presale(sender, request=None, **kwargs): return "" -@receiver(signal=logentry_display) -def pretixcontrol_logentry_display(sender, logentry, **kwargs): - plains = { - 'pretix.plugins.banktransfer.order.email.invoice': _('The invoice was sent to the designated email address.'), - } - if logentry.action_type in plains: - return plains[logentry.action_type] +@log_entry_types.new() +class BanktransferOrderEmailInvoiceLogEntryType(OrderLogEntryType, ClearDataShredderMixin): + action_type = 'pretix.plugins.banktransfer.order.email.invoice' + plain = _('The invoice was sent to the designated email address.') settings_hierarkey.add_default( diff --git a/src/pretix/plugins/paypal/signals.py b/src/pretix/plugins/paypal/signals.py index 3c7d36f504..572a79c819 100644 --- a/src/pretix/plugins/paypal/signals.py +++ b/src/pretix/plugins/paypal/signals.py @@ -22,17 +22,10 @@ from django.dispatch import receiver -from pretix.base.signals import logentry_display, register_payment_providers +from pretix.base.signals import register_payment_providers @receiver(register_payment_providers, dispatch_uid="payment_paypal") def register_payment_provider(sender, **kwargs): from .payment import Paypal return Paypal - - -@receiver(signal=logentry_display, dispatch_uid="paypal_logentry_display") -def pretixcontrol_logentry_display(sender, logentry, **kwargs): - from pretix.plugins.paypal2.signals import pretixcontrol_logentry_display - - return pretixcontrol_logentry_display(sender, logentry, **kwargs) diff --git a/src/pretix/plugins/paypal2/signals.py b/src/pretix/plugins/paypal2/signals.py index 50ca242cf7..e5e4c9bfc6 100644 --- a/src/pretix/plugins/paypal2/signals.py +++ b/src/pretix/plugins/paypal2/signals.py @@ -19,7 +19,6 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -import json from collections import OrderedDict from django import forms @@ -32,10 +31,11 @@ from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _, pgettext_lazy from pretix.base.forms import SecretKeySettingsField +from pretix.base.logentrytypes import EventLogEntryType, log_entry_types from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp from pretix.base.settings import settings_hierarkey from pretix.base.signals import ( - logentry_display, register_global_settings, register_payment_providers, + register_global_settings, register_payment_providers, ) from pretix.plugins.paypal2.payment import PaypalMethod from pretix.presale.signals import html_head, process_response @@ -47,33 +47,32 @@ def register_payment_provider(sender, **kwargs): return [PaypalSettingsHolder, PaypalWallet, PaypalAPM] -@receiver(signal=logentry_display, dispatch_uid="paypal2_logentry_display") -def pretixcontrol_logentry_display(sender, logentry, **kwargs): - if logentry.action_type != 'pretix.plugins.paypal.event': - return +@log_entry_types.new() +class PaypalEventLogEntryType(EventLogEntryType): + action_type = 'pretix.plugins.paypal.event' - data = json.loads(logentry.data) - event_type = data.get('event_type') - text = None - plains = { - 'PAYMENT.SALE.COMPLETED': _('Payment completed.'), - 'PAYMENT.SALE.DENIED': _('Payment denied.'), - 'PAYMENT.SALE.REFUNDED': _('Payment refunded.'), - 'PAYMENT.SALE.REVERSED': _('Payment reversed.'), - 'PAYMENT.SALE.PENDING': _('Payment pending.'), - 'CHECKOUT.ORDER.APPROVED': pgettext_lazy('paypal', 'Order approved.'), - 'CHECKOUT.ORDER.COMPLETED': pgettext_lazy('paypal', 'Order completed.'), - 'PAYMENT.CAPTURE.COMPLETED': pgettext_lazy('paypal', 'Capture completed.'), - 'PAYMENT.CAPTURE.PENDING': pgettext_lazy('paypal', 'Capture pending.'), - } + def display(self, logentry): + event_type = logentry.parsed_data.get('event_type') + text = None + plains = { + 'PAYMENT.SALE.COMPLETED': _('Payment completed.'), + 'PAYMENT.SALE.DENIED': _('Payment denied.'), + 'PAYMENT.SALE.REFUNDED': _('Payment refunded.'), + 'PAYMENT.SALE.REVERSED': _('Payment reversed.'), + 'PAYMENT.SALE.PENDING': _('Payment pending.'), + 'CHECKOUT.ORDER.APPROVED': pgettext_lazy('paypal', 'Order approved.'), + 'CHECKOUT.ORDER.COMPLETED': pgettext_lazy('paypal', 'Order completed.'), + 'PAYMENT.CAPTURE.COMPLETED': pgettext_lazy('paypal', 'Capture completed.'), + 'PAYMENT.CAPTURE.PENDING': pgettext_lazy('paypal', 'Capture pending.'), + } - if event_type in plains: - text = plains[event_type] - else: - text = event_type + if event_type in plains: + text = plains[event_type] + else: + text = event_type - if text: - return _('PayPal reported an event: {}').format(text) + if text: + return _('PayPal reported an event: {}').format(text) @receiver(register_global_settings, dispatch_uid='paypal2_global_settings') diff --git a/src/pretix/plugins/sendmail/signals.py b/src/pretix/plugins/sendmail/signals.py index cfc1644198..f1da7ca025 100644 --- a/src/pretix/plugins/sendmail/signals.py +++ b/src/pretix/plugins/sendmail/signals.py @@ -46,13 +46,16 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django_scopes import scope, scopes_disabled +from pretix.base.logentrytypes import ( + EventLogEntryType, OrderLogEntryType, log_entry_types, +) from pretix.base.models import SubEvent from pretix.base.signals import ( - EventPluginSignal, event_copy_data, logentry_display, periodic_task, + EventPluginSignal, event_copy_data, periodic_task, ) from pretix.control.signals import nav_event from pretix.helpers import OF_SELF -from pretix.plugins.sendmail.models import ScheduledMail +from pretix.plugins.sendmail.models import Rule, ScheduledMail from pretix.plugins.sendmail.views import OrderSendView, WaitinglistSendView logger = logging.getLogger(__name__) @@ -115,21 +118,28 @@ def control_nav_import(sender, request=None, **kwargs): ] -@receiver(signal=logentry_display) -def pretixcontrol_logentry_display(sender, logentry, **kwargs): - plains = { - 'pretix.plugins.sendmail.sent': _('Mass email was sent to customers or attendees.'), - 'pretix.plugins.sendmail.sent.waitinglist': _('Mass email was sent to waiting list entries.'), - 'pretix.plugins.sendmail.order.email.sent': _('The order received a mass email.'), - 'pretix.plugins.sendmail.order.email.sent.attendee': _('A ticket holder of this order received a mass email.'), - 'pretix.plugins.sendmail.rule.added': _('An email rule was created'), - 'pretix.plugins.sendmail.rule.changed': _('An email rule was updated'), - 'pretix.plugins.sendmail.rule.order.email.sent': _('A scheduled email was sent to the order'), - 'pretix.plugins.sendmail.rule.order.position.email.sent': _('A scheduled email was sent to a ticket holder'), - 'pretix.plugins.sendmail.rule.deleted': _('An email rule was deleted'), - } - if logentry.action_type in plains: - return plains[logentry.action_type] +@log_entry_types.new('pretix.plugins.sendmail.sent', _('Mass email was sent to customers or attendees.')) +@log_entry_types.new('pretix.plugins.sendmail.sent.waitinglist', _('Mass email was sent to waiting list entries.')) +class SendmailPluginLogEntryType(EventLogEntryType): + pass + + +@log_entry_types.new('pretix.plugins.sendmail.order.email.sent', _('The order received a mass email.')) +@log_entry_types.new('pretix.plugins.sendmail.order.email.sent.attendee', _('A ticket holder of this order received a mass email.')) +class SendmailPluginOrderLogEntryType(OrderLogEntryType): + pass + + +@log_entry_types.new('pretix.plugins.sendmail.rule.added', _('An email rule was created')) +@log_entry_types.new('pretix.plugins.sendmail.rule.changed', _('An email rule was updated')) +@log_entry_types.new('pretix.plugins.sendmail.rule.order.email.sent', _('A scheduled email was sent to the order')) +@log_entry_types.new('pretix.plugins.sendmail.rule.order.position.email.sent', _('A scheduled email was sent to a ticket holder')) +@log_entry_types.new('pretix.plugins.sendmail.rule.deleted', _('An email rule was deleted')) +class SendmailPluginRuleLogEntryType(EventLogEntryType): + object_type = Rule + object_link_wrapper = _('Mail rule {val}') + object_link_viewname = 'plugins:sendmail:rule.update' + object_link_argname = 'rule' @receiver(periodic_task) diff --git a/src/pretix/plugins/ticketoutputpdf/signals.py b/src/pretix/plugins/ticketoutputpdf/signals.py index eb1d377a9d..d0978b5166 100644 --- a/src/pretix/plugins/ticketoutputpdf/signals.py +++ b/src/pretix/plugins/ticketoutputpdf/signals.py @@ -24,10 +24,9 @@ import json from django.dispatch import receiver from django.template.loader import get_template -from django.urls import reverse -from django.utils.html import escape from django.utils.translation import gettext_lazy as _ +from pretix.base.logentrytypes import EventLogEntryType, log_entry_types from pretix.base.models import Event, SalesChannel from pretix.base.signals import ( # NOQA: legacy import EventPluginSignal, event_copy_data, item_copy_data, layout_text_variables, @@ -134,38 +133,16 @@ def pdf_event_copy_data_receiver(sender, other, item_map, question_map, **kwargs return layout_map -@receiver(signal=logentry_display, dispatch_uid="pretix_ticketoutputpdf_logentry_display") -def pdf_logentry_display(sender, logentry, **kwargs): - if not logentry.action_type.startswith('pretix.plugins.ticketoutputpdf'): - return - - plains = { - 'pretix.plugins.ticketoutputpdf.layout.added': _('Ticket layout created.'), - 'pretix.plugins.ticketoutputpdf.layout.deleted': _('Ticket layout deleted.'), - 'pretix.plugins.ticketoutputpdf.layout.changed': _('Ticket layout changed.'), - } - - if logentry.action_type in plains: - return plains[logentry.action_type] - - -@receiver(signal=logentry_object_link, dispatch_uid="pretix_ticketoutputpdf_logentry_object_link") -def pdf_logentry_object_link(sender, logentry, **kwargs): - if not logentry.action_type.startswith('pretix.plugins.ticketoutputpdf.layout') or not isinstance( - logentry.content_object, TicketLayout): - return - - a_text = _('Ticket layout {val}') - a_map = { - 'href': reverse('plugins:ticketoutputpdf:edit', kwargs={ - 'event': sender.slug, - 'organizer': sender.organizer.slug, - 'layout': logentry.content_object.id - }), - 'val': escape(logentry.content_object.name), - } - a_map['val'] = '{val}'.format_map(a_map) - return a_text.format_map(a_map) +@log_entry_types.new_from_dict({ + 'pretix.plugins.ticketoutputpdf.layout.added': _('Ticket layout created.'), + 'pretix.plugins.ticketoutputpdf.layout.deleted': _('Ticket layout deleted.'), + 'pretix.plugins.ticketoutputpdf.layout.changed': _('Ticket layout changed.'), +}) +class PdfTicketLayoutLogEntryType(EventLogEntryType): + object_type = TicketLayout + object_link_wrapper = _('Ticket layout {val}') + object_link_viewname = 'plugins:ticketoutputpdf:edit' + object_link_argname = 'layout' def _ticket_layouts_for_item(request, item): diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 658817f7f9..efc7c7daeb 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -435,14 +435,6 @@ REST_FRAMEWORK = { } -CORE_MODULES = { - "pretix.base", - "pretix.presale", - "pretix.control", - "pretix.plugins.checkinlists", - "pretix.plugins.reports", -} - MIDDLEWARE = [ 'pretix.helpers.logs.RequestIdMiddleware', 'pretix.api.middleware.IdempotencyMiddleware', diff --git a/src/tests/base/test_registry.py b/src/tests/base/test_registry.py new file mode 100644 index 0000000000..403f2d1d88 --- /dev/null +++ b/src/tests/base/test_registry.py @@ -0,0 +1,179 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from unittest import mock + +import pytest + +from pretix.base.logentrytypes import ( + ItemLogEntryType, LogEntryType, LogEntryTypeRegistry, +) +from pretix.base.signals import Registry + + +def test_registry_classes(): + animal_type_registry = Registry({"type": lambda s: s.__name__, "classis": lambda s: s.classis}) + + @animal_type_registry.register + class Cat: + classis = 'mammalia' + + def make_sound(self): + return "meow" + + @animal_type_registry.register + class Dog: + classis = 'mammalia' + + def make_sound(self): + return "woof" + + @animal_type_registry.register + class Cricket: + classis = 'insecta' + + def make_sound(self): + return "chirp" + + # test retrieving and instantiating a class based on metadata value + clz, meta = animal_type_registry.get(type="Cat") + assert clz().make_sound() == "meow" + assert meta.get('type') == "Cat" + + clz, meta = animal_type_registry.get(type="Dog") + assert clz().make_sound() == "woof" + assert meta.get('type') == "Dog" + + # check that None is returned when no class exists with the specified metadata value + clz, meta = animal_type_registry.get(type="Unicorn") + assert clz is None + assert meta is None + + # check that an error is raised when trying to retrieve by an undefined metadata key + with pytest.raises(Exception): + _, _ = animal_type_registry.get(whatever="hello") + + # test finding all entries with a given metadata value + mammals = animal_type_registry.filter(classis='mammalia') + assert set(cls for cls, meta in mammals) == {Cat, Dog} + assert all(meta['classis'] == 'mammalia' for cls, meta in mammals) + + insects = animal_type_registry.filter(classis='insecta') + assert set(cls for cls, meta in insects) == {Cricket} + + fantasy = animal_type_registry.filter(classis='fantasia') + assert set(cls for cls, meta in fantasy) == set() + + # check normal object instantiation still works with our decorator + assert Cat().make_sound() == "meow" + + +def test_registry_instances(): + 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"]) + self.i = 0 + + def make_sound(self): + self.i += 1 + return self.sound[self.i % len(self.sound)] + + # test registry + assert animal_sound_registry.get(animal='dog')[0].make_sound() == "woof" + assert animal_sound_registry.get(animal='dog')[0].make_sound() == "woof" + assert animal_sound_registry.get(animal='cricket')[0].make_sound() == "chirp" + assert animal_sound_registry.get(animal='cat')[0].make_sound() == "meww" + assert animal_sound_registry.get(animal='cat')[0].make_sound() == "miaou" + assert animal_sound_registry.get(animal='cat')[0].make_sound() == "meow" + + # check normal object instantiation still works with our decorator + assert AnimalSound("test", "test").make_sound() == "test" + + +def test_registry_prevent_duplicates(): + my_registry = Registry({"animal": lambda s: s.animal}) + + class AnimalSound: + def __init__(self, animal, sound): + self.animal = animal + self.sound = sound + + cat = AnimalSound("cat", "meow") + my_registry.register(cat) + + with pytest.raises(RuntimeError): + my_registry.register(cat) + + +def test_logentrytype_registry(): + reg = LogEntryTypeRegistry() + + with mock.patch('pretix.base.signals.get_defining_app') as mock_get_defining_app: + mock_get_defining_app.return_value = 'my_plugin' + + @reg.new("foo.mytype") + class MyType(LogEntryType): + pass + + @reg.new("foo.myothertype") + class MyOtherType(LogEntryType): + pass + + typ, meta = reg.get(action_type="foo.mytype") + assert isinstance(typ, MyType) + assert meta['action_type'] == "foo.mytype" + assert meta['plugin'] == 'my_plugin' + + typ, meta = reg.get(action_type="foo.myothertype") + assert isinstance(typ, MyOtherType) + assert meta['action_type'] == "foo.myothertype" + assert meta['plugin'] is None + + by_my_plugin = reg.filter(plugin='my_plugin') + assert set(type(typ) for typ, meta in by_my_plugin) == {MyType} + + +def test_logentrytype_registry_validation(): + reg = LogEntryTypeRegistry() + + with pytest.raises(TypeError, match='Must not register base classes, only derived ones'): + reg.register(LogEntryType("foo.mytype")) + + with pytest.raises(TypeError, match='Must not register base classes, only derived ones'): + reg.new_from_dict({"foo.mytype": "My Log Entry"})(ItemLogEntryType) + + with pytest.raises(TypeError, match='Entries must be derived from LogEntryType'): + @reg.new() + class MyType: + pass