Files
goodie/pkg/server/menus.go
Marc 3e6b1439e4 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>
2026-04-01 02:36:48 +02:00

178 lines
4.9 KiB
Go

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")
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
}