[IMP]hide_portal_module_by_user: fix invoices access in v18

This commit is contained in:
2026-08-10 16:04:48 +02:00
parent 76d0085c2d
commit bb759eb8ca
4 changed files with 67 additions and 9 deletions

View File

@@ -5,7 +5,7 @@
'name': 'Hide Portal Module By User', 'name': 'Hide Portal Module By User',
'description': """ 'description': """
Show / Hide Specific Portal Docs on res.users""", Show / Hide Specific Portal Docs on res.users""",
'version': "18.0.1.0.0", 'version': "18.0.1.1.0",
'license': 'AGPL-3', 'license': 'AGPL-3',
'author': 'Munin', 'author': 'Munin',
'website': 'https://github.com/AxeldelosReyes/odoo_web_modules', 'website': 'https://github.com/AxeldelosReyes/odoo_web_modules',

View File

@@ -5,14 +5,33 @@ from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal from odoo.addons.portal.controllers.portal import CustomerPortal
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
WHITELISTED_ROUTES = ['/my/invoices','/my/home', '/my', '/my/account', '/my/security', '/my/payment_method'] from ..models.res_users import match_portal_url
# Routes always reachable, whatever the portal groups of the user.
#
# Split in two because of how each route is matched, not because of how
# important it is: both lists are equally "always reachable".
#
# Exact match, for routes that have no sub-route of their own. '/my' has to
# stay here in any case: as a prefix it would match every portal page and
# make this module a no-op.
WHITELISTED_ROUTES = ['/my', '/my/home', '/my/account', '/my/security', '/my/payment_method']
# Prefix match, for routes that also serve detail and pager sub-pages
# ('/my/invoices/<id>', '/my/invoices/page/<n>', '/my/invoices/overdue'),
# which go through _prepare_portal_layout_values too.
WHITELISTED_ROUTE_PREFIXES = ['/my/invoices']
class CustomerPortalPolicy(CustomerPortal): class CustomerPortalPolicy(CustomerPortal):
def _prepare_portal_layout_values(self): def _prepare_portal_layout_values(self):
vals = super()._prepare_portal_layout_values() vals = super()._prepare_portal_layout_values()
current_path = request.httprequest.path current_path = request.httprequest.path
if request.env.user.validate_portal_url(current_path) or current_path in WHITELISTED_ROUTES: if current_path in WHITELISTED_ROUTES:
return vals
if any(match_portal_url(route, current_path) for route in WHITELISTED_ROUTE_PREFIXES):
return vals
if request.env.user.validate_portal_url(current_path):
return vals return vals
if '/my/' not in current_path: if '/my/' not in current_path:
return vals return vals

View File

@@ -1,16 +1,47 @@
# Copyright 2026 Munin # Copyright 2026 Munin
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from urllib.parse import parse_qsl, urlsplit
from odoo import fields, models from odoo import fields, models
def match_portal_url(portal_url, target):
"""Whether ``target`` is covered by the ``portal_url`` of a portal group.
A group url is the one read from the portal.portal_docs_entry t-call it
was generated from, and it covers:
- the listing itself and its sub-pages, since the portal serves detail
and pager pages below it ('/my/projects/135', '/my/projects/page/2'),
which go through _prepare_portal_layout_values as well;
- the entries narrowing that listing down with query arguments. Odoo 18
splits some listings in several entries this way, eg. account has
'/my/invoices?filterby=invoices' and '/my/invoices?filterby=bills'
where 16.0 had a single '/my/invoices'.
A group url carrying query arguments only matches targets carrying them
all, so a group can be restricted to one such entry; a group url without
any matches every entry of the listing.
"""
if not portal_url or not target:
return False
ref = urlsplit(portal_url)
got = urlsplit(target)
root = ref.path.rstrip('/')
if not (got.path == root or got.path.startswith(root + '/')):
return False
return set(parse_qsl(ref.query)) <= set(parse_qsl(got.query))
class ResUsers(models.Model): class ResUsers(models.Model):
_inherit = "res.users" _inherit = "res.users"
portal_url = fields.Char(string='Portal URL') portal_url = fields.Char(string='Portal URL')
def validate_portal_url(self, url): def validate_portal_url(self, url):
group = self.sudo().env['res.groups'].search([('portal_url', '=', url)]) groups = self.sudo().env['res.groups'].search([('portal_url', '!=', False)])
for group in groups.filtered(lambda g: match_portal_url(g.portal_url, url)):
if self.env.user in group.users: if self.env.user in group.users:
return True return True
return False return False

View File

@@ -2,8 +2,16 @@
<odoo> <odoo>
<data> <data>
<template inherit_id="portal.portal_docs_entry" id="hide_portal_user"> <template inherit_id="portal.portal_docs_entry" id="hide_portal_user">
<xpath expr="//a" position="attributes"> <!-- Since 18.0 the entry link is wrapped in a "o_portal_index_card"
<attribute name="t-if">request.env.user.validate_portal_url(url)</attribute> div, which the portal javascript un-hides once the counters are
loaded. The t-if has to be carried by that card, otherwise
hiding the link only leaves an empty card in the grid.
The card class is dynamic (t-att-class), so hasclass() cannot
be used to locate it. -->
<xpath expr="//a/.." position="attributes">
<!-- config_card entries (Connection &amp; Security) are not
document listings and are never group restricted. -->
<attribute name="t-if">config_card or request.env.user.validate_portal_url(url)</attribute>
</xpath> </xpath>
</template> </template>
</data> </data>