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

@@ -2,7 +2,9 @@ package server
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// handleLoadBreadcrumbs returns breadcrumb data for the current navigation path.
@@ -22,8 +24,12 @@ func (s *Server) handleLoadBreadcrumbs(w http.ResponseWriter, r *http.Request) {
s.writeJSONRPC(w, req.ID, []interface{}{}, nil)
}
// handleActionLoad loads an action definition by ID.
// handleActionLoad loads an action definition by ID from the database.
// Mirrors: odoo/addons/web/controllers/action.py Action.load()
//
// The action_id can be:
// - An integer (database ID): SELECT directly from ir_act_window
// - A string (XML ID like "base.action_res_users"): resolve via ir_model_data first
func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -42,258 +48,162 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
}
json.Unmarshal(req.Params, &params)
// Parse action_id from params (can be float64 from JSON or string)
actionID := 0
ctx := r.Context()
var actionID int64
switch v := params.ActionID.(type) {
case float64:
actionID = int(v)
actionID = int64(v)
case string:
// Try to parse numeric string
for _, c := range v {
if c >= '0' && c <= '9' {
actionID = actionID*10 + int(c-'0')
} else {
actionID = 0
break
// Try numeric string first
if id, ok := parseNumericString(v); ok {
actionID = id
} else {
// XML ID: "module.name" → look up in ir_model_data
parts := strings.SplitN(v, ".", 2)
if len(parts) == 2 {
err := s.pool.QueryRow(ctx,
`SELECT res_id FROM ir_model_data
WHERE module = $1 AND name = $2 AND model = 'ir.actions.act_window'`,
parts[0], parts[1]).Scan(&actionID)
if err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32000,
Message: fmt.Sprintf("Action not found: %s", v),
})
return
}
}
}
}
// Action definitions by ID
actions := map[int]map[string]interface{}{
1: {
"id": 1,
"type": "ir.actions.act_window",
"name": "Contacts",
"res_model": "res.partner",
"view_mode": "list,kanban,form",
"views": [][]interface{}{{nil, "list"}, {nil, "kanban"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "contacts.action_contacts",
},
2: {
"id": 2,
"type": "ir.actions.act_window",
"name": "Invoices",
"res_model": "account.move",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": `[("move_type","in",["out_invoice","out_refund"])]`,
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "account.action_move_out_invoice_type",
},
3: {
"id": 3,
"type": "ir.actions.act_window",
"name": "Sale Orders",
"res_model": "sale.order",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "sale.action_quotations_with_onboarding",
},
4: {
"id": 4,
"type": "ir.actions.act_window",
"name": "CRM Pipeline",
"res_model": "crm.lead",
"view_mode": "kanban,list,form",
"views": [][]interface{}{{nil, "kanban"}, {nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "crm.crm_lead_all_pipeline",
},
5: {
"id": 5,
"type": "ir.actions.act_window",
"name": "Transfers",
"res_model": "stock.picking",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "stock.action_picking_tree_all",
},
6: {
"id": 6,
"type": "ir.actions.act_window",
"name": "Products",
"res_model": "product.template",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "stock.action_product_template",
},
7: {
"id": 7,
"type": "ir.actions.act_window",
"name": "Purchase Orders",
"res_model": "purchase.order",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "purchase.action_purchase_orders",
},
8: {
"id": 8,
"type": "ir.actions.act_window",
"name": "Employees",
"res_model": "hr.employee",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "hr.action_hr_employee",
},
9: {
"id": 9,
"type": "ir.actions.act_window",
"name": "Departments",
"res_model": "hr.department",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "hr.action_hr_department",
},
10: {
"id": 10,
"type": "ir.actions.act_window",
"name": "Projects",
"res_model": "project.project",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "project.action_project",
},
11: {
"id": 11,
"type": "ir.actions.act_window",
"name": "Tasks",
"res_model": "project.task",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "project.action_project_task",
},
12: {
"id": 12,
"type": "ir.actions.act_window",
"name": "Vehicles",
"res_model": "fleet.vehicle",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "fleet.action_fleet_vehicle",
},
100: {
"id": 100,
"type": "ir.actions.act_window",
"name": "Settings",
"res_model": "res.company",
"res_id": 1,
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "base.action_res_company_form",
},
101: {
"id": 101,
"type": "ir.actions.act_window",
"name": "Users",
"res_model": "res.users",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "base.action_res_users",
},
102: {
"id": 102,
"type": "ir.actions.act_window",
"name": "Sequences",
"res_model": "ir.sequence",
"view_mode": "list,form",
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
"search_view_id": false,
"domain": "[]",
"context": "{}",
"target": "current",
"limit": 80,
"help": "",
"xml_id": "base.ir_sequence_form",
},
if actionID == 0 {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32000,
Message: "Invalid action_id",
})
return
}
action, ok := actions[actionID]
if !ok {
// Default to Contacts if unknown action ID
action = actions[1]
// Load action from ir_act_window table
var (
id int64
name string
actType string
resModel string
viewMode string
resID *int64
domain *string
actCtx *string
target *string
limit *int
searchVID *string
help *string
)
err := s.pool.QueryRow(ctx,
`SELECT id, name, type, res_model, view_mode,
res_id, domain, context, target, "limit",
search_view_id, help
FROM ir_act_window WHERE id = $1`, actionID,
).Scan(&id, &name, &actType, &resModel, &viewMode,
&resID, &domain, &actCtx, &target, &limit,
&searchVID, &help)
if err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32000,
Message: fmt.Sprintf("Action %d not found", actionID),
})
return
}
// Look up xml_id from ir_model_data
xmlID := ""
_ = s.pool.QueryRow(ctx,
`SELECT module || '.' || name FROM ir_model_data
WHERE model = 'ir.actions.act_window' AND res_id = $1
LIMIT 1`, id).Scan(&xmlID)
// Build views array from view_mode string (e.g. "list,kanban,form" → [[nil,"list"],[nil,"kanban"],[nil,"form"]])
views := buildViewsFromMode(viewMode)
// Assemble action response in the format the webclient expects
action := map[string]interface{}{
"id": id,
"type": coalesce(actType, "ir.actions.act_window"),
"name": name,
"res_model": resModel,
"view_mode": coalesce(viewMode, "list,form"),
"views": views,
"search_view_id": false,
"domain": coalesce(deref(domain), "[]"),
"context": coalesce(deref(actCtx), "{}"),
"target": coalesce(deref(target), "current"),
"limit": coalesceInt(limit, 80),
"help": deref(help),
"xml_id": xmlID,
}
// Include res_id if set (for single-record actions like Settings)
if resID != nil && *resID != 0 {
action["res_id"] = *resID
}
s.writeJSONRPC(w, req.ID, action, nil)
}
// parseNumericString tries to parse a string as a positive integer.
func parseNumericString(s string) (int64, bool) {
if len(s) == 0 {
return 0, false
}
var n int64
for _, c := range s {
if c < '0' || c > '9' {
return 0, false
}
n = n*10 + int64(c-'0')
}
return n, true
}
// buildViewsFromMode converts a view_mode string like "list,kanban,form"
// into the webclient format: [[nil,"list"],[nil,"kanban"],[nil,"form"]].
func buildViewsFromMode(viewMode string) [][]interface{} {
modes := strings.Split(viewMode, ",")
views := make([][]interface{}, 0, len(modes))
for _, m := range modes {
m = strings.TrimSpace(m)
if m != "" {
views = append(views, []interface{}{nil, m})
}
}
return views
}
// coalesce returns the first non-empty string.
func coalesce(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// coalesceInt returns *p if non-nil, otherwise the fallback.
func coalesceInt(p *int, fallback int) int {
if p != nil {
return *p
}
return fallback
}
// deref returns the value of a string pointer, or "" if nil.
func deref(p *string) string {
if p != nil {
return *p
}
return ""
}