- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
813 lines
21 KiB
Go
813 lines
21 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"odoo-go/pkg/orm"
|
|
)
|
|
|
|
const (
|
|
defaultWebSearchLimit = 80
|
|
defaultO2MFetchLimit = 200
|
|
defaultNameSearchLimit = 8
|
|
)
|
|
|
|
// handleWebSearchRead implements the web_search_read method.
|
|
// Mirrors: odoo/addons/web/models/models.py web_search_read()
|
|
// Returns {length: N, records: [...]} instead of just records.
|
|
func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) {
|
|
rs := env.Model(model)
|
|
|
|
// Parse domain from first arg (regular search_read) or kwargs (web_search_read)
|
|
domain := parseDomain(params.Args)
|
|
if domain == nil {
|
|
if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 {
|
|
domain = parseDomain([]interface{}{domainRaw})
|
|
}
|
|
}
|
|
|
|
// Parse specification from kwargs
|
|
spec, _ := params.KW["specification"].(map[string]interface{})
|
|
fields := specToFields(spec)
|
|
|
|
// Always include id
|
|
hasID := false
|
|
for _, f := range fields {
|
|
if f == "id" {
|
|
hasID = true
|
|
break
|
|
}
|
|
}
|
|
if !hasID {
|
|
fields = append([]string{"id"}, fields...)
|
|
}
|
|
|
|
// Parse offset, limit, order
|
|
offset := 0
|
|
limit := defaultWebSearchLimit
|
|
order := ""
|
|
if v, ok := params.KW["offset"].(float64); ok {
|
|
offset = int(v)
|
|
}
|
|
if v, ok := params.KW["limit"].(float64); ok {
|
|
limit = int(v)
|
|
}
|
|
if v, ok := params.KW["order"].(string); ok {
|
|
order = v
|
|
}
|
|
|
|
// Get total count, respecting count_limit for optimization.
|
|
// Mirrors: odoo/addons/web/models/models.py web_search_read() count_limit parameter
|
|
countLimit := int64(0)
|
|
if v, ok := params.KW["count_limit"].(float64); ok {
|
|
countLimit = int64(v)
|
|
}
|
|
count, err := rs.SearchCount(domain)
|
|
if err != nil {
|
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
|
}
|
|
if countLimit > 0 && count > countLimit {
|
|
count = countLimit
|
|
}
|
|
|
|
// Search with offset/limit
|
|
found, err := rs.Search(domain, orm.SearchOpts{
|
|
Offset: offset,
|
|
Limit: limit,
|
|
Order: order,
|
|
})
|
|
if err != nil {
|
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
|
}
|
|
|
|
var records []orm.Values
|
|
if !found.IsEmpty() {
|
|
records, err = found.Read(fields)
|
|
if err != nil {
|
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
|
}
|
|
}
|
|
|
|
formatRecordsForWeb(env, model, records, spec)
|
|
|
|
if records == nil {
|
|
records = []orm.Values{}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"length": count,
|
|
"records": records,
|
|
}, nil
|
|
}
|
|
|
|
// handleWebRead implements the web_read method.
|
|
// Mirrors: odoo/addons/web/models/models.py web_read()
|
|
func handleWebRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) {
|
|
ids := parseIDs(params.Args)
|
|
if len(ids) == 0 {
|
|
return []orm.Values{}, nil
|
|
}
|
|
|
|
spec, _ := params.KW["specification"].(map[string]interface{})
|
|
fields := specToFields(spec)
|
|
|
|
// Always include id
|
|
hasID := false
|
|
for _, f := range fields {
|
|
if f == "id" {
|
|
hasID = true
|
|
break
|
|
}
|
|
}
|
|
if !hasID {
|
|
fields = append([]string{"id"}, fields...)
|
|
}
|
|
|
|
rs := env.Model(model)
|
|
records, err := rs.Browse(ids...).Read(fields)
|
|
if err != nil {
|
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
|
}
|
|
|
|
formatRecordsForWeb(env, model, records, spec)
|
|
|
|
if records == nil {
|
|
records = []orm.Values{}
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
// formatRecordsForWeb applies all web-client formatting in one call.
|
|
func formatRecordsForWeb(env *orm.Environment, model string, records []orm.Values, spec map[string]interface{}) {
|
|
formatM2OFields(env, model, records, spec)
|
|
formatO2MFields(env, model, records, spec)
|
|
formatDateFields(model, records)
|
|
normalizeNullFields(model, records)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Collect all parent IDs from records
|
|
var parentIDs []int64
|
|
for _, rec := range records {
|
|
parentID, ok := rec["id"].(int64)
|
|
if !ok {
|
|
if pid, ok := rec["id"].(int32); ok {
|
|
parentID = int64(pid)
|
|
}
|
|
}
|
|
if parentID > 0 {
|
|
parentIDs = append(parentIDs, parentID)
|
|
}
|
|
}
|
|
|
|
// Initialize all records with empty slices
|
|
for _, rec := range records {
|
|
rec[fieldName] = []interface{}{}
|
|
}
|
|
|
|
if len(parentIDs) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Single batched search: WHERE inverse_field IN (all parent IDs)
|
|
childRS := env.Model(comodel)
|
|
domain := orm.And(orm.Leaf(inverseField, "in", parentIDs))
|
|
found, err := childRS.Search(domain, orm.SearchOpts{Limit: defaultO2MFetchLimit})
|
|
if err != nil || found.IsEmpty() {
|
|
continue
|
|
}
|
|
|
|
// Single batched read
|
|
childRecords, err := found.Read(childFields)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Format child records (M2O fields, dates, nulls)
|
|
formatM2OFields(env, comodel, childRecords, subFieldsSpec)
|
|
formatDateFields(comodel, childRecords)
|
|
normalizeNullFields(comodel, childRecords)
|
|
|
|
// Group child records by their inverse field (parent ID)
|
|
grouped := make(map[int64][]interface{})
|
|
for _, cr := range childRecords {
|
|
if pid, ok := orm.ToRecordID(cr[inverseField]); ok && pid > 0 {
|
|
grouped[pid] = append(grouped[pid], cr)
|
|
}
|
|
}
|
|
|
|
// Assign grouped children to each parent record
|
|
for _, rec := range records {
|
|
parentID, ok := rec["id"].(int64)
|
|
if !ok {
|
|
if pid, ok := rec["id"].(int32); ok {
|
|
parentID = int64(pid)
|
|
}
|
|
}
|
|
if children, ok := grouped[parentID]; ok {
|
|
rec[fieldName] = children
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// specToFields extracts field names from a specification dict.
|
|
// {"name": {}, "partner_id": {"fields": {"display_name": {}}}} → ["name", "partner_id"]
|
|
func specToFields(spec map[string]interface{}) []string {
|
|
if len(spec) == 0 {
|
|
return nil
|
|
}
|
|
fields := make([]string, 0, len(spec))
|
|
for name := range spec {
|
|
fields = append(fields, name)
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// formatM2OFields converts Many2one field values from raw int to {id, display_name}.
|
|
func formatM2OFields(env *orm.Environment, modelName string, records []orm.Values, spec map[string]interface{}) {
|
|
m := orm.Registry.Get(modelName)
|
|
if m == nil || spec == nil {
|
|
return
|
|
}
|
|
|
|
// Batch M2O resolution: collect all FK IDs per (fieldName, comodel), one NameGet per comodel
|
|
type m2oKey struct{ field, comodel string }
|
|
idSets := make(map[m2oKey]map[int64]bool)
|
|
|
|
for fieldName, fieldSpec := range spec {
|
|
f := m.GetField(fieldName)
|
|
if f == nil || f.Type != orm.TypeMany2one || f.Comodel == "" {
|
|
continue
|
|
}
|
|
if _, ok := fieldSpec.(map[string]interface{}); !ok {
|
|
continue
|
|
}
|
|
key := m2oKey{fieldName, f.Comodel}
|
|
idSets[key] = make(map[int64]bool)
|
|
for _, rec := range records {
|
|
if fkID, ok := orm.ToRecordID(rec[fieldName]); ok && fkID > 0 {
|
|
idSets[key][fkID] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// One NameGet per comodel (batched)
|
|
nameCache := make(map[m2oKey]map[int64]string)
|
|
for key, ids := range idSets {
|
|
if len(ids) == 0 {
|
|
continue
|
|
}
|
|
var idSlice []int64
|
|
for id := range ids {
|
|
idSlice = append(idSlice, id)
|
|
}
|
|
coRS := env.Model(key.comodel).Browse(idSlice...)
|
|
names, err := coRS.NameGet()
|
|
if err == nil {
|
|
nameCache[key] = names
|
|
}
|
|
}
|
|
|
|
// Apply resolved names to records
|
|
for _, rec := range records {
|
|
for fieldName, fieldSpec := range spec {
|
|
f := m.GetField(fieldName)
|
|
if f == nil || f.Type != orm.TypeMany2one || f.Comodel == "" {
|
|
continue
|
|
}
|
|
if _, ok := fieldSpec.(map[string]interface{}); !ok {
|
|
continue
|
|
}
|
|
fkID, ok := orm.ToRecordID(rec[fieldName])
|
|
if !ok || fkID == 0 {
|
|
rec[fieldName] = false
|
|
continue
|
|
}
|
|
key := m2oKey{fieldName, f.Comodel}
|
|
if names, ok := nameCache[key]; ok {
|
|
rec[fieldName] = []interface{}{fkID, names[fkID]}
|
|
} else {
|
|
rec[fieldName] = []interface{}{fkID, fmt.Sprintf("%s,%d", f.Comodel, fkID)}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// normalizeNullFields converts SQL NULL (Go nil) to Odoo's false convention.
|
|
// Without this, the webclient renders "null" as literal text.
|
|
func normalizeNullFields(model string, records []orm.Values) {
|
|
for _, rec := range records {
|
|
for fieldName, val := range rec {
|
|
if val == nil {
|
|
rec[fieldName] = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleReadGroup dispatches web_read_group and read_group RPC calls.
|
|
// Mirrors: odoo/addons/web/models/models.py web_read_group() + formatted_read_group()
|
|
func (s *Server) handleReadGroup(rs *orm.Recordset, params CallKWParams) (interface{}, *RPCError) {
|
|
// Parse domain
|
|
domain := parseDomain(params.Args)
|
|
if domain == nil {
|
|
if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 {
|
|
domain = parseDomain([]interface{}{domainRaw})
|
|
}
|
|
}
|
|
|
|
// Parse groupby
|
|
var groupby []string
|
|
if gb, ok := params.KW["groupby"].([]interface{}); ok {
|
|
for _, g := range gb {
|
|
if s, ok := g.(string); ok {
|
|
groupby = append(groupby, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse aggregates (web client sends "fields" or "aggregates")
|
|
var aggregates []string
|
|
if aggs, ok := params.KW["aggregates"].([]interface{}); ok {
|
|
for _, a := range aggs {
|
|
if s, ok := a.(string); ok {
|
|
aggregates = append(aggregates, s)
|
|
}
|
|
}
|
|
}
|
|
// Always include __count
|
|
hasCount := false
|
|
for _, a := range aggregates {
|
|
if a == "__count" {
|
|
hasCount = true
|
|
break
|
|
}
|
|
}
|
|
if !hasCount {
|
|
aggregates = append(aggregates, "__count")
|
|
}
|
|
|
|
// Parse opts
|
|
opts := orm.ReadGroupOpts{}
|
|
if v, ok := params.KW["limit"].(float64); ok {
|
|
opts.Limit = int(v)
|
|
}
|
|
if v, ok := params.KW["offset"].(float64); ok {
|
|
opts.Offset = int(v)
|
|
}
|
|
if v, ok := params.KW["order"].(string); ok {
|
|
opts.Order = v
|
|
}
|
|
|
|
if len(groupby) == 0 {
|
|
// No groupby: return total count only (like Python Odoo)
|
|
count, _ := rs.SearchCount(domain)
|
|
group := map[string]interface{}{
|
|
"__count": count,
|
|
}
|
|
for _, agg := range aggregates {
|
|
if agg != "__count" {
|
|
group[agg] = 0
|
|
}
|
|
}
|
|
if params.Method == "web_read_group" {
|
|
return map[string]interface{}{
|
|
"groups": []interface{}{group},
|
|
"length": 1,
|
|
}, nil
|
|
}
|
|
return []interface{}{group}, nil
|
|
}
|
|
|
|
// Execute ReadGroup
|
|
results, err := rs.ReadGroup(domain, groupby, aggregates, opts)
|
|
if err != nil {
|
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
|
}
|
|
|
|
// Format results for the web client
|
|
// Mirrors: odoo/addons/web/models/models.py _web_read_group_format()
|
|
groups := make([]interface{}, 0, len(results))
|
|
for _, r := range results {
|
|
group := map[string]interface{}{
|
|
"__extra_domain": r.Domain,
|
|
}
|
|
// Groupby values
|
|
for spec, val := range r.GroupValues {
|
|
group[spec] = val
|
|
}
|
|
// Aggregate values
|
|
for spec, val := range r.AggValues {
|
|
group[spec] = val
|
|
}
|
|
// Ensure __count
|
|
if _, ok := group["__count"]; !ok {
|
|
group["__count"] = r.Count
|
|
}
|
|
groups = append(groups, group)
|
|
}
|
|
|
|
if groups == nil {
|
|
groups = []interface{}{}
|
|
}
|
|
|
|
if params.Method == "web_read_group" {
|
|
// --- __fold support ---
|
|
// If the first groupby is a Many2one whose comodel has a "fold" field,
|
|
// add __fold to each group. Mirrors: odoo/addons/web/models/models.py
|
|
if len(groupby) > 0 {
|
|
fieldName := strings.SplitN(groupby[0], ":", 2)[0]
|
|
m := rs.ModelDef()
|
|
if m != nil {
|
|
f := m.GetField(fieldName)
|
|
if f != nil && f.Type == orm.TypeMany2one && f.Comodel != "" {
|
|
comodel := orm.Registry.Get(f.Comodel)
|
|
if comodel != nil && comodel.GetField("fold") != nil {
|
|
addFoldInfo(rs.Env(), f.Comodel, groupby[0], groups)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- __records for auto_unfold ---
|
|
autoUnfold := false
|
|
if v, ok := params.KW["auto_unfold"].(bool); ok {
|
|
autoUnfold = v
|
|
}
|
|
if autoUnfold {
|
|
unfoldReadSpec, _ := params.KW["unfold_read_specification"].(map[string]interface{})
|
|
unfoldLimit := defaultWebSearchLimit
|
|
if v, ok := params.KW["unfold_read_default_limit"].(float64); ok {
|
|
unfoldLimit = int(v)
|
|
}
|
|
|
|
// Parse original domain for combining with group domain
|
|
origDomain := parseDomain(params.Args)
|
|
if origDomain == nil {
|
|
if dr, ok := params.KW["domain"].([]interface{}); ok && len(dr) > 0 {
|
|
origDomain = parseDomain([]interface{}{dr})
|
|
}
|
|
}
|
|
|
|
modelName := rs.ModelDef().Name()
|
|
maxUnfolded := 10
|
|
unfolded := 0
|
|
for _, g := range groups {
|
|
if unfolded >= maxUnfolded {
|
|
break
|
|
}
|
|
gm := g.(map[string]interface{})
|
|
fold, _ := gm["__fold"].(bool)
|
|
count, _ := gm["__count"].(int64)
|
|
// Skip folded, empty, and groups with false/nil M2O value
|
|
// Mirrors: odoo/addons/web/models/models.py _open_groups() fold checks
|
|
if fold || count == 0 {
|
|
continue
|
|
}
|
|
// For M2O groupby: skip groups where the value is false (unset M2O)
|
|
if len(groupby) > 0 {
|
|
gbVal := gm[groupby[0]]
|
|
if gbVal == nil || gbVal == false {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Build combined domain: original + group extra domain
|
|
var combinedDomain orm.Domain
|
|
if origDomain != nil {
|
|
combinedDomain = append(combinedDomain, origDomain...)
|
|
}
|
|
if extraDom, ok := gm["__extra_domain"].([]interface{}); ok && len(extraDom) > 0 {
|
|
groupDomain := parseDomain([]interface{}{extraDom})
|
|
combinedDomain = append(combinedDomain, groupDomain...)
|
|
}
|
|
|
|
found, err := rs.Env().Model(modelName).Search(combinedDomain, orm.SearchOpts{Limit: unfoldLimit})
|
|
if err != nil || found.IsEmpty() {
|
|
gm["__records"] = []orm.Values{}
|
|
unfolded++
|
|
continue
|
|
}
|
|
|
|
fields := specToFields(unfoldReadSpec)
|
|
if len(fields) == 0 {
|
|
fields = []string{"id"}
|
|
}
|
|
hasID := false
|
|
for _, f := range fields {
|
|
if f == "id" {
|
|
hasID = true
|
|
break
|
|
}
|
|
}
|
|
if !hasID {
|
|
fields = append([]string{"id"}, fields...)
|
|
}
|
|
|
|
records, err := found.Read(fields)
|
|
if err != nil {
|
|
gm["__records"] = []orm.Values{}
|
|
unfolded++
|
|
continue
|
|
}
|
|
formatRecordsForWeb(rs.Env(), modelName, records, unfoldReadSpec)
|
|
gm["__records"] = records
|
|
unfolded++
|
|
}
|
|
}
|
|
|
|
// web_read_group: also get total group count (without limit/offset)
|
|
totalLen := len(results)
|
|
if opts.Limit > 0 || opts.Offset > 0 {
|
|
allResults, err := rs.ReadGroup(domain, groupby, []string{"__count"})
|
|
if err == nil {
|
|
totalLen = len(allResults)
|
|
}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"groups": groups,
|
|
"length": totalLen,
|
|
}, nil
|
|
}
|
|
|
|
// Legacy read_group format
|
|
return groups, nil
|
|
}
|
|
|
|
// handleReadProgressBar returns per-group counts for a progress bar field.
|
|
// Mirrors: odoo/orm/models.py BaseModel._read_progress_bar()
|
|
//
|
|
// Called by the kanban view to render colored progress bars per column.
|
|
// Input (via KW):
|
|
//
|
|
// domain: search filter
|
|
// group_by: field to group columns by (e.g. "stage_id")
|
|
// progress_bar: {field: "kanban_state", colors: {"done": "success", ...}}
|
|
//
|
|
// Output:
|
|
//
|
|
// {groupByValue: {pbValue: count, ...}, ...}
|
|
//
|
|
// Where groupByValue is the raw DB value (integer ID for M2O, string for
|
|
// selection, "True"/"False" for boolean).
|
|
func (s *Server) handleReadProgressBar(rs *orm.Recordset, params CallKWParams) (interface{}, *RPCError) {
|
|
// Parse domain from KW
|
|
domain := parseDomain(params.Args)
|
|
if domain == nil {
|
|
if dr, ok := params.KW["domain"].([]interface{}); ok && len(dr) > 0 {
|
|
domain = parseDomain([]interface{}{dr})
|
|
}
|
|
}
|
|
|
|
// Parse group_by (single string)
|
|
groupBy := ""
|
|
if v, ok := params.KW["group_by"].(string); ok {
|
|
groupBy = v
|
|
}
|
|
|
|
// Parse progress_bar map
|
|
progressBar, _ := params.KW["progress_bar"].(map[string]interface{})
|
|
pbField, _ := progressBar["field"].(string)
|
|
|
|
if groupBy == "" || pbField == "" {
|
|
return map[string]interface{}{}, nil
|
|
}
|
|
|
|
// Use ReadGroup with two groupby levels: [groupBy, pbField]
|
|
results, err := rs.ReadGroup(domain, []string{groupBy, pbField}, []string{"__count"})
|
|
if err != nil {
|
|
return map[string]interface{}{}, nil
|
|
}
|
|
|
|
// Determine field types for key formatting
|
|
m := rs.ModelDef()
|
|
gbField := m.GetField(groupBy)
|
|
pbFieldDef := m.GetField(pbField)
|
|
|
|
// Build nested map: {groupByValue: {pbValue: count}}
|
|
data := make(map[string]interface{})
|
|
|
|
// Collect all known progress bar values (from colors) so we initialize zeros
|
|
pbColors, _ := progressBar["colors"].(map[string]interface{})
|
|
|
|
for _, r := range results {
|
|
// Format the group-by key
|
|
gbVal := r.GroupValues[groupBy]
|
|
gbKey := formatProgressBarKey(gbVal, gbField)
|
|
|
|
// Format the progress bar value
|
|
pbVal := r.GroupValues[pbField]
|
|
pbKey := formatProgressBarValue(pbVal, pbFieldDef)
|
|
|
|
// Initialize group entry with zero counts if first time
|
|
if _, exists := data[gbKey]; !exists {
|
|
entry := make(map[string]interface{})
|
|
for colorKey := range pbColors {
|
|
entry[colorKey] = 0
|
|
}
|
|
data[gbKey] = entry
|
|
}
|
|
|
|
// Add count
|
|
entry := data[gbKey].(map[string]interface{})
|
|
existing, _ := entry[pbKey].(int)
|
|
entry[pbKey] = existing + int(r.Count)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// formatProgressBarKey formats a group-by value as the string key expected
|
|
// by the frontend progress bar.
|
|
// - M2O: integer ID (as string)
|
|
// - Boolean: "True" / "False"
|
|
// - nil/false: "False"
|
|
// - Other: value as string
|
|
func formatProgressBarKey(val interface{}, f *orm.Field) string {
|
|
if val == nil || val == false {
|
|
return "False"
|
|
}
|
|
|
|
// M2O: ReadGroup resolves to [id, name] pair — use the id
|
|
if f != nil && f.Type == orm.TypeMany2one {
|
|
switch v := val.(type) {
|
|
case []interface{}:
|
|
if len(v) > 0 {
|
|
return fmt.Sprintf("%v", v[0])
|
|
}
|
|
return "False"
|
|
case int64:
|
|
return fmt.Sprintf("%d", v)
|
|
case float64:
|
|
return fmt.Sprintf("%d", int64(v))
|
|
case int:
|
|
return fmt.Sprintf("%d", v)
|
|
}
|
|
}
|
|
|
|
// Boolean
|
|
if f != nil && f.Type == orm.TypeBoolean {
|
|
switch v := val.(type) {
|
|
case bool:
|
|
if v {
|
|
return "True"
|
|
}
|
|
return "False"
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("%v", val)
|
|
}
|
|
|
|
// formatProgressBarValue formats a progress bar field value as a string key.
|
|
// Selection fields use the raw value (e.g. "done", "blocked").
|
|
// Boolean fields use "True"/"False".
|
|
func formatProgressBarValue(val interface{}, f *orm.Field) string {
|
|
if val == nil || val == false {
|
|
return "False"
|
|
}
|
|
if f != nil && f.Type == orm.TypeBoolean {
|
|
switch v := val.(type) {
|
|
case bool:
|
|
if v {
|
|
return "True"
|
|
}
|
|
return "False"
|
|
}
|
|
}
|
|
return fmt.Sprintf("%v", val)
|
|
}
|
|
|
|
// addFoldInfo reads the "fold" boolean from the comodel records referenced
|
|
// by each group and sets __fold on the group maps accordingly.
|
|
func addFoldInfo(env *orm.Environment, comodel string, groupbySpec string, groups []interface{}) {
|
|
// Collect IDs from group values (M2O pairs like [id, name])
|
|
var ids []int64
|
|
for _, g := range groups {
|
|
gm := g.(map[string]interface{})
|
|
val := gm[groupbySpec]
|
|
if pair, ok := val.([]interface{}); ok && len(pair) >= 1 {
|
|
if id, ok := orm.ToRecordID(pair[0]); ok && id > 0 {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
// All groups have false/empty value — fold them by default
|
|
for _, g := range groups {
|
|
gm := g.(map[string]interface{})
|
|
gm["__fold"] = false
|
|
}
|
|
return
|
|
}
|
|
|
|
// Read fold values from comodel
|
|
rs := env.Model(comodel).Browse(ids...)
|
|
records, err := rs.Read([]string{"id", "fold"})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Build fold map
|
|
foldMap := make(map[int64]bool)
|
|
for _, rec := range records {
|
|
id, _ := orm.ToRecordID(rec["id"])
|
|
fold, _ := rec["fold"].(bool)
|
|
foldMap[id] = fold
|
|
}
|
|
|
|
// Apply to groups
|
|
for _, g := range groups {
|
|
gm := g.(map[string]interface{})
|
|
val := gm[groupbySpec]
|
|
if pair, ok := val.([]interface{}); ok && len(pair) >= 1 {
|
|
if id, ok := orm.ToRecordID(pair[0]); ok {
|
|
gm["__fold"] = foldMap[id]
|
|
}
|
|
} else {
|
|
// false/empty group value
|
|
gm["__fold"] = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// formatDateFields converts date/datetime values to Odoo's expected string format.
|
|
func formatDateFields(model string, records []orm.Values) {
|
|
m := orm.Registry.Get(model)
|
|
if m == nil {
|
|
return
|
|
}
|
|
for _, rec := range records {
|
|
for fieldName, val := range rec {
|
|
f := m.GetField(fieldName)
|
|
if f == nil {
|
|
continue
|
|
}
|
|
if f.Type == orm.TypeDate || f.Type == orm.TypeDatetime {
|
|
switch v := val.(type) {
|
|
case time.Time:
|
|
if f.Type == orm.TypeDate {
|
|
rec[fieldName] = v.Format("2006-01-02")
|
|
} else {
|
|
rec[fieldName] = v.Format("2006-01-02 15:04:05")
|
|
}
|
|
case string:
|
|
// Already a string, might need reformatting
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
|
if f.Type == orm.TypeDate {
|
|
rec[fieldName] = t.Format("2006-01-02")
|
|
} else {
|
|
rec[fieldName] = t.Format("2006-01-02 15:04:05")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Also convert boolean fields: Go nil → Odoo false
|
|
if f.Type == orm.TypeBoolean && val == nil {
|
|
rec[fieldName] = false
|
|
}
|
|
}
|
|
}
|
|
}
|