Complete ORM core: _inherits, Related write-back, Copy, constraints
Batch 1 (quick-fixes): - Field.Copy attribute + IsCopyable() method for copy control - Constraints now run in Write() (was only Create — bug fix) - Readonly fields silently skipped in Write() - active_test: auto-filter archived records in Search() Batch 2 (Related field write-back): - preprocessRelatedWrites() follows FK chain and writes to target model - Enables Settings page to edit company name/address/etc. - Loop protection via _write_related_depth context counter Batch 3 (_inherits delegation): - SetupAllInherits() generates Related fields from parent models - preprocessInheritsCreate() auto-creates parent records on Create - Declared on res.users, res.company, product.product - Called in LoadModules before compute setup Batch 4 (Copy method): - Recordset.Copy(defaults) with blacklist, IsCopyable check - M2M re-linking, rec_name "(copy)" suffix - Replaces simplified copy case in server dispatch Batch 5 (Onchange compute): - RunOnchangeComputes() triggers dependent computes on field change - Virtual record (ID=-1) with client values in cache - Integrated into onchange RPC handler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ func initResCompany() {
|
|||||||
m := orm.NewModel("res.company", orm.ModelOpts{
|
m := orm.NewModel("res.company", orm.ModelOpts{
|
||||||
Description: "Companies",
|
Description: "Companies",
|
||||||
Order: "sequence, name",
|
Order: "sequence, name",
|
||||||
|
Inherits: map[string]string{"res.partner": "partner_id"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// -- Identity --
|
// -- Identity --
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func initResUsers() {
|
|||||||
m := orm.NewModel("res.users", orm.ModelOpts{
|
m := orm.NewModel("res.users", orm.ModelOpts{
|
||||||
Description: "Users",
|
Description: "Users",
|
||||||
Order: "login",
|
Order: "login",
|
||||||
|
Inherits: map[string]string{"res.partner": "partner_id"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// -- Authentication --
|
// -- Authentication --
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ func initProductProduct() {
|
|||||||
m := orm.NewModel("product.product", orm.ModelOpts{
|
m := orm.NewModel("product.product", orm.ModelOpts{
|
||||||
Description: "Product",
|
Description: "Product",
|
||||||
Order: "default_code, name, id",
|
Order: "default_code, name, id",
|
||||||
|
Inherits: map[string]string{"product.template": "product_tmpl_id"},
|
||||||
})
|
})
|
||||||
|
|
||||||
m.AddFields(
|
m.AddFields(
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ func LoadModules(moduleNames []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Build computed field dependency maps
|
// Phase 3: Generate Related fields for _inherits delegation
|
||||||
|
orm.SetupAllInherits()
|
||||||
|
|
||||||
|
// Phase 4: Build computed field dependency maps
|
||||||
orm.SetupAllComputes()
|
orm.SetupAllComputes()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -196,6 +196,74 @@ func joinStrings(s []string, sep string) string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunOnchangeComputes runs computed fields that depend on changed fields.
|
||||||
|
// Used by the onchange RPC to recompute values when form fields change.
|
||||||
|
// Mirrors: odoo/orm/models.py BaseModel._onchange_eval()
|
||||||
|
//
|
||||||
|
// Creates a virtual record (ID=-1) with the client's values in cache,
|
||||||
|
// then runs any compute functions whose dependencies include the changed fields.
|
||||||
|
func RunOnchangeComputes(m *Model, env *Environment, currentVals Values, changedFields []string) Values {
|
||||||
|
result := make(Values)
|
||||||
|
if m == nil || len(m.dependencyMap) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find which computed fields need recomputation
|
||||||
|
toRecompute := make(map[string]bool)
|
||||||
|
for _, fname := range changedFields {
|
||||||
|
if deps, ok := m.dependencyMap[fname]; ok {
|
||||||
|
for _, computedField := range deps {
|
||||||
|
toRecompute[computedField] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toRecompute) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate cache with current form values so computes can read them
|
||||||
|
fakeID := int64(-1)
|
||||||
|
for k, v := range currentVals {
|
||||||
|
env.cache.Set(m.Name(), fakeID, k, v)
|
||||||
|
}
|
||||||
|
defer env.cache.InvalidateRecord(m.Name(), fakeID)
|
||||||
|
|
||||||
|
// Create virtual recordset
|
||||||
|
rs := &Recordset{env: env, model: m, ids: []int64{fakeID}}
|
||||||
|
|
||||||
|
// Track which compute functions we've already called (multiple fields can share one function)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for fieldName := range toRecompute {
|
||||||
|
f := m.GetField(fieldName)
|
||||||
|
if f == nil || f.Compute == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Don't call the same compute function twice
|
||||||
|
if seen[f.Compute] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[f.Compute] = true
|
||||||
|
|
||||||
|
fn, ok := m.computes[fieldName]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
computed, err := fn(rs)
|
||||||
|
if err != nil {
|
||||||
|
// Non-fatal: skip failed computes during onchange
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k, v := range computed {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// SetupAllComputes calls SetupComputes on all registered models.
|
// SetupAllComputes calls SetupComputes on all registered models.
|
||||||
func SetupAllComputes() {
|
func SetupAllComputes() {
|
||||||
for _, m := range Registry.Models() {
|
for _, m := range Registry.Models() {
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ type Field struct {
|
|||||||
// Translation
|
// Translation
|
||||||
Translate bool // Field supports multi-language
|
Translate bool // Field supports multi-language
|
||||||
|
|
||||||
|
// Copy
|
||||||
|
Copy bool // Whether this field is copied on record duplication
|
||||||
|
|
||||||
// Groups (access control)
|
// Groups (access control)
|
||||||
Groups string // Comma-separated group XML IDs
|
Groups string // Comma-separated group XML IDs
|
||||||
|
|
||||||
@@ -97,6 +100,27 @@ func (f *Field) IsStored() bool {
|
|||||||
return f.Type.IsStored()
|
return f.Type.IsStored()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsCopyable returns true if this field should be copied on record duplication.
|
||||||
|
// Mirrors: odoo/orm/fields.py Field.copy
|
||||||
|
func (f *Field) IsCopyable() bool {
|
||||||
|
// If explicitly set, use that
|
||||||
|
if f.Copy {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Defaults: non-copyable for computed, O2M, id, timestamps
|
||||||
|
if f.Name == "id" || f.Name == "create_uid" || f.Name == "create_date" ||
|
||||||
|
f.Name == "write_uid" || f.Name == "write_date" || f.Name == "password" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if f.Compute != "" && !f.Store {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if f.Type == TypeOne2many {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// IsRelational returns true for relational field types.
|
// IsRelational returns true for relational field types.
|
||||||
func (f *Field) IsRelational() bool {
|
func (f *Field) IsRelational() bool {
|
||||||
return f.Type.IsRelational()
|
return f.Type.IsRelational()
|
||||||
@@ -130,6 +154,7 @@ type FieldOpts struct {
|
|||||||
OnDelete OnDelete
|
OnDelete OnDelete
|
||||||
CurrencyField string
|
CurrencyField string
|
||||||
Translate bool
|
Translate bool
|
||||||
|
Copy bool
|
||||||
Groups string
|
Groups string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +185,7 @@ func newField(name string, typ FieldType, opts FieldOpts) *Field {
|
|||||||
OnDelete: opts.OnDelete,
|
OnDelete: opts.OnDelete,
|
||||||
CurrencyField: opts.CurrencyField,
|
CurrencyField: opts.CurrencyField,
|
||||||
Translate: opts.Translate,
|
Translate: opts.Translate,
|
||||||
|
Copy: opts.Copy,
|
||||||
Groups: opts.Groups,
|
Groups: opts.Groups,
|
||||||
column: name,
|
column: name,
|
||||||
}
|
}
|
||||||
|
|||||||
152
pkg/orm/inherits.go
Normal file
152
pkg/orm/inherits.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package orm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupAllInherits generates Related fields for all models with _inherits declarations.
|
||||||
|
// Must be called after all models are registered but before computes are set up.
|
||||||
|
// Mirrors: odoo/orm/model_classes.py _add_inherited_fields()
|
||||||
|
func SetupAllInherits() {
|
||||||
|
for _, m := range Registry.Models() {
|
||||||
|
if len(m.inherits) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
setupModelInherits(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupModelInherits(m *Model) {
|
||||||
|
for parentName, fkField := range m.inherits {
|
||||||
|
parent := Registry.Get(parentName)
|
||||||
|
if parent == nil {
|
||||||
|
log.Printf("inherits: parent model %q not found for %s", parentName, m.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify FK field exists on child
|
||||||
|
fkDef := m.GetField(fkField)
|
||||||
|
if fkDef == nil {
|
||||||
|
log.Printf("inherits: FK field %q not found on %s", fkField, m.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each parent field, create a Related field on child if not already defined
|
||||||
|
inherited := 0
|
||||||
|
for _, fname := range parent.fieldOrder {
|
||||||
|
// Skip magic fields (child has its own)
|
||||||
|
if fname == "id" || fname == "display_name" ||
|
||||||
|
fname == "create_uid" || fname == "create_date" ||
|
||||||
|
fname == "write_uid" || fname == "write_date" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't override fields already defined on child
|
||||||
|
if existing := m.GetField(fname); existing != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pf := parent.GetField(fname)
|
||||||
|
if pf == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Related field: "fk_field.field_name"
|
||||||
|
relatedPath := fkField + "." + fname
|
||||||
|
|
||||||
|
inheritedField := &Field{
|
||||||
|
Name: fname,
|
||||||
|
Type: pf.Type,
|
||||||
|
String: pf.String,
|
||||||
|
Help: pf.Help,
|
||||||
|
Related: relatedPath,
|
||||||
|
Comodel: pf.Comodel,
|
||||||
|
InverseField: pf.InverseField,
|
||||||
|
Selection: pf.Selection,
|
||||||
|
CurrencyField: pf.CurrencyField,
|
||||||
|
Readonly: pf.Readonly,
|
||||||
|
Required: false, // Inherited fields are not required on child
|
||||||
|
model: m,
|
||||||
|
column: fname,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to model's field maps
|
||||||
|
m.fields[fname] = inheritedField
|
||||||
|
m.allFields[fname] = inheritedField
|
||||||
|
m.fieldOrder = append(m.fieldOrder, fname)
|
||||||
|
inherited++
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("inherits: %s inherits %d fields from %s via %s",
|
||||||
|
m.Name(), inherited, parentName, fkField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// preprocessInheritsCreate separates inherited field values and creates/updates parent records.
|
||||||
|
// Must be called in Create() BEFORE ApplyDefaults.
|
||||||
|
// Mirrors: odoo/orm/models.py create() _inherits handling
|
||||||
|
func preprocessInheritsCreate(env *Environment, m *Model, vals Values) error {
|
||||||
|
if len(m.inherits) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for parentName, fkField := range m.inherits {
|
||||||
|
parent := Registry.Get(parentName)
|
||||||
|
if parent == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate parent vals from child vals
|
||||||
|
parentVals := make(Values)
|
||||||
|
for fieldName, val := range vals {
|
||||||
|
// Check if this field belongs to parent (not child's own stored field)
|
||||||
|
childField := m.fields[fieldName]
|
||||||
|
parentField := parent.GetField(fieldName)
|
||||||
|
|
||||||
|
if parentField != nil && fieldName != fkField &&
|
||||||
|
(childField == nil || childField.Related != "") {
|
||||||
|
parentVals[fieldName] = val
|
||||||
|
delete(vals, fieldName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if FK is already provided
|
||||||
|
fkVal, hasFk := vals[fkField]
|
||||||
|
if hasFk && fkVal != nil {
|
||||||
|
// FK provided — update existing parent record with any inherited vals
|
||||||
|
fkID := int64(0)
|
||||||
|
switch v := fkVal.(type) {
|
||||||
|
case float64:
|
||||||
|
fkID = int64(v)
|
||||||
|
case int64:
|
||||||
|
fkID = v
|
||||||
|
case int32:
|
||||||
|
fkID = int64(v)
|
||||||
|
}
|
||||||
|
if fkID > 0 && len(parentVals) > 0 {
|
||||||
|
parentRS := env.Model(parentName).Browse(fkID)
|
||||||
|
if err := parentRS.Write(parentVals); err != nil {
|
||||||
|
return fmt.Errorf("inherits: update %s(%d): %w", parentName, fkID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No FK — create parent record first
|
||||||
|
if len(parentVals) == 0 {
|
||||||
|
// Need at least something for the parent (e.g., name)
|
||||||
|
// Check if name is in child vals but not moved to parent
|
||||||
|
if nameVal, ok := vals["name"]; ok {
|
||||||
|
parentVals["name"] = nameVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parentRS := env.Model(parentName)
|
||||||
|
created, err := parentRS.Create(parentVals)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inherits: create %s: %w", parentName, err)
|
||||||
|
}
|
||||||
|
vals[fkField] = created.ID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -94,6 +94,11 @@ func (rs *Recordset) WithContext(ctx map[string]interface{}) *Recordset {
|
|||||||
func (rs *Recordset) Create(vals Values) (*Recordset, error) {
|
func (rs *Recordset) Create(vals Values) (*Recordset, error) {
|
||||||
m := rs.model
|
m := rs.model
|
||||||
|
|
||||||
|
// Handle _inherits: separate parent vals and create/update parent records
|
||||||
|
if err := preprocessInheritsCreate(rs.env, m, vals); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 1: Apply defaults for missing fields
|
// Phase 1: Apply defaults for missing fields
|
||||||
ApplyDefaults(m, vals)
|
ApplyDefaults(m, vals)
|
||||||
|
|
||||||
@@ -211,6 +216,96 @@ func (rs *Recordset) Create(vals Values) (*Recordset, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preprocessRelatedWrites handles write-back for Related fields.
|
||||||
|
// When writing to a Related field (e.g., "company_id.name"), this function
|
||||||
|
// follows the FK chain and writes to the target model instead.
|
||||||
|
// Mirrors: odoo/orm/fields.py _inverse_related()
|
||||||
|
func preprocessRelatedWrites(env *Environment, m *Model, ids []int64, vals Values) error {
|
||||||
|
// Prevent infinite recursion
|
||||||
|
depth := 0
|
||||||
|
if d, ok := env.context["_write_related_depth"]; ok {
|
||||||
|
if di, ok := d.(int); ok {
|
||||||
|
depth = di
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if depth > 5 {
|
||||||
|
return nil // Safety: max recursion depth
|
||||||
|
}
|
||||||
|
|
||||||
|
var relatedFields []string
|
||||||
|
for fieldName := range vals {
|
||||||
|
f := m.GetField(fieldName)
|
||||||
|
if f != nil && f.Related != "" && !f.IsStored() {
|
||||||
|
relatedFields = append(relatedFields, fieldName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(relatedFields) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read FK values for the records being written
|
||||||
|
// We need the FK IDs to know which parent records to update
|
||||||
|
for _, fieldName := range relatedFields {
|
||||||
|
f := m.GetField(fieldName)
|
||||||
|
parts := strings.Split(f.Related, ".")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
delete(vals, fieldName) // Can't handle multi-hop yet, skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fkFieldName := parts[0]
|
||||||
|
targetFieldName := parts[1]
|
||||||
|
|
||||||
|
fkDef := m.GetField(fkFieldName)
|
||||||
|
if fkDef == nil || fkDef.Type != TypeMany2one || fkDef.Comodel == "" {
|
||||||
|
delete(vals, fieldName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
value := vals[fieldName]
|
||||||
|
delete(vals, fieldName) // Remove from vals — no local column
|
||||||
|
|
||||||
|
// Read FK IDs for all records
|
||||||
|
var fkIDs []int64
|
||||||
|
for _, id := range ids {
|
||||||
|
var fkID *int64
|
||||||
|
env.tx.QueryRow(env.ctx,
|
||||||
|
fmt.Sprintf(`SELECT %q FROM %q WHERE id = $1`, fkDef.Column(), m.Table()),
|
||||||
|
id,
|
||||||
|
).Scan(&fkID)
|
||||||
|
if fkID != nil && *fkID > 0 {
|
||||||
|
fkIDs = append(fkIDs, *fkID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fkIDs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
seen := make(map[int64]bool)
|
||||||
|
var uniqueIDs []int64
|
||||||
|
for _, id := range fkIDs {
|
||||||
|
if !seen[id] {
|
||||||
|
seen[id] = true
|
||||||
|
uniqueIDs = append(uniqueIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to target model with increased depth
|
||||||
|
depthEnv := env.WithContext(map[string]interface{}{
|
||||||
|
"_write_related_depth": depth + 1,
|
||||||
|
})
|
||||||
|
targetRS := &Recordset{env: depthEnv, model: Registry.MustGet(fkDef.Comodel), ids: uniqueIDs}
|
||||||
|
if err := targetRS.Write(Values{targetFieldName: value}); err != nil {
|
||||||
|
return fmt.Errorf("related write-back %s.%s → %s.%s: %w",
|
||||||
|
m.Name(), fieldName, fkDef.Comodel, targetFieldName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Write updates the records in this recordset.
|
// Write updates the records in this recordset.
|
||||||
// Mirrors: records.write(vals)
|
// Mirrors: records.write(vals)
|
||||||
func (rs *Recordset) Write(vals Values) error {
|
func (rs *Recordset) Write(vals Values) error {
|
||||||
@@ -230,11 +325,20 @@ func (rs *Recordset) Write(vals Values) error {
|
|||||||
}
|
}
|
||||||
vals["write_date"] = "NOW()" // Will be handled specially
|
vals["write_date"] = "NOW()" // Will be handled specially
|
||||||
|
|
||||||
|
// Handle Related field write-back (must happen before building SET clauses)
|
||||||
|
if err := preprocessRelatedWrites(rs.env, m, rs.ids, vals); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for _, name := range m.fieldOrder {
|
for _, name := range m.fieldOrder {
|
||||||
f := m.fields[name]
|
f := m.fields[name]
|
||||||
if name == "id" || !f.IsStored() {
|
if name == "id" || !f.IsStored() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Skip readonly fields (like Python Odoo, silently ignore)
|
||||||
|
if f.Readonly && name != "write_uid" && name != "write_date" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
val, exists := vals[name]
|
val, exists := vals[name]
|
||||||
if !exists {
|
if !exists {
|
||||||
continue
|
continue
|
||||||
@@ -294,6 +398,15 @@ func (rs *Recordset) Write(vals Values) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run constraints after write (mirrors Create constraint check)
|
||||||
|
for _, constraint := range m.Constraints {
|
||||||
|
for _, id := range rs.ids {
|
||||||
|
if err := constraint(rs.Browse(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,6 +703,25 @@ func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, erro
|
|||||||
m := rs.model
|
m := rs.model
|
||||||
opt := mergeSearchOpts(opts)
|
opt := mergeSearchOpts(opts)
|
||||||
|
|
||||||
|
// Auto-filter archived records unless active_test=false in context
|
||||||
|
// Mirrors: odoo/orm/models.py BaseModel._where_calc()
|
||||||
|
if activeField := m.GetField("active"); activeField != nil {
|
||||||
|
activeTest := true
|
||||||
|
if v, ok := rs.env.context["active_test"]; ok {
|
||||||
|
if b, ok := v.(bool); ok {
|
||||||
|
activeTest = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if activeTest {
|
||||||
|
activeLeaf := Leaf("active", "=", true)
|
||||||
|
if len(domain) == 0 {
|
||||||
|
domain = Domain{activeLeaf}
|
||||||
|
} else {
|
||||||
|
domain = append(Domain{OpAnd}, append(domain, activeLeaf)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply record rules (e.g., multi-company filter)
|
// Apply record rules (e.g., multi-company filter)
|
||||||
domain = ApplyRecordRules(rs.env, m, domain)
|
domain = ApplyRecordRules(rs.env, m, domain)
|
||||||
|
|
||||||
@@ -749,6 +881,88 @@ func (rs *Recordset) Records() []*Recordset {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy duplicates a singleton record, returning the new record.
|
||||||
|
// Mirrors: odoo/orm/models.py BaseModel.copy()
|
||||||
|
//
|
||||||
|
// new_partner = partner.Copy(orm.Values{"name": "New Name"})
|
||||||
|
func (rs *Recordset) Copy(defaults Values) (*Recordset, error) {
|
||||||
|
if err := rs.Ensure(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := rs.model
|
||||||
|
|
||||||
|
// Read all stored fields
|
||||||
|
records, err := rs.Read(nil)
|
||||||
|
if err != nil || len(records) == 0 {
|
||||||
|
return nil, fmt.Errorf("orm: copy: record %s(%d) not found", m.name, rs.ids[0])
|
||||||
|
}
|
||||||
|
original := records[0]
|
||||||
|
|
||||||
|
// Blacklisted fields (never copied)
|
||||||
|
blacklist := map[string]bool{
|
||||||
|
"id": true, "create_uid": true, "create_date": true,
|
||||||
|
"write_uid": true, "write_date": true, "display_name": true,
|
||||||
|
"password": true, "password_crypt": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also blacklist _inherits FK fields (parent will be copied separately)
|
||||||
|
for _, fkField := range m.inherits {
|
||||||
|
blacklist[fkField] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
newVals := make(Values)
|
||||||
|
|
||||||
|
for _, name := range m.fieldOrder {
|
||||||
|
if blacklist[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f := m.fields[name]
|
||||||
|
if f == nil || !f.IsCopyable() || !f.IsStored() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val, exists := original[name]
|
||||||
|
if !exists || val == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch f.Type {
|
||||||
|
case TypeMany2many:
|
||||||
|
// Re-link existing M2M entries (don't duplicate targets)
|
||||||
|
if ids, ok := val.([]int64); ok && len(ids) > 0 {
|
||||||
|
cmds := make([]interface{}, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
cmds[i] = []interface{}{4, id, 0} // Command LINK
|
||||||
|
}
|
||||||
|
newVals[name] = cmds
|
||||||
|
}
|
||||||
|
case TypeOne2many:
|
||||||
|
// Skip — O2M records are NOT copied by default (copy=false)
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
newVals[name] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append "(copy)" to rec_name field
|
||||||
|
recName := m.recName
|
||||||
|
if recName == "" {
|
||||||
|
recName = "name"
|
||||||
|
}
|
||||||
|
if nameVal, ok := newVals[recName].(string); ok {
|
||||||
|
newVals[recName] = nameVal + " (copy)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply user-provided defaults (override copied values)
|
||||||
|
if defaults != nil {
|
||||||
|
for k, v := range defaults {
|
||||||
|
newVals[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rs.Create(newVals)
|
||||||
|
}
|
||||||
|
|
||||||
// Union returns the union of this recordset with others.
|
// Union returns the union of this recordset with others.
|
||||||
// Mirrors: records | other_records
|
// Mirrors: records | other_records
|
||||||
func (rs *Recordset) Union(others ...*Recordset) *Recordset {
|
func (rs *Recordset) Union(others ...*Recordset) *Recordset {
|
||||||
|
|||||||
@@ -429,8 +429,10 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
if len(params.Args) >= 3 {
|
if len(params.Args) >= 3 {
|
||||||
if vals, ok := params.Args[1].(map[string]interface{}); ok {
|
if vals, ok := params.Args[1].(map[string]interface{}); ok {
|
||||||
if fieldNames, ok := params.Args[2].([]interface{}); ok {
|
if fieldNames, ok := params.Args[2].([]interface{}); ok {
|
||||||
|
var changed []string
|
||||||
for _, fn := range fieldNames {
|
for _, fn := range fieldNames {
|
||||||
fname, _ := fn.(string)
|
fname, _ := fn.(string)
|
||||||
|
changed = append(changed, fname)
|
||||||
if handler, exists := model.OnchangeHandlers[fname]; exists {
|
if handler, exists := model.OnchangeHandlers[fname]; exists {
|
||||||
updates := handler(env, vals)
|
updates := handler(env, vals)
|
||||||
for k, v := range updates {
|
for k, v := range updates {
|
||||||
@@ -438,6 +440,11 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Run computed fields that depend on changed fields
|
||||||
|
computed := orm.RunOnchangeComputes(model, env, vals, changed)
|
||||||
|
for k, v := range computed {
|
||||||
|
defaults[k] = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -660,23 +667,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return nil, &RPCError{Code: -32000, Message: "No record to copy"}
|
return nil, &RPCError{Code: -32000, Message: "No record to copy"}
|
||||||
}
|
}
|
||||||
// Read the original record
|
// Parse optional default overrides from args[1]
|
||||||
records, err := rs.Browse(ids[0]).Read(nil)
|
defaults := parseValuesAt(params.Args, 1)
|
||||||
if err != nil || len(records) == 0 {
|
created, err := rs.Browse(ids[0]).Copy(defaults)
|
||||||
return nil, &RPCError{Code: -32000, Message: "Record not found"}
|
|
||||||
}
|
|
||||||
// Remove id and unique fields, create copy
|
|
||||||
vals := records[0]
|
|
||||||
delete(vals, "id")
|
|
||||||
delete(vals, "create_uid")
|
|
||||||
delete(vals, "write_uid")
|
|
||||||
delete(vals, "create_date")
|
|
||||||
delete(vals, "write_date")
|
|
||||||
// Append "(copy)" to name if it exists
|
|
||||||
if name, ok := vals["name"].(string); ok {
|
|
||||||
vals["name"] = name + " (copy)"
|
|
||||||
}
|
|
||||||
created, err := rs.Create(vals)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user