[ADD] report_carbone, jsonifier, export_json : carbone is an alternative to Py3o

This commit is contained in:
2026-04-21 14:59:24 +02:00
parent ae3c34257f
commit c2061984d1
216 changed files with 29344 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from . import base
from . import res
from . import carbone

View File

@@ -0,0 +1,5 @@
from . import base
from . import ir_model
from . import ir_actions_report
from . import ir_exports
from . import exceptions

View File

@@ -0,0 +1,45 @@
import pytz
from odoo import api, fields, models
class Base(models.AbstractModel):
"""The base model, which is implicitly inherited by all models."""
_inherit = "base"
carbone_default_currency_id = fields.Many2one(
"res.currency", string="Default currency used in Carbone Report", compute="_compute_carbone_default_currency_id"
)
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():
currency = rec.currency_id
rec.carbone_default_currency_id = currency
@api.model
def _jsonify_value(self, field, value):
"""Overloading the OCA jsonifier library function :
- Datetime fields are displayed directly with the time zone set in context.
-The value of a Selection field's label is displayed, rather than the key stored in the database."""
if value is False and field.type != "boolean":
value = None
elif field.type == "date":
value = fields.Date.to_date(value).isoformat()
elif field.type == "datetime":
# Ensures value is a datetime
value = fields.Datetime.to_datetime(value)
expected_tz = self.env.context.get("tz") or self.env.user.tz
tz_pytz = pytz.timezone(expected_tz) if expected_tz else pytz.utc
value = pytz.utc.localize(value).astimezone(tz_pytz)
value = value.isoformat()
elif field.type in ("many2one", "reference"):
value = value.display_name if value else None
elif field.type in ("one2many", "many2many"):
value = [v.display_name for v in value]
elif field.type == "selection":
selection_list = field._description_selection(self.env)
value = dict(selection_list).get(value)
return value

View File

@@ -0,0 +1,11 @@
from odoo.exceptions import UserError
class MissingApiKeyError(UserError):
"""Missing Carbone API Keys error.
When you try to use Carbone API without correct key.
"""
def __init__(self, message):
super().__init__(message)

View File

@@ -0,0 +1,884 @@
import base64
import io
import json
import logging
import mimetypes
import os
import re
import zipfile
from collections import OrderedDict
from typing import Any
from urllib.parse import urljoin
import carbone_sdk
import pytz
import requests
from PIL import Image
from werkzeug import urls
from odoo import _, api, exceptions, fields, models, release
from odoo.modules import get_module_path
from odoo.tools.safe_eval import safe_eval, time
from odoo.addons.export_json.controller.main import JsonExportFormat
from ...const import ALLOWED_EXTENSIONS
from ...controllers.main import CarboneReportController
from .exceptions import MissingApiKeyError
_logger = logging.getLogger(__name__)
# put POSIX 'Etc/*' entries at the end to avoid confusing users - see bug 1086728
_tzs = [(tz, tz) for tz in sorted(pytz.all_timezones, key=lambda tz: tz if not tz.startswith("Etc/") else "_")]
MODULE_NAME = "report_carbone"
RELATIVE_PATH_PDF = "docs/carbone_userguide_v18.pdf"
RELATIVE_PATH_ODT = "data/demo_template_purchase.odt"
RELATIVE_PLACEHOLDERS_PATH = "data/placeholders"
# In Carbone API documentation, Values ≥ 42000000000 (year 3300) are treated as 'now'.
TIMESTAMP_NOW = 42000000000
def _tz_get(self):
return _tzs
def _build_zip_from_data(stream_to_ids: dict[Any, list]) -> bytes:
"""
:param stream_to_ids: dict { io.bytesIo object : [
int, str]}
:return: zip bytes
"""
buffer = io.BytesIO()
i = 1
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zipfile_obj:
for doc_data in stream_to_ids:
content = doc_data.getvalue()
file_name = stream_to_ids[doc_data][1]
zipfile_obj.writestr(f"{i}-{file_name}", content)
i += 1
return buffer.getvalue()
class IrActionsReportCarbone(models.Model):
_inherit = "ir.actions.report"
report_type = fields.Selection(selection_add=[("carbone", "Carbone Report")], ondelete={"carbone": "set default"})
lang_ids = fields.Many2many(
"res.lang",
string="Languages",
help="Language(s) into which the report must be "
"translated. Leave blank if no specific translation "
"is required.",
)
currency_id = fields.Many2one(
"res.currency",
string="Currency",
help="Leave blank to let Odoo manage the currency to use "
"(by default the currency of the associated template, "
"if there is a currency, otherwise the company currency).",
)
tz = fields.Selection(selection=_tzs, string="Timezone")
template_id = fields.Char(string="Carbone Template ID", default=None)
export_model = fields.Many2one(
"ir.exports",
string="Export Model",
help="Odoo export template used to retrieve information from a record when "
"printing the report. These are the same exports as on the Odoo “Export Data” pop-up.",
)
hide_create_update_button = fields.Boolean(
"Hide the 'Create/update export template' button", compute="_compute_hide_create_update_button"
)
jsonify_export = fields.Json(string="Export JSON", compute="_compute_all_jsonify_export")
jsonify_translate_export = fields.Json(string="Export translate JSON", compute="_compute_all_jsonify_export")
m2o_reference_id = fields.Many2oneReference(
string="Record for preview",
model_field="m2o_reference_model",
help="The data from the field record will be "
"used for document preview in Studio. The record ID will be used by default for the 'test generation' pop-up.",
)
m2o_reference_model = fields.Char(
string="Reference to the model for m2o_reference", compute="_compute_m2o_reference_model"
)
input_user_model_id = fields.Many2one("ir.model", string="Odoo model name", domain=[("transient", "=", False)])
carbone_translate_ids = fields.One2many(
"carbone.translate", "ir_actions_report_id", string="Translations available"
)
file_extension = fields.Char(string="File extension linked to Carbone Template ID", default="docx")
is_valid_template_id = fields.Boolean(
string="Is valid templateId",
help="True if template id is a templateId,false if it is a versionId",
compute="_compute_is_valid_template_id",
store=True,
)
partner_lang_path = fields.Char(
string="Path of the language field",
help="If specified, the report will be printed according to the language defined in the path. "
"For example, to print the report in the customer's language for a purchase order, specify partner_id.lang_id. "
"Leave blank to print the report in the first language specified in the 'Language' field.",
)
is_available_in_print_action = fields.Boolean(string="Enable", compute="_compute_is_available_in_print_action")
report_output_file_extension = fields.Char(
string="File extension of the generated document",
help="To be specified in order to generate a document in a format other than PDF "
"The output format must be included in the list of formats "
"supported by Carbone. You must provide a production API key and must not "
"be in test mode.",
)
@api.model
def _setup_template_id_and_extension(self, vals):
"""If a template_id is specified during creation, only the extension is retrieved via the Carbone API
and the studio is allowed to retrieve the report automatically.
If there is no template_id, a placeholder report is added to ir.actions.report so that the studio
can be set up without the user having to specify a template_id.
"""
if vals.get("template_id"):
new_file_extension = self.get_extension_file_from_api(vals.get("template_id"), raise_error=False)
vals.update({"file_extension": new_file_extension})
else:
try:
file_extension = vals.get("file_extension", "docx")
template_name = vals.get("name") or f"PlaceholderTemplate ({file_extension})"
new_vals = self.post_template_from_api(template_name, file_extension)
vals.update(new_vals)
except Exception as e:
raise exceptions.UserError(
_("An error occurred when uploading placeholder report Carbone : %s") % e
) from e
def check_report_output_file_extension(self, vals):
report_output_file_extension = vals.get("report_output_file_extension")
if not report_output_file_extension:
return
report_output_file_extension = report_output_file_extension.lower().lstrip(".")
if report_output_file_extension not in ALLOWED_EXTENSIONS:
raise exceptions.UserError(
_("Extension you entered is not included in the list of extensions supported by Carbone.")
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get("report_type") == "carbone":
if not vals.get("report_name"):
name = vals.get("name")
vals.update({"report_name": name})
self._setup_template_id_and_extension(vals)
return super().create(vals_list)
def write(self, vals):
if "template_id" in vals.keys():
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():
self.check_report_output_file_extension(vals)
return super().write(vals)
def _compute_all_jsonify_export(self):
"""This compute allows you to create data for the jsonify_export and jsonify_translate_export fields.
- A first step to retrieve a complete JSON of the data, based on the configured export.
This JSON also contains all available translations, based on the languages active in the tool.
- The previously retrieved JSON will be used to enhance the translation JSON, which is first built with all the
carbone.translate related to the report.
- Once the translation JSON has been modified, we define the jsonify_translate_export field.
- We can then extract the keys and values containing translations from the complete JSON.
"""
for rec in self:
dict_full_data = rec._get_jsonify_export()
dict_langs = rec._get_jsonify_translate_export()
# 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)
# The translations are now correctly configured.
rec.jsonify_translate_export = json.dumps(dict_langs, indent=4)
# The data no longer has the keys with all the translations for each language.
rec.jsonify_export = json.dumps(dict_full_data, indent=4)
def extract_translations(self, data, translations, path: str = ""):
sub_pattern = ""
lang = list(translations.keys())
lang_map = {}
for code in lang:
key = code.split("-")[1].upper()
lang_map.update({key: code})
sub_pattern += f"{key}|"
sub_pattern.strip("|")
translation_pattern = re.compile(r"^(.+)_(" + sub_pattern + ")$")
keys_to_remove = []
for key, value in data.items():
current_path = f"{path}/{key}" if path else key
match = translation_pattern.match(key)
if match:
base_key = match.group(1)
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
keys_to_remove.append(key)
elif isinstance(value, dict):
self.extract_translations(value, translations, current_path)
elif isinstance(value, list):
for _i, item in enumerate(value):
if isinstance(item, dict):
self.extract_translations(item, translations, current_path)
for key in keys_to_remove:
del data[key]
def _get_jsonify_export(self):
self.ensure_one()
if not self.export_model or not self.model or self.model not in self.env.registry or not self.m2o_reference_id:
return {}
else:
test_record = self.env[self.model].browse(self.m2o_reference_id)
export_json_instance = JsonExportFormat()
export_lines = self.export_model.export_fields
field_names = self._prepare_fields_name(export_lines)
lang_codes = self.lang_ids.mapped("code")
specific_lang = self.get_lang_to_use(test_record, parse_for_carbone=False)
if specific_lang not in lang_codes:
lang_codes.append(specific_lang)
json_data = export_json_instance.perform_json_export(
[], field_names, test_record.ids, self.env[self.model], lang_codes
)
# json_data is an str with a list of dict, we don't want to give user a list, just a dict.
json_list = json.loads(json_data)
dict_json = json_list and json_list[0] or json_list[:1]
return dict_json
def _get_jsonify_translate_export(self):
for rec in self:
all_langs = {}
available_langs = self.env["res.lang"].search([])
for lang in available_langs:
lang_code = lang.code.lower().replace("_", "-")
all_langs.update({lang_code: {}})
for carbone_translate in rec.carbone_translate_ids:
lang_code = carbone_translate.lang_id.code.lower().replace("_", "-")
lines = {}
for line in carbone_translate.carbone_translate_line_ids:
lines.update({line.source: line.value})
all_langs.update({lang_code: lines})
return all_langs
def _compute_hide_create_update_button(self):
for rec in self:
rec.hide_create_update_button = rec.get_hide_create_update_button_value()
def _compute_is_available_in_print_action(self):
for rec in self:
rec.is_available_in_print_action = rec.binding_model_id
@api.depends("model")
def _compute_m2o_reference_model(self):
for rec in self:
if rec.model not in self.env.registry:
rec.m2o_reference_model = "res.partner"
else:
rec.m2o_reference_model = rec.model
@api.depends("template_id")
def _compute_is_valid_template_id(self):
for rec in self:
if re.match(r"^[a-f0-9]{64}$", rec.template_id or ""):
rec.is_valid_template_id = False
else:
rec.is_valid_template_id = True
@api.onchange("model", "export_model", "report_type")
def onchange_hide_create_update_button(self):
self.hide_create_update_button = self.get_hide_create_update_button_value()
@api.onchange("input_user_model_id")
def onchange_user_model_id(self):
if self.report_type == "carbone" and self.input_user_model_id:
self.model = self.input_user_model_id.model
# We have to set a new record in the m2o_reference_id, because Odoo will try to display a record with
# current id (4 for example), from a existing model X, in a non-existing record with id 4 in model Y.
self.m2o_reference_id = self.env[self.model].search([], limit=1)
export_suffixe_name = self.input_user_model_id.display_name
if not self.export_model:
self.export_model = self.retrieve_global_export_model(self.model, export_suffixe_name)
@api.onchange("name")
def onchange_name(self):
"""For Carbon reports, the report_type field is not displayed.
It is not used when printing Carbon reports; it is filled in automatically."""
if self.report_type == "carbone":
self.report_name = self.name
def action_setup_carbone_studio_options(self):
"""Use to set-up JSON dicts (data, translations) for Carbone Studio, and retrieve lang in which
report will be rendered."""
self.ensure_one()
test_record = self.env[self.model].browse(self.m2o_reference_id)
lang_code = self.get_lang_to_use(test_record)
currency = self.get_currency_to_use(test_record)
timezone = self.env.context.get("tz") or self.env.user.tz
template = self.template_id
extension = self.file_extension
if not extension:
extension = self.get_extension_file_from_api(template, raise_error=False)
self.file_extension = extension
return {
"type": "ir.actions.client",
"tag": "copy_options_to_carbone",
"context": {
"record_id": self.id,
"json_data": self.jsonify_export,
"json_translate_data": self.jsonify_translate_export,
"lang": lang_code,
"timezone": timezone,
"currency": currency.name,
"template": template,
"extension": extension,
},
}
def action_refresh_carbone_studio(self):
self.ensure_one()
return {
"type": "ir.actions.client",
"tag": "action_refresh_carbone_studio",
}
def get_hide_create_update_button_value(self):
self.ensure_one()
res = False
group_viewer = "report_carbone.group_report_carbone_viewer"
if (
self.report_type != "carbone"
or (self.export_model and not self.export_model.is_global_export)
or (self.model not in self.env.registry)
or (self.env.user.has_groups(group_viewer))
):
res = True
return res
def get_currency_to_use(self, record=False) -> "odoo.model.res_currency":
self.ensure_one()
currency = record and record.carbone_default_currency_id or self.env.user.company_id.currency_id
if self.currency_id and self.currency_id != currency:
currency = self.currency_id
return currency
def _get_nested_field(self, record, field_path):
"""
Find the information in the field, based on the path entered, only if the path is valid.
"""
if not field_path:
return False
fields = field_path.split(".")
current = record
for field_name in fields:
if not current or field_name not in current._fields:
return False
current = current[field_name]
if hasattr(current, "_name") and not current:
return False
return current
def get_lang_to_use(self, record, parse_for_carbone=True) -> str:
"""
For language, the rules are as follows:
If the function call comes from the test report pop-up, we keep the language specified in the context.
Otherwise, if a language is specified in partner_lang_path, we retrieve the associated language.
Otherwise, we retrieve the first language specified in the lang_ids field.
If the language is still not specified, the language set in the context is retrieved, or else the user's
language.
:param record: "odoo.model.any"
:param parse_for_carbone: bool
:return: lang code (ex : fr_FR for French)
"""
self.ensure_one()
lang = ""
if self.env.context.get("from_print_by_action"):
lang = self.env.context.get("lang")
elif self.partner_lang_path:
lang = self._get_nested_field(record, self.partner_lang_path)
if not lang:
lang = self.lang_ids and self.lang_ids[0].code or self.env.context.get("lang") or self.env.user.lang
if parse_for_carbone:
return lang.lower().replace("_", "-")
return lang
def _prepare_fields_name(self, export_lines: "odoo.model.ir_exports_line") -> tuple[str]:
field_names = []
if "id" not in export_lines.mapped("name"):
field_names = ["id"]
for line in export_lines:
field_names.append(line.name)
return field_names
def check_required_fields(self):
"""Raise user error if template_id or export_model fields are missing."""
missing_field = []
if not self.template_id:
missing_field.append("Carbone Template ID")
if not self.export_model:
missing_field.append("export model")
if missing_field:
raise exceptions.UserError(f"Missing {', '.join(missing_field)} to generate this report")
def _get_parameters_for_render(self, context, record, parse_for_carbone=True) -> tuple:
"""Used to retrieve lang to translate report, timezone and currency, to render a
Carbone report."""
self.ensure_one()
lang = self.with_context(context).get_lang_to_use(record, parse_for_carbone)
currency_id = context.get("currency_id")
currency = self.env["res.currency"].browse(currency_id)
if not currency:
currency = self.get_currency_to_use(record)
tz = context.get("tz") or self.env.user.tz
return lang, tz, currency
def _check_no_test_mode_and_prod_api_key(self, extension: str):
"""A user error is raised if the user attempts to print a report in a format other than PDF,
and the system is in test mode and/or there is no production API key"""
self.ensure_one()
is_stage_mode = self.env["ir.config_parameter"].sudo().get_param("report-engine.is_stage_mode")
prod_api_key = self.env["ir.config_parameter"].sudo().get_param("report-engine.prod_api_key")
if extension != "pdf" and (is_stage_mode or not prod_api_key):
raise exceptions.UserError(
_(
"You cannot generate a document in any format other than "
"PDF in test mode and/or without a prod API key"
)
)
def get_report_output_file_extension(self) -> str:
self.ensure_one()
if self.report_output_file_extension:
self._check_no_test_mode_and_prod_api_key(self.report_output_file_extension)
return self.report_output_file_extension
return "pdf"
def get_default_user_agent(self) -> str:
default_user_agent = requests.utils.default_user_agent()
return f"{default_user_agent} Mangono Odoo v{release.version}"
def _retrieve_attachement(self, collected_streams, res_ids, has_duplicated_ids):
"""Copy of odoo/addons/base/models/ir_actions_report.py "_render_qweb_pdf_prepare_streams" function.
The only change was to retrieve the name of the associated
attachment when printing several reports at the same time
(we create one zip file with X files, rather than one file containing the information from X files).
"""
if res_ids:
records = self.env[self.model].browse(res_ids)
for record in records:
res_id = record.id
if res_id in collected_streams:
continue
stream = None
attachment = None
if not has_duplicated_ids and self.attachment and not self._context.get("report_pdf_no_attachment"):
attachment = self.retrieve_attachment(record)
# Extract the stream from the attachment.
if attachment and self.attachment_use:
stream = io.BytesIO(attachment.raw)
# Ensure the stream can be saved in Image.
if attachment.mimetype.startswith("image"):
img = Image.open(stream)
new_stream = io.BytesIO()
img.convert("RGB").save(new_stream, format="pdf")
stream.close()
stream = new_stream
filename = attachment and attachment.name or False
collected_streams[res_id] = {
"stream": stream,
"attachment": attachment,
"filename": filename,
}
return collected_streams
@api.model
def get_carbone_sdk(self) -> carbone_sdk.CarboneSDK:
access_token = self.env["res.config.settings"].retrieve_carbone_api_key()
if not access_token:
raise MissingApiKeyError(
_("No API Carbone key has been entered. Please enter it or contact your administrator.")
)
csdk = carbone_sdk.CarboneSDK(access_token)
csdk._api_headers.update({"User-Agent": self.get_default_user_agent()})
return csdk
def _get_json_data(self, export_json_instance, field_names, record, model, lang_codes):
json_data = export_json_instance.perform_json_export([], field_names, record.ids, self.env[model], lang_codes)
dict_full_data = json.loads(json_data)[0]
return dict_full_data
def _call_carbone_to_get_streams(self, all_res_ids_wo_stream: list, collected_streams: OrderedDict):
# Creation of Carbone and JsonExportFormat instances
csdk = self.get_carbone_sdk()
export_json_instance = JsonExportFormat()
# Recovery of the report model.
model = self.model
# Retrieving field_names from the report.
field_names = ["id"]
export_lines = self.export_model.export_fields
field_names.extend(line.name for line in export_lines)
context = dict(self.env.context)
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
for record in records:
try:
# We retrieve the translation language, time zone and currency.
lang, tz, currency = self._get_parameters_for_render(context, record, parse_for_carbone=False)
# We collect all the languages into which the report can be translated. And, if necessary,
# the target language is added to the list of possible translations for the report.
lang_codes = self.lang_ids.mapped("code")
if lang not in lang_codes:
lang_codes.append(lang)
# After that, we can transform lang code to match the Carbone language format.
lang = lang.lower().replace("_", "-")
# Creating the JSON file (data and translate).
dict_full_data = self._get_json_data(export_json_instance, field_names, record, model, lang_codes)
dict_langs = self._get_jsonify_translate_export()
# Modification by reference of dicts.
self.extract_translations(dict_full_data, dict_langs)
output_file_extension = self.get_report_output_file_extension()
tuple_pdf = csdk.render(
self.template_id,
{
"data": dict_full_data,
"convertTo": output_file_extension,
"translations": dict_langs,
"lang": lang,
"timezone": tz,
"currencySource": currency.name,
"currencyTarget": currency.name,
},
)
pdf_content_stream = io.BytesIO(tuple_pdf[0])
filename = self._retrieve_carbone_filename(record, output_file_extension)
collected_streams[record.id]["stream"] = pdf_content_stream
collected_streams[record.id]["filename"] = filename
collected_streams[record.id]["out_file_extension"] = output_file_extension
except Exception as e:
raise exceptions.UserError(
_("An error occurred when generating the report via Carbone : %s") % e
) from e
return collected_streams
def _render_carbone_prepare_streams(self, res_ids=None):
has_duplicated_ids = res_ids and len(res_ids) != len(set(res_ids))
collected_streams = OrderedDict()
if res_ids:
collected_streams = self._retrieve_attachement(collected_streams, res_ids, has_duplicated_ids)
res_ids_wo_stream = [res_id for res_id, stream_data in collected_streams.items() if not stream_data["stream"]]
all_res_ids_wo_stream = res_ids if has_duplicated_ids else res_ids_wo_stream
is_carbone_needed = not res_ids or res_ids_wo_stream
if is_carbone_needed:
collected_streams = self._call_carbone_to_get_streams(all_res_ids_wo_stream, collected_streams)
return collected_streams
def _render_carbone_handler_create_attachment(self, has_duplicated_ids, collected_streams):
"""Copy of odoo/addons/base/models/ir_actions_report.py _render_qweb_pdf() ir.attachment handler"""
# Generate the ir.attachment if needed.
if not has_duplicated_ids and self.attachment and not self._context.get("report_pdf_no_attachment"):
attachment_vals_list = self._prepare_pdf_report_attachment_vals_list(self, collected_streams)
if attachment_vals_list:
attachment_names = ", ".join(x["name"] for x in attachment_vals_list)
try:
self.env["ir.attachment"].create(attachment_vals_list)
except exceptions.AccessError:
_logger.info(
"Cannot save PDF report %r attachments for user %r",
attachment_names,
self.env.user.display_name,
)
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]:
context = dict(self.env.context)
report_sudo = self._get_report(report_ref)
report_sudo.env.context = context
# docids can be either a string, if the function call comes from
# a "Print" button, on a list the call does not come from the button.
res_ids = self.get_ids(docids)
has_duplicated_ids = res_ids and len(res_ids) != len(set(res_ids))
report_sudo.check_required_fields()
collected_streams = report_sudo._render_carbone_prepare_streams(res_ids)
report_sudo._render_carbone_handler_create_attachment(has_duplicated_ids, collected_streams)
stream_to_ids = {
v["stream"]: [k, v.get("filename", False), v.get("out_file_extension", False)]
for k, v in collected_streams.items()
if v["stream"]
}
streams_to_dl = list(stream_to_ids.keys())
if not context.get("from_ir_report_controller") or len(streams_to_dl) == 1:
pdf_content = streams_to_dl[0].getvalue()
# stream_to_ids[streams_to_dl[0]] contains [record_id, filename, extension]
extension = stream_to_ids[streams_to_dl[0]][2]
return pdf_content, extension
zip_content = _build_zip_from_data(stream_to_ids)
return zip_content, "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}"
return filename
@api.model
def _sanitize(self, name: str) -> str:
"""Avoid slash in filename, otherwise zip will create subdirectory."""
return name.replace("/", "_").replace(":", "_").replace("\\", "_").replace(" ", "_")
@api.model
def _get_report_from_name(self, report_name: str) -> "odoo.model.ir_actions_report":
"""Override to first search for an ir.actions.report by the "Carbone" report type and name,
before just searching by the report name. Allows you to have reports of different types,
"Carbone" and others."""
report_obj = self.env["ir.actions.report"]
domain = [
("report_type", "=", "carbone"),
("report_name", "=", report_name),
]
context = self.env["res.users"].context_get()
res = report_obj.with_context(context).sudo().search(domain, limit=1)
if not res:
return super()._get_report_from_name(report_name)
return res
@api.model
def get_ids(self, docids: str | list) -> list[int]:
if isinstance(docids, list):
return docids
return [int(x) for x in docids.split(",")]
@api.model
def check_model_in_registry(self, model_name: str):
if model_name not in self.env.registry:
raise exceptions.UserError(_("Error : Model not found in registry"))
def action_carbon_print_by_action_window(self):
self.ensure_one()
self.check_model_in_registry(self.model)
record = self.env[self.model].browse(self.m2o_reference_id)
context = self.env.context.copy()
if record:
context.update({"record_id": record.id, "currency_id": self.get_currency_to_use(record).id})
return {
"name": _("Test generation"),
"view_mode": "form",
"res_model": "carbone.print_by_action",
"type": "ir.actions.act_window",
"context": dict(context),
"target": "new",
}
def create_global_export(self, model_name: str, export_suffixe_name: str) -> "odoo.model.ir_exports":
fields = self.env["carbone.field.extractor"].get_fields_from_multiple_views(self.model)
to_create_ir_export_line = [(0, 0, {"name": field}) for field in fields]
to_create_ir_export_line.insert(0, (5, 0, 0))
return self.env["ir.exports"].create(
{
"name": f"Global Export For Carbone - {export_suffixe_name}",
"resource": model_name,
"export_fields": to_create_ir_export_line,
"is_global_export": True,
}
)
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"
return self.download_carbone_file(ir_attachment_name, attachment_xml_id, MODULE_NAME, RELATIVE_PATH_ODT)
def download_carbone_file(
self, ir_attachment_name: str, attachment_xml_id: str, module_name: str, relative_path: str
) -> dict:
attachment = self.env.ref(attachment_xml_id, raise_if_not_found=False)
if not attachment:
attachment = self.env["ir.attachment"].search([("name", "=", ir_attachment_name)], limit=1)
if not attachment:
try:
module_path = get_module_path(module_name)
pdf_path = os.path.join(module_path, relative_path)
with open(pdf_path, "rb") as pdf_file:
pdf_content = pdf_file.read()
except Exception as e:
raise exceptions.UserError(_("Unable to read file: %s") % str(e)) from e
datas = base64.b64encode(pdf_content)
attachment = self.env["ir.attachment"].create(
{
"name": ir_attachment_name,
"datas": datas,
"public": True,
}
)
return {
"type": "ir.actions.act_url",
"target": "new",
"url": f"/web/content/{attachment.id}",
}
def retrieve_global_export_model(self, model_name: str, export_suffixe_name: str) -> "odoo.model.ir_exports":
"""Retrieve or create a global export model, when user change Odoo model from ir.actions.report."""
global_export = self.env["ir.exports"].search(
[("resource", "=", model_name), ("is_global_export", "=", True)], limit=1
)
if not global_export:
return self.create_global_export(model_name, export_suffixe_name)
return global_export
def button_create_update_ir_export(self):
if self.model not in self.env.registry or not self.input_user_model_id:
return
global_export = self.env["ir.exports"].search(
[("resource", "=", self.model), ("is_global_export", "=", True)], limit=1
)
if not global_export:
export_suffixe = self.input_user_model_id.display_name
self.export_model = self.create_global_export(self.model, export_suffixe)
else:
carbone_extractor = self.env["carbone.field.extractor"]
last_update_fields = carbone_extractor.get_fields_from_multiple_views(self.model)
old_fields = global_export.export_fields.mapped("name")
if last_update_fields == old_fields:
return
carbone_extractor.update_current_global_export(global_export, last_update_fields, old_fields)
@api.model
def post_template_from_api(self, template_name: str, file_extension: str) -> dict:
module_path = get_module_path(MODULE_NAME)
relative_file_path = f"{RELATIVE_PLACEHOLDERS_PATH}/template.{file_extension}"
file_path = os.path.join(module_path, relative_file_path)
with open(file_path, "rb") as f:
file_content = f.read()
files = {
"template": (
os.path.basename(file_path),
file_content,
mimetypes.guess_type(file_path)[0] or "application/octet-stream",
)
}
data = {
"name": template_name,
"versioning": "true",
"deployedAt": TIMESTAMP_NOW,
}
response = self.call_carbone_endpoint("template", method="POST", files=files, data=data)
return {"template_id": response["data"]["id"], "file_extension": file_extension}
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.
if self.env.context.get("install_mode"):
return ".docx"
endpoint = "templates"
params = {
"search": template_id,
"limit": 1,
"includeVersions": "true",
}
res = self.call_carbone_endpoint(endpoint, params, raise_error)
data_list = res.get("data")
if not data_list:
return False
return data_list[0].get("type")
def call_carbone_endpoint(self, endpoint: str, params=None, raise_exception=True, method="GET", **kwargs):
api_token = self.env["res.config.settings"].retrieve_carbone_api_key()
if not api_token:
if raise_exception:
raise MissingApiKeyError(
_("No API Carbone key has been entered. Please enter it or contact your administrator.")
)
else:
return False
api_endpoint = self.env["ir.config_parameter"].sudo().get_param("report-engine.carbone_studio_url")
response = requests.Response
# We have to specified carbone-version 5
headers = {"Authorization": "Bearer " + api_token, "carbone-version": "5"}
url = f"{api_endpoint}/{endpoint}"
if params:
url = urljoin(url, "?" + urls.url_encode(params))
if method == "GET":
response = requests.get(url, headers=headers, **kwargs)
elif method == "POST":
response = requests.post(url, headers=headers, **kwargs)
handled_response = CarboneReportController.handle_response(response, raise_exception=raise_exception)
return handled_response

View File

@@ -0,0 +1,59 @@
from odoo import fields, models
class IrExportsCarbone(models.Model):
_inherit = "ir.exports"
is_global_export = fields.Boolean(string="Global Export")
class IrExportsLineCarbone(models.Model):
_inherit = "ir.exports.line"
field_label = fields.Char(string="Field label", help="Name render in model view", compute="_compute_field_label")
def _get_fields_model_data(self, model_name: str):
model = self.env[model_name]
return model.fields_get(attributes=["string", "relation"])
def _get_fields_model_data(self, model_name: str):
model = self.env[model_name]
return model.fields_get(attributes=["string", "relation"])
def _get_field_label_recursive(self, field_path, current_model):
"""
Recursively retrieves the label of a field for relations
:param field_path: Field path (ex: 'company_id/name' ou 'partner_id/country_id/name')
:param current_model: Current model name
:return: Field label
"""
path_parts = field_path.split("/")
fields = self._get_fields_model_data(current_model)
current_field = path_parts[0]
field_info = fields.get(current_field)
if not field_info:
return field_path
if len(path_parts) == 1:
return field_info.get("string", current_field)
relation_model = field_info.get("relation")
if not relation_model:
return field_path
remaining_path = "/".join(path_parts[1:])
return self._get_field_label_recursive(remaining_path, relation_model)
def _compute_field_label(self):
for rec in self:
field_label = ""
current_model = self.export_id.resource
fields = self._get_fields_model_data(current_model)
if rec.name and "/" in rec.name:
field_label = self._get_field_label_recursive(rec.name, current_model)
else:
field_get_information_field = fields.get(rec.name)
if field_get_information_field:
field_label = field_get_information_field.get("string")
rec.field_label = field_label

View File

@@ -0,0 +1,12 @@
from odoo import api, models
class IrModelCarbone(models.Model):
_inherit = "ir.model"
@api.depends("name", "model")
def _compute_display_name(self):
if not self.env.context.get("carbone_report_display_name"):
return super()._compute_display_name()
for rec in self:
rec.display_name = f"{rec.name} - {rec.model}"

View File

@@ -0,0 +1,5 @@
from . import carbone_print_by_action
from . import carbone_field_extractor
from . import carbone_create_report_wizard
from . import carbone_translate
from . import carbone_translate_line

View File

@@ -0,0 +1,72 @@
from odoo import _, api, exceptions, fields, models
class CarbonCreateReportWizard(models.TransientModel):
_name = "carbone.create.report.wizard"
_description = "Create Carbone report wizard helper"
report_type_extension = fields.Selection(
string="Report extension",
selection=[("docx", ".docx"), ("pptx", ".pptx"), ("xlsx", ".xlsx")],
help="Leave empty if you already have a existing Carbone template for this report, then set the template ID",
)
template_id = fields.Char(
string="Carbone Template ID", help="Leave empty if you don't have any existing template for this report"
)
input_user_model_id = fields.Many2one(
"ir.model", string="Odoo model name", domain=[("transient", "=", False)], required=True
)
action_name = fields.Char(string="Action name", required=True)
@api.constrains("template_id", "report_type_extension")
def _check_template_xor_extension(self):
"""Users are not allowed to enter both the extension of their report and a Carbone Template ID.
This is because if the user enters an extension, it is to create a report from scratch, and we mock a call
to Carbone to properly set up the studio.
If the user enters a Carbone Template ID, we will rely exclusively on that, without making a mock call
to Carbone.
If the user enters a Carbone Template ID, we will rely exclusively on that, without making
a mock call."""
for record in self:
has_template = bool(record.template_id)
has_extension = bool(record.report_type_extension)
if has_template == has_extension:
raise exceptions.ValidationError(_("You must specify either a template or an extension, but not both."))
def action_create_carbone_report(self):
export_suffixe_name = self.input_user_model_id.display_name
new_carbone_report = self.env["ir.actions.report"].create(
{
"name": self.action_name,
"input_user_model_id": self.input_user_model_id.id,
"model": self.input_user_model_id.model,
"template_id": self.template_id,
"report_type": "carbone",
"file_extension": self.report_type_extension,
"m2o_reference_id": self.env[self.input_user_model_id.model].search([], limit=1).id,
}
)
export_model = new_carbone_report.retrieve_global_export_model(
self.input_user_model_id.model, export_suffixe_name
)
new_carbone_report.export_model = export_model
view_id = self.env.ref("report_carbone.act_report_carbone_view").id
return {
"type": "ir.actions.act_window",
"res_model": "ir.actions.report",
"view_mode": "form",
"view_id": view_id,
"views": [(view_id, "form")],
"res_id": new_carbone_report.id,
"target": "current",
"context": {
"active_model": "ir.actions.report",
"active_id": new_carbone_report.id,
"active_ids": [new_carbone_report.id],
"default_report_type": "carbone",
},
}

View File

@@ -0,0 +1,196 @@
import xml.etree.ElementTree as ET # type: ignore # noqa: F401
from odoo import api, models
FIELDS_RECURSION_LIMIT = 0
EXCLUDED_FIELDS = [
"id",
"create_uid",
"create_date",
"write_uid",
"write_date",
"display_name",
"message_ids",
"activity_ids",
"activity_user_id",
"message_partner_ids",
"activity_type_id",
"default_user_id",
]
class CarboneFieldExtractor(models.Model):
_name = "carbone.field.extractor"
_description = "Field extractor for exports based on views"
@api.model
def get_exportable_fields_from_view(self, model_name, view_type):
"""
Retrieves exportable fields from a view (priority to form/list)
:param model_name: Model name
:param view_type: Type of view ('list', 'form', 'search')
:return: List of exportable fields with their info
"""
target_model = self.env[model_name]
view_info = target_model.get_view(view_type=view_type)
root = ET.fromstring(view_info["arch"])
exportable_fields = []
processed_fields = set()
for field_elem in root.iter("field"):
field_name = field_elem.get("name")
if field_name and field_name not in processed_fields and field_name in target_model._fields:
field_obj = target_model._fields[field_name]
if self._is_field_exportable(field_obj):
fields_info = self._get_field_export_info(field_name, field_obj, field_elem)
exportable_fields.extend(fields_info)
processed_fields.add(field_name)
return exportable_fields
@api.model
def _is_field_exportable(self, field_obj):
if field_obj.name in EXCLUDED_FIELDS:
return False
return getattr(field_obj, "exportable", True)
@api.model
def _get_field_export_info(self, field_name, field_obj, field_elem):
"""
Retrieves export information for a field
"""
field_info = field_name
sub_fields = []
if field_obj.type in ["one2many", "many2many"]:
sub_fields = self._extract_o2m_m2m_fields(field_elem, field_obj.comodel_name, field_name)
sub_fields.extend([field_info])
return sub_fields
@api.model
def _get_sub_field_info(self, field_name, comodel_name):
"""
Retrieves the name of a subfield
"""
target_model = self.env[comodel_name]
if field_name not in target_model._fields:
return None
field_obj = target_model._fields[field_name]
if not self._is_field_exportable(field_obj):
return None
return field_name
@api.model
def _get_inline_view_fields(self, field_elem, comodel_name):
"""
Recovers fields from inline views (list/form in the field XML)
"""
inline_fields = []
for child_elem in field_elem:
if child_elem.tag in ("list", "form"):
for sub_field_elem in child_elem.iter("field"):
sub_field_name = sub_field_elem.get("name")
if sub_field_name:
field_info = self._get_sub_field_info(sub_field_name, comodel_name)
if field_info:
inline_fields.append(field_info)
break # Prendre la première vue trouvée
return inline_fields
@api.model
def _extract_o2m_m2m_fields(self, field_elem, comodel_name, parent_field_name):
"""
Extracts the O2M/M2M relationship fields from inline views
:param field_elem: XML element of the O2M/M2M field
:param comodel_name: Target model name
:param parent_field_name: Parent field name
:return: List of subfields with their information
"""
sub_fields = []
inline_fields = self._get_inline_view_fields(field_elem, comodel_name)
if not inline_fields:
return sub_fields
sub_fields = [f"{parent_field_name}/{sub_field_info}" for sub_field_info in inline_fields]
return sub_fields
def clean_fields(self, fields):
"""Allows you to remove occurrences from fields that are displayed with subfields
(order_line/name, order_line/product_id, and order_line)..
If 'order_line' is not removed, it is the only thing Odoo will display."""
prefixes = {f.split("/")[0] for f in fields if "/" in f}
result = [f for f in fields if not (f in prefixes and any(ff.startswith(f + "/") for ff in fields))]
return result
@api.model
def get_fields_from_multiple_views(self, model_name, view_types=None):
if view_types is None:
view_types = ["list", "form"]
all_fields = []
for view_type in view_types:
fields = self.get_exportable_fields_from_view(model_name, view_type)
for field_info in fields:
if field_info not in all_fields:
all_fields.append(field_info)
clean_fields = self.clean_fields(all_fields)
if not clean_fields:
return self.fallback_get_fields(model_name)
return clean_fields
def update_current_global_export(
self, export: "odoo.model.ir_exports", old_fields: list[str], newer_fields: list[str]
):
added = [field for field in old_fields if field not in newer_fields]
removed = [field for field in newer_fields if field not in old_fields]
if removed:
to_unlink = self.env["ir.exports.line"].search([("export_id", "=", export.id), ("name", "in", removed)])
to_unlink.unlink()
if added:
vals_list = [{"name": name, "export_id": export.id} for name in added]
self.env["ir.exports.line"].create(vals_list)
def fallback_get_fields(self, model_name: str, depth: int = FIELDS_RECURSION_LIMIT, prefix: str = "") -> list[str]:
"""
Used to retrieve all fields that can be exported from a model
:param model_name: Model to export field
:param depth: Maximum recursion number
⚠ Be careful to not call it with an excessif number, field number grows exponentially
(tested with purchase.order model with default depth, it gives 76 fields, in depth=1, it increases
to 1800 fields.
:param prefix: Must be left empty. Use for recursion call, to have a complete field's parent name.
:return: Fields name to export
"""
object_model = self.env[model_name]
result = []
fields = object_model.fields_get(
attributes=["type", "required", "relation", "exportable"],
)
for field_name, field in fields.items():
if field.get("exportable") and field_name not in EXCLUDED_FIELDS:
full_name = f"{prefix}{field_name}" if not prefix else f"{prefix}/{field_name}"
result.append(full_name)
if depth > 0 and field.get("type") in ("many2one", "many2many"):
related_model = field.get("relation")
nested_fields = self.fallback_get_fields(related_model, depth=depth - 1, prefix=full_name)
result.extend(nested_fields)
return result

View File

@@ -0,0 +1,76 @@
import logging
from odoo import _, api, exceptions, fields, models
from ..base.ir_actions_report import _tz_get
_logger = logging.getLogger(__name__)
class CarbonReportPrintByAction(models.TransientModel):
_name = "carbone.print_by_action"
_description = "Print by action"
@api.model
def _get_model(self):
rep_obj = self.env["ir.actions.report"]
report = rep_obj.browse(self.env.context["active_ids"])
return report[0].model
@api.model
def _get_id_record(self):
id = self.env.context.get("record_id")
return id
@api.model
def _get_currency_id(self):
id = self.env.context.get("currency_id")
return id
name = fields.Text(string="Object Model", default=_get_model, readonly=True)
id_object = fields.Integer(string="Object ID", default=_get_id_record)
lang_id = fields.Many2one(
"res.lang",
string="Language",
default=lambda self: self.env["res.lang"].search([("code", "=", self.env.user.lang)], limit=1),
help="If this option is enabled, the language in which the report is printed",
)
currency_id = fields.Many2one("res.currency", string="Currency", default=_get_currency_id)
tz = fields.Selection(_tz_get, string="Timezone", default=lambda self: self.env.user.tz or "UTC")
def to_print(self):
rep_obj = self.env["ir.actions.report"]
report = rep_obj.browse(self.env.context["active_id"])[0]
ctx = dict(self.env.context)
print_ids = self.env[self.name].browse(self.id_object)
if not print_ids:
raise exceptions.UserError(_("No record is retrieve with this id."))
# report_pdf_no_attachment context key
# forces the system not to retrieve an attachment saved in the database.
ctx.update(
{
"active_id": print_ids[0],
"active_ids": print_ids.ids,
"active_model": report.model,
"lang": self.lang_id.code,
"tz": self.tz,
"currency_id": self.currency_id.id,
"from_print_by_action": True,
"report_pdf_no_attachment": True,
}
)
data = {
"model": report.model,
"id": print_ids[0],
"ids": print_ids,
"report_type": "carbone",
}
res = {
"type": "ir.actions.report",
"report_name": report.report_name,
"report_type": report.report_type,
"datas": data,
"context": ctx,
"target": "current",
}
return res

View File

@@ -0,0 +1,67 @@
from odoo import fields, models
class CarboneTranslate(models.Model):
_name = "carbone.translate"
_description = "Carbone Translate"
_rec_name = "ir_actions_report_id"
ir_actions_report_id = fields.Many2one(
"ir.actions.report", string="Carbone Report", required=True, ondelete="cascade"
)
lang_id = fields.Many2one(
"res.lang",
string="Language",
required=True,
)
carbone_translate_line_ids = fields.One2many(
"carbone.translate.line", "carbone_translate_id", string="Translation lines"
)
_sql_constraints = [
(
"lang_report_uniq",
"UNIQUE(ir_actions_report_id, lang_id)",
"A report cannot have two translations for the same language.",
)
]
def _create_translation_lines(
self,
carbone_translate_to_maj: "odoo.model.carbone_translate",
translation_lines: "odoo.model.carbone_translate_line",
):
existing_sources = carbone_translate_to_maj.mapped("carbone_translate_line_ids.source")
lines = [
{
"source": current_translate_line.source,
"value": "",
"carbone_translate_id": carbone_translate_to_maj.id,
}
for current_translate_line in translation_lines
if current_translate_line.source not in existing_sources
]
self.env["carbone.translate.line"].create(lines)
def button_create_update_copy_of_translate(self):
self.ensure_one()
create_vals = []
report_translate_langs = self.ir_actions_report_id.carbone_translate_ids.mapped("lang_id")
available_languages = self.ir_actions_report_id.lang_ids
# If there is no language at all, we will create one, with the right keys.
for lang in available_languages:
if lang not in report_translate_langs:
vals = {"lang_id": lang.id, "ir_actions_report_id": self.ir_actions_report_id.id}
lines = [
(0, 0, {"source": translate_line.source, "value": ""})
for translate_line in self.carbone_translate_line_ids
]
vals.update({"carbone_translate_line_ids": lines})
create_vals.append(vals)
else:
carbone_translate_to_maj = self.ir_actions_report_id.carbone_translate_ids.filtered_domain(
[("lang_id", "=", lang.id)]
)
self._create_translation_lines(carbone_translate_to_maj, self.carbone_translate_line_ids)
if create_vals:
self.env["carbone.translate"].create(create_vals)

View File

@@ -0,0 +1,27 @@
from odoo import fields, models
class CarboneTranslateLine(models.Model):
_name = "carbone.translate.line"
_description = "Carbone Translate Line"
_rec_name = "source"
carbone_translate_id = fields.Many2one(
"carbone.translate", string="Carbone Translate", required=True, ondelete="cascade"
)
source = fields.Text(string="Source term")
value = fields.Text(string="Translation Value", default="")
_sql_constraints = [
(
"source_value_uniq",
"UNIQUE(source, value, carbone_translate_id)",
"A report cannot have two translations for the same value.",
)
]
def write(self, vals):
# We replace occurrences of the value 'False'
# with a null string. Carbone does not interpret 'false' in translations.
if "value" in vals and not vals.get("value"):
vals["value"] = ""
return super().write(vals)

View File

@@ -0,0 +1 @@
from . import res_config_settings

View File

@@ -0,0 +1,30 @@
from odoo import fields, models
class CarboneResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
carbone_studio_url = fields.Char("Carbone Studio URL", config_parameter="report-engine.carbone_studio_url")
carbone_js_file_url = fields.Char("Carbone JS file URL", config_parameter="report-engine.carbone_js_file_url")
is_stage_mode = fields.Boolean(string="Test mode", config_parameter="report-engine.is_stage_mode")
prod_api_key = fields.Char(string="Prod API Key", config_parameter="report-engine.prod_api_key")
stage_api_key = fields.Char(string="Test API Key", config_parameter="report-engine.stage_api_key")
def open_ir_actions_reports(self):
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."""
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")
def action_download_carbone_documentation(self):
ir_action_report = self.env["ir.actions.report"]
return ir_action_report.action_download_carbone_documentation()
def action_download_carbone_file_sample(self):
ir_action_report = self.env["ir.actions.report"]
return ir_action_report.action_download_carbone_file_sample()