Skip to content

Commit 32329d6

Browse files
Merge pull request #82 from OpenSPP/fix/disambiguate-vocab-by-extra-domain
fix(spp_vocabulary): support extra domain for vocabulary_code search of the same display name
2 parents a8ba4c3 + 3426b62 commit 32329d6

File tree

5 files changed

+146
-1
lines changed

5 files changed

+146
-1
lines changed

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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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 as e:
34+
_logger.warning(
35+
"Failed to evaluate domain %r for field %s on model %s;"
36+
"skipping domain scoping during import. Error: %r",
37+
domain,
38+
getattr(field, "name", "<unknown>"),
39+
model._name if model else "<unknown>",
40+
e,
41+
)
42+
domain = []
43+
if isinstance(domain, list) and domain:
44+
return super(
45+
IrFieldsConverter,
46+
self.with_context(_import_name_search_domain=domain),
47+
).db_id_for(model, field, subfield, value, savepoint)
48+
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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import MagicMock
3+
14
from odoo.exceptions import UserError, ValidationError
25
from odoo.tests.common import TransactionCase
36

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

0 commit comments

Comments
 (0)