ORM: - SQL Constraints support (Model.AddSQLConstraint, applied in InitDatabase) - Translatable field Read (ir_translation lookup for non-en_US) - active_test filter in SearchCount + ReadGroup (consistency with Search) - Environment.Ref() improved (format validation, parameterized query) Server: - /web/action/run endpoint (server action execution stub) - /web/model/get_definitions (field metadata for multiple models) - Binary field serving rewritten: reads from DB, falls back to SVG with record initial (fixes avatar/logo rendering) Business modules deepened: - Account: action_post validation (partner, lines), sequence numbering (JOURNAL/YYYY/NNNN), action_register_payment, remove_move_reconcile - Sale: action_cancel, action_draft, action_view_invoice - Purchase: button_draft - Stock: action_cancel on picking - CRM: action_set_won_rainbowman, convert_opportunity - HR: hr.contract model (employee, wage, dates, state) - Project: action_blocked, task stage seed data Views: - Cancel/Reset buttons in sale.form header - Register Payment button in invoice.form (visible when posted+unpaid) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
445 lines
12 KiB
Go
445 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"odoo-go/pkg/orm"
|
|
)
|
|
|
|
// handleGetDefinitions returns field definitions for one or more models.
|
|
// Mirrors: odoo/addons/web/controllers/model.py Model.get_definitions()
|
|
func (s *Server) handleGetDefinitions(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req JSONRPCRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
|
return
|
|
}
|
|
var params struct {
|
|
Models []string `json:"model_names"`
|
|
}
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
|
|
result := make(map[string]interface{})
|
|
for _, modelName := range params.Models {
|
|
result[modelName] = fieldsGetForModel(modelName)
|
|
}
|
|
s.writeJSONRPC(w, req.ID, result, nil)
|
|
}
|
|
|
|
// 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"))
|
|
}
|