From fbe65af951b68d5e8cfcacf558f6fe68fe7bf19b Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 2 Apr 2026 21:43:42 +0200 Subject: [PATCH] Fetch O2M child records in web_read/web_search_read responses When a view specification requests O2M fields (e.g., order_line on sale.order), the response now includes the child records as an array of dicts. Searches child model by inverse_field = parent_id, reads requested sub-fields, and formats M2O/dates/nulls on children too. This makes inline list views in form notebooks show actual data: - Sale order lines (product, qty, price) in sale.order form - Invoice lines in account.move form - Any O2M field with specification in get_views response Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/server/web_methods.go | 87 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) 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 {