Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 27 additions & 21 deletions base_custom_filter/README.rst
Original file line number Diff line number Diff line change
@@ -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
=============================================================
Expand All @@ -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
Expand All @@ -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**

Expand All @@ -53,26 +50,35 @@ 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 <https://www.odoo.com/documentation/16.0/developer/reference/backend/views.html#search>`__
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)

Usage
=====

Expand Down Expand Up @@ -102,19 +108,19 @@ Authors
Contributors
------------

- `ForgeFlow S.L. <https://www.forgeflow.com>`__:
- `ForgeFlow S.L. <https://www.forgeflow.com>`__:

- Jordi Masvidal
- Jordi Masvidal

- Ashish Hirpara
<`https://www.ashish-hirpara.com\\> <https://www.ashish-hirpara.com\>>`__
- `Quartile <https://www.quartile.co>`__:
- Ashish Hirpara <https://www.ashish-hirpara.com>
- `Quartile <https://www.quartile.co>`__:

- Aung Ko Ko Lin
- Aung Ko Ko Lin
- Yoshi Tashiro

- `Kencove <https://www.kencove.com>`__:
- `Kencove <https://www.kencove.com>`__:

- Mohamed Alkobrosli
- Mohamed Alkobrosli

Maintainers
-----------
Expand Down
156 changes: 116 additions & 40 deletions base_custom_filter/models/base.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
14 changes: 11 additions & 3 deletions base_custom_filter/models/ir_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
32 changes: 31 additions & 1 deletion base_custom_filter/models/ir_filters_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions base_custom_filter/readme/CONFIGURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 1 addition & 0 deletions base_custom_filter/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
- Ashish Hirpara \<https://www.ashish-hirpara.com\>
- [Quartile](https://www.quartile.co):
- Aung Ko Ko Lin
- Yoshi Tashiro
- [Kencove](https://www.kencove.com):
- Mohamed Alkobrosli
3 changes: 2 additions & 1 deletion base_custom_filter/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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.
Loading