Phase 1: read_group/web_read_group with SQL GROUP BY, aggregates (sum/avg/min/max/count/array_agg/sum_currency), date granularity, M2O groupby resolution to [id, display_name]. Phase 2: Record rules with domain_force parsing (Python literal parser), global AND + group OR merging. Domain operators: child_of, parent_of, any, not any compiled to SQL hierarchy/EXISTS queries. Phase 3: Button dispatch via /web/dataset/call_button, method return values interpreted as actions. Payment register wizard (account.payment.register) for sale→invoice→pay flow. Phase 4: ir.filters, ir.default, product fields expanded, SO line product_id onchange, ir_model+ir_model_fields DB seeding. Phase 5: CSV export (/web/export/csv), attachment upload/download via ir.attachment, fields_get with aggregator hints. Admin/System: Session persistence (PostgreSQL-backed), ir.config_parameter with get_param/set_param, ir.cron, ir.logging, res.lang, res.config.settings with company-related fields, Settings form view. Technical menu with Views/Actions/Parameters/Security/Logging sub-menus. User change_password, preferences. Password never exposed in UI/API. Bugfixes: false→nil for varchar/int fields, int32 in toInt64, call_button route with trailing slash, create_invoices returns action, search view always included, get_formview_action, name_create, ir.http stub. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
285 lines
6.8 KiB
Go
285 lines
6.8 KiB
Go
package orm
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// ProcessO2MCommands processes One2many field commands after a Create/Write.
|
|
// Mirrors: odoo/orm/fields_relational.py One2many write logic
|
|
//
|
|
// Commands:
|
|
//
|
|
// CmdCreate(vals) → Create child record with inverse_field = parentID
|
|
// CmdUpdate(id, vals) → Update child record
|
|
// CmdDelete(id) → Delete child record
|
|
func ProcessO2MCommands(env *Environment, f *Field, parentID int64, cmds []Command) error {
|
|
if f.Comodel == "" || f.InverseField == "" {
|
|
return fmt.Errorf("orm: O2M field %q missing comodel or inverse_field", f.Name)
|
|
}
|
|
|
|
comodelRS := env.Model(f.Comodel)
|
|
|
|
for _, cmd := range cmds {
|
|
switch cmd.Operation {
|
|
case CommandCreate:
|
|
vals := cmd.Values
|
|
if vals == nil {
|
|
vals = make(Values)
|
|
}
|
|
vals[f.InverseField] = parentID
|
|
if _, err := comodelRS.Create(vals); err != nil {
|
|
return fmt.Errorf("orm: O2M create on %s: %w", f.Comodel, err)
|
|
}
|
|
|
|
case CommandUpdate:
|
|
if cmd.ID <= 0 {
|
|
continue
|
|
}
|
|
if err := comodelRS.Browse(cmd.ID).Write(cmd.Values); err != nil {
|
|
return fmt.Errorf("orm: O2M update %s(%d): %w", f.Comodel, cmd.ID, err)
|
|
}
|
|
|
|
case CommandDelete:
|
|
if cmd.ID <= 0 {
|
|
continue
|
|
}
|
|
if err := comodelRS.Browse(cmd.ID).Unlink(); err != nil {
|
|
return fmt.Errorf("orm: O2M delete %s(%d): %w", f.Comodel, cmd.ID, err)
|
|
}
|
|
|
|
case CommandClear:
|
|
// Delete all children linked to this parent
|
|
children, err := comodelRS.Search(And(Leaf(f.InverseField, "=", parentID)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := children.Unlink(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ProcessM2MCommands processes Many2many field commands after a Create/Write.
|
|
// Mirrors: odoo/orm/fields_relational.py Many2many write logic
|
|
//
|
|
// Commands:
|
|
//
|
|
// CmdLink(id) → Add link in junction table
|
|
// CmdUnlink(id) → Remove link from junction table
|
|
// CmdClear() → Remove all links
|
|
// CmdSet(ids) → Replace all links
|
|
// CmdCreate(vals) → Create comodel record then link it
|
|
func ProcessM2MCommands(env *Environment, f *Field, parentID int64, cmds []Command) error {
|
|
jt := f.JunctionTable()
|
|
col1 := f.JunctionCol1()
|
|
col2 := f.JunctionCol2()
|
|
|
|
if jt == "" || col1 == "" || col2 == "" {
|
|
return fmt.Errorf("orm: M2M field %q: cannot determine junction table", f.Name)
|
|
}
|
|
|
|
for _, cmd := range cmds {
|
|
switch cmd.Operation {
|
|
case CommandLink:
|
|
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
|
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
jt, col1, col2,
|
|
), parentID, cmd.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("orm: M2M link %s: %w", f.Name, err)
|
|
}
|
|
|
|
case CommandUnlink:
|
|
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
|
`DELETE FROM %q WHERE %q = $1 AND %q = $2`,
|
|
jt, col1, col2,
|
|
), parentID, cmd.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("orm: M2M unlink %s: %w", f.Name, err)
|
|
}
|
|
|
|
case CommandClear:
|
|
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
|
`DELETE FROM %q WHERE %q = $1`,
|
|
jt, col1,
|
|
), parentID)
|
|
if err != nil {
|
|
return fmt.Errorf("orm: M2M clear %s: %w", f.Name, err)
|
|
}
|
|
|
|
case CommandSet:
|
|
// Clear then link all
|
|
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
|
`DELETE FROM %q WHERE %q = $1`,
|
|
jt, col1,
|
|
), parentID); err != nil {
|
|
return err
|
|
}
|
|
for _, targetID := range cmd.IDs {
|
|
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
|
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
jt, col1, col2,
|
|
), parentID, targetID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
case CommandCreate:
|
|
// Create comodel record then link
|
|
comodelRS := env.Model(f.Comodel)
|
|
created, err := comodelRS.Create(cmd.Values)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
|
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
jt, col1, col2,
|
|
), parentID, created.ID()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadM2MField reads the linked IDs for a Many2many field.
|
|
func ReadM2MField(env *Environment, f *Field, parentIDs []int64) (map[int64][]int64, error) {
|
|
jt := f.JunctionTable()
|
|
col1 := f.JunctionCol1()
|
|
col2 := f.JunctionCol2()
|
|
|
|
if jt == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
placeholders := make([]string, len(parentIDs))
|
|
args := make([]interface{}, len(parentIDs))
|
|
for i, id := range parentIDs {
|
|
args[i] = id
|
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
}
|
|
|
|
query := fmt.Sprintf(
|
|
`SELECT %q, %q FROM %q WHERE %q IN (%s)`,
|
|
col1, col2, jt, col1, strings.Join(placeholders, ", "),
|
|
)
|
|
|
|
rows, err := env.tx.Query(env.ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := make(map[int64][]int64)
|
|
for rows.Next() {
|
|
var pid, tid int64
|
|
if err := rows.Scan(&pid, &tid); err != nil {
|
|
return nil, err
|
|
}
|
|
result[pid] = append(result[pid], tid)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// ParseCommands converts a JSON value to a slice of Commands.
|
|
// JSON format: [[0, 0, {vals}], [4, id, 0], [6, 0, [ids]], ...]
|
|
func ParseCommands(raw interface{}) ([]Command, bool) {
|
|
list, ok := raw.([]interface{})
|
|
if !ok || len(list) == 0 {
|
|
return nil, false
|
|
}
|
|
|
|
// Check if this looks like commands (list of lists)
|
|
first, ok := list[0].([]interface{})
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if len(first) < 2 {
|
|
return nil, false
|
|
}
|
|
|
|
var cmds []Command
|
|
for _, item := range list {
|
|
tuple, ok := item.([]interface{})
|
|
if !ok || len(tuple) < 2 {
|
|
continue
|
|
}
|
|
|
|
op, ok := toInt64(tuple[0])
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
switch CommandOp(op) {
|
|
case CommandCreate: // [0, _, {vals}]
|
|
vals := make(Values)
|
|
if len(tuple) > 2 {
|
|
if m, ok := tuple[2].(map[string]interface{}); ok {
|
|
vals = m
|
|
}
|
|
}
|
|
cmds = append(cmds, CmdCreate(vals))
|
|
|
|
case CommandUpdate: // [1, id, {vals}]
|
|
id, _ := toInt64(tuple[1])
|
|
vals := make(Values)
|
|
if len(tuple) > 2 {
|
|
if m, ok := tuple[2].(map[string]interface{}); ok {
|
|
vals = m
|
|
}
|
|
}
|
|
cmds = append(cmds, CmdUpdate(id, vals))
|
|
|
|
case CommandDelete: // [2, id, _]
|
|
id, _ := toInt64(tuple[1])
|
|
cmds = append(cmds, CmdDelete(id))
|
|
|
|
case CommandUnlink: // [3, id, _]
|
|
id, _ := toInt64(tuple[1])
|
|
cmds = append(cmds, CmdUnlink(id))
|
|
|
|
case CommandLink: // [4, id, _]
|
|
id, _ := toInt64(tuple[1])
|
|
cmds = append(cmds, CmdLink(id))
|
|
|
|
case CommandClear: // [5, _, _]
|
|
cmds = append(cmds, CmdClear())
|
|
|
|
case CommandSet: // [6, _, [ids]]
|
|
var ids []int64
|
|
if len(tuple) > 2 {
|
|
if arr, ok := tuple[2].([]interface{}); ok {
|
|
for _, v := range arr {
|
|
if id, ok := toInt64(v); ok {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
cmds = append(cmds, CmdSet(ids))
|
|
}
|
|
}
|
|
|
|
if len(cmds) == 0 {
|
|
return nil, false
|
|
}
|
|
return cmds, true
|
|
}
|
|
|
|
func toInt64(v interface{}) (int64, bool) {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return int64(n), true
|
|
case int64:
|
|
return n, true
|
|
case int32:
|
|
return int64(n), true
|
|
case int:
|
|
return int64(n), true
|
|
}
|
|
return 0, false
|
|
}
|