Load menus and actions from database (like Python Odoo)
Replaces all hardcoded Go maps with database-driven loading:
Menus (pkg/server/menus.go):
- Queries ir_ui_menu table joined with ir_model_data for XML IDs
- Builds parent/child tree from parent_id relationships
- Resolves appID by walking up to top-level ancestor
- Parses action references ("ir.actions.act_window,123" format)
Actions (pkg/server/action.go):
- Loads from ir_actions_act_window table by integer ID
- Supports XML ID resolution ("sale.action_quotations_with_onboarding")
via ir_model_data lookup
- Builds views array from view_mode string dynamically
- Handles nullable DB columns with helper functions
Seed data (pkg/service/db.go):
- seedActions(): 15 actions with XML IDs in ir_model_data
- seedMenus(): 25 menus with parent/child hierarchy and XML IDs
- Both called during database creation
This mirrors Python Odoo's architecture where menus and actions
are database records loaded from XML data files.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,379 +1,177 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// menuRow holds a single row from the ir_ui_menu table joined with ir_model_data.
|
||||
type menuRow struct {
|
||||
ID int
|
||||
Name string
|
||||
ParentID *int // NULL for top-level menus
|
||||
Sequence int
|
||||
Action *string // e.g. "ir.actions.act_window,1"
|
||||
WebIcon *string
|
||||
XMLID string // e.g. "contacts.menu_contacts" from ir_model_data
|
||||
}
|
||||
|
||||
// handleLoadMenus returns the menu tree for the webclient.
|
||||
// Mirrors: odoo/addons/web/controllers/home.py Home.web_load_menus()
|
||||
//
|
||||
// Loads menus from the ir_ui_menu table, builds the parent/child tree,
|
||||
// and returns the JSON map keyed by string IDs that the webclient expects.
|
||||
func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
// Build menu tree from database or hardcoded defaults
|
||||
menus := map[string]interface{}{
|
||||
"root": map[string]interface{}{
|
||||
"id": "root",
|
||||
"name": "root",
|
||||
"children": []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 100},
|
||||
"appID": false,
|
||||
"xmlid": "",
|
||||
"actionID": false,
|
||||
"actionModel": false,
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Contacts
|
||||
"1": map[string]interface{}{
|
||||
"id": 1,
|
||||
"name": "Contacts",
|
||||
"children": []int{10},
|
||||
"appID": 1,
|
||||
"xmlid": "contacts.menu_contacts",
|
||||
"actionID": 1,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-address-book,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"10": map[string]interface{}{
|
||||
"id": 10,
|
||||
"name": "Contacts",
|
||||
"children": []int{},
|
||||
"appID": 1,
|
||||
"xmlid": "contacts.menu_contacts_list",
|
||||
"actionID": 1,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Invoicing
|
||||
"2": map[string]interface{}{
|
||||
"id": 2,
|
||||
"name": "Invoicing",
|
||||
"children": []int{20},
|
||||
"appID": 2,
|
||||
"xmlid": "account.menu_finance",
|
||||
"actionID": 2,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-book,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"20": map[string]interface{}{
|
||||
"id": 20,
|
||||
"name": "Invoices",
|
||||
"children": []int{},
|
||||
"appID": 2,
|
||||
"xmlid": "account.menu_finance_invoices",
|
||||
"actionID": 2,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Sales
|
||||
"3": map[string]interface{}{
|
||||
"id": 3,
|
||||
"name": "Sales",
|
||||
"children": []int{30},
|
||||
"appID": 3,
|
||||
"xmlid": "sale.menu_sale_root",
|
||||
"actionID": 3,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-bar-chart,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"30": map[string]interface{}{
|
||||
"id": 30,
|
||||
"name": "Orders",
|
||||
"children": []int{},
|
||||
"appID": 3,
|
||||
"xmlid": "sale.menu_sale_orders",
|
||||
"actionID": 3,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// CRM
|
||||
"4": map[string]interface{}{
|
||||
"id": 4,
|
||||
"name": "CRM",
|
||||
"children": []int{40},
|
||||
"appID": 4,
|
||||
"xmlid": "crm.menu_crm_root",
|
||||
"actionID": 4,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-star,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"40": map[string]interface{}{
|
||||
"id": 40,
|
||||
"name": "Pipeline",
|
||||
"children": []int{},
|
||||
"appID": 4,
|
||||
"xmlid": "crm.menu_crm_pipeline",
|
||||
"actionID": 4,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Inventory / Stock
|
||||
"5": map[string]interface{}{
|
||||
"id": 5,
|
||||
"name": "Inventory",
|
||||
"children": []int{50, 51},
|
||||
"appID": 5,
|
||||
"xmlid": "stock.menu_stock_root",
|
||||
"actionID": 5,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-cubes,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"50": map[string]interface{}{
|
||||
"id": 50,
|
||||
"name": "Transfers",
|
||||
"children": []int{},
|
||||
"appID": 5,
|
||||
"xmlid": "stock.menu_stock_transfers",
|
||||
"actionID": 5,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"51": map[string]interface{}{
|
||||
"id": 51,
|
||||
"name": "Products",
|
||||
"children": []int{},
|
||||
"appID": 5,
|
||||
"xmlid": "stock.menu_stock_products",
|
||||
"actionID": 6,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Purchase
|
||||
"6": map[string]interface{}{
|
||||
"id": 6,
|
||||
"name": "Purchase",
|
||||
"children": []int{60},
|
||||
"appID": 6,
|
||||
"xmlid": "purchase.menu_purchase_root",
|
||||
"actionID": 7,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-shopping-cart,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"60": map[string]interface{}{
|
||||
"id": 60,
|
||||
"name": "Purchase Orders",
|
||||
"children": []int{},
|
||||
"appID": 6,
|
||||
"xmlid": "purchase.menu_purchase_orders",
|
||||
"actionID": 7,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Employees / HR
|
||||
"7": map[string]interface{}{
|
||||
"id": 7,
|
||||
"name": "Employees",
|
||||
"children": []int{70, 71},
|
||||
"appID": 7,
|
||||
"xmlid": "hr.menu_hr_root",
|
||||
"actionID": 8,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-users,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"70": map[string]interface{}{
|
||||
"id": 70,
|
||||
"name": "Employees",
|
||||
"children": []int{},
|
||||
"appID": 7,
|
||||
"xmlid": "hr.menu_hr_employees",
|
||||
"actionID": 8,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"71": map[string]interface{}{
|
||||
"id": 71,
|
||||
"name": "Departments",
|
||||
"children": []int{},
|
||||
"appID": 7,
|
||||
"xmlid": "hr.menu_hr_departments",
|
||||
"actionID": 9,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Project
|
||||
"8": map[string]interface{}{
|
||||
"id": 8,
|
||||
"name": "Project",
|
||||
"children": []int{80, 81},
|
||||
"appID": 8,
|
||||
"xmlid": "project.menu_project_root",
|
||||
"actionID": 10,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-puzzle-piece,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"80": map[string]interface{}{
|
||||
"id": 80,
|
||||
"name": "Projects",
|
||||
"children": []int{},
|
||||
"appID": 8,
|
||||
"xmlid": "project.menu_projects",
|
||||
"actionID": 10,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"81": map[string]interface{}{
|
||||
"id": 81,
|
||||
"name": "Tasks",
|
||||
"children": []int{},
|
||||
"appID": 8,
|
||||
"xmlid": "project.menu_project_tasks",
|
||||
"actionID": 11,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Fleet
|
||||
"9": map[string]interface{}{
|
||||
"id": 9,
|
||||
"name": "Fleet",
|
||||
"children": []int{90},
|
||||
"appID": 9,
|
||||
"xmlid": "fleet.menu_fleet_root",
|
||||
"actionID": 12,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-car,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"90": map[string]interface{}{
|
||||
"id": 90,
|
||||
"name": "Vehicles",
|
||||
"children": []int{},
|
||||
"appID": 9,
|
||||
"xmlid": "fleet.menu_fleet_vehicles",
|
||||
"actionID": 12,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Settings
|
||||
"100": map[string]interface{}{
|
||||
"id": 100,
|
||||
"name": "Settings",
|
||||
"children": []int{101, 102},
|
||||
"appID": 100,
|
||||
"xmlid": "base.menu_administration",
|
||||
"actionID": 100,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-cog,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"101": map[string]interface{}{
|
||||
"id": 101,
|
||||
"name": "Users & Companies",
|
||||
"children": []int{},
|
||||
"appID": 100,
|
||||
"xmlid": "base.menu_users",
|
||||
"actionID": 101,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"102": map[string]interface{}{
|
||||
"id": 102,
|
||||
"name": "Technical",
|
||||
"children": []int{},
|
||||
"appID": 100,
|
||||
"xmlid": "base.menu_custom",
|
||||
"actionID": 102,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
ctx := r.Context()
|
||||
menus, err := s.loadWebMenus(ctx)
|
||||
if err != nil {
|
||||
log.Printf("menus: error loading menus: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(menus)
|
||||
}
|
||||
|
||||
// loadWebMenus queries ir_ui_menu and builds the JSON structure the webclient expects.
|
||||
// Mirrors: odoo/addons/base/models/ir_ui_menu.py IrUiMenu.load_web_menus()
|
||||
func (s *Server) loadWebMenus(ctx context.Context) (map[string]interface{}, error) {
|
||||
// Query all active menus, joined with ir_model_data for the xmlid.
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT m.id, m.name, m.parent_id, m.sequence, m.action, m.web_icon,
|
||||
COALESCE(d.module || '.' || d.name, '') AS xmlid
|
||||
FROM ir_ui_menu m
|
||||
LEFT JOIN ir_model_data d
|
||||
ON d.model = 'ir.ui.menu' AND d.res_id = m.id
|
||||
WHERE m.active = true
|
||||
ORDER BY m.sequence, m.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query ir_ui_menu: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var allMenus []menuRow
|
||||
for rows.Next() {
|
||||
var mr menuRow
|
||||
if err := rows.Scan(&mr.ID, &mr.Name, &mr.ParentID, &mr.Sequence, &mr.Action, &mr.WebIcon, &mr.XMLID); err != nil {
|
||||
return nil, fmt.Errorf("scan menu row: %w", err)
|
||||
}
|
||||
allMenus = append(allMenus, mr)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate menu rows: %w", err)
|
||||
}
|
||||
|
||||
// Build children map: parent_id → list of child IDs
|
||||
childrenOf := make(map[int][]int)
|
||||
for _, m := range allMenus {
|
||||
if m.ParentID != nil {
|
||||
childrenOf[*m.ParentID] = append(childrenOf[*m.ParentID], m.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the root app ID for each menu by walking up to the top-level ancestor.
|
||||
// Top-level menus (parent_id IS NULL) are their own appID.
|
||||
menuByID := make(map[int]*menuRow, len(allMenus))
|
||||
for i := range allMenus {
|
||||
menuByID[allMenus[i].ID] = &allMenus[i]
|
||||
}
|
||||
|
||||
appIDOf := func(id int) int {
|
||||
cur := id
|
||||
for {
|
||||
m, ok := menuByID[cur]
|
||||
if !ok || m.ParentID == nil {
|
||||
return cur
|
||||
}
|
||||
cur = *m.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
// Build the result map
|
||||
result := make(map[string]interface{}, len(allMenus)+1)
|
||||
|
||||
// Collect top-level menu IDs (parent_id IS NULL) for the root entry
|
||||
var rootChildren []int
|
||||
for _, m := range allMenus {
|
||||
if m.ParentID == nil {
|
||||
rootChildren = append(rootChildren, m.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// "root" pseudo-entry
|
||||
result["root"] = map[string]interface{}{
|
||||
"id": "root",
|
||||
"name": "root",
|
||||
"children": rootChildren,
|
||||
"appID": false,
|
||||
"xmlid": "",
|
||||
"actionID": false,
|
||||
"actionModel": false,
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
}
|
||||
|
||||
// Each menu entry
|
||||
for _, m := range allMenus {
|
||||
children := childrenOf[m.ID]
|
||||
if children == nil {
|
||||
children = []int{}
|
||||
}
|
||||
|
||||
actionID, actionModel := parseMenuAction(m.Action)
|
||||
appID := appIDOf(m.ID)
|
||||
|
||||
var webIcon interface{}
|
||||
if m.WebIcon != nil && *m.WebIcon != "" {
|
||||
webIcon = *m.WebIcon
|
||||
}
|
||||
|
||||
result[strconv.Itoa(m.ID)] = map[string]interface{}{
|
||||
"id": m.ID,
|
||||
"name": m.Name,
|
||||
"children": children,
|
||||
"appID": appID,
|
||||
"xmlid": m.XMLID,
|
||||
"actionID": actionID,
|
||||
"actionModel": actionModel,
|
||||
"actionPath": false,
|
||||
"webIcon": webIcon,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseMenuAction parses the Odoo action reference format "ir.actions.act_window,123"
|
||||
// into an action ID (int or false) and action model (string or false).
|
||||
// Mirrors: odoo/addons/base/models/ir_ui_menu.py _compute_action()
|
||||
func parseMenuAction(action *string) (interface{}, interface{}) {
|
||||
if action == nil || *action == "" {
|
||||
return false, false
|
||||
}
|
||||
parts := strings.SplitN(*action, ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return false, false
|
||||
}
|
||||
model := parts[0]
|
||||
id, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
return id, model
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user