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:
157
pkg/server/export.go
Normal file
157
pkg/server/export.go
Normal 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, ¶ms); 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user