Skip to content

Commit 3c0f120

Browse files
committed
fix(spp_vocabulary): support extra domain for vocabulary_code search of the same display name
Signed-off-by: Aldrin Navarro <aldrin@newlogic.com>
1 parent a3fec68 commit 3c0f120

5 files changed

Lines changed: 141 additions & 1 deletion

File tree

spp_vocabulary/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"name": "OpenSPP: Vocabulary",
44
"category": "OpenSPP",
5-
"version": "19.0.2.0.0",
5+
"version": "19.0.2.0.1",
66
"sequence": 1,
77
"author": "OpenSPP.org",
88
"website": "https://github.com/OpenSPP/OpenSPP2",

spp_vocabulary/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from . import vocabulary_code
33
from . import vocabulary_mapping
44
from . import concept_group
5+
from . import ir_fields_converter
56

67
# Temporarily disabled to break circular dependency with spp_registry
78
# from . import relationship
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import logging
2+
3+
from odoo import api, models
4+
from odoo.tools.safe_eval import safe_eval
5+
6+
_logger = logging.getLogger(__name__)
7+
8+
9+
class IrFieldsConverter(models.AbstractModel):
10+
_inherit = "ir.fields.converter"
11+
12+
@api.model
13+
def db_id_for(self, model, field, subfield, value, savepoint):
14+
"""Override to scope name_search by field domain during CSV import.
15+
16+
When importing Many2one fields pointing to spp.vocabulary.code,
17+
multiple vocabulary codes may share the same display name across
18+
different vocabularies. This passes the field's domain as extra
19+
search criteria via _import_name_search_domain context key so
20+
name_search can disambiguate.
21+
"""
22+
if (
23+
subfield is None
24+
and getattr(field, "comodel_name", None) == "spp.vocabulary.code"
25+
and getattr(field, "domain", None)
26+
):
27+
domain = field.domain
28+
if isinstance(domain, str):
29+
try:
30+
domain = safe_eval( # nosemgrep: odoo-unsafe-safe-eval
31+
domain, {"context": self.env.context}
32+
)
33+
except Exception:
34+
_logger.warning(
35+
"Failed to evaluate domain %r for field %s on model %s; skipping domain scoping during import.",
36+
domain,
37+
field.name,
38+
model._name,
39+
)
40+
domain = []
41+
if isinstance(domain, list) and domain:
42+
return super(
43+
IrFieldsConverter,
44+
self.with_context(_import_name_search_domain=domain),
45+
).db_id_for(model, field, subfield, value, savepoint)
46+
return super().db_id_for(model, field, subfield, value, savepoint)

spp_vocabulary/models/vocabulary_code.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,17 @@ def name_get(self):
353353
"""
354354
return [(rec.id, f"{rec.display} ({rec.code})") for rec in self]
355355

356+
@api.model
357+
def name_search(self, name="", domain=None, operator="ilike", limit=None):
358+
"""Override name_search to add additional domain conditions.
359+
Specifically, if display happens to be the same for different
360+
namespaces, the namespace should be used to disambiguate.
361+
"""
362+
extra = self.env.context.get("_import_name_search_domain", [])
363+
if extra:
364+
domain = list(domain or []) + extra
365+
return super().name_search(name=name, domain=domain, operator=operator, limit=limit)
366+
356367
@api.model
357368
@tools.ormcache("namespace_uri", "code")
358369
def _get_code_id(self, namespace_uri, code):

spp_vocabulary/tests/test_vocabulary_code.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,3 +786,85 @@ def test_uri_updates_on_code_change(self):
786786
# URI should be recomputed
787787
self.assertEqual(code.uri, "urn:test:user#CHANGED")
788788
self.assertNotEqual(code.uri, original_uri)
789+
790+
# Import name_search scoping tests
791+
def _create_shared_display_codes(self, display="Active"):
792+
"""Helper: create a code with the same display in two different vocabularies."""
793+
code_user = self.VocabularyCode.create(
794+
{"vocabulary_id": self.vocab_user.id, "code": "NS_ACT1", "display": display}
795+
)
796+
code_system = self.VocabularyCode.with_context(_test_bypass_system_protection=True).create(
797+
{"vocabulary_id": self.vocab_system.id, "code": "NS_ACT2", "display": display}
798+
)
799+
return code_user, code_system
800+
801+
def test_name_search_without_context_returns_all_matches(self):
802+
"""Without context domain, name_search returns codes from all vocabularies."""
803+
code_user, code_system = self._create_shared_display_codes("SharedDisplay")
804+
results = self.VocabularyCode.name_search("SharedDisplay", operator="=")
805+
result_ids = [r[0] for r in results]
806+
self.assertIn(code_user.id, result_ids)
807+
self.assertIn(code_system.id, result_ids)
808+
809+
def test_name_search_with_context_domain_scopes_to_vocabulary(self):
810+
"""With _import_name_search_domain in context, name_search is restricted to that vocabulary."""
811+
code_user, code_system = self._create_shared_display_codes("ScopedDisplay")
812+
domain = [("vocabulary_id", "=", self.vocab_user.id)]
813+
results = self.VocabularyCode.with_context(_import_name_search_domain=domain).name_search(
814+
"ScopedDisplay", operator="="
815+
)
816+
result_ids = [r[0] for r in results]
817+
self.assertIn(code_user.id, result_ids)
818+
self.assertNotIn(code_system.id, result_ids)
819+
820+
def test_name_search_context_domain_does_not_affect_other_vocabulary(self):
821+
"""Context domain scoped to system vocab excludes user vocab codes."""
822+
code_user, code_system = self._create_shared_display_codes("OtherScopeDisplay")
823+
domain = [("vocabulary_id", "=", self.vocab_system.id)]
824+
results = self.VocabularyCode.with_context(_import_name_search_domain=domain).name_search(
825+
"OtherScopeDisplay", operator="="
826+
)
827+
result_ids = [r[0] for r in results]
828+
self.assertIn(code_system.id, result_ids)
829+
self.assertNotIn(code_user.id, result_ids)
830+
831+
def test_db_id_for_list_domain_scopes_name_search(self):
832+
"""db_id_for passes a list field domain to name_search, avoiding cross-vocab matches."""
833+
code_user, code_system = self._create_shared_display_codes("DbIdDisplay")
834+
field = SimpleNamespace(
835+
domain=[("vocabulary_id", "=", self.vocab_user.id)],
836+
comodel_name="spp.vocabulary.code",
837+
)
838+
savepoint = MagicMock()
839+
converter = self.env["ir.fields.converter"]
840+
result_id, warnings = converter.db_id_for(None, field, None, "DbIdDisplay", savepoint)
841+
self.assertEqual(result_id, code_user.id)
842+
self.assertEqual(warnings, [])
843+
844+
def test_db_id_for_string_domain_scopes_name_search(self):
845+
"""db_id_for evaluates a static string domain and applies it to name_search."""
846+
code_user, code_system = self._create_shared_display_codes("StrDomainDisplay")
847+
domain_str = f"[('vocabulary_id', '=', {self.vocab_user.id})]"
848+
field = SimpleNamespace(
849+
domain=domain_str,
850+
comodel_name="spp.vocabulary.code",
851+
)
852+
savepoint = MagicMock()
853+
converter = self.env["ir.fields.converter"]
854+
result_id, warnings = converter.db_id_for(None, field, None, "StrDomainDisplay", savepoint)
855+
self.assertEqual(result_id, code_user.id)
856+
self.assertEqual(warnings, [])
857+
858+
def test_db_id_for_no_domain_returns_multiple_match_warning(self):
859+
"""db_id_for with no field domain falls back to unscoped search, producing a multiple-match warning."""
860+
code_user, code_system = self._create_shared_display_codes("NoDomainDisplay")
861+
field = SimpleNamespace(
862+
domain=[],
863+
comodel_name="spp.vocabulary.code",
864+
)
865+
savepoint = MagicMock()
866+
converter = self.env["ir.fields.converter"]
867+
result_id, warnings = converter.db_id_for(None, field, None, "NoDomainDisplay", savepoint)
868+
# Still resolves (picks first), but warns about multiple matches
869+
self.assertIsNotNone(result_id)
870+
self.assertTrue(len(warnings) > 0)

0 commit comments

Comments
 (0)