Skip to content

Commit 0a9fe93

Browse files
committed
feat(spp_vocabulary): manual codes UX on system vocabularies (#954)
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.
1 parent 8845624 commit 0a9fe93

6 files changed

Lines changed: 214 additions & 9 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: 34 additions & 1 deletion
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"
@@ -78,16 +84,27 @@
7884
readonly="vocabulary_id.is_system and not is_local"
7985
/>
8086
</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+
/>
8194
</div>
8295

8396
<notebook>
8497
<page string="Details" name="details">
8598
<group name="details_section">
8699
<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. -->
87105
<field
88106
name="vocabulary_id"
89107
required="1"
90-
domain="[('is_system', '=', False)]"
91108
readonly="vocabulary_id.is_system and not is_local"
92109
/>
93110
<field name="namespace_uri" readonly="1" />
@@ -225,12 +242,28 @@
225242
name="filter_archived"
226243
domain="[('active', '=', False)]"
227244
/>
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+
/>
228256
<group>
229257
<filter
230258
string="Vocabulary"
231259
name="group_vocabulary"
232260
context="{'group_by': 'vocabulary_id'}"
233261
/>
262+
<filter
263+
string="Source"
264+
name="group_source"
265+
context="{'group_by': 'code_source'}"
266+
/>
234267
<filter
235268
string="Parent"
236269
name="group_parent"

spp_vocabulary/views/vocabulary_views.xml

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,17 +154,31 @@
154154
</field>
155155

156156
<!-- SYSTEM vocabulary: codes are loaded via XML
157-
by modules / data files. Users can't add or
158-
delete rows here, and identifying columns
157+
by modules / data files. The embedded list
158+
disables inline create/delete because that
159+
path can't pre-set is_local=True; use the
160+
explicit "Add Manual Code" button below
161+
instead. Identifying columns
159162
(code/display/definition/parent_id) are
160-
readonly. Operational toggles (sequence,
161-
deprecated, active) stay editable so admins
163+
readonly for system codes; manual codes
164+
(`is_local=True`) keep them editable inline.
165+
Operational toggles (sequence, deprecated,
166+
active) stay editable for all rows so admins
162167
can still archive, deprecate, or reorder a
163-
system code. Local codes (`is_local=True`)
164-
keep their pre-existing edit behaviour. See
165-
OP#954 — matches the allowed_fields set in
166-
the Python guard at
168+
system code. Deletion goes through the code
169+
form (backend blocks system-code deletion,
170+
allows manual). See OP#954 — matches the
171+
allowed_fields set in the Python guard at
167172
models/vocabulary_code.py:279. -->
173+
<div invisible="not is_system">
174+
<button
175+
name="action_add_manual_code"
176+
string="Add Manual Code"
177+
type="object"
178+
icon="fa-plus"
179+
class="btn-primary mb-2"
180+
/>
181+
</div>
168182
<field
169183
name="code_ids"
170184
nolabel="1"
@@ -178,6 +192,12 @@
178192
>
179193
<field name="is_local" column_invisible="1" />
180194
<field name="sequence" widget="handle" />
195+
<field
196+
name="code_source"
197+
widget="badge"
198+
decoration-success="code_source == 'manual'"
199+
decoration-info="code_source == 'system'"
200+
/>
181201
<field
182202
name="code"
183203
readonly="parent.is_system and not is_local"

0 commit comments

Comments
 (0)