Skip to content

Commit 332e757

Browse files
authored
Merge pull request #201 from OpenSPP/feat/954-system-vocab-code-readonly
feat(spp_vocabulary): manual codes UX on SYSTEM vocabularies (#954)
2 parents 78fe996 + b2411f2 commit 332e757

6 files changed

Lines changed: 315 additions & 12 deletions

File tree

spp_vocabulary/models/vocabulary.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,23 @@ def action_view_codes(self):
176176
"domain": [("vocabulary_id", "=", self.id)],
177177
"context": {"default_vocabulary_id": self.id},
178178
}
179+
180+
def action_add_manual_code(self):
181+
"""Open the code form pre-flagged as a Manual (local) code.
182+
183+
Manual codes (`is_local=True`) are admin-added overlays on top of a
184+
SYSTEM vocabulary. They are fully editable and deletable, unlike the
185+
module-shipped system codes which the backend locks. See OP#954.
186+
"""
187+
self.ensure_one()
188+
return {
189+
"type": "ir.actions.act_window",
190+
"name": _("Add Manual Code: %s") % self.name,
191+
"res_model": "spp.vocabulary.code",
192+
"view_mode": "form",
193+
"target": "current",
194+
"context": {
195+
"default_vocabulary_id": self.id,
196+
"default_is_local": True,
197+
},
198+
}

spp_vocabulary/models/vocabulary_code.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,24 @@ def _is_protection_bypassed(self):
9090
default=False,
9191
help="Indicates if this is a local/country-specific code",
9292
)
93+
code_source = fields.Selection(
94+
[("system", "System"), ("manual", "Manual")],
95+
string="Source",
96+
compute="_compute_code_source",
97+
store=True,
98+
index=True,
99+
help=(
100+
"Where this code came from. 'System' = shipped by a module's data "
101+
"files (immutable identifying fields). 'Manual' = added by an "
102+
"admin via the UI (fully editable). Derived from is_local."
103+
),
104+
)
105+
106+
@api.depends("is_local")
107+
def _compute_code_source(self):
108+
for rec in self:
109+
rec.code_source = "manual" if rec.is_local else "system"
110+
93111
reference_uri = fields.Char(
94112
string="Reference URI",
95113
help="For local codes: URI of the standard code this maps to",

spp_vocabulary/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
from . import test_deployment_profile
77
from . import test_access_rights
88
from . import test_system_vocabulary_protection
9+
from . import test_manual_codes
910
from . import test_e2e_workflow
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Manual codes on SYSTEM vocabularies (OP#954 round-3).
2+
3+
System vocabularies (`is_system=True`) ship immutable system codes via module
4+
data files. Admins must still be able to layer their own *manual* codes
5+
(`is_local=True`) on top — these are fully editable and deletable. This test
6+
file covers the UI-facing additions:
7+
8+
- `code_source` Selection field is computed correctly from `is_local`.
9+
- Manual codes can be created in a system vocab.
10+
- Manual codes in a system vocab can be edited (identifying fields included)
11+
and deleted, without tripping the system-code guards.
12+
- `action_add_manual_code` on `spp.vocabulary` returns an action whose context
13+
pre-seeds `default_is_local=True` and `default_vocabulary_id` for the form.
14+
"""
15+
16+
from odoo.tests.common import TransactionCase
17+
18+
19+
class TestManualCodesOnSystemVocabulary(TransactionCase):
20+
"""Manual (is_local=True) codes on a system vocabulary stay fully editable."""
21+
22+
@classmethod
23+
def setUpClass(cls):
24+
super().setUpClass()
25+
cls.Vocabulary = cls.env["spp.vocabulary"]
26+
cls.VocabularyCode = cls.env["spp.vocabulary.code"]
27+
28+
cls.system_vocab = cls.Vocabulary.create(
29+
{
30+
"name": "Manual-Code Host Vocab",
31+
"namespace_uri": "urn:test:manual-codes-host",
32+
"is_system": True,
33+
}
34+
)
35+
36+
def test_code_source_computed_from_is_local(self):
37+
"""code_source reflects is_local: True -> manual, False -> system."""
38+
system_code = self.VocabularyCode.with_context(_test_bypass_system_protection=True).create(
39+
{
40+
"vocabulary_id": self.system_vocab.id,
41+
"code": "SYS_FOR_SOURCE",
42+
"display": "System For Source",
43+
}
44+
)
45+
manual_code = self.VocabularyCode.create(
46+
{
47+
"vocabulary_id": self.system_vocab.id,
48+
"code": "MANUAL_FOR_SOURCE",
49+
"display": "Manual For Source",
50+
"is_local": True,
51+
}
52+
)
53+
54+
self.assertEqual(system_code.code_source, "system")
55+
self.assertEqual(manual_code.code_source, "manual")
56+
57+
def test_manual_code_create_allowed_on_system_vocab(self):
58+
"""is_local=True codes can be created on a system vocabulary."""
59+
manual = self.VocabularyCode.create(
60+
{
61+
"vocabulary_id": self.system_vocab.id,
62+
"code": "MANUAL_NEW",
63+
"display": "Manual New",
64+
"is_local": True,
65+
}
66+
)
67+
self.assertTrue(manual.id)
68+
self.assertTrue(manual.is_local)
69+
self.assertEqual(manual.code_source, "manual")
70+
71+
def test_manual_code_identifying_fields_editable(self):
72+
"""Manual codes keep `code`, `display`, `definition` writeable on a system vocab."""
73+
manual = self.VocabularyCode.create(
74+
{
75+
"vocabulary_id": self.system_vocab.id,
76+
"code": "MANUAL_EDIT",
77+
"display": "Manual Edit",
78+
"is_local": True,
79+
}
80+
)
81+
manual.write(
82+
{
83+
"code": "MANUAL_EDIT_RENAMED",
84+
"display": "Manual Edit Renamed",
85+
"definition": "Edited definition",
86+
}
87+
)
88+
self.assertEqual(manual.code, "MANUAL_EDIT_RENAMED")
89+
self.assertEqual(manual.display, "Manual Edit Renamed")
90+
self.assertEqual(manual.definition, "Edited definition")
91+
92+
def test_manual_code_can_be_deleted(self):
93+
"""Manual codes on a system vocabulary can be unlinked."""
94+
manual = self.VocabularyCode.create(
95+
{
96+
"vocabulary_id": self.system_vocab.id,
97+
"code": "MANUAL_DEL",
98+
"display": "Manual Del",
99+
"is_local": True,
100+
}
101+
)
102+
manual_id = manual.id
103+
manual.unlink()
104+
self.assertFalse(self.VocabularyCode.search([("id", "=", manual_id)]))
105+
106+
def test_action_add_manual_code_seeds_context(self):
107+
"""action_add_manual_code returns an act_window with the right defaults."""
108+
action = self.system_vocab.action_add_manual_code()
109+
self.assertEqual(action["type"], "ir.actions.act_window")
110+
self.assertEqual(action["res_model"], "spp.vocabulary.code")
111+
self.assertEqual(action["view_mode"], "form")
112+
self.assertEqual(action["context"]["default_vocabulary_id"], self.system_vocab.id)
113+
self.assertTrue(action["context"]["default_is_local"])

spp_vocabulary/views/vocabulary_code_views.xml

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
<field name="sequence" widget="handle" />
1515
<field name="code" />
1616
<field name="display" />
17+
<field
18+
name="code_source"
19+
widget="badge"
20+
decoration-success="code_source == 'manual'"
21+
decoration-info="code_source == 'system'"
22+
/>
1723
<field name="target_type" />
1824
<field
1925
name="vocabulary_id"
@@ -55,35 +61,62 @@
5561
bg_color="text-bg-warning"
5662
invisible="not deprecated"
5763
/>
64+
<!-- Hidden: needed by the readonly expressions on identifying
65+
fields below. System vocabularies lock their codes from
66+
edits unless the code is a deployment-local override
67+
(`is_local=True`). See OP#954 — and the matching Python
68+
guard in models/vocabulary_code.py:279. -->
69+
<field name="is_local" invisible="1" />
5870
<div class="oe_title">
5971
<h1>
6072
<field
6173
name="display"
6274
placeholder="Display Name"
6375
required="1"
76+
readonly="vocabulary_id.is_system and not is_local"
6477
/>
6578
</h1>
6679
<h2>
67-
<field name="code" placeholder="Code" required="1" />
80+
<field
81+
name="code"
82+
placeholder="Code"
83+
required="1"
84+
readonly="vocabulary_id.is_system and not is_local"
85+
/>
6886
</h2>
87+
<field
88+
name="code_source"
89+
widget="badge"
90+
decoration-success="code_source == 'manual'"
91+
decoration-info="code_source == 'system'"
92+
readonly="1"
93+
/>
6994
</div>
7095

7196
<notebook>
7297
<page string="Details" name="details">
7398
<group name="details_section">
7499
<group name="basic" string="Basic Information">
100+
<!-- No is_system domain: manual codes
101+
(is_local=True) are valid on SYSTEM
102+
vocabularies too. Backend enforces
103+
that bare system codes still cannot
104+
be created in system vocabs. -->
75105
<field
76106
name="vocabulary_id"
77107
required="1"
78-
domain="[('is_system', '=', False)]"
108+
readonly="vocabulary_id.is_system and not is_local"
79109
/>
80110
<field name="namespace_uri" readonly="1" />
81111
<field
82112
name="sequence"
83113
help="Drag rows in list view to reorder"
84114
/>
85115
<field name="active" />
86-
<field name="target_type" />
116+
<field
117+
name="target_type"
118+
readonly="vocabulary_id.is_system and not is_local"
119+
/>
87120
</group>
88121
<group
89122
name="hierarchy"
@@ -93,6 +126,7 @@
93126
<field
94127
name="parent_id"
95128
domain="[('vocabulary_id', '=', vocabulary_id)]"
129+
readonly="vocabulary_id.is_system and not is_local"
96130
/>
97131
<field name="level" readonly="1" />
98132
</group>
@@ -103,6 +137,7 @@
103137
nolabel="1"
104138
colspan="2"
105139
placeholder="Formal definition of what this code means..."
140+
readonly="vocabulary_id.is_system and not is_local"
106141
/>
107142
</group>
108143
<group name="lifecycle_section" string="Lifecycle">
@@ -207,12 +242,28 @@
207242
name="filter_archived"
208243
domain="[('active', '=', False)]"
209244
/>
245+
<separator />
246+
<filter
247+
string="System Codes"
248+
name="filter_system_codes"
249+
domain="[('code_source', '=', 'system')]"
250+
/>
251+
<filter
252+
string="Manual Codes"
253+
name="filter_manual_codes"
254+
domain="[('code_source', '=', 'manual')]"
255+
/>
210256
<group>
211257
<filter
212258
string="Vocabulary"
213259
name="group_vocabulary"
214260
context="{'group_by': 'vocabulary_id'}"
215261
/>
262+
<filter
263+
string="Source"
264+
name="group_source"
265+
context="{'group_by': 'code_source'}"
266+
/>
216267
<filter
217268
string="Parent"
218269
name="group_parent"

0 commit comments

Comments
 (0)