[ADD] recursive_tree_view: new module for hierarchical models

Allows the viewing of hierarchical models (i.e. parent-child
relationships) directly in a tree view. Simply add the recursive
attribute to the tree tag with `recursive="1"` or `recursive="true"` or
`recursive="True"`.

Parent-child field is autodetected from the model based on the
_parent_name field which is standard in the Odoo ORM.

Currently there is no option for expanding all children, but this may be
added in future versions.
This commit is contained in:
Marc Durepos 2024-10-30 12:11:55 -04:00
parent 4db6dab0eb
commit 6bab956a54
12 changed files with 501 additions and 0 deletions

View file

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

View file

@ -0,0 +1,38 @@
#
# Bemade Inc.
#
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : marc@bemade.org)
#
# This program is under the terms of the GNU Lesser General Public License,
# version 3.
#
# For full license details, see https://www.gnu.org/licenses/lgpl-3.0.en.html.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
{
"name": "Recursive Tree View",
"version": "17.0.0.0.1",
"summary": "Adds the ability to mark a tree view as recursive, to expand "
"descendants of a parent record.",
"category": "Technical",
"author": "Bemade Inc.",
"website": "http://www.bemade.org",
"license": "LGPL-3",
"depends": ["web"],
"data": [],
"assets": {
"web.assets_backend": [
"recursive_tree_view/static/src/**/*",
]
},
"installable": True,
"application": False,
}

View file

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

View file

@ -0,0 +1,18 @@
# models/parent_field_service.py
from odoo import models, api
class ParentFieldService(models.AbstractModel):
_name = "parent.field.service"
_description = "Service to retrieve parent field dynamically for any model"
@api.model
def get_parent_field(self, model_name):
"""
Returns the parent field name for the given model.
If _parent_name is not set, defaults to 'parent_id' if the field exists.
"""
Model = self.env[model_name] # Access the model dynamically
return Model._parent_name or (
"parent_id" if hasattr(Model, "parent_id") else None
)

View file

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<rng:grammar xmlns:rng="http://relaxng.org/ns/structure/1.0"
xmlns:a="http://relaxng.org/ns/annotation/1.0"
datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
<!-- Handling of element overloading when inheriting from a base
template
-->
<rng:include href="odoo/base/rng/common.rng"/>
<rng:define name="groupby">
<rng:element name="groupby">
<rng:attribute name="name"/>
<rng:optional><rng:attribute name="expand"/></rng:optional>
<rng:zeroOrMore>
<rng:ref name="field"/>
</rng:zeroOrMore>
<rng:zeroOrMore>
<rng:ref name="button"/>
</rng:zeroOrMore>
</rng:element>
</rng:define>
<rng:define name="tree">
<rng:element name="tree">
<rng:ref name="overload"/>
<rng:optional><rng:attribute name="name"/></rng:optional>
<rng:optional><rng:attribute name="create"/></rng:optional>
<rng:optional><rng:attribute name="delete"/></rng:optional>
<rng:optional><rng:attribute name="edit"/></rng:optional>
<rng:optional><rng:attribute name="multi_edit"/></rng:optional>
<rng:optional><rng:attribute name="export_xlsx"/></rng:optional>
<rng:optional><rng:attribute name="duplicate"/></rng:optional>
<rng:optional><rng:attribute name="import"/></rng:optional>
<rng:optional><rng:attribute name="string"/></rng:optional> <!-- deprecated, has no effect anymore -->
<rng:optional><rng:attribute name="class"/></rng:optional>
<!-- Allows to take a custom View widget for handling -->
<rng:optional><rng:attribute name="js_class"/></rng:optional>
<rng:optional><rng:attribute name="default_order"/></rng:optional>
<rng:optional><rng:attribute name="default_group_by"/></rng:optional>
<rng:optional><rng:attribute name="decoration-bf"/></rng:optional>
<rng:optional><rng:attribute name="decoration-it"/></rng:optional>
<rng:optional><rng:attribute name="decoration-danger"/></rng:optional>
<rng:optional><rng:attribute name="decoration-info"/></rng:optional>
<rng:optional><rng:attribute name="decoration-muted"/></rng:optional>
<rng:optional><rng:attribute name="decoration-primary"/></rng:optional>
<rng:optional><rng:attribute name="decoration-success"/></rng:optional>
<rng:optional><rng:attribute name="decoration-warning"/></rng:optional>
<rng:optional><rng:attribute name="banner_route"/></rng:optional>
<rng:optional><rng:attribute name="sample"/></rng:optional>
<rng:optional><rng:attribute name="action"/></rng:optional>
<rng:optional><rng:attribute name="type"/></rng:optional>
<rng:optional><rng:attribute name="open_form_view"/></rng:optional>
<rng:optional><rng:attribute name="recursive"/></rng:optional>
<rng:optional>
<rng:attribute name="limit">
<rng:data type="int"/>
</rng:attribute>
</rng:optional>
<rng:optional>
<rng:attribute name="count_limit">
<rng:data type="int"/>
</rng:attribute>
</rng:optional>
<rng:optional>
<rng:attribute name="groups_limit">
<rng:data type="int"/>
</rng:attribute>
</rng:optional>
<rng:optional>
<rng:attribute name="editable">
<rng:choice>
<rng:value>top</rng:value>
<rng:value>bottom</rng:value>
</rng:choice>
</rng:attribute>
</rng:optional>
<rng:optional><rng:attribute name="expand"/></rng:optional>
<rng:zeroOrMore>
<rng:choice>
<rng:element name="header">
<rng:zeroOrMore>
<rng:ref name="button"/>
</rng:zeroOrMore>
</rng:element>
<rng:ref name="control"/>
<rng:ref name="field"/>
<rng:ref name="widget"/>
<rng:ref name="separator"/>
<rng:ref name="tree"/>
<rng:ref name="groupby"/>
<rng:ref name="button"/>
<rng:ref name="filter"/>
<rng:ref name="html"/>
<rng:element name="newline"><rng:empty/></rng:element>
</rng:choice>
</rng:zeroOrMore>
</rng:element>
</rng:define>
<rng:start>
<rng:choice>
<rng:ref name="tree" />
</rng:choice>
</rng:start>
</rng:grammar>

View file

@ -0,0 +1,16 @@
/** @odoo-module **/
import { ListArchParser} from "@web/views/list/list_arch_parser";
import { patch } from "@web/core/utils/patch";
patch(ListArchParser.prototype, {
parse(xmlDoc, models, modelName) {
const result = super.parse(...arguments);
const recursiveAttr = xmlDoc.getAttribute("recursive")
if ( recursiveAttr ) {
result.recursive = recursiveAttr === "1" || recursiveAttr === "true" || recursiveAttr === "True";
}
return result;
}
})

View file

@ -0,0 +1,37 @@
/** @odoo-module **/
import {ListController} from '@web/views/list/list_controller';
import {patch} from '@web/core/utils/patch';
import {useBus} from "@web/core/utils/hooks";
patch(ListController.prototype, {
async setup() {
if (this.props.archInfo.recursive) {
this.recursive = true;
useBus(this.env.bus, "expand-collapse-parent", this.onExpandCollapseParent);
} else {
this.recursive = false;
}
super.setup();
},
async onExpandCollapseParent(ev) {
const parentId = ev.detail;
const record = this.model.findRecordInHierarchy(parentId);
if (record.expanded) {
record.expanded = false;
} else {
await this.model._loadChildren(record.children);
record.expanded = true;
}
},
get modelParams() {
const params = super.modelParams;
if (this.recursive) {
params["config"]["recursive"] = true;
}
return params;
},
});

View file

@ -0,0 +1,19 @@
/** @odoo-module **/
import { ListRenderer } from '@web/views/list/list_renderer';
import { patch } from '@web/core/utils/patch';
import { useState } from '@odoo/owl';
patch(ListRenderer.prototype, {
setup() {
super.setup();
this.recursive = this.props.archInfo.recursive;
useState(this.props.list.records);
},
async _onExpandClick(ev) {
const $button = $(ev.currentTarget);
const parent = $button.data('expand');
this.env.bus.trigger("expand-collapse-parent", parent);
},
});

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Extend List Renderer Header to Add Extra Column for Expand/Collapse Button Alignment -->
<t t-name="recursive_tree_view.Renderer" t-inherit="web.ListRenderer"
t-inherit-mode="extension">
<xpath expr="//th[hasclass('o_list_record_selector')]" position="before">
<th t-if="recursive"
class="o_recursive_expand_column cursor-default o_list_button">
<div style="min-width: 100px;"/>
</th>
</xpath>
</t>
<!-- Extend List Renderer Row to Add Expand/Collapse Button -->
<t t-name="web.ListRenderer.RecordRow" t-inherit="web.ListRenderer.RecordRow"
t-inherit-mode="extension">
<xpath expr="//tr[hasclass('o_data_row')]" position="after">
<t t-if="record.children &amp;&amp; record.expanded">
<t t-foreach="record.children" t-as="record" t-key="record.id">
<t t-call="{{ constructor.recordRowTemplate }}"/>
</t>
</t>
</xpath>
<xpath expr="//td[1]" position="before">
<td t-if="this.recursive"
class="o_recursive_expand_column"
>
<button t-if="record.children"
t-att-data-expand="record.resId"
t-on-click.prevent="_onExpandClick"
t-att-data-depth="record.depth"
class="o_expand_button">
<span role="img"
t-att-class="'fa ' + (record.expanded ? 'fa-angle-down' : 'fa-angle-right')"
/>
</button>
</td>
</xpath>
</t>
</odoo>

View file

@ -0,0 +1,139 @@
/** @odoo-module **/
import {RelationalModel} from '@web/model/relational_model/relational_model';
import {patch} from '@web/core/utils/patch';
import {getFieldsSpec, getBasicEvalContext} from "@web/model/relational_model/utils";
patch(RelationalModel.prototype, {
// TODO: Modify the domain to include only records with parentField = False in the root search
setup(params, services) {
super.setup(...arguments);
this.hooks.onRootLoaded = () => {
const root = this.root;
const config = this.root.config;
if (config.recursive && root.records) {
this._loadChildren(root.records, config).then(() => {
return root;
});
}
}
},
async _loadData(config) {
if (config.recursive) {
const parentField = await this._get_parent_field(config.resModel);
const domain = [parentField, '=', false];
if (!(domain in config.domain)) {
config.domain = config.domain.concat([domain]);
}
}
return super._loadData(config);
},
async _loadChildren(records, config = undefined) {
if (!records) {
return [];
}
if (!config) {
config = this.config;
}
if (!Array.isArray(records)) {
records = [records];
}
const {resModel, activeFields, fields, context} = config;
if (!resModel) {
return [];
}
const parentField = await this._get_parent_field(resModel);
if (!parentField) {
return [];
}
const evalContext = getBasicEvalContext(config);
const fieldSpec = getFieldsSpec(activeFields, fields, evalContext);
// Fetch records with the additional field
const parentIds = records.map(record => record.resId);
const children = await this.orm.webSearchRead(
resModel, [[parentField, "in", parentIds]], {
context: {...context},
specification: fieldSpec,
});
if (children && children.length) {
// Track children by grouping child records under each parent
const childrenByParent = {};
for (const child of children.records) {
const parentId = child[parentField].id;
if (parentId) {
if (!childrenByParent[parentId]) {
childrenByParent[parentId] = [];
}
childrenByParent[parentId].push(child);
}
}
for (const parent of records) {
if (parent.depth == undefined) {
parent.depth = 0;
}
if (parent.expanded == undefined) {
parent.expanded = false;
}
if (!parent.childrenFetched) {
if (childrenByParent[parent.resId]) {
const childRecords = childrenByParent[parent.resId];
parent.children = []
for (const child of childRecords) {
const childRecord = new this.constructor.Record(
this,
{
context: context,
activeFields: activeFields,
resModel: resModel,
fields: fields,
resId: child.id,
resIds: [child.id],
isMonoRecord: true,
currentCompanyId: parent.currentCompanyId,
mode: parent.mode,
},
child,
{manuallyAdded: false},
);
parent.children.push(childRecord);
childRecord.parent = parent;
childRecord.depth = parent.depth + 1;
childRecord.expanded = false;
childRecord.childrenFetched = false;
}
}
parent.childrenFetched = true;
}
}
}
},
async _get_parent_field(model) {
return await this.orm.call(
'parent.field.service',
'get_parent_field',
[model],
);
},
findRecordInHierarchy(resId) {
const records = this.root.records;
function findRecord(records) {
for (let record of records) {
if (record.resId === resId) {
return record;
}
if (record.children && record.children.length >0) {
const found = findRecord(record.children);
if (found) {
return found;
}
}
}
}
return findRecord(records) || null;
}
});

View file

@ -0,0 +1,28 @@
.o_recursive_expand_column {
width: 30px;
}
.o_expand_button {
border-color: transparent;
background-color: transparent;
}
button.o_expand_button[data-depth="1"] {
margin-left: 15px;
}
button.o_expand_button[data-depth="2"] {
margin-left: 30px;
}
button.o_expand_button[data-depth="3"] {
margin-left: 45px;
}
button.o_expand_button[data-depth="4"] {
margin-left: 60px;
}
button.o_expand_button[data-depth="5"] {
margin-left: 75px;
}

View file

@ -0,0 +1,59 @@
import logging
import os
from lxml import etree
from odoo.tools import misc, view_validation
_logger = logging.getLogger(__name__)
_tree_validator = None
RNG_NS = "http://relaxng.org/ns/structure/1.0"
def schema_tree(arch, **kwargs):
global _tree_validator
if _tree_validator is None:
try:
# Load and parse the existing schema file
schema_path = os.path.join("addons", "base", "rng", "tree_view.rng")
with misc.file_open(schema_path) as f:
rng_doc = etree.parse(f)
# Find the `tree` definition in the parsed document
tree_define = rng_doc.find(".//{%s}define[@name='tree']" % RNG_NS)
if tree_define is not None:
# Add the `recursive` attribute as an optional attribute in the `tree` definition
tree_elem = tree_define.find(".//{%s}element[@name='tree']" % RNG_NS)
if tree_elem is not None:
optional_attr = etree.SubElement(
tree_elem,
_tag="{%s}optional" % RNG_NS,
)
recursive_attr = etree.SubElement(
optional_attr,
_tag="{%s}attribute" % RNG_NS,
name="recursive",
)
# Create RelaxNG validator from the modified schema
_tree_validator = etree.RelaxNG(rng_doc)
except (etree.RelaxNGParseError, etree.XMLSyntaxError) as e:
_logger.error("Error parsing RelaxNG schema: %s", e.error_log)
return False
except Exception as e:
_logger.error("General error loading RelaxNG schema: %s", e)
return False
# Validate the XML arch
if _tree_validator.validate(arch):
return True
# Log validation errors
for error in _tree_validator.error_log:
_logger.error("Validation error: %s", error)
return False
# Register the validator with Odoo's view validation
view_validation._validators.update(tree=[schema_tree])