diff --git a/recursive_tree_view/__init__.py b/recursive_tree_view/__init__.py new file mode 100644 index 0000000..8222d9b --- /dev/null +++ b/recursive_tree_view/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import validation diff --git a/recursive_tree_view/__manifest__.py b/recursive_tree_view/__manifest__.py new file mode 100644 index 0000000..ce10ad0 --- /dev/null +++ b/recursive_tree_view/__manifest__.py @@ -0,0 +1,38 @@ +# +# Bemade Inc. +# +# Copyright (C) 2023-June Bemade Inc. (). +# 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, +} diff --git a/recursive_tree_view/models/__init__.py b/recursive_tree_view/models/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/recursive_tree_view/models/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/recursive_tree_view/models/models.py b/recursive_tree_view/models/models.py new file mode 100644 index 0000000..4acf873 --- /dev/null +++ b/recursive_tree_view/models/models.py @@ -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 + ) diff --git a/recursive_tree_view/rng/tree_view.rng b/recursive_tree_view/rng/tree_view.rng new file mode 100644 index 0000000..82bea53 --- /dev/null +++ b/recursive_tree_view/rng/tree_view.rng @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/recursive_tree_view/static/src/list_arch_parser.js b/recursive_tree_view/static/src/list_arch_parser.js new file mode 100644 index 0000000..20abd27 --- /dev/null +++ b/recursive_tree_view/static/src/list_arch_parser.js @@ -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; + } +}) \ No newline at end of file diff --git a/recursive_tree_view/static/src/list_controller.js b/recursive_tree_view/static/src/list_controller.js new file mode 100644 index 0000000..50ed66f --- /dev/null +++ b/recursive_tree_view/static/src/list_controller.js @@ -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; + }, + +}); diff --git a/recursive_tree_view/static/src/list_renderer.js b/recursive_tree_view/static/src/list_renderer.js new file mode 100644 index 0000000..873d41a --- /dev/null +++ b/recursive_tree_view/static/src/list_renderer.js @@ -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); + }, +}); diff --git a/recursive_tree_view/static/src/recursive_tree_templates.xml b/recursive_tree_view/static/src/recursive_tree_templates.xml new file mode 100644 index 0000000..757e150 --- /dev/null +++ b/recursive_tree_view/static/src/recursive_tree_templates.xml @@ -0,0 +1,40 @@ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + diff --git a/recursive_tree_view/static/src/relational_model.js b/recursive_tree_view/static/src/relational_model.js new file mode 100644 index 0000000..4a66f70 --- /dev/null +++ b/recursive_tree_view/static/src/relational_model.js @@ -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; + } +}); diff --git a/recursive_tree_view/static/src/tree_recursive_styles.css b/recursive_tree_view/static/src/tree_recursive_styles.css new file mode 100644 index 0000000..c4425c4 --- /dev/null +++ b/recursive_tree_view/static/src/tree_recursive_styles.css @@ -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; +} diff --git a/recursive_tree_view/validation.py b/recursive_tree_view/validation.py new file mode 100644 index 0000000..ca8de27 --- /dev/null +++ b/recursive_tree_view/validation.py @@ -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])