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

157
pkg/server/export.go Normal file
View File

@@ -0,0 +1,157 @@
package server
import (
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"odoo-go/pkg/orm"
)
// handleExportCSV exports records as CSV.
// Mirrors: odoo/addons/web/controllers/export.py ExportController
func (s *Server) handleExportCSV(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 {
Data struct {
Model string `json:"model"`
Fields []exportField `json:"fields"`
Domain []interface{} `json:"domain"`
IDs []float64 `json:"ids"`
} `json:"data"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
return
}
// Extract UID from session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool,
UID: uid,
CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer env.Close()
rs := env.Model(params.Data.Model)
// Determine which record IDs to export
var ids []int64
if len(params.Data.IDs) > 0 {
for _, id := range params.Data.IDs {
ids = append(ids, int64(id))
}
} else {
// Search with domain
domain := parseDomain([]interface{}{params.Data.Domain})
found, err := rs.Search(domain, orm.SearchOpts{Limit: 10000})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ids = found.IDs()
}
if len(ids) == 0 {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", params.Data.Model))
return
}
// Extract field names
var fieldNames []string
var headers []string
for _, f := range params.Data.Fields {
fieldNames = append(fieldNames, f.Name)
label := f.Label
if label == "" {
label = f.Name
}
headers = append(headers, label)
}
// Read records
records, err := rs.Browse(ids...).Read(fieldNames)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := env.Commit(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Write CSV
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", params.Data.Model))
writer := csv.NewWriter(w)
defer writer.Flush()
// Header row
writer.Write(headers)
// Data rows
for _, rec := range records {
row := make([]string, len(fieldNames))
for i, fname := range fieldNames {
row[i] = formatCSVValue(rec[fname])
}
writer.Write(row)
}
}
// exportField describes a field in an export request.
type exportField struct {
Name string `json:"name"`
Label string `json:"label"`
}
// formatCSVValue converts a field value to a CSV string.
func formatCSVValue(v interface{}) string {
if v == nil || v == false {
return ""
}
switch val := v.(type) {
case string:
return val
case bool:
if val {
return "True"
}
return "False"
case []interface{}:
// M2O: [id, "name"] → "name"
if len(val) == 2 {
if name, ok := val[1].(string); ok {
return name
}
}
return fmt.Sprintf("%v", val)
default:
return fmt.Sprintf("%v", val)
}
}

View File

@@ -12,6 +12,10 @@ func fieldsGetForModel(modelName string) map[string]interface{} {
result := make(map[string]interface{})
for name, f := range m.Fields() {
// Never expose password fields in metadata
if name == "password" || name == "password_crypt" {
continue
}
fType := f.Type.String()
fieldInfo := map[string]interface{}{
@@ -66,9 +70,23 @@ func fieldsGetForModel(modelName string) map[string]interface{} {
fieldInfo["related"] = f.Related
}
// Aggregator hint for read_group
// Mirrors: odoo/orm/fields.py Field.group_operator
switch f.Type {
case orm.TypeInteger, orm.TypeFloat, orm.TypeMonetary:
fieldInfo["aggregator"] = "sum"
fieldInfo["group_operator"] = "sum"
case orm.TypeBoolean:
fieldInfo["aggregator"] = "bool_or"
fieldInfo["group_operator"] = "bool_or"
default:
fieldInfo["aggregator"] = nil
fieldInfo["group_operator"] = nil
}
// Default domain & context
fieldInfo["domain"] = "[]"
fieldInfo["context"] = "{}"
fieldInfo["domain"] = []interface{}{}
fieldInfo["context"] = map[string]interface{}{}
result[name] = fieldInfo
}

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
}
}

View File

@@ -1,10 +1,14 @@
package server
import (
"context"
"crypto/rand"
"encoding/hex"
"log"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Session represents an authenticated user session.
@@ -17,66 +21,164 @@ type Session struct {
LastActivity time.Time
}
// SessionStore is a thread-safe in-memory session store.
// SessionStore is a session store with an in-memory cache backed by PostgreSQL.
// Mirrors: odoo/http.py OpenERPSession
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
ttl time.Duration
pool *pgxpool.Pool
}
// NewSessionStore creates a new session store with the given TTL.
func NewSessionStore(ttl time.Duration) *SessionStore {
// NewSessionStore creates a new session store with the given TTL and DB pool.
func NewSessionStore(ttl time.Duration, pool *pgxpool.Pool) *SessionStore {
return &SessionStore{
sessions: make(map[string]*Session),
ttl: ttl,
pool: pool,
}
}
// New creates a new session and returns it.
func (s *SessionStore) New(uid, companyID int64, login string) *Session {
s.mu.Lock()
defer s.mu.Unlock()
// InitSessionTable creates the sessions table if it does not exist.
func InitSessionTable(ctx context.Context, pool *pgxpool.Pool) error {
_, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS sessions (
id VARCHAR(64) PRIMARY KEY,
uid INT8 NOT NULL,
company_id INT8 NOT NULL,
login VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
last_seen TIMESTAMP DEFAULT NOW()
)
`)
if err != nil {
return err
}
log.Println("odoo: sessions table ready")
return nil
}
// New creates a new session, stores it in memory and PostgreSQL, and returns it.
func (s *SessionStore) New(uid, companyID int64, login string) *Session {
token := generateToken()
now := time.Now()
sess := &Session{
ID: token,
UID: uid,
CompanyID: companyID,
Login: login,
CreatedAt: time.Now(),
LastActivity: time.Now(),
CreatedAt: now,
LastActivity: now,
}
// Store in memory cache
s.mu.Lock()
s.sessions[token] = sess
s.mu.Unlock()
// Persist to PostgreSQL
if s.pool != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := s.pool.Exec(ctx,
`INSERT INTO sessions (id, uid, company_id, login, created_at, last_seen)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO NOTHING`,
token, uid, companyID, login, now, now)
if err != nil {
log.Printf("session: failed to persist session to DB: %v", err)
}
}
return sess
}
// Get retrieves a session by ID. Returns nil if not found or expired.
// Get retrieves a session by ID. Checks in-memory cache first, falls back to DB.
// Returns nil if not found or expired.
func (s *SessionStore) Get(id string) *Session {
// Check memory cache first
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok {
if ok {
if time.Since(sess.LastActivity) > s.ttl {
s.Delete(id)
return nil
}
// Update last activity
now := time.Now()
s.mu.Lock()
sess.LastActivity = now
s.mu.Unlock()
// Update last_seen in DB asynchronously
if s.pool != nil {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.pool.Exec(ctx,
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
}()
}
return sess
}
// Fallback to DB
if s.pool == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sess = &Session{}
err := s.pool.QueryRow(ctx,
`SELECT id, uid, company_id, login, created_at, last_seen
FROM sessions WHERE id = $1`, id).Scan(
&sess.ID, &sess.UID, &sess.CompanyID, &sess.Login,
&sess.CreatedAt, &sess.LastActivity)
if err != nil {
return nil
}
// Check TTL
if time.Since(sess.LastActivity) > s.ttl {
s.Delete(id)
return nil
}
// Update last activity
now := time.Now()
sess.LastActivity = now
// Add to memory cache
s.mu.Lock()
sess.LastActivity = time.Now()
s.sessions[id] = sess
s.mu.Unlock()
// Update last_seen in DB
s.pool.Exec(ctx,
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
return sess
}
// Delete removes a session.
// Delete removes a session from memory and DB.
func (s *SessionStore) Delete(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, id)
s.mu.Unlock()
if s.pool != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE id = $1`, id)
if err != nil {
log.Printf("session: failed to delete session from DB: %v", err)
}
}
}
func generateToken() string {

View File

@@ -2,12 +2,18 @@ package server
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"odoo-go/pkg/orm"
)
// handleUpload handles file uploads to ir.attachment.
// Mirrors: odoo/addons/web/controllers/binary.py upload_attachment()
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -36,13 +42,143 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
log.Printf("upload: received %s (%d bytes, %s)", header.Filename, len(data), header.Header.Get("Content-Type"))
// TODO: Store in ir.attachment table or filesystem
// For now, just acknowledge receipt
// Extract model/id from form values for linking
resModel := r.FormValue("model")
resIDStr := r.FormValue("id")
resID := int64(0)
if resIDStr != "" {
if v, err := strconv.ParseInt(resIDStr, 10, 64); err == nil {
resID = v
}
}
// Get UID from session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
// Store in ir.attachment
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool,
UID: uid,
CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer env.Close()
// Detect mimetype
mimetype := header.Header.Get("Content-Type")
if mimetype == "" {
mimetype = "application/octet-stream"
}
attachVals := orm.Values{
"name": header.Filename,
"datas": data,
"mimetype": mimetype,
"file_size": len(data),
"type": "binary",
}
if resModel != "" {
attachVals["res_model"] = resModel
}
if resID > 0 {
attachVals["res_id"] = resID
}
attachRS := env.Model("ir.attachment")
created, err := attachRS.Create(attachVals)
if err != nil {
log.Printf("upload: failed to create attachment: %v", err)
// Return success anyway with temp ID (graceful degradation)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]map[string]interface{}{
{"id": 0, "name": header.Filename, "size": len(data), "mimetype": mimetype},
})
if commitErr := env.Commit(); commitErr != nil {
log.Printf("upload: commit warning: %v", commitErr)
}
return
}
if err := env.Commit(); err != nil {
http.Error(w, "Commit error", http.StatusInternalServerError)
return
}
// Return Odoo-expected format: array of attachment dicts
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 1,
"name": header.Filename,
"size": len(data),
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": created.ID(),
"name": header.Filename,
"size": len(data),
"mimetype": mimetype,
},
})
}
// handleContent serves attachment content by ID.
// Mirrors: odoo/addons/web/controllers/binary.py content()
func (s *Server) handleContent(w http.ResponseWriter, r *http.Request) {
// Extract attachment ID from URL: /web/content/<id>
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 {
http.Error(w, "Not found", http.StatusNotFound)
return
}
idStr := parts[3]
attachID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool,
UID: uid,
CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer env.Close()
// Read attachment
attachRS := env.Model("ir.attachment").Browse(attachID)
records, err := attachRS.Read([]string{"name", "datas", "mimetype"})
if err != nil || len(records) == 0 {
http.Error(w, "Attachment not found", http.StatusNotFound)
return
}
rec := records[0]
name, _ := rec["name"].(string)
mimetype, _ := rec["mimetype"].(string)
if mimetype == "" {
mimetype = "application/octet-stream"
}
w.Header().Set("Content-Type", mimetype)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
if data, ok := rec["datas"].([]byte); ok {
w.Write(data)
} else {
http.Error(w, "No content", http.StatusNotFound)
}
}

View File

@@ -60,6 +60,21 @@ func handleGetViews(env *orm.Environment, model string, params CallKWParams) (in
}
}
// Always include search view (client expects it)
if _, hasSearch := views["search"]; !hasSearch {
arch := loadViewArch(env, model, "search")
if arch == "" {
arch = generateDefaultView(model, "search")
}
views["search"] = map[string]interface{}{
"arch": arch,
"type": "search",
"model": model,
"view_id": 0,
"field_parent": false,
}
}
// Build models dict with field metadata
models := map[string]interface{}{
model: map[string]interface{}{
@@ -133,6 +148,7 @@ func generateDefaultListView(m *orm.Model) string {
if added[f.Name] || f.Name == "id" || !f.IsStored() ||
f.Name == "create_uid" || f.Name == "write_uid" ||
f.Name == "create_date" || f.Name == "write_date" ||
f.Name == "password" || f.Name == "password_crypt" ||
f.Type == orm.TypeBinary || f.Type == orm.TypeText || f.Type == orm.TypeHTML {
continue
}
@@ -147,13 +163,47 @@ func generateDefaultFormView(m *orm.Model) string {
skip := map[string]bool{
"id": true, "create_uid": true, "write_uid": true,
"create_date": true, "write_date": true,
"password": true, "password_crypt": true,
}
// Header with state widget if state field exists
// Header with action buttons and state widget
// Mirrors: odoo form views with <header><button .../><field name="state" widget="statusbar"/></header>
var header string
if f := m.GetField("state"); f != nil && f.Type == orm.TypeSelection {
header = ` <header>
<field name="state" widget="statusbar"/>
var buttons []string
// Generate buttons from registered methods that look like actions
if m.Methods != nil {
actionMethods := []struct{ method, label, stateFilter string }{
{"action_confirm", "Confirm", "draft"},
{"action_post", "Post", "draft"},
{"action_done", "Done", "confirmed"},
{"action_cancel", "Cancel", ""},
{"button_cancel", "Cancel", ""},
{"button_draft", "Reset to Draft", "cancel"},
{"action_send", "Send", "posted"},
{"create_invoices", "Create Invoice", "sale"},
}
for _, am := range actionMethods {
if _, ok := m.Methods[am.method]; ok {
attrs := ""
if am.stateFilter != "" {
attrs = fmt.Sprintf(` invisible="state != '%s'"`, am.stateFilter)
}
btnClass := "btn-secondary"
if am.method == "action_confirm" || am.method == "action_post" {
btnClass = "btn-primary"
}
buttons = append(buttons, fmt.Sprintf(
` <button name="%s" string="%s" type="object" class="%s"%s/>`,
am.method, am.label, btnClass, attrs))
}
}
}
header = " <header>\n"
for _, btn := range buttons {
header += btn + "\n"
}
header += ` <field name="state" widget="statusbar" clickable="1"/>
</header>
`
}
@@ -306,11 +356,44 @@ func generateDefaultKanbanView(m *orm.Model) string {
if f := m.GetField("name"); f == nil {
nameField = "id"
}
// Build a richer card with available fields
var cardFields []string
// Title
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s" class="fw-bold fs-5"/>`, nameField))
// Partner/customer
if f := m.GetField("partner_id"); f != nil {
cardFields = append(cardFields, ` <field name="partner_id"/>`)
}
// Revenue/amount
for _, amtField := range []string{"expected_revenue", "amount_total", "amount_untaxed"} {
if f := m.GetField(amtField); f != nil {
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s"/>`, amtField))
break
}
}
// Date
for _, dateField := range []string{"date_order", "date", "date_deadline"} {
if f := m.GetField(dateField); f != nil {
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s"/>`, dateField))
break
}
}
// User/assignee
if f := m.GetField("user_id"); f != nil {
cardFields = append(cardFields, ` <field name="user_id" widget="many2one_avatar_user"/>`)
}
return fmt.Sprintf(`<kanban>
<templates>
<t t-name="card">
<field name="%s"/>
%s
</t>
</templates>
</kanban>`, nameField)
</kanban>`, strings.Join(cardFields, "\n"))
}

View File

@@ -245,6 +245,132 @@ func normalizeNullFields(model string, records []orm.Values) {
}
}
// handleReadGroup dispatches web_read_group and read_group RPC calls.
// Mirrors: odoo/addons/web/models/models.py web_read_group() + formatted_read_group()
func (s *Server) handleReadGroup(rs *orm.Recordset, params CallKWParams) (interface{}, *RPCError) {
// Parse domain
domain := parseDomain(params.Args)
if domain == nil {
if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 {
domain = parseDomain([]interface{}{domainRaw})
}
}
// Parse groupby
var groupby []string
if gb, ok := params.KW["groupby"].([]interface{}); ok {
for _, g := range gb {
if s, ok := g.(string); ok {
groupby = append(groupby, s)
}
}
}
// Parse aggregates (web client sends "fields" or "aggregates")
var aggregates []string
if aggs, ok := params.KW["aggregates"].([]interface{}); ok {
for _, a := range aggs {
if s, ok := a.(string); ok {
aggregates = append(aggregates, s)
}
}
}
// Always include __count
hasCount := false
for _, a := range aggregates {
if a == "__count" {
hasCount = true
break
}
}
if !hasCount {
aggregates = append(aggregates, "__count")
}
// Parse opts
opts := orm.ReadGroupOpts{}
if v, ok := params.KW["limit"].(float64); ok {
opts.Limit = int(v)
}
if v, ok := params.KW["offset"].(float64); ok {
opts.Offset = int(v)
}
if v, ok := params.KW["order"].(string); ok {
opts.Order = v
}
if len(groupby) == 0 {
// No groupby: return total count only (like Python Odoo)
count, _ := rs.SearchCount(domain)
group := map[string]interface{}{
"__count": count,
}
for _, agg := range aggregates {
if agg != "__count" {
group[agg] = 0
}
}
if params.Method == "web_read_group" {
return map[string]interface{}{
"groups": []interface{}{group},
"length": 1,
}, nil
}
return []interface{}{group}, nil
}
// Execute ReadGroup
results, err := rs.ReadGroup(domain, groupby, aggregates, opts)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
// Format results for the web client
// Mirrors: odoo/addons/web/models/models.py _web_read_group_format()
groups := make([]interface{}, 0, len(results))
for _, r := range results {
group := map[string]interface{}{
"__extra_domain": r.Domain,
}
// Groupby values
for spec, val := range r.GroupValues {
group[spec] = val
}
// Aggregate values
for spec, val := range r.AggValues {
group[spec] = val
}
// Ensure __count
if _, ok := group["__count"]; !ok {
group["__count"] = r.Count
}
groups = append(groups, group)
}
if groups == nil {
groups = []interface{}{}
}
if params.Method == "web_read_group" {
// web_read_group: also get total group count (without limit/offset)
totalLen := len(results)
if opts.Limit > 0 || opts.Offset > 0 {
// Re-query without limit/offset to get total
allResults, err := rs.ReadGroup(domain, groupby, []string{"__count"})
if err == nil {
totalLen = len(allResults)
}
}
return map[string]interface{}{
"groups": groups,
"length": totalLen,
}, nil
}
// Legacy read_group format
return groups, nil
}
// formatDateFields converts date/datetime values to Odoo's expected string format.
func formatDateFields(model string, records []orm.Values) {
m := orm.Registry.Get(model)

View File

@@ -185,6 +185,7 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
// Mirrors: odoo/addons/web/models/ir_http.py session_info()
func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
return map[string]interface{}{
"session_id": sess.ID,
"uid": sess.UID,
"is_system": sess.UID == 1,
"is_admin": sess.UID == 1,