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 60bc65a0..a82061b5 100644 --- a/spp_vocabulary/views/vocabulary_code_views.xml +++ b/spp_vocabulary/views/vocabulary_code_views.xml @@ -14,6 +14,12 @@ + + +

- +

+
+ - + @@ -103,6 +137,7 @@ nolabel="1" colspan="2" placeholder="Formal definition of what this code means..." + readonly="vocabulary_id.is_system and not is_local" /> @@ -207,12 +242,28 @@ name="filter_archived" domain="[('active', '=', False)]" /> + + + + - + + - + @@ -58,12 +64,18 @@ bg_color="text-bg-warning" invisible="not is_system" /> + +

@@ -72,14 +84,24 @@ - - - + + + - - - + + @@ -87,6 +109,7 @@ name="reference_url" widget="url" placeholder="https://..." + readonly="is_system" /> @@ -95,17 +118,20 @@ nolabel="1" placeholder="Description of this vocabulary..." class="oe_inline" + readonly="is_system" /> + @@ -126,6 +152,80 @@ + + +
+
+ + + + + + + + + + + + +