[IMP] report_carbone : bump to version 18.0.1.1.4

This commit is contained in:
2026-09-25 17:11:23 +02:00
parent 8a4d393dc0
commit 40a08e2ef8
18 changed files with 932 additions and 433 deletions

View File

@@ -1,11 +1,11 @@
import inspect
import json
from unittest.mock import MagicMock, Mock, patch
from requests import Response
from unittest.mock import MagicMock, Mock, mock_open, patch
from odoo import exceptions
from odoo.tests import common, tagged
from odoo.addons.base.models.ir_actions_report import IrActionsReport
from odoo.addons.export_json.controller import main as controller_module
from ..models.base.exceptions import MissingApiKeyError
@@ -65,6 +65,8 @@ class TestIrActionsReport(common.TransactionCase):
self.docids = f"{self.partner_demo.id},{self.partner_admin.id}"
self.env["ir.config_parameter"].set_param("report-engine.stage_api_key", "mock_key")
self._setup_patcher(IrActionsReportCarbone, "call_carbone_endpoint", Mock(return_value={}))
# Force template_id on every record found (guards against stale DB data from previous runs)
self.report.template_id = "mock_carbone_template_id"
def test_generate_report_carbone_without_api_keys(self):
"""MissingApiKeyError has to be raised when we try to generate a carbone report without an API key."""
@@ -91,6 +93,58 @@ class TestIrActionsReport(common.TransactionCase):
ir_actions_report_carbone = self.report_object._get_report_from_name(self.report_name)
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
def test_render_carbone_signature_matches_odoo_render_dispatch_contract(self):
"""`ir.actions.report._render()` is Odoo's generic dispatcher: for a report of type
"xxx" it looks up `_render_xxx` and calls it as `render_func(report_ref, res_ids,
data=data)`. `_render_carbone` must stay callable that way, and its result must still
be unpackable as a 2-element `(content, report_type)` tuple like every other
`_render_<type>` method (see `_render_qweb_pdf`, used here as the reference contract).
If Odoo ever changes that convention, this test breaks and flags the incompatibility."""
dispatch_source = inspect.getsource(IrActionsReport._render)
self.assertIn(
"render_func(report_ref, res_ids, data=data)",
dispatch_source,
"odoo.addons.base.models.ir_actions_report.IrActionsReport._render no longer calls "
"render_func(report_ref, res_ids, data=data): the assumed dispatch contract for "
"_render_carbone has changed upstream.",
)
carbone_signature = inspect.signature(IrActionsReportCarbone._render_carbone)
reference_signature = inspect.signature(IrActionsReport._render_qweb_pdf)
for signature, label in ((carbone_signature, "_render_carbone"), (reference_signature, "_render_qweb_pdf")):
params = list(signature.parameters.values())
self.assertGreaterEqual(len(params), 3, f"{label} must accept (self, report_ref, res_ids/docids, ...)")
self.assertEqual(params[0].name, "self")
self.assertIn(
params[1].kind,
(inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.POSITIONAL_ONLY),
f"{label}'s 2nd parameter (report_ref) must be positional",
)
self.assertIn(
params[2].kind,
(inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.POSITIONAL_ONLY),
f"{label}'s 3rd parameter (res_ids/docids) must be positional",
)
self.assertIn("data", signature.parameters, f"{label} must accept a 'data' parameter")
self.assertEqual(
signature.parameters["data"].default,
None,
f"{label}'s 'data' parameter must default to None",
)
# Simulate the exact call performed by the generic dispatcher, unbound (self is the
# positional receiver): render_func(report_ref, res_ids, data=data).
ir_actions_report_carbone = self.report_object._get_report_from_name(self.report_name)
bound = carbone_signature.bind(
ir_actions_report_carbone, ir_actions_report_carbone, "1,2", data={"some": "data"}
)
bound.apply_defaults()
with patch.object(IrActionsReportCarbone, "_render_carbone_full", return_value=(b"content", "file.pdf", "pdf")):
content, report_type = IrActionsReportCarbone._render_carbone(*bound.args, **bound.kwargs)
self.assertEqual((content, report_type), (b"content", "pdf"))
def test_get_report_from_name(self):
# ir_actions_report found with name and report_type == "carbone':
res = self.report_object._get_report_from_name(self.report_name)
@@ -114,9 +168,9 @@ class TestIrActionsReport(common.TransactionCase):
with (
patch("carbone_sdk.CarboneSDK.render_report", return_value=mock_render_report),
patch("carbone_sdk.CarboneSDK.get_report", return_value=mock_get_report),
self.assertRaises(exceptions.UserError) as error,
):
with self.assertRaises(exceptions.UserError) as error:
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
self.assertEqual(
str(error.exception),
@@ -127,21 +181,12 @@ class TestIrActionsReport(common.TransactionCase):
def test_failed_generate_report_carbone_status_code_400(self):
ir_actions_report_carbone = self.report_object._get_report_from_name(self.report_name)
def post(url, **kwargs):
response = Response()
if url.startswith("https://api.carbone.io/render/mock_carbone_template_id"):
response.status_code = 400 # NotFileError
response._content = json.dumps(
{
"success": False,
"error": "NotFileError",
}
).encode("utf-8")
return response
with patch("requests.post", post):
with self.assertRaises(exceptions.UserError) as error:
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
mock_result = {"success": False, "error": "NotFileError"}
with (
patch("carbone_sdk.CarboneSDK.render_report", return_value=mock_result),
self.assertRaises(exceptions.UserError) as error,
):
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
self.assertEqual(
str(error.exception),
"An error occurred when generating the report via Carbone : Carbone SDK render error: NotFileError",
@@ -150,21 +195,12 @@ class TestIrActionsReport(common.TransactionCase):
def test_failed_generate_report_carbone_status_code_500(self):
ir_actions_report_carbone = self.report_object._get_report_from_name(self.report_name)
def post(url, **kwargs):
response = Response()
if url.startswith("https://api.carbone.io/render/mock_carbone_template_id"):
response.status_code = 500 # GenerateReportError
response._content = json.dumps(
{
"success": False,
"error": "GenerateReportError",
}
).encode("utf-8")
return response
with patch("requests.post", post):
with self.assertRaises(exceptions.UserError) as error:
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
mock_result = {"success": False, "error": "GenerateReportError"}
with (
patch("carbone_sdk.CarboneSDK.render_report", return_value=mock_result),
self.assertRaises(exceptions.UserError) as error,
):
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
self.assertEqual(
str(error.exception),
"An error occurred when generating the report via Carbone : Carbone SDK render error: GenerateReportError",
@@ -180,9 +216,9 @@ class TestIrActionsReport(common.TransactionCase):
patch("carbone_sdk.CarboneSDK.generate_template_id", return_value="no_template_carbone_id"),
patch("carbone_sdk.CarboneSDK.add_template", return_value=mock_result),
patch("carbone_sdk.CarboneSDK.render_report", return_value=mock_result),
self.assertRaises(exceptions.UserError) as error,
):
with self.assertRaises(exceptions.UserError) as error:
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
self.assertEqual(
str(error.exception),
"An error occurred when generating the report via Carbone : Carbone SDK render error:mock_error_message",
@@ -192,9 +228,8 @@ class TestIrActionsReport(common.TransactionCase):
# Error Carbone SDK render error: failled to generate the template id
ir_actions_report_carbone = self.report_object._get_report_from_name(self.report_name)
with patch("os.path.exists", return_value=True):
with self.assertRaises(exceptions.UserError) as error:
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
with patch("os.path.exists", return_value=True), self.assertRaises(exceptions.UserError) as error:
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
self.assertEqual(
str(error.exception),
"An error occurred when generating the report via Carbone :"
@@ -225,9 +260,9 @@ class TestIrActionsReport(common.TransactionCase):
with (
patch("carbone_sdk.CarboneSDK.generate_template_id", return_value="no_template_carbone_id"),
patch("carbone_sdk.CarboneSDK.render_report", return_value=mock_result),
self.assertRaises(exceptions.UserError) as error,
):
with self.assertRaises(exceptions.UserError) as error:
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
self.assertEqual(
str(error.exception),
"An error occurred when generating the report via Carbone : Carbone SDK render error: render_id empty",
@@ -237,14 +272,160 @@ class TestIrActionsReport(common.TransactionCase):
# Default SDK's error
ir_actions_report_carbone = self.report_object._get_report_from_name(self.report_name)
mock_result = None
with patch("carbone_sdk.CarboneSDK.render_report", return_value=mock_result):
with self.assertRaises(exceptions.UserError) as error:
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
with (
patch("carbone_sdk.CarboneSDK.render_report", return_value=mock_result),
self.assertRaises(exceptions.UserError) as error,
):
ir_actions_report_carbone._render_carbone(ir_actions_report_carbone, self.docids)
self.assertEqual(
str(error.exception),
"An error occurred when generating the report via Carbone : Carbone SDK render error: something went wrong",
)
# region local_context
def test_is_json_serializable(self):
"""_is_json_serializable accepts JSON primitives and rejects non-serialisable values
(e.g. recordsets), so the Carbone payload never breaks csdk.render's JSON encoding."""
report = self.report
self.assertTrue(report._is_json_serializable("string"))
self.assertTrue(report._is_json_serializable(42))
self.assertTrue(report._is_json_serializable({"a": [1, 2, True, None]}))
self.assertFalse(report._is_json_serializable(self.partner_demo)) # recordset
self.assertFalse(report._is_json_serializable(object()))
def test_get_env_context_datas_to_export(self):
"""_get_env_context_datas_to_export returns a JSON-safe `local_context` block: default_*
keys and non-serialisable context values are filtered out, user/company info is added."""
report = self.report.with_context(
default_should_be_dropped=1,
custom_key="kept",
non_serialisable=self.partner_demo, # recordset -> not JSON serialisable
)
result = report._get_env_context_datas_to_export()
# Structure.
self.assertIn("local_context", result)
local_context = result["local_context"]
self.assertEqual(set(local_context), {"context", "user", "company"})
# clean_context: default_* dropped, plain key kept.
context = local_context["context"]
self.assertNotIn("default_should_be_dropped", context)
self.assertEqual(context.get("custom_key"), "kept")
# Non-serialisable values are filtered out.
self.assertNotIn("non_serialisable", context)
# User / company info is populated from the environment.
self.assertEqual(local_context["user"]["name"], self.env.user.name)
self.assertEqual(local_context["user"]["login"], self.env.user.login)
self.assertEqual(local_context["company"]["name"], self.env.user.company_id.name)
# The whole block must be JSON serialisable (it is sent to csdk.render, which JSON-encodes it).
json.dumps(result)
# endregion local_context
# region deploy_carbone_template
def _clear_template_id(self):
"""Return the demo report with its template_id reset (call_carbone_endpoint
is mocked in setUp, so the write() override does not hit the network)."""
report = self.report
report.template_id = False
return report
def test_carbone_template_exists(self):
report = self.report
# call_carbone_endpoint is mocked to {} in setUp -> no data -> not found.
self.assertFalse(report._carbone_template_exists("whatever"))
with patch.object(IrActionsReportCarbone, "call_carbone_endpoint", return_value={"data": [{"id": "x"}]}):
self.assertTrue(report._carbone_template_exists("x"))
def test_deploy_carbone_template_no_api_key(self):
"""Garde A: without a configured API key, nothing happens."""
self.env["ir.config_parameter"].set_param("report-engine.stage_api_key", False)
self.env["ir.config_parameter"].set_param("report-engine.prod_api_key", False)
report = self._clear_template_id()
with patch.object(IrActionsReportCarbone, "get_carbone_sdk") as mock_sdk:
self.assertFalse(report.deploy_carbone_template("/tmp/template.docx"))
mock_sdk.assert_not_called()
self.assertFalse(report.template_id)
def test_deploy_carbone_template_set_once(self):
"""Garde B: a report that already has a template_id is left untouched."""
self.env["ir.config_parameter"].set_param("report-engine.is_stage_mode", True)
report = self.report
report.template_id = "mock_carbone_template_id"
self.assertTrue(report.template_id)
with patch.object(IrActionsReportCarbone, "get_carbone_sdk") as mock_sdk:
self.assertFalse(report.deploy_carbone_template("/tmp/template.docx"))
mock_sdk.assert_not_called()
def test_deploy_carbone_template_file_not_found(self):
self.env["ir.config_parameter"].set_param("report-engine.is_stage_mode", True)
report = self._clear_template_id()
with patch("os.path.exists", return_value=False), self.assertRaises(exceptions.UserError):
report.deploy_carbone_template("/tmp/missing.docx")
def test_deploy_carbone_template_already_on_carbone(self):
"""Template already known by Carbone: stable template_id retrieved via GET, no upload."""
self.env["ir.config_parameter"].set_param("report-engine.is_stage_mode", True)
report = self._clear_template_id()
mock_csdk = MagicMock()
mock_csdk.generate_template_id.return_value = "hash_id"
def mock_endpoint(endpoint, params=None, raise_exception=True, method="GET", **kwargs):
return {"data": [{"id": "stable_template_id", "type": "docx"}]}
with (
patch("os.path.exists", return_value=True),
patch.object(IrActionsReportCarbone, "get_carbone_sdk", return_value=mock_csdk),
patch.object(IrActionsReportCarbone, "_carbone_template_exists", return_value=True),
patch.object(IrActionsReportCarbone, "call_carbone_endpoint", side_effect=mock_endpoint),
):
res = report.deploy_carbone_template("/tmp/template.docx")
self.assertEqual(res, "stable_template_id")
# No upload: the template already existed on Carbone, SDK add_template never called.
mock_csdk.add_template.assert_not_called()
self.assertEqual(report.template_id, "stable_template_id")
def test_deploy_carbone_template_uploads_when_missing(self):
"""Template unknown by Carbone: upload via call_carbone_endpoint with deployedAt."""
self.env["ir.config_parameter"].set_param("report-engine.is_stage_mode", True)
report = self._clear_template_id()
mock_csdk = MagicMock()
mock_csdk.generate_template_id.return_value = "hash_id"
def mock_endpoint(endpoint, params=None, raise_exception=True, method="GET", **kwargs):
if method == "POST" and endpoint == "template":
return {"data": {"id": "new_template_id"}}
return {}
with (
patch("os.path.exists", return_value=True),
patch("builtins.open", mock_open(read_data=b"template_content")),
patch.object(IrActionsReportCarbone, "get_carbone_sdk", return_value=mock_csdk),
patch.object(IrActionsReportCarbone, "_carbone_template_exists", return_value=False),
patch.object(IrActionsReportCarbone, "call_carbone_endpoint", side_effect=mock_endpoint),
):
res = report.deploy_carbone_template("/tmp/template.docx")
self.assertEqual(res, "new_template_id")
# SDK add_template must NOT be used — upload goes through call_carbone_endpoint.
mock_csdk.add_template.assert_not_called()
self.assertEqual(report.template_id, "new_template_id")
# endregion deploy_carbone_template
# region utils
def get_not_use_id(self, model_name: "str") -> int: