diff --git a/base_custom_filter/README.rst b/base_custom_filter/README.rst index a9dec3e08a..e509365b6c 100644 --- a/base_custom_filter/README.rst +++ b/base_custom_filter/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ============================================================= Add custom filters in standard filters and group by dropdowns ============================================================= @@ -17,7 +13,7 @@ Add custom filters in standard filters and group by dropdowns .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--ux-lightgray.png?logo=github @@ -35,7 +31,8 @@ Add custom filters in standard filters and group by dropdowns This module enables the definition of bespoke searches within a model's search view, in addition to permitting the creation of custom filters that will be displayed beneath the standard filters, as well as within -the group-by menus of a model's search view. +the group-by menus of a model's search view. Filter groups support +XPath-based insertion point configuration. **Table of contents** @@ -53,19 +50,19 @@ Configuration Search: - - Search Field (``name``) - - Filter Domain (``filter_domain``) - - User Groups (``groups``) + - Search Field (``name``) + - Filter Domain (``filter_domain``) + - User Groups (``groups``) Filter: - - Domain (``domain``) - - User Groups (``groups``) + - Domain (``domain``) + - User Groups (``groups``) Group By: - - Group By Field (field to be assigned to ``group_by`` context) - - User Groups (``groups``) + - Group By Field (field to be assigned to ``group_by`` context) + - User Groups (``groups``) See `the official documentation `__ @@ -73,6 +70,15 @@ Configuration group-by records can be respectively grouped together with "Group" assignment (there will be a separator in between groups). +3. Filter groups (accessible via *Settings > Custom Filters > Custom + Filter Groups*) support the following optional settings: + + - Insert XPath: XPath expression for the insertion point (e.g. + ``//filter[@name='my_filter']``) + - Insert Position: Insert before or after the target element + - Separator Position: Place separator before, after, or none + (filter type only) + Usage ===== @@ -102,19 +108,19 @@ Authors Contributors ------------ -- `ForgeFlow S.L. `__: +- `ForgeFlow S.L. `__: - - Jordi Masvidal + - Jordi Masvidal -- Ashish Hirpara - <`https://www.ashish-hirpara.com\\> >`__ -- `Quartile `__: +- Ashish Hirpara +- `Quartile `__: - - Aung Ko Ko Lin + - Aung Ko Ko Lin + - Yoshi Tashiro -- `Kencove `__: +- `Kencove `__: - - Mohamed Alkobrosli + - Mohamed Alkobrosli Maintainers ----------- diff --git a/base_custom_filter/models/base.py b/base_custom_filter/models/base.py index bfd960903b..93347c8c3a 100644 --- a/base_custom_filter/models/base.py +++ b/base_custom_filter/models/base.py @@ -1,11 +1,16 @@ # Migrated to v14.0 by Ashish Hirpara (https://www.ashish-hirpara.com) # Copyright 2021 ForgeFlow S.L. (https://www.forgeflow.com) +# Copyright 2026 Quartile (https://www.quartile.co) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging + from lxml import etree from odoo import api, models +_logger = logging.getLogger(__name__) + class Base(models.AbstractModel): _inherit = "base" @@ -17,50 +22,117 @@ def _user_has_access_to_item(self, item): return bool(set(self.env.user.groups_id.ids) & set(item.group_ids.ids)) @api.model - def _add_grouped_filters(self, res, custom_filters): + def _insert_element(self, node, elem, position, is_root=False): + if is_root: + if position == "before": + node.insert(0, elem) + else: + node.append(elem) + elif position == "before": + node.addprevious(elem) + else: + node.addnext(elem) + + @api.model + def _get_default_filter_insertion_node(self, arch): + return ( + arch.xpath("//search/filter[last()]") + or arch.xpath("//search/field[last()]") + or arch.xpath("//search") + ) + + @api.model + def _get_custom_insertion_node(self, arch, xpath): + if not xpath: + return None + try: + return arch.xpath(xpath) or None + except etree.XPathEvalError: + _logger.warning("Invalid XPath expression: %s", xpath) + return None + + @api.model + def _build_filter_element(self, item, is_groupby=False): + """Build a filter element for the search view.""" + attrs = {"name": f"ir_custom_filter_{item.id}", "string": item.name} + if is_groupby: + attrs["context"] = str({"group_by": item.groupby_field.sudo().name}) + else: + attrs["domain"] = item.domain + return etree.Element("filter", attrs) + + @api.model + def _add_grouped_filters(self, res, custom_filters, group=None): arch = etree.fromstring(res["arch"]) - node = arch.xpath("//search/filter[last()]") - if node: - node[0].addnext(etree.Element("separator")) - for custom_filter in custom_filters: - if not self._user_has_access_to_item(custom_filter): - continue - node = arch.xpath("//search/separator[last()]") - if node: - elem = etree.Element( - "filter", - { - "name": f"ir_custom_filter_{custom_filter.id}", - "string": custom_filter.name, - "domain": custom_filter.domain, - }, - ) - node[0].addnext(elem) + default_node = self._get_default_filter_insertion_node(arch) + separator_added = False + insert_xpath = group.insert_xpath if group else None + insert_position = (group.insert_position if group else None) or "after" + separator_position = (group.separator_position if group else None) or "before" + first_inserted = None + last_inserted = None + custom_node = self._get_custom_insertion_node(arch, insert_xpath) + for custom_filter in custom_filters: + if not self._user_has_access_to_item(custom_filter): + continue + elem = self._build_filter_element(custom_filter) + if custom_node: + if last_inserted is not None: + # Insert after the last inserted element to maintain order + last_inserted.addnext(elem) + else: + # First element: just insert the filter + self._insert_element(custom_node[0], elem, insert_position) + first_inserted = elem + last_inserted = elem + elif default_node: + # Fall back to default behavior with separator + if not separator_added: + is_root = default_node[0].tag == "search" + sep = etree.Element("separator") + self._insert_element(default_node[0], sep, "after", is_root) + separator_added = True + sep_node = arch.xpath("//search/separator[last()]") + if sep_node: + sep_node[0].addnext(elem) + # Add separator based on separator_position + if custom_node and first_inserted is not None and separator_position != "none": + sep = etree.Element("separator") + if separator_position == "before": + first_inserted.addprevious(sep) + else: + last_inserted.addnext(sep) res["arch"] = etree.tostring(arch) return res @api.model - def _add_grouped_groupby(self, res, custom_groupbys): + def _get_default_groupby_insertion_node(self, arch): + # Find the group that contains groupby filters + return arch.xpath( + "//search/group[.//filter[contains(@context, 'group_by')]]/filter[last()]" + ) or arch.xpath("//search") + + @api.model + def _add_grouped_groupby(self, res, custom_groupbys, group=None): arch = etree.fromstring(res["arch"]) - node = arch.xpath("//group/filter[last()]") - if node: - node[0].addnext(etree.Element("separator")) - for custom_groupby in custom_groupbys: - if not self._user_has_access_to_item(custom_groupby): - continue - node = arch.xpath("//group/separator[last()]") - if node: - elem = etree.Element( - "filter", - { - "name": f"ir_custom_filter_{custom_groupby.id}", - "string": custom_groupby.name, - "context": str( - {"group_by": custom_groupby.groupby_field.sudo().name} - ), - }, - ) - node[0].addnext(elem) + default_node = self._get_default_groupby_insertion_node(arch) + insert_xpath = group.insert_xpath if group else None + insert_position = (group.insert_position if group else None) or "after" + last_inserted = None + custom_node = self._get_custom_insertion_node(arch, insert_xpath) + insertion_node = custom_node or default_node + if not insertion_node: + return res + for custom_groupby in custom_groupbys: + if not self._user_has_access_to_item(custom_groupby): + continue + elem = self._build_filter_element(custom_groupby, is_groupby=True) + if last_inserted is not None: + last_inserted.addnext(elem) + else: + is_root = insertion_node[0].tag in ("search", "group") + self._insert_element(insertion_node[0], elem, insert_position, is_root) + last_inserted = elem res["arch"] = etree.tostring(arch) return res @@ -126,7 +198,9 @@ def get_view(self, view_id=None, view_type="form", **options): if filter_groups: for filter_group in filter_groups: res = self._add_grouped_filters( - res, filter_group.filter_ids.sorted("sequence", True) + res, + filter_group.filter_ids.sorted("sequence", True), + group=filter_group, ) if filters_no_group: for filter_no_group in filters_no_group: @@ -135,7 +209,9 @@ def get_view(self, view_id=None, view_type="form", **options): if groupby_groups: for groupby_group in groupby_groups: res = self._add_grouped_groupby( - res, groupby_group.filter_ids.sorted("sequence", True) + res, + groupby_group.filter_ids.sorted("sequence", True), + group=groupby_group, ) if groupbys_no_group: for groupby_no_group in groupbys_no_group: diff --git a/base_custom_filter/models/ir_filters.py b/base_custom_filter/models/ir_filters.py index c524d41e5f..44700f69fd 100644 --- a/base_custom_filter/models/ir_filters.py +++ b/base_custom_filter/models/ir_filters.py @@ -18,6 +18,7 @@ def _selection_type(self): ("groupby", "Standard Group By"), ] + name = fields.Char(translate=True) sequence = fields.Integer() type = fields.Selection( selection="_selection_type", @@ -48,11 +49,18 @@ def get_filters( embedded_action_id=None, embedded_parent_res_id=None, ): - """We need to inject a context to obtain only the records of favorite type.""" - self = self.with_context(filter_type="favorite") - return super().get_filters( + """Filter out non-favorite types from the results.""" + results = super().get_filters( model, action_id, embedded_action_id, embedded_parent_res_id ) + if not results: + return results + # Get IDs of non-favorite filters to exclude + result_ids = [r["id"] for r in results if r.get("id")] + non_favorite_ids = set( + self.search([("id", "in", result_ids), ("type", "!=", "favorite")]).ids + ) + return [r for r in results if r.get("id") not in non_favorite_ids] @api.model @api.returns("self") diff --git a/base_custom_filter/models/ir_filters_group.py b/base_custom_filter/models/ir_filters_group.py index 02b0441bf4..614bb9fea7 100644 --- a/base_custom_filter/models/ir_filters_group.py +++ b/base_custom_filter/models/ir_filters_group.py @@ -2,7 +2,10 @@ # Copyright 2021 ForgeFlow S.L. (https://www.forgeflow.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import api, fields, models +from lxml import etree + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError class IrFiltersGroup(models.Model): @@ -26,6 +29,33 @@ def _selection_type(self): filter_ids = fields.One2many( comodel_name="ir.filters", inverse_name="group_id", string="Filters" ) + insert_xpath = fields.Char( + string="Insert XPath", + help="XPath expression for the insertion point. " + "Example: //search/filter[@name='my_filter']", + ) + insert_position = fields.Selection( + selection=[("before", "Before"), ("after", "After")], + default="after", + help="Insert the filter group before or after the element found by XPath.", + ) + separator_position = fields.Selection( + selection=[("before", "Before"), ("after", "After"), ("none", "None")], + default="before", + help="Where to place the separator relative to the filters. " + "'None' to insert without a separator.", + ) + + @api.constrains("insert_xpath") + def _check_insert_xpath(self): + for rec in self: + xpath = (rec.insert_xpath or "").strip() + if not xpath: + continue + try: + etree.XPath(xpath) + except (etree.XPathSyntaxError, etree.XPathEvalError) as e: + raise ValidationError(_("Invalid XPath:\n%s") % e) from e def unlink(self): self.filter_ids.unlink() diff --git a/base_custom_filter/readme/CONFIGURE.md b/base_custom_filter/readme/CONFIGURE.md index 7aeefa4880..89d292215f 100644 --- a/base_custom_filter/readme/CONFIGURE.md +++ b/base_custom_filter/readme/CONFIGURE.md @@ -25,3 +25,12 @@ for the definition of each attribute. Additionally, filter and group-by records can be respectively grouped together with "Group" assignment (there will be a separator in between groups). + +3. Filter groups (accessible via *Settings \> Custom Filters \> Custom + Filter Groups*) support the following optional settings: + + > - Insert XPath: XPath expression for the insertion point + > (e.g. `//filter[@name='my_filter']`) + > - Insert Position: Insert before or after the target element + > - Separator Position: Place separator before, after, or none + > (filter type only) diff --git a/base_custom_filter/readme/CONTRIBUTORS.md b/base_custom_filter/readme/CONTRIBUTORS.md index b7a42880ca..a85caa6b33 100644 --- a/base_custom_filter/readme/CONTRIBUTORS.md +++ b/base_custom_filter/readme/CONTRIBUTORS.md @@ -3,5 +3,6 @@ - Ashish Hirpara \ - [Quartile](https://www.quartile.co): - Aung Ko Ko Lin + - Yoshi Tashiro - [Kencove](https://www.kencove.com): - Mohamed Alkobrosli diff --git a/base_custom_filter/readme/DESCRIPTION.md b/base_custom_filter/readme/DESCRIPTION.md index 9f5ea6de8a..4787262be9 100644 --- a/base_custom_filter/readme/DESCRIPTION.md +++ b/base_custom_filter/readme/DESCRIPTION.md @@ -1,4 +1,5 @@ This module enables the definition of bespoke searches within a model's search view, in addition to permitting the creation of custom filters that will be displayed beneath the standard filters, as well as within -the group-by menus of a model's search view. +the group-by menus of a model's search view. Filter groups support +XPath-based insertion point configuration. diff --git a/base_custom_filter/static/description/index.html b/base_custom_filter/static/description/index.html index 86fe661e12..769fb5dca4 100644 --- a/base_custom_filter/static/description/index.html +++ b/base_custom_filter/static/description/index.html @@ -3,16 +3,15 @@ -README.rst +Add custom filters in standard filters and group by dropdowns -
+
+

Add custom filters in standard filters and group by dropdowns

- - -Odoo Community Association - -
-

Add custom filters in standard filters and group by dropdowns

-

Beta License: AGPL-3 OCA/server-ux Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/server-ux Translate me on Weblate Try me on Runboat

This module enables the definition of bespoke searches within a model’s search view, in addition to permitting the creation of custom filters that will be displayed beneath the standard filters, as well as within -the group-by menus of a model’s search view.

+the group-by menus of a model’s search view. Filter groups support +XPath-based insertion point configuration.

Table of contents

    @@ -394,7 +389,7 @@

    Add custom filters in standard filters and group by dropdowns

-

Configuration

+

Configuration

  1. Go to Settings > Custom Filters.

  2. @@ -429,10 +424,22 @@

    Configuration

    group-by records can be respectively grouped together with “Group” assignment (there will be a separator in between groups).

    +
  3. Filter groups (accessible via Settings > Custom Filters > Custom +Filter Groups) support the following optional settings:

    +
    +
      +
    • Insert XPath: XPath expression for the insertion point (e.g. +//filter[@name='my_filter'])
    • +
    • Insert Position: Insert before or after the target element
    • +
    • Separator Position: Place separator before, after, or none +(filter type only)
    • +
    +
    +
-

Usage

+

Usage

  1. Go to the model’s menu entry for which you have defined the filter.
  2. On the filters and group by dropdowns, you will see the configured @@ -440,7 +447,7 @@

    Usage

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -448,25 +455,25 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Ashish Hirpara
  • ForgeFlow
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

- -Odoo Community Association - +Odoo Community Association

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

@@ -491,6 +496,5 @@

Maintainers

-
diff --git a/base_custom_filter/tests/test_filters.py b/base_custom_filter/tests/test_filters.py index 16c640264f..a3234cc4a4 100644 --- a/base_custom_filter/tests/test_filters.py +++ b/base_custom_filter/tests/test_filters.py @@ -1,3 +1,4 @@ +from odoo.exceptions import ValidationError from odoo.tests import Form, TransactionCase, tagged @@ -16,7 +17,6 @@ def setUpClass(cls): "base_custom_filter.field_ir_filters_group__name" ) filters_group = filters_group.save() - filters_group = Form(cls.filters_obj) filters_group.name = "Test No filters group" filters_group.type = "filter" @@ -112,3 +112,73 @@ def test_get_view_content_groupby(self): view_content, "The string is not in the returned view", ) + + def test_insert_xpath_validation(self): + group = self.filters_group_obj.create( + { + "name": "Test Valid XPath", + "type": "filter", + "model_id": "ir.filters.group", + "insert_xpath": "//filter[@name='Without_filters']", + } + ) + with self.assertRaises(ValidationError): + group.write({"insert_xpath": "///invalid[xpath"}) + + def test_filter_with_custom_xpath(self): + """Test filter insertion with custom XPath and separator position.""" + with Form(self.filters_group_obj) as filters_group: + filters_group.name = "Test XPath Group" + filters_group.type = "filter" + filters_group.model_id = "ir.filters.group" + filters_group.insert_xpath = "//filter[@name='Without_filters']" + filters_group.insert_position = "before" + filters_group.separator_position = "after" + with filters_group.filter_ids.new() as line: + line.name = "XPath Filter" + line.domain = '[["id","=",99]]' + view_dict = self.filters_group_obj.get_view(view_type="search") + view_content = view_dict.get("arch", b"").decode("utf-8") + self.assertIn("XPath Filter", view_content) + # Verify the filter is inserted before Without_filters + filter_pos = view_content.find("XPath Filter") + target_pos = view_content.find("Without_filters") + self.assertLess(filter_pos, target_pos) + # Verify separator exists after the filter (before Without_filters) + separator_pos = view_content.find("", filter_pos) + self.assertLess(separator_pos, target_pos) + # Update to no separator and verify it's removed + group = self.filters_group_obj.search([("name", "=", "Test XPath Group")]) + group.write({"separator_position": "none"}) + view_dict = self.filters_group_obj.get_view(view_type="search") + view_content = view_dict.get("arch", b"").decode("utf-8") + filter_pos = view_content.find("XPath Filter") + target_pos = view_content.find("Without_filters") + # No separator should exist between filter and target + between_content = view_content[filter_pos:target_pos] + self.assertNotIn("", between_content) + + def test_get_filters_excludes_non_favorites(self): + """Test that get_filters excludes non-favorite filter types.""" + # Create a non-favorite filter + self.filters_obj.create( + { + "name": "Non-Favorite Filter", + "type": "filter", + "model_id": "ir.filters.group", + "domain": '[["id","=",1]]', + } + ) + # Create a favorite filter + self.filters_obj.create( + { + "name": "Favorite Filter", + "type": "favorite", + "model_id": "ir.filters.group", + "domain": '[["id","=",2]]', + } + ) + results = self.filters_obj.get_filters("ir.filters.group") + result_names = [r["name"] for r in results] + self.assertNotIn("Non-Favorite Filter", result_names) + self.assertIn("Favorite Filter", result_names) diff --git a/base_custom_filter/views/ir_filters_group_views.xml b/base_custom_filter/views/ir_filters_group_views.xml index 73dd602471..6b2f12bcc8 100644 --- a/base_custom_filter/views/ir_filters_group_views.xml +++ b/base_custom_filter/views/ir_filters_group_views.xml @@ -16,6 +16,15 @@ + + + + + + + +