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