Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions spp_vocabulary/models/vocabulary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
18 changes: 18 additions & 0 deletions spp_vocabulary/models/vocabulary_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions spp_vocabulary/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
113 changes: 113 additions & 0 deletions spp_vocabulary/tests/test_manual_codes.py
Original file line number Diff line number Diff line change
@@ -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"])
57 changes: 54 additions & 3 deletions spp_vocabulary/views/vocabulary_code_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
<field name="sequence" widget="handle" />
<field name="code" />
<field name="display" />
<field
name="code_source"
widget="badge"
decoration-success="code_source == 'manual'"
decoration-info="code_source == 'system'"
/>
<field name="target_type" />
<field
name="vocabulary_id"
Expand Down Expand Up @@ -55,35 +61,62 @@
bg_color="text-bg-warning"
invisible="not deprecated"
/>
<!-- Hidden: needed by the readonly expressions on identifying
fields below. System vocabularies lock their codes from
edits unless the code is a deployment-local override
(`is_local=True`). See OP#954 — and the matching Python
guard in models/vocabulary_code.py:279. -->
<field name="is_local" invisible="1" />
<div class="oe_title">
<h1>
<field
name="display"
placeholder="Display Name"
required="1"
readonly="vocabulary_id.is_system and not is_local"
/>
</h1>
<h2>
<field name="code" placeholder="Code" required="1" />
<field
name="code"
placeholder="Code"
required="1"
readonly="vocabulary_id.is_system and not is_local"
/>
</h2>
<field
name="code_source"
widget="badge"
decoration-success="code_source == 'manual'"
decoration-info="code_source == 'system'"
readonly="1"
/>
</div>

<notebook>
<page string="Details" name="details">
<group name="details_section">
<group name="basic" string="Basic Information">
<!-- No is_system domain: manual codes
(is_local=True) are valid on SYSTEM
vocabularies too. Backend enforces
that bare system codes still cannot
be created in system vocabs. -->
<field
name="vocabulary_id"
required="1"
domain="[('is_system', '=', False)]"
readonly="vocabulary_id.is_system and not is_local"
/>
<field name="namespace_uri" readonly="1" />
<field
name="sequence"
help="Drag rows in list view to reorder"
/>
<field name="active" />
<field name="target_type" />
<field
name="target_type"
readonly="vocabulary_id.is_system and not is_local"
/>
</group>
<group
name="hierarchy"
Expand All @@ -93,6 +126,7 @@
<field
name="parent_id"
domain="[('vocabulary_id', '=', vocabulary_id)]"
readonly="vocabulary_id.is_system and not is_local"
/>
<field name="level" readonly="1" />
</group>
Expand All @@ -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"
/>
</group>
<group name="lifecycle_section" string="Lifecycle">
Expand Down Expand Up @@ -207,12 +242,28 @@
name="filter_archived"
domain="[('active', '=', False)]"
/>
<separator />
<filter
string="System Codes"
name="filter_system_codes"
domain="[('code_source', '=', 'system')]"
/>
<filter
string="Manual Codes"
name="filter_manual_codes"
domain="[('code_source', '=', 'manual')]"
/>
<group>
<filter
string="Vocabulary"
name="group_vocabulary"
context="{'group_by': 'vocabulary_id'}"
/>
<filter
string="Source"
name="group_source"
context="{'group_by': 'code_source'}"
/>
<filter
string="Parent"
name="group_parent"
Expand Down
Loading
Loading