Skip to content

Commit 7ec5c72

Browse files
committed
fix: create ir.model.data entries when using create() method
When records are created using the create() method (in fail mode or when load() falls back to create()), XML IDs were not being persisted to ir.model.data. This caused XML IDs to be missing after import. Added _create_xmlid_entry() helper function that: - Parses module and name from XML ID (uses __import__ for IDs without prefix) - Creates or updates ir.model.data entry for each created record - Handles edge cases like existing entries with different res_id This ensures XML IDs are properly persisted regardless of whether records are created via load() or create().
1 parent d10b5f3 commit 7ec5c72

File tree

2 files changed

+167
-2
lines changed

2 files changed

+167
-2
lines changed

src/odoo_data_flow/import_threaded.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -701,13 +701,79 @@ def _handle_create_error( # noqa C901
701701
return error_message, failed_line, error_summary
702702

703703

704-
def _create_batch_individually(
704+
def _create_xmlid_entry(
705+
model: Any,
706+
xml_id: str,
707+
res_id: int,
708+
model_name: str,
709+
) -> bool:
710+
"""Create an ir.model.data entry for a record created via create().
711+
712+
When records are created using Odoo's create() method instead of load(),
713+
the XML ID is not automatically persisted. This function creates the
714+
ir.model.data entry to ensure the XML ID is saved.
715+
716+
Args:
717+
model: The Odoo model proxy (used to access other models)
718+
xml_id: The external ID (e.g., 'MODULE.identifier' or just 'identifier')
719+
res_id: The database ID of the created record
720+
model_name: The model name (e.g., 'res.partner')
721+
722+
Returns:
723+
True if the ir.model.data entry was created successfully, False otherwise.
724+
"""
725+
try:
726+
# Parse module and name from XML ID
727+
if "." in xml_id:
728+
module, name = xml_id.split(".", 1)
729+
else:
730+
# Use __import__ as the default module for records without a prefix
731+
module = "__import__"
732+
name = xml_id
733+
734+
# Get ir.model.data model
735+
ir_model_data = model.browse().env["ir.model.data"]
736+
737+
# Check if entry already exists
738+
existing = ir_model_data.search([
739+
("module", "=", module),
740+
("name", "=", name),
741+
], limit=1)
742+
743+
if existing:
744+
# Update existing entry if it points to a different record
745+
if existing.res_id != res_id:
746+
log.debug(
747+
f"Updating existing ir.model.data entry for {xml_id} "
748+
f"from res_id={existing.res_id} to res_id={res_id}"
749+
)
750+
existing.write({"res_id": res_id, "model": model_name})
751+
return True
752+
753+
# Create new ir.model.data entry
754+
ir_model_data.create({
755+
"module": module,
756+
"name": name,
757+
"model": model_name,
758+
"res_id": res_id,
759+
})
760+
log.debug(
761+
f"Created ir.model.data entry: {module}.{name} -> {model_name}({res_id})"
762+
)
763+
return True
764+
except Exception as e:
765+
log.warning(f"Failed to create ir.model.data entry for {xml_id}: {e}")
766+
return False
767+
768+
769+
def _create_batch_individually( # noqa: C901
705770
model: Any,
706771
batch_lines: list[list[Any]],
707772
batch_header: list[str],
708773
uid_index: int,
709774
context: dict[str, Any],
710775
ignore_list: list[str],
776+
model_name: str = "",
711777
) -> dict[str, Any]:
712778
"""Fallback to create records one-by-one to get detailed errors."""
713779
id_map: dict[str, int] = {}
@@ -758,6 +824,12 @@ def _create_batch_individually(
758824

759825
new_record = model.create(converted_vals, context=context)
760826
id_map[sanitized_source_id] = new_record.id
827+
828+
# Create ir.model.data entry for XML ID since create() doesn't do it
829+
if model_name:
830+
_create_xmlid_entry(
831+
model, sanitized_source_id, new_record.id, model_name
832+
)
761833
except IndexError as e:
762834
error_message = f"Malformed row detected (row {i + 1} in batch): {e}"
763835
failed_lines.append([*line, error_message])
@@ -863,13 +935,15 @@ def _execute_load_batch( # noqa: C901
863935
)
864936
uid_index = thread_state["unique_id_field_index"]
865937
ignore_list = thread_state.get("ignore_list", [])
938+
model_name = thread_state.get("model_name", "")
866939

867940
if thread_state.get("force_create"):
868941
progress.console.print(
869942
f"Batch {batch_number}: Fail mode active, using `create` method."
870943
)
871944
result = _create_batch_individually(
872-
model, batch_lines, batch_header, uid_index, context, ignore_list
945+
model, batch_lines, batch_header, uid_index, context,
946+
ignore_list, model_name
873947
)
874948
result["success"] = bool(result.get("id_map"))
875949
return result
@@ -1168,6 +1242,7 @@ def _execute_load_batch( # noqa: C901
11681242
uid_index,
11691243
context,
11701244
ignore_list,
1245+
model_name,
11711246
)
11721247
# Update id_map with new successes
11731248
aggregated_id_map.update(fallback_result.get("id_map", {}))
@@ -1296,6 +1371,7 @@ def _execute_load_batch( # noqa: C901
12961371
uid_index,
12971372
context,
12981373
ignore_list,
1374+
model_name,
12991375
)
13001376
aggregated_id_map.update(fallback_result.get("id_map", {}))
13011377
aggregated_failed_lines.extend(
@@ -1319,6 +1395,7 @@ def _execute_load_batch( # noqa: C901
13191395
uid_index,
13201396
context,
13211397
ignore_list,
1398+
model_name,
13221399
)
13231400
aggregated_id_map.update(fallback_result.get("id_map", {}))
13241401
aggregated_failed_lines.extend(fallback_result.get("failed_lines", []))
@@ -1581,6 +1658,7 @@ def _orchestrate_pass_1(
15811658

15821659
thread_state_1 = {
15831660
"model": model_obj,
1661+
"model_name": model_name,
15841662
"context": context,
15851663
"unique_id_field_index": pass_1_uid_index,
15861664
"batch_header": pass_1_header,

tests/test_import_threaded.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,93 @@ def test_filter_ignored_columns(self) -> None:
608608
assert new_data == [["1", "Alice"], ["2", "Bob"]]
609609

610610

611+
class TestXmlIdCreation:
612+
"""Tests for XML ID creation when using create() method."""
613+
614+
def test_create_xmlid_entry_with_module_prefix(self) -> None:
615+
"""Test XML ID creation with module prefix (e.g., 'my_module.identifier')."""
616+
from odoo_data_flow.import_threaded import _create_xmlid_entry
617+
618+
mock_model = MagicMock()
619+
mock_ir_model_data = MagicMock()
620+
mock_ir_model_data.search.return_value = [] # No existing entry
621+
mock_model.browse.return_value.env = {"ir.model.data": mock_ir_model_data}
622+
623+
result = _create_xmlid_entry(mock_model, "my_module.partner_001", 42, "res.partner")
624+
625+
assert result is True
626+
mock_ir_model_data.create.assert_called_once_with({
627+
"module": "my_module",
628+
"name": "partner_001",
629+
"model": "res.partner",
630+
"res_id": 42,
631+
})
632+
633+
def test_create_xmlid_entry_without_module_prefix(self) -> None:
634+
"""Test XML ID creation without module prefix (uses __import__)."""
635+
from odoo_data_flow.import_threaded import _create_xmlid_entry
636+
637+
mock_model = MagicMock()
638+
mock_ir_model_data = MagicMock()
639+
mock_ir_model_data.search.return_value = [] # No existing entry
640+
mock_model.browse.return_value.env = {"ir.model.data": mock_ir_model_data}
641+
642+
result = _create_xmlid_entry(mock_model, "PARTNER_001", 42, "res.partner")
643+
644+
assert result is True
645+
mock_ir_model_data.create.assert_called_once_with({
646+
"module": "__import__",
647+
"name": "PARTNER_001",
648+
"model": "res.partner",
649+
"res_id": 42,
650+
})
651+
652+
def test_create_xmlid_entry_existing_entry_same_res_id(self) -> None:
653+
"""Test that existing entries with same res_id are not updated."""
654+
from odoo_data_flow.import_threaded import _create_xmlid_entry
655+
656+
mock_model = MagicMock()
657+
mock_existing = MagicMock()
658+
mock_existing.res_id = 42 # Same res_id
659+
mock_ir_model_data = MagicMock()
660+
mock_ir_model_data.search.return_value = mock_existing
661+
mock_model.browse.return_value.env = {"ir.model.data": mock_ir_model_data}
662+
663+
result = _create_xmlid_entry(mock_model, "my_module.partner_001", 42, "res.partner")
664+
665+
assert result is True
666+
mock_ir_model_data.create.assert_not_called()
667+
mock_existing.write.assert_not_called()
668+
669+
def test_create_xmlid_entry_existing_entry_different_res_id(self) -> None:
670+
"""Test that existing entries with different res_id are updated."""
671+
from odoo_data_flow.import_threaded import _create_xmlid_entry
672+
673+
mock_model = MagicMock()
674+
mock_existing = MagicMock()
675+
mock_existing.res_id = 99 # Different res_id
676+
mock_ir_model_data = MagicMock()
677+
mock_ir_model_data.search.return_value = mock_existing
678+
mock_model.browse.return_value.env = {"ir.model.data": mock_ir_model_data}
679+
680+
result = _create_xmlid_entry(mock_model, "my_module.partner_001", 42, "res.partner")
681+
682+
assert result is True
683+
mock_ir_model_data.create.assert_not_called()
684+
mock_existing.write.assert_called_once_with({"res_id": 42, "model": "res.partner"})
685+
686+
def test_create_xmlid_entry_handles_exception(self) -> None:
687+
"""Test that exceptions during XML ID creation are handled gracefully."""
688+
from odoo_data_flow.import_threaded import _create_xmlid_entry
689+
690+
mock_model = MagicMock()
691+
mock_model.browse.side_effect = Exception("Connection error")
692+
693+
result = _create_xmlid_entry(mock_model, "my_module.partner_001", 42, "res.partner")
694+
695+
assert result is False
696+
697+
611698
class TestRecursiveBatching:
612699
"""Tests for the recursive batch creation logic."""
613700

0 commit comments

Comments
 (0)