Files
goodie/pkg/server/action.go
Marc 24dee3704a Complete ORM gaps + server features + module depth push
ORM:
- SQL Constraints support (Model.AddSQLConstraint, applied in InitDatabase)
- Translatable field Read (ir_translation lookup for non-en_US)
- active_test filter in SearchCount + ReadGroup (consistency with Search)
- Environment.Ref() improved (format validation, parameterized query)

Server:
- /web/action/run endpoint (server action execution stub)
- /web/model/get_definitions (field metadata for multiple models)
- Binary field serving rewritten: reads from DB, falls back to SVG
  with record initial (fixes avatar/logo rendering)

Business modules deepened:
- Account: action_post validation (partner, lines), sequence numbering
  (JOURNAL/YYYY/NNNN), action_register_payment, remove_move_reconcile
- Sale: action_cancel, action_draft, action_view_invoice
- Purchase: button_draft
- Stock: action_cancel on picking
- CRM: action_set_won_rainbowman, convert_opportunity
- HR: hr.contract model (employee, wage, dates, state)
- Project: action_blocked, task stage seed data

Views:
- Cancel/Reset buttons in sale.form header
- Register Payment button in invoice.form (visible when posted+unpaid)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 01:03:47 +02:00

235 lines
6.2 KiB
Go

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, &params)
// 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, &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 ""
}