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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user