From 2d6571fa7dc235f0cc24918c81eb35a67f09a547 Mon Sep 17 00:00:00 2001 From: Pol Reig Date: Mon, 27 Jul 2026 11:13:53 +0200 Subject: [PATCH] [ADD] global_undo: undo/redo backend ops with Ctrl+Z Journal user CRUD and reversible business actions so they can be undone/redone from the systray, with history and a recoverable trash. --- global_undo/README.rst | 291 ++++++++ global_undo/__init__.py | 4 + global_undo/__manifest__.py | 38 + global_undo/data/ir_config_parameter_data.xml | 21 + global_undo/data/ir_cron_data.xml | 14 + global_undo/i18n/es.po | 196 ++++++ global_undo/models/__init__.py | 13 + global_undo/models/base.py | 266 +++++++ global_undo/models/global_undo_action.py | 112 +++ .../models/global_undo_config_mixin.py | 36 + global_undo/models/global_undo_exclusion.py | 45 ++ global_undo/models/global_undo_hook.py | 54 ++ global_undo/models/global_undo_operation.py | 293 ++++++++ global_undo/models/global_undo_transaction.py | 419 +++++++++++ global_undo/pyproject.toml | 3 + global_undo/readme/CONFIGURE.md | 41 ++ global_undo/readme/CONTRIBUTORS.md | 3 + global_undo/readme/CREDITS.md | 2 + global_undo/readme/DESCRIPTION.md | 18 + global_undo/readme/ROADMAP.md | 71 ++ global_undo/readme/USAGE.md | 28 + global_undo/security/global_undo_security.xml | 82 +++ global_undo/security/ir.model.access.csv | 7 + global_undo/static/description/icon.png | Bin 0 -> 10254 bytes global_undo/static/description/index.html | 660 ++++++++++++++++++ .../global_undo_service.esm.js | 104 +++ .../global_undo_systray.esm.js | 53 ++ .../global_undo_systray.scss | 27 + .../global_undo_systray.xml | 60 ++ .../tests/tours/global_undo_tour.esm.js | 89 +++ global_undo/tests/__init__.py | 8 + global_undo/tests/common.py | 42 ++ .../tests/test_global_undo_business.py | 156 +++++ global_undo/tests/test_global_undo_config.py | 112 +++ global_undo/tests/test_global_undo_crud.py | 222 ++++++ global_undo/tests/test_global_undo_tour.py | 26 + .../views/global_undo_action_views.xml | 32 + .../views/global_undo_exclusion_views.xml | 30 + global_undo/views/global_undo_menus.xml | 51 ++ .../views/global_undo_operation_views.xml | 133 ++++ .../views/global_undo_transaction_views.xml | 158 +++++ 41 files changed, 4020 insertions(+) create mode 100644 global_undo/README.rst create mode 100644 global_undo/__init__.py create mode 100644 global_undo/__manifest__.py create mode 100644 global_undo/data/ir_config_parameter_data.xml create mode 100644 global_undo/data/ir_cron_data.xml create mode 100644 global_undo/i18n/es.po create mode 100644 global_undo/models/__init__.py create mode 100644 global_undo/models/base.py create mode 100644 global_undo/models/global_undo_action.py create mode 100644 global_undo/models/global_undo_config_mixin.py create mode 100644 global_undo/models/global_undo_exclusion.py create mode 100644 global_undo/models/global_undo_hook.py create mode 100644 global_undo/models/global_undo_operation.py create mode 100644 global_undo/models/global_undo_transaction.py create mode 100644 global_undo/pyproject.toml create mode 100644 global_undo/readme/CONFIGURE.md create mode 100644 global_undo/readme/CONTRIBUTORS.md create mode 100644 global_undo/readme/CREDITS.md create mode 100644 global_undo/readme/DESCRIPTION.md create mode 100644 global_undo/readme/ROADMAP.md create mode 100644 global_undo/readme/USAGE.md create mode 100644 global_undo/security/global_undo_security.xml create mode 100644 global_undo/security/ir.model.access.csv create mode 100644 global_undo/static/description/icon.png create mode 100644 global_undo/static/description/index.html create mode 100644 global_undo/static/src/global_undo_service/global_undo_service.esm.js create mode 100644 global_undo/static/src/global_undo_systray/global_undo_systray.esm.js create mode 100644 global_undo/static/src/global_undo_systray/global_undo_systray.scss create mode 100644 global_undo/static/src/global_undo_systray/global_undo_systray.xml create mode 100644 global_undo/static/tests/tours/global_undo_tour.esm.js create mode 100644 global_undo/tests/__init__.py create mode 100644 global_undo/tests/common.py create mode 100644 global_undo/tests/test_global_undo_business.py create mode 100644 global_undo/tests/test_global_undo_config.py create mode 100644 global_undo/tests/test_global_undo_crud.py create mode 100644 global_undo/tests/test_global_undo_tour.py create mode 100644 global_undo/views/global_undo_action_views.xml create mode 100644 global_undo/views/global_undo_exclusion_views.xml create mode 100644 global_undo/views/global_undo_menus.xml create mode 100644 global_undo/views/global_undo_operation_views.xml create mode 100644 global_undo/views/global_undo_transaction_views.xml diff --git a/global_undo/README.rst b/global_undo/README.rst new file mode 100644 index 00000000000..31cb62848ab --- /dev/null +++ b/global_undo/README.rst @@ -0,0 +1,291 @@ +================== +Global Undo & Redo +================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:53eb7368a98deda858d4b8621af002c123790c878965cce36e51a07eb63587e8 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/18.0/global_undo + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-18-0/server-tools-18-0-global_undo + :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/server-tools&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module journals what a user does in the backend and lets them take +it back with **Ctrl+Z** (**Cmd+Z** on macOS), or replay it with +**Ctrl+Shift+Z**: + +- creations, updates and deletions, grouped one step per saved form, so + that a form and its one2many lines are a single undo; +- business actions that have a known inverse, such as confirming a sales + order or posting a journal entry; +- a history view and a trash from which deleted records can be restored. + +Permissions, record rules, multi-company and accounting integrity are +all checked before anything is replayed. When a step cannot be replayed +safely the module refuses with a clear message rather than leaving the +database inconsistent. + +Odoo core is not modified. The CRUD hooks come from an +``_inherit = "base"`` model and the business action hooks from +``_register_hook``, the same technique ``base_automation`` uses. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Groups +------ + +- **Global Undo: User**, implied by *Internal User*. Only the operations + of users in this group are journalled. Removing the group disables + recording for that user entirely. +- **Global Undo: Administrator**, implied by *Settings*. Can review and + undo the operations of every user, and read the stored snapshots. + +Undoable actions +---------------- + +*Settings > Global Undo > Configuration > Undoable Actions* declares +which business methods may be undone and which methods revert them, in +order. + +Three are shipped and activate themselves when their module is +installed: + ++--------------------+--------------------+----------------------------+ +| Model | Action | Reverted with | ++====================+====================+============================+ +| ``sale.order`` | ``action_confirm`` | ``_action_cancel``, | +| | | ``action_draft`` | ++--------------------+--------------------+----------------------------+ +| ``purchase.order`` | ``button_confirm`` | ``button_cancel``, | +| | | ``button_draft`` | ++--------------------+--------------------+----------------------------+ +| ``account.move`` | ``action_post`` | ``button_draft`` | ++--------------------+--------------------+----------------------------+ + +Add a line to cover a method of your own, or to change the inverse of +one of the three. Archiving a line that matches a shipped default +switches that default off. Changes take effect when the registry +reloads. + +Excluded models +--------------- + +*Settings > Global Undo > Configuration > Excluded Models* takes any +model out of the journal, on top of the technical and ledger models the +module always leaves alone. + +Retention +--------- + +A daily cron purges the history. Two separate windows, because +forgetting that something was deleted is a nuisance while losing the +only copy of it is data loss: + +- ``global_undo.retention_days`` (30 by default) for the history; +- ``global_undo.trash_retention_days`` (180 by default) for the steps + still holding recoverable records. Such a step survives the first + window even though it can no longer be undone. + +Usage +===== + +Press **Ctrl+Z** to undo the last thing you did, **Ctrl+Shift+Z** to +redo it. The shortcuts are deliberately registered without +``bypassEditableProtection``: inside an ``input`` or a ``textarea``, +Ctrl+Z keeps its native meaning of "undo what I just typed" and never +reaches the server. + +The systray also carries an undo icon, a redo icon and a history +dropdown with the ten most recent steps. Each button names in its +tooltip the step it would act on and is disabled when there is none, so +the shortcut never quietly undoes something other than what it +announces. + +The undo stack behaves like the one in any editor. Ctrl+Z takes the most +recently applied step, Ctrl+Shift+Z takes the oldest undone one, so +repeated redos walk back up the stack in the order it was unwound. Any +new operation discards the redo stack. + +Trash +----- + +Deleted records are listed under *Settings > Global Undo > Trash* and +can be restored from there. To bring back a parent and its children, +select them all and restore them in one go: the trash applies the same +ordering and the same id remapping as an undo, so the children come back +pointing at the parent's new id. Restoring only a child leaves it +orphaned. + +History +------- + +*Settings > Global Undo > Undo History* lists every recorded step, its +operations, and lets a step be undone or redone from the form view. +Ordinary users see only their own; a Global Undo Administrator sees +everyone's. + +Known issues / Roadmap +====================== + +Some operations cannot be undone. The module prefers to refuse with a +clear message over leaving the database inconsistent. + +Never recorded +-------------- + +These do not appear in the history at all. + +- **Technical models.** Everything under ``ir.``, ``bus.``, ``mail.``, + ``base.``, ``report.``, ``iap.``, plus ``res.users.log`` and + ``res.users.settings``. This covers module installation, views, crons, + sequences, chatter messages, activities and notifications. +- **Ledger rows**, where the accounting and stock truth lives and which + may only change through the business layer that owns it: + ``account.move.line``, ``account.payment``, + ``account.partial.reconcile``, ``account.full.reconcile``, + ``account.bank.statement.line``, ``account.tax.repartition.line``, + ``stock.move``, ``stock.move.line``, ``stock.quant``, + ``stock.valuation.layer``, ``pos.order`` and its lines, and + ``product.price.history``. +- **Transient and abstract models**, which have no rows to put back. +- **Operations touching more than 200 records at once.** A mass update + is cheaper to redo by hand than to journal. +- **Anything done as superuser or with ``sudo()``**, during a module + install or upgrade, or while the registry is not ready. +- **Anything that is not the user editing data through the web client.** + Only the client's save, delete and archive calls and its button + presses count. Imports, XML-RPC, the portal, the website and the + client's own housekeeping calls are not journalled, so they never land + on top of somebody's undo stack. + +Recorded, but the undo is refused +--------------------------------- + +- **The record changed afterwards.** Every operation stores the + ``write_date`` it read right after running, and stores it again on + every replay. If it no longer matches exactly, somebody else touched + the record and undoing would discard their work. This also applies to + undoing a creation: deleting the record would take the other user's + edits with it. +- **The permission is no longer there.** Undoing a creation requires + delete rights, undoing a deletion requires create rights. Both + model-level access and record-level rules are checked. +- **The record belongs to a company outside your allowed companies.** +- **The record is gone**, or, for something being re-created, already + exists. +- **Journal entries.** An ``account.move`` carrying an + ``inalterable_hash`` cannot be touched at all. A posted entry refuses + plain field changes, since the only reversible thing about it is the + posting itself. A reconciled or paid entry refuses to be set back to + draft. Beyond that, Odoo's own ``button_draft`` rules (lock dates, + sequence, hashed journals) apply and their error is propagated as is. +- **Transfers already done.** A ``stock.picking`` in state ``done`` is + not reverted: the correct reversal is a return, not a deletion. +- **Another user's operations**, unless you are a Global Undo + Administrator. + +Undone, with a caveat worth knowing +----------------------------------- + +- **A restored record gets a new database id.** The ORM will not reuse a + deleted one. The new id is kept in ``restored_res_id`` and later + replays follow it, but any external reference to the old id stays + broken -- and in most cases already was, since the deletion cascaded + or nulled it. +- **One2many children do not come back on their own.** They have their + own lifecycle and were deleted as separate operations. Restore them + together with their parent from the trash and the link is rebuilt. +- **Binary fields are stored only up to 512 KB.** Larger attachments and + images are dropped rather than bloat the journal. The computed + variants of an image are not stored either, since they are regenerated + from the original. +- **Read-only computed fields and related fields are not stored**, + because they are recomputed from the fields that are. +- **Sequences are consumed.** Undoing the creation of an invoice does + not give its number back to ``ir.sequence``. A redo restores the + original number from the snapshot. +- **External effects are not reverted.** Sent emails, webhooks, third + party API calls and files written to disk are outside the database. +- **The chatter is not cleaned up.** Messages and followers created by + the original operation stay, because ``mail.*`` is not recorded. + +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 +------- + +* Pol Reig +* QubiQ + +Contributors +------------ + +- `QubiQ `__: + + - Pol Reig + +Other credits +------------- + +The business action hooks follow the method wrapping technique used by +Odoo's own ``base_automation`` module. + +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. + +.. |maintainer-polreig| image:: https://github.com/polreig.png?size=40px + :target: https://github.com/polreig + :alt: polreig + +Current `maintainer `__: + +|maintainer-polreig| + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/global_undo/__init__.py b/global_undo/__init__.py new file mode 100644 index 00000000000..35c838ffed6 --- /dev/null +++ b/global_undo/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/global_undo/__manifest__.py b/global_undo/__manifest__.py new file mode 100644 index 00000000000..f9c23903bbe --- /dev/null +++ b/global_undo/__manifest__.py @@ -0,0 +1,38 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Global Undo & Redo", + "summary": "Undo and redo backend operations with Ctrl+Z, with history and trash", + "version": "18.0.1.0.0", + "category": "Tools", + "author": "Pol Reig, QubiQ, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-tools", + "license": "AGPL-3", + "development_status": "Beta", + "maintainers": ["polreig"], + "application": False, + "installable": True, + "depends": ["base", "web"], + "data": [ + "security/global_undo_security.xml", + "security/ir.model.access.csv", + "data/ir_cron_data.xml", + "data/ir_config_parameter_data.xml", + "views/global_undo_transaction_views.xml", + "views/global_undo_operation_views.xml", + "views/global_undo_action_views.xml", + "views/global_undo_exclusion_views.xml", + "views/global_undo_menus.xml", + ], + "assets": { + "web.assets_backend": [ + "global_undo/static/src/global_undo_systray/global_undo_systray.scss", + "global_undo/static/src/global_undo_service/global_undo_service.esm.js", + "global_undo/static/src/global_undo_systray/global_undo_systray.esm.js", + "global_undo/static/src/global_undo_systray/global_undo_systray.xml", + ], + "web.assets_tests": [ + "global_undo/static/tests/tours/global_undo_tour.esm.js", + ], + }, +} diff --git a/global_undo/data/ir_config_parameter_data.xml b/global_undo/data/ir_config_parameter_data.xml new file mode 100644 index 00000000000..1d09babc0b8 --- /dev/null +++ b/global_undo/data/ir_config_parameter_data.xml @@ -0,0 +1,21 @@ + + + + + global_undo.retention_days + 30 + + + + + global_undo.trash_retention_days + 180 + + diff --git a/global_undo/data/ir_cron_data.xml b/global_undo/data/ir_cron_data.xml new file mode 100644 index 00000000000..be902e4cad8 --- /dev/null +++ b/global_undo/data/ir_cron_data.xml @@ -0,0 +1,14 @@ + + + + + Global Undo: purge old history + + code + model._gu_vacuum() + 1 + days + + + diff --git a/global_undo/i18n/es.po b/global_undo/i18n/es.po new file mode 100644 index 00000000000..36ecf4e43d4 --- /dev/null +++ b/global_undo/i18n/es.po @@ -0,0 +1,196 @@ +# Spanish translation of global_undo. +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: global_undo +msgid "%(model)s has no method %(method)s." +msgstr "%(model)s no tiene el método %(method)s." + +#. module: global_undo +msgid "%(record)s already exists." +msgstr "%(record)s ya existe." + +#. module: global_undo +msgid "%(record)s belongs to a company you are not working in." +msgstr "%(record)s pertenece a una empresa en la que no estás trabajando." + +#. module: global_undo +msgid "%(record)s changed after this operation; undoing it would discard newer edits." +msgstr "%(record)s ha cambiado después de esta operación; deshacerla descartaría cambios más recientes." + +#. module: global_undo +msgid "%(record)s is already done; stock moves cannot be reverted automatically." +msgstr "%(record)s ya está hecho; los movimientos de stock no se pueden revertir automáticamente." + +#. module: global_undo +msgid "%(record)s is not in the trash." +msgstr "%(record)s no está en la papelera." + +#. module: global_undo +msgid "%(record)s is posted; only its posting can be undone." +msgstr "%(record)s está contabilizado; solo se puede deshacer su contabilización." + +#. module: global_undo +msgid "%(record)s is reconciled or paid; unpost it manually first." +msgstr "%(record)s está conciliado o cobrado; pásalo a borrador manualmente primero." + +#. module: global_undo +msgid "%(record)s is secured by a hash and can no longer be changed." +msgstr "%(record)s está protegido por un hash y ya no se puede modificar." + +#. module: global_undo +msgid "%(record)s no longer exists." +msgstr "%(record)s ya no existe." + +#. module: global_undo +msgid "Empty step" +msgstr "Paso vacío" + +#. module: global_undo +msgid "Model %(model)s is no longer installed." +msgstr "El modelo %(model)s ya no está instalado." + +#. module: global_undo +msgid "Nothing left to redo." +msgstr "No queda nada por rehacer." + +#. module: global_undo +msgid "Nothing left to undo." +msgstr "No queda nada por deshacer." + +#. module: global_undo +msgid "Nothing to undo (Ctrl+Z)" +msgstr "Nada que deshacer (Ctrl+Z)" + +#. module: global_undo +msgid "Nothing to redo (Ctrl+Shift+Z)" +msgstr "Nada que rehacer (Ctrl+Shift+Z)" + +#. module: global_undo +msgid "Undo (Ctrl+Z): %s" +msgstr "Deshacer (Ctrl+Z): %s" + +#. module: global_undo +msgid "Redo (Ctrl+Shift+Z): %s" +msgstr "Rehacer (Ctrl+Shift+Z): %s" + +#. module: global_undo +msgid "Redone: %(step)s" +msgstr "Rehecho: %(step)s" + +#. module: global_undo +msgid "Undone: %(step)s" +msgstr "Deshecho: %(step)s" + +#. module: global_undo +msgid "This operation cannot be %(direction)s:\n%(reasons)s" +msgstr "Esta operación no se puede %(direction)s:\n%(reasons)s" + +#. module: global_undo +msgid "undone" +msgstr "deshacer" + +#. module: global_undo +msgid "redone" +msgstr "rehacer" + +#. module: global_undo +msgid "You are not allowed to %(access)s %(model)s records." +msgstr "No tienes permiso para %(access)s registros de %(model)s." + +#. module: global_undo +msgid "You are not allowed to %(access)s %(record)s." +msgstr "No tienes permiso para %(access)s %(record)s." + +#. module: global_undo +msgid "You can only undo your own operations." +msgstr "Solo puedes deshacer tus propias operaciones." + +#. module: global_undo +#: model:ir.model,name:global_undo.model_global_undo_transaction +msgid "Global Undo Transaction" +msgstr "Paso de deshacer global" + +#. module: global_undo +#: model:ir.model,name:global_undo.model_global_undo_operation +msgid "Global Undo Operation" +msgstr "Operación de deshacer global" + +#. module: global_undo +#: model:ir.model,name:global_undo.model_global_undo_exclusion +msgid "Global Undo Excluded Model" +msgstr "Modelo excluido de deshacer global" + +#. module: global_undo +#: model:ir.model,name:global_undo.model_global_undo_action +msgid "Global Undo Business Action" +msgstr "Acción de negocio deshacible" + +#. module: global_undo +#: model:ir.ui.menu,name:global_undo.global_undo_menu +msgid "Global Undo" +msgstr "Deshacer global" + +#. module: global_undo +#: model:ir.ui.menu,name:global_undo.global_undo_transaction_menu +msgid "Undo History" +msgstr "Historial" + +#. module: global_undo +#: model:ir.ui.menu,name:global_undo.global_undo_operation_menu_trash +msgid "Trash" +msgstr "Papelera" + +#. module: global_undo +#: model:ir.ui.menu,name:global_undo.global_undo_action_menu +msgid "Undoable Actions" +msgstr "Acciones deshacibles" + +#. module: global_undo +#: model:ir.ui.menu,name:global_undo.global_undo_exclusion_menu +msgid "Excluded Models" +msgstr "Modelos excluidos" + +#. module: global_undo +#: model:ir.model.fields.selection,name:global_undo.selection__global_undo_operation__kind__create +msgid "Created" +msgstr "Creado" + +#. module: global_undo +#: model:ir.model.fields.selection,name:global_undo.selection__global_undo_operation__kind__write +msgid "Updated" +msgstr "Modificado" + +#. module: global_undo +#: model:ir.model.fields.selection,name:global_undo.selection__global_undo_operation__kind__unlink +msgid "Deleted" +msgstr "Eliminado" + +#. module: global_undo +#: model:ir.model.fields.selection,name:global_undo.selection__global_undo_operation__kind__action +msgid "Executed" +msgstr "Ejecutado" + +#. module: global_undo +#: model:ir.model.fields.selection,name:global_undo.selection__global_undo_transaction__state__done +msgid "Applied" +msgstr "Aplicado" + +#. module: global_undo +#: model:ir.model.fields.selection,name:global_undo.selection__global_undo_transaction__state__undone +msgid "Undone" +msgstr "Deshecho" + +#. module: global_undo +#: model:ir.model.fields.selection,name:global_undo.selection__global_undo_transaction__state__discarded +msgid "Discarded" +msgstr "Descartado" diff --git a/global_undo/models/__init__.py b/global_undo/models/__init__.py new file mode 100644 index 00000000000..6ac8c76e312 --- /dev/null +++ b/global_undo/models/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +# The order matters: a model may only inherit a mixin that is already built, +# so the mixin has to be imported before the models using it. Keep isort away. +# isort: skip_file +from . import base +from . import global_undo_config_mixin +from . import global_undo_exclusion +from . import global_undo_action +from . import global_undo_hook +from . import global_undo_transaction +from . import global_undo_operation diff --git a/global_undo/models/base.py b/global_undo/models/base.py new file mode 100644 index 00000000000..c9867774aec --- /dev/null +++ b/global_undo/models/base.py @@ -0,0 +1,266 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +"""Recording layer: every tracked create, write and unlink is journalled so it +can be replayed backwards (undo) or forwards (redo). + +Odoo core is not modified: the hooks come from ``_inherit = "base"``. +""" + +from contextlib import contextmanager + +from odoo import api, fields, models +from odoo.http import request + +# Never recorded. Technical plumbing, logs and messaging side effects: replaying +# them is either meaningless or actively harmful. +UNTRACKED_PREFIXES = ( + "global.undo.", + "ir.", + "bus.", + "mail.", + "base.", + "report.", + "iap.", + "res.users.log", + "res.users.settings", +) + +# Never recorded either. These are ledger rows: the accounting and stock truth +# is stored here and is only allowed to change through the business layer that +# owns it. Writing them back directly would silently break integrity, so they +# are kept out of the journal entirely rather than recorded and then refused. +# Administrators can add their own through ``global.undo.exclusion``. +UNTRACKED_MODELS = frozenset( + { + "account.bank.statement.line", + "account.full.reconcile", + "account.move.line", + "account.partial.reconcile", + "account.payment", + "account.tax.repartition.line", + "pos.order", + "pos.order.line", + "product.price.history", + "stock.move", + "stock.move.line", + "stock.quant", + "stock.valuation.layer", + } +) + +# Fields that are never part of a snapshot: they are managed by the ORM itself. +UNTRACKED_FIELDS = frozenset( + {"id", "create_uid", "create_date", "write_uid", "write_date"} +) + +# Beyond this, the operation is a mass update and journalling it would cost more +# than it is worth. Such operations stay out of the history. +MAX_RECORDS = 200 + +# Attachments and images are stored so that restoring from the trash brings them +# back, but a single oversized file would bloat the journal for little gain. +MAX_BINARY_BYTES = 512 * 1024 + +# The ORM entry points the web client uses to change data on the user's behalf. +# Anything else it calls -- session bootstrap, onchange, name_search, or a +# housekeeping method such as res.company.iap_enrich_auto -- is the client +# talking to itself, and must not land on top of the user's undo stack. +USER_EDIT_METHODS = frozenset( + { + "action_archive", + "action_unarchive", + "copy", + "create", + "toggle_active", + "unlink", + "web_save", + "write", + } +) + + +@contextmanager +def gu_suspend(env): + """Stop recording inside the block. + + Used while undoing or redoing, so the replay does not become new history, + and while running a business action, since the action is the undoable unit + rather than the dozens of writes it performs. + + The flag lives on the cursor because ``Environment`` and ``Transaction`` + both use ``__slots__``, and a cursor is exactly one database transaction. + """ + cursor = env.cr + cursor.gu_suspended = getattr(cursor, "gu_suspended", 0) + 1 + try: + yield + finally: + cursor.gu_suspended -= 1 + + +def gu_is_user_edit(): + """Whether the current request is the user changing data through the UI. + + Without a request there is no client to second-guess (shell, cron, tests), + so the operation counts. With one, only the web client's save, delete and + archive calls and its button presses do. + """ + if not request: + return True + path = request.httprequest.path + if path.startswith("/web/dataset/call_button"): + return True + return ( + path.startswith("/web/dataset/call_kw") + and request.params.get("method") in USER_EDIT_METHODS + ) + + +def gu_dump(field, value): + """Turn a field value into something JSON can store.""" + if field.type == "many2one": + return value.id or False + if field.type in ("many2many", "one2many"): + return value.ids + if field.type == "reference": + return f"{value._name},{value.id}" if value else False + if field.type == "datetime": + return fields.Datetime.to_string(value) if value else False + if field.type == "date": + return fields.Date.to_string(value) if value else False + if field.type == "html": + return str(value) if value else False + if field.type == "binary": + if not value or len(value) > MAX_BINARY_BYTES: + return False + # Stored binaries already come back base64 encoded, as bytes. + return value.decode() if isinstance(value, bytes) else value + return value + + +class Base(models.AbstractModel): + _inherit = "base" + + # CRUD methods + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + if records._gu_is_tracked(): + self.env["global.undo.transaction"]._gu_log_create(records) + return records + + def write(self, vals): + tracked = self._gu_is_tracked() + before = self._gu_snapshot(list(vals)) if tracked else None + result = super().write(vals) + if tracked: + self.env["global.undo.transaction"]._gu_log_write(self, before) + return result + + def unlink(self): + tracked = self._gu_is_tracked() + snapshots = names = None + if tracked: + # Everything must be read before the rows are gone. + snapshots = self._gu_snapshot() + names = {record.id: record._gu_display_name() for record in self} + result = super().unlink() + if tracked: + self.env["global.undo.transaction"]._gu_log_unlink( + self._name, snapshots, names + ) + return result + + # Business methods + + def _gu_is_tracked(self): + """Whether operations on ``self`` belong in the undo journal.""" + if self._transient or self._abstract or self._name in UNTRACKED_MODELS: + return False + if self._name.startswith(UNTRACKED_PREFIXES): + return False + if len(self) > MAX_RECORDS: + return False + env = self.env + if env.su or not env.uid or not env.registry.ready: + return False + if getattr(env.cr, "gu_suspended", 0): + return False + if not gu_is_user_edit(): + return False + if self._name in env["global.undo.exclusion"]._gu_excluded_models(): + return False + return env.user.has_group("global_undo.global_undo_group_user") + + def _gu_recordable_fields(self): + """Stored fields whose value can be written back verbatim. + + Odoo 18 declares most user-editable fields as computed but writable + (``compute=..., store=True, readonly=False``) -- quantities, unit + prices, dates -- so only genuinely read-only computed fields are left + out. Related fields belong to another record and one2many children have + their own lifecycle. Binaries are kept, but only the source field: the + resized variants of an image are recomputed from it anyway. + """ + for name, field in self._fields.items(): + if name in UNTRACKED_FIELDS or not field.store: + continue + if field.type in ("one2many", "properties", "properties_definition"): + continue + if field.type == "binary" and field.compute: + continue + if field.related or ( + field.compute and field.readonly and not field.inverse + ): + continue + yield name, field + + def _gu_snapshot(self, fnames=None): + """Return ``{record_id: {field_name: json_value}}`` for ``self``. + + Read with elevated rights on purpose. A model may carry stored fields + restricted to another group -- ``res.partner.signup_type`` is one -- + and reading them as the acting user would make deleting an ordinary + contact fail outright. The snapshot is a faithful copy of the row so + that an undo puts it back whole; the views only show it to managers. + """ + recordable = dict(self._gu_recordable_fields()) + if fnames is not None: + recordable = { + name: recordable[name] for name in fnames if name in recordable + } + return { + record.id: { + name: gu_dump(field, record[name]) for name, field in recordable.items() + } + for record in self.sudo() + } + + def _gu_write_vals(self, data): + """Turn a snapshot back into values accepted by ``create`` or ``write``.""" + vals = {} + for name, value in data.items(): + field = self._fields.get(name) + if field is None: + continue + if field.type == "many2many": + vals[name] = [fields.Command.set(value or [])] + elif field.type == "binary": + vals[name] = value.encode() if value else False + else: + vals[name] = value + return vals + + def _gu_display_name(self): + """``display_name`` that never raises: it is only used as a log label.""" + self.ensure_one() + try: + return self.display_name + except Exception: # pylint: disable=broad-except + return f"{self._name},{self.id}" + + def _gu_write_stamp(self): + """``write_date`` observed right after an operation, for conflict checks.""" + self.ensure_one() + return self.write_date if self._log_access else False diff --git a/global_undo/models/global_undo_action.py b/global_undo/models/global_undo_action.py new file mode 100644 index 00000000000..aaafe032d79 --- /dev/null +++ b/global_undo/models/global_undo_action.py @@ -0,0 +1,112 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models, tools +from odoo.exceptions import ValidationError + +# Business actions shipped with a known, complete inverse. They live in Python +# rather than in a data file because the models they target belong to modules +# that may well be installed after this one. A model whose module is missing is +# simply skipped when the hooks are registered. +DEFAULT_ACTIONS = { + "sale.order": {"action_confirm": ("_action_cancel", "action_draft")}, + "purchase.order": {"button_confirm": ("button_cancel", "button_draft")}, + "account.move": {"action_post": ("button_draft",)}, +} + + +class GlobalUndoAction(models.Model): + """A business method whose effect can be reverted by calling other methods. + + Only configured actions are journalled as a single undoable step: undoing a + business action means trusting the inverse to leave the records consistent, + which is a judgement call belonging to whoever configures the database. + """ + + _name = "global.undo.action" + _inherit = "global.undo.config.mixin" + _description = "Global Undo Business Action" + _order = "model_id, method" + + model_id = fields.Many2one( + "ir.model", + string="Model", + required=True, + ondelete="cascade", + ) + model_name = fields.Char( + related="model_id.model", + store=True, + string="Technical Name", + ) + method = fields.Char( + required=True, + help="Method to make undoable, for example action_confirm.", + ) + undo_methods = fields.Char( + required=True, + help="Comma separated methods that, called in order, revert the " + "action. For example: _action_cancel, action_draft", + ) + active = fields.Boolean(default=True) + + _sql_constraints = [ + ( + "method_unique", + "unique(model_id, method)", + "This action is already configured.", + ), + ] + + # Constraints and onchanges + + @api.constrains("method", "undo_methods") + def _check_methods(self): + for rule in self: + model = self.env.get(rule.model_name) + if model is None: + continue + for name in [rule.method] + rule._gu_undo_methods(): + if not hasattr(model, name): + raise ValidationError( + _( + "%(model)s has no method %(method)s.", + model=rule.model_name, + method=name, + ) + ) + + # Business methods + + def _gu_undo_methods(self): + self.ensure_one() + return [ + name.strip() + for name in (self.undo_methods or "").split(",") + if name.strip() + ] + + @api.model + def _gu_registered(self): + """Return ``{model_name: {method: (undo_method, ...)}}`` for the hook. + + Starts from :data:`DEFAULT_ACTIONS` and lets the configured rules add + new actions, change an inverse, or archive a default one away. + """ + registered = { + model: dict(methods) for model, methods in DEFAULT_ACTIONS.items() + } + # The table does not exist yet the very first time the registry is + # loaded during this module's own installation. + if not tools.sql.table_exists(self.env.cr, self._table): + return registered + for rule in self.sudo().with_context(active_test=False).search([]): + methods = registered.setdefault(rule.model_name, {}) + if rule.active: + methods[rule.method] = tuple(rule._gu_undo_methods()) + else: + methods.pop(rule.method, None) + return registered + + def _gu_config_changed(self): + """Patching and unpatching methods only takes effect on a reload.""" + self.env.registry.registry_invalidated = True diff --git a/global_undo/models/global_undo_config_mixin.py b/global_undo/models/global_undo_config_mixin.py new file mode 100644 index 00000000000..4e61fe3122d --- /dev/null +++ b/global_undo/models/global_undo_config_mixin.py @@ -0,0 +1,36 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, models + + +class GlobalUndoConfigMixin(models.AbstractModel): + """Configuration whose every change has to reach a cache somewhere. + + Both configuration models are read on hot paths and cached, so no edit may + be allowed to go unnoticed. + """ + + _name = "global.undo.config.mixin" + _description = "Global Undo Configuration Mixin" + + # CRUD methods + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._gu_config_changed() + return records + + def write(self, vals): + result = super().write(vals) + self._gu_config_changed() + return result + + def unlink(self): + self._gu_config_changed() + return super().unlink() + + # Business methods + + def _gu_config_changed(self): + raise NotImplementedError diff --git a/global_undo/models/global_undo_exclusion.py b/global_undo/models/global_undo_exclusion.py new file mode 100644 index 00000000000..5b3dddf028d --- /dev/null +++ b/global_undo/models/global_undo_exclusion.py @@ -0,0 +1,45 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, fields, models, tools + + +class GlobalUndoExclusion(models.Model): + """A model that must never be recorded, on top of the built-in blacklist.""" + + _name = "global.undo.exclusion" + _inherit = "global.undo.config.mixin" + _description = "Global Undo Excluded Model" + _order = "model_id" + + model_id = fields.Many2one( + "ir.model", + string="Model", + required=True, + ondelete="cascade", + ) + model_name = fields.Char( + related="model_id.model", + store=True, + string="Technical Name", + ) + reason = fields.Char(help="Why this model must not be undoable.") + active = fields.Boolean(default=True) + + _sql_constraints = [ + ("model_unique", "unique(model_id)", "This model is already excluded."), + ] + + # Business methods + + @api.model + @tools.ormcache() + def _gu_excluded_models(self): + """Technical names of the excluded models, cached for the CRUD hooks. + + This runs on every tracked write, so it must not hit the database each + time; the cache is cleared whenever the configuration changes. + """ + return frozenset(self.sudo().search([]).mapped("model_name")) + + def _gu_config_changed(self): + self.env.registry.clear_cache() diff --git a/global_undo/models/global_undo_hook.py b/global_undo/models/global_undo_hook.py new file mode 100644 index 00000000000..e50f1d1e1ec --- /dev/null +++ b/global_undo/models/global_undo_hook.py @@ -0,0 +1,54 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import models + +from .base import gu_suspend + + +class GlobalUndoHook(models.AbstractModel): + """Wraps the business actions configured in ``global.undo.action``. + + A dedicated abstract model is used because ``_register_hook`` runs once per + model in the registry; putting it on ``base`` would run it hundreds of + times. Odoo core is not modified: the methods are wrapped in place, exactly + as ``base_automation`` does, and the original is kept on ``origin``. + """ + + _name = "global.undo.hook" + _description = "Global Undo Business Action Hook" + + def _register_hook(self): + def make_action(method_name, undo_methods): + def action(self, *args, **kwargs): + if not self._gu_is_tracked(): + return action.origin(self, *args, **kwargs) + # The action is the undoable unit; its writes are not. + with gu_suspend(self.env): + result = action.origin(self, *args, **kwargs) + # Labelled afterwards: actions such as posting assign the name. + targets = [ + (record.id, record._gu_display_name(), record._gu_write_stamp()) + for record in self.exists() + ] + self.env["global.undo.transaction"]._gu_log_action( + self._name, method_name, undo_methods, targets + ) + return result + + action._gu_patched = True + return action + + registered = self.env["global.undo.action"]._gu_registered() + for model_name, methods in registered.items(): + if model_name not in self.env: + continue + model_class = self.env.registry[model_name] + for method_name, undo_methods in methods.items(): + origin = getattr(model_class, method_name, None) + if origin is None or getattr(origin, "_gu_patched", False): + continue + if not all(hasattr(model_class, name) for name in undo_methods): + continue + patched = make_action(method_name, undo_methods) + patched.origin = origin + setattr(model_class, method_name, patched) diff --git a/global_undo/models/global_undo_operation.py b/global_undo/models/global_undo_operation.py new file mode 100644 index 00000000000..86e3b6b63d9 --- /dev/null +++ b/global_undo/models/global_undo_operation.py @@ -0,0 +1,293 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +"""One journalled operation, and the rules deciding whether it may be replayed.""" + +import json + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from .base import gu_suspend + +# Access right required to replay an operation in each direction. +REQUIRED_ACCESS = { + ("create", "undo"): "unlink", + ("create", "redo"): "create", + ("write", "undo"): "write", + ("write", "redo"): "write", + ("unlink", "undo"): "create", + ("unlink", "redo"): "unlink", + ("action", "undo"): "write", + ("action", "redo"): "write", +} + + +class GlobalUndoOperation(models.Model): + _name = "global.undo.operation" + _description = "Global Undo Operation" + _order = "id desc" + + transaction_id = fields.Many2one( + "global.undo.transaction", + required=True, + index=True, + ondelete="cascade", + ) + kind = fields.Selection( + [ + ("create", "Created"), + ("write", "Updated"), + ("unlink", "Deleted"), + ("action", "Executed"), + ], + required=True, + index=True, + ) + model_name = fields.Char(string="Model", required=True, index=True) + res_id = fields.Integer(string="Record ID", required=True) + res_name = fields.Char(string="Record") + # A record restored from the trash gets a fresh database id; later replays + # must follow it instead of the original one. + restored_res_id = fields.Integer() + # The record's write_date as it stood right after this operation, and after + # every replay of it. Anything else means somebody edited the record in the + # meantime and undoing would silently discard their work. An exact match is + # used rather than a time window: the journal writes the value it read, so + # two edits within the same second are still two different values. + record_write_date = fields.Datetime() + values_before = fields.Text() + values_after = fields.Text() + method = fields.Char(help="Business method that was executed.") + undo_methods = fields.Char( + help="JSON list of methods that revert the executed one." + ) + + user_id = fields.Many2one(related="transaction_id.user_id", store=True, index=True) + company_id = fields.Many2one( + related="transaction_id.company_id", store=True, index=True + ) + state = fields.Selection(related="transaction_id.state", store=True) + # Deleted records still in the trash: never restored, and the deletion itself + # has not been undone. + in_trash = fields.Boolean(compute="_compute_in_trash", store=True) + + @api.depends("kind", "restored_res_id", "state") + def _compute_in_trash(self): + for operation in self: + operation.in_trash = ( + operation.kind == "unlink" + and not operation.restored_res_id + and operation.state != "undone" + ) + + # ------------------------------------------------------------------ + # Replay + # ------------------------------------------------------------------ + + def _gu_target(self): + self.ensure_one() + return self.env[self.model_name].browse(self.restored_res_id or self.res_id) + + def _gu_stamp(self): + """Record the target's current ``write_date`` as this operation's baseline.""" + for operation in self: + if operation.model_name not in self.env: + continue + record = operation._gu_target().exists() + operation.sudo().record_write_date = ( + record._gu_write_stamp() if record else False + ) + + def _gu_blocker(self, direction): + """Human readable reason why this cannot be replayed, or ``None``.""" + self.ensure_one() + if self.model_name not in self.env: + return _("Model %(model)s is no longer installed.", model=self.model_name) + model = self.env[self.model_name] + access = REQUIRED_ACCESS[(self.kind, direction)] + if not model.browse().has_access(access): + return _( + "You are not allowed to %(access)s %(model)s records.", + access=access, + model=self.model_name, + ) + + recreating = (self.kind, direction) in (("unlink", "undo"), ("create", "redo")) + record = self._gu_target().exists() + if recreating: + if record: + return _( + "%(record)s already exists.", + record=self.res_name or self.model_name, + ) + return None + if not record: + return _( + "%(record)s no longer exists.", record=self.res_name or self.model_name + ) + if not record.has_access(access): + return _( + "You are not allowed to %(access)s %(record)s.", + access=access, + record=self.res_name or self.model_name, + ) + if ( + "company_id" in model._fields + and record.company_id + and record.company_id.id not in self.env.companies.ids + ): + return _( + "%(record)s belongs to a company you are not working in.", + record=self.res_name, + ) + # Applies to undoing a creation too: deleting the record would take + # somebody else's later edits down with it. + if ( + self.record_write_date + and record._gu_write_stamp() != self.record_write_date + ): + return _( + "%(record)s changed after this operation; undoing it would " + "discard newer edits.", + record=self.res_name or self.model_name, + ) + return self._gu_integrity_blocker(record, direction) + + def _gu_integrity_blocker(self, record, direction): + """Accounting and stock rules that outrank the undo history.""" + if self.model_name == "account.move": + if record.inalterable_hash: + return _( + "%(record)s is secured by a hash and can no longer be changed.", + record=self.res_name, + ) + if record.state == "posted" and self.kind != "action": + return _( + "%(record)s is posted; only its posting can be undone.", + record=self.res_name, + ) + if ( + self.kind == "action" + and direction == "undo" + and record.payment_state not in ("not_paid", False) + ): + return _( + "%(record)s is reconciled or paid; unpost it manually first.", + record=self.res_name, + ) + if self.model_name == "stock.picking" and record.state == "done": + return _( + "%(record)s is already done; stock moves cannot be reverted " + "automatically.", + record=self.res_name, + ) + return None + + def _gu_apply(self, direction, remap=None): + """Replay this operation. + + Operations whose target has vanished mid-step are skipped rather than + failing: deleting a parent cascades to children that carry their own + journal entries, and reaching the intended state early is a success. + + The replay runs with elevated rights. ``_gu_blocker`` is the gate that + decides whether this user may touch this record; past it, the snapshot + has to go back verbatim, including stored fields whose own group the + user is not in and which they were never able to set by hand. + """ + self.ensure_one() + if (self.kind, direction) in (("unlink", "undo"), ("create", "redo")): + self._gu_restore(remap) + return + record = self._gu_target().sudo().exists() + if not record: + return + if self.kind == "action": + methods = ( + json.loads(self.undo_methods) if direction == "undo" else [self.method] + ) + for name in methods: + getattr(record, name)() + elif (self.kind, direction) in (("create", "undo"), ("unlink", "redo")): + record.unlink() + # The restored copy is gone: a later restore must start over, and the + # deletion belongs back in the trash. + self.sudo().restored_res_id = False + else: + data = json.loads( + (self.values_before if direction == "undo" else self.values_after) + or "{}" + ) + record.write(self.env[self.model_name]._gu_write_vals(data)) + + def _gu_restore(self, remap=None): + """Re-create the record from its snapshot, under a new database id. + + ``remap`` carries ``{(model, old_id): new_id}`` for records already + restored in this step, so children re-created after their parent point + at the parent's new id instead of the dead one. + """ + model = self.env[self.model_name].sudo() + data = json.loads(self.values_before or self.values_after or "{}") + vals = model._gu_write_vals(data) + for name, value in vals.items(): + field = model._fields[name] + if field.type == "many2one" and value and remap: + vals[name] = remap.get((field.comodel_name, value), value) + record = model.create(vals) + self.sudo().restored_res_id = record.id + if remap is not None: + remap[(self.model_name, self.res_id)] = record.id + + # ------------------------------------------------------------------ + # Trash + # ------------------------------------------------------------------ + + def _gu_restore_order(self): + """Parents before children, as in a transaction replay. + + A nested delete journals the children before their parent, so replaying + newest first re-creates the parent before the records that point at it. + """ + return self.sorted("id", reverse=True) + + def action_restore(self): + """Restore deleted records straight from the trash view. + + Restoring a parent and its children together goes through the same + ordering and id remapping as an undo, otherwise the children would come + back pointing at the parent's dead id. + """ + operations = self._gu_restore_order() + remap = {} + # All or nothing: a half-restored parent is worse than none at all. + with self.env.cr.savepoint(), gu_suspend(self.env): + for operation in operations: + if not operation.in_trash: + raise UserError( + _("%(record)s is not in the trash.", record=operation.res_name) + ) + blocker = operation._gu_blocker("undo") + if blocker: + raise UserError(blocker) + operation._gu_restore(remap) + operations._gu_stamp() + return True + + def action_open_record(self): + self.ensure_one() + record = self._gu_target() + if not record.exists(): + raise UserError( + _( + "%(record)s no longer exists.", + record=self.res_name or self.model_name, + ) + ) + return { + "type": "ir.actions.act_window", + "res_model": self.model_name, + "res_id": record.id, + "view_mode": "form", + "target": "current", + } diff --git a/global_undo/models/global_undo_transaction.py b/global_undo/models/global_undo_transaction.py new file mode 100644 index 00000000000..fa98f5e25cc --- /dev/null +++ b/global_undo/models/global_undo_transaction.py @@ -0,0 +1,419 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +"""A transaction is one undoable step: everything a single user request did.""" + +import json +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from .base import gu_suspend + +_logger = logging.getLogger(__name__) + +DEFAULT_RETENTION_DAYS = 30 +DEFAULT_TRASH_RETENTION_DAYS = 180 +VACUUM_BATCH = 1000 + + +class GlobalUndoTransaction(models.Model): + _name = "global.undo.transaction" + _description = "Global Undo Transaction" + _order = "id desc" + + user_id = fields.Many2one( + "res.users", string="User", required=True, index=True, ondelete="cascade" + ) + company_id = fields.Many2one("res.company", string="Company", index=True) + state = fields.Selection( + [("done", "Applied"), ("undone", "Undone"), ("discarded", "Discarded")], + default="done", + required=True, + index=True, + ) + operation_ids = fields.One2many( + "global.undo.operation", "transaction_id", string="Operations" + ) + operation_count = fields.Integer(compute="_compute_summary") + name = fields.Char(compute="_compute_summary") + + @api.depends( + "operation_ids.kind", "operation_ids.res_name", "operation_ids.model_name" + ) + def _compute_summary(self): + labels = dict( + self.env["global.undo.operation"] + ._fields["kind"] + ._description_selection(self.env) + ) + # One lookup per distinct model instead of one per transaction: the + # history list would otherwise query ir.model on every row. + model_labels = {} + for transaction in self: + operations = transaction.operation_ids + transaction.operation_count = len(operations) + if not operations: + transaction.name = _("Empty step") + continue + # Label the step after its newest operation: a nested create + # journals the children first, so the newest one is the parent the + # user actually thinks they created. + main = operations.sorted("id")[-1] + if main.model_name not in model_labels: + model_labels[main.model_name] = ( + self.env["ir.model"]._get(main.model_name).name or main.model_name + ) + model_label = model_labels[main.model_name] + if len(operations) == 1: + transaction.name = _( + "%(action)s %(model)s: %(record)s", + action=labels.get(main.kind, main.kind), + model=model_label, + record=main.res_name or main.res_id, + ) + else: + transaction.name = _( + "%(action)s %(count)s %(model)s", + action=labels.get(main.kind, main.kind), + count=len(operations), + model=model_label, + ) + + # ------------------------------------------------------------------ + # Journalling + # ------------------------------------------------------------------ + + @api.model + def _gu_current(self): + """The step being recorded, one per user request. + + Grouping on the cursor is what makes a form save with its one2many + children a single Ctrl+Z, since a request owns exactly one cursor. + """ + cursor = self.env.cr + current = self.browse(getattr(cursor, "gu_transaction_id", False)).exists() + if current: + return current + current = self.sudo().create( + { + "user_id": self.env.uid, + "company_id": self.env.company.id, + } + ) + cursor.gu_transaction_id = current.id + # New history makes the redo stack unreachable, as in any editor. + self.sudo().search( + [("user_id", "=", self.env.uid), ("state", "=", "undone")] + ).state = "discarded" + return current + + @api.model + def _gu_log(self, values_list): + transaction = self._gu_current() + for values in values_list: + values["transaction_id"] = transaction.id + operations = self.env["global.undo.operation"].sudo().create(values_list) + transaction._gu_refresh_stamps(operations) + + def _gu_refresh_stamps(self, operations): + """Realign earlier operations of this step on the same records. + + Saving a form creates a record and then writes to it; both are part of + the same step, but the write moves ``write_date`` past what the creation + recorded. Without this the step would look concurrently modified to + itself and refuse to be undone. + """ + self.ensure_one() + stamps = { + (operation.model_name, operation.res_id): operation.record_write_date + for operation in operations + if operation.record_write_date + } + if not stamps: + return + siblings = ( + self.env["global.undo.operation"] + .sudo() + .search( + [ + ("transaction_id", "=", self.id), + ("id", "not in", operations.ids), + ("model_name", "in", [model for model, _res_id in stamps]), + ] + ) + ) + for sibling in siblings: + stamp = stamps.get((sibling.model_name, sibling.res_id)) + if ( + stamp + and sibling.record_write_date + and sibling.record_write_date < stamp + ): + sibling.record_write_date = stamp + + @api.model + def _gu_log_create(self, records): + snapshots = records._gu_snapshot() + self._gu_log( + [ + { + "kind": "create", + "model_name": records._name, + "res_id": record.id, + "res_name": record._gu_display_name(), + "record_write_date": record._gu_write_stamp(), + # Needed to re-create the record if the undo is later redone. + "values_after": json.dumps(snapshots[record.id], default=str), + } + for record in records + ] + ) + + @api.model + def _gu_log_write(self, records, before): + # Every record of the batch was snapshotted on the same field names. + fnames = list(next(iter(before.values()), ())) + after = records._gu_snapshot(fnames) + values_list = [] + for record in records: + old, new = before.get(record.id, {}), after.get(record.id, {}) + changed = {name for name, value in old.items() if new.get(name) != value} + if not changed: + continue + values_list.append( + { + "kind": "write", + "model_name": records._name, + "res_id": record.id, + "res_name": record._gu_display_name(), + "record_write_date": record._gu_write_stamp(), + "values_before": json.dumps( + {name: old[name] for name in changed}, default=str + ), + "values_after": json.dumps( + {name: new[name] for name in changed}, default=str + ), + } + ) + if values_list: + self._gu_log(values_list) + + @api.model + def _gu_log_unlink(self, model_name, snapshots, names): + self._gu_log( + [ + { + "kind": "unlink", + "model_name": model_name, + "res_id": res_id, + "res_name": names.get(res_id), + "values_before": json.dumps(snapshot, default=str), + } + for res_id, snapshot in snapshots.items() + ] + ) + + @api.model + def _gu_log_action(self, model_name, method, undo_methods, targets): + self._gu_log( + [ + { + "kind": "action", + "model_name": model_name, + "res_id": res_id, + "res_name": res_name, + "record_write_date": stamp, + "method": method, + "undo_methods": json.dumps(list(undo_methods)), + } + for res_id, res_name, stamp in targets + ] + ) + + # ------------------------------------------------------------------ + # Undo / redo + # ------------------------------------------------------------------ + + def _gu_ordered(self, direction): + """Operations in the order they must be replayed. + + A nested create journals the children before their parent, so newest + first is also parent first: on undo that lets the parent's delete + cascade to the children, and on redo it makes the parent exist before + the children that reference it. Everything else replays in the order it + originally happened. + """ + self.ensure_one() + operations = self.operation_ids.sorted("id", reverse=True) + if direction == "undo": + return operations + creations = operations.filtered(lambda operation: operation.kind == "create") + return creations + (operations - creations).sorted("id") + + def _gu_apply(self, direction): + """Replay this step backwards (``undo``) or forwards (``redo``).""" + self.ensure_one() + if self.user_id != self.env.user and not self.env.user.has_group( + "global_undo.global_undo_group_manager" + ): + raise UserError(_("You can only undo your own operations.")) + operations = self._gu_ordered(direction) + blockers = [ + reason + for reason in (op._gu_blocker(direction) for op in operations) + if reason + ] + if blockers: + raise UserError( + _( + "This operation cannot be %(direction)s:\n%(reasons)s", + direction=_("undone") if direction == "undo" else _("redone"), + reasons="\n".join( + "- " + reason for reason in dict.fromkeys(blockers) + ), + ) + ) + # Old id -> new id of everything re-created along the way, so that + # children restored after their parent point at the new parent. + remap = {} + # A half-applied step would be worse than none: the savepoint rolls the + # whole thing back, cache included, if any operation fails. + with self.env.cr.savepoint(), gu_suspend(self.env): + for operation in operations: + operation._gu_apply(direction, remap) + # The replay itself just moved write_date on every record it touched. + operations._gu_stamp() + self.sudo().state = "undone" if direction == "undo" else "done" + return self.name + + def action_undo(self): + self._gu_apply("undo") + return True + + def action_redo(self): + self._gu_apply("redo") + return True + + # ------------------------------------------------------------------ + # Client interface + # ------------------------------------------------------------------ + + @api.model + def _gu_next(self, direction): + """The step Ctrl+Z / Ctrl+Shift+Z would act on, if any. + + Undo takes the newest applied step; redo takes the oldest undone one, + so that repeated redos walk back up the stack in the order it was + unwound. + """ + return self.search( + [ + ("user_id", "=", self.env.uid), + ("state", "=", "done" if direction == "undo" else "undone"), + ], + order="id desc" if direction == "undo" else "id asc", + limit=1, + ) + + @api.model + def gu_state(self): + """Everything the systray needs, in one round trip.""" + undo, redo = self._gu_next("undo"), self._gu_next("redo") + history = self.search([("user_id", "=", self.env.uid)], limit=10) + return { + "undo": {"id": undo.id, "name": undo.name} if undo else False, + "redo": {"id": redo.id, "name": redo.name} if redo else False, + "history": [ + { + "id": transaction.id, + "name": transaction.name, + "state": transaction.state, + "date": fields.Datetime.to_string(transaction.create_date), + } + for transaction in history + ], + } + + @api.model + def gu_apply_next(self, direction): + """Undo or redo the next step and report the outcome to the client.""" + transaction = self._gu_next(direction) + if not transaction: + return { + "done": False, + "message": ( + _("Nothing left to undo.") + if direction == "undo" + else _("Nothing left to redo.") + ), + "state": self.gu_state(), + } + try: + name = transaction._gu_apply(direction) + except UserError as error: + return {"done": False, "message": str(error), "state": self.gu_state()} + return { + "done": True, + "message": ( + _("Undone: %(step)s", step=name) + if direction == "undo" + else _("Redone: %(step)s", step=name) + ), + "state": self.gu_state(), + } + + # ------------------------------------------------------------------ + # Housekeeping + # ------------------------------------------------------------------ + + @api.model + def _gu_vacuum(self): + """Drop history past its retention window (cron). + + The trash keeps its own, longer window: forgetting that a record was + deleted is a nuisance, but losing the only copy of it is data loss, so + a step still holding recoverable records is kept until the trash window + expires even though it can no longer be undone. + """ + parameters = self.env["ir.config_parameter"].sudo() + days = int( + parameters.get_param("global_undo.retention_days", DEFAULT_RETENTION_DAYS) + ) + trash_days = int( + parameters.get_param( + "global_undo.trash_retention_days", DEFAULT_TRASH_RETENTION_DAYS + ) + ) + now = fields.Datetime.now() + trash_limit = fields.Datetime.subtract(now, days=trash_days) + expired = self.sudo().search( + [ + ("create_date", "<", fields.Datetime.subtract(now, days=days)), + ] + ) + holding_trash = ( + self.env["global.undo.operation"] + .sudo() + .search( + [ + ("transaction_id", "in", expired.ids), + ("in_trash", "=", True), + ] + ) + .transaction_id + ) + stale = (expired - holding_trash) | holding_trash.filtered( + lambda transaction: transaction.create_date < trash_limit + ) + _logger.info( + "Global undo vacuum: removing %s transactions " + "(history %s days, trash %s days)", + len(stale), + days, + trash_days, + ) + # Batched rather than one statement: a year of history can be hundreds + # of thousands of rows. The cron commits once the job returns. + for index in range(0, len(stale), VACUUM_BATCH): + stale[index : index + VACUUM_BATCH].unlink() diff --git a/global_undo/pyproject.toml b/global_undo/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/global_undo/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/global_undo/readme/CONFIGURE.md b/global_undo/readme/CONFIGURE.md new file mode 100644 index 00000000000..7e20501e09d --- /dev/null +++ b/global_undo/readme/CONFIGURE.md @@ -0,0 +1,41 @@ +## Groups + +* **Global Undo: User**, implied by *Internal User*. Only the operations of + users in this group are journalled. Removing the group disables recording for + that user entirely. +* **Global Undo: Administrator**, implied by *Settings*. Can review and undo + the operations of every user, and read the stored snapshots. + +## Undoable actions + +*Settings > Global Undo > Configuration > Undoable Actions* declares which +business methods may be undone and which methods revert them, in order. + +Three are shipped and activate themselves when their module is installed: + +| Model | Action | Reverted with | +| --- | --- | --- | +| `sale.order` | `action_confirm` | `_action_cancel`, `action_draft` | +| `purchase.order` | `button_confirm` | `button_cancel`, `button_draft` | +| `account.move` | `action_post` | `button_draft` | + +Add a line to cover a method of your own, or to change the inverse of one of +the three. Archiving a line that matches a shipped default switches that +default off. Changes take effect when the registry reloads. + +## Excluded models + +*Settings > Global Undo > Configuration > Excluded Models* takes any model out +of the journal, on top of the technical and ledger models the module always +leaves alone. + +## Retention + +A daily cron purges the history. Two separate windows, because forgetting that +something was deleted is a nuisance while losing the only copy of it is data +loss: + +* `global_undo.retention_days` (30 by default) for the history; +* `global_undo.trash_retention_days` (180 by default) for the steps still + holding recoverable records. Such a step survives the first window even + though it can no longer be undone. diff --git a/global_undo/readme/CONTRIBUTORS.md b/global_undo/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..d1a1d9c9205 --- /dev/null +++ b/global_undo/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [QubiQ](https://www.qubiq.es): + + - Pol Reig \<\> diff --git a/global_undo/readme/CREDITS.md b/global_undo/readme/CREDITS.md new file mode 100644 index 00000000000..abe7a393ab6 --- /dev/null +++ b/global_undo/readme/CREDITS.md @@ -0,0 +1,2 @@ +The business action hooks follow the method wrapping technique used by Odoo's +own `base_automation` module. diff --git a/global_undo/readme/DESCRIPTION.md b/global_undo/readme/DESCRIPTION.md new file mode 100644 index 00000000000..4db0dcea008 --- /dev/null +++ b/global_undo/readme/DESCRIPTION.md @@ -0,0 +1,18 @@ +This module journals what a user does in the backend and lets them take it +back with **Ctrl+Z** (**Cmd+Z** on macOS), or replay it with +**Ctrl+Shift+Z**: + +* creations, updates and deletions, grouped one step per saved form, so that a + form and its one2many lines are a single undo; +* business actions that have a known inverse, such as confirming a sales order + or posting a journal entry; +* a history view and a trash from which deleted records can be restored. + +Permissions, record rules, multi-company and accounting integrity are all +checked before anything is replayed. When a step cannot be replayed safely the +module refuses with a clear message rather than leaving the database +inconsistent. + +Odoo core is not modified. The CRUD hooks come from an `_inherit = "base"` +model and the business action hooks from `_register_hook`, the same technique +`base_automation` uses. diff --git a/global_undo/readme/ROADMAP.md b/global_undo/readme/ROADMAP.md new file mode 100644 index 00000000000..3668e76dd56 --- /dev/null +++ b/global_undo/readme/ROADMAP.md @@ -0,0 +1,71 @@ +Some operations cannot be undone. The module prefers to refuse with a clear +message over leaving the database inconsistent. + +## Never recorded + +These do not appear in the history at all. + +* **Technical models.** Everything under `ir.`, `bus.`, `mail.`, + `base.`, `report.`, `iap.`, plus `res.users.log` and + `res.users.settings`. This covers module installation, views, crons, + sequences, chatter messages, activities and notifications. +* **Ledger rows**, where the accounting and stock truth lives and which may + only change through the business layer that owns it: `account.move.line`, + `account.payment`, `account.partial.reconcile`, `account.full.reconcile`, + `account.bank.statement.line`, `account.tax.repartition.line`, + `stock.move`, `stock.move.line`, `stock.quant`, + `stock.valuation.layer`, `pos.order` and its lines, and + `product.price.history`. +* **Transient and abstract models**, which have no rows to put back. +* **Operations touching more than 200 records at once.** A mass update is + cheaper to redo by hand than to journal. +* **Anything done as superuser or with `sudo()`**, during a module install or + upgrade, or while the registry is not ready. +* **Anything that is not the user editing data through the web client.** Only + the client's save, delete and archive calls and its button presses count. + Imports, XML-RPC, the portal, the website and the client's own housekeeping + calls are not journalled, so they never land on top of somebody's undo stack. + +## Recorded, but the undo is refused + +* **The record changed afterwards.** Every operation stores the `write_date` + it read right after running, and stores it again on every replay. If it no + longer matches exactly, somebody else touched the record and undoing would + discard their work. This also applies to undoing a creation: deleting the + record would take the other user's edits with it. +* **The permission is no longer there.** Undoing a creation requires delete + rights, undoing a deletion requires create rights. Both model-level access + and record-level rules are checked. +* **The record belongs to a company outside your allowed companies.** +* **The record is gone**, or, for something being re-created, already exists. +* **Journal entries.** An `account.move` carrying an `inalterable_hash` + cannot be touched at all. A posted entry refuses plain field changes, since + the only reversible thing about it is the posting itself. A reconciled or + paid entry refuses to be set back to draft. Beyond that, Odoo's own + `button_draft` rules (lock dates, sequence, hashed journals) apply and + their error is propagated as is. +* **Transfers already done.** A `stock.picking` in state `done` is not + reverted: the correct reversal is a return, not a deletion. +* **Another user's operations**, unless you are a Global Undo Administrator. + +## Undone, with a caveat worth knowing + +* **A restored record gets a new database id.** The ORM will not reuse a + deleted one. The new id is kept in `restored_res_id` and later replays + follow it, but any external reference to the old id stays broken -- and in + most cases already was, since the deletion cascaded or nulled it. +* **One2many children do not come back on their own.** They have their own + lifecycle and were deleted as separate operations. Restore them together with + their parent from the trash and the link is rebuilt. +* **Binary fields are stored only up to 512 KB.** Larger attachments and images + are dropped rather than bloat the journal. The computed variants of an image + are not stored either, since they are regenerated from the original. +* **Read-only computed fields and related fields are not stored**, because they + are recomputed from the fields that are. +* **Sequences are consumed.** Undoing the creation of an invoice does not give + its number back to `ir.sequence`. A redo restores the original number from + the snapshot. +* **External effects are not reverted.** Sent emails, webhooks, third party API + calls and files written to disk are outside the database. +* **The chatter is not cleaned up.** Messages and followers created by the + original operation stay, because `mail.*` is not recorded. diff --git a/global_undo/readme/USAGE.md b/global_undo/readme/USAGE.md new file mode 100644 index 00000000000..62767ad0404 --- /dev/null +++ b/global_undo/readme/USAGE.md @@ -0,0 +1,28 @@ +Press **Ctrl+Z** to undo the last thing you did, **Ctrl+Shift+Z** to redo it. +The shortcuts are deliberately registered without `bypassEditableProtection`: +inside an `input` or a `textarea`, Ctrl+Z keeps its native meaning of "undo +what I just typed" and never reaches the server. + +The systray also carries an undo icon, a redo icon and a history dropdown with +the ten most recent steps. Each button names in its tooltip the step it would +act on and is disabled when there is none, so the shortcut never quietly undoes +something other than what it announces. + +The undo stack behaves like the one in any editor. Ctrl+Z takes the most +recently applied step, Ctrl+Shift+Z takes the oldest undone one, so repeated +redos walk back up the stack in the order it was unwound. Any new operation +discards the redo stack. + +## Trash + +Deleted records are listed under *Settings > Global Undo > Trash* and can be +restored from there. To bring back a parent and its children, select them all +and restore them in one go: the trash applies the same ordering and the same id +remapping as an undo, so the children come back pointing at the parent's new +id. Restoring only a child leaves it orphaned. + +## History + +*Settings > Global Undo > Undo History* lists every recorded step, its +operations, and lets a step be undone or redone from the form view. Ordinary +users see only their own; a Global Undo Administrator sees everyone's. diff --git a/global_undo/security/global_undo_security.xml b/global_undo/security/global_undo_security.xml new file mode 100644 index 00000000000..0547c8f9565 --- /dev/null +++ b/global_undo/security/global_undo_security.xml @@ -0,0 +1,82 @@ + + + + + Global Undo: User + + Operations of these users are journalled and can be undone by themselves. Removing a user from this group disables recording for them. + + + + Global Undo: Administrator + + + Can review and undo the operations of every user. + + + + + + + + + + + + Global Undo: own history only + + [('user_id', '=', user.id)] + + + + + Global Undo: all history + + [(1, '=', 1)] + + + + + Global Undo: allowed companies + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + + Global Undo: own operations only + + [('user_id', '=', user.id)] + + + + + Global Undo: all operations + + [(1, '=', 1)] + + + + + Global Undo: allowed companies (operations) + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + diff --git a/global_undo/security/ir.model.access.csv b/global_undo/security/ir.model.access.csv new file mode 100644 index 00000000000..7529da93541 --- /dev/null +++ b/global_undo/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_global_undo_transaction_user,global.undo.transaction user,model_global_undo_transaction,global_undo_group_user,1,0,0,0 +access_global_undo_operation_user,global.undo.operation user,model_global_undo_operation,global_undo_group_user,1,0,0,0 +access_global_undo_transaction_manager,global.undo.transaction manager,model_global_undo_transaction,global_undo_group_manager,1,1,1,1 +access_global_undo_operation_manager,global.undo.operation manager,model_global_undo_operation,global_undo_group_manager,1,1,1,1 +access_global_undo_exclusion_manager,global.undo.exclusion manager,model_global_undo_exclusion,global_undo_group_manager,1,1,1,1 +access_global_undo_action_manager,global.undo.action manager,model_global_undo_action,global_undo_group_manager,1,1,1,1 diff --git a/global_undo/static/description/icon.png b/global_undo/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11 GIT binary patch literal 10254 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 + + + + +Global Undo & Redo + + + +
+

Global Undo & Redo

+ + +

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

+

This module journals what a user does in the backend and lets them take +it back with Ctrl+Z (Cmd+Z on macOS), or replay it with +Ctrl+Shift+Z:

+
    +
  • creations, updates and deletions, grouped one step per saved form, so +that a form and its one2many lines are a single undo;
  • +
  • business actions that have a known inverse, such as confirming a sales +order or posting a journal entry;
  • +
  • a history view and a trash from which deleted records can be restored.
  • +
+

Permissions, record rules, multi-company and accounting integrity are +all checked before anything is replayed. When a step cannot be replayed +safely the module refuses with a clear message rather than leaving the +database inconsistent.

+

Odoo core is not modified. The CRUD hooks come from an +_inherit = "base" model and the business action hooks from +_register_hook, the same technique base_automation uses.

+

Table of contents

+ +
+

Configuration

+
+

Groups

+
    +
  • Global Undo: User, implied by Internal User. Only the operations +of users in this group are journalled. Removing the group disables +recording for that user entirely.
  • +
  • Global Undo: Administrator, implied by Settings. Can review and +undo the operations of every user, and read the stored snapshots.
  • +
+
+
+

Undoable actions

+

Settings > Global Undo > Configuration > Undoable Actions declares +which business methods may be undone and which methods revert them, in +order.

+

Three are shipped and activate themselves when their module is +installed:

+ +++++ + + + + + + + + + + + + + + + + + + + + +
ModelActionReverted with
sale.orderaction_confirm_action_cancel, +action_draft
purchase.orderbutton_confirmbutton_cancel, +button_draft
account.moveaction_postbutton_draft
+

Add a line to cover a method of your own, or to change the inverse of +one of the three. Archiving a line that matches a shipped default +switches that default off. Changes take effect when the registry +reloads.

+
+
+

Excluded models

+

Settings > Global Undo > Configuration > Excluded Models takes any +model out of the journal, on top of the technical and ledger models the +module always leaves alone.

+
+
+

Retention

+

A daily cron purges the history. Two separate windows, because +forgetting that something was deleted is a nuisance while losing the +only copy of it is data loss:

+
    +
  • global_undo.retention_days (30 by default) for the history;
  • +
  • global_undo.trash_retention_days (180 by default) for the steps +still holding recoverable records. Such a step survives the first +window even though it can no longer be undone.
  • +
+
+
+
+

Usage

+

Press Ctrl+Z to undo the last thing you did, Ctrl+Shift+Z to +redo it. The shortcuts are deliberately registered without +bypassEditableProtection: inside an input or a textarea, +Ctrl+Z keeps its native meaning of “undo what I just typed” and never +reaches the server.

+

The systray also carries an undo icon, a redo icon and a history +dropdown with the ten most recent steps. Each button names in its +tooltip the step it would act on and is disabled when there is none, so +the shortcut never quietly undoes something other than what it +announces.

+

The undo stack behaves like the one in any editor. Ctrl+Z takes the most +recently applied step, Ctrl+Shift+Z takes the oldest undone one, so +repeated redos walk back up the stack in the order it was unwound. Any +new operation discards the redo stack.

+
+

Trash

+

Deleted records are listed under Settings > Global Undo > Trash and +can be restored from there. To bring back a parent and its children, +select them all and restore them in one go: the trash applies the same +ordering and the same id remapping as an undo, so the children come back +pointing at the parent’s new id. Restoring only a child leaves it +orphaned.

+
+
+

History

+

Settings > Global Undo > Undo History lists every recorded step, its +operations, and lets a step be undone or redone from the form view. +Ordinary users see only their own; a Global Undo Administrator sees +everyone’s.

+
+
+
+

Known issues / Roadmap

+

Some operations cannot be undone. The module prefers to refuse with a +clear message over leaving the database inconsistent.

+
+

Never recorded

+

These do not appear in the history at all.

+
    +
  • Technical models. Everything under ir., bus., mail., +base., report., iap., plus res.users.log and +res.users.settings. This covers module installation, views, crons, +sequences, chatter messages, activities and notifications.
  • +
  • Ledger rows, where the accounting and stock truth lives and which +may only change through the business layer that owns it: +account.move.line, account.payment, +account.partial.reconcile, account.full.reconcile, +account.bank.statement.line, account.tax.repartition.line, +stock.move, stock.move.line, stock.quant, +stock.valuation.layer, pos.order and its lines, and +product.price.history.
  • +
  • Transient and abstract models, which have no rows to put back.
  • +
  • Operations touching more than 200 records at once. A mass update +is cheaper to redo by hand than to journal.
  • +
  • Anything done as superuser or with ``sudo()``, during a module +install or upgrade, or while the registry is not ready.
  • +
  • Anything that is not the user editing data through the web client. +Only the client’s save, delete and archive calls and its button +presses count. Imports, XML-RPC, the portal, the website and the +client’s own housekeeping calls are not journalled, so they never land +on top of somebody’s undo stack.
  • +
+
+
+

Recorded, but the undo is refused

+
    +
  • The record changed afterwards. Every operation stores the +write_date it read right after running, and stores it again on +every replay. If it no longer matches exactly, somebody else touched +the record and undoing would discard their work. This also applies to +undoing a creation: deleting the record would take the other user’s +edits with it.
  • +
  • The permission is no longer there. Undoing a creation requires +delete rights, undoing a deletion requires create rights. Both +model-level access and record-level rules are checked.
  • +
  • The record belongs to a company outside your allowed companies.
  • +
  • The record is gone, or, for something being re-created, already +exists.
  • +
  • Journal entries. An account.move carrying an +inalterable_hash cannot be touched at all. A posted entry refuses +plain field changes, since the only reversible thing about it is the +posting itself. A reconciled or paid entry refuses to be set back to +draft. Beyond that, Odoo’s own button_draft rules (lock dates, +sequence, hashed journals) apply and their error is propagated as is.
  • +
  • Transfers already done. A stock.picking in state done is +not reverted: the correct reversal is a return, not a deletion.
  • +
  • Another user’s operations, unless you are a Global Undo +Administrator.
  • +
+
+
+

Undone, with a caveat worth knowing

+
    +
  • A restored record gets a new database id. The ORM will not reuse a +deleted one. The new id is kept in restored_res_id and later +replays follow it, but any external reference to the old id stays +broken – and in most cases already was, since the deletion cascaded +or nulled it.
  • +
  • One2many children do not come back on their own. They have their +own lifecycle and were deleted as separate operations. Restore them +together with their parent from the trash and the link is rebuilt.
  • +
  • Binary fields are stored only up to 512 KB. Larger attachments and +images are dropped rather than bloat the journal. The computed +variants of an image are not stored either, since they are regenerated +from the original.
  • +
  • Read-only computed fields and related fields are not stored, +because they are recomputed from the fields that are.
  • +
  • Sequences are consumed. Undoing the creation of an invoice does +not give its number back to ir.sequence. A redo restores the +original number from the snapshot.
  • +
  • External effects are not reverted. Sent emails, webhooks, third +party API calls and files written to disk are outside the database.
  • +
  • The chatter is not cleaned up. Messages and followers created by +the original operation stay, because mail.* is not recorded.
  • +
+
+
+
+

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

+
    +
  • Pol Reig
  • +
  • QubiQ
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The business action hooks follow the method wrapping technique used by +Odoo’s own base_automation module.

+
+
+

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.

+

Current maintainer:

+

polreig

+

This module is part of the OCA/server-tools project on GitHub.

+

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

+
+
+
+ + diff --git a/global_undo/static/src/global_undo_service/global_undo_service.esm.js b/global_undo/static/src/global_undo_service/global_undo_service.esm.js new file mode 100644 index 00000000000..755daa0f6a2 --- /dev/null +++ b/global_undo/static/src/global_undo_service/global_undo_service.esm.js @@ -0,0 +1,104 @@ +// Copyright 2026 Pol Reig +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import {reactive} from "@odoo/owl"; +import {rpcBus} from "@web/core/network/rpc"; +import {registry} from "@web/core/registry"; + +/** + * The ORM methods through which the web client changes data on the user's + * behalf. Mirrors USER_EDIT_METHODS on the server: after any of them the undo + * stack has a new top, and the systray must stop advertising the previous one. + */ +const USER_EDIT_METHODS = new Set([ + "action_archive", + "action_unarchive", + "copy", + "create", + "toggle_active", + "unlink", + "web_save", + "write", +]); + +/** + * Owns the undo/redo state shared by the hotkeys and the systray item. + * + * The hotkeys are registered without `bypassEditableProtection`, so Ctrl+Z + * inside an input keeps its native "undo my typing" meaning and only reaches + * the server once the focus is out of a text field. + */ +export const globalUndoService = { + dependencies: ["orm", "notification", "hotkey", "action"], + + start(env, {orm, notification, hotkey, action}) { + const state = reactive({undo: false, redo: false, history: [], pending: false}); + + async function refresh() { + Object.assign( + state, + await orm.call("global.undo.transaction", "gu_state", []) + ); + } + + async function apply(direction) { + if (state.pending) { + return; + } + state.pending = true; + let result = null; + try { + result = await orm.call("global.undo.transaction", "gu_apply_next", [ + direction, + ]); + Object.assign(state, result.state); + } finally { + // Released as soon as the server has answered. The reload below + // is cosmetic, and holding the lock through it would silently + // swallow a quick second Ctrl+Z. + state.pending = false; + } + notification.add(result.message, { + type: result.done ? "success" : "warning", + }); + if (result.done) { + // The records on screen just changed under the user's feet. + await action.doAction("soft_reload"); + } + } + + hotkey.add("control+z", () => apply("undo"), {global: true}); + hotkey.add("control+shift+z", () => apply("redo"), {global: true}); + + // Without this the systray keeps showing the step that was on top when + // it was last opened, so the user reads one label and Ctrl+Z undoes a + // newer one. Saving a record is precisely what makes it stale. + rpcBus.addEventListener("RPC:RESPONSE", ({detail}) => { + const params = detail.data && detail.data.params; + if (detail.error || !params || params.model === "global.undo.transaction") { + return; + } + // Buttons carry arbitrary method names, so anything action-shaped + // counts too: gu_state is one small query, a stale label is not. + const method = params.method || ""; + if (USER_EDIT_METHODS.has(method) || /^(action|button)_/.test(method)) { + refresh(); + } + }); + + refresh(); + + return { + state, + refresh, + undo: () => apply("undo"), + redo: () => apply("redo"), + openHistory: () => + action.doAction("global_undo.global_undo_transaction_action"), + openTrash: () => + action.doAction("global_undo.global_undo_operation_action_trash"), + }; + }, +}; + +registry.category("services").add("global_undo", globalUndoService); diff --git a/global_undo/static/src/global_undo_systray/global_undo_systray.esm.js b/global_undo/static/src/global_undo_systray/global_undo_systray.esm.js new file mode 100644 index 00000000000..7c755821d6c --- /dev/null +++ b/global_undo/static/src/global_undo_systray/global_undo_systray.esm.js @@ -0,0 +1,53 @@ +// Copyright 2026 Pol Reig +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import {Component, useState} from "@odoo/owl"; +import {deserializeDateTime, formatDateTime} from "@web/core/l10n/dates"; +import {Dropdown} from "@web/core/dropdown/dropdown"; +import {DropdownItem} from "@web/core/dropdown/dropdown_item"; +import {_t} from "@web/core/l10n/translation"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; + +export class GlobalUndoSystray extends Component { + static template = "global_undo.GlobalUndoSystray"; + static components = {Dropdown, DropdownItem}; + static props = {}; + + setup() { + this.undoService = useService("global_undo"); + // UseState, not the bare reactive object: without subscribing here the + // systray would keep rendering whatever it saw on its first paint. + this.state = useState(this.undoService.state); + } + + /** + * What the button would act on right now. Naming the step is the whole + * point of keeping the state fresh: a shortcut that silently undoes + * something other than what the user has in mind is worse than no shortcut. + */ + buttonTitle(direction) { + const step = this.state[direction]; + if (direction === "undo") { + return step + ? _t("Undo (Ctrl+Z): %s", step.name) + : _t("Nothing to undo (Ctrl+Z)"); + } + return step + ? _t("Redo (Ctrl+Shift+Z): %s", step.name) + : _t("Nothing to redo (Ctrl+Shift+Z)"); + } + + formatDate(value) { + return value ? formatDateTime(deserializeDateTime(value)) : ""; + } + + onOpened() { + // The history changes with every save the user makes elsewhere. + this.undoService.refresh(); + } +} + +registry + .category("systray") + .add("global_undo.systray", {Component: GlobalUndoSystray}, {sequence: 20}); diff --git a/global_undo/static/src/global_undo_systray/global_undo_systray.scss b/global_undo/static/src/global_undo_systray/global_undo_systray.scss new file mode 100644 index 00000000000..0d8c76f97a7 --- /dev/null +++ b/global_undo/static/src/global_undo_systray/global_undo_systray.scss @@ -0,0 +1,27 @@ +// Copyright 2026 Pol Reig +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +.o_global_undo { + .o_global_undo_button { + color: inherit; + opacity: 0.7; + + &:hover:not(:disabled) { + opacity: 1; + } + + &:disabled { + opacity: 0.3; + } + } +} + +.o_global_undo_menu { + min-width: 22rem; + max-width: 30rem; + + .o_global_undo_entry { + white-space: normal; + word-break: break-word; + } +} diff --git a/global_undo/static/src/global_undo_systray/global_undo_systray.xml b/global_undo/static/src/global_undo_systray/global_undo_systray.xml new file mode 100644 index 00000000000..4a84da7af1f --- /dev/null +++ b/global_undo/static/src/global_undo_systray/global_undo_systray.xml @@ -0,0 +1,60 @@ + + + + + +
+ + + + + +
Recent operations
+
+ Nothing recorded yet. +
+ +
+
+ +
+ + + + + diff --git a/global_undo/static/tests/tours/global_undo_tour.esm.js b/global_undo/static/tests/tours/global_undo_tour.esm.js new file mode 100644 index 00000000000..814f3c318c4 --- /dev/null +++ b/global_undo/static/tests/tours/global_undo_tour.esm.js @@ -0,0 +1,89 @@ +// Copyright 2026 Pol Reig +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +/* global document */ + +import {isMacOS} from "@web/core/browser/feature_detection"; +import {registry} from "@web/core/registry"; + +/** + * Drives the systray in a real browser: undo the contact created by the test + * with the button, redo it, then undo it again with the keyboard shortcut and + * check the history reflects it. + */ +registry.category("web_tour.tours").add("global_undo_tour", { + url: "/odoo", + steps: () => [ + { + // The button names the step it would act on, which is only correct + // if the systray state was refreshed after the contact was created. + content: "the systray knows what Ctrl+Z would undo", + trigger: + ".o_global_undo_undo[title='Undo (Ctrl+Z): Created Contact: GU Tour Partner']", + }, + { + content: "nothing has been undone yet, so there is nothing to redo", + trigger: ".o_global_undo_redo[disabled]", + }, + { + content: "undo the contact creation", + trigger: ".o_global_undo_undo", + run: "click", + }, + { + content: "the server confirms the undo", + trigger: + ".o_notification:contains('Undone: Created Contact: GU Tour Partner')", + }, + { + content: "the redo button now names the step it would replay", + trigger: + ".o_global_undo_redo[title='Redo (Ctrl+Shift+Z): Created Contact: GU Tour Partner']", + run: "click", + }, + { + content: "the server confirms the redo", + trigger: + ".o_notification:contains('Redone: Created Contact: GU Tour Partner')", + }, + { + // So the next assertion can only match the keyboard undo. + content: "dismiss the notifications", + trigger: "body", + run: () => { + document + .querySelectorAll(".o_notification_close") + .forEach((button) => button.click()); + }, + }, + { + // Waiting for the last notification to go also waits for the reload + // the redo triggered, which would otherwise put the focus back in + // the search field a moment after it was dropped. + content: "wait for the screen to settle, then leave any text field", + trigger: "body:not(:has(.o_notification))", + run: () => document.activeElement && document.activeElement.blur(), + }, + { + // Outside a text field, where Ctrl+Z no longer means "undo my typing". + content: "undo again, this time with the keyboard", + trigger: "body", + // Odoo's hotkey service maps "control" to Cmd on macOS. + run: `press ${isMacOS() ? "Meta" : "Control"}+z`, + }, + { + content: "the shortcut reached the server too", + trigger: + ".o_notification:contains('Undone: Created Contact: GU Tour Partner')", + }, + { + content: "open the history", + trigger: ".o_global_undo .fa-history", + run: "click", + }, + { + content: "the step is listed as undone", + trigger: + ".o_global_undo_entry.text-decoration-line-through:contains('GU Tour Partner')", + }, + ], +}); diff --git a/global_undo/tests/__init__.py b/global_undo/tests/__init__.py new file mode 100644 index 00000000000..b3e00875968 --- /dev/null +++ b/global_undo/tests/__init__.py @@ -0,0 +1,8 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import ( + test_global_undo_business, + test_global_undo_config, + test_global_undo_crud, + test_global_undo_tour, +) diff --git a/global_undo/tests/common.py b/global_undo/tests/common.py new file mode 100644 index 00000000000..8c9b44d6fc2 --- /dev/null +++ b/global_undo/tests/common.py @@ -0,0 +1,42 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.tests import TransactionCase + + +class GlobalUndoCase(TransactionCase): + """Shared setup: an ordinary user whose operations are journalled. + + The journal ignores superuser work, so the tests must act as a real user. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Transaction = cls.env["global.undo.transaction"] + cls.Operation = cls.env["global.undo.operation"] + cls.user = cls.env["res.users"].create( + { + "name": "Global Undo Tester", + "login": "gu_tester", + "groups_id": [ + (4, cls.env.ref("base.group_user").id), + # The tests journal contacts, which need this to be created. + (4, cls.env.ref("base.group_partner_manager").id), + ], + } + ) + cls.uenv = cls.env(user=cls.user) + + def step(self): + """Close the current step so the next operations form a new one. + + A step is one user request; a test is one cursor, so it has to be said + explicitly here. + """ + self.env.cr.gu_transaction_id = False + + def undo(self): + return self.Transaction.with_env(self.uenv).gu_apply_next("undo") + + def redo(self): + return self.Transaction.with_env(self.uenv).gu_apply_next("redo") diff --git a/global_undo/tests/test_global_undo_business.py b/global_undo/tests/test_global_undo_business.py new file mode 100644 index 00000000000..4f7cd420e5b --- /dev/null +++ b/global_undo/tests/test_global_undo_business.py @@ -0,0 +1,156 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +"""Business actions and the accounting rules that outrank the undo history. + +Skipped when the modules they target are not installed: the hooks are optional +by design, and the module must stay usable on a bare database. +""" + +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestGlobalUndoBusiness(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Transaction = cls.env["global.undo.transaction"] + cls.Operation = cls.env["global.undo.operation"] + # An administrator: confirming orders and posting entries needs the + # rights of one, and the journal records their work like anyone else's. + cls.uenv = cls.env(user=cls.env.ref("base.user_admin")) + + def step(self): + self.env.cr.gu_transaction_id = False + + def undo(self): + return self.Transaction.with_env(self.uenv).gu_apply_next("undo") + + def redo(self): + return self.Transaction.with_env(self.uenv).gu_apply_next("redo") + + def _require_accounting(self): + if "account.account" not in self.env or not self.uenv["account.account"].search( + [], limit=1 + ): + self.skipTest("no chart of accounts installed") + + def _partner(self): + return self.uenv["res.partner"].create({"name": "GU Business Partner"}) + + def test_sale_confirmation_is_a_single_undoable_step(self): + if "sale.order" not in self.env: + self.skipTest("sale is not installed") + self.assertTrue( + getattr( + self.env.registry["sale.order"].action_confirm, "_gu_patched", False + ), + "the business action hook was not registered", + ) + product = self.uenv["product.product"].create( + { + "name": "GU Service", + "type": "service", + "list_price": 100, + } + ) + order = self.uenv["sale.order"].create( + { + "partner_id": self._partner().id, + "order_line": [ + (0, 0, {"product_id": product.id, "product_uom_qty": 2}) + ], + } + ) + self.step() + order.action_confirm() + self.assertEqual(order.state, "sale") + + operation = self.Operation.with_env(self.uenv).search( + [("kind", "=", "action"), ("model_name", "=", "sale.order")], limit=1 + ) + self.assertEqual(operation.method, "action_confirm") + + result = self.undo() + self.assertTrue(result["done"], result["message"]) + order.invalidate_recordset() + self.assertEqual(order.state, "draft") + + result = self.redo() + self.assertTrue(result["done"], result["message"]) + order.invalidate_recordset() + self.assertEqual(order.state, "sale") + + def _invoice(self, price): + return self.uenv["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self._partner().id, + "invoice_line_ids": [ + (0, 0, {"name": "GU line", "quantity": 1, "price_unit": price}) + ], + } + ) + + def test_posting_an_entry_can_be_undone(self): + self._require_accounting() + invoice = self._invoice(50) + self.step() + invoice.action_post() + self.assertEqual(invoice.state, "posted") + + result = self.undo() + self.assertTrue(result["done"], result["message"]) + invoice.invalidate_recordset() + self.assertEqual(invoice.state, "draft") + + def test_a_posted_entry_refuses_a_plain_write_undo(self): + self._require_accounting() + invoice = self._invoice(50) + invoice.action_post() + self.step() + invoice.write({"ref": "GU ref"}) + + operation = self.Operation.with_env(self.uenv).search( + [ + ("kind", "=", "write"), + ("model_name", "=", "account.move"), + ("res_id", "=", invoice.id), + ], + limit=1, + ) + self.assertTrue(operation) + self.assertIn("posted", operation._gu_blocker("undo") or "") + + def test_ledger_rows_are_never_journalled(self): + self._require_accounting() + self._invoice(50).action_post() + self.assertFalse( + self.Operation.with_env(self.uenv).search_count( + [("model_name", "=", "account.move.line")] + ), + "ledger rows leaked into the journal", + ) + + def test_a_paid_invoice_cannot_be_unposted(self): + self._require_accounting() + invoice = self._invoice(70) + self.step() + invoice.action_post() + operation = self.Operation.with_env(self.uenv).search( + [ + ("kind", "=", "action"), + ("model_name", "=", "account.move"), + ("res_id", "=", invoice.id), + ], + limit=1, + ) + + self.uenv["account.payment.register"].with_context( + active_model="account.move", + active_ids=invoice.ids, + ).create({}).action_create_payments() + invoice.invalidate_recordset() + + self.assertNotEqual(invoice.payment_state, "not_paid") + self.assertIn("reconciled", operation._gu_blocker("undo") or "") diff --git a/global_undo/tests/test_global_undo_config.py b/global_undo/tests/test_global_undo_config.py new file mode 100644 index 00000000000..18c658ff18b --- /dev/null +++ b/global_undo/tests/test_global_undo_config.py @@ -0,0 +1,112 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +"""Administrator-facing configuration and history housekeeping.""" + +from dateutil.relativedelta import relativedelta + +from odoo import fields +from odoo.exceptions import ValidationError +from odoo.tests import tagged + +from .common import GlobalUndoCase + + +@tagged("post_install", "-at_install") +class TestGlobalUndoConfig(GlobalUndoCase): + def test_an_excluded_model_is_no_longer_recorded(self): + self.env["global.undo.exclusion"].create( + { + "model_id": self.env["ir.model"]._get("res.partner.category").id, + "reason": "Tested here", + } + ) + before = self.Transaction.search_count([]) + self.uenv["res.partner.category"].create({"name": "GU Tag"}) + self.assertEqual(self.Transaction.search_count([]), before) + + # And recording resumes as soon as the exclusion is lifted. + self.env["global.undo.exclusion"].search([]).unlink() + self.uenv["res.partner.category"].create({"name": "GU Tag 2"}) + self.assertGreater(self.Transaction.search_count([]), before) + + def test_an_action_rule_must_name_methods_that_exist(self): + with self.assertRaises(ValidationError): + self.env["global.undo.action"].create( + { + "model_id": self.env["ir.model"]._get("res.partner").id, + "method": "no_such_method", + "undo_methods": "write", + } + ) + + def test_configured_actions_extend_and_override_the_defaults(self): + Action = self.env["global.undo.action"] + Action.create( + { + "model_id": self.env["ir.model"]._get("res.partner").id, + "method": "toggle_active", + "undo_methods": "toggle_active", + } + ) + self.assertEqual( + Action._gu_registered()["res.partner"]["toggle_active"], ("toggle_active",) + ) + + # Archiving a rule for a shipped default switches that default off. + if "sale.order" not in self.env: + return + self.assertIn("action_confirm", Action._gu_registered()["sale.order"]) + Action.create( + { + "model_id": self.env["ir.model"]._get("sale.order").id, + "method": "action_confirm", + "undo_methods": "action_draft", + "active": False, + } + ) + self.assertNotIn( + "action_confirm", Action._gu_registered().get("sale.order", {}) + ) + + def test_vacuum_keeps_the_trash_after_the_history_expires(self): + keeper = self.uenv["res.partner"].create({"name": "GU Vacuum Keeper"}) + self.step() + keeper.unlink() + self.step() + plain = self.uenv["res.partner"].create({"name": "GU Vacuum Plain"}) + + trashing, plain_step = self.Transaction.search([], limit=2, order="id desc")[ + ::-1 + ] + self.assertTrue(trashing.operation_ids.filtered("in_trash")) + + # Both steps are older than the history window, neither than the trash one. + old = fields.Datetime.now() - relativedelta(days=60) + self.env.cr.execute( + "UPDATE global_undo_transaction SET create_date = %s WHERE id IN %s", + (old, tuple((trashing + plain_step).ids)), + ) + (trashing + plain_step).invalidate_recordset() + + self.Transaction._gu_vacuum() + self.assertFalse(plain_step.exists(), "expired history should be purged") + self.assertTrue( + trashing.exists(), + "a step still holding a deleted record must survive the history window", + ) + self.assertTrue(plain.exists()) + + def test_vacuum_eventually_empties_the_trash(self): + victim = self.uenv["res.partner"].create({"name": "GU Vacuum Victim"}) + self.step() + victim.unlink() + trashing = self.Transaction.search([], limit=1) + + self.env.cr.execute( + "UPDATE global_undo_transaction SET create_date = %s WHERE id = %s", + (fields.Datetime.now() - relativedelta(days=400), trashing.id), + ) + trashing.invalidate_recordset() + + self.Transaction._gu_vacuum() + self.assertFalse(trashing.exists()) diff --git a/global_undo/tests/test_global_undo_crud.py b/global_undo/tests/test_global_undo_crud.py new file mode 100644 index 00000000000..ebf9120f3c6 --- /dev/null +++ b/global_undo/tests/test_global_undo_crud.py @@ -0,0 +1,222 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +"""Journalling and replay of ordinary create / write / unlink operations.""" + +import base64 + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import GlobalUndoCase + +# A 1x1 transparent GIF: the smallest thing that proves binaries survive a +# round trip through the trash. +TINY_GIF = base64.b64encode( + b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!" + b"\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00" + b"\x02\x02D\x01\x00;" +) + + +@tagged("post_install", "-at_install") +class TestGlobalUndoCrud(GlobalUndoCase): + def test_group_is_implied_for_internal_users(self): + self.assertTrue(self.user.has_group("global_undo.global_undo_group_user")) + + def test_create_undo_redo(self): + partner = self.uenv["res.partner"].create( + {"name": "GU Partner", "city": "Girona"} + ) + self.assertTrue( + self.Transaction.with_env(self.uenv).gu_state()["undo"], + "the creation was not journalled", + ) + + self.assertTrue(self.undo()["done"]) + self.assertFalse(partner.exists(), "undoing a creation must delete the record") + + self.assertTrue(self.redo()["done"]) + operation = self.Operation.with_env(self.uenv).search( + [("res_id", "=", partner.id), ("kind", "=", "create")], limit=1 + ) + restored = self.uenv["res.partner"].browse(operation.restored_res_id) + self.assertEqual(restored.name, "GU Partner") + self.assertEqual(restored.city, "Girona") + + def test_write_undo_restores_every_changed_field(self): + partner = self.uenv["res.partner"].create( + {"name": "GU Partner", "city": "Girona"} + ) + self.step() + partner.write({"city": "Barcelona", "comment": "

hello

"}) + + self.assertTrue(self.undo()["done"]) + partner.invalidate_recordset() + self.assertEqual(partner.city, "Girona") + self.assertFalse(partner.comment) + + def test_a_new_operation_discards_the_redo_stack(self): + self.uenv["res.partner"].create({"name": "GU Partner"}) + self.undo() + self.assertTrue(self.Transaction.with_env(self.uenv)._gu_next("redo")) + + self.step() + self.uenv["res.partner"].create({"name": "GU Discarder"}) + self.assertFalse( + self.Transaction.with_env(self.uenv)._gu_next("redo"), + "history written after an undo must make the redo unreachable", + ) + + def test_many2many_round_trip(self): + partner = self.uenv["res.partner"].create({"name": "GU Partner"}) + tag = self.uenv["res.partner.category"].create({"name": "GU Tag"}) + self.step() + partner.write({"category_id": [(4, tag.id)]}) + + self.assertTrue(self.undo()["done"]) + partner.invalidate_recordset() + self.assertNotIn(tag, partner.category_id) + + def test_delete_restore_and_full_cycle(self): + victim = self.uenv["res.partner"].create( + { + "name": "GU Victim", + "phone": "+34 600 000 000", + "image_1920": TINY_GIF, + } + ) + victim_id = victim.id + self.step() + victim.unlink() + + operation = self.Operation.with_env(self.uenv).search( + [("kind", "=", "unlink"), ("res_id", "=", victim_id)] + ) + self.assertTrue(operation.in_trash) + + self.assertTrue(self.undo()["done"]) + operation.invalidate_recordset() + self.assertFalse(operation.in_trash) + restored = self.uenv["res.partner"].browse(operation.restored_res_id) + self.assertEqual(restored.phone, "+34 600 000 000") + self.assertEqual(restored.image_1920, TINY_GIF, "the image was not restored") + + # Redoing the deletion must put the record back in the trash, not leave + # a dangling id behind. + self.assertTrue(self.redo()["done"]) + operation.invalidate_recordset() + self.assertFalse(operation.restored_res_id) + self.assertTrue(operation.in_trash) + + self.assertTrue(self.undo()["done"]) + operation.invalidate_recordset() + self.assertTrue( + self.uenv["res.partner"].browse(operation.restored_res_id).exists() + ) + + def test_trash_restore_keeps_children_attached_to_their_parent(self): + parent = self.uenv["res.partner"].create({"name": "GU Parent"}) + child = self.uenv["res.partner"].create( + {"name": "GU Child", "parent_id": parent.id} + ) + parent_id, child_id = parent.id, child.id + self.step() + (child + parent).unlink() + + trash = self.Operation.with_env(self.uenv).search([("in_trash", "=", True)]) + self.assertEqual(len(trash), 2) + trash.action_restore() + + restored = {operation.res_id: operation.restored_res_id for operation in trash} + restored_child = self.uenv["res.partner"].browse(restored[child_id]) + self.assertEqual( + restored_child.parent_id.id, + restored[parent_id], + "a child restored next to its parent must follow the parent's new id", + ) + + def test_a_concurrent_edit_blocks_the_undo(self): + partner = self.uenv["res.partner"].create({"name": "GU Partner"}) + self.step() + partner.write({"function": "Tester"}) + operation = self.Operation.with_env(self.uenv).search( + [("res_id", "=", partner.id), ("kind", "=", "write")], limit=1 + ) + + # Somebody else edits the record afterwards. + self.uenv.flush_all() + self.env.cr.execute( + "UPDATE res_partner SET write_date = write_date " + "+ interval '1 second' WHERE id = %s", + (partner.id,), + ) + partner.invalidate_recordset() + + self.assertIn("changed after", operation._gu_blocker("undo") or "") + self.assertFalse(self.undo()["done"]) + + def test_undoing_a_creation_also_respects_concurrent_edits(self): + partner = self.uenv["res.partner"].create({"name": "GU Partner"}) + operation = self.Operation.with_env(self.uenv).search( + [("res_id", "=", partner.id), ("kind", "=", "create")], limit=1 + ) + + self.uenv.flush_all() + self.env.cr.execute( + "UPDATE res_partner SET write_date = write_date " + "+ interval '1 second' WHERE id = %s", + (partner.id,), + ) + partner.invalidate_recordset() + + self.assertIn( + "changed after", + operation._gu_blocker("undo") or "", + "deleting the record would discard the other user's edit", + ) + + def test_a_step_that_creates_then_writes_can_still_be_undone(self): + """The write moves write_date past what the creation recorded.""" + partner = self.uenv["res.partner"].create({"name": "GU Partner"}) + partner.write({"city": "Girona"}) + self.assertTrue(self.undo()["done"]) + self.assertFalse(partner.exists()) + + def test_another_user_cannot_undo_my_step(self): + self.uenv["res.partner"].create({"name": "GU Partner"}) + other = self.env["res.users"].create( + { + "name": "GU Other", + "login": "gu_other", + "groups_id": [(4, self.env.ref("base.group_user").id)], + } + ) + step = self.Transaction.with_env(self.uenv).search( + [("user_id", "=", self.user.id)], limit=1 + ) + with self.assertRaises(UserError): + step.with_user(other)._gu_apply("undo") + + def test_technical_models_stay_out_of_the_journal(self): + before = self.Transaction.search_count([]) + self.uenv["ir.config_parameter"].sudo().set_param("gu.probe", "1") + self.assertEqual( + self.Transaction.search_count([]), before, "ir.* leaked into the journal" + ) + + def test_superuser_work_is_not_journalled(self): + before = self.Transaction.search_count([]) + self.env["res.partner"].sudo().create({"name": "GU Sudo"}) + self.assertEqual(self.Transaction.search_count([]), before) + + def test_mass_updates_are_skipped(self): + partners = self.uenv["res.partner"].create( + [{"name": f"GU Bulk {index}"} for index in range(3)] + ) + self.step() + before = self.Transaction.search_count([]) + # The cap is on the recordset size, so a stub recordset is enough. + huge = self.uenv["res.partner"].browse(range(1, 300)) + self.assertFalse(huge._gu_is_tracked(), "a mass update must not be journalled") + self.assertEqual(self.Transaction.search_count([]), before) + self.assertTrue(partners.exists()) diff --git a/global_undo/tests/test_global_undo_tour.py b/global_undo/tests/test_global_undo_tour.py new file mode 100644 index 00000000000..f235fbda838 --- /dev/null +++ b/global_undo/tests/test_global_undo_tour.py @@ -0,0 +1,26 @@ +# Copyright 2026 Pol Reig +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestGlobalUndoTour(HttpCase): + def test_undo_redo_from_the_systray(self): + """The OWL systray and the Ctrl+Z hotkey drive the server round trip.""" + admin = self.env.ref("base.user_admin") + # Start from an empty stack so the tour's Ctrl+Z can only hit our step. + self.env["global.undo.transaction"].search([]).unlink() + # Journalled exactly like a user request: not sudo, own transaction step. + self.env(user=admin)["res.partner"].create({"name": "GU Tour Partner"}) + step = self.env["global.undo.transaction"].search([]) + self.assertEqual(step.name, "Created Contact: GU Tour Partner") + + self.start_tour("/odoo", "global_undo_tour", login="admin") + + self.assertEqual( + step.state, "undone", "the last hotkey undo should have unwound the step" + ) + self.assertFalse( + self.env["res.partner"].search([("name", "=", "GU Tour Partner")]), + "the contact should be gone again after the keyboard undo", + ) diff --git a/global_undo/views/global_undo_action_views.xml b/global_undo/views/global_undo_action_views.xml new file mode 100644 index 00000000000..090b1d97117 --- /dev/null +++ b/global_undo/views/global_undo_action_views.xml @@ -0,0 +1,32 @@ + + + + + global.undo.action.list + global.undo.action + + + + + + + + + + + + Undoable Actions + global.undo.action + list + {'active_test': False} + +

Make a business action undoable

+

Give the method to record and the methods that revert it, in order. + Confirming a sales order, a purchase order and posting a journal entry + are handled out of the box; add a line here to change one of those + inverses, to switch it off, or to cover a method of your own.

+

Changes take effect once the registry reloads.

+
+
+
diff --git a/global_undo/views/global_undo_exclusion_views.xml b/global_undo/views/global_undo_exclusion_views.xml new file mode 100644 index 00000000000..1ef56291b32 --- /dev/null +++ b/global_undo/views/global_undo_exclusion_views.xml @@ -0,0 +1,30 @@ + + + + + global.undo.exclusion.list + global.undo.exclusion + + + + + + + + + + + + Excluded Models + global.undo.exclusion + list + +

Exclude a model from the undo history

+

Operations on these models are never recorded, on top of the technical + and ledger models the module always leaves alone.

+
+
+
diff --git a/global_undo/views/global_undo_menus.xml b/global_undo/views/global_undo_menus.xml new file mode 100644 index 00000000000..2ede259cdda --- /dev/null +++ b/global_undo/views/global_undo_menus.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + diff --git a/global_undo/views/global_undo_operation_views.xml b/global_undo/views/global_undo_operation_views.xml new file mode 100644 index 00000000000..c0fe5d13346 --- /dev/null +++ b/global_undo/views/global_undo_operation_views.xml @@ -0,0 +1,133 @@ + + + + + global.undo.operation.list + global.undo.operation + + + + + + + + + +