From ee9fe4912f864c87643056ec2b3d377f787cb9f5 Mon Sep 17 00:00:00 2001 From: Jose Zambudio Date: Tue, 16 Dec 2025 07:32:11 +0100 Subject: [PATCH] [17.0][ADD] bus_record_events: New Module --- bus_record_events/README.rst | 290 ++++++++ bus_record_events/__init__.py | 1 + bus_record_events/__manifest__.py | 38 + bus_record_events/models/__init__.py | 2 + bus_record_events/models/bus_event_mixin.py | 166 +++++ bus_record_events/models/ir_websocket.py | 42 ++ bus_record_events/pyproject.toml | 3 + bus_record_events/readme/CONFIGURE.md | 1 + bus_record_events/readme/CONTEXT.md | 1 + bus_record_events/readme/CONTRIBUTORS.md | 2 + bus_record_events/readme/CREDITS.md | 3 + bus_record_events/readme/DESCRIPTION.md | 7 + bus_record_events/readme/HISTORY.md | 4 + bus_record_events/readme/INSTALL.md | 1 + bus_record_events/readme/ROADMAP.md | 3 + bus_record_events/readme/USAGE.md | 110 +++ .../static/description/index.html | 671 ++++++++++++++++++ .../src/js/hooks/use_record_stream.esm.js | 91 +++ .../services/bus_record_event_service.esm.js | 56 ++ .../calendar/bus_calendar_controller.esm.js | 23 + .../js/views/form/bus_form_controller.esm.js | 28 + .../views/graph/bus_graph_controller.esm.js | 23 + .../views/kanban/bus_kanban_controller.esm.js | 23 + .../js/views/list/bus_list_controller.esm.js | 23 + .../views/pivot/bus_pivot_controller.esm.js | 41 ++ .../unit/hooks/use_record_stream_tests.esm.js | 93 +++ .../bus_record_event_service_tests.esm.js | 54 ++ .../unit/views/bus_calendar_view_tests.esm.js | 68 ++ .../unit/views/bus_form_view_tests.esm.js | 91 +++ .../unit/views/bus_graph_view_tests.esm.js | 74 ++ .../unit/views/bus_kanban_view_tests.esm.js | 65 ++ .../unit/views/bus_list_view_tests.esm.js | 68 ++ .../unit/views/bus_pivot_view_tests.esm.js | 73 ++ bus_record_events/tests/__init__.py | 3 + bus_record_events/tests/common.py | 26 + .../tests/test_bus_record_events.py | 167 +++++ .../tests/test_bus_record_events_websocket.py | 149 ++++ bus_record_events/tests/test_js.py | 13 + bus_record_events_all/README.rst | 136 ++++ bus_record_events_all/__init__.py | 1 + bus_record_events_all/__manifest__.py | 15 + bus_record_events_all/models/__init__.py | 1 + bus_record_events_all/models/base.py | 64 ++ bus_record_events_all/pyproject.toml | 3 + bus_record_events_all/readme/CONFIGURE.md | 1 + bus_record_events_all/readme/CONTEXT.md | 1 + bus_record_events_all/readme/CONTRIBUTORS.md | 2 + bus_record_events_all/readme/CREDITS.md | 3 + bus_record_events_all/readme/DESCRIPTION.md | 5 + bus_record_events_all/readme/HISTORY.md | 4 + bus_record_events_all/readme/INSTALL.md | 1 + bus_record_events_all/readme/ROADMAP.md | 1 + bus_record_events_all/readme/USAGE.md | 1 + .../static/description/index.html | 485 +++++++++++++ bus_record_events_demo/README.rst | 130 ++++ bus_record_events_demo/__init__.py | 1 + bus_record_events_demo/__manifest__.py | 21 + .../demo/bus_record_event_demo.xml | 17 + bus_record_events_demo/demo/res_users.xml | 27 + bus_record_events_demo/models/__init__.py | 1 + .../models/bus_record_event_demo.py | 23 + bus_record_events_demo/pyproject.toml | 3 + bus_record_events_demo/readme/CONFIGURE.md | 1 + bus_record_events_demo/readme/CONTEXT.md | 1 + bus_record_events_demo/readme/CONTRIBUTORS.md | 2 + bus_record_events_demo/readme/CREDITS.md | 3 + bus_record_events_demo/readme/DESCRIPTION.md | 1 + bus_record_events_demo/readme/HISTORY.md | 4 + bus_record_events_demo/readme/INSTALL.md | 1 + bus_record_events_demo/readme/ROADMAP.md | 1 + bus_record_events_demo/readme/USAGE.md | 5 + .../security/bus_record_events_demo.xml | 18 + .../security/ir.model.access.csv | 3 + .../static/description/index.html | 483 +++++++++++++ .../views/bus_record_event_demo_views.xml | 171 +++++ 75 files changed, 4238 insertions(+) create mode 100644 bus_record_events/README.rst create mode 100644 bus_record_events/__init__.py create mode 100644 bus_record_events/__manifest__.py create mode 100644 bus_record_events/models/__init__.py create mode 100644 bus_record_events/models/bus_event_mixin.py create mode 100644 bus_record_events/models/ir_websocket.py create mode 100644 bus_record_events/pyproject.toml create mode 100644 bus_record_events/readme/CONFIGURE.md create mode 100644 bus_record_events/readme/CONTEXT.md create mode 100644 bus_record_events/readme/CONTRIBUTORS.md create mode 100644 bus_record_events/readme/CREDITS.md create mode 100644 bus_record_events/readme/DESCRIPTION.md create mode 100644 bus_record_events/readme/HISTORY.md create mode 100644 bus_record_events/readme/INSTALL.md create mode 100644 bus_record_events/readme/ROADMAP.md create mode 100644 bus_record_events/readme/USAGE.md create mode 100644 bus_record_events/static/description/index.html create mode 100644 bus_record_events/static/src/js/hooks/use_record_stream.esm.js create mode 100644 bus_record_events/static/src/js/services/bus_record_event_service.esm.js create mode 100644 bus_record_events/static/src/js/views/calendar/bus_calendar_controller.esm.js create mode 100644 bus_record_events/static/src/js/views/form/bus_form_controller.esm.js create mode 100644 bus_record_events/static/src/js/views/graph/bus_graph_controller.esm.js create mode 100644 bus_record_events/static/src/js/views/kanban/bus_kanban_controller.esm.js create mode 100644 bus_record_events/static/src/js/views/list/bus_list_controller.esm.js create mode 100644 bus_record_events/static/src/js/views/pivot/bus_pivot_controller.esm.js create mode 100644 bus_record_events/static/tests/unit/hooks/use_record_stream_tests.esm.js create mode 100644 bus_record_events/static/tests/unit/services/bus_record_event_service_tests.esm.js create mode 100644 bus_record_events/static/tests/unit/views/bus_calendar_view_tests.esm.js create mode 100644 bus_record_events/static/tests/unit/views/bus_form_view_tests.esm.js create mode 100644 bus_record_events/static/tests/unit/views/bus_graph_view_tests.esm.js create mode 100644 bus_record_events/static/tests/unit/views/bus_kanban_view_tests.esm.js create mode 100644 bus_record_events/static/tests/unit/views/bus_list_view_tests.esm.js create mode 100644 bus_record_events/static/tests/unit/views/bus_pivot_view_tests.esm.js create mode 100644 bus_record_events/tests/__init__.py create mode 100644 bus_record_events/tests/common.py create mode 100644 bus_record_events/tests/test_bus_record_events.py create mode 100644 bus_record_events/tests/test_bus_record_events_websocket.py create mode 100644 bus_record_events/tests/test_js.py create mode 100644 bus_record_events_all/README.rst create mode 100644 bus_record_events_all/__init__.py create mode 100644 bus_record_events_all/__manifest__.py create mode 100644 bus_record_events_all/models/__init__.py create mode 100644 bus_record_events_all/models/base.py create mode 100644 bus_record_events_all/pyproject.toml create mode 100644 bus_record_events_all/readme/CONFIGURE.md create mode 100644 bus_record_events_all/readme/CONTEXT.md create mode 100644 bus_record_events_all/readme/CONTRIBUTORS.md create mode 100644 bus_record_events_all/readme/CREDITS.md create mode 100644 bus_record_events_all/readme/DESCRIPTION.md create mode 100644 bus_record_events_all/readme/HISTORY.md create mode 100644 bus_record_events_all/readme/INSTALL.md create mode 100644 bus_record_events_all/readme/ROADMAP.md create mode 100644 bus_record_events_all/readme/USAGE.md create mode 100644 bus_record_events_all/static/description/index.html create mode 100644 bus_record_events_demo/README.rst create mode 100644 bus_record_events_demo/__init__.py create mode 100644 bus_record_events_demo/__manifest__.py create mode 100644 bus_record_events_demo/demo/bus_record_event_demo.xml create mode 100644 bus_record_events_demo/demo/res_users.xml create mode 100644 bus_record_events_demo/models/__init__.py create mode 100644 bus_record_events_demo/models/bus_record_event_demo.py create mode 100644 bus_record_events_demo/pyproject.toml create mode 100644 bus_record_events_demo/readme/CONFIGURE.md create mode 100644 bus_record_events_demo/readme/CONTEXT.md create mode 100644 bus_record_events_demo/readme/CONTRIBUTORS.md create mode 100644 bus_record_events_demo/readme/CREDITS.md create mode 100644 bus_record_events_demo/readme/DESCRIPTION.md create mode 100644 bus_record_events_demo/readme/HISTORY.md create mode 100644 bus_record_events_demo/readme/INSTALL.md create mode 100644 bus_record_events_demo/readme/ROADMAP.md create mode 100644 bus_record_events_demo/readme/USAGE.md create mode 100644 bus_record_events_demo/security/bus_record_events_demo.xml create mode 100644 bus_record_events_demo/security/ir.model.access.csv create mode 100644 bus_record_events_demo/static/description/index.html create mode 100644 bus_record_events_demo/views/bus_record_event_demo_views.xml diff --git a/bus_record_events/README.rst b/bus_record_events/README.rst new file mode 100644 index 000000000000..5edec622b3c4 --- /dev/null +++ b/bus_record_events/README.rst @@ -0,0 +1,290 @@ +================= +Bus Record Events +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:edc60b3b2e2697b562ea36d30a90695ed3b1d0422d04df07a09eb76890efae66 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/17.0/bus_record_events + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-17-0/web-17-0-bus_record_events + :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/web&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides an efficient and secure mechanism to notify CRUD +events (Create, Write, Unlink) in real-time via the Odoo Bus. + +**Key Features:** + +- **Server Efficiency**: Uses Odoo's native ``bus.bus`` system to avoid + table locking and minimize performance impact. +- **Granular Security**: Implements a permission system in + ``ir.websocket`` ensuring users can only subscribe to record or model + channels for which they have read permissions (Access Rights and + Record Rules). + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +This module was designed to provide real-time capabilities to Odoo views +without heavy polling. + +Installation +============ + +This module depends on the ``bus`` and ``web`` modules. + +Configuration +============= + +No specific configuration is required. + +Usage +===== + +For Developers +-------------- + +To enable notifications on a model, simply inherit from the +``bus.record.event.mixin`` mixin: + +.. code:: python + + class MyModel(models.Model): + _name = 'my.model' + _inherit = ['my.model', 'bus.record.event.mixin'] + +Subscription Channels +~~~~~~~~~~~~~~~~~~~~~ + +The module manages two types of channels for client-side subscriptions +(OWL/JS): + +1. **Model Events (``create``)**: + + - Channel: ``record_events:{model_name}`` + - Example: ``record_events:res.partner`` + - Requires: Read permissions at the model level. + +2. **Record Events (``write``, ``unlink``)**: + + - Channel: ``record_events:{model_name}:{record_id}`` + - Example: ``record_events:res.partner:15`` + - Requires: Read permissions on the specific record (record rules + applied). + +Reactive Views +-------------- + +The module includes extended view controllers that automatically listen +for these events and update the interface or notify the user. + +To use these reactive views, you must specify the ``js_class`` attribute +in the XML view definition. + +Available Views +~~~~~~~~~~~~~~~ + ++--------------+-------------------------------+---------------------------+ +| View Type | js_class | Behavior | ++==============+===============================+===========================+ +| **Form** | ``bus_record_event_form`` | Notifies if the record | +| | | has been modified or | +| | | deleted by another user | +| | | while editing. If there | +| | | are no unsaved changes, | +| | | it automatically reloads | +| | | the data. | ++--------------+-------------------------------+---------------------------+ +| **Kanban** | ``bus_record_event_kanban`` | Automatically reloads the | +| | | view upon receiving | +| | | create, update, or delete | +| | | events in the model. | ++--------------+-------------------------------+---------------------------+ +| **Pivot** | ``bus_record_event_pivot`` | Automatically reloads the | +| | | view upon changes in the | +| | | model. | ++--------------+-------------------------------+---------------------------+ +| **Graph** | ``bus_record_event_graph`` | Automatically reloads the | +| | | view upon changes in the | +| | | model. | ++--------------+-------------------------------+---------------------------+ +| **Calendar** | ``bus_record_event_calendar`` | Automatically reloads the | +| | | view upon changes in the | +| | | model. | ++--------------+-------------------------------+---------------------------+ +| **List** | ``bus_record_event_list`` | Automatically reloads the | +| | | view upon changes in the | +| | | model. | ++--------------+-------------------------------+---------------------------+ + +Usage Example (XML) +~~~~~~~~~~~~~~~~~~~ + +.. code:: xml + + + my.model.form + my.model + +
+ +
+
+
+ + + my.model.kanban + my.model + + + + + + + +JavaScript API +-------------- + +Service: ``bus_record_event_service`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The module provides a service to manage subscriptions to record events. + +.. code:: javascript + + const busRecordEventService = useService("bus_record_event_service"); + + // Add a channel to listen to + busRecordEventService.addChannel("record_events:res.partner:1"); + + // Subscribe to notifications + const unsubscribe = busRecordEventService.subscribe((payload) => { + console.log("Received event:", payload); + }); + + // Clean up + unsubscribe(); + +Hook: ``useRecordStream`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +For OWL components, it is recommended to use the ``useRecordStream`` +hook. It handles the lifecycle of the subscription (adding channels on +start, unsubscribing on unmount). + +.. code:: javascript + + import { useRecordStream } from "@bus_record_events/js/hooks/use_record_stream.esm"; + + setup() { + useRecordStream("res.partner", { + id: this.props.resId, // Optional: Listen to a specific record + onReload: async () => { + // Callback to reload the view/component + await this.model.load(); + }, + onUpdate: async (payload) => { + // Optional: Custom handling of the event + }, + filter: (payload) => { + // Optional: Filter events before processing + return true; + } + }); + } + +Known issues / Roadmap +====================== + +- No known issues. +- **Data Optimization**: Notification payloads compressed using ``zlib`` + and encoded in ``base64``. (Discarded for now: Tested but with + thousands of messages it generated too many ``blob:...`` threads in + the client browser, making it unable to manage all messages). +- **Permission Revocation Handling**: Currently, if a client loses + permission to a record they are already subscribed to, they might + continue receiving updates. It would be beneficial to find a + server-side mechanism to force-unsubscribe a client from a specific + channel when their permissions change. + +Changelog +========= + +17.0.1.0.0 (2023-12-16) + +:: + + + * Initial release. + +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 +------- + +* Heligrafics Fotogrametria S.L. + +Contributors +------------ + +- Heligrafics Fotogrametria S.L. : + + - Jose Zambudio + +Other credits +------------- + +The development of this module has been financially supported by: + +- Heligrafics Fotogrametria S.L. + +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/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/bus_record_events/__init__.py b/bus_record_events/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/bus_record_events/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/bus_record_events/__manifest__.py b/bus_record_events/__manifest__.py new file mode 100644 index 000000000000..c1645c6761bf --- /dev/null +++ b/bus_record_events/__manifest__.py @@ -0,0 +1,38 @@ +{ + "name": "Bus Record Events", + "version": "17.0.1.0.0", + "category": "Web", + "summary": "Broadcast CRUD operations (Create, Write, Unlink) via Odoo " + "Bus for OWL reactivity.", + "author": "Heligrafics Fotogrametria S.L., Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "license": "AGPL-3", + "depends": [ + "base", + "bus", + "web", + ], + "data": [], + "assets": { + "web.assets_backend": [ + "bus_record_events/static/src/js/services/*.js", + "bus_record_events/static/src/js/hooks/*.js", + "bus_record_events/static/src/js/views/form/*.js", + "bus_record_events/static/src/js/views/kanban/*.js", + "bus_record_events/static/src/js/views/pivot/*.js", + "bus_record_events/static/src/js/views/graph/*.js", + "bus_record_events/static/src/js/views/calendar/*.js", + "bus_record_events/static/src/js/views/list/*.js", + ], + "web.qunit_suite_tests": [ + "web/static/src/legacy/utils.js", + "web/static/src/legacy/js/**/*", + ("remove", "web/static/src/legacy/js/libs/**/*"), + ("remove", "web/static/src/legacy/js/public/**/*"), + "bus_record_events/static/tests/unit/hooks/*.js", + "bus_record_events/static/tests/unit/services/*.js", + "bus_record_events/static/tests/unit/views/*.js", + ], + }, + "installable": True, +} diff --git a/bus_record_events/models/__init__.py b/bus_record_events/models/__init__.py new file mode 100644 index 000000000000..4be0c6083e00 --- /dev/null +++ b/bus_record_events/models/__init__.py @@ -0,0 +1,2 @@ +from . import bus_event_mixin +from . import ir_websocket diff --git a/bus_record_events/models/bus_event_mixin.py b/bus_record_events/models/bus_event_mixin.py new file mode 100644 index 000000000000..5e6bd97d0fd8 --- /dev/null +++ b/bus_record_events/models/bus_event_mixin.py @@ -0,0 +1,166 @@ +from odoo import Command, api, models + + +class BusRecordEventMixin(models.AbstractModel): + _name = "bus.record.event.mixin" + _description = "Mixin to broadcast CRUD events" + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + self._notify_bus_static(records, "create") + return records + + def write(self, vals): + res = super().write(vals) + self._notify_bus_static(self, "write", vals) + return res + + def unlink(self): + notifications = self._prepare_unlink_notifications_static(self) + res = super().unlink() + if notifications: + self.env["bus.bus"]._sendmany(notifications) + return res + + @api.model + def _notify_bus_static(self, records, event_type, vals=None): + """Static-like method to be called from other models.""" + if records._name in ["bus.bus", "bus.presence", "ir.websocket"]: + return + + notifications = [] + for record in records: + # Prepare base payload for model channel (lightweight) + model_payload = { + "model": records._name, + "type": event_type, + "data": {"id": record.id}, + } + model_channel = self._get_bus_channel_name_static(records._name) + notifications.append((model_channel, "bus.record/event", model_payload)) + + # Check if we need to send detailed data to the record channel + if self._check_add_event_data(event_type, vals): + if hasattr(record, "_get_bus_event_data"): + event_data = record._get_bus_event_data(record, event_type, vals) + else: + event_data = self._get_bus_event_data_static( + record, event_type, vals + ) + + record_payload = model_payload.copy() + record_payload["data"] = event_data + record_channel = self._get_bus_channel_name_static( + records._name, record.id + ) + notifications.append( + (record_channel, "bus.record/event", record_payload) + ) + + if notifications: + self.env["bus.bus"]._sendmany(notifications) + + @api.model + def _check_add_event_data(self, event_type, vals=None): + """Check if the model requires additional event data.""" + return event_type == "write" + + @api.model + def _prepare_unlink_notifications_static(self, records): + if records._name in ["bus.bus", "bus.presence", "ir.websocket"]: + return [] + + ids = records.ids + notifications = [] + model_channel = self._get_bus_channel_name_static(records._name) + model_payload = { + "model": records._name, + "type": "unlink", + "ids": ids, + } + notifications.append((model_channel, "bus.record/event", model_payload)) + + for record_id in ids: + channel = self._get_bus_channel_name_static(records._name, record_id) + payload = { + "model": records._name, + "type": "unlink", + "id": record_id, + } + notifications.append((channel, "bus.record/event", payload)) + return notifications + + @api.model + def _get_bus_channel_name_static(self, model_name, record_id=None): + if record_id: + return f"record_events:{model_name}:{record_id}" + return f"record_events:{model_name}" + + @api.model + def _sanitize_event_values(self, record, vals): + """Sanitize values to avoid sending large binary data or raw commands.""" + res = {} + for key, value in vals.items(): + if key not in record._fields: + res[key] = value + continue + + field = record._fields[key] + + if field.type == "binary" and value: + res[key] = "" + + elif field.type == "many2one" and isinstance(value, int) and value: + # Resolve Many2one ID to (ID, Name) for frontend convenience + related_record = record.env[field.comodel_name].browse(value) + res[key] = ( + related_record.id if related_record.exists() else None, + related_record.display_name, + ) + + elif field.type in ("one2many", "many2many") and value: + # If value contains commands, return the full list of IDs + if self._is_x2many_command(value): + res[key] = getattr(record, key).ids + else: + res[key] = value + else: + res[key] = value + return res + + @api.model + def _is_x2many_command(self, value): + """Check if the value contains Odoo x2many commands.""" + if not isinstance(value, list | tuple): + return False + for item in value: + if ( + isinstance(item, list | tuple) + and len(item) >= 1 + and item[0] + in ( + Command.CREATE, + Command.UPDATE, + Command.DELETE, + Command.UNLINK, + Command.LINK, + Command.CLEAR, + Command.SET, + ) + ): + return True + return False + + @api.model + def _get_bus_event_data_static(self, record, event_type, vals=None): + data = { + "id": record.id, + } + + if event_type != "create": + if vals: + data.update(self._sanitize_event_values(record, vals)) + if "display_name" not in data: + data.update({"display_name": record.display_name}) + return data diff --git a/bus_record_events/models/ir_websocket.py b/bus_record_events/models/ir_websocket.py new file mode 100644 index 000000000000..2cc04a4caf3e --- /dev/null +++ b/bus_record_events/models/ir_websocket.py @@ -0,0 +1,42 @@ +from odoo import models + + +class IrWebsocket(models.AbstractModel): + _inherit = "ir.websocket" + + def _build_bus_channel_list(self, channels): + valid_channels = [] + for channel in channels: + if isinstance(channel, str) and channel.startswith("record_events:"): + if self._check_record_event_permission(channel): + valid_channels.append(channel) + else: + valid_channels.append(channel) + return super()._build_bus_channel_list(valid_channels) + + def _check_record_event_permission(self, channel): + parts = channel.split(":") + if len(parts) < 2: + return False + + model_name = parts[1] + if model_name not in self.env: + return False + + if len(parts) == 2: # record_events:model + # Check model read access + try: + self.env[model_name].check_access_rights("read") + return True + except Exception: + return False + elif len(parts) == 3: # record_events:model:id + try: + res_id = int(parts[2]) + record = self.env[model_name].browse(res_id) + record.check_access_rights("read") + record.check_access_rule("read") + return True + except Exception: + return False + return False diff --git a/bus_record_events/pyproject.toml b/bus_record_events/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/bus_record_events/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/bus_record_events/readme/CONFIGURE.md b/bus_record_events/readme/CONFIGURE.md new file mode 100644 index 000000000000..971da4a9b8a1 --- /dev/null +++ b/bus_record_events/readme/CONFIGURE.md @@ -0,0 +1 @@ +No specific configuration is required. diff --git a/bus_record_events/readme/CONTEXT.md b/bus_record_events/readme/CONTEXT.md new file mode 100644 index 000000000000..f37c0c1251ad --- /dev/null +++ b/bus_record_events/readme/CONTEXT.md @@ -0,0 +1 @@ +This module was designed to provide real-time capabilities to Odoo views without heavy polling. diff --git a/bus_record_events/readme/CONTRIBUTORS.md b/bus_record_events/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..7e6d311bae0b --- /dev/null +++ b/bus_record_events/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +* Heligrafics Fotogrametria S.L. \<\>: + - Jose Zambudio \<\> diff --git a/bus_record_events/readme/CREDITS.md b/bus_record_events/readme/CREDITS.md new file mode 100644 index 000000000000..6576257282fc --- /dev/null +++ b/bus_record_events/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +- Heligrafics Fotogrametria S.L. diff --git a/bus_record_events/readme/DESCRIPTION.md b/bus_record_events/readme/DESCRIPTION.md new file mode 100644 index 000000000000..5bc3ca5d709c --- /dev/null +++ b/bus_record_events/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +This module provides an efficient and secure mechanism to notify CRUD events +(Create, Write, Unlink) in real-time via the Odoo Bus. + +**Key Features:** + +* **Server Efficiency**: Uses Odoo's native `bus.bus` system to avoid table locking and minimize performance impact. +* **Granular Security**: Implements a permission system in `ir.websocket` ensuring users can only subscribe to record or model channels for which they have read permissions (Access Rights and Record Rules). diff --git a/bus_record_events/readme/HISTORY.md b/bus_record_events/readme/HISTORY.md new file mode 100644 index 000000000000..abe25daa9583 --- /dev/null +++ b/bus_record_events/readme/HISTORY.md @@ -0,0 +1,4 @@ +17.0.1.0.0 (2023-12-16) +~~~~~~~~~~~~~~~~~~~~~~~ + +* Initial release. diff --git a/bus_record_events/readme/INSTALL.md b/bus_record_events/readme/INSTALL.md new file mode 100644 index 000000000000..6965bdf9aac5 --- /dev/null +++ b/bus_record_events/readme/INSTALL.md @@ -0,0 +1 @@ +This module depends on the `bus` and `web` modules. diff --git a/bus_record_events/readme/ROADMAP.md b/bus_record_events/readme/ROADMAP.md new file mode 100644 index 000000000000..d5d184f8d4bd --- /dev/null +++ b/bus_record_events/readme/ROADMAP.md @@ -0,0 +1,3 @@ +* No known issues. +* **Data Optimization**: Notification payloads compressed using `zlib` and encoded in `base64`. (Discarded for now: Tested but with thousands of messages it generated too many `blob:...` threads in the client browser, making it unable to manage all messages). +* **Permission Revocation Handling**: Currently, if a client loses permission to a record they are already subscribed to, they might continue receiving updates. It would be beneficial to find a server-side mechanism to force-unsubscribe a client from a specific channel when their permissions change. diff --git a/bus_record_events/readme/USAGE.md b/bus_record_events/readme/USAGE.md new file mode 100644 index 000000000000..35e8ea30c741 --- /dev/null +++ b/bus_record_events/readme/USAGE.md @@ -0,0 +1,110 @@ +## For Developers + +To enable notifications on a model, simply inherit from the `bus.record.event.mixin` mixin: + +```python +class MyModel(models.Model): + _name = 'my.model' + _inherit = ['my.model', 'bus.record.event.mixin'] +``` + +### Subscription Channels + +The module manages two types of channels for client-side subscriptions (OWL/JS): + +1. **Model Events (`create`)**: + * Channel: `record_events:{model_name}` + * Example: `record_events:res.partner` + * Requires: Read permissions at the model level. + +2. **Record Events (`write`, `unlink`)**: + * Channel: `record_events:{model_name}:{record_id}` + * Example: `record_events:res.partner:15` + * Requires: Read permissions on the specific record (record rules applied). + +## Reactive Views + +The module includes extended view controllers that automatically listen for these events and update the interface or notify the user. + +To use these reactive views, you must specify the `js_class` attribute in the XML view definition. + +### Available Views + +| View Type | js_class | Behavior | +| :--- | :--- | :--- | +| **Form** | `bus_record_event_form` | Notifies if the record has been modified or deleted by another user while editing. If there are no unsaved changes, it automatically reloads the data. | +| **Kanban** | `bus_record_event_kanban` | Automatically reloads the view upon receiving create, update, or delete events in the model. | +| **Pivot** | `bus_record_event_pivot` | Automatically reloads the view upon changes in the model. | +| **Graph** | `bus_record_event_graph` | Automatically reloads the view upon changes in the model. | +| **Calendar** | `bus_record_event_calendar` | Automatically reloads the view upon changes in the model. | +| **List** | `bus_record_event_list` | Automatically reloads the view upon changes in the model. | + +### Usage Example (XML) + +```xml + + my.model.form + my.model + +
+ +
+
+
+ + + my.model.kanban + my.model + + + + + + +``` + +## JavaScript API + +### Service: `bus_record_event_service` + +The module provides a service to manage subscriptions to record events. + +```javascript +const busRecordEventService = useService("bus_record_event_service"); + +// Add a channel to listen to +busRecordEventService.addChannel("record_events:res.partner:1"); + +// Subscribe to notifications +const unsubscribe = busRecordEventService.subscribe((payload) => { + console.log("Received event:", payload); +}); + +// Clean up +unsubscribe(); +``` + +### Hook: `useRecordStream` + +For OWL components, it is recommended to use the `useRecordStream` hook. It handles the lifecycle of the subscription (adding channels on start, unsubscribing on unmount). + +```javascript +import { useRecordStream } from "@bus_record_events/js/hooks/use_record_stream.esm"; + +setup() { + useRecordStream("res.partner", { + id: this.props.resId, // Optional: Listen to a specific record + onReload: async () => { + // Callback to reload the view/component + await this.model.load(); + }, + onUpdate: async (payload) => { + // Optional: Custom handling of the event + }, + filter: (payload) => { + // Optional: Filter events before processing + return true; + } + }); +} +``` diff --git a/bus_record_events/static/description/index.html b/bus_record_events/static/description/index.html new file mode 100644 index 000000000000..6bad2c5162b1 --- /dev/null +++ b/bus_record_events/static/description/index.html @@ -0,0 +1,671 @@ + + + + + +Bus Record Events + + + +
+

Bus Record Events

+ + +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This module provides an efficient and secure mechanism to notify CRUD +events (Create, Write, Unlink) in real-time via the Odoo Bus.

+

Key Features:

+
    +
  • Server Efficiency: Uses Odoo’s native bus.bus system to avoid +table locking and minimize performance impact.
  • +
  • Granular Security: Implements a permission system in +ir.websocket ensuring users can only subscribe to record or model +channels for which they have read permissions (Access Rights and +Record Rules).
  • +
+

Table of contents

+ +
+

Use Cases / Context

+

This module was designed to provide real-time capabilities to Odoo views +without heavy polling.

+
+
+

Installation

+

This module depends on the bus and web modules.

+
+
+

Configuration

+

No specific configuration is required.

+
+
+

Usage

+
+

For Developers

+

To enable notifications on a model, simply inherit from the +bus.record.event.mixin mixin:

+
+class MyModel(models.Model):
+    _name = 'my.model'
+    _inherit = ['my.model', 'bus.record.event.mixin']
+
+
+

Subscription Channels

+

The module manages two types of channels for client-side subscriptions +(OWL/JS):

+
    +
  1. Model Events (``create``):
      +
    • Channel: record_events:{model_name}
    • +
    • Example: record_events:res.partner
    • +
    • Requires: Read permissions at the model level.
    • +
    +
  2. +
  3. Record Events (``write``, ``unlink``):
      +
    • Channel: record_events:{model_name}:{record_id}
    • +
    • Example: record_events:res.partner:15
    • +
    • Requires: Read permissions on the specific record (record rules +applied).
    • +
    +
  4. +
+
+
+
+

Reactive Views

+

The module includes extended view controllers that automatically listen +for these events and update the interface or notify the user.

+

To use these reactive views, you must specify the js_class attribute +in the XML view definition.

+
+

Available Views

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
View Typejs_classBehavior
Formbus_record_event_formNotifies if the record +has been modified or +deleted by another user +while editing. If there +are no unsaved changes, +it automatically reloads +the data.
Kanbanbus_record_event_kanbanAutomatically reloads the +view upon receiving +create, update, or delete +events in the model.
Pivotbus_record_event_pivotAutomatically reloads the +view upon changes in the +model.
Graphbus_record_event_graphAutomatically reloads the +view upon changes in the +model.
Calendarbus_record_event_calendarAutomatically reloads the +view upon changes in the +model.
Listbus_record_event_listAutomatically reloads the +view upon changes in the +model.
+
+
+

Usage Example (XML)

+
+<record id="view_my_model_form" model="ir.ui.view">
+    <field name="name">my.model.form</field>
+    <field name="model">my.model</field>
+    <field name="arch" type="xml">
+        <form js_class="bus_record_event_form">
+            <!-- ... -->
+        </form>
+    </field>
+</record>
+
+<record id="view_my_model_kanban" model="ir.ui.view">
+    <field name="name">my.model.kanban</field>
+    <field name="model">my.model</field>
+    <field name="arch" type="xml">
+        <kanban js_class="bus_record_event_kanban">
+            <!-- ... -->
+        </kanban>
+    </field>
+</record>
+
+
+
+
+

JavaScript API

+
+

Service: bus_record_event_service

+

The module provides a service to manage subscriptions to record events.

+
+const busRecordEventService = useService("bus_record_event_service");
+
+// Add a channel to listen to
+busRecordEventService.addChannel("record_events:res.partner:1");
+
+// Subscribe to notifications
+const unsubscribe = busRecordEventService.subscribe((payload) => {
+    console.log("Received event:", payload);
+});
+
+// Clean up
+unsubscribe();
+
+
+
+

Hook: useRecordStream

+

For OWL components, it is recommended to use the useRecordStream +hook. It handles the lifecycle of the subscription (adding channels on +start, unsubscribing on unmount).

+
+import { useRecordStream } from "@bus_record_events/js/hooks/use_record_stream.esm";
+
+setup() {
+    useRecordStream("res.partner", {
+        id: this.props.resId, // Optional: Listen to a specific record
+        onReload: async () => {
+            // Callback to reload the view/component
+            await this.model.load();
+        },
+        onUpdate: async (payload) => {
+            // Optional: Custom handling of the event
+        },
+        filter: (payload) => {
+            // Optional: Filter events before processing
+            return true;
+        }
+    });
+}
+
+
+
+
+
+

Known issues / Roadmap

+
    +
  • No known issues.
  • +
  • Data Optimization: Notification payloads compressed using zlib +and encoded in base64. (Discarded for now: Tested but with +thousands of messages it generated too many blob:... threads in +the client browser, making it unable to manage all messages).
  • +
  • Permission Revocation Handling: Currently, if a client loses +permission to a record they are already subscribed to, they might +continue receiving updates. It would be beneficial to find a +server-side mechanism to force-unsubscribe a client from a specific +channel when their permissions change.
  • +
+
+
+

Changelog

+

17.0.1.0.0 (2023-12-16)

+
+*   Initial release.
+
+
+
+

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

+
    +
  • Heligrafics Fotogrametria S.L.
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • Heligrafics Fotogrametria S.L.
  • +
+
+
+

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/web project on GitHub.

+

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

+
+
+
+ + diff --git a/bus_record_events/static/src/js/hooks/use_record_stream.esm.js b/bus_record_events/static/src/js/hooks/use_record_stream.esm.js new file mode 100644 index 000000000000..ed53b0f2868e --- /dev/null +++ b/bus_record_events/static/src/js/hooks/use_record_stream.esm.js @@ -0,0 +1,91 @@ +/** @odoo-module */ + +import {onWillStart, onWillUnmount} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; + +/** + * Hook to subscribe to record events. + * @param {String} model The model to observe. + * @param {Object} options + * @param {number|string} [options.id] The specific record ID to observe. + * @param {function(Object): boolean} [options.filter] Pure function to filter events. + * @param {function(Object): void} [options.onUpdate] Custom callback to execute when an event matches. + * @param {function(): Promise} [options.isDirty] Callback to check if the view is dirty. + * @param {function(): Promise} [options.onReload] Callback to reload the view. + * @param {function(): void} [options.onRecordDeleted] Callback when the observed record is deleted (only if id is provided). + */ +export function useRecordStream( + model, + {id, filter, onUpdate, isDirty, onReload, onRecordDeleted} = {} +) { + const service = useService("bus_record_event_service"); + let unsubscribe = null; + + const handleUnlink = async (dirty) => { + if (dirty) { + service.displayNotification( + "Record deleted elsewhere, but you have unsaved changes." + ); + return; + } + if (id && onRecordDeleted) { + service.displayNotification("Record deleted. Returning to list view."); + onRecordDeleted(); + } else if (onReload) { + await onReload(); + } + }; + + const handleUpdate = async (dirty) => { + if (dirty) { + service.displayNotification( + "Records updated elsewhere, but you have unsaved changes." + ); + } else if (onReload) { + await onReload(); + } + }; + + const handleNotification = async (payload) => { + if (payload.model !== model) { + return; + } + // If we are listening to a specific record, filter by ID + if (id) { + // For unlink, payload has 'id'. For create/write, payload has 'data.id'. + const payloadId = payload.id || (payload.data && payload.data.id); + if (payloadId !== id) { + return; + } + } + + if (filter && !(await filter(payload))) { + return; + } + + if (onUpdate) { + await onUpdate(payload); + return; + } + + const dirty = isDirty ? await isDirty() : false; + if (payload.type === "unlink") { + await handleUnlink(dirty); + } else { + await handleUpdate(dirty); + } + }; + + onWillStart(() => { + const channel = id ? `record_events:${model}:${id}` : `record_events:${model}`; + service.addChannel(channel); + + unsubscribe = service.subscribe(handleNotification); + }); + + onWillUnmount(() => { + if (unsubscribe) { + unsubscribe(); + } + }); +} diff --git a/bus_record_events/static/src/js/services/bus_record_event_service.esm.js b/bus_record_events/static/src/js/services/bus_record_event_service.esm.js new file mode 100644 index 000000000000..562195ae31bd --- /dev/null +++ b/bus_record_events/static/src/js/services/bus_record_event_service.esm.js @@ -0,0 +1,56 @@ +/** @odoo-module */ + +import {markup} from "@odoo/owl"; +import {registry} from "@web/core/registry"; + +export const busRecordEventService = { + dependencies: ["bus_service", "notification"], + start(env, {bus_service, notification}) { + const subscribers = new Set(); + + const displayNotification = (message, options = {}) => { + notification.add(markup(message), { + type: "warning", + sticky: false, + ...options, + }); + }; + + const processNotification = (notif) => { + const {payload, type} = notif; + if (type !== "bus.record/event") { + return; + } + + const notify = () => { + subscribers.forEach((callback) => callback(payload)); + }; + + notify(); + }; + + const onNotification = ({detail: notifications}) => { + for (const notif of notifications) { + processNotification(notif); + } + }; + + bus_service.addEventListener("notification", onNotification); + + return { + subscribe(callback) { + subscribers.add(callback); + return () => subscribers.delete(callback); + }, + addChannel(channel) { + bus_service.addChannel(channel); + }, + deleteChannel(channel) { + bus_service.deleteChannel(channel); + }, + displayNotification, + }; + }, +}; + +registry.category("services").add("bus_record_event_service", busRecordEventService); diff --git a/bus_record_events/static/src/js/views/calendar/bus_calendar_controller.esm.js b/bus_record_events/static/src/js/views/calendar/bus_calendar_controller.esm.js new file mode 100644 index 000000000000..7c3602366de2 --- /dev/null +++ b/bus_record_events/static/src/js/views/calendar/bus_calendar_controller.esm.js @@ -0,0 +1,23 @@ +/** @odoo-module */ + +import {CalendarController} from "@web/views/calendar/calendar_controller"; +import {calendarView} from "@web/views/calendar/calendar_view"; +import {registry} from "@web/core/registry"; +import {useRecordStream} from "../../hooks/use_record_stream.esm"; + +export class BusCalendarController extends CalendarController { + setup() { + super.setup(); + + useRecordStream(this.props.resModel, { + onReload: async () => await this.model.load(), + }); + } +} + +export const busCalendarView = { + ...calendarView, + Controller: BusCalendarController, +}; + +registry.category("views").add("bus_record_event_calendar", busCalendarView); diff --git a/bus_record_events/static/src/js/views/form/bus_form_controller.esm.js b/bus_record_events/static/src/js/views/form/bus_form_controller.esm.js new file mode 100644 index 000000000000..b73e4b831b77 --- /dev/null +++ b/bus_record_events/static/src/js/views/form/bus_form_controller.esm.js @@ -0,0 +1,28 @@ +/** @odoo-module */ + +import {FormController} from "@web/views/form/form_controller"; +import {formView} from "@web/views/form/form_view"; +import {registry} from "@web/core/registry"; +import {useRecordStream} from "../../hooks/use_record_stream.esm"; + +export class BusFormController extends FormController { + setup() { + super.setup(); + + useRecordStream(this.props.resModel, { + id: this.props.resId, + // In create mode (no resId), we don't want to listen to any events. + filter: () => Boolean(this.props.resId), + isDirty: async () => await this.model.root.isDirty(), + onReload: async () => await this.model.load(), + onRecordDeleted: () => this.env.config.historyBack(), + }); + } +} + +export const busFormView = { + ...formView, + Controller: BusFormController, +}; + +registry.category("views").add("bus_record_event_form", busFormView); diff --git a/bus_record_events/static/src/js/views/graph/bus_graph_controller.esm.js b/bus_record_events/static/src/js/views/graph/bus_graph_controller.esm.js new file mode 100644 index 000000000000..627f7c1fd68e --- /dev/null +++ b/bus_record_events/static/src/js/views/graph/bus_graph_controller.esm.js @@ -0,0 +1,23 @@ +/** @odoo-module */ + +import {GraphController} from "@web/views/graph/graph_controller"; +import {graphView} from "@web/views/graph/graph_view"; +import {registry} from "@web/core/registry"; +import {useRecordStream} from "../../hooks/use_record_stream.esm"; + +export class BusGraphController extends GraphController { + setup() { + super.setup(); + + useRecordStream(this.props.resModel, { + onReload: async () => await this.loadAll(), + }); + } +} + +export const busGraphView = { + ...graphView, + Controller: BusGraphController, +}; + +registry.category("views").add("bus_record_event_graph", busGraphView); diff --git a/bus_record_events/static/src/js/views/kanban/bus_kanban_controller.esm.js b/bus_record_events/static/src/js/views/kanban/bus_kanban_controller.esm.js new file mode 100644 index 000000000000..c91a61f21724 --- /dev/null +++ b/bus_record_events/static/src/js/views/kanban/bus_kanban_controller.esm.js @@ -0,0 +1,23 @@ +/** @odoo-module */ + +import {KanbanController} from "@web/views/kanban/kanban_controller"; +import {kanbanView} from "@web/views/kanban/kanban_view"; +import {registry} from "@web/core/registry"; +import {useRecordStream} from "../../hooks/use_record_stream.esm"; + +export class BusKanbanController extends KanbanController { + setup() { + super.setup(); + + useRecordStream(this.props.resModel, { + onReload: async () => await this.model.load(), + }); + } +} + +export const busKanbanView = { + ...kanbanView, + Controller: BusKanbanController, +}; + +registry.category("views").add("bus_record_event_kanban", busKanbanView); diff --git a/bus_record_events/static/src/js/views/list/bus_list_controller.esm.js b/bus_record_events/static/src/js/views/list/bus_list_controller.esm.js new file mode 100644 index 000000000000..a5a833b1c2a2 --- /dev/null +++ b/bus_record_events/static/src/js/views/list/bus_list_controller.esm.js @@ -0,0 +1,23 @@ +/** @odoo-module */ + +import {ListController} from "@web/views/list/list_controller"; +import {listView} from "@web/views/list/list_view"; +import {registry} from "@web/core/registry"; +import {useRecordStream} from "../../hooks/use_record_stream.esm"; + +export class BusListController extends ListController { + setup() { + super.setup(); + + useRecordStream(this.props.resModel, { + onUpdate: () => this.model.load(), + }); + } +} + +export const busListView = { + ...listView, + Controller: BusListController, +}; + +registry.category("views").add("bus_record_event_list", busListView); diff --git a/bus_record_events/static/src/js/views/pivot/bus_pivot_controller.esm.js b/bus_record_events/static/src/js/views/pivot/bus_pivot_controller.esm.js new file mode 100644 index 000000000000..d5a96ebb5bee --- /dev/null +++ b/bus_record_events/static/src/js/views/pivot/bus_pivot_controller.esm.js @@ -0,0 +1,41 @@ +/** @odoo-module */ + +import {PivotController} from "@web/views/pivot/pivot_controller"; +import {SEARCH_KEYS} from "@web/search/with_search/with_search"; +import {pivotView} from "@web/views/pivot/pivot_view"; +import {registry} from "@web/core/registry"; +import {useRecordStream} from "../../hooks/use_record_stream.esm"; + +/** + * SEE: /workspaces/nexe/custom/src/odoo/addons/web/static/src/model/model.js + * @param {Object} props + * @returns {SearchParams} + */ +export const getSearchParams = (props) => { + const params = {}; + for (const key of SEARCH_KEYS) { + params[key] = props[key]; + } + return params; +}; + +export class BusPivotController extends PivotController { + setup() { + super.setup(); + + useRecordStream(this.props.resModel, { + onReload: async () => { + const searchParams = getSearchParams(this.props); + await this.model.load(searchParams); + this.model.bus.trigger("update"); + }, + }); + } +} + +export const busPivotView = { + ...pivotView, + Controller: BusPivotController, +}; + +registry.category("views").add("bus_record_event_pivot", busPivotView); diff --git a/bus_record_events/static/tests/unit/hooks/use_record_stream_tests.esm.js b/bus_record_events/static/tests/unit/hooks/use_record_stream_tests.esm.js new file mode 100644 index 000000000000..2cf155d99f06 --- /dev/null +++ b/bus_record_events/static/tests/unit/hooks/use_record_stream_tests.esm.js @@ -0,0 +1,93 @@ +/** @odoo-module */ +/* global QUnit */ + +import {Component, mount, xml} from "@odoo/owl"; +import {getFixture} from "@web/../tests/helpers/utils"; +import {makeTestEnv} from "@web/../tests/helpers/mock_env"; +import {registry} from "@web/core/registry"; +import {useRecordStream} from "../../../src/js/hooks/use_record_stream.esm"; + +QUnit.module("Hooks", {}, function () { + QUnit.module("useRecordStream"); + + QUnit.test("subscribes to channel and handles updates", async function (assert) { + const serviceMock = { + addChannel: (channel) => assert.step(`addChannel:${channel}`), + subscribe: (callback) => { + assert.step("subscribe"); + this.callback = callback; + }, + displayNotification: (msg) => assert.step(`notify:${msg}`), + }; + + registry.category("services").add("bus_record_event_service", { + start: () => serviceMock, + }); + + class TestComponent extends Component { + setup() { + useRecordStream("test.model", { + onReload: () => assert.step("reload"), + }); + } + } + TestComponent.template = xml`
`; + + const env = await makeTestEnv(); + const target = getFixture(); + await mount(TestComponent, target, {env}); + + assert.verifySteps(["addChannel:record_events:test.model", "subscribe"]); + + // Simulate notification + await this.callback({ + model: "test.model", + type: "write", + data: {id: 1}, + }); + assert.verifySteps(["reload"]); + + // Simulate notification for another model + await this.callback({ + model: "other.model", + type: "write", + data: {id: 1}, + }); + assert.verifySteps([]); + }); + + QUnit.test("handles dirty state", async function (assert) { + const serviceMock = { + addChannel: () => null, + subscribe: (callback) => { + this.callback = callback; + return () => null; + }, + displayNotification: (msg) => assert.step(`notify:${msg}`), + }; + + registry.category("services").add("bus_record_event_service", { + start: () => serviceMock, + }); + + class TestComponent extends Component { + setup() { + useRecordStream("test.model", { + isDirty: () => true, + onReload: () => assert.step("reload"), + }); + } + } + TestComponent.template = xml`
`; + + const env = await makeTestEnv(); + const target = getFixture(); + await mount(TestComponent, target, {env}); + + // Simulate notification + await this.callback({model: "test.model", type: "write", data: {id: 1}}); + assert.verifySteps([ + "notify:Records updated elsewhere, but you have unsaved changes.", + ]); + }); +}); diff --git a/bus_record_events/static/tests/unit/services/bus_record_event_service_tests.esm.js b/bus_record_events/static/tests/unit/services/bus_record_event_service_tests.esm.js new file mode 100644 index 000000000000..17907a950435 --- /dev/null +++ b/bus_record_events/static/tests/unit/services/bus_record_event_service_tests.esm.js @@ -0,0 +1,54 @@ +/** @odoo-module */ +/* global QUnit */ + +import {EventBus} from "@odoo/owl"; +import {busRecordEventService} from "../../../src/js/services/bus_record_event_service.esm"; +import {makeTestEnv} from "@web/../tests/helpers/mock_env"; +import {registry} from "@web/core/registry"; + +QUnit.module("Services", {}, function () { + QUnit.module("bus_record_event_service"); + + QUnit.test("can subscribe and receive notifications", async function (assert) { + const busService = new EventBus(); + busService.addChannel = (channel) => { + assert.step(`addChannel:${channel}`); + }; + + const notificationService = { + add: (message) => { + assert.step(`notification:${message}`); + }, + }; + + const registryMock = registry.category("services"); + registryMock.add("bus_service", { + start: () => busService, + }); + registryMock.add("notification", { + start: () => notificationService, + }); + registryMock.add("bus_record_event_service", busRecordEventService); + + const env = await makeTestEnv(); + const service = env.services.bus_record_event_service; + + service.addChannel("test_channel"); + assert.verifySteps(["addChannel:test_channel"]); + + let receivedPayload = null; + service.subscribe((payload) => { + receivedPayload = payload; + }); + + // Simulate notification + const payload = { + model: "test.model", + type: "create", + data: {id: 1}, + }; + busService.trigger("notification", [{type: "bus.record/event", payload}]); + + assert.deepEqual(receivedPayload, payload, "Subscriber received the payload"); + }); +}); diff --git a/bus_record_events/static/tests/unit/views/bus_calendar_view_tests.esm.js b/bus_record_events/static/tests/unit/views/bus_calendar_view_tests.esm.js new file mode 100644 index 000000000000..a694203619b5 --- /dev/null +++ b/bus_record_events/static/tests/unit/views/bus_calendar_view_tests.esm.js @@ -0,0 +1,68 @@ +/** @odoo-module */ +/* global QUnit */ + +import "@bus_record_events/js/views/calendar/bus_calendar_controller.esm"; +import {makeView, setupViewRegistries} from "@web/../tests/views/helpers"; +import {getFixture} from "@web/../tests/helpers/utils"; +import {registry} from "@web/core/registry"; + +let serverData = null; +let busCallback = null; + +QUnit.module("Views", (hooks) => { + hooks.beforeEach(() => { + getFixture(); + serverData = { + models: { + partner: { + fields: { + foo: {string: "Foo", type: "char"}, + date_start: {string: "Date", type: "date"}, + }, + records: [{id: 1, foo: "yop", date_start: "2023-01-01"}], + }, + }, + }; + setupViewRegistries(); + + const serviceMock = { + addChannel: () => null, + subscribe: (callback) => { + busCallback = callback; + return () => null; + }, + displayNotification: () => null, + }; + registry.category("services").add( + "bus_record_event_service", + { + start: () => serviceMock, + }, + {force: true} + ); + }); + + QUnit.module("BusCalendarView"); + + QUnit.test("calendar view reloads on bus event", async function (assert) { + await makeView({ + type: "calendar", + resModel: "partner", + serverData, + arch: '', + mockRPC: (route, args) => { + if (args.method === "search_read") { + assert.step("search_read"); + } else if (args.method === "check_access_rights") { + return true; + } + }, + }); + + assert.verifySteps(["search_read"]); + + // Simulate notification + await busCallback({model: "partner", type: "write", data: {id: 1}}); + assert.verifySteps(["search_read"]); + }); +}); diff --git a/bus_record_events/static/tests/unit/views/bus_form_view_tests.esm.js b/bus_record_events/static/tests/unit/views/bus_form_view_tests.esm.js new file mode 100644 index 000000000000..c2dbc4740d64 --- /dev/null +++ b/bus_record_events/static/tests/unit/views/bus_form_view_tests.esm.js @@ -0,0 +1,91 @@ +/** @odoo-module */ +/* global QUnit */ + +import "@bus_record_events/js/views/form/bus_form_controller.esm"; +import {makeView, setupViewRegistries} from "@web/../tests/views/helpers"; +import {getFixture} from "@web/../tests/helpers/utils"; +import {registry} from "@web/core/registry"; + +let serverData = null; +let target = null; +let busCallback = null; +let assertCallback = null; + +QUnit.module("Views", (hooks) => { + hooks.beforeEach(() => { + target = getFixture(); + serverData = { + models: { + partner: { + fields: { + foo: {string: "Foo", type: "char"}, + }, + records: [{id: 1, foo: "yop"}], + }, + }, + }; + setupViewRegistries(); + + const serviceMock = { + addChannel: () => null, + subscribe: (callback) => { + busCallback = callback; + return () => null; + }, + displayNotification: (msg) => assertCallback.step(`notify:${msg}`), + }; + registry.category("services").add( + "bus_record_event_service", + { + start: () => serviceMock, + }, + {force: true} + ); + }); + + QUnit.module("BusFormView"); + + QUnit.test("form view handles updates and dirty state", async function (assert) { + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: '
', + mockRPC: (route, args) => { + if (args.method === "web_read") { + assert.step("web_read"); + } + }, + }); + + assert.verifySteps(["web_read"]); + + // Simulate notification (clean state) + await busCallback({model: "partner", type: "write", data: {id: 1}}); + assert.verifySteps(["web_read"]); + }); + + QUnit.test("form view notifies on update when dirty", async function (assert) { + assertCallback = assert; + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: '
', + }); + + // Make dirty + const input = target.querySelector(".o_field_widget[name='foo'] input"); + input.value = "bar"; + input.dispatchEvent(new Event("input", {bubbles: true})); + input.dispatchEvent(new Event("change", {bubbles: true})); + + // Simulate notification + await busCallback({model: "partner", type: "write", data: {id: 1}}); + assert.verifySteps([ + "notify:Records updated elsewhere, but you have unsaved changes.", + ]); + }); +}); diff --git a/bus_record_events/static/tests/unit/views/bus_graph_view_tests.esm.js b/bus_record_events/static/tests/unit/views/bus_graph_view_tests.esm.js new file mode 100644 index 000000000000..128f913e979f --- /dev/null +++ b/bus_record_events/static/tests/unit/views/bus_graph_view_tests.esm.js @@ -0,0 +1,74 @@ +/** @odoo-module */ +/* global QUnit */ + +import "@bus_record_events/js/views/graph/bus_graph_controller.esm"; +import {makeView, setupViewRegistries} from "@web/../tests/views/helpers"; +import {getFixture} from "@web/../tests/helpers/utils"; +import {registry} from "@web/core/registry"; + +let serverData = null; +let busCallback = null; + +QUnit.module("Views", (hooks) => { + hooks.beforeEach(() => { + getFixture(); + serverData = { + models: { + partner: { + fields: { + foo: { + string: "Foo", + type: "integer", + store: true, + group_operator: "sum", + sortable: true, + }, + }, + records: [ + {id: 1, foo: 10}, + {id: 2, foo: 20}, + ], + }, + }, + }; + setupViewRegistries(); + + const serviceMock = { + addChannel: () => null, + subscribe: (callback) => { + busCallback = callback; + return () => null; + }, + displayNotification: () => null, + }; + registry.category("services").add( + "bus_record_event_service", + { + start: () => serviceMock, + }, + {force: true} + ); + }); + + QUnit.module("BusGraphView"); + + QUnit.test("graph view reloads on bus event", async function (assert) { + await makeView({ + type: "graph", + resModel: "partner", + serverData, + arch: '', + mockRPC: (route, args) => { + if (args.method === "web_read_group") { + assert.step("web_read_group"); + } + }, + }); + + assert.verifySteps(["web_read_group"]); + + // Simulate notification + await busCallback({model: "partner", type: "write", data: {id: 1}}); + assert.verifySteps(["web_read_group"]); + }); +}); diff --git a/bus_record_events/static/tests/unit/views/bus_kanban_view_tests.esm.js b/bus_record_events/static/tests/unit/views/bus_kanban_view_tests.esm.js new file mode 100644 index 000000000000..99bc141c0182 --- /dev/null +++ b/bus_record_events/static/tests/unit/views/bus_kanban_view_tests.esm.js @@ -0,0 +1,65 @@ +/** @odoo-module */ +/* global QUnit */ + +import "@bus_record_events/js/views/kanban/bus_kanban_controller.esm"; +import {makeView, setupViewRegistries} from "@web/../tests/views/helpers"; +import {getFixture} from "@web/../tests/helpers/utils"; +import {registry} from "@web/core/registry"; + +let serverData = null; +let busCallback = null; + +QUnit.module("Views", (hooks) => { + hooks.beforeEach(() => { + getFixture(); + serverData = { + models: { + partner: { + fields: { + foo: {string: "Foo", type: "char"}, + }, + records: [{id: 1, foo: "yop"}], + }, + }, + }; + setupViewRegistries(); + + const serviceMock = { + addChannel: () => null, + subscribe: (callback) => { + busCallback = callback; + return () => null; + }, + displayNotification: () => null, + }; + registry.category("services").add( + "bus_record_event_service", + { + start: () => serviceMock, + }, + {force: true} + ); + }); + + QUnit.module("BusKanbanView"); + + QUnit.test("kanban view reloads on bus event", async function (assert) { + await makeView({ + type: "kanban", + resModel: "partner", + serverData, + arch: '', + mockRPC: (route, args) => { + if (args.method === "web_search_read") { + assert.step("web_search_read"); + } + }, + }); + + assert.verifySteps(["web_search_read"]); + + // Simulate notification + await busCallback({model: "partner", type: "write", data: {id: 1}}); + assert.verifySteps(["web_search_read"]); + }); +}); diff --git a/bus_record_events/static/tests/unit/views/bus_list_view_tests.esm.js b/bus_record_events/static/tests/unit/views/bus_list_view_tests.esm.js new file mode 100644 index 000000000000..8b8bba72b928 --- /dev/null +++ b/bus_record_events/static/tests/unit/views/bus_list_view_tests.esm.js @@ -0,0 +1,68 @@ +/** @odoo-module */ +/* global QUnit */ + +import "@bus_record_events/js/views/list/bus_list_controller.esm"; +import {makeView, setupViewRegistries} from "@web/../tests/views/helpers"; +import {getFixture} from "@web/../tests/helpers/utils"; +import {registry} from "@web/core/registry"; + +let serverData = null; +let busCallback = null; + +QUnit.module("Views", (hooks) => { + hooks.beforeEach(() => { + getFixture(); + serverData = { + models: { + partner: { + fields: { + foo: {string: "Foo", type: "char"}, + }, + records: [ + {id: 1, foo: "yop"}, + {id: 2, foo: "blip"}, + ], + }, + }, + }; + setupViewRegistries(); + + const serviceMock = { + addChannel: () => null, + subscribe: (callback) => { + busCallback = callback; + return () => null; + }, + displayNotification: () => null, + }; + registry.category("services").add( + "bus_record_event_service", + { + start: () => serviceMock, + }, + {force: true} + ); + }); + + QUnit.module("BusListView"); + + QUnit.test("list view reloads on bus event", async function (assert) { + await makeView({ + type: "list", + resModel: "partner", + serverData, + arch: '', + mockRPC: (route, args) => { + if (args.method === "web_search_read") { + assert.step("web_search_read"); + } + }, + }); + + assert.verifySteps(["web_search_read"]); + + // Simulate notification + await busCallback({model: "partner", type: "write", data: {id: 1}}); + assert.verifySteps(["web_search_read"]); + }); +}); diff --git a/bus_record_events/static/tests/unit/views/bus_pivot_view_tests.esm.js b/bus_record_events/static/tests/unit/views/bus_pivot_view_tests.esm.js new file mode 100644 index 000000000000..1a54eda6c159 --- /dev/null +++ b/bus_record_events/static/tests/unit/views/bus_pivot_view_tests.esm.js @@ -0,0 +1,73 @@ +/** @odoo-module */ +/* global QUnit */ + +import "@bus_record_events/js/views/pivot/bus_pivot_controller.esm"; +import {makeView, setupViewRegistries} from "@web/../tests/views/helpers"; +import {getFixture} from "@web/../tests/helpers/utils"; +import {registry} from "@web/core/registry"; + +let serverData = null; +let busCallback = null; + +QUnit.module("Views", (hooks) => { + hooks.beforeEach(() => { + getFixture(); + serverData = { + models: { + partner: { + fields: { + foo: { + string: "Foo", + type: "integer", + searchable: true, + group_operator: "sum", + }, + }, + records: [ + {id: 1, foo: 10}, + {id: 2, foo: 20}, + ], + }, + }, + }; + setupViewRegistries(); + + const serviceMock = { + addChannel: () => null, + subscribe: (callback) => { + busCallback = callback; + return () => null; + }, + displayNotification: () => null, + }; + registry.category("services").add( + "bus_record_event_service", + { + start: () => serviceMock, + }, + {force: true} + ); + }); + + QUnit.module("BusPivotView"); + + QUnit.test("pivot view reloads on bus event", async function (assert) { + await makeView({ + type: "pivot", + resModel: "partner", + serverData, + arch: '', + mockRPC: (route, args) => { + if (args.method === "read_group") { + assert.step("read_group"); + } + }, + }); + + assert.verifySteps(["read_group"]); + + // Simulate notification + await busCallback({model: "partner", type: "write", data: {id: 1}}); + assert.verifySteps(["read_group"]); + }); +}); diff --git a/bus_record_events/tests/__init__.py b/bus_record_events/tests/__init__.py new file mode 100644 index 000000000000..29205c7258f3 --- /dev/null +++ b/bus_record_events/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_bus_record_events +from . import test_bus_record_events_websocket +from . import test_js diff --git a/bus_record_events/tests/common.py b/bus_record_events/tests/common.py new file mode 100644 index 000000000000..738e31470a19 --- /dev/null +++ b/bus_record_events/tests/common.py @@ -0,0 +1,26 @@ +from odoo_test_helper import FakeModelLoader + +from odoo import fields, models + + +class BusRecordEventFake(models.Model): + _name = "bus.record.event.fake" + _description = "Fake Model for Bus Events" + _inherit = ["bus.record.event.mixin"] + + name = fields.Char() + user_id = fields.Many2one("res.users", string="User") + + +class TestBusRecordEventsCase: + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + cls.loader.update_registry([BusRecordEventFake]) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() diff --git a/bus_record_events/tests/test_bus_record_events.py b/bus_record_events/tests/test_bus_record_events.py new file mode 100644 index 000000000000..342829521483 --- /dev/null +++ b/bus_record_events/tests/test_bus_record_events.py @@ -0,0 +1,167 @@ +import json +import logging + +from odoo.exceptions import AccessError +from odoo.tests import TransactionCase, tagged +from odoo.tests.common import new_test_user + +from .common import TestBusRecordEventsCase + +_logger = logging.getLogger(__name__) + + +@tagged("-at_install", "post_install") +class TestBusRecordEvents(TestBusRecordEventsCase, TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.user_1 = new_test_user( + cls.env, login="test_user_1", groups="base.group_user" + ) + cls.user_2 = new_test_user( + cls.env, login="test_user_2", groups="base.group_user" + ) + cls.Model = cls.env["bus.record.event.fake"] + + # Create access rights for the fake model so users can access it + cls.env["ir.model.access"].create( + { + "name": "access_bus_record_event_fake_user", + "model_id": cls.env["ir.model"]._get("bus.record.event.fake").id, + "group_id": cls.env.ref("base.group_user").id, + "perm_read": 1, + "perm_write": 1, + "perm_create": 1, + "perm_unlink": 1, + } + ) + + def _get_relevant_notification(self, notifications, channel_name): + return next( + ( + n + for n in notifications + if n.channel == f'["{self.env.cr.dbname}","{channel_name}"]' + ), + None, + ) + + def test_create_notification(self): + """Test that creating a record sends a notification.""" + bus = self.env["bus.bus"] + last_id = bus.search([], order="id desc", limit=1).id or 0 + + record = self.Model.with_user(self.user_1).create({"name": "Test Create"}) + self.env.cr.precommit.run() # trigger the creation of bus.bus records + + notifications = bus.search([("id", ">", last_id)]) + self.assertTrue(notifications, "Should have generated notifications") + + # Check channel + channel_name = "record_events:bus.record.event.fake" + relevant_notif = self._get_relevant_notification(notifications, channel_name) + self.assertTrue(relevant_notif, "Should have notification on model channel") + + # Check payload + message = json.loads(relevant_notif.message) + self.assertEqual(message["type"], "bus.record/event") + payload = message["payload"] + self.assertEqual(payload["type"], "create") + self.assertEqual(payload["model"], "bus.record.event.fake") + + data = payload["data"] + self.assertEqual(data["id"], record.id) + + def test_write_notification(self): + """Test that writing to a record sends a notification.""" + record = self.Model.with_user(self.user_1).create({"name": "Test Write"}) + self.env.cr.precommit.run() # trigger the creation of bus.bus records + + bus = self.env["bus.bus"] + last_id = bus.search([], order="id desc", limit=1).id or 0 + + record.write({"name": "Updated Name"}) + self.env.cr.precommit.run() # trigger the creation of bus.bus records + + notifications = bus.search([("id", ">", last_id)]) + + # Check channel + channel_name = f"record_events:bus.record.event.fake:{record.id}" + relevant_notif = self._get_relevant_notification(notifications, channel_name) + self.assertTrue(relevant_notif, "Should have notification on record channel") + + # Check model channel + model_channel_name = "record_events:bus.record.event.fake" + model_notif = self._get_relevant_notification(notifications, model_channel_name) + self.assertTrue(model_notif, "Should have notification on model channel") + + # Check payload + message = json.loads(relevant_notif.message) + self.assertEqual(message["type"], "bus.record/event") + payload = message["payload"] + self.assertEqual(payload["type"], "write") + + data = payload["data"] + self.assertEqual(data["id"], record.id) + self.assertEqual(data["name"], "Updated Name") + + def test_unlink_notification(self): + """Test that deleting a record sends a notification.""" + record = self.Model.with_user(self.user_1).create({"name": "Test Unlink"}) + self.env.cr.precommit.run() # trigger the creation of bus.bus records + record_id = record.id + + bus = self.env["bus.bus"] + last_id = bus.search([], order="id desc", limit=1).id or 0 + + record.unlink() + self.env.cr.precommit.run() # trigger the creation of bus.bus records + + notifications = bus.search([("id", ">", last_id)]) + + # Check channel + channel_name = f"record_events:bus.record.event.fake:{record_id}" + relevant_notif = self._get_relevant_notification(notifications, channel_name) + self.assertTrue(relevant_notif, "Should have notification on record channel") + + # Check payload + message = json.loads(relevant_notif.message) + self.assertEqual(message["type"], "bus.record/event") + payload = message["payload"] + self.assertEqual(payload["type"], "unlink") + self.assertEqual(payload["id"], record_id) + + def test_permission_check_orm(self): + """Test the permission logic in ir.websocket using ORM methods.""" + + # Let's create a rule that restricts access based on user_id + self.env["ir.rule"].create( + { + "name": "Fake Model Rule", + "model_id": self.env["ir.model"]._get("bus.record.event.fake").id, + "domain_force": "[('user_id', '=', user.id)]", + "groups": [(4, self.env.ref("base.group_user").id)], + } + ) + + record_u1 = self.Model.with_user(self.user_1).create( + {"name": "User 1 Record", "user_id": self.user_1.id} + ) + record_u2 = self.Model.with_user(self.user_2).create( + {"name": "User 2 Record", "user_id": self.user_2.id} + ) + + # User 1 should be able to subscribe to their own record + # Note: _check_subscription is an internal method of ir.websocket, + # but we can test if the user can read the record which implies they + # can subscribe if our ir.websocket implementation delegates to + # check_access_rights('read'). + + # Verify User 1 can read their record + self.assertTrue(record_u1.with_user(self.user_1).check_access_rights("read")) + record_u1.with_user(self.user_1).check_access_rule("read") + + # Verify User 1 cannot read User 2's record + with self.assertRaises(AccessError): + record_u2.with_user(self.user_1).check_access_rule("read") diff --git a/bus_record_events/tests/test_bus_record_events_websocket.py b/bus_record_events/tests/test_bus_record_events_websocket.py new file mode 100644 index 000000000000..16f33d929ba0 --- /dev/null +++ b/bus_record_events/tests/test_bus_record_events_websocket.py @@ -0,0 +1,149 @@ +import json +import logging +import time + +from odoo.tests import tagged +from odoo.tests.common import new_test_user + +from odoo.addons.bus.tests.common import WebsocketCase + +from .common import TestBusRecordEventsCase + +_logger = logging.getLogger(__name__) + + +@tagged("-at_install", "post_install") +class TestBusRecordEventsWebsocket(TestBusRecordEventsCase, WebsocketCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create access rights for the fake model + cls.env["ir.model.access"].create( + { + "name": "access_bus_record_event_fake_user", + "model_id": cls.env["ir.model"]._get("bus.record.event.fake").id, + "group_id": cls.env.ref("base.group_user").id, + "perm_read": 1, + "perm_write": 1, + "perm_create": 1, + "perm_unlink": 1, + } + ) + + # Create record rule + cls.env["ir.rule"].create( + { + "name": "Fake Model Rule", + "model_id": cls.env["ir.model"]._get("bus.record.event.fake").id, + "domain_force": "[('user_id', '=', user.id)]", + "groups": [(4, cls.env.ref("base.group_user").id)], + } + ) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() + + def _is_message_for_record(self, notification, record_id): + """Check if the notification is for the given record.""" + message = notification.get("message") + if not message: + return False + + # Handle potential string message (if bus sends it as string) + if isinstance(message, str): + try: + message = json.loads(message) + except json.JSONDecodeError: + _logger.exception("Failed to decode message JSON") + pass + + if not isinstance(message, dict): + return False + + if message.get("type") != "bus.record/event": + return False + + payload = message.get("payload") + if not payload: + return False + + if payload.get("model") != "bus.record.event.fake": + return False + + try: + data = payload.get("data") + return data.get("id") == record_id + except Exception: + return False + + def test_permission_check_websocket(self): + """Test that notifications are filtered based on permissions.""" + user_1 = new_test_user(self.env, login="ws_user_1", groups="base.group_user") + user_2 = new_test_user(self.env, login="ws_user_2", groups="base.group_user") + + record_u1 = ( + self.env["bus.record.event.fake"] + .with_user(user_1) + .create({"name": "User 1", "user_id": user_1.id}) + ) + record_u2 = ( + self.env["bus.record.event.fake"] + .with_user(user_2) + .create({"name": "User 2", "user_id": user_2.id}) + ) + + # Authenticate as User 1 + session = self.authenticate("ws_user_1", "ws_user_1") + websocket = self.websocket_connect(cookie=f"session_id={session.sid};") + + # Channels to subscribe + channel_u1 = f"record_events:bus.record.event.fake:{record_u1.id}" + channel_u2 = f"record_events:bus.record.event.fake:{record_u2.id}" + + # Subscribe to both channels + # User 1 should only be successfully subscribed to channel_u1 + self.subscribe( + websocket, [channel_u1, channel_u2], self.env["bus.bus"]._bus_last_id() + ) + + # Trigger notification for U2 (User 1 should NOT receive it) + record_u2.write({"name": "Update U2"}) + self.trigger_notification_dispatching([channel_u2]) + + # Trigger notification for U1 (User 1 SHOULD receive it) + record_u1.write({"name": "Update U1"}) + self.trigger_notification_dispatching([channel_u1]) + + # Read messages + messages = [] + start_time = time.time() + # Wait up to 5 seconds for messages + while time.time() - start_time < 5: + try: + frame = websocket.recv() + data = json.loads(frame) + if isinstance(data, list): + messages.extend(data) + # If we found the message for U1, we can stop waiting + if any( + self._is_message_for_record(m, record_u1.id) for m in messages + ): + break + except Exception: + _logger.exception("Error receiving websocket message") + # Timeout or other error + pass + + # Verify results + u1_msgs = [m for m in messages if self._is_message_for_record(m, record_u1.id)] + u2_msgs = [m for m in messages if self._is_message_for_record(m, record_u2.id)] + + self.assertTrue( + u1_msgs, "User 1 should receive notification for their own record" + ) + self.assertFalse( + u2_msgs, "User 1 should NOT receive notification for User 2's record" + ) diff --git a/bus_record_events/tests/test_js.py b/bus_record_events/tests/test_js.py new file mode 100644 index 000000000000..76a4a31eb2e2 --- /dev/null +++ b/bus_record_events/tests/test_js.py @@ -0,0 +1,13 @@ +from odoo.tests import HttpCase, tagged + + +@tagged("-at_install", "post_install") +class TestBusRecordEventsJS(HttpCase): + def test_js_unit(self): + self.browser_js( + "/web/tests/?debug=assets&filter=bus_record_events", + "", + "", + login="admin", + timeout=1800, + ) diff --git a/bus_record_events_all/README.rst b/bus_record_events_all/README.rst new file mode 100644 index 000000000000..dbea95808ff8 --- /dev/null +++ b/bus_record_events_all/README.rst @@ -0,0 +1,136 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +===================== +Bus Record Events All +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:edf36d9f82d7f9a2246ea20b742ac377839578e59e26a6d3009941de11788f26 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/17.0/bus_record_events_all + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-17-0/web-17-0-bus_record_events_all + :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/web&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends ``bus_record_events`` to automatically apply the +``bus.record.event.mixin`` behavior to all models in the system (with +some technical exceptions). + +It also automatically injects the appropriate ``js_class`` (e.g., +``bus_record_event_form``, ``bus_record_event_kanban``) into views, +making them reactive by default without manual XML changes. + +**WARNING**: This module should be installed with caution, being aware +of the risks it entails, both in terms of database space usage (due to +the volume of bus notifications) and security implications (broadcasting +events for all models). + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +Global application of bus record events. + +Installation +============ + +Depends on ``bus_record_events``. + +Configuration +============= + +No configuration required. + +Usage +===== + +Just install the module. All supported views for all models will become +reactive to CRUD events. + +Known issues / Roadmap +====================== + +- Monitor performance impact on high-traffic systems. + +Changelog +========= + +17.0.1.0.0 + +:: + + + * Initial release. + +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 +------- + +* Heligrafics Fotogrametria S.L. + +Contributors +------------ + +- Heligrafics Fotogrametria S.L. : + + - Jose Zambudio + +Other credits +------------- + +The development of this module has been financially supported by: + +- Heligrafics Fotogrametria S.L. + +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/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/bus_record_events_all/__init__.py b/bus_record_events_all/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/bus_record_events_all/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/bus_record_events_all/__manifest__.py b/bus_record_events_all/__manifest__.py new file mode 100644 index 000000000000..3e1f310b9268 --- /dev/null +++ b/bus_record_events_all/__manifest__.py @@ -0,0 +1,15 @@ +{ + "name": "Bus Record Events All", + "version": "17.0.1.0.0", + "category": "Web", + "summary": "Applies to all models notifications with bus.record.event.mixin", + "author": "Heligrafics Fotogrametria S.L., Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "license": "AGPL-3", + "depends": [ + "base", + "bus_record_events", + ], + "data": [], + "installable": True, +} diff --git a/bus_record_events_all/models/__init__.py b/bus_record_events_all/models/__init__.py new file mode 100644 index 000000000000..0e44449338cf --- /dev/null +++ b/bus_record_events_all/models/__init__.py @@ -0,0 +1 @@ +from . import base diff --git a/bus_record_events_all/models/base.py b/bus_record_events_all/models/base.py new file mode 100644 index 000000000000..00f894657827 --- /dev/null +++ b/bus_record_events_all/models/base.py @@ -0,0 +1,64 @@ +from lxml import etree + +from odoo import api, models + + +class Base(models.AbstractModel): + _inherit = "base" + + def create(self, vals_list): + records = super().create(vals_list) + if self._should_notify_bus(): + self.env["bus.record.event.mixin"]._notify_bus_static(records, "create") + return records + + def write(self, vals): + res = super().write(vals) + if self._should_notify_bus(): + self.env["bus.record.event.mixin"]._notify_bus_static(self, "write", vals) + return res + + def unlink(self): + should_notify = self._should_notify_bus() + notifications = [] + if should_notify: + notifications = self.env[ + "bus.record.event.mixin" + ]._prepare_unlink_notifications_static(self) + + res = super().unlink() + + if should_notify and notifications: + self.env["bus.bus"]._sendmany(notifications) + + return res + + @api.model + def get_view(self, view_id=None, view_type="form", **options): + result = super().get_view(view_id=view_id, view_type=view_type, **options) + + view_type_mapping = { + "tree": "bus_record_event_list", + "list": "bus_record_event_list", + "form": "bus_record_event_form", + "kanban": "bus_record_event_kanban", + "calendar": "bus_record_event_calendar", + "pivot": "bus_record_event_pivot", + "graph": "bus_record_event_graph", + } + + if js_class := view_type_mapping.get(view_type): + doc = etree.XML(result["arch"]) + doc.set("js_class", js_class) + result["arch"] = etree.tostring(doc, encoding="unicode") + + return result + + def _should_notify_bus(self): + # Exclude technical models or models that shouldn't broadcast + if self._name in ["bus.bus", "bus.presence", "ir.websocket", "ir.logging"]: + return False + # Maybe exclude transient models? + if self.is_transient(): + return False + return True diff --git a/bus_record_events_all/pyproject.toml b/bus_record_events_all/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/bus_record_events_all/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/bus_record_events_all/readme/CONFIGURE.md b/bus_record_events_all/readme/CONFIGURE.md new file mode 100644 index 000000000000..e7dc235973ab --- /dev/null +++ b/bus_record_events_all/readme/CONFIGURE.md @@ -0,0 +1 @@ +No configuration required. diff --git a/bus_record_events_all/readme/CONTEXT.md b/bus_record_events_all/readme/CONTEXT.md new file mode 100644 index 000000000000..d75b8f35a843 --- /dev/null +++ b/bus_record_events_all/readme/CONTEXT.md @@ -0,0 +1 @@ +Global application of bus record events. diff --git a/bus_record_events_all/readme/CONTRIBUTORS.md b/bus_record_events_all/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..7e6d311bae0b --- /dev/null +++ b/bus_record_events_all/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +* Heligrafics Fotogrametria S.L. \<\>: + - Jose Zambudio \<\> diff --git a/bus_record_events_all/readme/CREDITS.md b/bus_record_events_all/readme/CREDITS.md new file mode 100644 index 000000000000..6576257282fc --- /dev/null +++ b/bus_record_events_all/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +- Heligrafics Fotogrametria S.L. diff --git a/bus_record_events_all/readme/DESCRIPTION.md b/bus_record_events_all/readme/DESCRIPTION.md new file mode 100644 index 000000000000..bac3737be2d2 --- /dev/null +++ b/bus_record_events_all/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This module extends `bus_record_events` to automatically apply the `bus.record.event.mixin` behavior to all models in the system (with some technical exceptions). + +It also automatically injects the appropriate `js_class` (e.g., `bus_record_event_form`, `bus_record_event_kanban`) into views, making them reactive by default without manual XML changes. + +**WARNING**: This module should be installed with caution, being aware of the risks it entails, both in terms of database space usage (due to the volume of bus notifications) and security implications (broadcasting events for all models). diff --git a/bus_record_events_all/readme/HISTORY.md b/bus_record_events_all/readme/HISTORY.md new file mode 100644 index 000000000000..2bc95893a9b2 --- /dev/null +++ b/bus_record_events_all/readme/HISTORY.md @@ -0,0 +1,4 @@ +17.0.1.0.0 +~~~~~~~~~~ + +* Initial release. diff --git a/bus_record_events_all/readme/INSTALL.md b/bus_record_events_all/readme/INSTALL.md new file mode 100644 index 000000000000..c7318b40f1c7 --- /dev/null +++ b/bus_record_events_all/readme/INSTALL.md @@ -0,0 +1 @@ +Depends on `bus_record_events`. diff --git a/bus_record_events_all/readme/ROADMAP.md b/bus_record_events_all/readme/ROADMAP.md new file mode 100644 index 000000000000..d033fbf3a698 --- /dev/null +++ b/bus_record_events_all/readme/ROADMAP.md @@ -0,0 +1 @@ +* Monitor performance impact on high-traffic systems. diff --git a/bus_record_events_all/readme/USAGE.md b/bus_record_events_all/readme/USAGE.md new file mode 100644 index 000000000000..2673ebf260aa --- /dev/null +++ b/bus_record_events_all/readme/USAGE.md @@ -0,0 +1 @@ +Just install the module. All supported views for all models will become reactive to CRUD events. diff --git a/bus_record_events_all/static/description/index.html b/bus_record_events_all/static/description/index.html new file mode 100644 index 000000000000..97691bc8a5f1 --- /dev/null +++ b/bus_record_events_all/static/description/index.html @@ -0,0 +1,485 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Bus Record Events All

+ +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This module extends bus_record_events to automatically apply the +bus.record.event.mixin behavior to all models in the system (with +some technical exceptions).

+

It also automatically injects the appropriate js_class (e.g., +bus_record_event_form, bus_record_event_kanban) into views, +making them reactive by default without manual XML changes.

+

WARNING: This module should be installed with caution, being aware +of the risks it entails, both in terms of database space usage (due to +the volume of bus notifications) and security implications (broadcasting +events for all models).

+

Table of contents

+ +
+

Use Cases / Context

+

Global application of bus record events.

+
+
+

Installation

+

Depends on bus_record_events.

+
+
+

Configuration

+

No configuration required.

+
+
+

Usage

+

Just install the module. All supported views for all models will become +reactive to CRUD events.

+
+
+

Known issues / Roadmap

+
    +
  • Monitor performance impact on high-traffic systems.
  • +
+
+
+

Changelog

+

17.0.1.0.0

+
+*   Initial release.
+
+
+
+

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

+
    +
  • Heligrafics Fotogrametria S.L.
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • Heligrafics Fotogrametria S.L.
  • +
+
+
+

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/web project on GitHub.

+

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

+
+
+
+
+ + diff --git a/bus_record_events_demo/README.rst b/bus_record_events_demo/README.rst new file mode 100644 index 000000000000..3d316be1f76c --- /dev/null +++ b/bus_record_events_demo/README.rst @@ -0,0 +1,130 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +====================== +Bus Record Events Demo +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:dfd4c41c1d420571faf8bd7c4155a187c4c44b3885c09d83eed4c6c7c83a31ef + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/17.0/bus_record_events_demo + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-17-0/web-17-0-bus_record_events_demo + :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/web&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This is a demo module for ``bus_record_events``. It creates a model +``bus.record.event.demo`` and provides views configured to use the +reactive mixin. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +Demo data and views. + +Installation +============ + +Depends on ``bus_record_events``. + +Configuration +============= + +No configuration required. + +Usage +===== + +1. Install the module. +2. Go to the "Bus Record Events Demo" menu. +3. Open the same record in two different browser tabs/windows. +4. Edit the record in one tab and save. +5. Observe the notification or update in the other tab. + +Known issues / Roadmap +====================== + +- None. + +Changelog +========= + +17.0.1.0.0 + +:: + + + * Initial release. + +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 +------- + +* Heligrafics Fotogrametria S.L. + +Contributors +------------ + +- Heligrafics Fotogrametria S.L. : + + - Jose Zambudio + +Other credits +------------- + +The development of this module has been financially supported by: + +- Heligrafics Fotogrametria S.L. + +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/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/bus_record_events_demo/__init__.py b/bus_record_events_demo/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/bus_record_events_demo/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/bus_record_events_demo/__manifest__.py b/bus_record_events_demo/__manifest__.py new file mode 100644 index 000000000000..d0f92ef1f63f --- /dev/null +++ b/bus_record_events_demo/__manifest__.py @@ -0,0 +1,21 @@ +{ + "name": "Bus Record Events Demo", + "version": "17.0.1.0.0", + "category": "Web", + "summary": "Demo module for Bus Record Events.", + "author": "Heligrafics Fotogrametria S.L., Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "license": "AGPL-3", + "depends": [ + "base", + "bus_record_events", + ], + "data": [ + "security/ir.model.access.csv", + "security/bus_record_events_demo.xml", + "views/bus_record_event_demo_views.xml", + "demo/res_users.xml", + "demo/bus_record_event_demo.xml", + ], + "installable": True, +} diff --git a/bus_record_events_demo/demo/bus_record_event_demo.xml b/bus_record_events_demo/demo/bus_record_event_demo.xml new file mode 100644 index 000000000000..0f7981c2243d --- /dev/null +++ b/bus_record_events_demo/demo/bus_record_event_demo.xml @@ -0,0 +1,17 @@ + + + + Record for User 1 + + + + + Record for User 2 + + + + + Public Record + + + diff --git a/bus_record_events_demo/demo/res_users.xml b/bus_record_events_demo/demo/res_users.xml new file mode 100644 index 000000000000..64f3f2e482bf --- /dev/null +++ b/bus_record_events_demo/demo/res_users.xml @@ -0,0 +1,27 @@ + + + + + + demo_bus_1 + demo_bus_1 + Demo Bus User 1 + + + + + + demo_bus_2 + demo_bus_2 + Demo Bus User 2 + + + diff --git a/bus_record_events_demo/models/__init__.py b/bus_record_events_demo/models/__init__.py new file mode 100644 index 000000000000..fe31bbbd01ea --- /dev/null +++ b/bus_record_events_demo/models/__init__.py @@ -0,0 +1 @@ +from . import bus_record_event_demo diff --git a/bus_record_events_demo/models/bus_record_event_demo.py b/bus_record_events_demo/models/bus_record_event_demo.py new file mode 100644 index 000000000000..f6b023c5beff --- /dev/null +++ b/bus_record_events_demo/models/bus_record_event_demo.py @@ -0,0 +1,23 @@ +from odoo import fields, models + + +class BusRecordEventDemo(models.Model): + _name = "bus.record.event.demo" + _description = "Bus Record Event Demo Model" + _inherit = ["bus.record.event.mixin"] + + name = fields.Char(required=True) + description = fields.Text() + user_id = fields.Many2one( + "res.users", string="User", default=lambda self: self.env.user + ) + date = fields.Date(default=fields.Date.context_today) + value = fields.Float(default=0.0) + state = fields.Selection( + [("draft", "Draft"), ("confirmed", "Confirmed"), ("done", "Done")], + default="draft", + ) + priority = fields.Selection( + [("0", "Low"), ("1", "Normal"), ("2", "High")], + default="0", + ) diff --git a/bus_record_events_demo/pyproject.toml b/bus_record_events_demo/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/bus_record_events_demo/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/bus_record_events_demo/readme/CONFIGURE.md b/bus_record_events_demo/readme/CONFIGURE.md new file mode 100644 index 000000000000..e7dc235973ab --- /dev/null +++ b/bus_record_events_demo/readme/CONFIGURE.md @@ -0,0 +1 @@ +No configuration required. diff --git a/bus_record_events_demo/readme/CONTEXT.md b/bus_record_events_demo/readme/CONTEXT.md new file mode 100644 index 000000000000..8ee4bd8b5b0b --- /dev/null +++ b/bus_record_events_demo/readme/CONTEXT.md @@ -0,0 +1 @@ +Demo data and views. diff --git a/bus_record_events_demo/readme/CONTRIBUTORS.md b/bus_record_events_demo/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..7e6d311bae0b --- /dev/null +++ b/bus_record_events_demo/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +* Heligrafics Fotogrametria S.L. \<\>: + - Jose Zambudio \<\> diff --git a/bus_record_events_demo/readme/CREDITS.md b/bus_record_events_demo/readme/CREDITS.md new file mode 100644 index 000000000000..6576257282fc --- /dev/null +++ b/bus_record_events_demo/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +- Heligrafics Fotogrametria S.L. diff --git a/bus_record_events_demo/readme/DESCRIPTION.md b/bus_record_events_demo/readme/DESCRIPTION.md new file mode 100644 index 000000000000..e17efa5a3da5 --- /dev/null +++ b/bus_record_events_demo/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This is a demo module for `bus_record_events`. It creates a model `bus.record.event.demo` and provides views configured to use the reactive mixin. diff --git a/bus_record_events_demo/readme/HISTORY.md b/bus_record_events_demo/readme/HISTORY.md new file mode 100644 index 000000000000..2bc95893a9b2 --- /dev/null +++ b/bus_record_events_demo/readme/HISTORY.md @@ -0,0 +1,4 @@ +17.0.1.0.0 +~~~~~~~~~~ + +* Initial release. diff --git a/bus_record_events_demo/readme/INSTALL.md b/bus_record_events_demo/readme/INSTALL.md new file mode 100644 index 000000000000..c7318b40f1c7 --- /dev/null +++ b/bus_record_events_demo/readme/INSTALL.md @@ -0,0 +1 @@ +Depends on `bus_record_events`. diff --git a/bus_record_events_demo/readme/ROADMAP.md b/bus_record_events_demo/readme/ROADMAP.md new file mode 100644 index 000000000000..6ffb48842078 --- /dev/null +++ b/bus_record_events_demo/readme/ROADMAP.md @@ -0,0 +1 @@ +* None. diff --git a/bus_record_events_demo/readme/USAGE.md b/bus_record_events_demo/readme/USAGE.md new file mode 100644 index 000000000000..660679220266 --- /dev/null +++ b/bus_record_events_demo/readme/USAGE.md @@ -0,0 +1,5 @@ +1. Install the module. +2. Go to the "Bus Record Events Demo" menu. +3. Open the same record in two different browser tabs/windows. +4. Edit the record in one tab and save. +5. Observe the notification or update in the other tab. diff --git a/bus_record_events_demo/security/bus_record_events_demo.xml b/bus_record_events_demo/security/bus_record_events_demo.xml new file mode 100644 index 000000000000..d824b97a7c24 --- /dev/null +++ b/bus_record_events_demo/security/bus_record_events_demo.xml @@ -0,0 +1,18 @@ + + + + Bus Record Event Demo: User own records + + ['|', ('user_id', '=', user.id), ('user_id', '=', False)] + + + + + Bus Record Event Demo: Admin all records + + [(1, '=', 1)] + + + diff --git a/bus_record_events_demo/security/ir.model.access.csv b/bus_record_events_demo/security/ir.model.access.csv new file mode 100644 index 000000000000..757b4ba364ef --- /dev/null +++ b/bus_record_events_demo/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_bus_record_event_demo_user,bus.record.event.demo.user,model_bus_record_event_demo,base.group_user,1,1,1,1 +access_bus_record_event_demo_admin,bus.record.event.demo.admin,model_bus_record_event_demo,base.group_system,1,1,1,1 diff --git a/bus_record_events_demo/static/description/index.html b/bus_record_events_demo/static/description/index.html new file mode 100644 index 000000000000..b210b2348918 --- /dev/null +++ b/bus_record_events_demo/static/description/index.html @@ -0,0 +1,483 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Bus Record Events Demo

+ +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This is a demo module for bus_record_events. It creates a model +bus.record.event.demo and provides views configured to use the +reactive mixin.

+

Table of contents

+ +
+

Use Cases / Context

+

Demo data and views.

+
+
+

Installation

+

Depends on bus_record_events.

+
+
+

Configuration

+

No configuration required.

+
+
+

Usage

+
    +
  1. Install the module.
  2. +
  3. Go to the “Bus Record Events Demo” menu.
  4. +
  5. Open the same record in two different browser tabs/windows.
  6. +
  7. Edit the record in one tab and save.
  8. +
  9. Observe the notification or update in the other tab.
  10. +
+
+
+

Known issues / Roadmap

+
    +
  • None.
  • +
+
+
+

Changelog

+

17.0.1.0.0

+
+*   Initial release.
+
+
+
+

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

+
    +
  • Heligrafics Fotogrametria S.L.
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • Heligrafics Fotogrametria S.L.
  • +
+
+
+

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/web project on GitHub.

+

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

+
+
+
+
+ + diff --git a/bus_record_events_demo/views/bus_record_event_demo_views.xml b/bus_record_events_demo/views/bus_record_event_demo_views.xml new file mode 100644 index 000000000000..2707f9e810f0 --- /dev/null +++ b/bus_record_events_demo/views/bus_record_event_demo_views.xml @@ -0,0 +1,171 @@ + + + + bus.record.event.demo.tree + bus.record.event.demo + + + + + + + + + + + + + bus.record.event.demo.form + bus.record.event.demo + +
+
+ +
+ + + + + + + + + + + + + + +
+
+
+ + + bus.record.event.demo.kanban + bus.record.event.demo + + + + + + +
+
+
+ + + +
+ +
+
+ + +
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ + + bus.record.event.demo.calendar + bus.record.event.demo + + + + + + + + + + + bus.record.event.demo.pivot + bus.record.event.demo + + + + + + + + + + + bus.record.event.demo.graph + bus.record.event.demo + + + + + + + + + + Bus Event Demo + bus.record.event.demo + tree,kanban,calendar,pivot,graph,form + + + +