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:
@@ -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, ¶ms)
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user