package server import ( "encoding/json" "fmt" "net/http" "strings" ) // handleActionRun executes a server action by ID. // Mirrors: odoo/addons/web/controllers/action.py Action.run() // // In Python Odoo this executes ir.actions.server records (Python code, email, etc.). // In Go we just dispatch to registered methods; for now, return false (no follow-up action). func (s *Server) handleActionRun(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, ¶ms) // For now, just return false (no follow-up action) s.writeJSONRPC(w, req.ID, false, nil) } // 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, ¶ms) 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": parseDomainOrDefault(deref(domain)), "context": parseContextOrDefault(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 } // parseDomainOrDefault tries to parse a Python-style domain string to JSON. // Returns parsed []interface{} if valid JSON, otherwise returns "[]". func parseDomainOrDefault(s string) interface{} { s = strings.TrimSpace(s) if s == "" || s == "[]" { return []interface{}{} } // Try JSON parse directly (handles [["field","op","val"]] format) var result []interface{} // Convert Python-style to JSON: replace ( with [, ) with ], True/False/None jsonStr := strings.ReplaceAll(s, "(", "[") jsonStr = strings.ReplaceAll(jsonStr, ")", "]") jsonStr = strings.ReplaceAll(jsonStr, "'", "\"") jsonStr = strings.ReplaceAll(jsonStr, "True", "true") jsonStr = strings.ReplaceAll(jsonStr, "False", "false") jsonStr = strings.ReplaceAll(jsonStr, "None", "null") if err := json.Unmarshal([]byte(jsonStr), &result); err == nil { return result } // Fallback: return as-is string (client may handle it) return s } // parseContextOrDefault tries to parse context string to JSON object. func parseContextOrDefault(s string) interface{} { s = strings.TrimSpace(s) if s == "" || s == "{}" { return map[string]interface{}{} } var result map[string]interface{} jsonStr := strings.ReplaceAll(s, "'", "\"") jsonStr = strings.ReplaceAll(jsonStr, "True", "true") jsonStr = strings.ReplaceAll(jsonStr, "False", "false") if err := json.Unmarshal([]byte(jsonStr), &result); err == nil { return result } return map[string]interface{}{} } // deref returns the value of a string pointer, or "" if nil. func deref(p *string) string { if p != nil { return *p } return "" }