diff --git a/pkg/server/web_methods.go b/pkg/server/web_methods.go index 2ddfa0e..33d2d0f 100644 --- a/pkg/server/web_methods.go +++ b/pkg/server/web_methods.go @@ -86,6 +86,9 @@ func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams // 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) @@ -133,6 +136,9 @@ func handleWebRead(env *orm.Environment, model string, params CallKWParams) (int 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) @@ -145,6 +151,87 @@ func handleWebRead(env *orm.Environment, model string, params CallKWParams) (int 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 {