Skip to content

Commit 290c891

Browse files
committed
Merge branch 'fix-o2m-id-field-handling-rebased3' of github.com:OdooDataFlow/odoo-data-flow into fix-o2m-id-field-handling-rebased3
2 parents 41bdce6 + 21369ae commit 290c891

File tree

2 files changed

+172
-8
lines changed

2 files changed

+172
-8
lines changed

src/odoo_data_flow/import_threaded.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -888,7 +888,13 @@ def _execute_load_batch( # noqa: C901
888888
f"Batch {batch_number}: Fail mode active, using `create` method."
889889
)
890890
result = _create_batch_individually(
891-
model, batch_lines, batch_header, uid_index, context, ignore_list, progress
891+
model,
892+
batch_lines,
893+
batch_header,
894+
uid_index,
895+
context,
896+
ignore_list,
897+
progress,
892898
)
893899
result["success"] = bool(result.get("id_map"))
894900
return result
@@ -979,6 +985,14 @@ def _execute_load_batch( # noqa: C901
979985
f"{load_lines[0][:10] if load_lines and load_lines[0] else []}"
980986
"Model has no _fields attribute, using raw values for load method"
981987
)
988+
# Even when model has no _fields, we still need to sanitize the
989+
# unique ID field to prevent XML ID constraint violations.
990+
for row in load_lines:
991+
# This is more efficient than a nested list comprehension as
992+
# it modifies the list in-place and only targets the
993+
# required cell.
994+
if uid_index < len(row) and row[uid_index] is not None:
995+
row[uid_index] = to_xmlid(str(row[uid_index]))
982996
try:
983997
log.debug(f"Attempting `load` for chunk of batch {batch_number}...")
984998

@@ -1039,7 +1053,11 @@ def _execute_load_batch( # noqa: C901
10391053
# to fail file
10401054
error_msg = res["messages"][0].get("message", "Batch load failed.")
10411055
log.error(f"Capturing load failure for fail file: {error_msg}")
1042-
# We'll add the failed lines to aggregated_failed_lines at the end
1056+
# Add all current chunk records to failed lines since there are
1057+
# error messages
1058+
for line in current_chunk:
1059+
failed_line = [*line, f"Load failed: {error_msg}"]
1060+
aggregated_failed_lines.append(failed_line)
10431061

10441062
# Use sanitized IDs for the id_map to match what was actually sent to Odoo
10451063
id_map = {
@@ -1063,7 +1081,26 @@ def _execute_load_batch( # noqa: C901
10631081
successful_count = len(created_ids)
10641082
total_count = len(load_lines)
10651083

1066-
if successful_count < total_count:
1084+
# If there are error messages from Odoo, all records in chunk
1085+
# should be marked as failed
1086+
if res.get("messages"):
1087+
# All records in the chunk are considered failed due to
1088+
# error messages
1089+
successful_count = len(created_ids)
1090+
total_count = len(load_lines)
1091+
1092+
# If there are error messages from Odoo, all records in chunk should
1093+
# be marked as failed
1094+
if res.get("messages"):
1095+
# All records in the chunk are considered failed due to
1096+
# error messages
1097+
log.info(
1098+
f"All {len(current_chunk)} records in chunk marked as "
1099+
f"failed due to error messages"
1100+
)
1101+
# Don't add them again since they were already added in the
1102+
# earlier block
1103+
elif successful_count < total_count:
10671104
failed_count = total_count - successful_count
10681105
log.info(f"Capturing {failed_count} failed records for fail file")
10691106
# Add error information to the lines that failed
@@ -1072,8 +1109,6 @@ def _execute_load_batch( # noqa: C901
10721109
if i >= len(created_ids) or created_ids[i] is None:
10731110
# This record failed, add it to failed_lines with error info
10741111
error_msg = "Record creation failed"
1075-
if res.get("messages"):
1076-
error_msg = res["messages"][0].get("message", error_msg)
10771112

10781113
failed_line = [*list(line), f"Load failed: {error_msg}"]
10791114
aggregated_failed_lines.append(failed_line)
@@ -1170,6 +1205,14 @@ def _execute_load_batch( # noqa: C901
11701205
continue
11711206

11721207
# For all other exceptions, use the original scalable error detection
1208+
# Also check for constraint violations which should be treated as
1209+
# non-scalable
1210+
is_constraint_violation = (
1211+
"constraint" in error_str
1212+
or "violation" in error_str
1213+
or "not-null constraint" in error_str
1214+
or "mandatory field" in error_str
1215+
)
11731216
is_scalable_error = (
11741217
"memory" in error_str
11751218
or "out of memory" in error_str
@@ -1184,6 +1227,24 @@ def _execute_load_batch( # noqa: C901
11841227
or "poolerror" in error_str.lower()
11851228
)
11861229

1230+
# Handle constraint violations separately - these are data issues,
1231+
# not scalable issues
1232+
if is_constraint_violation:
1233+
# Constraint violations are data problems, add all records to
1234+
# failed lines
1235+
clean_error = str(e).strip().replace("\\n", " ")
1236+
log.error(
1237+
f"Constraint violation in batch {batch_number}: {clean_error}"
1238+
)
1239+
error_msg = f"Constraint violation: {clean_error}"
1240+
1241+
for line in current_chunk:
1242+
failed_line = [*line, error_msg]
1243+
aggregated_failed_lines.append(failed_line)
1244+
1245+
lines_to_process = lines_to_process[chunk_size:]
1246+
continue
1247+
11871248
if is_scalable_error and chunk_size > 1:
11881249
chunk_size = max(1, chunk_size // 2)
11891250
progress.console.print(

tests/test_import_threaded.py

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
_setup_fail_file,
1818
import_data,
1919
)
20+
from odoo_data_flow.lib.internal.tools import to_xmlid
2021

2122

2223
class TestImportData:
@@ -220,7 +221,12 @@ def test_batch_scales_down_on_gateway_error(
220221
"ignore_list": [],
221222
}
222223
batch_header = ["id", "name"]
223-
batch_lines = [["rec1", "A"], ["rec2", "B"], ["rec3", "C"], ["rec4", "D"]]
224+
batch_lines = [
225+
["rec1", "A"],
226+
["rec2", "B"],
227+
["rec3", "C"],
228+
["rec4", "D"],
229+
]
224230

225231
# Set up the return value for the mocked function before the call
226232
mock_create_individually.return_value = {
@@ -415,7 +421,10 @@ def test_pass_2_handles_failed_batch(self, mock_run_pass: MagicMock) -> None:
415421
(2, {"parent_id": 101}, "Access Error"),
416422
],
417423
}
418-
mock_run_pass.return_value = (failed_write_result, False) # result, aborted
424+
mock_run_pass.return_value = (
425+
failed_write_result,
426+
False,
427+
) # result, aborted
419428

420429
# Act
421430
with Progress() as progress:
@@ -538,7 +547,10 @@ def test_run_threaded_pass_keyboard_interrupt(
538547
self, mock_as_completed: MagicMock
539548
) -> None:
540549
"""Test that a KeyboardInterrupt is handled gracefully."""
541-
from odoo_data_flow.import_threaded import RPCThreadImport, _run_threaded_pass
550+
from odoo_data_flow.import_threaded import (
551+
RPCThreadImport,
552+
_run_threaded_pass,
553+
)
542554

543555
rpc_thread = RPCThreadImport(1, Progress(), MagicMock())
544556
rpc_thread.task_id = rpc_thread.progress.add_task("test")
@@ -743,3 +755,94 @@ def test_execute_load_batch_successfully_aggregates_all_records() -> None:
743755
assert result["id_map"]["rec4"] == 4
744756
# Should have no failed lines
745757
assert len(result["failed_lines"]) == 0
758+
759+
760+
def test_execute_load_batch_sanitizes_ids_when_model_has_no_fields() -> None:
761+
"""Test that unique ID field values are sanitized."""
762+
mock_model = MagicMock()
763+
# Model has no _fields attribute
764+
mock_model._fields = None
765+
mock_model.load.return_value = {"ids": [1, 2]}
766+
mock_progress = MagicMock()
767+
thread_state = {
768+
"model": mock_model,
769+
"progress": mock_progress,
770+
"unique_id_field_index": 0, # Index of the ID column
771+
"ignore_list": [],
772+
}
773+
batch_header = ["id", "name"]
774+
# IDs with spaces that should be sanitized
775+
batch_lines = [
776+
["product_template_2023_02_08 09_45_32_0001", "Product 1"],
777+
["another id with spaces", "Product 2"],
778+
]
779+
780+
from odoo_data_flow.import_threaded import _execute_load_batch
781+
from odoo_data_flow.lib.internal.tools import to_xmlid
782+
783+
# Call the function
784+
result = _execute_load_batch(thread_state, batch_lines, batch_header, 1)
785+
786+
# Verify that model.load was called with properly sanitized IDs
787+
# The call_args should show that the IDs were sanitized
788+
# (spaces replaced with underscores)
789+
call_args = mock_model.load.call_args
790+
sent_header, sent_data = call_args[0] # Get the positional arguments
791+
792+
# Verify header is unchanged
793+
assert sent_header == batch_header
794+
# Verify that the IDs in the data have been sanitized
795+
assert sent_data[0][0] == to_xmlid(
796+
"product_template_2023_02_08 09_45_32_0001"
797+
) # Should be sanitized
798+
assert sent_data[1][0] == to_xmlid("another id with spaces") # Should be sanitized
799+
800+
# Verify the id_map uses the sanitized IDs
801+
expected_id1 = to_xmlid("product_template_2023_02_08 09_45_32_0001")
802+
expected_id2 = to_xmlid("another id with spaces")
803+
assert result["id_map"][expected_id1] == 1
804+
assert result["id_map"][expected_id2] == 2
805+
806+
807+
def test_execute_load_batch_sanitizes_ids_in_model_fields_case() -> None:
808+
"""Test that unique ID field values are sanitized."""
809+
mock_model = MagicMock()
810+
# Model has _fields attribute (like normal Odoo models)
811+
mock_model._fields = {"id": {"type": "char"}, "name": {"type": "char"}}
812+
mock_model.load.return_value = {"ids": [1, 2]}
813+
mock_progress = MagicMock()
814+
thread_state = {
815+
"model": mock_model,
816+
"progress": mock_progress,
817+
"unique_id_field_index": 0, # Index of the ID column
818+
"ignore_list": [],
819+
}
820+
batch_header = ["id", "name"]
821+
# IDs with spaces that should be sanitized
822+
batch_lines = [
823+
["product_template_2023_02_08 09_45_32_0003", "Product 1"],
824+
["different id with spaces", "Product 2"],
825+
]
826+
827+
# Call the function
828+
result = _execute_load_batch(thread_state, batch_lines, batch_header, 1)
829+
830+
# Verify that model.load was called with properly sanitized IDs
831+
call_args = mock_model.load.call_args
832+
sent_header, sent_data = call_args[0] # Get the positional arguments
833+
834+
# Verify header is unchanged
835+
assert sent_header == batch_header
836+
# Verify that the IDs in the data have been sanitized
837+
assert sent_data[0][0] == to_xmlid(
838+
"product_template_2023_02_08 09_45_32_0003"
839+
) # Should be sanitized
840+
assert sent_data[1][0] == to_xmlid(
841+
"different id with spaces"
842+
) # Should be sanitized
843+
844+
# Verify the id_map uses the sanitized IDs
845+
expected_id1 = to_xmlid("product_template_2023_02_08 09_45_32_0003")
846+
expected_id2 = to_xmlid("different id with spaces")
847+
assert result["id_map"][expected_id1] == 1
848+
assert result["id_map"][expected_id2] == 2

0 commit comments

Comments
 (0)