feat: Portal, Email Inbound, Discuss + module improvements
- 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>
This commit is contained in:
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
@@ -451,6 +452,110 @@ func (s *Server) handleReadGroup(rs *orm.Recordset, params CallKWParams) (interf
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -470,6 +575,203 @@ func (s *Server) handleReadGroup(rs *orm.Recordset, params CallKWParams) (interf
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user