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