[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

@@ -11,11 +11,8 @@ Configuration
Add your Carbon API keys and define users who have access to the "Carbon Reports" menu with the "Carbon Report - Viewer" and "Carbon Report - Manager" groups.
.. image:: static/description/assets/screenshots/add_api_keys.png
:scale: 36 %
.. image:: static/description/assets/screenshots/carbone_groups.png
:scale: 50 %
More information on Odoo App Store :
`report_carbone <https://apps.odoo.com/apps/modules/18.0/report_carbone>`__
Company
@@ -29,7 +26,7 @@ General Public License, Version 3 (LGPL v3).
Credits
-------
Developer: (V18) Mangono , Contact: contact@mangono.fr
Developer: Mangono
Contacts
--------

View File

@@ -1,6 +1,6 @@
{
"name": "Report Generator | Carbone.io",
"version": "18.0.1.0.8",
"version": "18.0.1.1.4",
"author": "Mangono",
"maintainer": "Mangono",
"summary": """
@@ -15,11 +15,13 @@
"category": "Reporting",
"depends": ["base", "base_setup", "web", "export_json"],
"description": "Report Generator | Carbone.io",
"website": "https://carbone.io/integration/odoo.html",
# Page produit Mangono plutôt que page de l'éditeur : c'est le lien que la
# place de marché affiche, et une visite acquise sur notre module a plus de
# valeur chez nous. La page renvoie vers carbone.io/integration/odoo.html.
"website": "https://mangono.fr/connecteurs-odoo/carbone.html",
"data": [
"security/groups.xml",
"security/ir.model.access.csv",
"data/carbone_guide_attachment.xml",
"data/carbone_demo_template_purchase_order.xml",
"views/base/ir_actions_report.xml",
"views/base/ir_exports.xml",

View File

@@ -90,7 +90,11 @@ class CarboneReportController(ReportController):
carbone_report = request.env["ir.actions.report"]._get_report_from_name(reportname)
self.check_carbone_report(carbone_report)
report_content, extension, filename = request.env["ir.actions.report"]._render_carbone(carbone_report, docids)
report_content, filename, extension = request.env["ir.actions.report"]._render_carbone_full(
carbone_report, docids
)
if not filename:
filename = f"{carbone_report.report_name}.{extension}"
headers = _get_headers(extension, report_content, filename)
return request.make_response(report_content, headers)
@@ -129,5 +133,5 @@ class CarboneReportController(ReportController):
response = self._call_carbone_converter(docids, reportname, context, url)
return response
except Exception as e:
except Exception as e: # noqa: BLE001
self.handle_exception_error(e, reportname)

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="report_carbone_userguide_attachment" model="ir.attachment">
<field name="name">Carbone_report_guide.pdf</field>
<field name="datas" type="base64" file="report_carbone/docs/carbone_userguide_v18.pdf"/>
<field name="public">True</field>
</record>
</odoo>

View File

@@ -284,6 +284,10 @@ image::../static/description/assets/screenshots/res_partner_lang.png[]
- Also via the "Languages" field. If nothing is defined in this field, the print language will be the language of the user printing the report. If several languages are defined, the chosen language will be the first `res.lang` in Odoo.
== Nested template
If you are using Carbone's appendTemplate feature, to access your variables in complement ({c.}) instead of data ({d.}), you must check the “Use complement datas” box in the Advanced Properties tab.
== User groups and rights
You can define which user(s) will be able to view or edit Carbone Reports via two new groups: "Carbone Report - Manager" and "Carbone Report - Viewer".

File diff suppressed because it is too large Load Diff

View File

@@ -754,6 +754,10 @@ msgstr "Nouveau document"
msgid "Sauvegarder"
msgstr ""
#. module: report_carbone
#: model:ir.model.fields,field_description:report_carbone.field_ir_actions_report__use_complement
msgid "Use complement datas"
msgstr "Utilisation des compléments"
#. module: report_carbone
#: model_terms:ir.ui.view,arch_db:report_carbone.act_carbone_create_report_wizard_form

View File

@@ -15,7 +15,7 @@ class Base(models.AbstractModel):
def _compute_carbone_default_currency_id(self):
for rec in self:
currency = self.env.user.company_id.currency_id
if "currency_id" in rec._fields.keys():
if "currency_id" in rec._fields:
currency = rec.currency_id
rec.carbone_default_currency_id = currency

View File

@@ -18,6 +18,7 @@ from werkzeug import urls
from odoo import _, api, exceptions, fields, models, release
from odoo.modules import get_module_path
from odoo.tools.misc import clean_context
from odoo.tools.safe_eval import safe_eval, time
from odoo.addons.export_json.controller.main import JsonExportFormat
@@ -129,6 +130,7 @@ class IrActionsReportCarbone(models.Model):
"supported by Carbone. You must provide a production API key and must not "
"be in test mode.",
)
use_complement = fields.Boolean(string="Use complement datas")
@api.model
def _setup_template_id_and_extension(self, vals):
@@ -174,11 +176,11 @@ class IrActionsReportCarbone(models.Model):
return super().create(vals_list)
def write(self, vals):
if "template_id" in vals.keys():
if "template_id" in vals:
new_template_id = vals.get("template_id")
new_file_extension = self.get_extension_file_from_api(new_template_id, raise_error=False)
vals.update({"file_extension": new_file_extension})
if "report_output_file_extension" in vals.keys():
if "report_output_file_extension" in vals:
self.check_report_output_file_extension(vals)
return super().write(vals)
@@ -195,6 +197,9 @@ class IrActionsReportCarbone(models.Model):
dict_full_data = rec._get_jsonify_export()
dict_langs = rec._get_jsonify_translate_export()
local_context = rec._get_env_context_datas_to_export()
dict_full_data.update(local_context)
# Based on the data from dict_full_data, we add the translations.
# dict_langs et dict_full_data are modified by reference.
rec.extract_translations(dict_full_data, dict_langs)
@@ -228,11 +233,10 @@ class IrActionsReportCarbone(models.Model):
lang_code = match.group(2)
locale = lang_map.get(lang_code)
if locale and locale in translations:
if base_key in data:
reference_key = data[base_key]
if isinstance(reference_key, str):
translations[locale][reference_key] = value
if locale and locale in translations and base_key in data:
reference_key = data[base_key]
if isinstance(reference_key, str):
translations[locale][reference_key] = value
keys_to_remove.append(key)
elif isinstance(value, dict):
@@ -285,6 +289,52 @@ class IrActionsReportCarbone(models.Model):
return all_langs
def _get_env_context_datas_to_export(self) -> dict:
"""Return auxiliary data to inject into the Carbone export JSON: the current context
cleaned (clean_context, keeping JSON-serialisable values only), enriched with some
user and company information."""
self.ensure_one()
user = self.env.user
company = user.company_id
clean = clean_context(self.env.context)
safe_context = {k: v for k, v in clean.items() if self._is_json_serializable(v)}
return {
"local_context": {
"context": safe_context,
"user": {
"name": user.name,
"login": user.login,
"email": user.email or "",
"phone": user.phone or "",
"lang": user.lang or "",
"tz": user.tz or "",
},
"company": {
"name": company.name,
"vat": company.vat or "",
"email": company.email or "",
"phone": company.phone or "",
"website": company.website or "",
"street": company.street or "",
"street2": company.street2 or "",
"zip": company.zip or "",
"city": company.city or "",
"country": company.country_id.name or "",
"contact_address": company.partner_id.contact_address or "",
},
},
}
@staticmethod
def _is_json_serializable(value) -> bool:
try:
json.dumps(value)
return True
except (TypeError, ValueError):
return False
def _compute_hide_create_update_button(self):
for rec in self:
rec.hide_create_update_button = rec.get_hide_create_update_button_value()
@@ -541,12 +591,6 @@ class IrActionsReportCarbone(models.Model):
csdk = carbone_sdk.CarboneSDK(access_token)
csdk._api_headers.update({"User-Agent": self.get_default_user_agent()})
api_url = (
self.env["ir.config_parameter"]
.sudo()
.get_param("report-engine.carbone_studio_url")
)
csdk.set_api_url(api_url)
return csdk
def _get_json_data(self, export_json_instance, field_names, record, model, lang_codes):
@@ -572,7 +616,7 @@ class IrActionsReportCarbone(models.Model):
records = self.env[model].browse(all_res_ids_wo_stream) or self.env[model]
if not records.exists():
raise exceptions.MissingError(_("No %s selected for printing." % records._description)) # noqa: UP031
raise exceptions.MissingError(_("No %s selected for printing.") % records._description)
for record in records:
try:
@@ -589,6 +633,7 @@ class IrActionsReportCarbone(models.Model):
# Creating the JSON file (data and translate).
dict_full_data = self._get_json_data(export_json_instance, field_names, record, model, lang_codes)
dict_full_data.update(self._get_env_context_datas_to_export())
dict_langs = self._get_jsonify_translate_export()
# Modification by reference of dicts.
self.extract_translations(dict_full_data, dict_langs)
@@ -597,7 +642,8 @@ class IrActionsReportCarbone(models.Model):
tuple_pdf = csdk.render(
self.template_id,
{
"data": dict_full_data,
"complement": self.use_complement and dict_full_data or "",
"data": self.use_complement and "" or dict_full_data,
"convertTo": output_file_extension,
"translations": dict_langs,
"lang": lang,
@@ -651,7 +697,17 @@ class IrActionsReportCarbone(models.Model):
else:
_logger.info("The PDF documents %r are now saved in the database", attachment_names)
def _render_carbone(self, report_ref, docids: str | list, data=None) -> tuple[bytes, str, str | bool]:
def _render_carbone(self, report_ref, docids: str | list, data=None) -> tuple[bytes, str]:
"""Odoo's generic `_render()` dispatches to `_render_<report_type>` and unpacks its
result as a 2-element tuple `(content, report_type)` (see e.g.
`mail.template._render_field()` which calls `ir.actions.report._render()` directly
for non qweb-pdf/html report types). Keep that contract here and expose the filename
separately through `_render_carbone_full()` for callers that need it (our own
controller)."""
content, _filename, extension = self._render_carbone_full(report_ref, docids, data=data)
return content, extension
def _render_carbone_full(self, report_ref, docids: str | list, data=None) -> tuple[bytes, str | bool, str]:
context = dict(self.env.context)
report_sudo = self._get_report(report_ref)
@@ -676,22 +732,21 @@ class IrActionsReportCarbone(models.Model):
streams_to_dl = list(stream_to_ids.keys())
if not context.get("from_ir_report_controller") or len(streams_to_dl) == 1:
stream = streams_to_dl[0]
pdf_content = stream.getvalue()
filename = stream_to_ids[stream][1]
extension = stream_to_ids[stream][2]
return pdf_content, extension, filename
pdf_content = streams_to_dl[0].getvalue()
# stream_to_ids[streams_to_dl[0]] contains [record_id, filename, extension]
filename = stream_to_ids[streams_to_dl[0]][1]
extension = stream_to_ids[streams_to_dl[0]][2]
return pdf_content, filename, extension
zip_content = _build_zip_from_data(stream_to_ids)
return zip_content, "zip", f"{report_sudo.name}.zip"
return zip_content, False, "zip"
def _retrieve_carbone_filename(self, records, output_file_extension: str) -> str:
self.ensure_one()
filename = f"{self.name}.{output_file_extension}"
if records:
if self.print_report_name and not len(records) > 1: # print_report_name is not mandatory
report_name = self._sanitize(safe_eval(self.print_report_name, {"object": records, "time": time}))
filename = f"{report_name}.{output_file_extension}"
if records and self.print_report_name and not len(records) > 1: # print_report_name is not mandatory
report_name = self._sanitize(safe_eval(self.print_report_name, {"object": records, "time": time}))
filename = f"{report_name}.{output_file_extension}"
return filename
@api.model
@@ -758,11 +813,6 @@ class IrActionsReportCarbone(models.Model):
}
)
def action_download_carbone_documentation(self):
ir_attachment_name = "Carbone_report_guide.pdf"
attachment_xml_id = "report_carbone.report_carbone_userguide_attachment"
return self.download_carbone_file(ir_attachment_name, attachment_xml_id, MODULE_NAME, RELATIVE_PATH_PDF)
def action_download_carbone_file_sample(self):
ir_attachment_name = "Demo_template_purchase_order.odt"
attachment_xml_id = "report_carbone.report_carbone_demo_purchase_order"
@@ -845,6 +895,132 @@ class IrActionsReportCarbone(models.Model):
response = self.call_carbone_endpoint("template", method="POST", files=files, data=data)
return {"template_id": response["data"]["id"], "file_extension": file_extension}
def _carbone_template_exists(self, template_id: str) -> bool | None:
"""Check whether a template is already known by Carbone.
Returns:
True — API responded and template found.
False — API responded but template not found (or API-level error).
None — API unreachable after all retry attempts.
Retries up to 3 times on network-level errors with exponential backoff
(1 s, 2 s) before giving up.
"""
self.ensure_one()
max_attempts = 3
for attempt in range(1, max_attempts + 1):
try:
res = self.call_carbone_endpoint(
"templates",
params={"search": template_id, "limit": 1, "includeVersions": "true"},
raise_exception=False,
)
return bool(res and res.get("data"))
except requests.exceptions.RequestException as exc:
_logger.warning(
"Carbone /templates unreachable (attempt %d/%d): %s",
attempt,
max_attempts,
exc,
)
if attempt < max_attempts:
time.sleep(2 ** (attempt - 1))
return None
def deploy_carbone_template(self, template_path: str, force: bool = False) -> str | bool:
"""Associate a Carbone template to this report at deployment time.
Idempotent "set once" helper, meant to be called from a deployment
script (``mangono_migration/post_<ticket>.py``):
* does nothing if no Carbone API key is configured;
* does nothing if the report already has a ``template_id`` (so it can
be replayed at every update without creating duplicates);
* otherwise computes the content-based template id; if that template
is already known by Carbone there is nothing to do, otherwise the
file is uploaded via ``call_carbone_endpoint`` with ``deployedAt``
so it is immediately available in production, and the returned id
is stored on the report. Setting ``template_id`` triggers
:meth:`write`, which fills ``file_extension`` from the Carbone API.
:param template_path: absolute path to the template file on disk.
:return: the Carbone template id, or ``False`` if skipped.
Example (from a ``mangono_migration`` post script)::
import os
from odoo.modules import get_module_path
from odoo.addons.mangono_migration.mangono_migration import migrate_mangono
# allowed_to_fail=True: an unreachable Carbone API must not block the update.
@migrate_mangono(allowed_to_fail=True)
def post_t1234(self):
report = self.env.ref("my_module.my_carbone_report")
path = os.path.join(get_module_path("my_module"), "data/templates/my_template.docx")
report.deploy_carbone_template(path)
"""
self.ensure_one()
# Garde A: an API key must be configured, otherwise we silently skip.
if not self.env["res.config.settings"].retrieve_carbone_api_key():
_logger.info("Carbone API key missing, skip template deployment for report %r", self.name)
return False
# Garde B: "set once" — never overwrite an existing association.
if self.template_id and not force:
return False
if not os.path.exists(template_path):
raise exceptions.UserError(_("Carbone template file not found: %s") % template_path)
csdk = self.get_carbone_sdk()
# SHA256 content hash — used only to check whether the template is already known by Carbone.
version_id = csdk.generate_template_id(template_path)
template_exists = self._carbone_template_exists(version_id)
if template_exists is None:
_logger.warning(
"Carbone API unreachable: could not check template existence for report %r (id=%s). "
"Deployment skipped, template_id unchanged.",
self.name,
self.id,
)
return False
if not template_exists:
with open(template_path, "rb") as f:
file_content = f.read()
files = {
"template": (
os.path.basename(template_path),
file_content,
mimetypes.guess_type(template_path)[0] or "application/octet-stream",
)
}
response = self.call_carbone_endpoint(
"template",
method="POST",
files=files,
data={
"name": os.path.basename(template_path),
"versioning": "true",
"deployedAt": TIMESTAMP_NOW,
},
)
# With versioning=True, data.id is the stable template_id (not a SHA256 version hash).
template_id = response["data"]["id"]
_logger.info("Carbone template %s uploaded and deployed for report %r", template_id, self.name)
else:
# Template already on Carbone: retrieve its stable template_id via the templates list.
res = self.call_carbone_endpoint(
"templates",
params={"search": version_id, "limit": 1, "includeVersions": "true"},
)
template_id = res["data"][0]["id"]
_logger.info("Carbone template %s already present on Carbone, nothing to upload", template_id)
# write() override fills file_extension from the Carbone API.
self.template_id = template_id
return template_id
def get_extension_file_from_api(self, template_id: str, raise_error=True) -> str | bool:
# If we are in install mode, for unit test for example, and we have to init a ir.actions.report from an XML
# file, we don't wan't to call Carbone's API to retrieve extension.
@@ -859,9 +1035,6 @@ class IrActionsReportCarbone(models.Model):
}
res = self.call_carbone_endpoint(endpoint, params, raise_error)
if not isinstance(res, dict):
return False
data_list = res.get("data")
if not data_list:
return False

View File

@@ -1,4 +1,4 @@
import xml.etree.ElementTree as ET # type: ignore # noqa: F401
import xml.etree.ElementTree as ET # type: ignore
from odoo import api, models

View File

@@ -17,7 +17,7 @@ class CarboneTranslate(models.Model):
carbone_translate_line_ids = fields.One2many(
"carbone.translate.line", "carbone_translate_id", string="Translation lines"
)
_sql_constraints = [
_sql_constraints = [ # noqa: RUF012
(
"lang_report_uniq",
"UNIQUE(ir_actions_report_id, lang_id)",

View File

@@ -11,7 +11,7 @@ class CarboneTranslateLine(models.Model):
)
source = fields.Text(string="Source term")
value = fields.Text(string="Translation Value", default="")
_sql_constraints = [
_sql_constraints = [ # noqa: RUF012
(
"source_value_uniq",
"UNIQUE(source, value, carbone_translate_id)",

View File

@@ -1,3 +1,5 @@
import os
from odoo import fields, models
@@ -14,12 +16,20 @@ class CarboneResConfigSettings(models.TransientModel):
return self.env["ir.actions.actions"]._for_xml_id("report_carbone.action_carbone_report_template_tree_all")
def retrieve_carbone_api_key(self, test_mode_key=False):
"""Depending on the ‘test mode’ checkbox or 'test_mode_key' parameter, either the production key or
the staging key is returned."""
"""Depending on the ‘test mode’ checkbox or ‘test_mode_key’ parameter, either the production key or
the staging key is returned.
The value set in the UI (ir.config_parameter) takes priority. If absent, the environment variable
CARBONE_STAGE_API_KEY or CARBONE_PROD_API_KEY is used as a fallback.
"""
stage_mode = self.env["ir.config_parameter"].sudo().get_param("report-engine.is_stage_mode")
if stage_mode or test_mode_key:
return self.env["ir.config_parameter"].sudo().get_param("report-engine.stage_api_key")
return self.env["ir.config_parameter"].sudo().get_param("report-engine.prod_api_key")
return self.env["ir.config_parameter"].sudo().get_param("report-engine.stage_api_key") or os.environ.get(
"CARBONE_STAGE_API_KEY"
)
return self.env["ir.config_parameter"].sudo().get_param("report-engine.prod_api_key") or os.environ.get(
"CARBONE_PROD_API_KEY"
)
def action_download_carbone_documentation(self):
ir_action_report = self.env["ir.actions.report"]

View File

@@ -1,9 +1,10 @@
[build-system]
requires = ["addon-odoo-wheel>=0.4.3"]
requires = ["addon-odoo-wheel>=0.4.4"]
build-backend = "addon_odoo_wheel.builder"
[tool.addon-odoo-wheel]
dependencies = [
"mangono-addon-export_json~=18.0",
"carbone_sdk~=1.0"
"carbone_sdk==1.1.1"
]
whl-exclude = ["docs"]

View File

@@ -279,6 +279,7 @@ class CarboneStudioManager {
}
return {
complement: JSON.parse(this.optionsList[0]),
data: JSON.parse(this.optionsList[0]),
translations: JSON.parse(this.optionsList[1]),
lang: this.optionsList[2],

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:

View File

@@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_open_carbone_documentation" model="ir.actions.act_url">
<field name="name">Open carbone documentation</field>
<field name="url">https://carbone.io/documentation/design/overview/getting-started.html</field>
<field name="target">new</field>
</record>
<record id="act_report_carbone_view" model="ir.ui.view">
<field name="name">act_report_carbone_view</field>
<field name="model">ir.actions.report</field>
@@ -21,9 +27,8 @@
class="oe_stat_button"
/>
<button
name="action_download_carbone_documentation"
name="%(action_open_carbone_documentation)d"
string="Documentation"
type="object"
icon="fa-file-pdf-o"
invisible="report_type != 'carbone'"
class="btn-secondary carbon_button_res_config"
@@ -93,6 +98,9 @@
<xpath expr="//field[@name='print_report_name']" position="after">
<field name="report_output_file_extension" invisible="report_type != 'carbone'"/>
</xpath>
<xpath expr="//page[@name='advanced']//group" position="inside">
<field name="use_complement"/>
</xpath>
<xpath expr="//page[@name='advanced']" position="after">
<page name="translations" string="Translations" invisible="report_type != 'carbone'">
<group>

View File

@@ -21,13 +21,6 @@
>
<block title="Carbone.io integration" name="carbone_integration">
<div class="content-group mt16">
<button
name="action_download_carbone_documentation"
string="Documentation"
type="object"
icon="fa-file-pdf-o"
class="btn-secondary me-2"
/>
<button
name="action_download_carbone_file_sample"
string="Report file example"