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>
235 lines
6.2 KiB
Go
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, ¶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": 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 ""
|
|
}
|