odoo17核心概念menu1——主菜单
作者:mmseoamin日期:2023-12-21

odoo的菜单可以分为主菜单和用户菜单,主菜单就是点击左上角的图标弹出的下拉列表中的菜单,而用户菜单是点击右上角用户头像弹出来的菜单,本文只介绍主菜单。

主菜单(menus)

1、模板web.layout

views\webclient_templates.xml

这个模板是webclient中加载的模板,里面有段js,定义了odoo的几个属性,重点关注loadMenusPromise

通过/web/webclient/load_menus/ 路由获取菜单

                    {
                        odoo.__session_info__ = ;
                        const { user_context,  cache_hashes } = odoo.__session_info__;
                        const lang = new URLSearchParams(document.location.search).get("lang");
                        let menuURL = `/web/webclient/load_menus/${cache_hashes.load_menus}`;
                        if (lang) {
                            user_context.lang = lang;
                            menuURL += `?lang=${lang}`
                        }
                        odoo.reloadMenus = () => fetch(menuURL).then(res => res.json());
                        odoo.loadMenusPromise = odoo.reloadMenus();
                        // Prefetch translations to speedup webclient. This is done in JS because link rel="prefetch"
                        // is not yet supported on safari.
                        fetch(`/web/webclient/translations/${cache_hashes.translations}?lang=${user_context.lang}`);
                    }

2、前端 MenuService

static\src\webclient\menus\menu_service.js

核心是闭包内的这个函数fetchLoadMenus,因为在上面的模板中定义了odoo.loadMenusPromise ,所以,该函数会返回odoo.loadMenusPromise

makeMenus 返回一个对象,提供对菜单数据的各种工具函数,也包括重新加载的方法。

/** @odoo-module **/
import { browser } from "../../core/browser/browser";
import { registry } from "../../core/registry";
import { session } from "@web/session";
const loadMenusUrl = `/web/webclient/load_menus`;
function makeFetchLoadMenus() {
    const cacheHashes = session.cache_hashes;
    let loadMenusHash = cacheHashes.load_menus || new Date().getTime().toString();
    debugger
    return async function fetchLoadMenus(reload) {
        if (reload) {
            loadMenusHash = new Date().getTime().toString();
        } else if (odoo.loadMenusPromise) {
            return odoo.loadMenusPromise;
        }
        const res = await browser.fetch(`${loadMenusUrl}/${loadMenusHash}`);
        if (!res.ok) {
            throw new Error("Error while fetching menus");
        }
        return res.json();
    };
}
function makeMenus(env, menusData, fetchLoadMenus) {
    let currentAppId;
    return {
        getAll() {
            return Object.values(menusData);
        },
        getApps() {
            return this.getMenu("root").children.map((mid) => this.getMenu(mid));
        },
        getMenu(menuID) {
            return menusData[menuID];
        },
        getCurrentApp() {
            if (!currentAppId) {
                return;
            }
            return this.getMenu(currentAppId);
        },
        getMenuAsTree(menuID) {
            const menu = this.getMenu(menuID);
            if (!menu.childrenTree) {
                menu.childrenTree = menu.children.map((mid) => this.getMenuAsTree(mid));
            }
            return menu;
        },
        async selectMenu(menu) {
            menu = typeof menu === "number" ? this.getMenu(menu) : menu;
            if (!menu.actionID) {
                return;
            }
            await env.services.action.doAction(menu.actionID, {
                clearBreadcrumbs: true,
                onActionReady: () => {
                    this.setCurrentMenu(menu);
                },
            });
        },
        setCurrentMenu(menu) {
            menu = typeof menu === "number" ? this.getMenu(menu) : menu;
            if (menu && menu.appID !== currentAppId) {
                currentAppId = menu.appID;
                env.bus.trigger("MENUS:APP-CHANGED");
                env.services.router.pushState({ menu_id: menu.id }, { lock: true });
            }
        },
        async reload() {
            if (fetchLoadMenus) {
                menusData = await fetchLoadMenus(true);
                env.bus.trigger("MENUS:APP-CHANGED");
            }
        },
    };
}
export const menuService = {
    dependencies: ["action", "router"],
    async start(env) {
        const fetchLoadMenus = makeFetchLoadMenus();
        const menusData = await fetchLoadMenus();
        return makeMenus(env, menusData, fetchLoadMenus);
    },
};
registry.category("services").add("menu", menuService);

获取到的菜单数据是一个json对象, 这里只列出几个,以菜单的id作为key,其中根对象的key是“root”,放在字典的最后

value 也是一个对象,有如下属性:

        "name": "设置",							菜单名称
        "children": [ 66, 3,4,8],				 子菜单id列表
        "appID": 1,								 模块id
        "xmlid": "base.menu_administration",     菜单的xmlid
        "actionID": 84,							 菜单对应的动作id
        "actionModel": "ir.actions.act_window",                 动作类型
        "webIcon": "base,static/description/settings.png",      图标路径
        "webIconData": 'xxxxxxxxxxx'							图标的base64数据
        "webIconDataMimetype": "image/png"                      图标文件的类型

列举其中几个菜单项:

{
    "1": {
        "id": 1,
        "name": "设置",
        "children": [
            66,
            3,
            4,
            8
        ],
        "appID": 1,
        "xmlid": "base.menu_administration",
        "actionID": 84,
        "actionModel": "ir.actions.act_window",
        "webIcon": "base,static/description/settings.png",
        "webIconData": "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",
        "webIconDataMimetype": "image/png"
    },
    "3": {
        "id": 3,
        "name": "用户和公司",
        "children": [
            60,
            59,
            57
        ],
        "appID": 1,
        "xmlid": "base.menu_users",
        "actionID": false,
        "actionModel": false,
        "webIcon": false,
        "webIconData": false,
        "webIconDataMimetype": false
    },
    "130": {
        "id": 130,
        "name": "待办事项",
        "children": [],
        "appID": 130,
        "xmlid": "project_todo.menu_todo_todos",
        "actionID": 224,
        "actionModel": "ir.actions.server",
        "webIcon": "project_todo,static/description/icon.png",
        "webIconData": "iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAMqSURBVHgB7d3NThNRGMbxZ6aoqVHRkODO1MSlhqh7Y1DXgldQ4g24wyWQCBg3cAOmNsal8QoU4wUoVwAr45KNH4l0jvMiGEVop53zfZ5fyEDSYcM/7xzanrYAERERERERERFRGDLQ0drz5SG7jV3MIM/ulz+39m/ZgVIbyPI36D55Ac0Y5LD2IzleQK/5GlkZpL8t7P58gFfPPkETBvlb+7EcL6PI35bfWxV/S5Vfc+iuaJkWBjkwWowDCkUxjZerG6gpB9WNITLk+XNo0EDq6sc4cB5Tt7ax+WETNaQ9IfpiiPLy35tBTekG0RtjXz6FmtIMYiTGnhZqSi+IuRhapBXEeAxV+w5iOkFsTEaWMUgldi5TClmxhJriD2JtzSjW0VndRk1xB7G3gH9E/mMRGsT7WJbVGN+m0VnbgQZxBgk0hogvSMAxRIxrSLAxRFwT0p5voWi8Q6AxRDxBIogh4ggSSQwRfpCIYoiwg0QWQ4QbJMIYIswgkcYQ4QWJOIYI646hlRiq4yqGCGdCbMXorszBoTCCJBJD+B8koRjC7yCJxRD+BkkwhvAzSKIxhH9BEo4h/AqSeAzhTxDG2ONHEMb4w30QxviH2yCM8R93QRjjSGOwzd6+qcUyxgICY3dCbO1CB5bQXV5AgOwFsRejnIxlLRufXbDzBJXVy1S4MYT5IIwxFLOXLLsxFhABc0EYYyRmgjDGyPQHYYxa9AZhjNr0BWEMLfQEYQxt6gdhDK3qBWEM7UYPwhhGjBaEMYwZPghjGDVcEMYwrnoQxrCiWhDGsGZwEMawqsoTVIxh0eAgjGFVlQlpwRzGOMTlq3AZ4wiugjDGMVwEYYw+bG4lDXpHoS02gzxszI53MPvU6vbVi1duwCefr91T/W63EiS72UR+6aR8Ao2WT6EJmMTou0wYX0P2Y8CFsVNNeGbge8MbDeIyhjg9PgmPqCLL1gadZCyI6xgyHc1zE/CFUmrry9W73UHnGVlDXMc40TyD8ckWPKEkhur17lQ5uUoQhSG4ipHljd9TcXbCm8ko/3Ab5b+U77/3vq7vXJ918v5bREREREREREREpvwCdsKeapIGIs0AAAAASUVORK5CYII=",
        "webIconDataMimetype": "image/png"
    },
    "root": {
        "id": "root",
        "name": "root",
        "children": [
            74,
            130,
            112,
            15,
            1
        ],
        "appID": false,
        "xmlid": "",
        "actionID": false,
        "actionModel": false,
        "webIcon": null,
        "webIconData": null,
        "webIconDataMimetype": null,
        "backgroundImage": null
    }
}

3、控制器

addons\web\controllers\home.py

关键是这句,调用了ir.ui.menu模型的load_web_menus方法

menus = request.env["ir.ui.menu"].load_web_menus(request.session.debug)
    @http.route('/web/webclient/load_menus/', type='http', auth='user', methods=['GET'])
    def web_load_menus(self, unique, lang=None):
        """
        Loads the menus for the webclient
        :param unique: this parameters is not used, but mandatory: it is used by the HTTP stack to make a unique request
        :param lang: language in which the menus should be loaded (only works if language is installed)
        :return: the menus (including the images in Base64)
        """
        if lang:
            request.update_context(lang=lang)
        menus = request.env["ir.ui.menu"].load_web_menus(request.session.debug)
        body = json.dumps(menus, default=ustr)
        response = request.make_response(body, [
            # this method must specify a content-type application/json instead of using the default text/html set because
            # the type of the route is set to HTTP, but the rpc is made with a get and expects JSON
            ('Content-Type', 'application/json'),
            ('Cache-Control', 'public, max-age=' + str(http.STATIC_CACHE_LONG)),
        ])
        return response

4、ORM

addons\web\models\ir_ui_menu.py

这是web中的menu文件对base中ir.ui.menu 进行了继承,这个函数做了三件事:

1、调用了原模型的load_menus方法

2、对root做了特殊处理

3、如果菜单id==appid, 让它的动作指向它的子菜单的第一个动作

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrUiMenu(models.Model):
    _inherit = "ir.ui.menu"
    def load_web_menus(self, debug):
        """ Loads all menu items (all applications and their sub-menus) and
        processes them to be used by the webclient. Mainly, it associates with
        each application (top level menu) the action of its first child menu
        that is associated with an action (recursively), i.e. with the action
        to execute when the opening the app.
        :return: the menus (including the images in Base64)
        """
        menus = self.load_menus(debug)
        web_menus = {}
        for menu in menus.values():
            if not menu['id']:
                # special root menu case
                web_menus['root'] = {
                    "id": 'root',
                    "name": menu['name'],
                    "children": menu['children'],
                    "appID": False,
                    "xmlid": "",
                    "actionID": False,
                    "actionModel": False,
                    "webIcon": None,
                    "webIconData": None,
                    "webIconDataMimetype": None,
                    "backgroundImage": menu.get('backgroundImage'),
                }
            else:
                action = menu['action']
                if menu['id'] == menu['app_id']:
                    # if it's an app take action of first (sub)child having one defined
                    child = menu
                    while child and not action:
                        action = child['action']
                        child = menus[child['children'][0]] if child['children'] else False
                action_model, action_id = action.split(',') if action else (False, False)
                action_id = int(action_id) if action_id else False
                web_menus[menu['id']] = {
                    "id": menu['id'],
                    "name": menu['name'],
                    "children": menu['children'],
                    "appID": menu['app_id'],
                    "xmlid": menu['xmlid'],
                    "actionID": action_id,
                    "actionModel": action_model,
                    "webIcon": menu['web_icon'],
                    "webIconData": menu['web_icon_data'],
                    "webIconDataMimetype": menu['web_icon_data_mimetype'],
                }
        return web_menus

现在看看原始模型的load_menus 方法,在base模块下:

odoo\addons\base\models\ir_ui_menu.py

获取数据的逻辑:

1、先获取顶级菜单,parent_id=false

2、根据顶级菜单生成root菜单

3、根据顶级菜单查找子菜单,要过滤黑名单

然后从ir_model_data中获取xmlid, 从ir_binary中获取图标信息

最后整合成一个对象返回。

	@api.model
    @tools.ormcache_context('self._uid', 'debug', keys=('lang',))
    def load_menus(self, debug):
        """ Loads all menu items (all applications and their sub-menus).
        :return: the menu root
        :rtype: dict('children': menu_nodes)
        """
        # 省略1000字
        return all_menus

阅读完这个load_menus 函数,发现一个问题, 加载的菜单,应该跟用户角色有关,那么是在什么地方做过滤的呢?

仔细阅读代码,发现有一个 _visible_menu_ids 的私有函数, 这个函数做了这么几个事:

  1. 获取所有的菜单项 menus
  2. 获取当前用户的所有角色groups
  3. 然后就是根据当前用户的group对menus做过滤,关键的代码就是这句,要么菜单没有设置group,要么设置了并且跟当前用户的groups有交集。

注意menu.groups_id & groups 这里的 “&” 操作符已经被重载过了,意思是取两个集合的交集。 具体可以查看models.py的代码。

        # first discard all menus with groups the user does not have
        menus = menus.filtered(
            lambda menu: not menu.groups_id or menu.groups_id & groups)

重载的代码在odoo\models.py的 BaseModel类中,目的是获取两个数据集的交集。

    def __and__(self, other):
        """ Return the intersection of two recordsets.
            Note that first occurrence order is preserved.
        """
        try:
            if self._name != other._name:
                raise TypeError(f"inconsistent models in: {self} & {other}")
            other_ids = set(other._ids)
            return self.browse(OrderedSet(id for id in self._ids if id in other_ids))
        except AttributeError:
            raise TypeError(f"unsupported operand types in: {self} & {other!r}")

写到这里,总结一波:

1、前端通过/web/webclient/load_menus 这个路由调用后端代码来获取菜单。

2、控制器调用了request.env[“ir.ui.menu”].load_web_menus方法

3、web模块的ir.ui.menu模型继承了,base中ir.ui.menu 进行了继承,并且只增加了load_web_menus函数,这个函数做了三件事:

调用了原模型的load_menus方法

对root做了特殊处理

如果菜单id==appid, 让它的动作指向它的子菜单的第一个动作

4、后端加载菜单的时候会根据用户的组group对返回给前端的菜单做过滤