Bring odoo-go to ~70%: read_group, record rules, admin, sessions

Phase 1: read_group/web_read_group with SQL GROUP BY, aggregates
  (sum/avg/min/max/count/array_agg/sum_currency), date granularity,
  M2O groupby resolution to [id, display_name].

Phase 2: Record rules with domain_force parsing (Python literal parser),
  global AND + group OR merging. Domain operators: child_of, parent_of,
  any, not any compiled to SQL hierarchy/EXISTS queries.

Phase 3: Button dispatch via /web/dataset/call_button, method return
  values interpreted as actions. Payment register wizard
  (account.payment.register) for sale→invoice→pay flow.

Phase 4: ir.filters, ir.default, product fields expanded, SO line
  product_id onchange, ir_model+ir_model_fields DB seeding.

Phase 5: CSV export (/web/export/csv), attachment upload/download
  via ir.attachment, fields_get with aggregator hints.

Admin/System: Session persistence (PostgreSQL-backed), ir.config_parameter
  with get_param/set_param, ir.cron, ir.logging, res.lang, res.config.settings
  with company-related fields, Settings form view. Technical menu with
  Views/Actions/Parameters/Security/Logging sub-menus. User change_password,
  preferences. Password never exposed in UI/API.

Bugfixes: false→nil for varchar/int fields, int32 in toInt64, call_button
  route with trailing slash, create_invoices returns action, search view
  always included, get_formview_action, name_create, ir.http stub.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-02 19:26:08 +02:00
parent 06e49c878a
commit b57176de2f
29 changed files with 3243 additions and 111 deletions

View File

@@ -43,7 +43,7 @@ func New(cfg *tools.Config, pool *pgxpool.Pool) *Server {
config: cfg,
pool: pool,
mux: http.NewServeMux(),
sessions: NewSessionStore(24 * time.Hour),
sessions: NewSessionStore(24*time.Hour, pool),
}
// Compile XML templates to JS at startup, replacing the Python build step.
@@ -82,6 +82,8 @@ func (s *Server) registerRoutes() {
s.mux.HandleFunc("/jsonrpc", s.handleJSONRPC)
s.mux.HandleFunc("/web/dataset/call_kw", s.handleCallKW)
s.mux.HandleFunc("/web/dataset/call_kw/", s.handleCallKW)
s.mux.HandleFunc("/web/dataset/call_button", s.handleCallKW) // call_button uses same dispatch as call_kw
s.mux.HandleFunc("/web/dataset/call_button/", s.handleCallKW) // with model/method suffix
// Session endpoints
s.mux.HandleFunc("/web/session/authenticate", s.handleAuthenticate)
@@ -116,8 +118,12 @@ func (s *Server) registerRoutes() {
// PWA manifest
s.mux.HandleFunc("/web/manifest.webmanifest", s.handleManifest)
// File upload
// File upload and download
s.mux.HandleFunc("/web/binary/upload_attachment", s.handleUpload)
s.mux.HandleFunc("/web/content/", s.handleContent)
// CSV export
s.mux.HandleFunc("/web/export/csv", s.handleExportCSV)
// Logout & Account
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
@@ -338,6 +344,15 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
return nil, err
}
// If model is "ir.http", handle special routing methods
if params.Model == "ir.http" {
switch params.Method {
case "session_info":
// Return session info - already handled by session endpoint
return map[string]interface{}{}, nil
}
}
rs := env.Model(params.Model)
switch params.Method {
@@ -352,44 +367,7 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
return fieldsGetForModel(params.Model), nil
case "web_read_group", "read_group":
// Basic implementation: if groupby is provided, return one group with all records
groupby := []string{}
if gb, ok := params.KW["groupby"].([]interface{}); ok {
for _, g := range gb {
if s, ok := g.(string); ok {
groupby = append(groupby, s)
}
}
}
if len(groupby) == 0 {
// No groupby → return empty groups
return map[string]interface{}{
"groups": []interface{}{},
"length": 0,
}, nil
}
// With groupby: return all records in one "ungrouped" group
domain := parseDomain(params.Args)
if domain == nil {
if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 {
domain = parseDomain([]interface{}{domainRaw})
}
}
count, _ := rs.SearchCount(domain)
return map[string]interface{}{
"groups": []interface{}{
map[string]interface{}{
"__domain": []interface{}{},
"__count": count,
groupby[0]: false,
"__records": []interface{}{},
},
},
"length": 1,
}, nil
return s.handleReadGroup(rs, params)
case "web_search_read":
return handleWebSearchRead(env, params.Model, params)
@@ -623,6 +601,40 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
}
return nameResult, nil
case "get_formview_action":
ids := parseIDs(params.Args)
if len(ids) == 0 {
return false, nil
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": params.Model,
"res_id": ids[0],
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "current",
}, nil
case "get_formview_id":
return false, nil
case "action_get":
return false, nil
case "name_create":
nameStr := ""
if len(params.Args) > 0 {
nameStr, _ = params.Args[0].(string)
}
if nameStr == "" {
return nil, &RPCError{Code: -32000, Message: "name_create requires a name"}
}
created, err := rs.Create(orm.Values{"name": nameStr})
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return []interface{}{created.ID(), nameStr}, nil
case "read_progress_bar":
return map[string]interface{}{}, nil
@@ -671,7 +683,8 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
return created.ID(), nil
default:
// Try registered business methods on the model
// Try registered business methods on the model.
// Mirrors: odoo/service/model.py call_kw() + odoo/addons/web/controllers/dataset.py call_button()
model := orm.Registry.Get(params.Model)
if model != nil && model.Methods != nil {
if method, ok := model.Methods[params.Method]; ok {
@@ -680,6 +693,18 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
// If the method returns an action dict (map with "type" key),
// return it directly so the web client can navigate.
// Mirrors: odoo/addons/web/controllers/dataset.py call_button()
if actionMap, ok := result.(map[string]interface{}); ok {
if _, hasType := actionMap["type"]; hasType {
return actionMap, nil
}
}
// If result is true or nil, return false (meaning "reload current view")
if result == nil || result == true {
return false, nil
}
return result, nil
}
}