[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

@@ -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