[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

1
export_json/README.md Normal file
View File

@@ -0,0 +1 @@
JSON Export system for Odoo Community edition.

47
export_json/README.rst Normal file
View File

@@ -0,0 +1,47 @@
.. image:: https://img.shields.io/badge/license-LGPL--3-blue.svg
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
JSON Export
===========
JSON Export system for Odoo 18 Community edition.
Configuration
=============
No configuration
Company
-------
* `Mangono <https://mangono.fr/>`__
License
-------
General Public License, Version 3 (LGPL v3).
(http://www.gnu.org/licenses/lgpl-3.0-standalone.html)
Credits
-------
Developer: (V18) Mangono , Contact: contact@mangono.fr
Contacts
--------
* Mail Contact : contact@mangono.fr
* Website : https://mangono.fr
Bug Tracker
-----------
In case of trouble, please contact us via our email address.
Maintainer
==========
.. image:: static/description/assets/logo/mangono-logo-bleu.png
:scale: 10 %
:target: https://mangono.fr
This module is maintained by Mangono.
For support and more information, please visit `Our Website <https://mangono.fr/>`__
Further information
===================
HTML Description: `<static/description/index.html>`__

2
export_json/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from . import models
from . import controller

View File

@@ -0,0 +1,20 @@
{
"name": "JSON Export",
"version": "18.0.1.0.5",
"category": "Extra Tools",
"license": "AGPL-3",
"summary": "Add JSON export to all models similar to XLSX or CSV exports.",
"author": "Mangono",
"maintainers": "Mangono",
"support": "contact@mangono.fr",
"description": """
JSON Export
===========
see README
""",
"website": "https://mangono.fr/",
"depends": ["base", "web", "jsonifier"],
"data": [],
"installable": True,
"images": ["static/description/banner.png"],
}

View File

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

View File

@@ -0,0 +1,432 @@
import json
import logging
import operator
from typing import Any
import requests
from odoo import api, http, models
from odoo.http import content_disposition, request
from odoo.tools import json_default, osutil
from odoo.addons.web.controllers.export import (
Export,
ExportFormat,
)
_logger = logging.getLogger(__name__)
class JsonExport(Export):
@http.route("/web/export/formats", type="json", auth="user")
def formats(self):
res = super().formats()
res.append({"tag": "json", "label": "JSON"})
return res
class JsonExportFormat(ExportFormat, http.Controller):
@http.route("/web/export/json", type="http", auth="user")
def index(self, data: str) -> "requests.Response":
return self.base(data)
@property
def content_type(self) -> str:
"""
:return: str, json format
"""
return "text/json;charset=utf8"
@property
def extension(self) -> str:
"""
Return extension for file, that will be generated later.
:return: json extension
"""
return ".json"
@api.model
def format_data(self, fields) -> str:
for field in fields:
for key, value in field.items():
if type(value) is bytes:
field.update({key: "data:image/png;base64," + value.decode("utf-8")})
return json.dumps(fields, indent=4, default=json_default)
@api.model
def _insert_simple_field(self, fields_name: list, field_groups: dict, parser: list[dict]):
"""
This function is used to add "simple" field like "partner_id", "name", in the parser that will be export.
Field name like "partner_id/child_ids, partner_id/child_ids/name" will be processed in another method.
:param fields_name: list of field name to export, display like ["partner_id", "partner_id/child_ids", ...].
:param field_groups: The dict is not return
:param parser: list of key's name field, define in a specific way
(exemple in function "define_parser" docstring).
:return: None, but "field_groups" and "parser" parameters are modified by side effect.
"""
for field in fields_name:
name = field["name"]
if "/" in name:
parts = name.split("/")
main_field = parts[0]
subfield = "/".join(parts[1:])
if main_field not in field_groups:
field_groups[main_field] = []
field_groups[main_field].append({"name": subfield})
else:
parser.append(name)
@api.model
def _insert_nested_field(self, field_groups, parser):
for main_field, subfields in field_groups.items():
if any("/" in subfield["name"] for subfield in subfields):
nested_fields = [subfield for subfield in subfields]
nested_parser = self.define_parser(nested_fields)
parser.append((main_field, nested_parser))
else:
sub_names = [subfield["name"] for subfield in subfields]
parser.append((main_field, sub_names))
@api.model
def define_parser(self, fields_name: list[dict[str, str]]) -> tuple[str | dict[str, Any]]:
"""
Args:
:param fields_name: list of dictionaries with two or three keys: 'label', 'name' which are field names
and optionally 'type', that is not use.
:return: list of field names to export. Example of parser:
parser = [
'name',
'number',
'create_date',
('partner_id', ['id', 'display_name', 'ref'])
('line_id', ['id', ('product_id', ['name']), 'price_unit'])
]
"""
parser = []
field_groups = {}
self._insert_simple_field(fields_name, field_groups, parser)
self._insert_nested_field(field_groups, parser)
return parser
def base(self, data: str) -> "requests.Response":
params = json.loads(data)
model, fields, ids, domain, import_compat = operator.itemgetter(
"model", "fields", "ids", "domain", "import_compat"
)(params)
Model = request.env[model].with_context(import_compat=import_compat, **params.get("context", {}))
response_data = self.perform_json_export(domain, fields, ids, Model)
return request.make_response(
response_data,
headers=[
(
"Content-Disposition",
content_disposition(osutil.clean_filename(self.filename(model) + self.extension)),
),
("Content-Type", self.content_type),
],
)
@api.model
def _convert_simple_list(self, fields_name) -> list[dict[str, str]]:
"""
Use to convert a list of fields names that it is not a dict with 'name' key
:param fields_name: list of field names, either str or dict.
:return: converted list of field names in dict{'name' : 'field_name'}.
"""
converted_fields_name = []
for element in fields_name:
if type(element) is not dict:
element_to_append = {"name": element}
else:
element_to_append = element
converted_fields_name.append(element_to_append)
return converted_fields_name
# region merge_lang_dict
@api.model
def process_keys(
self,
all_keys: set,
dicts_at_idx: dict,
merged_dict: dict,
parent_key: str = False,
):
"""
Traite un ensemble de clés en regroupant leurs valeurs depuis plusieurs dictionnaires par langue.
La méthode itère sur toutes les clés fournies et collecte leurs valeurs correspondantes
depuis différents dictionnaires organisés par langue. Pour chaque clé, on rassemble
les valeurs disponibles dans chaque langue et les transmet à "process_key_values" pour
un traitement ultérieur.
"""
for key in all_keys:
key_values = {}
for lang, d in dicts_at_idx.items():
if key in d:
key_values[lang] = d[key]
self.process_key_values(key, key_values, merged_dict, parent_key)
@api.model
def process_key_values(
self,
current_key: str,
key_values: dict,
merged_item: dict,
parent_key: str = False,
):
if key_values:
first_val = list(key_values.values())[0]
item_added = self.add_list_or_dict_on_merge_item(
current_key, first_val, key_values, merged_item, parent_key
)
if not item_added:
self.add_lang_key(current_key, first_val, key_values, merged_item, parent_key)
@api.model
def add_list_or_dict_on_merge_item(
self,
current_key,
current_val: list | dict,
values_by_lang: dict,
merged_item: dict,
parent_key: str = False,
) -> bool:
"""Permets de différencier l'ajout de dict ou de list dans le dictionnaire final.
Le paramètre merged_item est modifié par référence.
Retourne un boolean qui permet de ne pas continuer le process d'ajouts de données,
si la valeur courante a déjà été ajoutée.
"""
# Si la clé courante et la clé parente sont définies, qu'elles ne sont pas identiques,
# et que la valeur courante est un dict (ou une list) ?
# Alors la clé parente devient la concaténation de la clé parente et courante,
# étant donnée que nous sommes dans des dicts imbriquées
if parent_key and current_key and parent_key != current_key and isinstance(current_val, dict):
parent_key = f"{parent_key}/{current_key}"
else:
parent_key = parent_key or current_key
# Gérer les listes de dictionnaires
if isinstance(current_val, list):
if current_val and isinstance(current_val[0], dict):
merged_item[current_key] = self._merge_list_of_dicts(values_by_lang, parent_key)
return True
# Gérer les dictionnaires imbriqués
elif isinstance(current_val, dict):
merged_item[current_key] = self._merge_nested_dict(values_by_lang, parent_key)
return True
return False
@api.model
def retrieve_all_keys(self, items: dict) -> set:
assert all(isinstance(v, dict) for v in items.values()), "All values must be dictionaries."
all_keys = set()
for vals in items.values():
all_keys.update(vals.keys())
return all_keys
@api.model
def add_unique_vals(self, key_values: dict) -> set:
"""Vérifier si toutes les valeurs sont identiques"""
unique_values = set()
for val in key_values.values():
if isinstance(val, list | dict):
unique_values.add(str(val))
else:
unique_values.add(val)
return unique_values
@api.model
def get_country_code(self, lang_code):
if "_" in lang_code:
return lang_code.split("_")[1]
else:
return lang_code
@api.model
def add_lang_key(
self,
current_key: str,
first_val: str | list,
key_values: dict,
merged_dict: dict,
parent_key: str = False,
):
"""Permets d'ajouter au dict final, soit une valeur avec une clé unique, s'il n'existe pas
de différence dans les dict de chaque langue, ou bien une clé par lang, s'il y a des différences.
"""
# Vérifier si toutes les valeurs sont identiques
unique_values = self.add_unique_vals(key_values)
if len(unique_values) <= 1:
# Valeurs identiques
merged_dict[current_key] = first_val
else:
# On ajoute un ensemble {key : val}, avec pour key la clé courante sans ajout, et pour valeur,
# le chemin de la key.
if parent_key:
merged_dict[current_key] = f"{parent_key}/{current_key}"
else:
merged_dict[current_key] = current_key
# Valeurs différentes, créer des clés avec suffixes, qui serviront provisoirement à stocker les traductions
# de la clé
for lang, val in key_values.items():
lang_suffix = self.get_country_code(lang)
merged_dict[f"{current_key}_{lang_suffix}"] = val
@api.model
def merge_multilingual_dicts(self, multilingual_data: dict) -> list:
"""
Fusionne les dictionnaires de différentes langues en un seul dictionnaire.
Pour chaque clé:
- Si toutes les valeurs sont identiques -> garde la clé originale
- Si les valeurs diffèrent -> crée des clés suffixées par le code langue
- Si la valeur est une liste de dicts -> fusionne récursivement
- Si la valeur est un dict -> fusionne récursivement
"""
languages = list(multilingual_data.keys())
if not languages:
return []
base_lang = languages[0]
base_data = multilingual_data[base_lang]
merged_data = []
for idx, base_item in enumerate(base_data):
merged_item = {}
# Récupérer tous les éléments correspondants dans les autres langues
lang_items = {base_lang: base_item}
for lang in languages[1:]:
if idx < len(multilingual_data[lang]):
lang_items[lang] = multilingual_data[lang][idx]
# Analyser chaque clé
all_keys = self.retrieve_all_keys(lang_items)
# Récupérer les valeurs pour cette clé dans toutes les langues
self.process_keys(all_keys, lang_items, merged_item)
merged_data.append(merged_item)
return merged_data
@api.model
def _merge_nested_dict(self, values_by_lang: dict, parent_key: str = False) -> dict:
"""
Fusionne des dictionnaires imbriqués provenant de différentes langues.
Args:
parent_key: str, key parent du dict imbriqué
values_by_lang: dict {lang_code: dict}
Returns:
dict: dictionnaire fusionné
"""
merged_dict = {}
# Récupérer toutes les clés possibles
all_keys = self.retrieve_all_keys(values_by_lang)
# Pour chaque clé, comparer les valeurs
for key in all_keys:
key_values = {}
for lang, d in values_by_lang.items():
if isinstance(d, dict) and key in d:
key_values[lang] = d[key]
if key_values:
# Modification du paramètre merged_dict par référence
first_val = list(key_values.values())[0]
self.add_lang_key(key, first_val, key_values, merged_dict, parent_key)
return merged_dict
@api.model
def _merge_list_of_dicts(self, values_by_lang: dict, parent_key: str = False) -> list[dict]:
"""
Fusionne des listes de dictionnaires provenant de différentes langues.
Args:
values_by_lang: dict {lang_code: [dict1, dict2, ...]}
parent_key : str
Returns:
list: liste fusionnée de dictionnaires
"""
languages = list(values_by_lang.keys())
if not languages:
return []
# Utiliser la première langue comme référence pour la longueur
base_lang = languages[0]
base_list = values_by_lang[base_lang]
merged_list = []
# Pour chaque index dans la liste
for idx in range(len(base_list)):
merged_dict = {}
# Récupérer tous les dictionnaires à cet index
dicts_at_idx = {}
for lang in languages:
if idx < len(values_by_lang[lang]):
dicts_at_idx[lang] = values_by_lang[lang][idx]
# Récupérer toutes les clés possibles
all_keys = self.retrieve_all_keys(dicts_at_idx)
self.process_keys(all_keys, dicts_at_idx, merged_dict, parent_key)
merged_list.append(merged_dict)
return merged_list
# endregion merge_lang_dict
def perform_json_export(
self,
domain: list[list[Any]],
field_names: list[str],
ids: list[int],
model: models.BaseModel,
langs_code: list[str] = False,
) -> str:
"""
Method to prepare and format json data for the export.
Args:
:param domain: condition (domain) needed for the search method
:param field_names: list of dictionaries with two keys : 'label' and 'name' which are field names
:param ids: list with ids of selected records
:param model: model on which the export is done
:param langs_code: languages code
:return: string with json_data
"""
if not model._is_an_ordinary_table():
field_names = [field for field in field_names if field != "id"]
field_names = self._convert_simple_list(field_names)
records = model.browse(ids) if ids else model.search(domain, offset=0, limit=False, order=False)
parser = self.define_parser(field_names)
if not langs_code:
langs_code = [request.env.context.get("lang")] or [request.env.user.lang]
tz = request.env.context.get("tz") or request.env.user.tz
if len(langs_code) == 1:
result = records.with_context(lang=langs_code[0], tz=tz).jsonify(parser)
return self.format_data(result)
result = {}
for lang in langs_code:
result[lang] = records.with_context(lang=lang, tz=tz).jsonify(parser)
merged_list = self.merge_multilingual_dicts(result)
response_data = self.format_data(merged_list)
return response_data

View File

@@ -0,0 +1,25 @@
= Guide d'utilisation de l'export JSON
== Présentation de la fonctionnalité
L'export JSON permet, comme son nom l'indique, d'exporter les données d'un record en format JSON.
Ce type d'export peut être utilisé par exemple si le connecteur Carbone.io est utilisé, car il a besoin de recevoir les données à intégrer au document sous le format JSON.
== Utilisation de la fonctionnalité
- Dans l'interface Odoo, aller sur une vue Liste > sélectionner au moins un record :
image::images/process_export1.png[]
- Ensuite, cliquer sur _Action_ -> _Exporter_ , cette action ouvre un popup :
image::images/export_json2.png[]
image::images/export_json3_popup_export.png[]
- Sélectionner le format d'exportation de type _JSON_ , insérer tous les champs à exporter, créer un modèle d'export et le sauvegarder :
image::images/export_json_popup_export2.png[]
- Cliquer sur _Exporter_ , le fichier en format JSON est téléchargé.

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

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

View File

@@ -0,0 +1,20 @@
import json
from odoo import models
from odoo.addons.base_import import models as model_import
model_import.base_import.FILE_TYPE_DICT["application/json"] = ("json", True, None)
model_import.base_import.EXTENSIONS[".json"] = True
class BaseImportJSON(models.TransientModel):
_inherit = "base_import.import"
def _read_json(self, record, options):
items = json.loads(record.file)
if items:
headers = items[0].keys()
yield headers
for item in items:
yield [item[header] for header in headers]

View File

@@ -0,0 +1,8 @@
[build-system]
requires = ["addon-odoo-wheel>=0.4.2"]
build-backend = "addon_odoo_wheel.builder"
[tool.addon-odoo-wheel]
dependencies = [
"odoo-addon-jsonifier~=18.0",
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,3 @@
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="12" cy="12" r="11" stroke="#01FFCC" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 193 B

View File

@@ -0,0 +1,3 @@
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="9.5" cy="9.5" r="9.5" fill="#01FFCC"/>
</svg>

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JSON Export - Odoo v18</title>
<!-- <link rel="stylesheet" href="/export_json/static/src/css/description.css">-->
</head>
<body>
<div class="container" style="font-family: 'Inter', sans-serif; line-height: 1.7; color: #001E2B;
background-color: #ffffff; max-width: 1200px; margin: 0 auto; padding: 20px;">
<!-- Header -->
<div class="header"
style="background-color: #001e2b; color: #f5f3ed; padding: 80px 60px; margin-bottom: 60px; text-align: center;">
<table class="header-title-table" style="width: 100%; margin-bottom: 20px;">
<tr>
<td class="header-title-left"
style="text-align: right; vertical-align: middle; padding-right: 40px;">
<h1 class="header-title"
style="color: #01FFCC; font-size: 3em; font-weight: 700; letter-spacing: -0.02em; margin: 0;">
JSON Export
</h1>
</td>
<td class="header-title-right" style="text-align: left; vertical-align: middle;">
<img src="assets/logo/mangono-logo.png" alt="Mangono Logo" class="header-logo"
style="width: 300px; max-width: 100%; object-fit: contain;">
</td>
</tr>
</table>
<span class="header-badge"
style="display: inline-block; border: 1px solid rgba(245,243,237,0.3); padding: 10px 24px; margin: 10px 8px; font-size: 0.85em; letter-spacing: 0.05em; text-transform: uppercase; color: #f5f3ed;">
Free Module
</span>
<span class="header-badge"
style="display: inline-block; border: 1px solid rgba(245,243,237,0.3); padding: 10px 24px; margin: 10px 8px; font-size: 0.85em; letter-spacing: 0.05em; text-transform: uppercase; color: #f5f3ed;">
Odoo v18
</span>
</div>
<!-- Description -->
<div class="section" style="margin-bottom: 80px;">
<div class="section-title-wrapper" style="margin-bottom: 40px;">
<div class="section-title-line"
style="background-color: #01ffcc; height: 16px; width: 310px; margin-bottom: 12px;"></div>
<h2 class="section-title"
style="color: #001E2B; font-size: 2.2em; font-weight: 700; letter-spacing: -0.01em; margin: 0; border-bottom: 1px solid #001E2B; padding-bottom: 15px;">
Description
</h2>
<img src="assets/svg/green-dot.svg" alt="green-dot" class="section-title-dot"
style="display: block; margin-top: 8px;">
</div>
<p class="section-text" style="font-size: 1.1em; line-height: 1.8;">
<strong>JSON Export</strong>
is a module that adds the JSON option as an export format inside Odoos native export tool.
</p>
</div>
<!-- Screenshots Section -->
<div class="section" style="margin-bottom: 80px;">
<div class="section-title-wrapper" style="margin-bottom: 40px;">
<div class="section-title-line"
style="background-color: #01ffcc; height: 16px; width: 310px; margin-bottom: 12px;"></div>
<h2 class="section-title"
style="color: #001E2B; font-size: 2.2em; font-weight: 700; letter-spacing: -0.01em; margin: 0; border-bottom: 1px solid #001E2B; padding-bottom: 15px;">
Screenshot
</h2>
<img src="assets/svg/green-dot.svg" alt="green-dot" class="section-title-dot"
style="display: block; margin-top: 8px;">
</div>
<div class="screenshot-wrapper" style="text-align: center; margin: 30px 0;">
<img src="assets/screenshots/pop-up-export.png" class="screenshot-image"
style="width: 100%; max-width: 100%; object-fit: contain;">
</div>
</div>
<!-- Installation -->
<div class="section" style="margin-bottom: 80px;">
<div class="section-title-wrapper" style="margin-bottom: 40px;">
<div class="section-title-line"
style="background-color: #01ffcc; height: 16px; width: 310px; margin-bottom: 12px;"></div>
<h2 class="section-title"
style="color: #001E2B; font-size: 2.2em; font-weight: 700; letter-spacing: -0.01em; margin: 0; border-bottom: 1px solid #001E2B; padding-bottom: 15px;">
Installation &amp; Configuration
</h2>
<img src="assets/svg/green-dot.svg" alt="green-dot" class="section-title-dot"
style="display: block; margin-top: 8px;">
</div>
<h3 style="color: #001E2B; font-size: 1.4em; margin-top: 40px; margin-bottom: 20px; font-weight: 500;">
1. Installation
</h3>
<p>Install the module from the Odoo Store.</p>
<h3 style="color: #001E2B; font-size: 1.4em; margin-top: 40px; margin-bottom: 20px; font-weight: 500;">
2. Configuration
</h3>
<p>No specific configuration required.</p>
<h3 style="color: #001E2B; font-size: 1.4em; margin-top: 40px; margin-bottom: 20px; font-weight: 500;">
3. Usage
</h3>
<p>When using Odoos export feature, the “JSON” option will appear in “Export Format”.</p>
</div>
<!-- Technical Specifications -->
<div class="section" style="margin-bottom: 80px;">
<div class="section-title-wrapper" style="margin-bottom: 40px;">
<div class="section-title-line"
style="background-color: #01ffcc; height: 16px; width: 310px; margin-bottom: 12px;"></div>
<h2 class="section-title"
style="color: #001E2B; font-size: 2.2em; font-weight: 700; letter-spacing: -0.01em; margin: 0; border-bottom: 1px solid #001E2B; padding-bottom: 15px;">
Technical Specifications
</h2>
<img src="assets/svg/green-dot.svg" alt="green-dot" class="section-title-dot"
style="display: block; margin-top: 8px;">
</div>
<div class="tech-specs"
style="background-color: #ffffff; padding: 30px; margin-top: 30px; border: 1px solid #e8e6df;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 15px 0; border-bottom: 1px solid #e8e6df; font-weight: 500; width: 240px;">
Odoo Version
</td>
<td style="padding: 15px 0; border-bottom: 1px solid #e8e6df;">18.0</td>
</tr>
<tr>
<td style="padding: 15px 0; border-bottom: 1px solid #e8e6df; font-weight: 500;">
Dependencies
</td>
<td style="padding: 15px 0; border-bottom: 1px solid #e8e6df;">base, web, jsonifier</td>
</tr>
<tr>
<td style="padding: 15px 0; border-bottom: 1px solid #e8e6df; font-weight: 500;">Category
</td>
<td style="padding: 15px 0; border-bottom: 1px solid #e8e6df;">Extra Tools</td>
</tr>
<tr>
<td style="padding: 15px 0; font-weight: 500;">License</td>
<td style="padding: 15px 0;">AGPL-3</td>
</tr>
</table>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,187 @@
/* =========================
GLOBAL
========================= */
body {
font-family: "Inter", sans-serif;
line-height: 1.7;
color: #001e2b;
background-color: #ffffff;
}
a {
color: #01ffcc;
}
.container {
font-family: "Inter", sans-serif;
line-height: 1.7;
color: #001e2b;
background-color: #ffffff;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* =========================
HEADER
========================= */
.header {
background-color: #001e2b;
color: #f5f3ed;
padding: 80px 60px;
margin-bottom: 60px;
text-align: center;
}
.header-title-table {
width: 100%;
margin-bottom: 20px;
}
.header-title-left {
text-align: right;
vertical-align: middle;
padding-right: 40px;
}
.header-title-right {
text-align: left;
vertical-align: middle;
}
.header-title {
color: #01ffcc;
font-size: 3em;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
}
.header-logo {
width: 300px;
max-width: 100%;
object-fit: contain;
}
/* =========================
BADGES
========================= */
.header-badge {
display: inline-block;
border: 1px solid rgba(245, 243, 237, 0.3);
padding: 10px 24px;
margin: 10px 8px;
font-size: 0.85em;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #f5f3ed;
}
/* =========================
SECTIONS
========================= */
.section {
margin-bottom: 80px;
}
/* =========================
SECTION TITLES PATTERN B
========================= */
.section-title-wrapper {
margin-bottom: 40px;
}
.section-title-line {
background-color: #01ffcc;
height: 16px;
width: 310px;
margin-bottom: 12px;
}
.section-title {
color: #001e2b;
font-size: 2.2em;
font-weight: 700;
letter-spacing: -0.01em;
margin: 0;
border-bottom: 1px solid #001e2b;
padding-bottom: 15px;
}
.section-title-dot {
display: block;
margin-top: 8px;
}
/* =========================
TEXT
========================= */
.section-text {
font-size: 1.1em;
line-height: 1.8;
}
/* =========================
SCREENSHOT
========================= */
.screenshot-wrapper {
text-align: center;
margin: 30px 0;
}
.screenshot-image {
width: 100%;
max-width: 100%;
object-fit: contain;
}
/* =========================
SUBTITLES
========================= */
.section h3 {
color: #001e2b;
font-size: 1.4em;
margin-top: 40px;
margin-bottom: 20px;
font-weight: 500;
}
/* =========================
TECH SPECS
========================= */
.tech-specs {
background-color: #ffffff;
padding: 30px;
margin-top: 30px;
border: 1px solid #e8e6df;
}
.tech-specs table {
width: 100%;
border-collapse: collapse;
}
.tech-specs td {
padding: 15px 0;
border-bottom: 1px solid #e8e6df;
font-size: 0.95em;
}
.tech-specs td:first-child {
font-weight: 500;
color: #001e2b;
width: 240px;
}
.tech-specs td:last-child {
color: #5a5a5a;
}

View File

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

View File

@@ -0,0 +1,821 @@
import json
import re
from unittest.mock import MagicMock, patch
from odoo.tests import common, tagged
from odoo.addons.export_json.controller import main as controller_module
from odoo.addons.export_json.controller.main import JsonExportFormat
@tagged("export_json")
class TestExportJson(common.HttpCase):
def setUp(self):
self.partner = self.env["res.partner"].create(
{
"name": "TEST",
"email": "test@test.fr",
"type": "contact",
"child_ids": [
(
0,
None,
{
"name": "test child 1",
"email": "test_child_1@test.fr",
"type": "delivery",
},
),
(
0,
None,
{
"name": "test child 2",
"email": "test_child_2@test.fr",
"type": "invoice",
},
),
],
}
)
self.data_dict = {
"model": "res.partner",
"fields": [
{"name": "id", "label": "External ID"},
{"name": "name", "label": "Name"},
{"name": "email", "label": "Email"},
{"name": "type", "label": "Type"},
{"name": "child_ids/name", "label": "Name"},
{"name": "child_ids/email", "label": "Email"},
{"name": "child_ids/type", "label": "Type"},
],
"ids": [self.partner.id],
"domain": [["type", "in", ("contact", "delivery")]],
"groupby": [],
"context": {
"lang": "en_US",
"tz": "Europe/Paris",
"uid": 2,
"allowed_company_ids": [1],
},
"import_compat": True,
}
self.basic_expect_dict = {
"id": self.partner.id,
"name": "TEST",
"email": "test@test.fr",
"type": "contact",
"child_ids": [
{
"name": "test child 1",
"email": "test_child_1@test.fr",
"type": "delivery",
},
{
"name": "test child 2",
"email": "test_child_2@test.fr",
"type": "invoice",
},
],
}
self.expected_list_dict_merge = [
{
"amount_total": 300.0,
"amount_untaxed": 250.0,
"company_id": "My Company",
"currency_id": "EUR",
"invoice_status": "invoice_status",
"invoice_status_FR": "Rien à facturer",
"invoice_status_US": "Nothing to Bill",
"order_line": [
{
"company_id": "My Company",
"currency_id": "EUR",
"display_type": "Section",
"name": "Exmple de section",
"state": "order_line/state",
"state_FR": "Bon de commande fournisseur",
"state_US": "Purchase Order",
"tax_calculation_rounding_method": "order_line/tax_calculation_rounding_method",
"tax_calculation_rounding_method_FR": "Arrondir à la ligne",
"tax_calculation_rounding_method_US": "Round per Line",
"taxes_id": [],
},
{
"company_id": "My Company",
"currency_id": "EUR",
"name": "Produit test 2",
"price_subtotal": 75.0,
"price_unit": 75.0,
"product_id": "order_line/product_id",
"product_id_FR": "Produit test 2",
"product_id_US": "ENGLISH Produit test 2",
"product_type": "order_line/product_type",
"product_type_FR": "Biens",
"product_type_US": "Goods",
"product_uom": "order_line/product_uom",
"product_uom_FR": "Unité(s)",
"product_uom_US": "Units",
"product_uom_category_id": "order_line/product_uom_category_id",
"product_uom_category_id_FR": "Unité",
"product_uom_category_id_US": "Unit",
"state": "order_line/state",
"state_FR": "Bon de commande fournisseur",
"state_US": "Purchase Order",
"tax_calculation_rounding_method": "order_line/tax_calculation_rounding_method",
"tax_calculation_rounding_method_FR": "Arrondir à la ligne",
"tax_calculation_rounding_method_US": "Round per Line",
"taxes_id": ["20%"],
},
],
"payment_term_id": "payment_term_id",
"payment_term_id_FR": "21 jours ",
"payment_term_id_US": "21 Days",
"tax_country_id": "tax_country_id",
"tax_country_id_FR": "États-Unis",
"tax_country_id_US": "United States",
}
]
self.data_str = str(self.data_dict)
self.data = json.dumps(self.data_dict)
self.json_export_format = JsonExportFormat()
self.mock_request = MagicMock()
self.mock_request.env = self.env
super().setUp()
def helper_get_csrf_token(self):
resp = self.url_open("/web/login")
html = resp.content.decode()
match = re.search(r'csrf_token:\s*"([^"]+)"', html)
return match and match.group(1) or False
def test_generate_export_json(self):
# Perform the json export
fields = [field["name"] for field in self.data_dict["fields"]]
model = self.env[self.data_dict["model"]]
with patch.object(controller_module, "request", self.mock_request):
result_json = self.json_export_format.perform_json_export(
self.data_dict["domain"], fields, self.data_dict["ids"], model
)
dict_result_json = json.loads(result_json)
self.assertDictEqual(
self.basic_expect_dict,
dict_result_json[0],
"Check if json export is equal to expected data",
)
def test_perform_json_export_with_lang(self):
self.env["res.lang"]._activate_lang("fr_FR")
self.data_dict["fields"].append({"name": "title/name", "label": "Title"})
self.data_dict["fields"].append({"name": "country_id/name", "label": "Country"})
fields = [field["name"] for field in self.data_dict["fields"]]
model = self.env[self.data_dict["model"]]
# Ajouts de traductions
title = (
self.env["res.partner.title"]
.with_context(lang="en_US")
.create(
{
"name": "Doctor",
}
)
)
title.with_context(lang="fr_FR").name = "Docteur"
self.partner.title = title
self.env.ref("base.es").with_context(lang="fr_FR").name = "Espagne"
self.partner.country_id = self.env.ref("base.es")
with patch.object(controller_module, "request", self.mock_request):
result_json = self.json_export_format.perform_json_export(
self.data_dict["domain"],
fields,
self.data_dict["ids"],
model,
langs_code=["fr_FR", "en_US"],
)
dict_result_json = json.loads(result_json)
expected_dict = {
"id": self.partner.id,
"name": "TEST",
"email": "test@test.fr",
"type": "contact",
"title": {
"name": "title/name",
"name_FR": "Docteur",
"name_US": "Doctor",
},
"country_id": {
"name": "country_id/name",
"name_FR": "Espagne",
"name_US": "Spain",
},
"child_ids": [
{
"name": "test child 1",
"email": "test_child_1@test.fr",
"type": "delivery",
},
{
"name": "test child 2",
"email": "test_child_2@test.fr",
"type": "invoice",
},
],
}
self.assertDictEqual(expected_dict, dict_result_json[0], "Le json doit contenir les traductions")
def test_convert_simple_list(self):
"""La fonction _convert_simple_list prend en entrée une liste de clée, correspondant
à des noms de champs, et retourne une liste de dict{"name": nom_de_champ}"""
res_partner_fields_names = list(self.partner._fields.keys())
res = self.json_export_format._convert_simple_list(res_partner_fields_names)
self.assertEqual(
len(res_partner_fields_names),
len(res),
"Tous les noms de champs du modèle res.partner doivent être retourné sous la forme {'name': nom_de_champ}",
)
# Si on ajoute un dict dans les noms de champ du res_partner, le dict doit être retourné tel quel, lors
# du passage de la fonction _convert_simple_list
mock_dict = {
"random_key": "random_value",
"random_key1": "random_value1",
"random_key2": "random_value2",
}
res_partner_fields_names.append(mock_dict)
res = self.json_export_format._convert_simple_list(res_partner_fields_names)
self.assertEqual(len(res_partner_fields_names), len(res))
self.assertIn(
mock_dict,
res,
"Le dict ajouté dans les champs de res.partner est bien retourner telquel dans la list",
)
def test_get_country_code(self):
"""La fonction prend un paramètre un string de lang, elle doit retourner la deuxième partie du code
(séparé par le premier '_' du string)"""
for test_val, expected in [
("fr_FR", "FR"),
("_fr_FR", "fr"),
("FR_", ""),
("", ""),
]:
self.assertEqual(self.json_export_format.get_country_code(test_val), expected)
def test_add_unique_vals(self):
"""La fonction prend en paramètre un dict, et retourne un set de valeur unique.
La fonction stringify les list et les dicts, si dans le dict d'entrée, des valeurs sont
des dicts ou des lists."""
mock_dict_same_value = {
"random_key": "random_value",
"random_key1": "random_value",
"random_key2": "random_value",
}
res = self.json_export_format.add_unique_vals(mock_dict_same_value)
self.assertEqual(
len(res),
1,
"Le dict d'entrée contenait 3 clés valeurs, mais les valeurs liées aux clés étaient identiques.",
)
self.assertIn("random_value", res)
mock_dict_different_value = {
"random_key": "random_value",
"random_key1": "random_value1",
"random_key2": "random_value",
}
res = self.json_export_format.add_unique_vals(mock_dict_different_value)
self.assertEqual(len(res), 2)
for expected in ["random_value", "random_value1"]:
self.assertIn(expected, res)
mock_dict_with_dict_list = {
"random_key": {"an_other": "one"},
"random_key1": [10, 20, 40],
"random_key2": [10, 20, 40],
}
res = self.json_export_format.add_unique_vals(mock_dict_with_dict_list)
self.assertEqual(len(res), 2)
for expected in ["{'an_other': 'one'}", "[10, 20, 40]"]:
self.assertIn(expected, res)
self.assertEqual(0, len(self.json_export_format.add_unique_vals({})))
def test_retrieve_all_keys(self):
test_dict = {
"fr_FR": {
"champ_1": "valeur",
"champ_2": "valeur",
"champ_3": "valeur",
"champ_4": "valeur",
},
"zn_CN": {
"champ_5": "valeur",
"champ_6": "valeur",
"champ_3": "valeur",
"champ_4": "valeur",
},
}
res = self.json_export_format.retrieve_all_keys(test_dict)
for expected in [
"champ_1",
"champ_2",
"champ_3",
"champ_4",
"champ_5",
"champ_6",
]:
self.assertIn(expected, res)
other_dict = {
"champ_1": "valeur",
"champ_2": "valeur",
"champ_3": "valeur",
}
with self.assertRaises(AssertionError):
self.json_export_format.retrieve_all_keys(other_dict)
def test_process_keys_without_parent_key(self):
test_dict = {
"fr_FR": {
"champ_1": "valeur",
"champ_2": "valeur",
"champ_3": "valeur",
"champ_4": "valeur",
},
"zn_CN": {
"champ_5": "valeur",
"champ_6": "valeur",
"champ_3": "valeur",
"champ_4": "valeur",
},
}
merged_dict = {}
all_keys = self.json_export_format.retrieve_all_keys(test_dict)
self.json_export_format.process_keys(all_keys, test_dict, merged_dict)
for expected in [
"champ_1",
"champ_2",
"champ_3",
"champ_4",
"champ_5",
"champ_6",
]:
self.assertIn(
expected,
merged_dict,
"Chaque champ doit être inclus dans le dictionnaire final, qui fusionne"
"l'ensemble des champs des dictionnaires d'entrées, par langue.",
)
self.json_export_format.process_keys(all_keys, test_dict, merged_dict)
# Si pour un même nom de champ donné, la valeur est différente entre des langues, les deux clés concaténées
# du suffixe du code la langue,
# doivent être présent dans le dict final.
test_dict_2 = {
"fr_FR": {
"champ_1": "valeur",
"champ_2": "valeur",
"champ_3": "VALEUR EN FRANÇAIS",
"champ_4": "valeur",
},
"zn_CN": {
"champ_5": "valeur",
"champ_6": "valeur",
"champ_3": "VALEUR PAS EN FRANÇAIS",
"champ_4": "valeur",
},
}
self.json_export_format.process_keys(all_keys, test_dict_2, merged_dict)
for expected in [
"champ_1",
"champ_2",
"champ_3",
"champ_4",
"champ_5",
"champ_6",
"champ_3_FR",
"champ_3_CN",
]:
self.assertIn(
expected,
merged_dict,
"Chaque champ doit être inclus dans le dictionnaire final, qui fusionne"
"l'ensemble des champs des dictionnaires d'entrées, par langue.",
)
self.assertEqual(merged_dict.get("champ_3_FR"), "VALEUR EN FRANÇAIS")
self.assertEqual(merged_dict.get("champ_3_CN"), "VALEUR PAS EN FRANÇAIS")
def test_process_keys_with_parent_key(self):
test_dict = {
"fr_FR": {
"champ_1": "valeur",
"champ_2": "valeur",
"champ_3": {
"cle_dict_imbrique_1": "valeur1",
"cle_dict_imbrique_2": "valeur2",
"cle_dict_imbrique_3": [
"element_liste_imbriqueFR",
"element_autre_listeFR",
],
},
},
"zn_CN": {
"champ_1": "valeur",
"champ_2": "valeur",
"champ_3": {
"cle_dict_imbrique_1": "valeur1489",
"cle_dict_imbrique_2": "valeur24996",
"cle_dict_imbrique_3": [
"element_liste_imbrique",
"element_autre_liste",
],
},
},
}
merged_dict = {}
all_keys = self.json_export_format.retrieve_all_keys(test_dict)
self.json_export_format.process_keys(all_keys, test_dict, merged_dict, "champ_3")
# Le dict attendu doit contenir toutes les valeurs de chaque langues, même si les valeurs sont eux mêmes dans
# des dict ou dans des listes. Un regroupement doit être effectué autour du champ_3 :
# le dict du champ_3 doit contenir la clé original de chaque dict (cle_dict_imbrique_1, cle_dict_imbrique_2,
# cle_dict_imbrique_3)
# avec pour valeurs le chemin absolu de la valeur (champ_3/cle_dict_imbrique_1, champ_3/cle_dict_imbrique_2,
# champ_3/cle_dict_imbrique_3)
expected_dict = {
"champ_1": "valeur",
"champ_2": "valeur",
"champ_3": {
"cle_dict_imbrique_1": "champ_3/cle_dict_imbrique_1",
"cle_dict_imbrique_1_CN": "valeur1489",
"cle_dict_imbrique_1_FR": "valeur1",
"cle_dict_imbrique_2": "champ_3/cle_dict_imbrique_2",
"cle_dict_imbrique_2_CN": "valeur24996",
"cle_dict_imbrique_2_FR": "valeur2",
"cle_dict_imbrique_3": "champ_3/cle_dict_imbrique_3",
"cle_dict_imbrique_3_CN": [
"element_liste_imbrique",
"element_autre_liste",
],
"cle_dict_imbrique_3_FR": [
"element_liste_imbriqueFR",
"element_autre_listeFR",
],
},
}
self.assertDictEqual(expected_dict, merged_dict)
def test_process_keys_nested_dict(self):
test_dict = {
"fr_FR": {
"champ_1": [
{
"cle_dict_imbrique_1": "valeur1",
"cle_dict_imbrique_2": "valeur2",
"cle_dict_imbrique_3": [
"element_liste_imbriqueFR",
"element_autre_listeFR",
],
},
{
"cle_dict_imbrique_1": "valeur44",
"cle_dict_imbrique_2": "valeur4858",
"cle_dict_imbrique_3": [
"element_liste_imbriqueFR",
"element_autre_liste",
],
},
],
"champ_2": "valeur",
},
"zn_CN": {
"champ_1": [
{
"cle_dict_imbrique_1": "valeur1",
"cle_dict_imbrique_2": "valeur2",
"cle_dict_imbrique_3": [
"element_liste_imbriqueFR",
"element_autre_listeFR",
],
},
{
"cle_dict_imbrique_1": "valeur44",
"cle_dict_imbrique_2": "valeur4858",
"cle_dict_imbrique_3": [
"element_liste_imbrique",
"element_autre_liste",
],
},
],
},
}
merged_dict = {}
all_keys = self.json_export_format.retrieve_all_keys(test_dict)
self.json_export_format.process_keys(all_keys, test_dict, merged_dict)
expected_dict = {
"champ_1": [
{
"cle_dict_imbrique_1": "valeur1",
"cle_dict_imbrique_2": "valeur2",
"cle_dict_imbrique_3": [
"element_liste_imbriqueFR",
"element_autre_listeFR",
],
},
{
"cle_dict_imbrique_1": "valeur44",
"cle_dict_imbrique_2": "valeur4858",
"cle_dict_imbrique_3": "champ_1/cle_dict_imbrique_3",
"cle_dict_imbrique_3_CN": [
"element_liste_imbrique",
"element_autre_liste",
],
"cle_dict_imbrique_3_FR": [
"element_liste_imbriqueFR",
"element_autre_liste",
],
},
],
"champ_2": "valeur",
}
self.assertDictEqual(expected_dict, merged_dict)
def test_merge_list_of_dicts_without_lang(self):
self.assertEqual(
[],
self.json_export_format._merge_list_of_dicts({}),
"Sans langue, cette fonction ne doit rien faire et retourner une liste vide",
)
self.assertEqual(
[],
self.json_export_format.merge_multilingual_dicts({}),
"Même chose, sans langue, pas de dict",
)
def test_merge_multilingual_dicts(self):
test_dict = {
"en_US": [
{
"amount_total": 300.0,
"amount_untaxed": 250.0,
"company_id": "My Company",
"currency_id": "EUR",
"invoice_status": "Nothing to Bill",
"order_line": [
{
"company_id": "My Company",
"currency_id": "EUR",
"display_type": "Section",
"name": "Exmple de section",
"state": "Purchase Order",
"tax_calculation_rounding_method": "Round per Line",
"taxes_id": [],
},
{
"company_id": "My Company",
"currency_id": "EUR",
"name": "Produit test 2",
"price_subtotal": 75.0,
"price_unit": 75.0,
"product_type": "Goods",
"product_id": "ENGLISH Produit test 2",
"product_uom": "Units",
"product_uom_category_id": "Unit",
"state": "Purchase Order",
"tax_calculation_rounding_method": "Round per Line",
"taxes_id": ["20%"],
},
],
"payment_term_id": "21 Days",
"tax_country_id": "United States",
}
],
"fr_FR": [
{
"amount_total": 300.0,
"amount_untaxed": 250.0,
"company_id": "My Company",
"currency_id": "EUR",
"invoice_status": "Rien à facturer",
"order_line": [
{
"company_id": "My Company",
"currency_id": "EUR",
"display_type": "Section",
"name": "Exmple de section",
"state": "Bon de commande fournisseur",
"tax_calculation_rounding_method": "Arrondir à la ligne",
"taxes_id": [],
},
{
"company_id": "My Company",
"currency_id": "EUR",
"name": "Produit test 2",
"price_subtotal": 75.0,
"price_unit": 75.0,
"product_id": "Produit test 2",
"product_type": "Biens",
"product_uom": "Unité(s)",
"product_uom_category_id": "Unité",
"state": "Bon de commande fournisseur",
"tax_calculation_rounding_method": "Arrondir à la ligne",
"taxes_id": ["20%"],
},
],
"payment_term_id": "21 jours ",
"tax_country_id": "États-Unis",
}
],
}
self.assertDictEqual(
self.expected_list_dict_merge[0],
self.json_export_format.merge_multilingual_dicts(test_dict)[0],
)
def test_format_data(self):
fields = self.expected_list_dict_merge
res_json = self.json_export_format.format_data(fields)
self.assertTrue(json.loads(res_json), "Le résultat de la fonction doit être au format .json")
fields[0].update({"mock_bytes_key": b"mock_bytes_value"})
res_json = self.json_export_format.format_data(fields)
list_dict_from_json = json.loads(res_json)
bytes_key = list_dict_from_json[0].get("mock_bytes_key")
self.assertEqual(
"data:image/png;base64,mock_bytes_value",
bytes_key,
"La présence d'une clé contenant une valeur en bytes, dans les champs d'entrée, doit obligatoirement"
"être modifié en sorti dans le json, pour y inclure un préfixe.",
)
def test_define_parser(self):
res_partner_fields_names = list(self.partner._fields.keys())
set_fields_names = set(res_partner_fields_names)
# Ajouts de faux nom de champs qui sont concaténés.
res_partner_fields_names.append("company_id/name")
res_partner_fields_names.append("company_id/street1")
res_partner_fields_names.append("company_id/siret")
res_partner_fields_names.append("country_id/currency_id/name")
res_partner_fields_names.append("country_id/currency_id/iso_numeric")
converted_fields_name = self.json_export_format._convert_simple_list(res_partner_fields_names)
parser = self.json_export_format.define_parser(converted_fields_name)
self.assertTrue(
set_fields_names.issubset(parser),
"Tous les champs du modèle res.partner doivent être présent dans le parser.",
)
expected_tuple_1 = ("company_id", ["name", "street1", "siret"])
expected_tuple_2 = ("country_id", [("currency_id", ["name", "iso_numeric"])])
for expected_tuple in [expected_tuple_1, expected_tuple_2]:
self.assertIn(
expected_tuple,
parser,
"Un tuple nom_de_champ et liste de nom de champ (qui peut lui même contenir un tuple avec "
"la même combinaison), doit être présent, pour les champs de type M2O, avec un accès à un "
"des champs du M2O",
)
def test_add_list_or_dict_on_merge_item(self):
# Faire la version avec un parent_key qui est un dict
test_dict = {
"fr_FR": {
"champ_2": "valeur",
"champ_3": {
"cle_dict_imbrique_1": "valeur1",
"cle_dict_imbrique_2": "valeur2",
"cle_dict_imbrique_3": [
"element_liste_imbriqueFR",
"element_autre_listeFR",
],
},
},
"zn_CN": {
"champ_2": "valeur",
"champ_3": {
"cle_dict_imbrique_1": "valeur1489",
"cle_dict_imbrique_2": "valeur24996",
"cle_dict_imbrique_3": [
"element_liste_imbrique",
"element_autre_liste",
],
},
},
}
merged_dict = {}
all_keys = self.json_export_format.retrieve_all_keys(test_dict)
# On simule le fait que le champ_2 soit le parent key de tout le dict d'entrée "test_dict".
# La clé doit apparaître dans l'arborescence de chaque champ imbriquée.
self.json_export_format.process_keys(all_keys, test_dict, merged_dict, "champ_2")
expected_dict = {
"champ_2": "valeur",
"champ_3": {
"cle_dict_imbrique_2": "champ_2/champ_3/cle_dict_imbrique_2",
"cle_dict_imbrique_2_FR": "valeur2",
"cle_dict_imbrique_2_CN": "valeur24996",
"cle_dict_imbrique_3": "champ_2/champ_3/cle_dict_imbrique_3",
"cle_dict_imbrique_3_FR": [
"element_liste_imbriqueFR",
"element_autre_listeFR",
],
"cle_dict_imbrique_3_CN": [
"element_liste_imbrique",
"element_autre_liste",
],
"cle_dict_imbrique_1": "champ_2/champ_3/cle_dict_imbrique_1",
"cle_dict_imbrique_1_FR": "valeur1",
"cle_dict_imbrique_1_CN": "valeur1489",
},
}
self.assertDictEqual(expected_dict, merged_dict)
def test_index_json_export_route(self):
# Fonction appelé par le controller qui gère l'export au format json (bouton "Action" > "Exporter")
self.authenticate("admin", "admin")
csrf = self.helper_get_csrf_token()
url = "/web/export/json"
payload = {
"csrf_token": csrf,
"data": json.dumps(self.data_dict),
}
response = self.url_open(url, payload)
self.assertEqual(response.status_code, 200)
self.assertDictEqual(self.basic_expect_dict, response.json()[0])
def test_formats_export_formats(self):
# On vérifie que la pop-up d'export ajoute bien la coche json dans les options de format de sortie.
self.authenticate("admin", "admin")
url = "/web/export/formats"
payload = {"jsonrpc": "2.0", "method": "call", "params": {}, "id": 1}
response = self.url_open(
url,
data=json.dumps(payload),
headers={"Content-Type": "application/json"},
)
result_format = response.json().get("result")
self.assertIn(
{"label": "JSON", "tag": "json"},
result_format,
"Le nouveau format json doit être disponible enexport",
)
def test_json_export_on_none_ordinary_table(self):
fields = [field["name"] for field in self.data_dict["fields"]]
model = self.env[self.data_dict["model"]]
with patch("odoo.models.BaseModel._is_an_ordinary_table", return_value=False):
with patch.object(controller_module, "request", self.mock_request):
result_json = self.json_export_format.perform_json_export(
self.data_dict["domain"], fields, self.data_dict["ids"], model
)
res_dict = json.loads(result_json)
self.basic_expect_dict.pop("id")
self.assertDictEqual(
res_dict[0],
self.basic_expect_dict,
"Seul l'id ne doit pas être récupérer, "
"si la table associé au modèle ne correspond pas à une table classique."
"Toutes les autres informations doivent être récupérés normalement.",
)
def test_property_extension(self):
self.assertEqual(self.json_export_format.extension, ".json")
def test_property_content_type(self):
self.assertEqual(self.json_export_format.content_type, "text/json;charset=utf8")