[IMP] report_carbone : bump to version 18.0.1.1.4
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user