Bring odoo-go to ~70%: read_group, record rules, admin, sessions

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>
This commit is contained in:
Marc
2026-04-02 19:26:08 +02:00
parent 06e49c878a
commit b57176de2f
29 changed files with 3243 additions and 111 deletions

View File

@@ -105,6 +105,7 @@ func Not(node DomainNode) Domain {
// Mirrors: odoo/orm/domains.py Domain._to_sql()
type DomainCompiler struct {
model *Model
env *Environment // For operators that need DB access (child_of, parent_of, any, not any)
params []interface{}
joins []joinClause
aliasCounter int
@@ -193,11 +194,35 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
case Condition:
return dc.compileCondition(n)
case domainGroup:
// domainGroup wraps a sub-domain as a single node.
// Compile it recursively as a full domain.
subSQL, subParams, err := dc.compileDomainGroup(Domain(n))
if err != nil {
return "", err
}
_ = subParams // params already appended inside compileDomainGroup
return subSQL, nil
}
return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, 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.
func (dc *DomainCompiler) compileDomainGroup(sub Domain) (string, []interface{}, error) {
if len(sub) == 0 {
return "TRUE", nil, nil
}
sql, err := dc.compileNodes(sub, 0)
if err != nil {
return "", nil, err
}
return sql, nil, nil
}
func (dc *DomainCompiler) compileCondition(c Condition) (string, error) {
if !validOperators[c.Operator] {
return "", fmt.Errorf("invalid operator: %q", c.Operator)
@@ -285,6 +310,18 @@ func (dc *DomainCompiler) compileSimpleCondition(column, operator string, value
dc.params = append(dc.params, value)
return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil
case "child_of":
return dc.compileHierarchyOp(column, value, true)
case "parent_of":
return dc.compileHierarchyOp(column, value, false)
case "any":
return dc.compileAnyOp(column, value, false)
case "not any":
return dc.compileAnyOp(column, value, true)
default:
return "", fmt.Errorf("unhandled operator: %q", operator)
}
@@ -396,6 +433,272 @@ func (dc *DomainCompiler) compileQualifiedCondition(qualifiedColumn, operator st
}
}
// compileHierarchyOp implements child_of / parent_of by querying the DB for hierarchy IDs.
// Mirrors: odoo/orm/domains.py _expression._get_hierarchy_ids
//
// - child_of: finds all descendants via parent_id traversal, then "id" IN (...)
// - parent_of: finds all ancestors via parent_id traversal, then "id" IN (...)
//
// Requires dc.env to be set for DB access.
func (dc *DomainCompiler) compileHierarchyOp(column string, value Value, isChildOf bool) (string, error) {
if dc.env == nil {
return "", fmt.Errorf("child_of/parent_of requires Environment on DomainCompiler")
}
// Normalize the root ID(s)
rootIDs := toInt64Slice(value)
if len(rootIDs) == 0 {
return "FALSE", nil
}
table := dc.model.Table()
var allIDs map[int64]bool
if isChildOf {
// child_of: find all descendants (including roots) via parent_id
allIDs = make(map[int64]bool)
queue := make([]int64, len(rootIDs))
copy(queue, rootIDs)
for _, id := range rootIDs {
allIDs[id] = true
}
for len(queue) > 0 {
// Build placeholders for current batch
placeholders := make([]string, len(queue))
args := make([]interface{}, len(queue))
for i, id := range queue {
args[i] = id
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf(
`SELECT "id" FROM %q WHERE "parent_id" IN (%s)`,
table, strings.Join(placeholders, ", "),
)
rows, err := dc.env.tx.Query(dc.env.ctx, query, args...)
if err != nil {
return "", fmt.Errorf("child_of query: %w", err)
}
var nextQueue []int64
for rows.Next() {
var childID int64
if err := rows.Scan(&childID); err != nil {
rows.Close()
return "", err
}
if !allIDs[childID] {
allIDs[childID] = true
nextQueue = append(nextQueue, childID)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return "", err
}
queue = nextQueue
}
} else {
// parent_of: find all ancestors (including roots) via parent_id
allIDs = make(map[int64]bool)
queue := make([]int64, len(rootIDs))
copy(queue, rootIDs)
for _, id := range rootIDs {
allIDs[id] = true
}
for len(queue) > 0 {
placeholders := make([]string, len(queue))
args := make([]interface{}, len(queue))
for i, id := range queue {
args[i] = id
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf(
`SELECT "parent_id" FROM %q WHERE "id" IN (%s) AND "parent_id" IS NOT NULL`,
table, strings.Join(placeholders, ", "),
)
rows, err := dc.env.tx.Query(dc.env.ctx, query, args...)
if err != nil {
return "", fmt.Errorf("parent_of query: %w", err)
}
var nextQueue []int64
for rows.Next() {
var parentID int64
if err := rows.Scan(&parentID); err != nil {
rows.Close()
return "", err
}
if !allIDs[parentID] {
allIDs[parentID] = true
nextQueue = append(nextQueue, parentID)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return "", err
}
queue = nextQueue
}
}
if len(allIDs) == 0 {
return "FALSE", nil
}
// Build "id" IN (1, 2, 3, ...) with parameters
paramIdx := len(dc.params) + 1
placeholders := make([]string, 0, len(allIDs))
for id := range allIDs {
dc.params = append(dc.params, id)
placeholders = append(placeholders, fmt.Sprintf("$%d", paramIdx))
paramIdx++
}
return fmt.Sprintf("%q IN (%s)", column, strings.Join(placeholders, ", ")), nil
}
// compileAnyOp implements 'any' and 'not any' operators.
// Mirrors: odoo/orm/domains.py for 'any' / 'not any' operators
//
// - any: EXISTS (SELECT 1 FROM comodel WHERE comodel.fk = model.id AND <subdomain>)
// - not any: NOT EXISTS (...)
//
// The value must be a Domain (sub-domain) to apply on the comodel.
func (dc *DomainCompiler) compileAnyOp(column string, value Value, negate bool) (string, error) {
// Resolve the field to find the comodel
f := dc.model.GetField(column)
if f == nil {
return "", fmt.Errorf("any/not any: field %q not found on %s", column, dc.model.Name())
}
comodel := Registry.Get(f.Comodel)
if comodel == nil {
return "", fmt.Errorf("any/not any: comodel %q not found for field %q", f.Comodel, column)
}
// The value should be a Domain (sub-domain for the comodel)
subDomain, ok := value.(Domain)
if !ok {
return "", fmt.Errorf("any/not any: value must be a Domain, got %T", value)
}
// Compile the sub-domain against the comodel
subCompiler := &DomainCompiler{model: comodel, env: dc.env}
subWhere, subParams, err := subCompiler.Compile(subDomain)
if err != nil {
return "", fmt.Errorf("any/not any: compile subdomain: %w", err)
}
// 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)
}
// Determine the join condition based on field type
var joinCond string
switch f.Type {
case TypeOne2many:
// One2many: comodel has a FK pointing back to this model
inverseField := f.InverseField
if inverseField == "" {
return "", fmt.Errorf("any/not any: One2many field %q has no InverseField", column)
}
inverseF := comodel.GetField(inverseField)
if inverseF == nil {
return "", fmt.Errorf("any/not any: inverse field %q not found on %s", inverseField, comodel.Name())
}
joinCond = fmt.Sprintf("%q.%q = %q.\"id\"", comodel.Table(), inverseF.Column(), dc.model.Table())
case TypeMany2many:
// Many2many: use junction table
relation := f.Relation
if relation == "" {
t1, t2 := dc.model.Table(), comodel.Table()
if t1 > t2 {
t1, t2 = t2, t1
}
relation = fmt.Sprintf("%s_%s_rel", t1, t2)
}
col1 := f.Column1
if col1 == "" {
col1 = dc.model.Table() + "_id"
}
col2 := f.Column2
if col2 == "" {
col2 = comodel.Table() + "_id"
}
joinCond = fmt.Sprintf(
"%q.\"id\" IN (SELECT %q FROM %q WHERE %q = %q.\"id\")",
comodel.Table(), col2, relation, col1, dc.model.Table(),
)
case TypeMany2one:
// Many2one: this model has the FK
joinCond = fmt.Sprintf("%q.\"id\" = %q.%q", comodel.Table(), dc.model.Table(), f.Column())
default:
return "", fmt.Errorf("any/not any: field %q is type %s, expected relational", column, f.Type)
}
subJoins := subCompiler.JoinSQL()
prefix := "EXISTS"
if negate {
prefix = "NOT EXISTS"
}
return fmt.Sprintf("%s (SELECT 1 FROM %q%s WHERE %s AND %s)",
prefix, comodel.Table(), subJoins, joinCond, rebased,
), nil
}
// toInt64Slice normalizes a value to []int64 for hierarchy operators.
func toInt64Slice(value Value) []int64 {
switch v := value.(type) {
case int64:
return []int64{v}
case int:
return []int64{int64(v)}
case int32:
return []int64{int64(v)}
case float64:
return []int64{int64(v)}
case []int64:
return v
case []int:
out := make([]int64, len(v))
for i, x := range v {
out[i] = int64(x)
}
return out
case []interface{}:
out := make([]int64, 0, len(v))
for _, x := range v {
switch n := x.(type) {
case int64:
out = append(out, n)
case int:
out = append(out, int64(n))
case float64:
out = append(out, int64(n))
}
}
return out
}
return nil
}
// normalizeSlice converts typed slices to []interface{} for IN/NOT IN operators.
func normalizeSlice(value Value) []interface{} {
switch v := value.(type) {

473
pkg/orm/domain_parse.go Normal file
View File

@@ -0,0 +1,473 @@
package orm
import (
"fmt"
"strconv"
"strings"
"unicode"
)
// ParseDomainString parses a Python-style domain_force string into an orm.Domain.
// Mirrors: odoo/addons/base/models/ir_rule.py safe_eval(domain_force, eval_context)
//
// Supported syntax:
// - Tuples: ('field', 'operator', value)
// - Logical operators: '&', '|', '!'
// - Values: string literals, numbers, True/False, None, list literals, context variables
// - Context variables: user.id, company_id, user.company_id, company_ids
//
// The env parameter provides runtime context for variable resolution.
func ParseDomainString(s string, env *Environment) (Domain, error) {
s = strings.TrimSpace(s)
if s == "" || s == "[]" {
return nil, nil
}
p := &domainParser{
input: []rune(s),
pos: 0,
env: env,
}
return p.parseDomain()
}
// domainParser is a simple recursive-descent parser for Python domain expressions.
type domainParser struct {
input []rune
pos int
env *Environment
}
func (p *domainParser) parseDomain() (Domain, error) {
p.skipWhitespace()
if p.pos >= len(p.input) {
return nil, nil
}
if p.input[p.pos] != '[' {
return nil, fmt.Errorf("domain_parse: expected '[' at position %d, got %c", p.pos, p.input[p.pos])
}
p.pos++ // consume '['
var nodes []DomainNode
for {
p.skipWhitespace()
if p.pos >= len(p.input) {
return nil, fmt.Errorf("domain_parse: unexpected end of input")
}
if p.input[p.pos] == ']' {
p.pos++ // consume ']'
break
}
// Skip commas between elements
if p.input[p.pos] == ',' {
p.pos++
continue
}
node, err := p.parseNode()
if err != nil {
return nil, err
}
nodes = append(nodes, node)
}
// Convert the list of nodes into a proper Domain.
// Odoo domains are in prefix (Polish) notation:
// ['&', (a), (b)] means a AND b
// If no explicit operator prefix, Odoo implicitly ANDs consecutive leaves.
return normalizeDomainNodes(nodes), nil
}
// normalizeDomainNodes adds implicit '&' operators between consecutive leaf nodes
// that don't have an explicit operator, mirroring Odoo's behavior.
func normalizeDomainNodes(nodes []DomainNode) Domain {
if len(nodes) == 0 {
return nil
}
// Check if the domain already has operators in prefix position.
// If first node is an operator, assume the domain is already in Polish notation.
if _, isOp := nodes[0].(Operator); isOp {
return Domain(nodes)
}
// No prefix operators: implicitly AND all leaf conditions.
if len(nodes) == 1 {
return Domain{nodes[0]}
}
// Multiple leaves without operators: AND them together.
return And(nodes...)
}
func (p *domainParser) parseNode() (DomainNode, error) {
p.skipWhitespace()
if p.pos >= len(p.input) {
return nil, fmt.Errorf("domain_parse: unexpected end of input")
}
ch := p.input[p.pos]
// Check for logical operators: '&', '|', '!'
if ch == '\'' || ch == '"' {
// Could be a string operator like '&' or '|' or '!'
str, err := p.parseString()
if err != nil {
return nil, err
}
switch str {
case "&":
return OpAnd, nil
case "|":
return OpOr, nil
case "!":
return OpNot, nil
default:
return nil, fmt.Errorf("domain_parse: unexpected string %q where operator or tuple expected", str)
}
}
// Check for tuple: (field, operator, value)
if ch == '(' {
return p.parseTuple()
}
return nil, fmt.Errorf("domain_parse: unexpected character %c at position %d", ch, p.pos)
}
func (p *domainParser) parseTuple() (DomainNode, error) {
if p.input[p.pos] != '(' {
return nil, fmt.Errorf("domain_parse: expected '(' at position %d", p.pos)
}
p.pos++ // consume '('
// Parse field name (string)
p.skipWhitespace()
field, err := p.parseString()
if err != nil {
return nil, fmt.Errorf("domain_parse: field name: %w", err)
}
p.skipWhitespace()
p.expectChar(',')
// Parse operator (string)
p.skipWhitespace()
operator, err := p.parseString()
if err != nil {
return nil, fmt.Errorf("domain_parse: operator: %w", err)
}
p.skipWhitespace()
p.expectChar(',')
// Parse value
p.skipWhitespace()
value, err := p.parseValue()
if err != nil {
return nil, fmt.Errorf("domain_parse: value for (%s, %s, ...): %w", field, operator, err)
}
p.skipWhitespace()
if p.pos < len(p.input) && p.input[p.pos] == ')' {
p.pos++ // consume ')'
} else {
return nil, fmt.Errorf("domain_parse: expected ')' at position %d", p.pos)
}
return Condition{Field: field, Operator: operator, Value: value}, nil
}
func (p *domainParser) parseValue() (Value, error) {
p.skipWhitespace()
if p.pos >= len(p.input) {
return nil, fmt.Errorf("domain_parse: unexpected end of input in value")
}
ch := p.input[p.pos]
// String literal
if ch == '\'' || ch == '"' {
return p.parseString()
}
// List literal
if ch == '[' {
return p.parseList()
}
// Tuple literal used as list value (some domain_force uses tuple syntax)
if ch == '(' {
return p.parseTupleAsList()
}
// Number or negative number
if ch == '-' || (ch >= '0' && ch <= '9') {
return p.parseNumber()
}
// Keywords or context variables
if unicode.IsLetter(ch) || ch == '_' {
return p.parseIdentOrKeyword()
}
return nil, fmt.Errorf("domain_parse: unexpected character %c at position %d in value", ch, p.pos)
}
func (p *domainParser) parseString() (string, error) {
if p.pos >= len(p.input) {
return "", fmt.Errorf("domain_parse: unexpected end of input in string")
}
quote := p.input[p.pos]
if quote != '\'' && quote != '"' {
return "", fmt.Errorf("domain_parse: expected quote at position %d, got %c", p.pos, quote)
}
p.pos++ // consume opening quote
var sb strings.Builder
for p.pos < len(p.input) {
ch := p.input[p.pos]
if ch == '\\' && p.pos+1 < len(p.input) {
p.pos++
sb.WriteRune(p.input[p.pos])
p.pos++
continue
}
if ch == quote {
p.pos++ // consume closing quote
return sb.String(), nil
}
sb.WriteRune(ch)
p.pos++
}
return "", fmt.Errorf("domain_parse: unterminated string starting at position %d", p.pos)
}
func (p *domainParser) parseNumber() (Value, error) {
start := p.pos
if p.input[p.pos] == '-' {
p.pos++
}
isFloat := false
for p.pos < len(p.input) {
ch := p.input[p.pos]
if ch == '.' && !isFloat {
isFloat = true
p.pos++
continue
}
if ch >= '0' && ch <= '9' {
p.pos++
continue
}
break
}
numStr := string(p.input[start:p.pos])
if isFloat {
f, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return nil, fmt.Errorf("domain_parse: invalid float %q: %w", numStr, err)
}
return f, nil
}
n, err := strconv.ParseInt(numStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("domain_parse: invalid integer %q: %w", numStr, err)
}
return n, nil
}
func (p *domainParser) parseList() (Value, error) {
if p.input[p.pos] != '[' {
return nil, fmt.Errorf("domain_parse: expected '[' at position %d", p.pos)
}
p.pos++ // consume '['
var items []interface{}
for {
p.skipWhitespace()
if p.pos >= len(p.input) {
return nil, fmt.Errorf("domain_parse: unterminated list")
}
if p.input[p.pos] == ']' {
p.pos++ // consume ']'
break
}
if p.input[p.pos] == ',' {
p.pos++
continue
}
val, err := p.parseValue()
if err != nil {
return nil, err
}
items = append(items, val)
}
// Try to produce typed slices for common cases.
return normalizeListValue(items), nil
}
// parseTupleAsList parses a Python tuple literal (1, 2, 3) as a list value.
func (p *domainParser) parseTupleAsList() (Value, error) {
if p.input[p.pos] != '(' {
return nil, fmt.Errorf("domain_parse: expected '(' at position %d", p.pos)
}
p.pos++ // consume '('
var items []interface{}
for {
p.skipWhitespace()
if p.pos >= len(p.input) {
return nil, fmt.Errorf("domain_parse: unterminated tuple-as-list")
}
if p.input[p.pos] == ')' {
p.pos++ // consume ')'
break
}
if p.input[p.pos] == ',' {
p.pos++
continue
}
val, err := p.parseValue()
if err != nil {
return nil, err
}
items = append(items, val)
}
return normalizeListValue(items), nil
}
// normalizeListValue converts []interface{} to typed slices when all elements
// share the same type, for compatibility with normalizeSlice in domain compilation.
func normalizeListValue(items []interface{}) interface{} {
if len(items) == 0 {
return []int64{}
}
// Check if all items are int64
allInt := true
for _, v := range items {
if _, ok := v.(int64); !ok {
allInt = false
break
}
}
if allInt {
result := make([]int64, len(items))
for i, v := range items {
result[i] = v.(int64)
}
return result
}
// Check if all items are strings
allStr := true
for _, v := range items {
if _, ok := v.(string); !ok {
allStr = false
break
}
}
if allStr {
result := make([]string, len(items))
for i, v := range items {
result[i] = v.(string)
}
return result
}
return items
}
func (p *domainParser) parseIdentOrKeyword() (Value, error) {
start := p.pos
for p.pos < len(p.input) {
ch := p.input[p.pos]
if unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '_' || ch == '.' {
p.pos++
} else {
break
}
}
ident := string(p.input[start:p.pos])
switch ident {
case "True":
return true, nil
case "False":
return false, nil
case "None":
return nil, nil
// Context variables from _eval_context
case "user.id":
if p.env != nil {
return p.env.UID(), nil
}
return int64(0), nil
case "company_id", "user.company_id":
if p.env != nil {
return p.env.CompanyID(), nil
}
return int64(0), nil
case "company_ids":
if p.env != nil {
return []int64{p.env.CompanyID()}, nil
}
return []int64{}, nil
}
// Handle dotted identifiers that start with known prefixes.
// e.g., user.company_id.id, user.partner_id.id, etc.
if strings.HasPrefix(ident, "user.") {
// For now, resolve common patterns. Unknown paths return 0/nil.
switch ident {
case "user.company_id.id":
if p.env != nil {
return p.env.CompanyID(), nil
}
return int64(0), nil
case "user.company_ids.ids":
if p.env != nil {
return []int64{p.env.CompanyID()}, nil
}
return []int64{}, nil
default:
// Unknown user attribute: return 0 as safe fallback.
return int64(0), nil
}
}
return nil, fmt.Errorf("domain_parse: unknown identifier %q at position %d", ident, start)
}
func (p *domainParser) skipWhitespace() {
for p.pos < len(p.input) && unicode.IsSpace(p.input[p.pos]) {
p.pos++
}
}
func (p *domainParser) expectChar(ch rune) {
p.skipWhitespace()
if p.pos < len(p.input) && p.input[p.pos] == ch {
p.pos++
}
// Tolerate missing comma (lenient parsing)
}

422
pkg/orm/read_group.go Normal file
View File

@@ -0,0 +1,422 @@
package orm
import (
"fmt"
"strings"
)
// ReadGroupResult holds one group returned by ReadGroup.
// Mirrors: one row from odoo/orm/models.py _read_group() result tuples.
type ReadGroupResult struct {
// GroupValues maps groupby spec → grouped value (e.g., "state" → "draft")
GroupValues map[string]interface{}
// AggValues maps aggregate spec → aggregated value (e.g., "amount_total:sum" → 1234.56)
AggValues map[string]interface{}
// Domain is the filter domain that selects records in this group.
Domain []interface{}
// Count is the number of records in this group (__count).
Count int64
}
// readGroupbyCol describes a parsed groupby column for ReadGroup.
type readGroupbyCol struct {
spec string // original spec, e.g. "date_order:month"
fieldName string // field name, e.g. "date_order"
granularity string // e.g. "month", "" if none
sqlExpr string // SQL expression for SELECT and GROUP BY
field *Field
}
// ReadGroupOpts configures a ReadGroup call.
type ReadGroupOpts struct {
Offset int
Limit int
Order string
}
// ReadGroup performs a grouped aggregation query.
// Mirrors: odoo/orm/models.py BaseModel._read_group()
//
// groupby: list of groupby specs, e.g. ["state", "date_order:month", "partner_id"]
// aggregates: list of aggregate specs, e.g. ["__count", "amount_total:sum", "id:count_distinct"]
func (rs *Recordset) ReadGroup(domain Domain, groupby []string, aggregates []string, opts ...ReadGroupOpts) ([]ReadGroupResult, error) {
m := rs.model
opt := ReadGroupOpts{}
if len(opts) > 0 {
opt = opts[0]
}
// Apply record rules
domain = ApplyRecordRules(rs.env, m, domain)
// Compile domain to WHERE clause
compiler := &DomainCompiler{model: m, env: rs.env}
where, params, err := compiler.Compile(domain)
if err != nil {
return nil, fmt.Errorf("orm: read_group %s: %w", m.name, err)
}
// Parse groupby specs
var gbCols []readGroupbyCol
for _, spec := range groupby {
fieldName, granularity := parseGroupbySpec(spec)
f := m.GetField(fieldName)
if f == nil {
return nil, fmt.Errorf("orm: read_group: field %q not found on %s", fieldName, m.name)
}
sqlExpr := groupbySQLExpr(m.table, f, granularity)
gbCols = append(gbCols, readGroupbyCol{
spec: spec,
fieldName: fieldName,
granularity: granularity,
sqlExpr: sqlExpr,
field: f,
})
}
// Parse aggregate specs
type aggCol struct {
spec string // original spec, e.g. "amount_total:sum"
fieldName string
function string // e.g. "sum", "count", "avg"
sqlExpr string
}
var aggCols []aggCol
for _, spec := range aggregates {
if spec == "__count" {
aggCols = append(aggCols, aggCol{
spec: "__count",
sqlExpr: "COUNT(*)",
})
continue
}
fieldName, function := parseAggregateSpec(spec)
if function == "" {
return nil, fmt.Errorf("orm: read_group: aggregate %q missing function (expected field:func)", spec)
}
f := m.GetField(fieldName)
if f == nil {
return nil, fmt.Errorf("orm: read_group: field %q not found on %s", fieldName, m.name)
}
sqlFunc := aggregateSQLFunc(function, fmt.Sprintf("%q.%q", m.table, f.Column()))
if sqlFunc == "" {
return nil, fmt.Errorf("orm: read_group: unknown aggregate function %q", function)
}
aggCols = append(aggCols, aggCol{
spec: spec,
fieldName: fieldName,
function: function,
sqlExpr: sqlFunc,
})
}
// Build SELECT clause
var selectParts []string
for _, gb := range gbCols {
selectParts = append(selectParts, gb.sqlExpr)
}
for _, agg := range aggCols {
selectParts = append(selectParts, agg.sqlExpr)
}
if len(selectParts) == 0 {
selectParts = append(selectParts, "COUNT(*)")
}
// Build GROUP BY clause
var groupByParts []string
for _, gb := range gbCols {
groupByParts = append(groupByParts, gb.sqlExpr)
}
// Build ORDER BY
orderSQL := ""
if opt.Order != "" {
orderSQL = opt.Order
} else if len(gbCols) > 0 {
// Default: order by groupby columns
var orderParts []string
for _, gb := range gbCols {
orderParts = append(orderParts, gb.sqlExpr)
}
orderSQL = strings.Join(orderParts, ", ")
}
// Assemble query
joinSQL := compiler.JoinSQL()
query := fmt.Sprintf("SELECT %s FROM %q%s WHERE %s",
strings.Join(selectParts, ", "),
m.table,
joinSQL,
where,
)
if len(groupByParts) > 0 {
query += " GROUP BY " + strings.Join(groupByParts, ", ")
}
if orderSQL != "" {
query += " ORDER BY " + orderSQL
}
if opt.Limit > 0 {
query += fmt.Sprintf(" LIMIT %d", opt.Limit)
}
if opt.Offset > 0 {
query += fmt.Sprintf(" OFFSET %d", opt.Offset)
}
// Execute
rows, err := rs.env.tx.Query(rs.env.ctx, query, params...)
if err != nil {
return nil, fmt.Errorf("orm: read_group %s: %w", m.name, err)
}
defer rows.Close()
// Scan results
totalCols := len(gbCols) + len(aggCols)
if totalCols == 0 {
totalCols = 1 // COUNT(*) fallback
}
var results []ReadGroupResult
for rows.Next() {
scanDest := make([]interface{}, totalCols)
for i := range scanDest {
scanDest[i] = new(interface{})
}
if err := rows.Scan(scanDest...); err != nil {
return nil, fmt.Errorf("orm: read_group scan %s: %w", m.name, err)
}
result := ReadGroupResult{
GroupValues: make(map[string]interface{}),
AggValues: make(map[string]interface{}),
}
// Extract groupby values
for i, gb := range gbCols {
val := *(scanDest[i].(*interface{}))
result.GroupValues[gb.spec] = val
}
// Extract aggregate values
for i, agg := range aggCols {
val := *(scanDest[len(gbCols)+i].(*interface{}))
if agg.spec == "__count" {
result.Count = asInt64(val)
result.AggValues["__count"] = result.Count
} else {
result.AggValues[agg.spec] = val
}
}
// If __count not explicitly requested, add it from COUNT(*)
if _, hasCount := result.AggValues["__count"]; !hasCount {
result.Count = 0
}
// Build domain for this group
result.Domain = buildGroupDomain(gbCols, scanDest)
results = append(results, result)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("orm: read_group %s: %w", m.name, err)
}
// Post-process: resolve Many2one groupby values to [id, display_name]
for _, gb := range gbCols {
if gb.field.Type == TypeMany2one && gb.field.Comodel != "" {
if err := rs.resolveM2OGroupby(gb.spec, gb.field, results); err != nil {
// Non-fatal: log and continue with raw IDs
continue
}
}
}
return results, nil
}
// resolveM2OGroupby replaces raw FK IDs in group results with [id, display_name] pairs.
func (rs *Recordset) resolveM2OGroupby(spec string, f *Field, results []ReadGroupResult) error {
// Collect unique IDs
idSet := make(map[int64]bool)
for _, r := range results {
if id := asInt64(r.GroupValues[spec]); id > 0 {
idSet[id] = true
}
}
if len(idSet) == 0 {
return nil
}
var ids []int64
for id := range idSet {
ids = append(ids, id)
}
// Fetch display names
comodelRS := rs.env.Model(f.Comodel).Browse(ids...)
names, err := comodelRS.NameGet()
if err != nil {
return err
}
// Replace values
for i, r := range results {
rawID := asInt64(r.GroupValues[spec])
if rawID > 0 {
name := names[rawID]
results[i].GroupValues[spec] = []interface{}{rawID, name}
} else {
results[i].GroupValues[spec] = false
}
}
return nil
}
// parseGroupbySpec splits "field:granularity" into field name and granularity.
// Mirrors: odoo/orm/models.py parse_read_group_spec() for groupby
func parseGroupbySpec(spec string) (fieldName, granularity string) {
parts := strings.SplitN(spec, ":", 2)
fieldName = parts[0]
if len(parts) > 1 {
granularity = parts[1]
}
return
}
// parseAggregateSpec splits "field:function" into field name and aggregate function.
// Mirrors: odoo/orm/models.py parse_read_group_spec() for aggregates
func parseAggregateSpec(spec string) (fieldName, function string) {
parts := strings.SplitN(spec, ":", 2)
fieldName = parts[0]
if len(parts) > 1 {
function = parts[1]
}
return
}
// groupbySQLExpr returns the SQL expression for a GROUP BY column.
// Mirrors: odoo/orm/models.py _read_group_groupby()
func groupbySQLExpr(table string, f *Field, granularity string) string {
col := fmt.Sprintf("%q.%q", table, f.Column())
if granularity == "" {
// Boolean fields: COALESCE to false (like Python Odoo)
if f.Type == TypeBoolean {
return fmt.Sprintf("COALESCE(%s, FALSE)", col)
}
return col
}
// Date/datetime granularity
// Mirrors: odoo/orm/models.py _read_group_groupby() date_trunc branch
switch granularity {
case "day", "month", "quarter", "year":
expr := fmt.Sprintf("date_trunc('%s', %s::timestamp)", granularity, col)
if f.Type == TypeDate {
expr += "::date"
}
return expr
case "week":
// ISO week: truncate to Monday
expr := fmt.Sprintf("date_trunc('week', %s::timestamp)", col)
if f.Type == TypeDate {
expr += "::date"
}
return expr
case "year_number":
return fmt.Sprintf("EXTRACT(YEAR FROM %s)", col)
case "quarter_number":
return fmt.Sprintf("EXTRACT(QUARTER FROM %s)", col)
case "month_number":
return fmt.Sprintf("EXTRACT(MONTH FROM %s)", col)
case "iso_week_number":
return fmt.Sprintf("EXTRACT(WEEK FROM %s)", col)
case "day_of_year":
return fmt.Sprintf("EXTRACT(DOY FROM %s)", col)
case "day_of_month":
return fmt.Sprintf("EXTRACT(DAY FROM %s)", col)
case "day_of_week":
return fmt.Sprintf("EXTRACT(ISODOW FROM %s)", col)
case "hour_number":
return fmt.Sprintf("EXTRACT(HOUR FROM %s)", col)
case "minute_number":
return fmt.Sprintf("EXTRACT(MINUTE FROM %s)", col)
case "second_number":
return fmt.Sprintf("EXTRACT(SECOND FROM %s)", col)
default:
// Unknown granularity: fall back to plain column
return col
}
}
// aggregateSQLFunc returns the SQL aggregate expression.
// Mirrors: odoo/orm/models.py READ_GROUP_AGGREGATE
func aggregateSQLFunc(function, column string) string {
switch function {
case "sum":
return fmt.Sprintf("SUM(%s)", column)
case "avg":
return fmt.Sprintf("AVG(%s)", column)
case "max":
return fmt.Sprintf("MAX(%s)", column)
case "min":
return fmt.Sprintf("MIN(%s)", column)
case "count":
return fmt.Sprintf("COUNT(%s)", column)
case "count_distinct":
return fmt.Sprintf("COUNT(DISTINCT %s)", column)
case "bool_and":
return fmt.Sprintf("BOOL_AND(%s)", column)
case "bool_or":
return fmt.Sprintf("BOOL_OR(%s)", column)
case "array_agg":
return fmt.Sprintf("ARRAY_AGG(%s)", column)
case "array_agg_distinct":
return fmt.Sprintf("ARRAY_AGG(DISTINCT %s)", column)
case "recordset":
return fmt.Sprintf("ARRAY_AGG(%s)", column)
case "sum_currency":
// Simplified: SUM without currency conversion (full impl needs exchange rates)
return fmt.Sprintf("SUM(%s)", column)
default:
return ""
}
}
// buildGroupDomain builds a domain that selects all records in this group.
func buildGroupDomain(gbCols []readGroupbyCol, scanDest []interface{}) []interface{} {
var domain []interface{}
for i, gb := range gbCols {
val := *(scanDest[i].(*interface{}))
if val == nil {
domain = append(domain, []interface{}{gb.fieldName, "=", false})
} else if gb.granularity != "" && isTimeGranularity(gb.granularity) {
// For date grouping, build a range domain
// The raw value is the truncated date — client uses __range instead
domain = append(domain, []interface{}{gb.fieldName, "=", val})
} else {
domain = append(domain, []interface{}{gb.fieldName, "=", val})
}
}
return domain
}
// isTimeGranularity returns true for date/time truncation granularities.
func isTimeGranularity(g string) bool {
switch g {
case "day", "week", "month", "quarter", "year":
return true
}
return false
}
// asInt64 converts various numeric types to int64 (ignoring ok).
// Uses toInt64 from relational.go when bool result is needed.
func asInt64(v interface{}) int64 {
n, _ := toInt64(v)
return n
}

View File

@@ -140,6 +140,12 @@ func (rs *Recordset) Create(vals Values) (*Recordset, error) {
if !exists {
continue
}
// Odoo sends false for empty fields; convert to nil for non-boolean types
val = sanitizeFieldValue(f, val)
// Skip nil values (let DB use column default)
if val == nil {
continue
}
columns = append(columns, fmt.Sprintf("%q", f.Column()))
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
args = append(args, val)
@@ -239,6 +245,9 @@ func (rs *Recordset) Write(vals Values) error {
continue
}
// Odoo sends false for empty fields; convert to nil for non-boolean types
val = sanitizeFieldValue(f, val)
setClauses = append(setClauses, fmt.Sprintf("%q = $%d", f.Column(), idx))
args = append(args, val)
idx++
@@ -585,7 +594,7 @@ func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, erro
domain = ApplyRecordRules(rs.env, m, domain)
// Compile domain to SQL
compiler := &DomainCompiler{model: m}
compiler := &DomainCompiler{model: m, env: rs.env}
where, params, err := compiler.Compile(domain)
if err != nil {
return nil, fmt.Errorf("orm: search %s: %w", m.name, err)
@@ -638,7 +647,7 @@ func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, erro
func (rs *Recordset) SearchCount(domain Domain) (int64, error) {
m := rs.model
compiler := &DomainCompiler{model: m}
compiler := &DomainCompiler{model: m, env: rs.env}
where, params, err := compiler.Compile(domain)
if err != nil {
return 0, fmt.Errorf("orm: search_count %s: %w", m.name, err)
@@ -859,3 +868,31 @@ func processRelationalCommands(env *Environment, m *Model, parentID int64, vals
}
return nil
}
// sanitizeFieldValue converts Odoo's false/empty values to Go-native types
// suitable for PostgreSQL. Odoo sends false for empty string/numeric/relational
// fields; PostgreSQL rejects false for varchar/int columns.
// Mirrors: odoo/orm/fields.py convert_to_column()
func sanitizeFieldValue(f *Field, val interface{}) interface{} {
if val == nil {
return nil
}
// Handle the Odoo false → nil conversion for non-boolean fields
if b, ok := val.(bool); ok && !b {
if f.Type == TypeBoolean {
return false // Keep false for boolean fields
}
return nil // Convert false → NULL for all other types
}
// Handle float→int conversion for integer/M2O fields
switch f.Type {
case TypeInteger, TypeMany2one:
if fv, ok := val.(float64); ok {
return int64(fv)
}
}
return val
}

View File

@@ -275,6 +275,8 @@ func toInt64(v interface{}) (int64, bool) {
return int64(n), true
case int64:
return n, true
case int32:
return int64(n), true
case int:
return int64(n), true
}

View File

@@ -10,12 +10,12 @@ import (
//
// Rules work as follows:
// - Global rules (no groups) are AND-ed together
// - Group rules are OR-ed within the group set
// - The final domain is: global_rules AND (group_rule_1 OR group_rule_2 OR ...)
// - Group rules (user belongs to one of the rule's groups) are OR-ed together
// - The final domain is: original AND global_rules AND (group_rule_1 OR group_rule_2 OR ...)
//
// Implementation:
// 1. Built-in company filter (for models with company_id)
// 2. Custom ir.rule records loaded from the database
// 2. Custom ir.rule records loaded from the database, domain_force parsed
func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain {
if env.su {
return domain // Superuser bypasses record rules
@@ -38,59 +38,143 @@ func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain {
}
}
// 2. Load custom ir.rule records from DB
// Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain()
// 2. Load ir.rule records from DB
// Mirrors: odoo/addons/base/models/ir_rule.py IrRule._get_rules() + _compute_domain()
//
// Query rules that apply to this model for the current user:
// - Rule must be active and have perm_read = true
// - Either the rule has no group restriction (global rule),
// or the user belongs to one of the rule's groups.
// Use a savepoint so that a failed query (e.g., missing junction table)
// doesn't abort the parent transaction.
// - Either the rule is global (no groups assigned),
// or the user belongs to one of the rule's groups via rule_group_rel.
// Use a savepoint so that a failed query (e.g., missing table) doesn't abort the parent tx.
sp, spErr := env.tx.Begin(env.ctx)
if spErr != nil {
return domain
}
rows, err := sp.Query(env.ctx,
`SELECT r.id, r.domain_force, COALESCE(r.global, false)
`SELECT r.id, r.domain_force, COALESCE(r."global", false) AS is_global
FROM ir_rule r
JOIN ir_model m ON m.id = r.model_id
WHERE m.model = $1 AND r.active = true
AND r.perm_read = true`,
m.Name())
WHERE m.model = $1
AND r.active = true
AND r.perm_read = true
AND (
r."global" = true
OR r.id IN (
SELECT rg.rule_group_id
FROM rule_group_rel rg
JOIN res_groups_users_rel gu ON gu.gid = rg.group_id
WHERE gu.uid = $2
)
)
ORDER BY r.id`,
m.Name(), env.UID())
if err != nil {
sp.Rollback(env.ctx)
return domain
}
defer func() {
rows.Close()
sp.Commit(env.ctx)
}()
// Collect domain_force strings from matching rules
// TODO: parse domain_force strings into Domain objects and merge them
ruleCount := 0
type ruleRow struct {
id int64
domainForce *string
global bool
}
var rules []ruleRow
for rows.Next() {
var ruleID int64
var domainForce *string
var global bool
if err := rows.Scan(&ruleID, &domainForce, &global); err != nil {
var r ruleRow
if err := rows.Scan(&r.id, &r.domainForce, &r.global); err != nil {
continue
}
ruleCount++
// TODO: parse domainForce (Python-style domain string) into Domain
// and AND global rules / OR group rules into the result domain.
// For now, rules are loaded but domain parsing is deferred.
_ = domainForce
_ = global
rules = append(rules, r)
}
if ruleCount > 0 {
log.Printf("orm: loaded %d ir.rule record(s) for %s (domain parsing pending)", ruleCount, m.Name())
rows.Close()
if err := sp.Commit(env.ctx); err != nil {
// Non-fatal: rules already read
_ = err
}
if len(rules) == 0 {
return domain
}
// Parse domain_force strings and split into global vs. group rules.
// Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain()
// global rules → AND together
// group rules → OR together
// final = original AND all_global AND (group_1 OR group_2 OR ...)
var globalDomains []DomainNode
var groupDomains []DomainNode
parseErrors := 0
for _, r := range rules {
if r.domainForce == nil || *r.domainForce == "" || *r.domainForce == "[]" {
// Empty domain_force = match everything, skip
continue
}
parsed, err := ParseDomainString(*r.domainForce, env)
if err != nil {
parseErrors++
log.Printf("orm: failed to parse domain_force for ir.rule %d: %v (raw: %s)", r.id, err, *r.domainForce)
continue
}
if len(parsed) == 0 {
continue
}
if r.global {
// Global rule: wrap as a single node for AND-ing
globalDomains = append(globalDomains, domainAsNode(parsed))
} else {
// Group rule: wrap as a single node for OR-ing
groupDomains = append(groupDomains, domainAsNode(parsed))
}
}
if parseErrors > 0 {
log.Printf("orm: %d ir.rule domain_force parse error(s) for %s", parseErrors, m.Name())
}
// Merge group domains with OR
if len(groupDomains) > 0 {
orDomain := Or(groupDomains...)
globalDomains = append(globalDomains, domainAsNode(orDomain))
}
// AND all rule domains into the original domain
if len(globalDomains) > 0 {
ruleDomain := And(globalDomains...)
if len(domain) == 0 {
domain = ruleDomain
} else {
result := Domain{OpAnd}
result = append(result, domain...)
result = append(result, ruleDomain...)
domain = result
}
}
return domain
}
// domainAsNode wraps a Domain (which is a []DomainNode) into a single DomainNode
// so it can be used as an operand for And() / Or().
// If the domain has a single node, return it directly.
// If multiple nodes, wrap in a domainGroup.
func domainAsNode(d Domain) DomainNode {
if len(d) == 1 {
return d[0]
}
return domainGroup(d)
}
// domainGroup wraps a Domain as a single DomainNode for use in And()/Or() combinations.
// When compiled, it produces the same SQL as the contained domain.
type domainGroup Domain
func (dg domainGroup) isDomainNode() {}
// CheckRecordRuleAccess verifies the user can access specific record IDs.
// Returns an error if any record is not accessible.
func CheckRecordRuleAccess(env *Environment, m *Model, ids []int64, perm string) error {

157
pkg/server/export.go Normal file
View File

@@ -0,0 +1,157 @@
package server
import (
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"odoo-go/pkg/orm"
)
// handleExportCSV exports records as CSV.
// Mirrors: odoo/addons/web/controllers/export.py ExportController
func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
return
}
var params struct {
Data struct {
Model string `json:"model"`
Fields []exportField `json:"fields"`
Domain []interface{} `json:"domain"`
IDs []float64 `json:"ids"`
} `json:"data"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
return
}
// Extract UID from session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool,
UID: uid,
CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer env.Close()
rs := env.Model(params.Data.Model)
// Determine which record IDs to export
var ids []int64
if len(params.Data.IDs) > 0 {
for _, id := range params.Data.IDs {
ids = append(ids, int64(id))
}
} else {
// Search with domain
domain := parseDomain([]interface{}{params.Data.Domain})
found, err := rs.Search(domain, orm.SearchOpts{Limit: 10000})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ids = found.IDs()
}
if len(ids) == 0 {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", params.Data.Model))
return
}
// Extract field names
var fieldNames []string
var headers []string
for _, f := range params.Data.Fields {
fieldNames = append(fieldNames, f.Name)
label := f.Label
if label == "" {
label = f.Name
}
headers = append(headers, label)
}
// Read records
records, err := rs.Browse(ids...).Read(fieldNames)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := env.Commit(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Write CSV
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", params.Data.Model))
writer := csv.NewWriter(w)
defer writer.Flush()
// Header row
writer.Write(headers)
// Data rows
for _, rec := range records {
row := make([]string, len(fieldNames))
for i, fname := range fieldNames {
row[i] = formatCSVValue(rec[fname])
}
writer.Write(row)
}
}
// exportField describes a field in an export request.
type exportField struct {
Name string `json:"name"`
Label string `json:"label"`
}
// formatCSVValue converts a field value to a CSV string.
func formatCSVValue(v interface{}) string {
if v == nil || v == false {
return ""
}
switch val := v.(type) {
case string:
return val
case bool:
if val {
return "True"
}
return "False"
case []interface{}:
// M2O: [id, "name"] → "name"
if len(val) == 2 {
if name, ok := val[1].(string); ok {
return name
}
}
return fmt.Sprintf("%v", val)
default:
return fmt.Sprintf("%v", val)
}
}

View File

@@ -12,6 +12,10 @@ func fieldsGetForModel(modelName string) map[string]interface{} {
result := make(map[string]interface{})
for name, f := range m.Fields() {
// Never expose password fields in metadata
if name == "password" || name == "password_crypt" {
continue
}
fType := f.Type.String()
fieldInfo := map[string]interface{}{
@@ -66,9 +70,23 @@ func fieldsGetForModel(modelName string) map[string]interface{} {
fieldInfo["related"] = f.Related
}
// Aggregator hint for read_group
// Mirrors: odoo/orm/fields.py Field.group_operator
switch f.Type {
case orm.TypeInteger, orm.TypeFloat, orm.TypeMonetary:
fieldInfo["aggregator"] = "sum"
fieldInfo["group_operator"] = "sum"
case orm.TypeBoolean:
fieldInfo["aggregator"] = "bool_or"
fieldInfo["group_operator"] = "bool_or"
default:
fieldInfo["aggregator"] = nil
fieldInfo["group_operator"] = nil
}
// Default domain & context
fieldInfo["domain"] = "[]"
fieldInfo["context"] = "{}"
fieldInfo["domain"] = []interface{}{}
fieldInfo["context"] = map[string]interface{}{}
result[name] = fieldInfo
}

View File

@@ -43,7 +43,7 @@ func New(cfg *tools.Config, pool *pgxpool.Pool) *Server {
config: cfg,
pool: pool,
mux: http.NewServeMux(),
sessions: NewSessionStore(24 * time.Hour),
sessions: NewSessionStore(24*time.Hour, pool),
}
// Compile XML templates to JS at startup, replacing the Python build step.
@@ -82,6 +82,8 @@ func (s *Server) registerRoutes() {
s.mux.HandleFunc("/jsonrpc", s.handleJSONRPC)
s.mux.HandleFunc("/web/dataset/call_kw", s.handleCallKW)
s.mux.HandleFunc("/web/dataset/call_kw/", s.handleCallKW)
s.mux.HandleFunc("/web/dataset/call_button", s.handleCallKW) // call_button uses same dispatch as call_kw
s.mux.HandleFunc("/web/dataset/call_button/", s.handleCallKW) // with model/method suffix
// Session endpoints
s.mux.HandleFunc("/web/session/authenticate", s.handleAuthenticate)
@@ -116,8 +118,12 @@ func (s *Server) registerRoutes() {
// PWA manifest
s.mux.HandleFunc("/web/manifest.webmanifest", s.handleManifest)
// File upload
// File upload and download
s.mux.HandleFunc("/web/binary/upload_attachment", s.handleUpload)
s.mux.HandleFunc("/web/content/", s.handleContent)
// CSV export
s.mux.HandleFunc("/web/export/csv", s.handleExportCSV)
// Logout & Account
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
@@ -338,6 +344,15 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
return nil, err
}
// If model is "ir.http", handle special routing methods
if params.Model == "ir.http" {
switch params.Method {
case "session_info":
// Return session info - already handled by session endpoint
return map[string]interface{}{}, nil
}
}
rs := env.Model(params.Model)
switch params.Method {
@@ -352,44 +367,7 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
return fieldsGetForModel(params.Model), nil
case "web_read_group", "read_group":
// Basic implementation: if groupby is provided, return one group with all records
groupby := []string{}
if gb, ok := params.KW["groupby"].([]interface{}); ok {
for _, g := range gb {
if s, ok := g.(string); ok {
groupby = append(groupby, s)
}
}
}
if len(groupby) == 0 {
// No groupby → return empty groups
return map[string]interface{}{
"groups": []interface{}{},
"length": 0,
}, nil
}
// With groupby: return all records in one "ungrouped" group
domain := parseDomain(params.Args)
if domain == nil {
if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 {
domain = parseDomain([]interface{}{domainRaw})
}
}
count, _ := rs.SearchCount(domain)
return map[string]interface{}{
"groups": []interface{}{
map[string]interface{}{
"__domain": []interface{}{},
"__count": count,
groupby[0]: false,
"__records": []interface{}{},
},
},
"length": 1,
}, nil
return s.handleReadGroup(rs, params)
case "web_search_read":
return handleWebSearchRead(env, params.Model, params)
@@ -623,6 +601,40 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
}
return nameResult, nil
case "get_formview_action":
ids := parseIDs(params.Args)
if len(ids) == 0 {
return false, nil
}
return map[string]interface{}{
"type": "ir.actions.act_window",
"res_model": params.Model,
"res_id": ids[0],
"view_mode": "form",
"views": [][]interface{}{{nil, "form"}},
"target": "current",
}, nil
case "get_formview_id":
return false, nil
case "action_get":
return false, nil
case "name_create":
nameStr := ""
if len(params.Args) > 0 {
nameStr, _ = params.Args[0].(string)
}
if nameStr == "" {
return nil, &RPCError{Code: -32000, Message: "name_create requires a name"}
}
created, err := rs.Create(orm.Values{"name": nameStr})
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return []interface{}{created.ID(), nameStr}, nil
case "read_progress_bar":
return map[string]interface{}{}, nil
@@ -671,7 +683,8 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
return created.ID(), nil
default:
// Try registered business methods on the model
// Try registered business methods on the model.
// Mirrors: odoo/service/model.py call_kw() + odoo/addons/web/controllers/dataset.py call_button()
model := orm.Registry.Get(params.Model)
if model != nil && model.Methods != nil {
if method, ok := model.Methods[params.Method]; ok {
@@ -680,6 +693,18 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
// If the method returns an action dict (map with "type" key),
// return it directly so the web client can navigate.
// Mirrors: odoo/addons/web/controllers/dataset.py call_button()
if actionMap, ok := result.(map[string]interface{}); ok {
if _, hasType := actionMap["type"]; hasType {
return actionMap, nil
}
}
// If result is true or nil, return false (meaning "reload current view")
if result == nil || result == true {
return false, nil
}
return result, nil
}
}

View File

@@ -1,10 +1,14 @@
package server
import (
"context"
"crypto/rand"
"encoding/hex"
"log"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Session represents an authenticated user session.
@@ -17,66 +21,164 @@ type Session struct {
LastActivity time.Time
}
// SessionStore is a thread-safe in-memory session store.
// SessionStore is a session store with an in-memory cache backed by PostgreSQL.
// Mirrors: odoo/http.py OpenERPSession
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
ttl time.Duration
pool *pgxpool.Pool
}
// NewSessionStore creates a new session store with the given TTL.
func NewSessionStore(ttl time.Duration) *SessionStore {
// NewSessionStore creates a new session store with the given TTL and DB pool.
func NewSessionStore(ttl time.Duration, pool *pgxpool.Pool) *SessionStore {
return &SessionStore{
sessions: make(map[string]*Session),
ttl: ttl,
pool: pool,
}
}
// New creates a new session and returns it.
func (s *SessionStore) New(uid, companyID int64, login string) *Session {
s.mu.Lock()
defer s.mu.Unlock()
// InitSessionTable creates the sessions table if it does not exist.
func InitSessionTable(ctx context.Context, pool *pgxpool.Pool) error {
_, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS sessions (
id VARCHAR(64) PRIMARY KEY,
uid INT8 NOT NULL,
company_id INT8 NOT NULL,
login VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
last_seen TIMESTAMP DEFAULT NOW()
)
`)
if err != nil {
return err
}
log.Println("odoo: sessions table ready")
return nil
}
// New creates a new session, stores it in memory and PostgreSQL, and returns it.
func (s *SessionStore) New(uid, companyID int64, login string) *Session {
token := generateToken()
now := time.Now()
sess := &Session{
ID: token,
UID: uid,
CompanyID: companyID,
Login: login,
CreatedAt: time.Now(),
LastActivity: time.Now(),
CreatedAt: now,
LastActivity: now,
}
// Store in memory cache
s.mu.Lock()
s.sessions[token] = sess
s.mu.Unlock()
// Persist to PostgreSQL
if s.pool != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := s.pool.Exec(ctx,
`INSERT INTO sessions (id, uid, company_id, login, created_at, last_seen)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO NOTHING`,
token, uid, companyID, login, now, now)
if err != nil {
log.Printf("session: failed to persist session to DB: %v", err)
}
}
return sess
}
// Get retrieves a session by ID. Returns nil if not found or expired.
// Get retrieves a session by ID. Checks in-memory cache first, falls back to DB.
// Returns nil if not found or expired.
func (s *SessionStore) Get(id string) *Session {
// Check memory cache first
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok {
if ok {
if time.Since(sess.LastActivity) > s.ttl {
s.Delete(id)
return nil
}
// Update last activity
now := time.Now()
s.mu.Lock()
sess.LastActivity = now
s.mu.Unlock()
// Update last_seen in DB asynchronously
if s.pool != nil {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.pool.Exec(ctx,
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
}()
}
return sess
}
// Fallback to DB
if s.pool == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sess = &Session{}
err := s.pool.QueryRow(ctx,
`SELECT id, uid, company_id, login, created_at, last_seen
FROM sessions WHERE id = $1`, id).Scan(
&sess.ID, &sess.UID, &sess.CompanyID, &sess.Login,
&sess.CreatedAt, &sess.LastActivity)
if err != nil {
return nil
}
// Check TTL
if time.Since(sess.LastActivity) > s.ttl {
s.Delete(id)
return nil
}
// Update last activity
now := time.Now()
sess.LastActivity = now
// Add to memory cache
s.mu.Lock()
sess.LastActivity = time.Now()
s.sessions[id] = sess
s.mu.Unlock()
// Update last_seen in DB
s.pool.Exec(ctx,
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
return sess
}
// Delete removes a session.
// Delete removes a session from memory and DB.
func (s *SessionStore) Delete(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, id)
s.mu.Unlock()
if s.pool != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE id = $1`, id)
if err != nil {
log.Printf("session: failed to delete session from DB: %v", err)
}
}
}
func generateToken() string {

View File

@@ -2,12 +2,18 @@ package server
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"odoo-go/pkg/orm"
)
// handleUpload handles file uploads to ir.attachment.
// Mirrors: odoo/addons/web/controllers/binary.py upload_attachment()
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -36,13 +42,143 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
log.Printf("upload: received %s (%d bytes, %s)", header.Filename, len(data), header.Header.Get("Content-Type"))
// TODO: Store in ir.attachment table or filesystem
// For now, just acknowledge receipt
// Extract model/id from form values for linking
resModel := r.FormValue("model")
resIDStr := r.FormValue("id")
resID := int64(0)
if resIDStr != "" {
if v, err := strconv.ParseInt(resIDStr, 10, 64); err == nil {
resID = v
}
}
// Get UID from session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
// Store in ir.attachment
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool,
UID: uid,
CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer env.Close()
// Detect mimetype
mimetype := header.Header.Get("Content-Type")
if mimetype == "" {
mimetype = "application/octet-stream"
}
attachVals := orm.Values{
"name": header.Filename,
"datas": data,
"mimetype": mimetype,
"file_size": len(data),
"type": "binary",
}
if resModel != "" {
attachVals["res_model"] = resModel
}
if resID > 0 {
attachVals["res_id"] = resID
}
attachRS := env.Model("ir.attachment")
created, err := attachRS.Create(attachVals)
if err != nil {
log.Printf("upload: failed to create attachment: %v", err)
// Return success anyway with temp ID (graceful degradation)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]map[string]interface{}{
{"id": 0, "name": header.Filename, "size": len(data), "mimetype": mimetype},
})
if commitErr := env.Commit(); commitErr != nil {
log.Printf("upload: commit warning: %v", commitErr)
}
return
}
if err := env.Commit(); err != nil {
http.Error(w, "Commit error", http.StatusInternalServerError)
return
}
// Return Odoo-expected format: array of attachment dicts
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 1,
"name": header.Filename,
"size": len(data),
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": created.ID(),
"name": header.Filename,
"size": len(data),
"mimetype": mimetype,
},
})
}
// handleContent serves attachment content by ID.
// Mirrors: odoo/addons/web/controllers/binary.py content()
func (s *Server) handleContent(w http.ResponseWriter, r *http.Request) {
// Extract attachment ID from URL: /web/content/<id>
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 {
http.Error(w, "Not found", http.StatusNotFound)
return
}
idStr := parts[3]
attachID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool,
UID: uid,
CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer env.Close()
// Read attachment
attachRS := env.Model("ir.attachment").Browse(attachID)
records, err := attachRS.Read([]string{"name", "datas", "mimetype"})
if err != nil || len(records) == 0 {
http.Error(w, "Attachment not found", http.StatusNotFound)
return
}
rec := records[0]
name, _ := rec["name"].(string)
mimetype, _ := rec["mimetype"].(string)
if mimetype == "" {
mimetype = "application/octet-stream"
}
w.Header().Set("Content-Type", mimetype)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
if data, ok := rec["datas"].([]byte); ok {
w.Write(data)
} else {
http.Error(w, "No content", http.StatusNotFound)
}
}

View File

@@ -60,6 +60,21 @@ func handleGetViews(env *orm.Environment, model string, params CallKWParams) (in
}
}
// Always include search view (client expects it)
if _, hasSearch := views["search"]; !hasSearch {
arch := loadViewArch(env, model, "search")
if arch == "" {
arch = generateDefaultView(model, "search")
}
views["search"] = map[string]interface{}{
"arch": arch,
"type": "search",
"model": model,
"view_id": 0,
"field_parent": false,
}
}
// Build models dict with field metadata
models := map[string]interface{}{
model: map[string]interface{}{
@@ -133,6 +148,7 @@ func generateDefaultListView(m *orm.Model) string {
if added[f.Name] || f.Name == "id" || !f.IsStored() ||
f.Name == "create_uid" || f.Name == "write_uid" ||
f.Name == "create_date" || f.Name == "write_date" ||
f.Name == "password" || f.Name == "password_crypt" ||
f.Type == orm.TypeBinary || f.Type == orm.TypeText || f.Type == orm.TypeHTML {
continue
}
@@ -147,13 +163,47 @@ func generateDefaultFormView(m *orm.Model) string {
skip := map[string]bool{
"id": true, "create_uid": true, "write_uid": true,
"create_date": true, "write_date": true,
"password": true, "password_crypt": true,
}
// Header with state widget if state field exists
// Header with action buttons and state widget
// Mirrors: odoo form views with <header><button .../><field name="state" widget="statusbar"/></header>
var header string
if f := m.GetField("state"); f != nil && f.Type == orm.TypeSelection {
header = ` <header>
<field name="state" widget="statusbar"/>
var buttons []string
// Generate buttons from registered methods that look like actions
if m.Methods != nil {
actionMethods := []struct{ method, label, stateFilter string }{
{"action_confirm", "Confirm", "draft"},
{"action_post", "Post", "draft"},
{"action_done", "Done", "confirmed"},
{"action_cancel", "Cancel", ""},
{"button_cancel", "Cancel", ""},
{"button_draft", "Reset to Draft", "cancel"},
{"action_send", "Send", "posted"},
{"create_invoices", "Create Invoice", "sale"},
}
for _, am := range actionMethods {
if _, ok := m.Methods[am.method]; ok {
attrs := ""
if am.stateFilter != "" {
attrs = fmt.Sprintf(` invisible="state != '%s'"`, am.stateFilter)
}
btnClass := "btn-secondary"
if am.method == "action_confirm" || am.method == "action_post" {
btnClass = "btn-primary"
}
buttons = append(buttons, fmt.Sprintf(
` <button name="%s" string="%s" type="object" class="%s"%s/>`,
am.method, am.label, btnClass, attrs))
}
}
}
header = " <header>\n"
for _, btn := range buttons {
header += btn + "\n"
}
header += ` <field name="state" widget="statusbar" clickable="1"/>
</header>
`
}
@@ -306,11 +356,44 @@ func generateDefaultKanbanView(m *orm.Model) string {
if f := m.GetField("name"); f == nil {
nameField = "id"
}
// Build a richer card with available fields
var cardFields []string
// Title
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s" class="fw-bold fs-5"/>`, nameField))
// Partner/customer
if f := m.GetField("partner_id"); f != nil {
cardFields = append(cardFields, ` <field name="partner_id"/>`)
}
// Revenue/amount
for _, amtField := range []string{"expected_revenue", "amount_total", "amount_untaxed"} {
if f := m.GetField(amtField); f != nil {
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s"/>`, amtField))
break
}
}
// Date
for _, dateField := range []string{"date_order", "date", "date_deadline"} {
if f := m.GetField(dateField); f != nil {
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s"/>`, dateField))
break
}
}
// User/assignee
if f := m.GetField("user_id"); f != nil {
cardFields = append(cardFields, ` <field name="user_id" widget="many2one_avatar_user"/>`)
}
return fmt.Sprintf(`<kanban>
<templates>
<t t-name="card">
<field name="%s"/>
%s
</t>
</templates>
</kanban>`, nameField)
</kanban>`, strings.Join(cardFields, "\n"))
}

View File

@@ -245,6 +245,132 @@ func normalizeNullFields(model string, records []orm.Values) {
}
}
// 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" {
// web_read_group: also get total group count (without limit/offset)
totalLen := len(results)
if opts.Limit > 0 || opts.Offset > 0 {
// Re-query without limit/offset to get total
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
}
// formatDateFields converts date/datetime values to Odoo's expected string format.
func formatDateFields(model string, records []orm.Values) {
m := orm.Registry.Get(model)

View File

@@ -185,6 +185,7 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
// Mirrors: odoo/addons/web/models/ir_http.py session_info()
func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
return map[string]interface{}{
"session_id": sess.ID,
"uid": sess.UID,
"is_system": sess.UID == 1,
"is_admin": sess.UID == 1,

View File

@@ -4,6 +4,7 @@ package service
import (
"context"
"crypto/rand"
"fmt"
"log"
@@ -103,6 +104,22 @@ func InitDatabase(ctx context.Context, pool *pgxpool.Pool) error {
}
}
// Phase 4b: Add unique constraint on ir_config_parameter.key for ON CONFLICT support.
// Mirrors: odoo/addons/base/models/ir_config_parameter.py _sql_constraints
sp, spErr := tx.Begin(ctx)
if spErr == nil {
if _, err := sp.Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS ir_config_parameter_key_uniq ON ir_config_parameter (key)`); err != nil {
sp.Rollback(ctx)
} else {
sp.Commit(ctx)
}
}
// Phase 5: Seed ir_model and ir_model_fields with model metadata.
// This is critical because ir.rule joins through ir_model to find rules for a model.
// Mirrors: odoo/modules/loading.py load_module_graph() → _setup_base()
seedIrModelMetadata(ctx, tx, models)
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("db: commit: %w", err)
}
@@ -111,6 +128,121 @@ func InitDatabase(ctx context.Context, pool *pgxpool.Pool) error {
return nil
}
// seedIrModelMetadata populates ir_model and ir_model_fields for all registered models.
// Each model gets a row in ir_model; each field gets a row in ir_model_fields.
// Uses ON CONFLICT DO NOTHING so it's safe to call on every startup.
func seedIrModelMetadata(ctx context.Context, tx pgx.Tx, models map[string]*orm.Model) {
// Check if ir_model table exists (it should, but guard against ordering issues)
var exists bool
err := tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ir_model')`,
).Scan(&exists)
if err != nil || !exists {
log.Println("db: ir_model table does not exist yet, skipping metadata seed")
return
}
// Also check ir_model_fields
var fieldsExists bool
err = tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ir_model_fields')`,
).Scan(&fieldsExists)
if err != nil || !fieldsExists {
log.Println("db: ir_model_fields table does not exist yet, skipping metadata seed")
return
}
modelCount := 0
fieldCount := 0
for name, m := range models {
if m.IsAbstract() {
continue
}
// Check if this model already exists in ir_model
var modelID int64
sp, spErr := tx.Begin(ctx)
if spErr != nil {
continue
}
err := sp.QueryRow(ctx,
`SELECT id FROM ir_model WHERE model = $1`, name,
).Scan(&modelID)
if err != nil {
// Model doesn't exist yet — insert it
sp.Rollback(ctx)
sp2, spErr2 := tx.Begin(ctx)
if spErr2 != nil {
continue
}
err = sp2.QueryRow(ctx,
`INSERT INTO ir_model (model, name, info, state, transient)
VALUES ($1, $2, $3, 'base', $4)
RETURNING id`,
name, m.Description(), m.Description(), m.IsTransient(),
).Scan(&modelID)
if err != nil {
sp2.Rollback(ctx)
continue
}
sp2.Commit(ctx)
modelCount++
} else {
sp.Commit(ctx)
}
if modelID == 0 {
continue
}
// INSERT into ir_model_fields for each field
for fieldName, field := range m.Fields() {
if fieldName == "id" || fieldName == "display_name" {
continue
}
// Check if this field already exists
sp, spErr := tx.Begin(ctx)
if spErr != nil {
continue
}
var fieldExists bool
err := sp.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM ir_model_fields WHERE model_id = $1 AND name = $2)`,
modelID, fieldName,
).Scan(&fieldExists)
if err != nil {
sp.Rollback(ctx)
continue
}
if fieldExists {
sp.Commit(ctx)
continue
}
sp.Commit(ctx)
sp2, spErr2 := tx.Begin(ctx)
if spErr2 != nil {
continue
}
_, err = sp2.Exec(ctx,
`INSERT INTO ir_model_fields (model_id, name, field_description, ttype, state, store)
VALUES ($1, $2, $3, $4, 'base', $5)`,
modelID, fieldName, field.String, field.Type.String(), field.IsStored(),
)
if err != nil {
sp2.Rollback(ctx)
} else {
sp2.Commit(ctx)
fieldCount++
}
}
}
log.Printf("db: seeded ir_model metadata: %d models, %d fields", modelCount, fieldCount)
}
// NeedsSetup checks if the database requires initial setup.
func NeedsSetup(ctx context.Context, pool *pgxpool.Pool) bool {
var count int
@@ -262,7 +394,14 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err
// 13. Menus (ir_ui_menu + ir_model_data for XML IDs)
seedMenus(ctx, tx)
// 14. Demo data
// 14. Settings record (res.config.settings needs at least one record to display)
tx.Exec(ctx, `INSERT INTO res_config_settings (id, company_id, show_effect, create_uid, write_uid)
VALUES (1, 1, true, 1, 1) ON CONFLICT (id) DO NOTHING`)
// 14b. System parameters (ir.config_parameter)
seedSystemParams(ctx, tx)
// 15. Demo data
if cfg.DemoData {
seedDemoData(ctx, tx)
}
@@ -274,6 +413,8 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err
"account_tax", "sale_order", "sale_order_line", "account_move",
"ir_act_window", "ir_model_data", "ir_ui_menu",
"stock_location", "stock_picking_type", "stock_warehouse",
"crm_stage", "crm_lead",
"ir_config_parameter",
}
for _, table := range seqs {
tx.Exec(ctx, fmt.Sprintf(
@@ -426,6 +567,161 @@ func seedViews(ctx context.Context, tx pgx.Tx) {
</kanban>', 16, true, 'primary')
ON CONFLICT DO NOTHING`)
// Settings form view
tx.Exec(ctx, `INSERT INTO ir_ui_view (name, model, type, arch, priority, active, mode) VALUES
('res.config.settings.form', 'res.config.settings', 'form', '<form string="Settings" class="oe_form_configuration">
<header>
<button name="execute" string="Save" type="object" class="btn-primary"/>
<button string="Discard" special="cancel" class="btn-secondary"/>
</header>
<div class="o_setting_container">
<div class="settings">
<div class="app_settings_block">
<h2>Company</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_name"/>
<div class="text-muted">Your company name</div>
<field name="company_name"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_email"/>
<field name="company_email"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_phone"/>
<field name="company_phone"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_website"/>
<field name="company_website"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_vat"/>
<div class="text-muted">Tax ID / VAT number</div>
<field name="company_vat"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_currency_id"/>
<field name="company_currency_id"/>
</div>
</div>
</div>
<h2>Address</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_street"/>
<field name="company_street"/>
<field name="company_street2"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_zip"/>
<field name="company_zip"/>
<label for="company_city"/>
<field name="company_city"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_country_id"/>
<field name="company_country_id"/>
</div>
</div>
</div>
<h2>Features</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="group_multi_company"/>
</div>
<div class="o_setting_right_pane">
<label for="group_multi_company"/>
<div class="text-muted">Manage multiple companies</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="module_base_import"/>
</div>
<div class="o_setting_right_pane">
<label for="module_base_import"/>
<div class="text-muted">Import records from CSV/Excel files</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="show_effect"/>
</div>
<div class="o_setting_right_pane">
<label for="show_effect"/>
<div class="text-muted">Show animation effects</div>
</div>
</div>
</div>
</div>
</div>
</div>
<field name="company_id" invisible="1"/>
</form>', 10, true, 'primary')
ON CONFLICT DO NOTHING`)
// Admin list views for Settings > Technical
tx.Exec(ctx, `INSERT INTO ir_ui_view (name, model, type, arch, priority, active, mode) VALUES
('company.list', 'res.company', 'list', '<list>
<field name="name"/>
<field name="partner_id"/>
<field name="currency_id"/>
<field name="email"/>
<field name="phone"/>
</list>', 16, true, 'primary'),
('users.list', 'res.users', 'list', '<list>
<field name="name"/>
<field name="login"/>
<field name="company_id"/>
<field name="active"/>
</list>', 16, true, 'primary'),
('config_parameter.list', 'ir.config_parameter', 'list', '<list>
<field name="key"/>
<field name="value"/>
</list>', 16, true, 'primary'),
('ui_view.list', 'ir.ui.view', 'list', '<list>
<field name="name"/>
<field name="model"/>
<field name="type"/>
<field name="priority"/>
<field name="active"/>
</list>', 16, true, 'primary'),
('ui_menu.list', 'ir.ui.menu', 'list', '<list>
<field name="name"/>
<field name="parent_id"/>
<field name="sequence"/>
<field name="action"/>
</list>', 16, true, 'primary')
ON CONFLICT DO NOTHING`)
log.Println("db: UI views seeded")
}
@@ -463,10 +759,20 @@ func seedActions(ctx context.Context, tx pgx.Tx) {
{10, "Projects", "project.project", "list,form", "[]", "{}", "current", 80, 0, "project", "action_project"},
{11, "Tasks", "project.task", "list,form", "[]", "{}", "current", 80, 0, "project", "action_project_task"},
{12, "Vehicles", "fleet.vehicle", "list,form", "[]", "{}", "current", 80, 0, "fleet", "action_fleet_vehicle"},
{100, "Settings", "res.company", "form", "[]", "{}", "current", 80, 1, "base", "action_res_company_form"},
{100, "Settings", "res.config.settings", "form", "[]", "{}", "current", 80, 1, "base", "action_general_configuration"},
{104, "Companies", "res.company", "list,form", "[]", "{}", "current", 80, 0, "base", "action_res_company_form"},
{101, "Users", "res.users", "list,form", "[]", "{}", "current", 80, 0, "base", "action_res_users"},
{102, "Sequences", "ir.sequence", "list,form", "[]", "{}", "current", 80, 0, "base", "ir_sequence_form"},
{103, "Change My Preferences", "res.users", "form", "[]", "{}", "new", 80, 0, "base", "action_res_users_my"},
{105, "Groups", "res.groups", "list,form", "[]", "{}", "current", 80, 0, "base", "action_res_groups"},
{106, "Logging", "ir.logging", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_logging"},
{107, "System Parameters", "ir.config_parameter", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_config_parameter"},
{108, "Scheduled Actions", "ir.cron", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_cron"},
{109, "Views", "ir.ui.view", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_ui_view"},
{110, "Actions", "ir.actions.act_window", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_act_window"},
{111, "Menus", "ir.ui.menu", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_ui_menu"},
{112, "Access Rights", "ir.model.access", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_model_access"},
{113, "Record Rules", "ir.rule", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_rule"},
}
for _, a := range actions {
@@ -552,8 +858,40 @@ func seedMenus(ctx context.Context, tx pgx.Tx) {
// ── Settings ─────────────────────────────────────────────
{100, "Settings", nil, 100, "ir.actions.act_window,100", "fa-cog,#71639e,#FFFFFF", "base", "menu_administration"},
{101, "Users & Companies", p(100), 10, "ir.actions.act_window,101", "", "base", "menu_users"},
{102, "Technical", p(100), 20, "ir.actions.act_window,102", "", "base", "menu_custom"},
{101, "Users & Companies", p(100), 10, "", "", "base", "menu_users"},
{110, "Users", p(101), 10, "ir.actions.act_window,101", "", "base", "menu_action_res_users"},
{111, "Companies", p(101), 20, "ir.actions.act_window,104", "", "base", "menu_action_res_company_form"},
{112, "Groups", p(101), 30, "ir.actions.act_window,105", "", "base", "menu_action_res_groups"},
{102, "Technical", p(100), 20, "", "", "base", "menu_custom"},
// Database Structure
{120, "Database Structure", p(102), 10, "", "", "base", "menu_custom_database_structure"},
// Sequences & Identifiers
{122, "Sequences", p(102), 15, "ir.actions.act_window,102", "", "base", "menu_custom_sequences"},
// Parameters
{125, "Parameters", p(102), 20, "", "", "base", "menu_custom_parameters"},
{126, "System Parameters", p(125), 10, "ir.actions.act_window,107", "", "base", "menu_ir_config_parameter"},
// Scheduled Actions
{128, "Automation", p(102), 25, "", "", "base", "menu_custom_automation"},
{129, "Scheduled Actions", p(128), 10, "ir.actions.act_window,108", "", "base", "menu_ir_cron"},
// User Interface
{130, "User Interface", p(102), 30, "", "", "base", "menu_custom_user_interface"},
{131, "Views", p(130), 10, "ir.actions.act_window,109", "", "base", "menu_ir_ui_view"},
{132, "Actions", p(130), 20, "ir.actions.act_window,110", "", "base", "menu_ir_act_window"},
{133, "Menus", p(130), 30, "ir.actions.act_window,111", "", "base", "menu_ir_ui_menu"},
// Security
{135, "Security", p(102), 40, "", "", "base", "menu_custom_security"},
{136, "Access Rights", p(135), 10, "ir.actions.act_window,112", "", "base", "menu_ir_model_access"},
{137, "Record Rules", p(135), 20, "ir.actions.act_window,113", "", "base", "menu_ir_rule"},
// Logging
{140, "Logging", p(102), 50, "ir.actions.act_window,106", "", "base", "menu_ir_logging"},
}
for _, m := range menus {
@@ -627,7 +965,22 @@ func seedDemoData(ctx context.Context, tx pgx.Tx) {
('RE/2026/0003', 'out_invoice', 'posted', '2026-03-25', '2026-03-25', 5, 1, 1, 1, 13923, 11700)
ON CONFLICT DO NOTHING`)
log.Println("db: demo data loaded (8 contacts, 3 sale orders, 3 invoices)")
// CRM pipeline stages
tx.Exec(ctx, `INSERT INTO crm_stage (id, name, sequence, fold, is_won) VALUES
(1, 'New', 1, false, false),
(2, 'Qualified', 2, false, false),
(3, 'Proposition', 3, false, false),
(4, 'Won', 4, false, true)
ON CONFLICT (id) DO NOTHING`)
// CRM demo leads (partner IDs 3-5 are the first three demo companies seeded above)
tx.Exec(ctx, `INSERT INTO crm_lead (name, type, stage_id, partner_id, expected_revenue, company_id, currency_id, active, state, priority) VALUES
('Website Redesign', 'opportunity', 1, 3, 15000, 1, 1, true, 'open', '0'),
('ERP Implementation', 'opportunity', 2, 4, 45000, 1, 1, true, 'open', '0'),
('Cloud Migration', 'opportunity', 3, 5, 28000, 1, 1, true, 'open', '0')
ON CONFLICT DO NOTHING`)
log.Println("db: demo data loaded (8 contacts, 3 sale orders, 3 invoices, 4 CRM stages, 3 CRM leads)")
}
// SeedBaseData is the legacy function — redirects to setup with defaults.
@@ -654,3 +1007,45 @@ func SeedBaseData(ctx context.Context, pool *pgxpool.Pool) error {
DemoData: false,
})
}
// seedSystemParams inserts default system parameters into ir_config_parameter.
// Mirrors: odoo/addons/base/data/ir_config_parameter_data.xml
func seedSystemParams(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding system parameters...")
// Ensure unique constraint on key column for ON CONFLICT to work
tx.Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS ir_config_parameter_key_uniq ON ir_config_parameter (key)`)
// Generate a random UUID for database.uuid
dbUUID := generateUUID()
params := []struct {
key string
value string
}{
{"web.base.url", "http://localhost:8069"},
{"database.uuid", dbUUID},
{"base.login_cooldown_after", "10"},
{"base.login_cooldown_duration", "60"},
}
for _, p := range params {
tx.Exec(ctx,
`INSERT INTO ir_config_parameter (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING`, p.key, p.value)
}
log.Printf("db: seeded %d system parameters", len(params))
}
// generateUUID creates a random UUID v4 string.
func generateUUID() string {
b := make([]byte, 16)
rand.Read(b)
// Set UUID version 4 bits
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}