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 @@