diff --git a/component_event/README.rst b/component_event/README.rst new file mode 100644 index 000000000..3f8fae7c0 --- /dev/null +++ b/component_event/README.rst @@ -0,0 +1,133 @@ +================= +Components Events +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b1bf7560d845dddfd31dd556b137084d31b15f146451998947fdfa0a60669f21 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fconnector-lightgray.png?logo=github + :target: https://github.com/OCA/connector/tree/17.0/component_event + :alt: OCA/connector +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/connector-17-0/connector-17-0-component_event + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/connector&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module implements an event system (`Observer +pattern `__) and is a +base block for the Connector Framework. It can be used without using the +full Connector though. It is built upon the ``component`` module. + +Documentation: http://odoo-connector.com/ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +As a developer, you have access to a events system. You can find the +documentation in the code or on http://odoo-connector.com + +In a nutshell, you can create trigger events: + +:: + + class Base(models.AbstractModel): + _inherit = 'base' + + @api.model + def create(self, vals): + record = super(Base, self).create(vals) + self._event('on_record_create').notify(record, fields=vals.keys()) + return record + +And subscribe listeners to the events: + +:: + + from odoo.addons.component.core import Component + from odoo.addons.component_event import skip_if + + class MagentoListener(Component): + _name = 'magento.event.listener' + _inherit = 'base.connector.listener' + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + """ Called when a record is created """ + record.with_delay().export_record(fields=fields) + +This module triggers 3 events: + +- ``on_record_create(record, fields=None)`` +- ``on_record_write(record, fields=None)`` +- ``on_record_unlink(record)`` + +Changelog +========= + +Next +---- + +12.0.1.0.0 (2018-11-26) +----------------------- + +- [MIGRATION] from 12.0 branched at rev. 324e006 + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- Guewen Baconnier + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/connector `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/component_event/__init__.py b/component_event/__init__.py new file mode 100644 index 000000000..fb993a0ab --- /dev/null +++ b/component_event/__init__.py @@ -0,0 +1,6 @@ +from . import core +from . import components +from . import models + +# allow public API 'from odoo.addons.component_event import skip_if' +from .components.event import skip_if # noqa diff --git a/component_event/__manifest__.py b/component_event/__manifest__.py new file mode 100644 index 000000000..f520dd457 --- /dev/null +++ b/component_event/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2019 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +{ + "name": "Components Events", + "version": "17.0.1.0.0", + "author": "Camptocamp," "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/connector", + "license": "LGPL-3", + "category": "Generic Modules", + "depends": ["component"], + "external_dependencies": {"python": ["cachetools"]}, + "data": [], + "installable": True, +} diff --git a/component_event/components/__init__.py b/component_event/components/__init__.py new file mode 100644 index 000000000..44ad1cba0 --- /dev/null +++ b/component_event/components/__init__.py @@ -0,0 +1 @@ +from . import event diff --git a/component_event/components/event.py b/component_event/components/event.py new file mode 100644 index 000000000..67bfd17fc --- /dev/null +++ b/component_event/components/event.py @@ -0,0 +1,298 @@ +# Copyright 2017 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +""" +Events +====== + +Events are a notification system. + +On one side, one or many listeners await for an event to happen. On +the other side, when such event happen, a notification is sent to +the listeners. + +An example of event is: 'when a record has been created'. + +The event system allows to write the notification code in only one place, in +one Odoo addon, and to write as many listeners as we want, in different places, +different addons. + +We'll see below how the ``on_record_create`` is implemented. + +Notifier +-------- + +The first thing is to find where/when the notification should be sent. +For the creation of a record, it is in :meth:`odoo.models.BaseModel.create`. +We can inherit from the `'base'` model to add this line: + +:: + + class Base(models.AbstractModel): + _inherit = 'base' + + @api.model + def create(self, vals): + record = super(Base, self).create(vals) + self._event('on_record_create').notify(record, fields=vals.keys()) + return record + +The :meth:`..models.base.Base._event` method has been added to the `'base'` +model, so an event can be notified from any model. The +:meth:`CollectedEvents.notify` method triggers the event and forward the +arguments to the listeners. + +This should be done only once. See :class:`..models.base.Base` for a list of +events that are implemented in the `'base'` model. + +Listeners +--------- + +Listeners are Components that respond to the event names. +The components must have a ``_usage`` equals to ``'event.listener'``, but it +doesn't to be set manually if the component inherits from +``'base.event.listener'`` + +Here is how we would log something each time a record is created:: + + class MyEventListener(Component): + _name = 'my.event.listener' + _inherit = 'base.event.listener' + + def on_record_create(self, record, fields=None): + _logger.info("%r has been created", record) + +Many listeners such as this one could be added for the same event. + + +Collection and models +--------------------- + +In the example above, the listeners is global. It will be executed for any +model and collection. You can also restrict a listener to only a collection or +model, using the ``_collection`` or ``_apply_on`` attributes. + +:: + + class MyEventListener(Component): + _name = 'my.event.listener' + _inherit = 'base.event.listener' + _collection = 'magento.backend' + + def on_record_create(self, record, fields=None): + _logger.info("%r has been created", record) + + + class MyModelEventListener(Component): + _name = 'my.event.listener' + _inherit = 'base.event.listener' + _apply_on = ['res.users'] + + def on_record_create(self, record, fields=None): + _logger.info("%r has been created", record) + + +If you want an event to be restricted to a collection, the +notification must also precise the collection, otherwise all listeners +will be executed:: + + + collection = self.env['magento.backend'] + self._event('on_foo_created', collection=collection).notify(record, vals) + +An event can be skipped based on a condition evaluated from the notified +arguments. See :func:`skip_if` + + +""" + +import logging +import operator +from collections import defaultdict +from functools import wraps + +# pylint: disable=W7950 +from odoo.addons.component.core import AbstractComponent, Component + +_logger = logging.getLogger(__name__) + +try: + from cachetools import LRUCache, cachedmethod +except ImportError: + _logger.debug("Cannot import 'cachetools'.") + +__all__ = ["skip_if"] + +# Number of items we keep in LRU cache when we collect the events. +# 1 item means: for an event name, model_name, collection, return +# the event methods +DEFAULT_EVENT_CACHE_SIZE = 512 + + +def skip_if(cond): + """Decorator allowing to skip an event based on a condition + + The condition is a python lambda expression, which takes the + same arguments than the event. + + Example:: + + @skip_if(lambda self, *args, **kwargs: + self.env.context.get('connector_no_export')) + def on_record_write(self, record, fields=None): + _logger('I'll delay a job, but only if we didn't disabled ' + ' the export with a context key') + record.with_delay().export_record() + + @skip_if(lambda self, record, kind: kind == 'complete') + def on_record_write(self, record, kind): + _logger("I'll delay a job, but only if the kind is 'complete'") + record.with_delay().export_record() + + """ + + def skip_if_decorator(func): + @wraps(func) + def func_wrapper(*args, **kwargs): + if cond(*args, **kwargs): + return + else: + return func(*args, **kwargs) + + return func_wrapper + + return skip_if_decorator + + +class CollectedEvents: + """Event methods ready to be notified + + This is a rather internal class. An instance of this class + is prepared by the :class:`EventCollecter` when we need to notify + the listener that the event has been triggered. + + :meth:`EventCollecter.collect_events` collects the events, + feed them to the instance, so we can use the :meth:`notify` method + that will forward the arguments and keyword arguments to the + listeners of the event. + :: + + >>> # collecter is an instance of CollectedEvents + >>> collecter.collect_events('on_record_create').notify(something) + + """ + + def __init__(self, events): + self.events = events + + def notify(self, *args, **kwargs): + """Forward the arguments to every listeners of an event""" + for event in self.events: + event(*args, **kwargs) + + +class EventCollecter(Component): + """Component that collects the event from an event name + + For doing so, it searches all the components that respond to the + ``event.listener`` ``_usage`` and having an event of the same + name. + + Then it feeds the events to an instance of :class:`EventCollecter` + and return it to the caller. + + It keeps the results in a cache, the Component is rebuilt when + the Odoo's registry is rebuilt, hence the cache is cleared as well. + + An event always starts with ``on_``. + + Note that the special + :class:`odoo.addons.component_event.core.EventWorkContext` class should be + used for this Component, because it can work + without a collection. + + It is used by :meth:`odoo.addons.component_event.models.base.Base._event`. + + """ + + _name = "base.event.collecter" + + @classmethod + def _complete_component_build(cls): + """Create a cache on the class when the component is built""" + super()._complete_component_build() + # the _cache being on the component class, which is + # dynamically rebuild when odoo registry is rebuild, we + # are sure that the result is always the same for a lookup + # until the next rebuild of odoo's registry + cls._cache = LRUCache(maxsize=DEFAULT_EVENT_CACHE_SIZE) + return + + def _collect_events(self, name): + collection_name = None + if self.work._collection is not None: + collection_name = self.work.collection._name + return self._collect_events_cached(collection_name, self.work.model_name, name) + + @cachedmethod(operator.attrgetter("_cache")) + def _collect_events_cached(self, collection_name, model_name, name): + events = defaultdict(set) + component_classes = self.work.components_registry.lookup( + collection_name=collection_name, + usage="event.listener", + model_name=model_name, + ) + for cls in component_classes: + if cls.has_event(name): + events[cls].add(name) + return events + + def _init_collected_events(self, class_events): + events = set() + for cls, names in class_events.items(): + for name in names: + component = cls(self.work) + events.add(getattr(component, name)) + return events + + def collect_events(self, name): + """Collect the events of a given name""" + if not name.startswith("on_"): + raise ValueError("an event name always starts with 'on_'") + + events = self._init_collected_events(self._collect_events(name)) + return CollectedEvents(events) + + +class EventListener(AbstractComponent): + """Base Component for the Event listeners + + Events must be methods starting with ``on_``. + + Example: :class:`RecordsEventListener` + + """ + + _name = "base.event.listener" + _usage = "event.listener" + + @classmethod + def has_event(cls, name): + """Indicate if the class has an event of this name""" + return name in cls._events + + @classmethod + def _build_event_listener_component(cls): + """Make a list of events listeners for this class""" + events = set() + if not cls._abstract: + for attr_name in dir(cls): + if attr_name.startswith("on_"): + events.add(attr_name) + cls._events = events + + @classmethod + def _complete_component_build(cls): + super()._complete_component_build() + cls._build_event_listener_component() + return diff --git a/component_event/core.py b/component_event/core.py new file mode 100644 index 000000000..63e937f05 --- /dev/null +++ b/component_event/core.py @@ -0,0 +1,160 @@ +# Copyright 2017 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +""" +Events Internals +================ + +Core classes for the events system. + + +""" + + +from odoo.addons.component.core import WorkContext + + +class EventWorkContext(WorkContext): + """Work context used by the Events internals + + Should not be used outside of the events internals. + The work context to use generally is + :class:`odoo.addons.component.core.WorkContext` or your own + subclass. + + The events are a special kind of components because they are + not attached to any collection (they can but not the main use case). + + So the work context must not need to have a collection, but when + it has no collection, it must at least have an 'env'. + + When no collection is provided, the methods to get the Components + cannot be used, but :meth:`work_on` can be used to switch back to + a :class:`odoo.addons.component.core.WorkContext` with collection. + This is needed when one want to get a component for a collection + from inside an event listener. + + """ + + def __init__( + self, + model_name=None, + collection=None, + env=None, + components_registry=None, + **kwargs, + ): + if not (collection is not None or env): + raise ValueError("collection or env is required") + if collection and env: + # when a collection is used, the env will be the one of + # the collection + raise ValueError("collection and env cannot both be provided") + + self.env = env + super().__init__( + model_name=model_name, + collection=collection, + components_registry=components_registry, + **kwargs, + ) + if self._env: + self._propagate_kwargs.remove("collection") + self._propagate_kwargs.append("env") + + @property + def env(self): + """Return the current Odoo env""" + if self._env: + return self._env + return super().env + + @env.setter + def env(self, value): + self._env = value + + @property + def collection(self): + """Return the current Odoo env""" + if self._collection is not None: + return self._collection + raise ValueError("No collection, it is optional for EventWorkContext") + + @collection.setter + def collection(self, value): + self._collection = value + + def work_on(self, model_name=None, collection=None): + """Create a new work context for another model keeping attributes + + Used when one need to lookup components for another model. + + Used on an EventWorkContext, it switch back to a normal + WorkContext. It means we are inside an event listener, and + we want to get a component. We need to set a collection + to be able to get components. + """ + if self._collection is None and collection is None: + raise ValueError("you must provide a collection to work with") + if collection is not None: + if self.env is not collection.env: + raise ValueError( + "the Odoo env of the collection must be " + "the same than the current one" + ) + kwargs = { + attr_name: getattr(self, attr_name) for attr_name in self._propagate_kwargs + } + kwargs.pop("env", None) + if collection is not None: + kwargs["collection"] = collection + if model_name is not None: + kwargs["model_name"] = model_name + return WorkContext(**kwargs) + + def component_by_name(self, name, model_name=None): + if self._collection is not None: + # switch to a normal WorkContext + work = self.work_on(collection=self._collection, model_name=model_name) + else: + raise TypeError( + "Can't be used on an EventWorkContext without collection. " + "The collection must be known to find components.\n" + "Hint: you can set the collection and get a component with:\n" + ">>> work.work_on(collection=self.env[...].browse(...))\n" + ">>> work.component_by_name(name, model_name=model_name)" + ) + return work.component_by_name(name, model_name=model_name) + + def component(self, usage=None, model_name=None): + if self._collection is not None: + # switch to a normal WorkContext + work = self.work_on(collection=self._collection, model_name=model_name) + else: + raise TypeError( + "Can't be used on an EventWorkContext without collection. " + "The collection must be known to find components.\n" + "Hint: you can set the collection and get a component with:\n" + ">>> work.work_on(collection=self.env[...].browse(...))\n" + ">>> work.component(usage=usage, model_name=model_name)" + ) + return work.component(usage=usage, model_name=model_name) + + def many_components(self, usage=None, model_name=None): + if self._collection is not None: + # switch to a normal WorkContext + work = self.work_on(collection=self._collection, model_name=model_name) + else: + raise TypeError( + "Can't be used on an EventWorkContext without collection. " + "The collection must be known to find components.\n" + "Hint: you can set the collection and get a component with:\n" + ">>> work.work_on(collection=self.env[...].browse(...))\n" + ">>> work.many_components(usage=usage, model_name=model_name)" + ) + return work.component(usage=usage, model_name=model_name) + + def __str__(self): + return "EventWorkContext({},{})".format( + repr(self._env or self._collection), self.model_name + ) diff --git a/component_event/i18n/component_event.pot b/component_event/i18n/component_event.pot new file mode 100644 index 000000000..d5bc50f96 --- /dev/null +++ b/component_event/i18n/component_event.pot @@ -0,0 +1,19 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * component_event +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: component_event +#: model:ir.model,name:component_event.model_base +msgid "Base" +msgstr "" diff --git a/component_event/i18n/es.po b/component_event/i18n/es.po new file mode 100644 index 000000000..dc687565d --- /dev/null +++ b/component_event/i18n/es.po @@ -0,0 +1,22 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * component_event +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-08-02 13:09+0000\n" +"Last-Translator: Ivorra78 \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: component_event +#: model:ir.model,name:component_event.model_base +msgid "Base" +msgstr "Base" diff --git a/component_event/i18n/fr.po b/component_event/i18n/fr.po new file mode 100644 index 000000000..f5c6daaae --- /dev/null +++ b/component_event/i18n/fr.po @@ -0,0 +1,27 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * component_event +# +# Translators: +# Nicolas JEUDY , 2018 +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 11.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-02-01 01:48+0000\n" +"PO-Revision-Date: 2018-02-01 01:48+0000\n" +"Last-Translator: Nicolas JEUDY , 2018\n" +"Language-Team: French (https://www.transifex.com/oca/teams/23907/fr/)\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#. module: component_event +#: model:ir.model,name:component_event.model_base +msgid "Base" +msgstr "" + +#~ msgid "base" +#~ msgstr "base" diff --git a/component_event/i18n/zh_CN.po b/component_event/i18n/zh_CN.po new file mode 100644 index 000000000..6d574eac3 --- /dev/null +++ b/component_event/i18n/zh_CN.po @@ -0,0 +1,22 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * component_event +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2019-09-01 06:14+0000\n" +"Last-Translator: 黎伟杰 <674416404@qq.com>\n" +"Language-Team: none\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 3.8\n" + +#. module: component_event +#: model:ir.model,name:component_event.model_base +msgid "Base" +msgstr "基础" diff --git a/component_event/models/__init__.py b/component_event/models/__init__.py new file mode 100644 index 000000000..0e4444933 --- /dev/null +++ b/component_event/models/__init__.py @@ -0,0 +1 @@ +from . import base diff --git a/component_event/models/base.py b/component_event/models/base.py new file mode 100644 index 000000000..97f3b8c12 --- /dev/null +++ b/component_event/models/base.py @@ -0,0 +1,119 @@ +# Copyright 2017 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +""" +Base Model +========== + +Extend the 'base' Odoo Model to add Events related features. + + +""" + +from odoo import api, models + +from odoo.addons.component.core import _component_databases + +from ..components.event import CollectedEvents +from ..core import EventWorkContext + + +class Base(models.AbstractModel): + """The base model, which is implicitly inherited by all models. + + Add an :meth:`_event` method to all Models. This method allows to + trigger events. + + It also notifies the following events: + + * ``on_record_create(self, record, fields=None)`` + * ``on_record_write(self, record, fields=none)`` + * ``on_record_unlink(self, record)`` + + ``on_record_unlink`` is notified just *before* the unlink is done. + + """ + + _inherit = "base" + + def _event(self, name, collection=None, components_registry=None): + """Collect events for notifications + + Usage:: + + def button_do_something(self): + for record in self: + # do something + self._event('on_do_something').notify('something') + + With this line, every listener having a ``on_do_something`` method + with be called and receive 'something' as argument. + + See: :mod:`..components.event` + + :param name: name of the event, start with 'on_' + :param collection: optional collection to filter on, only + listeners with similar ``_collection`` will be + notified + :param components_registry: component registry for lookups, + mainly used for tests + :type components_registry: + :class:`odoo.addons.components.core.ComponentRegistry` + + + """ + dbname = self.env.cr.dbname + components_registry = self.env.context.get( + "components_registry", components_registry + ) + comp_registry = components_registry or _component_databases.get(dbname) + if not comp_registry or not comp_registry.ready: + # No event should be triggered before the registry has been loaded + # This is a very special case, when the odoo registry is being + # built, it calls odoo.modules.loading.load_modules(). + # This function might trigger events (by writing on records, ...). + # But at this point, the component registry is not guaranteed + # to be ready, and anyway we should probably not trigger events + # during the initialization. Hence we return an empty list of + # events, the 'notify' calls will do nothing. + return CollectedEvents([]) + if not comp_registry.get("base.event.collecter"): + return CollectedEvents([]) + + model_name = self._name + if collection is not None: + work = EventWorkContext( + collection=collection, + model_name=model_name, + components_registry=components_registry, + ) + else: + work = EventWorkContext( + env=self.env, + model_name=model_name, + components_registry=components_registry, + ) + + collecter = work._component_class_by_name("base.event.collecter")(work) + return collecter.collect_events(name) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for idx, vals in enumerate(vals_list): + fields = list(vals.keys()) + self._event("on_record_create").notify(records[idx], fields=fields) + return records + + def write(self, vals): + result = super().write(vals) + fields = list(vals.keys()) + for record in self: + self._event("on_record_write").notify(record, fields=fields) + return result + + def unlink(self): + for record in self: + self._event("on_record_unlink").notify(record) + result = super().unlink() + return result diff --git a/component_event/pyproject.toml b/component_event/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/component_event/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/component_event/readme/CONTRIBUTORS.md b/component_event/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..7f97cd053 --- /dev/null +++ b/component_event/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Guewen Baconnier \<\> diff --git a/component_event/readme/DESCRIPTION.md b/component_event/readme/DESCRIPTION.md new file mode 100644 index 000000000..d3970c0b4 --- /dev/null +++ b/component_event/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +This module implements an event system ([Observer +pattern](https://en.wikipedia.org/wiki/Observer_pattern)) and is a base +block for the Connector Framework. It can be used without using the full +Connector though. It is built upon the `component` module. + +Documentation: diff --git a/component_event/readme/HISTORY.md b/component_event/readme/HISTORY.md new file mode 100644 index 000000000..b0d14a600 --- /dev/null +++ b/component_event/readme/HISTORY.md @@ -0,0 +1,5 @@ +## Next + +## 12.0.1.0.0 (2018-11-26) + +- \[MIGRATION\] from 12.0 branched at rev. 324e006 diff --git a/component_event/readme/USAGE.md b/component_event/readme/USAGE.md new file mode 100644 index 000000000..b8f2035ac --- /dev/null +++ b/component_event/readme/USAGE.md @@ -0,0 +1,33 @@ +As a developer, you have access to a events system. You can find the +documentation in the code or on + +In a nutshell, you can create trigger events: + + class Base(models.AbstractModel): + _inherit = 'base' + + @api.model + def create(self, vals): + record = super(Base, self).create(vals) + self._event('on_record_create').notify(record, fields=vals.keys()) + return record + +And subscribe listeners to the events: + + from odoo.addons.component.core import Component + from odoo.addons.component_event import skip_if + + class MagentoListener(Component): + _name = 'magento.event.listener' + _inherit = 'base.connector.listener' + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + """ Called when a record is created """ + record.with_delay().export_record(fields=fields) + +This module triggers 3 events: + +- `on_record_create(record, fields=None)` +- `on_record_write(record, fields=None)` +- `on_record_unlink(record)` diff --git a/component_event/static/description/icon.png b/component_event/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/component_event/static/description/icon.png differ diff --git a/component_event/static/description/index.html b/component_event/static/description/index.html new file mode 100644 index 000000000..2120b958d --- /dev/null +++ b/component_event/static/description/index.html @@ -0,0 +1,479 @@ + + + + + + +Components Events + + + +
+

Components Events

+ + +

Beta License: LGPL-3 OCA/connector Translate me on Weblate Try me on Runboat

+

This module implements an event system (Observer +pattern) and is a +base block for the Connector Framework. It can be used without using the +full Connector though. It is built upon the component module.

+

Documentation: http://odoo-connector.com/

+

Table of contents

+ +
+

Usage

+

As a developer, you have access to a events system. You can find the +documentation in the code or on http://odoo-connector.com

+

In a nutshell, you can create trigger events:

+
+class Base(models.AbstractModel):
+    _inherit = 'base'
+
+    @api.model
+    def create(self, vals):
+        record = super(Base, self).create(vals)
+        self._event('on_record_create').notify(record, fields=vals.keys())
+        return record
+
+

And subscribe listeners to the events:

+
+from odoo.addons.component.core import Component
+from odoo.addons.component_event import skip_if
+
+class MagentoListener(Component):
+    _name = 'magento.event.listener'
+    _inherit = 'base.connector.listener'
+
+    @skip_if(lambda self, record, **kwargs: self.no_connector_export(record))
+    def on_record_create(self, record, fields=None):
+        """ Called when a record is created """
+        record.with_delay().export_record(fields=fields)
+
+

This module triggers 3 events:

+
    +
  • on_record_create(record, fields=None)
  • +
  • on_record_write(record, fields=None)
  • +
  • on_record_unlink(record)
  • +
+
+
+

Changelog

+ +
+

12.0.1.0.0 (2018-11-26)

+
    +
  • [MIGRATION] from 12.0 branched at rev. 324e006
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/connector project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/component_event/tests/__init__.py b/component_event/tests/__init__.py new file mode 100644 index 000000000..8d73378b6 --- /dev/null +++ b/component_event/tests/__init__.py @@ -0,0 +1 @@ +from . import test_event diff --git a/component_event/tests/test_event.py b/component_event/tests/test_event.py new file mode 100644 index 000000000..770764fd2 --- /dev/null +++ b/component_event/tests/test_event.py @@ -0,0 +1,461 @@ +# Copyright 2017 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from unittest import mock + +from odoo.tests.case import TestCase +from odoo.tests.common import MetaCase, tagged + +from odoo.addons.component.core import Component +from odoo.addons.component.tests.common import ( + ComponentRegistryCase, + TransactionComponentRegistryCase, +) +from odoo.addons.component_event.components.event import skip_if +from odoo.addons.component_event.core import EventWorkContext + + +@tagged("standard", "at_install") +class TestEventWorkContext(TestCase, MetaCase("DummyCase", (), {})): + """Test Events Components""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.test_sequence = 0 + + def setUp(self): + super().setUp() + self.env = mock.MagicMock(name="env") + self.record = mock.MagicMock(name="record") + self.components_registry = mock.MagicMock(name="ComponentRegistry") + + def test_env(self): + """WorkContext with env""" + work = EventWorkContext( + model_name="res.users", + env=self.env, + components_registry=self.components_registry, + ) + self.assertEqual(self.env, work.env) + self.assertEqual("res.users", work.model_name) + with self.assertRaises(ValueError): + # pylint: disable=W0104 + work.collection # noqa + + def test_collection(self): + """WorkContext with collection""" + env = mock.MagicMock(name="env") + collection = mock.MagicMock(name="collection") + collection.env = env + work = EventWorkContext( + model_name="res.users", + collection=collection, + components_registry=self.components_registry, + ) + self.assertEqual(collection, work.collection) + self.assertEqual(env, work.env) + self.assertEqual("res.users", work.model_name) + + def test_env_and_collection(self): + """WorkContext with collection and env is forbidden""" + env = mock.MagicMock(name="env") + collection = mock.MagicMock(name="collection") + collection.env = env + with self.assertRaises(ValueError): + EventWorkContext( + model_name="res.users", + collection=collection, + env=env, + components_registry=self.components_registry, + ) + + def test_missing(self): + """WorkContext with collection and env is forbidden""" + with self.assertRaises(ValueError): + EventWorkContext( + model_name="res.users", components_registry=self.components_registry + ) + + def test_env_work_on(self): + """WorkContext propagated through work_on""" + env = mock.MagicMock(name="env") + collection = mock.MagicMock(name="collection") + collection.env = env + work = EventWorkContext( + env=env, + model_name="res.users", + components_registry=self.components_registry, + ) + work2 = work.work_on(model_name="res.partner", collection=collection) + self.assertEqual("WorkContext", work2.__class__.__name__) + self.assertEqual(env, work2.env) + self.assertEqual("res.partner", work2.model_name) + self.assertEqual(self.components_registry, work2.components_registry) + with self.assertRaises(ValueError): + # pylint: disable=W0104 + work.collection # noqa + + def test_collection_work_on(self): + """WorkContext propagated through work_on""" + env = mock.MagicMock(name="env") + collection = mock.MagicMock(name="collection") + collection.env = env + work = EventWorkContext( + collection=collection, + model_name="res.users", + components_registry=self.components_registry, + ) + work2 = work.work_on(model_name="res.partner") + self.assertEqual("WorkContext", work2.__class__.__name__) + self.assertEqual(collection, work2.collection) + self.assertEqual(env, work2.env) + self.assertEqual("res.partner", work2.model_name) + self.assertEqual(self.components_registry, work2.components_registry) + + def test_collection_work_on_collection(self): + """WorkContext collection changed with work_on""" + env = mock.MagicMock(name="env") + collection = mock.MagicMock(name="collection") + collection.env = env + work = EventWorkContext( + model_name="res.users", + env=env, + components_registry=self.components_registry, + ) + work2 = work.work_on(collection=collection) + # when work_on is used inside an event component, we want + # to switch back to a normal WorkContext, because we don't + # need anymore the EventWorkContext + self.assertEqual("WorkContext", work2.__class__.__name__) + self.assertEqual(collection, work2.collection) + self.assertEqual(env, work2.env) + self.assertEqual("res.users", work2.model_name) + self.assertEqual(self.components_registry, work2.components_registry) + + +class TestEvent(ComponentRegistryCase): + """Test Events Components""" + + def setUp(self): + super().setUp() + self._setup_registry(self) + self._load_module_components("component_event") + + # get the collecter to notify the event + # we don't mind about the collection and the model here, + # the events we test are global + env = mock.MagicMock() + self.work = EventWorkContext( + model_name="res.users", env=env, components_registry=self.comp_registry + ) + self.collecter = self.comp_registry["base.event.collecter"](self.work) + + def test_event(self): + class MyEventListener(Component): + _name = "my.event.listener" + _inherit = "base.event.listener" + + def on_record_create(self, recipient, something, fields=None): + recipient.append(("OK", something, fields)) + + MyEventListener._build_component(self.comp_registry) + + something = object() + fields = ["name", "code"] + + # as there is no return value by the event, we + # modify this recipient to check it has been called + recipient = [] + + # collect the event and notify it + self.collecter.collect_events("on_record_create").notify( + recipient, something, fields=fields + ) + self.assertEqual([("OK", something, fields)], recipient) + + def test_collect_several(self): + class MyEventListener(Component): + _name = "my.event.listener" + _inherit = "base.event.listener" + + def on_record_create(self, recipient, something, fields=None): + recipient.append(("OK", something, fields)) + + class MyOtherEventListener(Component): + _name = "my.other.event.listener" + _inherit = "base.event.listener" + + def on_record_create(self, recipient, something, fields=None): + recipient.append(("OK", something, fields)) + + MyEventListener._build_component(self.comp_registry) + MyOtherEventListener._build_component(self.comp_registry) + + something = object() + fields = ["name", "code"] + + # as there is no return value by the event, we + # modify this recipient to check it has been called + recipient = [] + + # collect the event and notify them + collected = self.collecter.collect_events("on_record_create") + self.assertEqual(2, len(collected.events)) + + collected.notify(recipient, something, fields=fields) + self.assertEqual( + [("OK", something, fields), ("OK", something, fields)], recipient + ) + + def test_event_cache(self): + class MyEventListener(Component): + _name = "my.event.listener" + _inherit = "base.event.listener" + + def on_record_create(self): + pass + + MyEventListener._build_component(self.comp_registry) + + # collect the event + collected = self.collecter.collect_events("on_record_create") + # CollectedEvents.events contains the collected events + self.assertEqual(1, len(collected.events)) + event = list(collected.events)[0] + self.assertEqual(self.work, event.__self__.work) + self.assertEqual(self.work.env, event.__self__.work.env) + + # build and register a new listener + class MyOtherEventListener(Component): + _name = "my.other.event.listener" + _inherit = "base.event.listener" + + def on_record_create(self): + pass + + MyOtherEventListener._build_component(self.comp_registry) + + # get a new collecter and check that we it finds the same + # events even if we built a new one: it means the cache works + env = mock.MagicMock() + work = EventWorkContext( + model_name="res.users", env=env, components_registry=self.comp_registry + ) + collecter = self.comp_registry["base.event.collecter"](work) + collected = collecter.collect_events("on_record_create") + # CollectedEvents.events contains the collected events + self.assertEqual(1, len(collected.events)) + event = list(collected.events)[0] + self.assertEqual(work, event.__self__.work) + self.assertEqual(env, event.__self__.work.env) + + # if we empty the cache, as it on the class, both collecters + # should now find the 2 events + collecter._cache.clear() + self.comp_registry._cache.clear() + # CollectedEvents.events contains the collected events + self.assertEqual(2, len(collecter.collect_events("on_record_create").events)) + self.assertEqual( + 2, len(self.collecter.collect_events("on_record_create").events) + ) + + def test_event_cache_collection(self): + class MyEventListener(Component): + _name = "my.event.listener" + _inherit = "base.event.listener" + + def on_record_create(self): + pass + + MyEventListener._build_component(self.comp_registry) + + # collect the event + collected = self.collecter.collect_events("on_record_create") + # CollectedEvents.events contains the collected events + self.assertEqual(1, len(collected.events)) + + # build and register a new listener + class MyOtherEventListener(Component): + _name = "my.other.event.listener" + _inherit = "base.event.listener" + _collection = "base.collection" + + def on_record_create(self): + pass + + MyOtherEventListener._build_component(self.comp_registry) + + # get a new collecter and check that we it finds the same + # events even if we built a new one: it means the cache works + collection = mock.MagicMock(name="base.collection") + collection._name = "base.collection" + collection.env = mock.MagicMock() + work = EventWorkContext( + model_name="res.users", + collection=collection, + components_registry=self.comp_registry, + ) + collecter = self.comp_registry["base.event.collecter"](work) + collected = collecter.collect_events("on_record_create") + # for a different collection, we should not have the same + # cache entry + self.assertEqual(2, len(collected.events)) + + def test_event_cache_model_name(self): + class MyEventListener(Component): + _name = "my.event.listener" + _inherit = "base.event.listener" + + def on_record_create(self): + pass + + MyEventListener._build_component(self.comp_registry) + + # collect the event + collected = self.collecter.collect_events("on_record_create") + # CollectedEvents.events contains the collected events + self.assertEqual(1, len(collected.events)) + + # build and register a new listener + class MyOtherEventListener(Component): + _name = "my.other.event.listener" + _inherit = "base.event.listener" + _apply_on = ["res.country"] + + def on_record_create(self): + pass + + MyOtherEventListener._build_component(self.comp_registry) + + # get a new collecter and check that we it finds the same + # events even if we built a new one: it means the cache works + env = mock.MagicMock() + work = EventWorkContext( + model_name="res.country", env=env, components_registry=self.comp_registry + ) + collecter = self.comp_registry["base.event.collecter"](work) + collected = collecter.collect_events("on_record_create") + # for a different collection, we should not have the same + # cache entry + self.assertEqual(2, len(collected.events)) + + def test_skip_if(self): + class MyEventListener(Component): + _name = "my.event.listener" + _inherit = "base.event.listener" + + def on_record_create(self, msg): + pass + + class MyOtherEventListener(Component): + _name = "my.other.event.listener" + _inherit = "base.event.listener" + + @skip_if(lambda self, msg: msg == "foo") + def on_record_create(self, msg): + raise AssertionError() + + self._build_components(MyEventListener, MyOtherEventListener) + + # collect the event and notify it + collected = self.collecter.collect_events("on_record_create") + self.assertEqual(2, len(collected.events)) + collected.notify("foo") + + +class TestEventFromModel(TransactionComponentRegistryCase): + """Test Events Components from Models""" + + def setUp(self): + super().setUp() + self._setup_registry(self) + self._load_module_components("component_event") + + def test_event_from_model(self): + class MyEventListener(Component): + _name = "my.event.listener" + _inherit = "base.event.listener" + + def on_foo(self, record, name): + record.name = name + + MyEventListener._build_component(self.comp_registry) + + partner = self.env["res.partner"].create({"name": "test"}) + # Normally you would not pass a components_registry, + # this is for the sake of the test, letting it empty + # will use the global registry. + # In a real code it would look like: + # partner._event('on_foo').notify('bar') + events = partner._event("on_foo", components_registry=self.comp_registry) + events.notify(partner, "bar") + self.assertEqual("bar", partner.name) + + def test_event_filter_on_model(self): + class GlobalListener(Component): + _name = "global.event.listener" + _inherit = "base.event.listener" + + def on_foo(self, record, name): + record.name = name + + class PartnerListener(Component): + _name = "partner.event.listener" + _inherit = "base.event.listener" + _apply_on = ["res.partner"] + + def on_foo(self, record, name): + record.ref = name + + class UserListener(Component): + _name = "user.event.listener" + _inherit = "base.event.listener" + _apply_on = ["res.users"] + + def on_foo(self, record, name): + raise AssertionError() + + self._build_components(GlobalListener, PartnerListener, UserListener) + + partner = self.env["res.partner"].create({"name": "test"}) + partner._event("on_foo", components_registry=self.comp_registry).notify( + partner, "bar" + ) + self.assertEqual("bar", partner.name) + self.assertEqual("bar", partner.ref) + + def test_event_filter_on_collection(self): + class GlobalListener(Component): + _name = "global.event.listener" + _inherit = "base.event.listener" + + def on_foo(self, record, name): + record.name = name + + class PartnerListener(Component): + _name = "partner.event.listener" + _inherit = "base.event.listener" + _collection = "collection.base" + + def on_foo(self, record, name): + record.ref = name + + class UserListener(Component): + _name = "user.event.listener" + _inherit = "base.event.listener" + _collection = "magento.backend" + + def on_foo(self, record, name): + raise AssertionError() + + self._build_components(GlobalListener, PartnerListener, UserListener) + + partner = self.env["res.partner"].create({"name": "test"}) + events = partner._event( + "on_foo", + collection=self.env["collection.base"], + components_registry=self.comp_registry, + ) + events.notify(partner, "bar") + self.assertEqual("bar", partner.name) + self.assertEqual("bar", partner.ref)