From 54560c7fea2cf061dcde7930c9cf94fdaed30d24 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 21 Sep 2026 10:48:14 +0800 Subject: [PATCH 1/3] fix(spp_hide_menus_base): make the menu-hiding pass best-effort on database errors hide_menus() runs from ir.module.module._register_hook at the end of every registry load. A psycopg2.Error escaping it aborted the registry load, so on a poisoned cursor every restart failed until the database was repaired by hand. The pass now flushes the caller's pending writes, runs in its own savepoint, and logs and skips any psycopg2.Error; a missing menu xmlid uses raise_if_not_found=False. Fixes #526 --- spp_hide_menus_base/__manifest__.py | 2 +- .../models/ir_module_module.py | 32 +++- spp_hide_menus_base/readme/HISTORY.md | 11 ++ spp_hide_menus_base/tests/__init__.py | 1 + .../tests/test_register_hook_guard.py | 156 ++++++++++++++++++ 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 spp_hide_menus_base/tests/test_register_hook_guard.py diff --git a/spp_hide_menus_base/__manifest__.py b/spp_hide_menus_base/__manifest__.py index 79db6476..274d4282 100644 --- a/spp_hide_menus_base/__manifest__.py +++ b/spp_hide_menus_base/__manifest__.py @@ -5,7 +5,7 @@ { "name": "OpenSPP Hide Non-OpenSPP Menus: Base", "category": "OpenSPP", - "version": "19.0.2.1.0", + "version": "19.0.2.1.1", "summary": "Administrators can manage the visibility of OpenSPP navigation menus, streamlining the user interface for specific user groups. The module modifies ir.ui.menu records to control menu visibility, providing a foundation for other modules to selectively hide non-essential navigation items.", "sequence": 1, "author": "OpenSPP.org", diff --git a/spp_hide_menus_base/models/ir_module_module.py b/spp_hide_menus_base/models/ir_module_module.py index d2c0c628..e9e88da6 100644 --- a/spp_hide_menus_base/models/ir_module_module.py +++ b/spp_hide_menus_base/models/ir_module_module.py @@ -1,5 +1,7 @@ import logging +import psycopg2 + from odoo import models _logger = logging.getLogger(__name__) @@ -58,14 +60,36 @@ class IrModuleModule(models.Model): } def hide_menus(self): + """Hide the root menus of the stock apps listed in MENU_APP. + + Best-effort: this runs from ``_register_hook`` at the end of every + registry load, where a database error that escapes aborts the load + and takes the instance (or a job worker) down with it. A menu left + visible is recoverable, so the whole pass runs in its own savepoint + and is logged and skipped on any ``psycopg2.Error``. + + The caller's pending ORM writes are flushed first so that only the + hiding pass itself is covered by the guard; a failure in the caller's + own writes stays the caller's error. + """ + self.env.cr.flush() + try: + with self.env.cr.savepoint(): + self._hide_catalog_menus() + except psycopg2.Error: + _logger.warning( + "Skipping the OpenSPP menu hiding pass because the database reported an error; " + "the menus keep their current visibility and the registry load continues", + exc_info=True, + ) + + def _hide_catalog_menus(self): for module in self.search([]): menu_info = self.MENU_APP.get(module.name) if menu_info: - try: - menu = self.env.ref(menu_info["menu_xml_id"]) - except ValueError: + menu = self.env.ref(menu_info["menu_xml_id"], raise_if_not_found=False) + if not menu: _logger.debug("Menu XML ID not found: %s", menu_info["menu_xml_id"]) - menu = False if menu: hidden_menus = self.env["spp.hide.menu"].search([("menu_id", "=", menu.id)]) diff --git a/spp_hide_menus_base/readme/HISTORY.md b/spp_hide_menus_base/readme/HISTORY.md index a56bacf8..8708bcf2 100644 --- a/spp_hide_menus_base/readme/HISTORY.md +++ b/spp_hide_menus_base/readme/HISTORY.md @@ -1,3 +1,14 @@ +### 19.0.2.1.1 + +- fix: make the menu-hiding pass run by ``_register_hook`` (and ``next()``) + best-effort on database errors (#526). ``hide_menus()`` now runs in its own + savepoint and logs and skips any ``psycopg2.Error`` instead of letting it + escape ``_register_hook`` and abort the registry load — on a poisoned + cursor that meant every restart failed until the database was repaired by + hand, the shape of the incident behind #383. The caller's own pending + writes are flushed before the guard and stay the caller's error; a missing + menu xmlid is skipped through ``raise_if_not_found=False``. + ### 19.0.2.1.0 - Enforce ``UNIQUE(menu_id)`` on ``spp.hide.menu``: a second configuration row diff --git a/spp_hide_menus_base/tests/__init__.py b/spp_hide_menus_base/tests/__init__.py index 8aa33c32..0cce5ddf 100644 --- a/spp_hide_menus_base/tests/__init__.py +++ b/spp_hide_menus_base/tests/__init__.py @@ -1,3 +1,4 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. from . import test_hide_menu from . import test_migration_dedup_hide_menu +from . import test_register_hook_guard diff --git a/spp_hide_menus_base/tests/test_register_hook_guard.py b/spp_hide_menus_base/tests/test_register_hook_guard.py new file mode 100644 index 00000000..f7641846 --- /dev/null +++ b/spp_hide_menus_base/tests/test_register_hook_guard.py @@ -0,0 +1,156 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Regression tests for the database-error guard around ``hide_menus()``. + +``hide_menus()`` runs from ``ir.module.module._register_hook`` at the end of +every registry load (startup, install, upgrade, worker reload). A +``psycopg2.Error`` escaping it aborts the registry load, so on a poisoned +cursor every restart fails until the database is repaired by hand. Hiding a +menu is best-effort there: a menu left visible is recoverable, an aborted +registry load is an outage. +""" + +import contextlib +import functools +from unittest.mock import patch + +import psycopg2 +import psycopg2.extensions + +from odoo.tests import TransactionCase, tagged +from odoo.tools import mute_logger + +HOOK_LOGGER = "odoo.addons.spp_hide_menus_base.models.ir_module_module" +MISSING_MENU_XMLID = "spp_hide_menus_base.test_526_menu_that_does_not_exist" + + +def _failing_lookup_for(menu_xml_id, original_lookup): + """An ``ir.model.data._xmlid_lookup`` stand-in that aborts the transaction + for one xmlid and behaves normally for every other one.""" + + @functools.wraps(original_lookup) + def _xmlid_lookup(model, xmlid): + if xmlid == menu_xml_id: + model.env.cr.execute("SELECT 1 FROM spp_table_that_does_not_exist") + return original_lookup(model, xmlid) + + return _xmlid_lookup + + +@tagged("post_install", "-at_install") +class TestHideMenusDatabaseErrorGuard(TransactionCase): + def setUp(self): + super().setUp() + self.IrModule = self.env["ir.module.module"] + self.HideMenu = self.env["spp.hide.menu"] + self.hide_group = self.HideMenu._hide_group() + self.assertTrue(self.hide_group, "precondition: the hide group must exist") + + def _catalog(self, **entries): + """Replace MENU_APP with ``{module_name: menu_xml_id}`` for the test.""" + menu_app = {module: {"menu_xml_id": xml_id} for module, xml_id in entries.items()} + return patch.object(type(self.IrModule), "MENU_APP", menu_app) + + def _menu_with_external_id(self, name): + """A menu with no hide configuration yet, reachable through a fresh xmlid.""" + taken = self.HideMenu.search([]).menu_id.ids + menu = self.env["ir.ui.menu"].search([("id", "not in", taken)], limit=1) + self.assertTrue(menu, "no unconfigured ir.ui.menu left to test against") + self.env["ir.model.data"].create( + {"module": "spp_hide_menus_base", "name": name, "model": "ir.ui.menu", "res_id": menu.id} + ) + return menu, f"spp_hide_menus_base.{name}" + + def _first_and_last_module(self): + """Two module names at opposite ends of the order hide_menus() walks, + so a failure planted on the last one is reached after the first one + has already done its work.""" + modules = self.IrModule.search([]) + self.assertGreater(len(modules), 1) + return modules[0].name, modules[-1].name + + def _poison_cursor(self): + # Odoo's assertRaises runs its body in a savepoint and rolls it back, + # which would un-poison the cursor; suppress keeps the transaction aborted. + with mute_logger("odoo.sql_db"), contextlib.suppress(psycopg2.Error): + self.env.cr.execute("SELECT 1 FROM spp_table_that_does_not_exist") + self.assertEqual( + self.env.cr.connection.get_transaction_status(), + psycopg2.extensions.TRANSACTION_STATUS_INERROR, + "precondition: the transaction must be aborted before the hook runs", + ) + + def test_01_missing_menu_xmlid_is_skipped(self): + """A catalog entry whose menu does not exist neither raises nor warns.""" + first, _last = self._first_and_last_module() + before = self.HideMenu.search([]) + + with self._catalog(**{first: MISSING_MENU_XMLID}), self.assertNoLogs(HOOK_LOGGER, level="WARNING"): + self.IrModule._register_hook() + + self.assertEqual(self.HideMenu.search([]), before) + + def test_02_database_error_inside_hook_is_logged_and_skipped(self): + """A SQL failure while hiding is logged, the registry load continues, + and the half-done pass is rolled back as a whole.""" + first, last = self._first_and_last_module() + menu, menu_xml_id = self._menu_with_external_id("test_526_real_menu") + groups_before = menu.group_ids + self.assertNotIn(self.hide_group, groups_before, "precondition: the menu starts visible") + IrModelData = type(self.env["ir.model.data"]) + failing_lookup = _failing_lookup_for(MISSING_MENU_XMLID, IrModelData._xmlid_lookup) + + with ( + self._catalog(**{first: menu_xml_id, last: MISSING_MENU_XMLID}), + patch.object(IrModelData, "_xmlid_lookup", failing_lookup), + mute_logger("odoo.sql_db"), + self.assertLogs(HOOK_LOGGER, level="WARNING") as captured, + ): + self.IrModule._register_hook() + + self.assertTrue( + any("menu" in message for message in captured.output), + f"expected a skipped-hiding warning, got {captured.output}", + ) + # The transaction is still usable: the failure was contained in a savepoint. + self.env.cr.execute("SELECT 1") + self.assertEqual(self.env.cr.fetchone(), (1,)) + # ...and the pass is atomic: the menu hidden before the failure is visible again + # and its configuration row is gone. + self.assertEqual(menu.group_ids, groups_before) + self.assertFalse(self.HideMenu.search([("menu_id", "=", menu.id)])) + + def test_03_hook_survives_already_aborted_transaction(self): + """The hook must not raise when the cursor is already poisoned before it starts.""" + self.env.cr.execute("SAVEPOINT test_526_poisoned_cursor") + try: + self._poison_cursor() + with mute_logger("odoo.sql_db"), self.assertLogs(HOOK_LOGGER, level="WARNING"): + self.IrModule._register_hook() + finally: + self.env.cr.execute("ROLLBACK TO SAVEPOINT test_526_poisoned_cursor") + self.env.cr.execute("RELEASE SAVEPOINT test_526_poisoned_cursor") + + def test_04_callers_pending_writes_are_not_swallowed(self): + """A failure flushing the caller's own pending writes is the caller's error. + + The guard covers only the hiding pass. Pending ORM writes queued before + it are flushed ahead of the guard, so their failure surfaces where it + belongs instead of being logged as a menu-hiding problem. + """ + menu, _menu_xml_id = self._menu_with_external_id("test_526_pending_write_menu") + row = self.HideMenu.create({"menu_id": menu.id, "xml_id": "test.pending"}) + self.env.cr.execute("SAVEPOINT test_526_pending_write") + try: + row.write({"xml_id": "test.pending_changed"}) # queued, not flushed yet + self._poison_cursor() + + raised = None + with mute_logger("odoo.sql_db"), self.assertNoLogs(HOOK_LOGGER, level="WARNING"): + try: + self.IrModule.hide_menus() + except psycopg2.Error as exc: + raised = exc + self.assertIsInstance(raised, psycopg2.errors.InFailedSqlTransaction) + finally: + self.env.cr.execute("ROLLBACK TO SAVEPOINT test_526_pending_write") + self.env.cr.execute("RELEASE SAVEPOINT test_526_pending_write") From 2c18f0529520efaf55b309cefafe5ca165a74371 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 21 Sep 2026 11:07:19 +0800 Subject: [PATCH 2/3] fix(spp_hide_menus_base): address review of the hide_menus guard - test_02 gains a control run proving the catalog entry hides the menu before the failing pass, so the rollback assertions are falsifiable - warning assertion matches the message text, not the logger name - HISTORY attributes the incident to odoo-job-worker#22, which #383 had first pinned on the menu-icon hook - docstring states why only psycopg2.Error is caught, why the pass is all-or-nothing, and that retryable errors are swallowed on purpose - log line no longer claims the registry load continues on an already aborted transaction - flatten the extracted loop; explicit psycopg2.errors import --- .../models/ir_module_module.py | 73 +++++++++++-------- spp_hide_menus_base/readme/HISTORY.md | 10 ++- .../tests/test_register_hook_guard.py | 27 ++++++- 3 files changed, 72 insertions(+), 38 deletions(-) diff --git a/spp_hide_menus_base/models/ir_module_module.py b/spp_hide_menus_base/models/ir_module_module.py index e9e88da6..b0e3dba9 100644 --- a/spp_hide_menus_base/models/ir_module_module.py +++ b/spp_hide_menus_base/models/ir_module_module.py @@ -70,7 +70,17 @@ def hide_menus(self): The caller's pending ORM writes are flushed first so that only the hiding pass itself is covered by the guard; a failure in the caller's - own writes stays the caller's error. + own writes stays the caller's error. Retryable errors (serialization + failures, deadlocks) are deliberately swallowed too: the registry + load has no retry loop, and on the ``next()`` path a retry would + rebuild the registry for a menu write. + + Two deliberate limits. Only ``psycopg2.Error`` is caught: a Python + exception raised here is a bug in this module and must fail loudly + in tests rather than silently leave menus visible in production. And + the pass is all-or-nothing, as in ``spp_base_common``: database + errors are transaction-wide in practice, so a savepoint per catalog + entry would add cost without adding recoverable cases. """ self.env.cr.flush() try: @@ -79,44 +89,45 @@ def hide_menus(self): except psycopg2.Error: _logger.warning( "Skipping the OpenSPP menu hiding pass because the database reported an error; " - "the menus keep their current visibility and the registry load continues", + "the menus keep their current visibility and this hook will not be the cause of a failed registry load", exc_info=True, ) def _hide_catalog_menus(self): for module in self.search([]): menu_info = self.MENU_APP.get(module.name) - if menu_info: - menu = self.env.ref(menu_info["menu_xml_id"], raise_if_not_found=False) - if not menu: - _logger.debug("Menu XML ID not found: %s", menu_info["menu_xml_id"]) + if not menu_info: + continue + menu = self.env.ref(menu_info["menu_xml_id"], raise_if_not_found=False) + if not menu: + _logger.debug("Menu XML ID not found: %s", menu_info["menu_xml_id"]) + continue - if menu: - hidden_menus = self.env["spp.hide.menu"].search([("menu_id", "=", menu.id)]) - if not hidden_menus: - hidden_menu = self.env["spp.hide.menu"].create( - { - "menu_id": menu.id, - "xml_id": menu_info["menu_xml_id"], - } - ) - hidden_menu.hide_menu() - continue + hidden_menus = self.env["spp.hide.menu"].search([("menu_id", "=", menu.id)]) + if not hidden_menus: + hidden_menu = self.env["spp.hide.menu"].create( + { + "menu_id": menu.id, + "xml_id": menu_info["menu_xml_id"], + } + ) + hidden_menu.hide_menu() + continue - # Read state off ONE row, never off the search result. This - # method runs from _register_hook, so an Expected singleton - # here aborts the whole registry load and every request 500s - # until the extra row is deleted by hand. UNIQUE(menu_id) - # normally rules that out, but a database that already held - # duplicates when the constraint landed keeps them: the - # registry logs the failed constraint and carries on. - hidden_menu = hidden_menus._primary() - if hidden_menu.state == "show": - hidden_menu.hide_menu() - elif hidden_menu.state == "hide": - # Module upgrade may have reset group_ids via XML - # (noupdate="0"). Re-apply hiding if stale. - hidden_menu._reapply_hide() + # Read state off ONE row, never off the search result. This + # method runs from _register_hook, so an Expected singleton + # here aborts the whole registry load and every request 500s + # until the extra row is deleted by hand. UNIQUE(menu_id) + # normally rules that out, but a database that already held + # duplicates when the constraint landed keeps them: the + # registry logs the failed constraint and carries on. + hidden_menu = hidden_menus._primary() + if hidden_menu.state == "show": + hidden_menu.hide_menu() + elif hidden_menu.state == "hide": + # Module upgrade may have reset group_ids via XML + # (noupdate="0"). Re-apply hiding if stale. + hidden_menu._reapply_hide() def next(self): # Call your menu hiding logic first diff --git a/spp_hide_menus_base/readme/HISTORY.md b/spp_hide_menus_base/readme/HISTORY.md index 8708bcf2..ca525306 100644 --- a/spp_hide_menus_base/readme/HISTORY.md +++ b/spp_hide_menus_base/readme/HISTORY.md @@ -5,9 +5,13 @@ savepoint and logs and skips any ``psycopg2.Error`` instead of letting it escape ``_register_hook`` and abort the registry load — on a poisoned cursor that meant every restart failed until the database was repaired by - hand, the shape of the incident behind #383. The caller's own pending - writes are flushed before the guard and stay the caller's error; a missing - menu xmlid is skipped through ``raise_if_not_found=False``. + hand, the shape of the 2026-07-30 preprod incident + (OpenSPP/odoo-job-worker#22), which #383 first attributed to the + ``spp_base_common`` menu-icon hook. The caller's own pending writes are + flushed before the guard and stay the caller's error; a missing menu xmlid + is skipped through ``raise_if_not_found=False``. Only ``psycopg2.Error`` is + caught, and the pass stays all-or-nothing: a Python exception here is a + bug in this module and still fails loudly. ### 19.0.2.1.0 diff --git a/spp_hide_menus_base/tests/test_register_hook_guard.py b/spp_hide_menus_base/tests/test_register_hook_guard.py index f7641846..33fa3b9d 100644 --- a/spp_hide_menus_base/tests/test_register_hook_guard.py +++ b/spp_hide_menus_base/tests/test_register_hook_guard.py @@ -14,6 +14,7 @@ from unittest.mock import patch import psycopg2 +import psycopg2.errors import psycopg2.extensions from odoo.tests import TransactionCase, tagged @@ -50,11 +51,16 @@ def _catalog(self, **entries): menu_app = {module: {"menu_xml_id": xml_id} for module, xml_id in entries.items()} return patch.object(type(self.IrModule), "MENU_APP", menu_app) - def _menu_with_external_id(self, name): - """A menu with no hide configuration yet, reachable through a fresh xmlid.""" + def _menu_without_hide_row(self): + """A menu no spp.hide.menu row points at yet.""" taken = self.HideMenu.search([]).menu_id.ids menu = self.env["ir.ui.menu"].search([("id", "not in", taken)], limit=1) self.assertTrue(menu, "no unconfigured ir.ui.menu left to test against") + return menu + + def _menu_with_external_id(self, name): + """A menu with no hide configuration yet, reachable through a fresh xmlid.""" + menu = self._menu_without_hide_row() self.env["ir.model.data"].create( {"module": "spp_hide_menus_base", "name": name, "model": "ir.ui.menu", "res_id": menu.id} ) @@ -99,6 +105,17 @@ def test_02_database_error_inside_hook_is_logged_and_skipped(self): IrModelData = type(self.env["ir.model.data"]) failing_lookup = _failing_lookup_for(MISSING_MENU_XMLID, IrModelData._xmlid_lookup) + # Control: the same catalog entry does hide the menu when nothing fails, + # so the "rolled back" assertions below cannot pass by never having run. + with self._catalog(**{first: menu_xml_id}): + self.IrModule._register_hook() + row = self.HideMenu.search([("menu_id", "=", menu.id)]) + self.assertTrue(row, "control: the catalog entry must create a hide row") + self.assertIn(self.hide_group, menu.group_ids, "control: the catalog entry must hide the menu") + row.show_menu() + row.unlink() + self.assertEqual(menu.group_ids, groups_before, "control undone: the menu is visible again") + with ( self._catalog(**{first: menu_xml_id, last: MISSING_MENU_XMLID}), patch.object(IrModelData, "_xmlid_lookup", failing_lookup), @@ -108,7 +125,7 @@ def test_02_database_error_inside_hook_is_logged_and_skipped(self): self.IrModule._register_hook() self.assertTrue( - any("menu" in message for message in captured.output), + any("menu hiding pass" in message for message in captured.output), f"expected a skipped-hiding warning, got {captured.output}", ) # The transaction is still usable: the failure was contained in a savepoint. @@ -124,6 +141,8 @@ def test_03_hook_survives_already_aborted_transaction(self): self.env.cr.execute("SAVEPOINT test_526_poisoned_cursor") try: self._poison_cursor() + # Driven through _register_hook() as the loader does; this relies on + # super()._register_hook() issuing no SQL of its own. with mute_logger("odoo.sql_db"), self.assertLogs(HOOK_LOGGER, level="WARNING"): self.IrModule._register_hook() finally: @@ -137,7 +156,7 @@ def test_04_callers_pending_writes_are_not_swallowed(self): it are flushed ahead of the guard, so their failure surfaces where it belongs instead of being logged as a menu-hiding problem. """ - menu, _menu_xml_id = self._menu_with_external_id("test_526_pending_write_menu") + menu = self._menu_without_hide_row() row = self.HideMenu.create({"menu_id": menu.id, "xml_id": "test.pending"}) self.env.cr.execute("SAVEPOINT test_526_pending_write") try: From 90b46654dfb2be4e27f8f75eb1c82cbf469562bc Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 21 Sep 2026 11:15:21 +0800 Subject: [PATCH 3/3] docs(spp_hide_menus_base): regenerate README from fragments (CI output) --- spp_hide_menus_base/README.rst | 17 ++++++++++++++ .../static/description/index.html | 22 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/spp_hide_menus_base/README.rst b/spp_hide_menus_base/README.rst index 6ff430a3..05751bd8 100644 --- a/spp_hide_menus_base/README.rst +++ b/spp_hide_menus_base/README.rst @@ -103,6 +103,23 @@ Dependencies Changelog ========= +19.0.2.1.1 +~~~~~~~~~~ + +- fix: make the menu-hiding pass run by ``_register_hook`` (and + ``next()``) best-effort on database errors (#526). ``hide_menus()`` + now runs in its own savepoint and logs and skips any + ``psycopg2.Error`` instead of letting it escape ``_register_hook`` and + abort the registry load — on a poisoned cursor that meant every + restart failed until the database was repaired by hand, the shape of + the 2026-07-30 preprod incident (OpenSPP/odoo-job-worker#22), which + #383 first attributed to the ``spp_base_common`` menu-icon hook. The + caller's own pending writes are flushed before the guard and stay the + caller's error; a missing menu xmlid is skipped through + ``raise_if_not_found=False``. Only ``psycopg2.Error`` is caught, and + the pass stays all-or-nothing: a Python exception here is a bug in + this module and still fails loudly. + 19.0.2.1.0 ~~~~~~~~~~ diff --git a/spp_hide_menus_base/static/description/index.html b/spp_hide_menus_base/static/description/index.html index 47178b92..205214a6 100644 --- a/spp_hide_menus_base/static/description/index.html +++ b/spp_hide_menus_base/static/description/index.html @@ -474,6 +474,24 @@

Changelog

+

19.0.2.1.1

+
    +
  • fix: make the menu-hiding pass run by _register_hook (and +next()) best-effort on database errors (#526). hide_menus() +now runs in its own savepoint and logs and skips any +psycopg2.Error instead of letting it escape _register_hook and +abort the registry load — on a poisoned cursor that meant every +restart failed until the database was repaired by hand, the shape of +the 2026-07-30 preprod incident (OpenSPP/odoo-job-worker#22), which +#383 first attributed to the spp_base_common menu-icon hook. The +caller’s own pending writes are flushed before the guard and stay the +caller’s error; a missing menu xmlid is skipped through +raise_if_not_found=False. Only psycopg2.Error is caught, and +the pass stays all-or-nothing: a Python exception here is a bug in +this module and still fails loudly.
  • +
+
+

19.0.2.1.0

  • Enforce UNIQUE(menu_id) on spp.hide.menu: a second @@ -495,7 +513,7 @@

    19.0.2.1.0

    Target the existing record or drop the seed.
-
+

19.0.2.0.1

  • Keep hidden menus hidden after a module upgrade resets their @@ -505,7 +523,7 @@

    19.0.2.0.1

    next().
-
+

19.0.2.0.0

  • Initial migration to OpenSPP2