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