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

@@ -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)