Files
goodie/pkg/server/views.go
Marc e8fe84b913 Fix get_views: include comodel field metadata for O2M/M2M inline views
The web client needs field metadata for sub-models when rendering
inline list views inside form views (e.g., sale.order.line inside
sale.order form). Now iterates all relational fields and adds their
comodel to the models dict in the get_views response.

Fixes "Cannot read properties of undefined (reading 'fields')" error
on sale.order and account.move stored form views with O2M tabs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 21:40:16 +02:00

419 lines
11 KiB
Go

package server
import (
"fmt"
"strings"
"odoo-go/pkg/orm"
)
// handleGetViews implements the get_views method.
// Mirrors: odoo/addons/base/models/ir_ui_view.py get_views()
func handleGetViews(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) {
// Parse views list: [[false, "list"], [false, "form"], [false, "search"]]
var viewRequests [][]interface{}
if len(params.Args) > 0 {
if vr, ok := params.Args[0].([]interface{}); ok {
viewRequests = make([][]interface{}, len(vr))
for i, v := range vr {
if pair, ok := v.([]interface{}); ok {
viewRequests[i] = pair
}
}
}
}
// Also check kwargs
if viewRequests == nil {
if vr, ok := params.KW["views"].([]interface{}); ok {
viewRequests = make([][]interface{}, len(vr))
for i, v := range vr {
if pair, ok := v.([]interface{}); ok {
viewRequests[i] = pair
}
}
}
}
views := make(map[string]interface{})
for _, vr := range viewRequests {
if len(vr) < 2 {
continue
}
viewType, _ := vr[1].(string)
if viewType == "" {
continue
}
// Try to load from ir_ui_view table
arch := loadViewArch(env, model, viewType)
if arch == "" {
// Generate default view
arch = generateDefaultView(model, viewType)
}
views[viewType] = map[string]interface{}{
"arch": arch,
"type": viewType,
"model": model,
"view_id": 0,
"field_parent": false,
}
}
// Always include search view (client expects it)
if _, hasSearch := views["search"]; !hasSearch {
arch := loadViewArch(env, model, "search")
if arch == "" {
arch = generateDefaultView(model, "search")
}
views["search"] = map[string]interface{}{
"arch": arch,
"type": "search",
"model": model,
"view_id": 0,
"field_parent": false,
}
}
// Build models dict with field metadata.
// Include the main model + all comodels referenced by relational fields.
// Mirrors: odoo/addons/web/models/models.py _get_view_fields()
models := map[string]interface{}{
model: map[string]interface{}{
"fields": fieldsGetForModel(model),
},
}
// Add comodels (needed for O2M/M2M inline views and M2O dropdowns)
m := orm.Registry.Get(model)
if m != nil {
for _, f := range m.Fields() {
if f.Comodel != "" {
if _, exists := models[f.Comodel]; !exists {
comodelFields := fieldsGetForModel(f.Comodel)
if len(comodelFields) > 0 {
models[f.Comodel] = map[string]interface{}{
"fields": comodelFields,
}
}
}
}
}
}
return map[string]interface{}{
"views": views,
"models": models,
}, nil
}
// loadViewArch tries to load a view from the ir_ui_view table.
func loadViewArch(env *orm.Environment, model, viewType string) string {
var arch string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT arch FROM ir_ui_view WHERE model = $1 AND type = $2 AND active = true ORDER BY priority LIMIT 1`,
model, viewType,
).Scan(&arch)
if err != nil {
return ""
}
return arch
}
// generateDefaultView creates a minimal view XML for a model.
func generateDefaultView(modelName, viewType string) string {
m := orm.Registry.Get(modelName)
if m == nil {
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
}
switch viewType {
case "list", "tree":
return generateDefaultListView(m)
case "form":
return generateDefaultFormView(m)
case "search":
return generateDefaultSearchView(m)
case "kanban":
return generateDefaultKanbanView(m)
default:
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
}
}
func generateDefaultListView(m *orm.Model) string {
// Prioritize important fields first
priority := []string{"name", "display_name", "state", "partner_id", "date_order", "date",
"amount_total", "amount_untaxed", "email", "phone", "company_id", "user_id",
"product_id", "quantity", "price_unit", "price_subtotal"}
var fields []string
added := make(map[string]bool)
// Add priority fields first
for _, pf := range priority {
f := m.GetField(pf)
if f != nil && f.IsStored() && f.Type != orm.TypeBinary {
fields = append(fields, fmt.Sprintf(`<field name="%s"/>`, pf))
added[pf] = true
}
}
// Fill remaining slots
for _, f := range m.Fields() {
if len(fields) >= 10 {
break
}
if added[f.Name] || f.Name == "id" || !f.IsStored() ||
f.Name == "create_uid" || f.Name == "write_uid" ||
f.Name == "create_date" || f.Name == "write_date" ||
f.Name == "password" || f.Name == "password_crypt" ||
f.Type == orm.TypeBinary || f.Type == orm.TypeText || f.Type == orm.TypeHTML {
continue
}
fields = append(fields, fmt.Sprintf(`<field name="%s"/>`, f.Name))
added[f.Name] = true
}
return fmt.Sprintf("<list>\n %s\n</list>", strings.Join(fields, "\n "))
}
func generateDefaultFormView(m *orm.Model) string {
skip := map[string]bool{
"id": true, "create_uid": true, "write_uid": true,
"create_date": true, "write_date": true,
"password": true, "password_crypt": true,
}
// Header with action buttons and state widget
// Mirrors: odoo form views with <header><button .../><field name="state" widget="statusbar"/></header>
var header string
if f := m.GetField("state"); f != nil && f.Type == orm.TypeSelection {
var buttons []string
// Generate buttons from registered methods that look like actions
if m.Methods != nil {
actionMethods := []struct{ method, label, stateFilter string }{
{"action_confirm", "Confirm", "draft"},
{"action_post", "Post", "draft"},
{"action_done", "Done", "confirmed"},
{"action_cancel", "Cancel", ""},
{"button_cancel", "Cancel", ""},
{"button_draft", "Reset to Draft", "cancel"},
{"action_send", "Send", "posted"},
{"create_invoices", "Create Invoice", "sale"},
}
for _, am := range actionMethods {
if _, ok := m.Methods[am.method]; ok {
attrs := ""
if am.stateFilter != "" {
attrs = fmt.Sprintf(` invisible="state != '%s'"`, am.stateFilter)
}
btnClass := "btn-secondary"
if am.method == "action_confirm" || am.method == "action_post" {
btnClass = "btn-primary"
}
buttons = append(buttons, fmt.Sprintf(
` <button name="%s" string="%s" type="object" class="%s"%s/>`,
am.method, am.label, btnClass, attrs))
}
}
}
header = " <header>\n"
for _, btn := range buttons {
header += btn + "\n"
}
header += ` <field name="state" widget="statusbar" clickable="1"/>
</header>
`
}
// Title field (name or display_name)
var title string
if f := m.GetField("name"); f != nil {
title = ` <div class="oe_title">
<h1><field name="name" placeholder="Name..."/></h1>
</div>
`
skip["name"] = true
}
// Split fields into left/right groups
var leftFields, rightFields []string
var o2mFields []string
count := 0
// Prioritize important fields
priority := []string{"partner_id", "date_order", "date", "company_id", "currency_id",
"user_id", "journal_id", "product_id", "email", "phone"}
for _, pf := range priority {
f := m.GetField(pf)
if f == nil || skip[pf] || f.Type == orm.TypeBinary {
continue
}
skip[pf] = true
line := fmt.Sprintf(` <field name="%s"/>`, pf)
if count%2 == 0 {
leftFields = append(leftFields, line)
} else {
rightFields = append(rightFields, line)
}
count++
}
// Add remaining stored fields
for _, f := range m.Fields() {
if skip[f.Name] || !f.IsStored() || f.Type == orm.TypeBinary {
continue
}
if f.Type == orm.TypeOne2many {
o2mFields = append(o2mFields, fmt.Sprintf(` <field name="%s"/>`, f.Name))
continue
}
if f.Type == orm.TypeMany2many {
continue
}
line := fmt.Sprintf(` <field name="%s"/>`, f.Name)
if len(leftFields) <= len(rightFields) {
leftFields = append(leftFields, line)
} else {
rightFields = append(rightFields, line)
}
if len(leftFields)+len(rightFields) >= 20 {
break
}
}
// Build form
var buf strings.Builder
buf.WriteString("<form>\n")
buf.WriteString(header)
buf.WriteString(" <sheet>\n")
buf.WriteString(title)
buf.WriteString(" <group>\n")
buf.WriteString(" <group>\n")
buf.WriteString(strings.Join(leftFields, "\n"))
buf.WriteString("\n </group>\n")
buf.WriteString(" <group>\n")
buf.WriteString(strings.Join(rightFields, "\n"))
buf.WriteString("\n </group>\n")
buf.WriteString(" </group>\n")
// O2M fields in notebook
if len(o2mFields) > 0 {
buf.WriteString(" <notebook>\n")
buf.WriteString(" <page string=\"Lines\">\n")
buf.WriteString(strings.Join(o2mFields, "\n"))
buf.WriteString("\n </page>\n")
buf.WriteString(" </notebook>\n")
}
buf.WriteString(" </sheet>\n")
buf.WriteString("</form>")
return buf.String()
}
func generateDefaultSearchView(m *orm.Model) string {
var fields []string
var filters []string
// Search fields
searchable := []string{"name", "display_name", "email", "phone", "ref",
"partner_id", "company_id", "user_id", "state", "date_order", "date"}
for _, sf := range searchable {
if f := m.GetField(sf); f != nil {
fields = append(fields, fmt.Sprintf(`<field name="%s"/>`, sf))
}
}
if len(fields) == 0 {
fields = append(fields, `<field name="id"/>`)
}
// Auto-generate filter for state field
if f := m.GetField("state"); f != nil && f.Type == orm.TypeSelection {
for _, sel := range f.Selection {
filters = append(filters, fmt.Sprintf(
`<filter string="%s" name="filter_%s" domain="[('state','=','%s')]"/>`,
sel.Label, sel.Value, sel.Value))
}
}
// Group-by for common fields
var groupby []string
groupable := []string{"partner_id", "state", "company_id", "user_id", "stage_id"}
for _, gf := range groupable {
if f := m.GetField(gf); f != nil {
groupby = append(groupby, fmt.Sprintf(`<filter string="%s" name="groupby_%s" context="{'group_by': '%s'}"/>`,
f.String, gf, gf))
}
}
var buf strings.Builder
buf.WriteString("<search>\n")
for _, f := range fields {
buf.WriteString(" " + f + "\n")
}
if len(filters) > 0 {
buf.WriteString(" <separator/>\n")
for _, f := range filters {
buf.WriteString(" " + f + "\n")
}
}
if len(groupby) > 0 {
buf.WriteString(" <group expand=\"0\" string=\"Group By\">\n")
for _, g := range groupby {
buf.WriteString(" " + g + "\n")
}
buf.WriteString(" </group>\n")
}
buf.WriteString("</search>")
return buf.String()
}
func generateDefaultKanbanView(m *orm.Model) string {
nameField := "name"
if f := m.GetField("name"); f == nil {
nameField = "id"
}
// Build a richer card with available fields
var cardFields []string
// Title
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s" class="fw-bold fs-5"/>`, nameField))
// Partner/customer
if f := m.GetField("partner_id"); f != nil {
cardFields = append(cardFields, ` <field name="partner_id"/>`)
}
// Revenue/amount
for _, amtField := range []string{"expected_revenue", "amount_total", "amount_untaxed"} {
if f := m.GetField(amtField); f != nil {
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s"/>`, amtField))
break
}
}
// Date
for _, dateField := range []string{"date_order", "date", "date_deadline"} {
if f := m.GetField(dateField); f != nil {
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s"/>`, dateField))
break
}
}
// User/assignee
if f := m.GetField("user_id"); f != nil {
cardFields = append(cardFields, ` <field name="user_id" widget="many2one_avatar_user"/>`)
}
return fmt.Sprintf(`<kanban>
<templates>
<t t-name="card">
%s
</t>
</templates>
</kanban>`, strings.Join(cardFields, "\n"))
}