From ae086d645cbac858ff4ba5c8c968c044bc988f3f Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Mon, 28 Oct 2024 17:02:50 -0400 Subject: [PATCH] first commit, nothing working yet --- recursive_tree_view/__init__.py | 2 + recursive_tree_view/__manifest__.py | 42 +++++++ recursive_tree_view/models/__init__.py | 1 + recursive_tree_view/models/models.py | 18 +++ recursive_tree_view/rng/tree_view.rng | 104 ++++++++++++++++++ .../static/src/list_arch_parser.js | 23 ++++ .../static/src/list_controller.js | 81 ++++++++++++++ .../static/src/list_renderer.js | 59 ++++++++++ .../static/src/recursive_tree_templates.xml | 22 ++++ .../static/src/relational_model.js | 80 ++++++++++++++ .../static/src/tree_recursive_styles.css | 15 +++ recursive_tree_view/validation.py | 64 +++++++++++ 12 files changed, 511 insertions(+) create mode 100644 recursive_tree_view/__init__.py create mode 100644 recursive_tree_view/__manifest__.py create mode 100644 recursive_tree_view/models/__init__.py create mode 100644 recursive_tree_view/models/models.py create mode 100644 recursive_tree_view/rng/tree_view.rng create mode 100644 recursive_tree_view/static/src/list_arch_parser.js create mode 100644 recursive_tree_view/static/src/list_controller.js create mode 100644 recursive_tree_view/static/src/list_renderer.js create mode 100644 recursive_tree_view/static/src/recursive_tree_templates.xml create mode 100644 recursive_tree_view/static/src/relational_model.js create mode 100644 recursive_tree_view/static/src/tree_recursive_styles.css create mode 100644 recursive_tree_view/validation.py 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..dd28c5b --- /dev/null +++ b/recursive_tree_view/__manifest__.py @@ -0,0 +1,42 @@ +# +# 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/list_arch_parser.js", + "recursive_tree_view/static/src/list_controller.js", + "recursive_tree_view/static/src/list_renderer.js", + "recursive_tree_view/static/src/tree_recursive_styles.css", + "recursive_tree_view/static/src/recursive_tree_templates.xml", + ] + }, + "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..eef4ad4 --- /dev/null +++ b/recursive_tree_view/static/src/list_arch_parser.js @@ -0,0 +1,23 @@ +/** @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"; + result.childField = xmlDoc.getAttribute("child-field") + + if (!(result.childField in result)) { + + } + } + + + 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..21d5a4e --- /dev/null +++ b/recursive_tree_view/static/src/list_controller.js @@ -0,0 +1,81 @@ +/** @odoo-module **/ + +import {ListController} from '@web/views/list/list_controller'; +import {patch} from '@web/core/utils/patch'; +import {useService} from '@web/core/utils/hooks'; +import {onWillRender} from '@odoo/owl'; +import {useBus} from "@web/core/utils/hooks"; + +patch(ListController.prototype, { + async setup() { + useBus(this.env.bus, "expandRow", this.onExpandRow) + useBus(this.env.bus, "collapseRow", this.onCollapseRow) + super.setup(); + if (this.props.archInfo.recursive) { + // Fetch the parent field from the model + const parentField = await this.orm.call( + 'parent.field.service', + 'get_parent_field', + [this.props.resModel], + ); + + // Validate the parentField and adjust archInfo accordingly + if (parentField) { + // If parentField is valid, store it and continue with recursive setup + this.parentField = parentField; + this.props.archInfo.parentField = parentField; + } else { + // If no valid parentField, disable recursion and childField functionality + this.props.archInfo.recursive = false; + delete this.props.archInfo.childField; + } + + // Proceed with recursive setup if recursive is enabled + if (this.props.archInfo.recursive) { + this.childrenByParent = {}; // Cache for loaded children + + // Bind event listeners for expand and collapse + this.model.hooks.onRootLoaded = async () => { + const rootRecords = this.model.root.records; + const rootIds = rootRecords.map(record => record.resId) + const childRecords = await this.orm.searchRead( + this.props.resModel, + [[this.parentField, 'in', rootIds]], + [], + ) + const rootIdsWithChildren = new Set(childRecords.map(child => child[this.parentField])); + rootRecords.forEach(record => { + record.data.hasChildren = rootIdsWithChildren.has(record.data.resId); + }); + } + } + } + }, + + async fetchChildren(parentId) { + if (this.childrenByParent[parentId]) { + return this.childrenByParent[parentId]; + } + + const children = await this.rpc({ + model: this.props.resModel, + method: 'search_read', + args: [[this.parentField, '=', parentId]], + kwargs: {fields: ['id', 'name', this.parentField]}, + }); + + this.childrenByParent[parentId] = children; + return children; + }, + + async onExpandRow(event) { + const {parentId} = event.data; + const children = await this.fetchChildren(parentId); + this.renderer.renderChildrenRows(children, parentId); + }, + + onCollapseRow(event) { + const {parentId} = event.data; + this.renderer.removeChildrenRows(parentId); + }, +}); 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..dddd651 --- /dev/null +++ b/recursive_tree_view/static/src/list_renderer.js @@ -0,0 +1,59 @@ +/** @odoo-module **/ + +import { ListRenderer } from '@web/views/list/list_renderer'; +import { patch } from '@web/core/utils/patch'; + +patch(ListRenderer.prototype, { + setup() { + super.setup(); + this.recursive = this.props.archInfo.recursive; + }, + + async _onExpandClick(ev) { + const $button = $(ev.currentTarget); + const isExpanded = $button.hasClass('expanded'); + $button.toggleClass('expanded', !isExpanded).text(isExpanded ? '+' : '-'); + + const parentId = $button.data('expand'); + if (!isExpanded) { + // Emit an event to request the controller to expand the row + this.env.bus.trigger('expandRow', { parentId }); + } else { + // Emit an event to request the controller to collapse the row + this.env.bus.trigger('collapseRow', { parentId }); + } + }, + + renderChildrenRows(children, parentId) { + const $parentRow = this.$(`tr[data-id="${parentId}"]`); + const parentDepth = $parentRow.data('depth') || 0; + + children.forEach(child => { + const $row = $('') + .attr('data-id', child.id) + .attr('data-parent-id', parentId) + .attr('data-depth', parentDepth + 1) + .attr('data-has-children', child.hasChildren) + .addClass('o_recursive_child_row') + .css('padding-left', `${(parentDepth + 1) * 20}px`); + + for (const [fieldName, fieldValue] of Object.entries(child)) { + const $cell = $('').text(fieldValue); + $row.append($cell); + } + + $parentRow.after($row); + }); + }, + + removeChildrenRows(parentId) { + const childRows = this.$(`tr[data-parent-id="${parentId}"]`); + + childRows.each((index, childRow) => { + const childId = $(childRow).data('id'); + this.removeChildrenRows(childId); + }); + + childRows.remove(); + }, +}); 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..0b06253 --- /dev/null +++ b/recursive_tree_view/static/src/recursive_tree_templates.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + 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..d7773ad --- /dev/null +++ b/recursive_tree_view/static/src/relational_model.js @@ -0,0 +1,80 @@ +/** @odoo-module **/ + +import {RelationalModel} from '@web/model/relational_model/relational_model'; +import {patch} from '@web/core/utils/patch'; +import { + getFieldsSpec, + makeActiveField, + getBasicEvalContext +} from "@web/model/relational_model/utils"; + +patch(RelationalModel.prototype, 'recursive-list-extension', { + /** + * Override to add child record tracking. + * Fetches records and marks records with `hasChildren` if they have child records. + */ + async _loadRecords(config, evalContext = config.context) { + const {resModel, resIds, activeFields, fields, context} = config; + const parentField = await this.orm.call( + 'parent.field.service', + 'get_parent_field', + [resModel], + ); + activeFields[parentField] = makeActiveField() + const records = await super._loadRecords(config, evalContext); + // Get the parent ID field if there is one + if (records && parentField) { + const fieldSpec = getFieldsSpec(activeFields, fields, evalContext); + // Fetch records with the additional field + const parentIds = resIds + const children = await this.orm.webSearchRead(resModel, [[parentField, "in", parentIds]], { + context: {bin_size: true, ...context}, + specification: fieldSpec, + }); + + // Track children by grouping child records under each parent + const childrenByParent = {}; + for (const child of children) { + const parentId = child[parentField]; + if (parentId) { + if (!childrenByParent[parentId]) { + childrenByParent[parentId] = []; + } + childrenByParent[parentId].push(child); + } + } + + records.forEach(record => { + record.children = new this.constructor.DynamicRecordList(this, config, childrenByParent[record.resId]); + }); + } + return records; + }, + + /** + * Fetch and load child records dynamically for a given parent record ID. + * Adds children to the model’s records to ensure they’re tracked in the model’s state. + * @param {number} parentId - The ID of the parent record. + * @returns {Promise} - List of child records as model records. + */ + async fetchChildren(parentId) { + const config = self.config + const {resModel, resIds, activeFields, fields, context} = config; + const evalContext = getBasicEvalContext(config); + const fieldSpec = getFieldsSpec(activeFields, fields, evalContext); + const parentRecord = this.root.records.find((r) => r.resId == parentId) + if (!parentRecord) { + throw Error("Attempt to find parent record failed."); + } + // Fetch child records where `parent_id` matches the given `parentId` + const childrenData = await this.orm.webSearchRead(resModel, [[this.parentField, '=', parentId]], { + context: context, + specification: fieldSpec, + }); + if (childrenData) { + parentRecord.children = new this.constructor.DynamicRecordList(this, this.config, childrenData); + } + return parentRecord.children; + }, + +}); 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..dc1b411 --- /dev/null +++ b/recursive_tree_view/static/src/tree_recursive_styles.css @@ -0,0 +1,15 @@ +.o_recursive_expand_column { + width: 30px; +} + +tr[data-depth="1"] .o_recursive_expand_column { + padding-left: 20px; +} + +tr[data-depth="2"] .o_recursive_expand_column { + padding-left: 40px; +} + +tr[data-depth="3"] .o_recursive_expand_column { + padding-left: 60px; +} diff --git a/recursive_tree_view/validation.py b/recursive_tree_view/validation.py new file mode 100644 index 0000000..48d16c2 --- /dev/null +++ b/recursive_tree_view/validation.py @@ -0,0 +1,64 @@ +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", + ) + child_field_attr = etree.SubElement( + optional_attr, + _tag="{%s}attribute" % RNG_NS, + name="child-field", + ) + + # 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])