From d5009597e403c98806152175d722c883cf8d0bb6 Mon Sep 17 00:00:00 2001 From: bosd <5e2fd43-d292-4c90-9d1f-74ff3436329a@anonaddy.me> Date: Thu, 14 May 2026 12:16:55 +0200 Subject: [PATCH 1/2] [MIG][ADD] base_tier_validation_board: migration to 19.0 Forward-port of the closed-but-approved [OCA/server-ux#1155](https://github.com/OCA/server-ux/pull/1155) (authored by @astirpe / Aselcis Consulting, functional review by @CasVissers-360ERP) onto 19.0. PR #1155 migrated the module to 18.0 but went stale and was auto-closed before merge; the module never made it to 18.0 or 19.0 of OCA/server-ux or to OCA/tier-validation. This module adds a "Tier Review Activity Board" -- a kanban / form / pivot / graph dashboard scoped to ``tier.review`` records, parented under ``spreadsheet_dashboard``. A new ``group_show_tier_review_board`` group gates access to the dashboard menu so it doesn't appear for every employee by default. A per-document ACL filter on ``tier.review._search`` ensures users only see review rows whose underlying document they themselves have read access to -- without this a regular employee with the board group could observe reviews of records they otherwise cannot see. ## 19.0 adaptations vs. the 18.0 PR - ``from odoo.osv import expression`` -> ``from odoo.fields import Domain``. ``odoo.osv.expression`` is no longer available in 19.0. - Dropped the ``_read_group_raw`` override entirely. The method was removed from Odoo's ORM in 17.0; the ``_read_group`` path now routes through ``_search``, so the ``_search`` override above already enforces the same per-document ACL on grouped queries. Inline comment left explaining why the override is intentionally absent. - Manifest: version 18.0.1.0.0 -> 19.0.1.0.0; website URL retargeted to OCA/tier-validation. ## Credit Built on top of the work by @JasminSForgeFlow (original 16.0 module), @astirpe (16.0 -> 18.0 migration in #1155), and @CasVissers-360ERP (functional review on #1155). ## Test plan - Install ``base_tier_validation_board`` against a database that already exercises tier validation (e.g. install ``account_move_tier_validation`` and create a few pending reviews). - As an admin in the ``Show tier review board`` group, the *Tier Reviews* menu appears under the *Dashboards* root and shows the kanban grouped by status. - As a user **not** in that group, the menu is absent. - As a user in the group but lacking read access on the underlying validated model (e.g. ``account.move``), the corresponding reviews are filtered out of the kanban / list / form views via the ``_search`` override. --- base_tier_validation_board/README.rst | 97 ++++ base_tier_validation_board/__init__.py | 1 + base_tier_validation_board/__manifest__.py | 18 + .../i18n/base_tier_validation_board.pot | 55 +++ base_tier_validation_board/i18n/it.po | 58 +++ base_tier_validation_board/models/__init__.py | 2 + .../models/res_users.py | 35 ++ .../models/tier_review.py | 192 ++++++++ base_tier_validation_board/pyproject.toml | 3 + .../readme/CONTRIBUTORS.md | 2 + .../readme/DESCRIPTION.md | 2 + base_tier_validation_board/readme/USAGE.md | 6 + .../security/groups.xml | 10 + .../static/description/icon.png | Bin 0 -> 4993 bytes .../static/description/index.html | 439 ++++++++++++++++++ base_tier_validation_board/tests/__init__.py | 1 + .../tests/test_tier_validation_board.py | 157 +++++++ .../views/tier_review_view.xml | 374 +++++++++++++++ 18 files changed, 1452 insertions(+) create mode 100644 base_tier_validation_board/README.rst create mode 100644 base_tier_validation_board/__init__.py create mode 100644 base_tier_validation_board/__manifest__.py create mode 100644 base_tier_validation_board/i18n/base_tier_validation_board.pot create mode 100644 base_tier_validation_board/i18n/it.po create mode 100644 base_tier_validation_board/models/__init__.py create mode 100644 base_tier_validation_board/models/res_users.py create mode 100644 base_tier_validation_board/models/tier_review.py create mode 100644 base_tier_validation_board/pyproject.toml create mode 100644 base_tier_validation_board/readme/CONTRIBUTORS.md create mode 100644 base_tier_validation_board/readme/DESCRIPTION.md create mode 100644 base_tier_validation_board/readme/USAGE.md create mode 100644 base_tier_validation_board/security/groups.xml create mode 100644 base_tier_validation_board/static/description/icon.png create mode 100644 base_tier_validation_board/static/description/index.html create mode 100644 base_tier_validation_board/tests/__init__.py create mode 100644 base_tier_validation_board/tests/test_tier_validation_board.py create mode 100644 base_tier_validation_board/views/tier_review_view.xml diff --git a/base_tier_validation_board/README.rst b/base_tier_validation_board/README.rst new file mode 100644 index 00000000..37413e7c --- /dev/null +++ b/base_tier_validation_board/README.rst @@ -0,0 +1,97 @@ +========================== +Tier Review Activity Board +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:6bc34836e6b88351ca1f0343b26edf7d8951087c0e3dd7127533620b2ef42e07 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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 + :target: https://github.com/OCA/server-ux/tree/18.0/base_tier_validation_board + :alt: OCA/server-ux +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-ux-18-0/server-ux-18-0-base_tier_validation_board + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-ux&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds an tier review board with form, tree, kanban, calendar, +pivot, graph and search views. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use this module, you need to: + +1. Access to the views from menu Boards. + +From the form view of the tier review you can navigate to the origin of +the tier review. + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ForgeFlow + +Contributors +------------ + +- `ForgeFlow `__: + + - Jasmin Solanki (jasmin.solanki@forgeflow.com) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +.. |maintainer-JasminSForgeFlow| image:: https://github.com/JasminSForgeFlow.png?size=40px + :target: https://github.com/JasminSForgeFlow + :alt: JasminSForgeFlow + +Current `maintainer `__: + +|maintainer-JasminSForgeFlow| + +This module is part of the `OCA/server-ux `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_tier_validation_board/__init__.py b/base_tier_validation_board/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/base_tier_validation_board/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_tier_validation_board/__manifest__.py b/base_tier_validation_board/__manifest__.py new file mode 100644 index 00000000..87c61cde --- /dev/null +++ b/base_tier_validation_board/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2024 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Tier Review Activity Board", + "summary": "Dashboard with kanban, list, pivot and graph views over" + " every pending/approved/rejected tier review, gated by an opt-in" + " group and per-document ACL.", + "version": "19.0.1.0.0", + "development_status": "Beta", + "maintainers": ["JasminSForgeFlow"], + "category": "Tools", + "website": "https://github.com/OCA/tier-validation", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "license": "AGPL-3", + "installable": True, + "depends": ["base_tier_validation", "spreadsheet_dashboard"], + "data": ["security/groups.xml", "views/tier_review_view.xml"], +} diff --git a/base_tier_validation_board/i18n/base_tier_validation_board.pot b/base_tier_validation_board/i18n/base_tier_validation_board.pot new file mode 100644 index 00000000..8fde1b60 --- /dev/null +++ b/base_tier_validation_board/i18n/base_tier_validation_board.pot @@ -0,0 +1,55 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_tier_validation_board +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_tier_validation_board +#: model_terms:ir.ui.view,arch_db:base_tier_validation_board.tier_review_view_kanban +msgid "Comment: " +msgstr "" + +#. module: base_tier_validation_board +#: model_terms:ir.ui.view,arch_db:base_tier_validation_board.tier_review_view_kanban +msgid "Requsted by: " +msgstr "" + +#. module: base_tier_validation_board +#: model_terms:ir.ui.view,arch_db:base_tier_validation_board.tier_review_view_kanban +msgid "Todo by: " +msgstr "" + +#. module: base_tier_validation_board +#: model:ir.model.fields,field_description:base_tier_validation_board.field_tier_review__related_model_instance +msgid "Document" +msgstr "" + +#. module: base_tier_validation_board +#: model:ir.model.fields,field_description:base_tier_validation_board.field_tier_review__res_name +msgid "Resource Name" +msgstr "" + +#. module: base_tier_validation_board +#: model:res.groups,name:base_tier_validation_board.group_show_tier_review_board +msgid "Show tier review board" +msgstr "" + +#. module: base_tier_validation_board +#: model:ir.model,name:base_tier_validation_board.model_tier_review +msgid "Tier Review" +msgstr "" + +#. module: base_tier_validation_board +#: model:ir.actions.act_window,name:base_tier_validation_board.open_boards_tier_reviews +#: model:ir.ui.menu,name:base_tier_validation_board.board_menu_tier_reviews +msgid "Tier Reviews" +msgstr "" diff --git a/base_tier_validation_board/i18n/it.po b/base_tier_validation_board/i18n/it.po new file mode 100644 index 00000000..007fa91f --- /dev/null +++ b/base_tier_validation_board/i18n/it.po @@ -0,0 +1,58 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_tier_validation_board +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-06-26 10:45+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: base_tier_validation_board +#: model_terms:ir.ui.view,arch_db:base_tier_validation_board.tier_review_view_kanban +msgid "Comment: " +msgstr "Commento: " + +#. module: base_tier_validation_board +#: model_terms:ir.ui.view,arch_db:base_tier_validation_board.tier_review_view_kanban +msgid "Requsted by: " +msgstr "Richiesto da: " + +#. module: base_tier_validation_board +#: model_terms:ir.ui.view,arch_db:base_tier_validation_board.tier_review_view_kanban +msgid "Todo by: " +msgstr "Da fare di: " + +#. module: base_tier_validation_board +#: model:ir.model.fields,field_description:base_tier_validation_board.field_tier_review__related_model_instance +msgid "Document" +msgstr "Documento" + +#. module: base_tier_validation_board +#: model:ir.model.fields,field_description:base_tier_validation_board.field_tier_review__res_name +msgid "Resource Name" +msgstr "Nome risorsa" + +#. module: base_tier_validation_board +#: model:res.groups,name:base_tier_validation_board.group_show_tier_review_board +msgid "Show tier review board" +msgstr "Visualizza scheda revisione livelli" + +#. module: base_tier_validation_board +#: model:ir.model,name:base_tier_validation_board.model_tier_review +msgid "Tier Review" +msgstr "Revisione livello" + +#. module: base_tier_validation_board +#: model:ir.actions.act_window,name:base_tier_validation_board.open_boards_tier_reviews +#: model:ir.ui.menu,name:base_tier_validation_board.board_menu_tier_reviews +msgid "Tier Reviews" +msgstr "Revisioni livello" diff --git a/base_tier_validation_board/models/__init__.py b/base_tier_validation_board/models/__init__.py new file mode 100644 index 00000000..942b13b3 --- /dev/null +++ b/base_tier_validation_board/models/__init__.py @@ -0,0 +1,2 @@ +from . import res_users +from . import tier_review diff --git a/base_tier_validation_board/models/res_users.py b/base_tier_validation_board/models/res_users.py new file mode 100644 index 00000000..cdc53a9c --- /dev/null +++ b/base_tier_validation_board/models/res_users.py @@ -0,0 +1,35 @@ +# Copyright 2026 OCA / @bosd +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, models + + +class Users(models.Model): + _inherit = "res.users" + + @api.model + def tier_review_dashboard_action(self): + """Expose the Tier Reviews dashboard as the destination of the + systray's "Show all reviews" footer link. + + The base module ships ``tier_review_dashboard_action`` as a stub + returning ``False`` so the systray omits the link when no + dashboard module is installed. When this module is installed, + we point the link at the all-reviews kanban gated by + ``group_show_tier_review_board``. + """ + action = self.env.ref( + "base_tier_validation_board.open_boards_tier_reviews", + raise_if_not_found=False, + ) + if not action: + return False + # Only expose the link to users who can actually see the + # dashboard menu in the first place. Otherwise the link would + # take them to an action that immediately errors with an + # AccessError. + if not self.env.user.has_group( + "base_tier_validation_board.group_show_tier_review_board" + ): + return False + return action.read()[0] diff --git a/base_tier_validation_board/models/tier_review.py b/base_tier_validation_board/models/tier_review.py new file mode 100644 index 00000000..f59a417e --- /dev/null +++ b/base_tier_validation_board/models/tier_review.py @@ -0,0 +1,192 @@ +# Copyright 2024 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models +from odoo.tools import SQL, split_every + +OVERDUE_DAYS = 7 +# Days after which a still-pending/waiting tier review is considered +# overdue. Surfaced as ``is_overdue`` and used by the kanban "rotten" +# indicator and the "Overdue" search filter. + + +class TierReview(models.Model): + _name = "tier.review" + _inherit = ["tier.review", "mail.thread", "mail.activity.mixin"] + + @api.depends("model", "res_id") + def _compute_res_name(self): + for record in self: + if record.res_id and record.model: + record.res_name = ( + self.env[record.model].browse(record.res_id).display_name + ) + else: + record.res_name = False + + related_model_instance = fields.Reference( + selection="_selection_related_model_instance", + compute="_compute_related_model_instance", + string="Document", + ) + res_name = fields.Char( + "Resource Name", compute="_compute_res_name", compute_sudo=True + ) + model_id = fields.Many2one( + comodel_name="ir.model", + related="definition_id.model_id", + store=True, + string="Model", + help="Many2one to ir.model used by the pivot/graph views so the " + "rows display the model's human-friendly description (e.g. " + "'Journal Entry') rather than its technical name " + "('account.move').", + ) + response_days = fields.Float( + string="Response (days)", + compute="_compute_response_days", + store=True, + help="Days between the review being created and it being done. " + "Empty until the review is approved or rejected. Use this as a " + "measure in pivot/graph views to compare reviewer response time.", + ) + is_overdue = fields.Boolean( + compute="_compute_is_overdue", + search="_search_is_overdue", + help="True when this review is still pending/waiting and was " + f"created more than {OVERDUE_DAYS} days ago. Surfaced on the " + "kanban with a 'rotten' indicator.", + ) + + @api.depends("create_date", "reviewed_date") + def _compute_response_days(self): + for rec in self: + if rec.create_date and rec.reviewed_date: + rec.response_days = ( + rec.reviewed_date - rec.create_date + ).total_seconds() / 86400.0 + else: + rec.response_days = 0.0 + + @api.depends("status", "create_date") + def _compute_is_overdue(self): + cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=OVERDUE_DAYS) + for rec in self: + rec.is_overdue = ( + rec.status in ("waiting", "pending") + and rec.create_date + and rec.create_date < cutoff + ) + + @api.model + def _search_is_overdue(self, operator, value): + if operator not in ("=", "!=") or not isinstance(value, bool): + return [("id", "=", False)] + cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=OVERDUE_DAYS) + overdue_ids = ( + self.sudo() + .search( + [ + ("status", "in", ["waiting", "pending"]), + ("create_date", "<", cutoff), + ] + ) + .ids + ) + match = (operator == "=") == bool(value) + return [("id", "in" if match else "not in", overdue_ids)] + + @api.depends("res_id", "model") + def _compute_related_model_instance(self): + for record in self: + ref = False + if record.res_id: + ref = f"{record.model},{record.res_id}" + record.related_model_instance = ref + + @api.model + def _selection_related_model_instance(self): + # Restrict the Reference selection to models that actually carry + # tier validation. Avoids the pylint `no-search-all` warning and + # mirrors the domain used in `tier.definition.model_id`. + model_names = self.env["tier.definition"]._get_tier_validation_model_names() + if not model_names: + return [] + models = self.env["ir.model"].sudo().search([("model", "in", model_names)]) + return [(model.model, model.name) for model in models] + + def open_origin(self): + self.ensure_one() + vid = self.env[self.model].browse(self.res_id).get_formview_id() + response = { + "type": "ir.actions.act_window", + "res_model": self.model, + "view_mode": "form", + "res_id": self.res_id, + "target": "current", + "views": [(vid, "form")], + } + return response + + @api.model + def _search(self, domain, *args, **kwargs): + # Forward arbitrary kwargs to super() (the v19 ORM keeps adding + # them: ``bypass_access``, ``active_test``, ...). When the caller + # has explicitly opted out of ACL or is the superuser, skip the + # board's per-document filter -- the board filter is itself an + # ACL, so bypass_access=True should bypass it too. + if kwargs.get("bypass_access") or self.env.is_superuser(): + return super()._search(domain, *args, **kwargs) + query = super()._search(domain, *args, **kwargs) + ids = self.browse(query).ids + if not ids: + return query + + super().check_access("read") + + self.flush_model(["model", "res_id"]) + reviews_to_check = [] + for sub_ids in split_every(self.env.cr.IN_MAX, ids): + self.env.cr.execute( + SQL( + """ + SELECT DISTINCT review.id, review.model, review.res_id + FROM %(table)s review + WHERE review.id = ANY (%(ids)s) AND review.res_id != 0""", + table=SQL.identifier(self._table), + ids=list(sub_ids), + ) + ) + reviews_to_check += self.env.cr.dictfetchall() + + review_to_documents = {} + for review in reviews_to_check: + review_to_documents.setdefault(review["model"], set()).add(review["res_id"]) + + allowed_ids = set() + for doc_model, doc_ids in review_to_documents.items(): + doc_operation = "read" + DocumentModel = self.env[doc_model].with_user(self.env.uid) + right = DocumentModel.has_access(doc_operation) + if right: + valid_docs = DocumentModel.browse(doc_ids)._filtered_access( + doc_operation + ) + valid_doc_ids = set(valid_docs.ids) + allowed_ids.update( + review["id"] + for review in reviews_to_check + if review["model"] == doc_model + and review["res_id"] in valid_doc_ids + ) + + id_list = [id for id in ids if id in allowed_ids] + + return super()._search([("id", "in", id_list)], *args, **kwargs) + + # NOTE: previous Odoo versions overrode ``_read_group_raw`` here to + # re-apply the same per-document ACL the ``_search`` override above + # enforces -- needed because the read_group path used to bypass + # ``_search``. From Odoo 17 onwards ``_read_group_raw`` is gone and + # ``_read_group`` routes through ``_search`` internally, so the + # ``_search`` override already covers the read_group case and the + # explicit override is no longer required. diff --git a/base_tier_validation_board/pyproject.toml b/base_tier_validation_board/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/base_tier_validation_board/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_tier_validation_board/readme/CONTRIBUTORS.md b/base_tier_validation_board/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..f2649e5e --- /dev/null +++ b/base_tier_validation_board/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [ForgeFlow](https://www.forgeflow.com): + - Jasmin Solanki () diff --git a/base_tier_validation_board/readme/DESCRIPTION.md b/base_tier_validation_board/readme/DESCRIPTION.md new file mode 100644 index 00000000..c1521ec2 --- /dev/null +++ b/base_tier_validation_board/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module adds an tier review board with form, tree, kanban, calendar, +pivot, graph and search views. diff --git a/base_tier_validation_board/readme/USAGE.md b/base_tier_validation_board/readme/USAGE.md new file mode 100644 index 00000000..4ad5276d --- /dev/null +++ b/base_tier_validation_board/readme/USAGE.md @@ -0,0 +1,6 @@ +To use this module, you need to: + +1. Access to the views from menu Boards. + +From the form view of the tier review you can navigate to the origin of +the tier review. diff --git a/base_tier_validation_board/security/groups.xml b/base_tier_validation_board/security/groups.xml new file mode 100644 index 00000000..d0b64006 --- /dev/null +++ b/base_tier_validation_board/security/groups.xml @@ -0,0 +1,10 @@ + + + + Show tier review board + + + diff --git a/base_tier_validation_board/static/description/icon.png b/base_tier_validation_board/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e4781126005a66566540c64bfb0fe55090148795 GIT binary patch literal 4993 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 literal 0 HcmV?d00001 diff --git a/base_tier_validation_board/static/description/index.html b/base_tier_validation_board/static/description/index.html new file mode 100644 index 00000000..df4296c7 --- /dev/null +++ b/base_tier_validation_board/static/description/index.html @@ -0,0 +1,439 @@ + + + + + +Tier Review Activity Board + + + +
+

Tier Review Activity Board

+ + +

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

+

This module adds an tier review board with form, tree, kanban, calendar, +pivot, graph and search views.

+

Table of contents

+ +
+

Usage

+

To use this module, you need to:

+
    +
  1. Access to the views from menu Boards.
  2. +
+

From the form view of the tier review you can navigate to the origin of +the tier review.

+
+
+

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 +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +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.

+

Current maintainer:

+

JasminSForgeFlow

+

This module is part of the OCA/server-ux project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/base_tier_validation_board/tests/__init__.py b/base_tier_validation_board/tests/__init__.py new file mode 100644 index 00000000..19cb3f59 --- /dev/null +++ b/base_tier_validation_board/tests/__init__.py @@ -0,0 +1 @@ +from . import test_tier_validation_board diff --git a/base_tier_validation_board/tests/test_tier_validation_board.py b/base_tier_validation_board/tests/test_tier_validation_board.py new file mode 100644 index 00000000..e5fba77d --- /dev/null +++ b/base_tier_validation_board/tests/test_tier_validation_board.py @@ -0,0 +1,157 @@ +# Copyright 2026 OCA / @bosd +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from freezegun import freeze_time + +from odoo import fields +from odoo.fields import Domain +from odoo.tests.common import tagged + +from odoo.addons.base_tier_validation.tests.common import CommonTierValidation + + +@tagged("post_install", "-at_install") +class TierValidationBoard(CommonTierValidation): + def setUp(self): + super().setUp() + # Trigger a review on the standard test fixture so the board + # always has at least one record to render. + self.test_record.with_user(self.test_user_2.id).request_validation() + self.review = self.env["tier.review"].search( + Domain("res_id", "=", self.test_record.id) + & Domain("model", "=", self.test_record._name), + limit=1, + ) + self.assertTrue(self.review) + + def test_related_model_instance_compute(self): + """The board's Reference + Char computes resolve cleanly off a + real tier review.""" + review = self.review + self.assertEqual(review.res_name, self.test_record.display_name) + self.assertEqual(review.related_model_instance._name, self.test_record._name) + self.assertEqual(review.related_model_instance.id, self.test_record.id) + + def test_model_id_related(self): + """The `model_id` related field resolves to the validated model's + `ir.model` record so pivot/graph views can display its + human-friendly description instead of the technical name.""" + self.assertEqual(self.review.model_id, self.tester_model) + self.assertEqual(self.review.model_id.model, self.test_record._name) + + def test_search_is_overdue_returns_id_in_domain(self): + """`_search_is_overdue` returns a domain shape the ORM can use -- + either ``[("id", "in", ids)]`` or ``[("id", "not in", ids)]`` + depending on the operator/value pair, and never raises.""" + Model = self.env["tier.review"] + for value in (True, False): + for operator in ("=", "!="): + domain = Model._search_is_overdue(operator, value) + self.assertEqual(len(domain), 1) + cond = domain[0] + self.assertEqual(cond[0], "id") + self.assertIn(cond[1], ("in", "not in", "=")) + # Bad operator -> match nothing (does not raise). + bad = Model._search_is_overdue("ilike", "garbage") + self.assertEqual(bad, [("id", "=", False)]) + + def test_selection_related_model_instance(self): + """The Reference selection only exposes tier-validated models, + not every model in the DB.""" + selection = self.env["tier.review"]._selection_related_model_instance() + models = {m for m, _name in selection} + expected = set(self.env["tier.definition"]._get_tier_validation_model_names()) + self.assertEqual(models, expected) + # Sanity: at least the standard tester model is in there. + self.assertIn(self.test_record._name, models) + + def test_open_origin(self): + """The board's `Open` button returns an act_window pointing at + the underlying validated record.""" + action = self.review.open_origin() + self.assertEqual(action["res_model"], self.test_record._name) + self.assertEqual(action["res_id"], self.test_record.id) + self.assertEqual(action["view_mode"], "form") + + def test_response_days_is_zero_until_reviewed(self): + """`response_days` only populates once the review is approved + or rejected; it stays 0 while pending/waiting.""" + self.assertEqual(self.review.response_days, 0.0) + # Force-set a reviewed_date so the compute fires deterministically. + self.review.write( + { + "reviewed_date": fields.Datetime.add(self.review.create_date, days=2), + "status": "approved", + "done_by": self.test_user_1.id, + } + ) + self.assertAlmostEqual(self.review.response_days, 2.0, places=2) + + def test_is_overdue_compute(self): + """`is_overdue` flips True for waiting/pending reviews older + than the configured threshold, and resets when the review is + completed. + + Uses ``freeze_time`` to move "now" past the threshold instead + of rewriting create_date, keeping the test independent of any + ORM-side cache invariants around the create_date magic field. + """ + # Fresh review: not overdue. + self.assertFalse(self.review.is_overdue) + # Move "now" 14 days forward -- the review's real create_date + # is now well past the 7-day overdue threshold. + later = fields.Datetime.add(fields.Datetime.now(), days=14) + with freeze_time(later): + self.review.invalidate_recordset(["is_overdue"]) + self.assertTrue(self.review.is_overdue) + # Once approved, the review is no longer "overdue" -- even + # if it took forever, it's done. + self.review.write({"status": "approved", "done_by": self.test_user_1.id}) + self.assertFalse(self.review.is_overdue) + + def test_tier_review_dashboard_action_for_authorised_user(self): + """Users in the board group get the dashboard action back from + the systray hook so the "Show all reviews" footer link can open + it. Users without the group get `False` -- without that gate + clicking the link would fail with an AccessError.""" + group = self.env.ref("base_tier_validation_board.group_show_tier_review_board") + admin = self.env.ref("base.user_admin") + # Admin is already in the group via the security/groups.xml + # default; assert and read the action. + self.assertIn(admin, group.user_ids) + action = self.env["res.users"].with_user(admin).tier_review_dashboard_action() + self.assertTrue(action) + self.assertEqual(action.get("res_model"), "tier.review") + # test_user_2 is a plain employee and not in the dashboard group + # -> the hook returns False so the systray omits the link. + self.assertNotIn(self.test_user_2, group.user_ids) + no_action = ( + self.env["res.users"] + .with_user(self.test_user_2) + .tier_review_dashboard_action() + ) + self.assertFalse(no_action) + + def test_search_filters_by_user_acl(self): + """The `_search` override hides reviews whose underlying record + the current user cannot read. The standard tester model has a + public ACL so test_user_2 sees everything; revoke that ACL and + the review should drop out of the board for them.""" + # Sanity: test_user_2 can see the review with the default ACL. + visible = ( + self.env["tier.review"] + .with_user(self.test_user_2) + .search(Domain("id", "=", self.review.id)) + ) + self.assertEqual(visible, self.review) + # Restrict the model's ACL to admins; test_user_2 loses access. + self.env["ir.model.access"].search( + Domain("model_id", "=", self.tester_model.id) + ).write({"group_id": self.env.ref("base.group_system").id}) + self.env["ir.model.access"].call_cache_clearing_methods() + hidden = ( + self.env["tier.review"] + .with_user(self.test_user_2) + .search(Domain("id", "=", self.review.id)) + ) + self.assertFalse(hidden) diff --git a/base_tier_validation_board/views/tier_review_view.xml b/base_tier_validation_board/views/tier_review_view.xml new file mode 100644 index 00000000..c83923e1 --- /dev/null +++ b/base_tier_validation_board/views/tier_review_view.xml @@ -0,0 +1,374 @@ + + + + tier.review.boards.view.form + tier.review + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + tier.review.boards.view.kanban + tier.review + + + + + + + + + + + + + + +
+
+ + + + + + +
+
+
+
+ Requested by + + +
+
+ To review + +
+
+ Todo by: + +
+
+ Done by + + + on + +
+
+ Comment: + +
+
+
+
+ Requested +
+
+ +
+
+
+
+
+
+
+ + + tier.review.boards.view.pivot + tier.review + + + + + + + + + + + tier.review.boards.view.graph + tier.review + + + + + + + + + + tier.review.boards.view.search + tier.review + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tier Reviews + tier.review + kanban,list,form,pivot,graph + [] + {} + + + + + + tier.review.boards.view.stats.pivot + tier.review + + + + + + + + + + + tier.review.boards.view.stats.graph + tier.review + + + + + + + + + + Review Statistics + tier.review + pivot,graph + [("done_by", "!=", False), ("status", "in", ("approved", "rejected"))] + {} + + + <p class="o_view_nocontent_smiling_face"> + No completed reviews to chart yet. + </p><p> + Statistics are computed across approved and rejected reviews. + The default *Avg. days* measure shows the average response + time per reviewer. Use the *Group by* menu in the search bar + to slice by Model, Definition or Requested by. + </p> + + + + My Tier Reviews + tier.review + list,kanban,form,pivot,graph + [("reviewer_ids", "in", uid), ("status", "in", ("pending", "waiting"))] + {"search_default_my_reviews": 1} + + + + + + + + +
From f05dd1b8998f7b90c425bbc84631cb256430face Mon Sep 17 00:00:00 2001 From: bosd <5e2fd43-d292-4c90-9d1f-74ff3436329a@anonaddy.me> Date: Sun, 17 May 2026 23:57:59 +0200 Subject: [PATCH 2/2] [FIX] base_tier_validation_board: no New button on review actions tier.review records are only ever created programmatically by request_validation() (they require model/res_id/definition_id), so a manually-created record can never be saved. The "My Reviews" action defaulted to the list view, which has no create="0", and neither review action set a create:False context -- so users got a New button that produced an unsavable record. "All Reviews" only hid it incidentally (kanban-first) and still showed it on its list tab. Set context {'create': False} on both open_my_tier_reviews and open_boards_tier_reviews so the New button is gone across every view mode of both actions. --- base_tier_validation_board/views/tier_review_view.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/base_tier_validation_board/views/tier_review_view.xml b/base_tier_validation_board/views/tier_review_view.xml index c83923e1..7f309ba1 100644 --- a/base_tier_validation_board/views/tier_review_view.xml +++ b/base_tier_validation_board/views/tier_review_view.xml @@ -261,7 +261,7 @@ tier.review kanban,list,form,pivot,graph [] - {} + {'create': False} [("reviewer_ids", "in", uid), ("status", "in", ("pending", "waiting"))] - {"search_default_my_reviews": 1} + {"search_default_my_reviews": 1, "create": False}