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:
Marc
2026-04-01 02:36:48 +02:00
parent 70649c4b4e
commit 3e6b1439e4
3 changed files with 476 additions and 607 deletions

View File

@@ -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
}