Files
goodie/pkg/server/action.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

210 lines
5.3 KiB
Go

package server
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// handleLoadBreadcrumbs returns breadcrumb data for the current navigation path.
// Mirrors: odoo/addons/web/controllers/action.py Action.load_breadcrumbs()
func (s *Server) handleLoadBreadcrumbs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
return
}
s.writeJSONRPC(w, req.ID, []interface{}{}, nil)
}
// 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)
return
}
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
return
}
var params struct {
ActionID interface{} `json:"action_id"`
Context interface{} `json:"context"`
}
json.Unmarshal(req.Params, &params)
ctx := r.Context()
var actionID int64
switch v := params.ActionID.(type) {
case float64:
actionID = int64(v)
case string:
// 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
}
}
}
}
if actionID == 0 {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32000,
Message: "Invalid action_id",
})
return
}
// 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 ""
}