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:
Marc
2026-04-12 18:41:57 +02:00
parent 2c7c1e6c88
commit 66383adf06
87 changed files with 14696 additions and 654 deletions

View File

@@ -1,6 +1,9 @@
package orm
import "fmt"
import (
"fmt"
"log"
)
// ComputeFunc is a function that computes field values for a recordset.
// Mirrors: @api.depends decorated methods in Odoo.
@@ -253,7 +256,7 @@ func RunOnchangeComputes(m *Model, env *Environment, currentVals Values, changed
computed, err := fn(rs)
if err != nil {
// Non-fatal: skip failed computes during onchange
log.Printf("orm: onchange compute %s.%s failed: %v", m.Name(), fieldName, err)
continue
}
for k, v := range computed {

View File

@@ -2,6 +2,8 @@ package orm
import (
"fmt"
"regexp"
"strconv"
"strings"
)
@@ -152,6 +154,8 @@ func (dc *DomainCompiler) JoinSQL() string {
return " " + strings.Join(parts, " ")
}
// compileNodes compiles domain nodes in Polish (prefix) notation.
// Returns the SQL string and the number of nodes consumed from the domain starting at pos.
func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
if pos >= len(domain) {
return "TRUE", nil
@@ -167,7 +171,8 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
if err != nil {
return "", err
}
right, err := dc.compileNodes(domain, pos+2)
leftSize := nodeSize(domain, pos+1)
right, err := dc.compileNodes(domain, pos+1+leftSize)
if err != nil {
return "", err
}
@@ -178,7 +183,8 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
if err != nil {
return "", err
}
right, err := dc.compileNodes(domain, pos+2)
leftSize := nodeSize(domain, pos+1)
right, err := dc.compileNodes(domain, pos+1+leftSize)
if err != nil {
return "", err
}
@@ -196,8 +202,6 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
return dc.compileCondition(n)
case domainGroup:
// domainGroup wraps a sub-domain as a single node.
// Compile it recursively as a full domain.
subSQL, _, err := dc.compileDomainGroup(Domain(n))
if err != nil {
return "", err
@@ -208,6 +212,28 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, node)
}
// nodeSize returns the number of domain nodes consumed by the subtree at pos.
// Operators (&, |) consume 1 + left subtree + right subtree.
// NOT consumes 1 + inner subtree. Leaf nodes consume 1.
func nodeSize(domain Domain, pos int) int {
if pos >= len(domain) {
return 0
}
switch n := domain[pos].(type) {
case Operator:
_ = n
switch domain[pos].(Operator) {
case OpAnd, OpOr:
leftSize := nodeSize(domain, pos+1)
rightSize := nodeSize(domain, pos+1+leftSize)
return 1 + leftSize + rightSize
case OpNot:
return 1 + nodeSize(domain, pos+1)
}
}
return 1 // Condition or domainGroup = 1 node
}
// compileDomainGroup compiles a sub-domain that was wrapped via domainGroup.
// It reuses the same DomainCompiler (sharing params and joins) so parameter
// indices stay consistent with the outer query.
@@ -227,14 +253,12 @@ func (dc *DomainCompiler) compileCondition(c Condition) (string, error) {
return "", fmt.Errorf("invalid operator: %q", c.Operator)
}
// Handle dot notation (e.g., "partner_id.name")
// Handle dot notation (e.g., "partner_id.name", "partner_id.country_id.code")
// by generating LEFT JOINs through the M2O relational chain.
parts := strings.Split(c.Field, ".")
column := parts[0]
// TODO: Handle JOINs for dot notation paths
// For now, only support direct fields
if len(parts) > 1 {
// Placeholder for JOIN resolution
return dc.compileJoinedCondition(parts, c.Operator, c.Value)
}
@@ -285,7 +309,7 @@ func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator st
dc.joins = append(dc.joins, joinClause{
table: comodel.Table(),
alias: alias,
on: fmt.Sprintf("%s.%q = %q.\"id\"", currentAlias, f.Column(), alias),
on: fmt.Sprintf("%q.%q = %q.\"id\"", currentAlias, f.Column(), alias),
})
currentModel = comodel
@@ -293,8 +317,12 @@ func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator st
}
// The last segment is the actual field to filter on
leafField := fieldPath[len(fieldPath)-1]
qualifiedColumn := fmt.Sprintf("%s.%q", currentAlias, leafField)
leafFieldName := fieldPath[len(fieldPath)-1]
leafCol := leafFieldName
if lf := currentModel.GetField(leafFieldName); lf != nil {
leafCol = lf.Column()
}
qualifiedColumn := fmt.Sprintf("%q.%q", currentAlias, leafCol)
return dc.compileQualifiedCondition(qualifiedColumn, operator, value)
}
@@ -528,13 +556,8 @@ func (dc *DomainCompiler) compileAnyOp(column string, value Value, negate bool)
// Rebase parameter indices: shift them by the current param count
baseIdx := len(dc.params)
dc.params = append(dc.params, subParams...)
rebased := subWhere
// Replace $N with $(N+baseIdx) in the sub-where clause
for i := len(subParams); i >= 1; i-- {
old := fmt.Sprintf("$%d", i)
new := fmt.Sprintf("$%d", i+baseIdx)
rebased = strings.ReplaceAll(rebased, old, new)
}
// Replace $N with $(N+baseIdx) using regex to avoid $1 matching $10
rebased := rebaseParams(subWhere, baseIdx)
// Determine the join condition based on field type
var joinCond string
@@ -676,3 +699,14 @@ func wrapLikeValue(value Value) Value {
}
return "%" + s + "%"
}
// rebaseParams shifts $N placeholders in a SQL string by baseIdx.
// Uses regex to avoid $1 matching inside $10.
var paramRegex = regexp.MustCompile(`\$(\d+)`)
func rebaseParams(sql string, baseIdx int) string {
return paramRegex.ReplaceAllStringFunc(sql, func(match string) string {
n, _ := strconv.Atoi(match[1:])
return fmt.Sprintf("$%d", n+baseIdx)
})
}

View File

@@ -45,8 +45,9 @@ type Model struct {
checkCompany bool // Enforce multi-company record rules
// Hooks
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB)
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
BeforeWrite func(env *Environment, ids []int64, vals Values) error // Called before UPDATE — for state guards
DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB)
Constraints []ConstraintFunc // Validation constraints
Methods map[string]MethodFunc // Named business methods
@@ -453,3 +454,32 @@ func (m *Model) Many2manyTableSQL() []string {
}
return stmts
}
// StateGuard returns a BeforeWrite function that prevents modifications on records
// in certain states, except for explicitly allowed fields.
// Eliminates the duplicated guard pattern across sale.order, purchase.order,
// account.move, and stock.picking.
func StateGuard(table, stateCondition string, allowedFields []string, errMsg string) func(env *Environment, ids []int64, vals Values) error {
allowed := make(map[string]bool, len(allowedFields))
for _, f := range allowedFields {
allowed[f] = true
}
return func(env *Environment, ids []int64, vals Values) error {
if _, changingState := vals["state"]; changingState {
return nil
}
var count int
err := env.Tx().QueryRow(env.Ctx(),
fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id = ANY($1) AND %s`, table, stateCondition), ids,
).Scan(&count)
if err != nil || count == 0 {
return nil
}
for field := range vals {
if !allowed[field] {
return fmt.Errorf("%s: %s", table, errMsg)
}
}
return nil
}
}

View File

@@ -153,7 +153,7 @@ func (rs *Recordset) ReadGroup(domain Domain, groupby []string, aggregates []str
// Build ORDER BY
orderSQL := ""
if opt.Order != "" {
orderSQL = opt.Order
orderSQL = sanitizeOrderBy(opt.Order, m)
} else if len(gbCols) > 0 {
// Default: order by groupby columns
var orderParts []string

View File

@@ -2,6 +2,7 @@ package orm
import (
"fmt"
"log"
"strings"
)
@@ -265,18 +266,28 @@ func preprocessRelatedWrites(env *Environment, m *Model, ids []int64, vals Value
value := vals[fieldName]
delete(vals, fieldName) // Remove from vals — no local column
// Read FK IDs for all records
// Read FK IDs for all records in a single query
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)
rows, err := env.tx.Query(env.ctx,
fmt.Sprintf(`SELECT %q FROM %q WHERE id = ANY($1) AND %q IS NOT NULL`,
fkDef.Column(), m.Table(), fkDef.Column()),
ids,
)
if err != nil {
delete(vals, fieldName)
continue
}
for rows.Next() {
var fkID int64
if err := rows.Scan(&fkID); err != nil {
log.Printf("orm: preprocessRelatedWrites scan error on %s.%s: %v", m.Name(), fieldName, err)
continue
}
if fkID > 0 {
fkIDs = append(fkIDs, fkID)
}
}
rows.Close()
if len(fkIDs) == 0 {
continue
@@ -315,6 +326,13 @@ func (rs *Recordset) Write(vals Values) error {
m := rs.model
// BeforeWrite hook — state guards, locked record checks etc.
if m.BeforeWrite != nil {
if err := m.BeforeWrite(rs.env, rs.ids, vals); err != nil {
return err
}
}
var setClauses []string
var args []interface{}
idx := 1
@@ -787,7 +805,7 @@ func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, erro
// Build query
order := m.order
if opt.Order != "" {
order = opt.Order
order = sanitizeOrderBy(opt.Order, m)
}
joinSQL := compiler.JoinSQL()
@@ -1103,6 +1121,72 @@ func toRecordID(v interface{}) (int64, bool) {
return 0, false
}
// sanitizeOrderBy validates an ORDER BY clause to prevent SQL injection.
// Only allows: field names (alphanumeric + underscore), ASC/DESC, NULLS FIRST/LAST, commas.
// Returns sanitized string or fallback to "id" if invalid.
func sanitizeOrderBy(order string, m *Model) string {
if order == "" {
return "id"
}
parts := strings.Split(order, ",")
var safe []string
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
tokens := strings.Fields(part)
if len(tokens) == 0 {
continue
}
// First token must be a valid field name or "table"."field"
col := tokens[0]
// Strip quotes for validation
cleanCol := strings.ReplaceAll(strings.ReplaceAll(col, "\"", ""), "'", "")
// Allow dot notation (table.field) but validate each part
colParts := strings.Split(cleanCol, ".")
valid := true
for _, cp := range colParts {
if !isValidIdentifier(cp) {
valid = false
break
}
}
if !valid {
continue // Skip this part entirely
}
// Remaining tokens must be ASC, DESC, NULLS, FIRST, LAST
safePart := col
for _, tok := range tokens[1:] {
upper := strings.ToUpper(tok)
switch upper {
case "ASC", "DESC", "NULLS", "FIRST", "LAST":
safePart += " " + upper
default:
// Invalid token — skip
}
}
safe = append(safe, safePart)
}
if len(safe) == 0 {
return "id"
}
return strings.Join(safe, ", ")
}
// isValidIdentifier checks if a string is a valid SQL identifier (letters, digits, underscore).
func isValidIdentifier(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
return false
}
}
return true
}
// qualifyOrderBy prefixes unqualified column names with the table name.
// "name, id desc" → "\"my_table\".name, \"my_table\".id desc"
func qualifyOrderBy(table, order string) string {

View File

@@ -70,8 +70,9 @@ func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain {
ORDER BY r.id`,
m.Name(), env.UID())
if err != nil {
log.Printf("orm: ir.rule query failed for %s: %v — denying access", m.Name(), err)
sp.Rollback(env.ctx)
return domain
return append(domain, Leaf("id", "=", -1)) // Deny all — no records match id=-1
}
type ruleRow struct {
@@ -207,7 +208,8 @@ func CheckRecordRuleAccess(env *Environment, m *Model, ids []int64, perm string)
var count int64
err := env.tx.QueryRow(env.ctx, query, args...).Scan(&count)
if err != nil {
return nil // Fail open on error
log.Printf("orm: record rule check failed for %s: %v", m.Name(), err)
return fmt.Errorf("orm: access denied on %s (record rule check failed)", m.Name())
}
if count < int64(len(ids)) {