package server import ( "fmt" "time" "odoo-go/pkg/orm" ) // handleWebSearchRead implements the web_search_read method. // Mirrors: odoo/addons/web/models/models.py web_search_read() // Returns {length: N, records: [...]} instead of just records. func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) { rs := env.Model(model) // Parse domain from first arg (regular search_read) or kwargs (web_search_read) domain := parseDomain(params.Args) if domain == nil { if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 { domain = parseDomain([]interface{}{domainRaw}) } } // Parse specification from kwargs spec, _ := params.KW["specification"].(map[string]interface{}) fields := specToFields(spec) // Always include id hasID := false for _, f := range fields { if f == "id" { hasID = true break } } if !hasID { fields = append([]string{"id"}, fields...) } // Parse offset, limit, order offset := 0 limit := 80 order := "" if v, ok := params.KW["offset"].(float64); ok { offset = int(v) } if v, ok := params.KW["limit"].(float64); ok { limit = int(v) } if v, ok := params.KW["order"].(string); ok { order = v } // Get total count, respecting count_limit for optimization. // Mirrors: odoo/addons/web/models/models.py web_search_read() count_limit parameter countLimit := int64(0) if v, ok := params.KW["count_limit"].(float64); ok { countLimit = int64(v) } count, err := rs.SearchCount(domain) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } if countLimit > 0 && count > countLimit { count = countLimit } // Search with offset/limit found, err := rs.Search(domain, orm.SearchOpts{ Offset: offset, Limit: limit, Order: order, }) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } var records []orm.Values if !found.IsEmpty() { records, err = found.Read(fields) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } } // Format M2O fields as {id, display_name} when spec requests it formatM2OFields(env, model, records, spec) // Fetch O2M fields (child records) based on specification formatO2MFields(env, model, records, spec) // Format date/datetime fields to Odoo's expected string format formatDateFields(model, records) // Convert SQL NULLs to Odoo-expected defaults (false, 0, etc.) normalizeNullFields(model, records) if records == nil { records = []orm.Values{} } return map[string]interface{}{ "length": count, "records": records, }, nil } // handleWebRead implements the web_read method. // Mirrors: odoo/addons/web/models/models.py web_read() func handleWebRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) { ids := parseIDs(params.Args) if len(ids) == 0 { return []orm.Values{}, nil } spec, _ := params.KW["specification"].(map[string]interface{}) fields := specToFields(spec) // Always include id hasID := false for _, f := range fields { if f == "id" { hasID = true break } } if !hasID { fields = append([]string{"id"}, fields...) } rs := env.Model(model) records, err := rs.Browse(ids...).Read(fields) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } formatM2OFields(env, model, records, spec) // Fetch O2M fields (child records) based on specification formatO2MFields(env, model, records, spec) // Format date/datetime fields to Odoo's expected string format formatDateFields(model, records) // Convert SQL NULLs to Odoo-expected defaults (false, 0, etc.) normalizeNullFields(model, records) if records == nil { records = []orm.Values{} } return records, nil } // formatO2MFields fetches One2many child records and adds them to the response. // Mirrors: odoo/addons/web/models/models.py web_read() O2M handling func formatO2MFields(env *orm.Environment, modelName string, records []orm.Values, spec map[string]interface{}) { m := orm.Registry.Get(modelName) if m == nil || spec == nil { return } for fieldName, fieldSpec := range spec { f := m.GetField(fieldName) if f == nil || f.Type != orm.TypeOne2many { continue } // Check if spec requests sub-fields specMap, ok := fieldSpec.(map[string]interface{}) if !ok { continue } subFieldsSpec, _ := specMap["fields"].(map[string]interface{}) // Determine which fields to read from child model var childFields []string if len(subFieldsSpec) > 0 { childFields = []string{"id"} for name := range subFieldsSpec { if name != "id" { childFields = append(childFields, name) } } } comodel := f.Comodel inverseField := f.InverseField if comodel == "" || inverseField == "" { continue } for _, rec := range records { parentID, ok := rec["id"].(int64) if !ok { if pid, ok := rec["id"].(int32); ok { parentID = int64(pid) } } if parentID == 0 { rec[fieldName] = []interface{}{} continue } // Search child records where inverse_field = parent_id childRS := env.Model(comodel) domain := orm.And(orm.Leaf(inverseField, "=", parentID)) found, err := childRS.Search(domain, orm.SearchOpts{Limit: 200}) if err != nil || found.IsEmpty() { rec[fieldName] = []interface{}{} continue } // Read child records childRecords, err := found.Read(childFields) if err != nil { rec[fieldName] = []interface{}{} continue } // Format child records (M2O fields, dates, nulls) formatM2OFields(env, comodel, childRecords, subFieldsSpec) formatDateFields(comodel, childRecords) normalizeNullFields(comodel, childRecords) // Convert to []interface{} for JSON lines := make([]interface{}, len(childRecords)) for i, cr := range childRecords { lines[i] = cr } rec[fieldName] = lines } } } // specToFields extracts field names from a specification dict. // {"name": {}, "partner_id": {"fields": {"display_name": {}}}} → ["name", "partner_id"] func specToFields(spec map[string]interface{}) []string { if len(spec) == 0 { return nil } fields := make([]string, 0, len(spec)) for name := range spec { fields = append(fields, name) } return fields } // formatM2OFields converts Many2one field values from raw int to {id, display_name}. func formatM2OFields(env *orm.Environment, modelName string, records []orm.Values, spec map[string]interface{}) { m := orm.Registry.Get(modelName) if m == nil || spec == nil { return } for _, rec := range records { for fieldName, fieldSpec := range spec { f := m.GetField(fieldName) if f == nil || f.Type != orm.TypeMany2one { continue } // Accept any spec entry for M2O fields (even empty {} means include it) _, ok := fieldSpec.(map[string]interface{}) if !ok { continue } // Get the raw FK ID rawID := rec[fieldName] fkID := int64(0) switch v := rawID.(type) { case int64: fkID = v case int32: fkID = int64(v) case float64: fkID = int64(v) } if fkID == 0 { rec[fieldName] = false // Odoo convention for empty M2O continue } // Fetch display_name from comodel — return as [id, "name"] array if f.Comodel != "" { coRS := env.Model(f.Comodel).Browse(fkID) names, err := coRS.NameGet() if err == nil && len(names) > 0 { rec[fieldName] = []interface{}{fkID, names[fkID]} } else { rec[fieldName] = []interface{}{fkID, fmt.Sprintf("%s,%d", f.Comodel, fkID)} } } } } } // normalizeNullFields converts SQL NULL (Go nil) values to Odoo-expected defaults. // In Python Odoo, empty fields return False (JSON false), not null. // Without this, the webclient may render "null" as literal text. // Mirrors: odoo/orm/fields.py convert_to_read() behaviour func normalizeNullFields(model string, records []orm.Values) { m := orm.Registry.Get(model) if m == nil { return } for _, rec := range records { for fieldName, val := range rec { if val != nil { continue } f := m.GetField(fieldName) if f == nil { continue } switch f.Type { case orm.TypeChar, orm.TypeText, orm.TypeHTML, orm.TypeSelection: rec[fieldName] = false case orm.TypeMany2one: rec[fieldName] = false case orm.TypeInteger, orm.TypeFloat, orm.TypeMonetary: rec[fieldName] = false case orm.TypeBoolean: rec[fieldName] = false case orm.TypeDate, orm.TypeDatetime: rec[fieldName] = false default: rec[fieldName] = false } } } } // 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) if m == nil { return } for _, rec := range records { for fieldName, val := range rec { f := m.GetField(fieldName) if f == nil { continue } if f.Type == orm.TypeDate || f.Type == orm.TypeDatetime { switch v := val.(type) { case time.Time: if f.Type == orm.TypeDate { rec[fieldName] = v.Format("2006-01-02") } else { rec[fieldName] = v.Format("2006-01-02 15:04:05") } case string: // Already a string, might need reformatting if t, err := time.Parse(time.RFC3339, v); err == nil { if f.Type == orm.TypeDate { rec[fieldName] = t.Format("2006-01-02") } else { rec[fieldName] = t.Format("2006-01-02 15:04:05") } } } } // Also convert boolean fields: Go nil → Odoo false if f.Type == orm.TypeBoolean && val == nil { rec[fieldName] = false } } } }