From d83a4da266b0d7d164926655d883880ce1d798ae Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Wed, 29 Apr 2026 09:59:53 +0800 Subject: [PATCH 1/3] feat(spp_vocabulary): lock system vocabularies and their codes (#954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System vocabularies are loaded via XML by modules / data files and shouldn't be reshaped by users at runtime — but until now the form allowed editing structural fields (name, namespace, domain, version, hierarchical flag, etc.) and the inline Codes list let users add or delete codes that downstream code paths key off of. This now matches the existing Python guards in `models/vocabulary_code.py` (line 191, 279, 437, 594) so users see what they can / can't change rather than hitting a server-side UserError after typing. Vocabulary form: - Hide the `is_system` toggle so it can no longer be flipped manually (the orange SYSTEM ribbon stays as the visual indicator). - Lock `name`, `namespace_uri`, `domain`, `version`, `is_hierarchical`, `reference_url`, `description`, and `active` when `is_system=True`. Codes tab on the vocabulary form: split into two `code_ids` blocks — the non-system one keeps the original full-edit list; the system one sets `create="false" delete="false"` on the inline list and locks identifying columns (`code`, `display`, `definition`, `parent_id`) while sequence handle / `deprecated` / `active` stay flippable. Local codes (`is_local=True`) keep their pre-existing edit behaviour via the `parent.is_system and not is_local` condition. Vocabulary code form: same per-field lock applied to `display`, `code`, `vocabulary_id`, `parent_id`, `target_type`, and `definition` — adds a hidden `is_local` field so the readonly expressions can reference it. Tests: spp_vocabulary suite still 0 failed, 0 errors of 193. --- .../views/vocabulary_code_views.xml | 22 ++++- spp_vocabulary/views/vocabulary_views.xml | 88 +++++++++++++++++-- 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/spp_vocabulary/views/vocabulary_code_views.xml b/spp_vocabulary/views/vocabulary_code_views.xml index 60bc65a0..9939c3f2 100644 --- a/spp_vocabulary/views/vocabulary_code_views.xml +++ b/spp_vocabulary/views/vocabulary_code_views.xml @@ -55,16 +55,28 @@ bg_color="text-bg-warning" invisible="not deprecated" /> + +

- +

@@ -76,6 +88,7 @@ name="vocabulary_id" required="1" domain="[('is_system', '=', False)]" + readonly="vocabulary_id.is_system and not is_local" /> - + @@ -103,6 +120,7 @@ nolabel="1" colspan="2" placeholder="Formal definition of what this code means..." + readonly="vocabulary_id.is_system and not is_local" /> diff --git a/spp_vocabulary/views/vocabulary_views.xml b/spp_vocabulary/views/vocabulary_views.xml index dd347d0d..0bd5e970 100644 --- a/spp_vocabulary/views/vocabulary_views.xml +++ b/spp_vocabulary/views/vocabulary_views.xml @@ -58,12 +58,18 @@ bg_color="text-bg-warning" invisible="not is_system" /> + +

@@ -72,14 +78,24 @@ - - - + + + - - - + + @@ -87,6 +103,7 @@ name="reference_url" widget="url" placeholder="https://..." + readonly="is_system" /> @@ -95,17 +112,20 @@ nolabel="1" placeholder="Description of this vocabulary..." class="oe_inline" + readonly="is_system" /> + @@ -126,6 +146,60 @@ + + + + + + + + + + + + + + Date: Wed, 29 Apr 2026 10:08:04 +0800 Subject: [PATCH 2/3] fix(spp_vocabulary): make is_system and active list toggles readonly (#954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vocabularies list view exposed `is_system` and `active` as boolean_toggle columns, so admins could flip a vocabulary to/from system status or archive it directly from the list. System status should only ever be set by a module's data files, and archiving should go through the form (where it's already gated to non-system vocabs after the previous round). Conditional readonly per row would be ideal but is out of scope for this round — making both columns unconditionally readonly is the safe interim fix. --- spp_vocabulary/views/vocabulary_views.xml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/spp_vocabulary/views/vocabulary_views.xml b/spp_vocabulary/views/vocabulary_views.xml index 0bd5e970..779d06ff 100644 --- a/spp_vocabulary/views/vocabulary_views.xml +++ b/spp_vocabulary/views/vocabulary_views.xml @@ -11,9 +11,15 @@ - + + - + From 0a9fe93325a7cad8bf547abc21d68c806542557a Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 14 May 2026 10:40:29 +0800 Subject: [PATCH 3/3] feat(spp_vocabulary): manual codes UX on system vocabularies (#954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System vocabularies already accepted is_local=True codes at the model layer but the UI made the manual-vs-system distinction invisible: - Add a stored, indexed `code_source` Selection field on spp.vocabulary.code derived from `is_local` ('system' / 'manual'). No data migration — keeps is_local as the source of truth (ADR-016). - Add `action_add_manual_code` on spp.vocabulary. Opens the code form with default_is_local=True + default_vocabulary_id pre-seeded so admins can intentionally layer manual codes onto a SYSTEM vocab without tripping the create-guard in vocabulary_code.py. - Form view (vocabulary_code_views.xml): show Source as a coloured badge next to the code title; drop the restrictive `[('is_system','=',False)]` domain on vocabulary_id so manual codes can reference system vocabularies. - Standalone code list + search: Source column with badge, System / Manual filters, Group-by Source. - Vocabulary form Codes tab (system case): explicit "Add Manual Code" button above the embedded list; new Source column in the inline list so admins can see at a glance which codes are module-shipped vs admin-added. Inline create/delete stay disabled — manual codes go through the button + form so is_local is correctly set; system-code deletion is still blocked at the backend. Adds tests covering code_source compute, manual code create/edit/delete on a system vocab, and action_add_manual_code context seeding. --- spp_vocabulary/models/vocabulary.py | 20 ++++ spp_vocabulary/models/vocabulary_code.py | 18 +++ spp_vocabulary/tests/__init__.py | 1 + spp_vocabulary/tests/test_manual_codes.py | 113 ++++++++++++++++++ .../views/vocabulary_code_views.xml | 35 +++++- spp_vocabulary/views/vocabulary_views.xml | 36 ++++-- 6 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 spp_vocabulary/tests/test_manual_codes.py diff --git a/spp_vocabulary/models/vocabulary.py b/spp_vocabulary/models/vocabulary.py index 092ffc66..e82963f8 100644 --- a/spp_vocabulary/models/vocabulary.py +++ b/spp_vocabulary/models/vocabulary.py @@ -176,3 +176,23 @@ def action_view_codes(self): "domain": [("vocabulary_id", "=", self.id)], "context": {"default_vocabulary_id": self.id}, } + + def action_add_manual_code(self): + """Open the code form pre-flagged as a Manual (local) code. + + Manual codes (`is_local=True`) are admin-added overlays on top of a + SYSTEM vocabulary. They are fully editable and deletable, unlike the + module-shipped system codes which the backend locks. See OP#954. + """ + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Add Manual Code: %s") % self.name, + "res_model": "spp.vocabulary.code", + "view_mode": "form", + "target": "current", + "context": { + "default_vocabulary_id": self.id, + "default_is_local": True, + }, + } diff --git a/spp_vocabulary/models/vocabulary_code.py b/spp_vocabulary/models/vocabulary_code.py index 36d810e2..792e123a 100644 --- a/spp_vocabulary/models/vocabulary_code.py +++ b/spp_vocabulary/models/vocabulary_code.py @@ -90,6 +90,24 @@ def _is_protection_bypassed(self): default=False, help="Indicates if this is a local/country-specific code", ) + code_source = fields.Selection( + [("system", "System"), ("manual", "Manual")], + string="Source", + compute="_compute_code_source", + store=True, + index=True, + help=( + "Where this code came from. 'System' = shipped by a module's data " + "files (immutable identifying fields). 'Manual' = added by an " + "admin via the UI (fully editable). Derived from is_local." + ), + ) + + @api.depends("is_local") + def _compute_code_source(self): + for rec in self: + rec.code_source = "manual" if rec.is_local else "system" + reference_uri = fields.Char( string="Reference URI", help="For local codes: URI of the standard code this maps to", diff --git a/spp_vocabulary/tests/__init__.py b/spp_vocabulary/tests/__init__.py index 60ebc43a..7fe6b661 100644 --- a/spp_vocabulary/tests/__init__.py +++ b/spp_vocabulary/tests/__init__.py @@ -6,4 +6,5 @@ from . import test_deployment_profile from . import test_access_rights from . import test_system_vocabulary_protection +from . import test_manual_codes from . import test_e2e_workflow diff --git a/spp_vocabulary/tests/test_manual_codes.py b/spp_vocabulary/tests/test_manual_codes.py new file mode 100644 index 00000000..9d92f5e1 --- /dev/null +++ b/spp_vocabulary/tests/test_manual_codes.py @@ -0,0 +1,113 @@ +"""Manual codes on SYSTEM vocabularies (OP#954 round-3). + +System vocabularies (`is_system=True`) ship immutable system codes via module +data files. Admins must still be able to layer their own *manual* codes +(`is_local=True`) on top — these are fully editable and deletable. This test +file covers the UI-facing additions: + +- `code_source` Selection field is computed correctly from `is_local`. +- Manual codes can be created in a system vocab. +- Manual codes in a system vocab can be edited (identifying fields included) + and deleted, without tripping the system-code guards. +- `action_add_manual_code` on `spp.vocabulary` returns an action whose context + pre-seeds `default_is_local=True` and `default_vocabulary_id` for the form. +""" + +from odoo.tests.common import TransactionCase + + +class TestManualCodesOnSystemVocabulary(TransactionCase): + """Manual (is_local=True) codes on a system vocabulary stay fully editable.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Vocabulary = cls.env["spp.vocabulary"] + cls.VocabularyCode = cls.env["spp.vocabulary.code"] + + cls.system_vocab = cls.Vocabulary.create( + { + "name": "Manual-Code Host Vocab", + "namespace_uri": "urn:test:manual-codes-host", + "is_system": True, + } + ) + + def test_code_source_computed_from_is_local(self): + """code_source reflects is_local: True -> manual, False -> system.""" + system_code = self.VocabularyCode.with_context(_test_bypass_system_protection=True).create( + { + "vocabulary_id": self.system_vocab.id, + "code": "SYS_FOR_SOURCE", + "display": "System For Source", + } + ) + manual_code = self.VocabularyCode.create( + { + "vocabulary_id": self.system_vocab.id, + "code": "MANUAL_FOR_SOURCE", + "display": "Manual For Source", + "is_local": True, + } + ) + + self.assertEqual(system_code.code_source, "system") + self.assertEqual(manual_code.code_source, "manual") + + def test_manual_code_create_allowed_on_system_vocab(self): + """is_local=True codes can be created on a system vocabulary.""" + manual = self.VocabularyCode.create( + { + "vocabulary_id": self.system_vocab.id, + "code": "MANUAL_NEW", + "display": "Manual New", + "is_local": True, + } + ) + self.assertTrue(manual.id) + self.assertTrue(manual.is_local) + self.assertEqual(manual.code_source, "manual") + + def test_manual_code_identifying_fields_editable(self): + """Manual codes keep `code`, `display`, `definition` writeable on a system vocab.""" + manual = self.VocabularyCode.create( + { + "vocabulary_id": self.system_vocab.id, + "code": "MANUAL_EDIT", + "display": "Manual Edit", + "is_local": True, + } + ) + manual.write( + { + "code": "MANUAL_EDIT_RENAMED", + "display": "Manual Edit Renamed", + "definition": "Edited definition", + } + ) + self.assertEqual(manual.code, "MANUAL_EDIT_RENAMED") + self.assertEqual(manual.display, "Manual Edit Renamed") + self.assertEqual(manual.definition, "Edited definition") + + def test_manual_code_can_be_deleted(self): + """Manual codes on a system vocabulary can be unlinked.""" + manual = self.VocabularyCode.create( + { + "vocabulary_id": self.system_vocab.id, + "code": "MANUAL_DEL", + "display": "Manual Del", + "is_local": True, + } + ) + manual_id = manual.id + manual.unlink() + self.assertFalse(self.VocabularyCode.search([("id", "=", manual_id)])) + + def test_action_add_manual_code_seeds_context(self): + """action_add_manual_code returns an act_window with the right defaults.""" + action = self.system_vocab.action_add_manual_code() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.vocabulary.code") + self.assertEqual(action["view_mode"], "form") + self.assertEqual(action["context"]["default_vocabulary_id"], self.system_vocab.id) + self.assertTrue(action["context"]["default_is_local"]) diff --git a/spp_vocabulary/views/vocabulary_code_views.xml b/spp_vocabulary/views/vocabulary_code_views.xml index 9939c3f2..a82061b5 100644 --- a/spp_vocabulary/views/vocabulary_code_views.xml +++ b/spp_vocabulary/views/vocabulary_code_views.xml @@ -14,6 +14,12 @@ + + + @@ -225,12 +242,28 @@ name="filter_archived" domain="[('active', '=', False)]" /> + + + + +
+
+