Odoo ERP ported to Go — complete backend + original OWL frontend
Full port of Odoo's ERP system from Python to Go, with the original Odoo JavaScript frontend (OWL framework) running against the Go server. Backend (10,691 LoC Go): - Custom ORM: CRUD, domains→SQL with JOINs, computed fields, sequences - 93 models across 14 modules (base, account, sale, stock, purchase, hr, project, crm, fleet, product, l10n_de, google_address/translate/calendar) - Auth with bcrypt + session cookies - Setup wizard (company, SKR03 chart, admin, demo data) - Double-entry bookkeeping constraint - Sale→Invoice workflow (confirm SO → generate invoice → post) - SKR03 chart of accounts (110 accounts) + German taxes (USt/VSt) - Record rules (multi-company filter) - Google integrations as opt-in modules (Maps, Translate, Calendar) Frontend: - Odoo's original OWL webclient (503 JS modules, 378 XML templates) - JS transpiled via Odoo's js_transpiler (ES modules → odoo.define) - SCSS compiled to CSS (675KB) via dart-sass - XML templates compiled to registerTemplate() JS calls - Static file serving from Odoo source addons - Login page, session management, menu navigation - Contacts list view renders with real data from PostgreSQL Infrastructure: - 14MB single binary (CGO_ENABLED=0) - Docker Compose (Go server + PostgreSQL 16) - Zero phone-home (no outbound calls to odoo.com) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
378
pkg/orm/model.go
Normal file
378
pkg/orm/model.go
Normal file
@@ -0,0 +1,378 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Model defines an Odoo model (database table + business logic).
|
||||
// Mirrors: odoo/orm/models.py BaseModel
|
||||
//
|
||||
// Odoo:
|
||||
//
|
||||
// class ResPartner(models.Model):
|
||||
// _name = 'res.partner'
|
||||
// _description = 'Contact'
|
||||
// _order = 'name'
|
||||
// _rec_name = 'name'
|
||||
//
|
||||
// Go:
|
||||
//
|
||||
// NewModel("res.partner", ModelOpts{
|
||||
// Description: "Contact",
|
||||
// Order: "name",
|
||||
// RecName: "name",
|
||||
// })
|
||||
type Model struct {
|
||||
name string
|
||||
description string
|
||||
modelType ModelType
|
||||
table string // SQL table name
|
||||
order string // Default sort order
|
||||
recName string // Field used as display name
|
||||
inherit []string // _inherit: models to extend
|
||||
inherits map[string]string // _inherits: {parent_model: fk_field}
|
||||
auto bool // Auto-create/update table schema
|
||||
logAccess bool // Auto-create create_uid, write_uid, create_date, write_date
|
||||
|
||||
// Fields
|
||||
fields map[string]*Field
|
||||
|
||||
// Methods (registered business logic)
|
||||
methods map[string]interface{}
|
||||
|
||||
// Access control
|
||||
checkCompany bool // Enforce multi-company record rules
|
||||
|
||||
// Hooks
|
||||
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
|
||||
Constraints []ConstraintFunc // Validation constraints
|
||||
Methods map[string]MethodFunc // Named business methods
|
||||
|
||||
// Computed fields
|
||||
computes map[string]ComputeFunc // field_name → compute function
|
||||
dependencyMap map[string][]string // trigger_field → []computed_field_names
|
||||
|
||||
// Resolved
|
||||
parents []*Model // Resolved parent models from _inherit
|
||||
allFields map[string]*Field // Including fields from parents
|
||||
fieldOrder []string // Field creation order
|
||||
}
|
||||
|
||||
// ModelOpts configures a new model.
|
||||
type ModelOpts struct {
|
||||
Description string
|
||||
Type ModelType
|
||||
Table string
|
||||
Order string
|
||||
RecName string
|
||||
Inherit []string
|
||||
Inherits map[string]string
|
||||
CheckCompany bool
|
||||
}
|
||||
|
||||
// NewModel creates and registers a new model.
|
||||
// Mirrors: class MyModel(models.Model): _name = '...'
|
||||
func NewModel(name string, opts ModelOpts) *Model {
|
||||
m := &Model{
|
||||
name: name,
|
||||
description: opts.Description,
|
||||
modelType: opts.Type,
|
||||
table: opts.Table,
|
||||
order: opts.Order,
|
||||
recName: opts.RecName,
|
||||
inherit: opts.Inherit,
|
||||
inherits: opts.Inherits,
|
||||
checkCompany: opts.CheckCompany,
|
||||
auto: opts.Type != ModelAbstract,
|
||||
logAccess: true,
|
||||
fields: make(map[string]*Field),
|
||||
methods: make(map[string]interface{}),
|
||||
allFields: make(map[string]*Field),
|
||||
}
|
||||
|
||||
// Derive table name from model name if not specified.
|
||||
// Mirrors: odoo/orm/models.py BaseModel._table
|
||||
// 'res.partner' → 'res_partner'
|
||||
if m.table == "" && m.auto {
|
||||
m.table = strings.ReplaceAll(name, ".", "_")
|
||||
}
|
||||
|
||||
if m.order == "" {
|
||||
m.order = "id"
|
||||
}
|
||||
|
||||
if m.recName == "" {
|
||||
m.recName = "name"
|
||||
}
|
||||
|
||||
// Add magic fields.
|
||||
// Mirrors: odoo/orm/models.py BaseModel.MAGIC_COLUMNS
|
||||
m.addMagicFields()
|
||||
|
||||
Registry.Register(m)
|
||||
return m
|
||||
}
|
||||
|
||||
// addMagicFields adds Odoo's automatic fields to every model.
|
||||
// Mirrors: odoo/orm/models.py MAGIC_COLUMNS + LOG_ACCESS_COLUMNS
|
||||
func (m *Model) addMagicFields() {
|
||||
// id is always present
|
||||
m.AddField(Integer("id", FieldOpts{
|
||||
String: "ID",
|
||||
Readonly: true,
|
||||
}))
|
||||
|
||||
if m.logAccess {
|
||||
m.AddField(Many2one("create_uid", "res.users", FieldOpts{
|
||||
String: "Created by",
|
||||
Readonly: true,
|
||||
}))
|
||||
m.AddField(Datetime("create_date", FieldOpts{
|
||||
String: "Created on",
|
||||
Readonly: true,
|
||||
}))
|
||||
m.AddField(Many2one("write_uid", "res.users", FieldOpts{
|
||||
String: "Last Updated by",
|
||||
Readonly: true,
|
||||
}))
|
||||
m.AddField(Datetime("write_date", FieldOpts{
|
||||
String: "Last Updated on",
|
||||
Readonly: true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// AddField adds a field to this model.
|
||||
func (m *Model) AddField(f *Field) *Model {
|
||||
f.model = m
|
||||
m.fields[f.Name] = f
|
||||
m.allFields[f.Name] = f
|
||||
m.fieldOrder = append(m.fieldOrder, f.Name)
|
||||
return m
|
||||
}
|
||||
|
||||
// AddFields adds multiple fields at once.
|
||||
func (m *Model) AddFields(fields ...*Field) *Model {
|
||||
for _, f := range fields {
|
||||
m.AddField(f)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// GetField returns a field by name, or nil.
|
||||
func (m *Model) GetField(name string) *Field {
|
||||
return m.allFields[name]
|
||||
}
|
||||
|
||||
// Fields returns all fields of this model (including inherited).
|
||||
func (m *Model) Fields() map[string]*Field {
|
||||
return m.allFields
|
||||
}
|
||||
|
||||
// StoredFields returns fields that have a database column.
|
||||
func (m *Model) StoredFields() []*Field {
|
||||
var result []*Field
|
||||
for _, name := range m.fieldOrder {
|
||||
f := m.fields[name]
|
||||
if f.IsStored() {
|
||||
result = append(result, f)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Name returns the model name (e.g., "res.partner").
|
||||
func (m *Model) Name() string { return m.name }
|
||||
|
||||
// Table returns the SQL table name (e.g., "res_partner").
|
||||
func (m *Model) Table() string { return m.table }
|
||||
|
||||
// Description returns the human-readable model description.
|
||||
func (m *Model) Description() string { return m.description }
|
||||
|
||||
// Order returns the default sort expression.
|
||||
func (m *Model) Order() string { return m.order }
|
||||
|
||||
// RecName returns the field name used as display name.
|
||||
func (m *Model) RecName() string { return m.recName }
|
||||
|
||||
// IsAbstract returns true if this model has no database table.
|
||||
func (m *Model) IsAbstract() bool { return m.modelType == ModelAbstract }
|
||||
|
||||
// IsTransient returns true if this is a wizard/temporary model.
|
||||
func (m *Model) IsTransient() bool { return m.modelType == ModelTransient }
|
||||
|
||||
// ConstraintFunc validates a recordset. Returns error if constraint violated.
|
||||
// Mirrors: @api.constrains in Odoo.
|
||||
type ConstraintFunc func(rs *Recordset) error
|
||||
|
||||
// MethodFunc is a callable business method on a model.
|
||||
// Mirrors: Odoo model methods called via RPC.
|
||||
type MethodFunc func(rs *Recordset, args ...interface{}) (interface{}, error)
|
||||
|
||||
// AddConstraint registers a validation constraint.
|
||||
func (m *Model) AddConstraint(fn ConstraintFunc) *Model {
|
||||
m.Constraints = append(m.Constraints, fn)
|
||||
return m
|
||||
}
|
||||
|
||||
// RegisterMethod registers a named business logic method on this model.
|
||||
// Mirrors: Odoo's method definitions on model classes.
|
||||
func (m *Model) RegisterMethod(name string, fn MethodFunc) *Model {
|
||||
if m.Methods == nil {
|
||||
m.Methods = make(map[string]MethodFunc)
|
||||
}
|
||||
m.Methods[name] = fn
|
||||
return m
|
||||
}
|
||||
|
||||
// Extend extends this model with additional fields (like _inherit in Odoo).
|
||||
// Mirrors: class MyModelExt(models.Model): _inherit = 'res.partner'
|
||||
func (m *Model) Extend(fields ...*Field) *Model {
|
||||
for _, f := range fields {
|
||||
m.AddField(f)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// CreateTableSQL generates the CREATE TABLE statement for this model.
|
||||
// Mirrors: odoo/orm/models.py BaseModel._table_exist / init()
|
||||
func (m *Model) CreateTableSQL() string {
|
||||
if !m.auto {
|
||||
return ""
|
||||
}
|
||||
|
||||
var cols []string
|
||||
cols = append(cols, `"id" SERIAL PRIMARY KEY`)
|
||||
|
||||
for _, name := range m.fieldOrder {
|
||||
f := m.fields[name]
|
||||
if name == "id" || !f.IsStored() {
|
||||
continue
|
||||
}
|
||||
|
||||
sqlType := f.SQLType()
|
||||
if sqlType == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
col := fmt.Sprintf(" %q %s", f.Column(), sqlType)
|
||||
if f.Required {
|
||||
col += " NOT NULL"
|
||||
}
|
||||
cols = append(cols, col)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("CREATE TABLE IF NOT EXISTS %q (\n%s\n)",
|
||||
m.table,
|
||||
strings.Join(cols, ",\n"),
|
||||
)
|
||||
}
|
||||
|
||||
// ForeignKeySQL generates ALTER TABLE statements for foreign keys.
|
||||
func (m *Model) ForeignKeySQL() []string {
|
||||
var stmts []string
|
||||
for _, name := range m.fieldOrder {
|
||||
f := m.fields[name]
|
||||
if f.Type != TypeMany2one || f.Comodel == "" {
|
||||
continue
|
||||
}
|
||||
if name == "create_uid" || name == "write_uid" {
|
||||
continue // Skip magic fields, added later
|
||||
}
|
||||
comodel := Registry.Get(f.Comodel)
|
||||
if comodel == nil {
|
||||
continue
|
||||
}
|
||||
onDelete := "SET NULL"
|
||||
switch f.OnDelete {
|
||||
case OnDeleteRestrict:
|
||||
onDelete = "RESTRICT"
|
||||
case OnDeleteCascade:
|
||||
onDelete = "CASCADE"
|
||||
}
|
||||
stmt := fmt.Sprintf(
|
||||
`ALTER TABLE %q ADD CONSTRAINT %q FOREIGN KEY (%q) REFERENCES %q ("id") ON DELETE %s`,
|
||||
m.table,
|
||||
fmt.Sprintf("%s_%s_fkey", m.table, f.Column()),
|
||||
f.Column(),
|
||||
comodel.Table(),
|
||||
onDelete,
|
||||
)
|
||||
stmts = append(stmts, stmt)
|
||||
}
|
||||
return stmts
|
||||
}
|
||||
|
||||
// IndexSQL generates CREATE INDEX statements.
|
||||
func (m *Model) IndexSQL() []string {
|
||||
var stmts []string
|
||||
for _, name := range m.fieldOrder {
|
||||
f := m.fields[name]
|
||||
if !f.Index || !f.IsStored() || name == "id" {
|
||||
continue
|
||||
}
|
||||
stmt := fmt.Sprintf(
|
||||
`CREATE INDEX IF NOT EXISTS %q ON %q (%q)`,
|
||||
fmt.Sprintf("%s_%s_index", m.table, f.Column()),
|
||||
m.table,
|
||||
f.Column(),
|
||||
)
|
||||
stmts = append(stmts, stmt)
|
||||
}
|
||||
return stmts
|
||||
}
|
||||
|
||||
// Many2manyTableSQL generates junction table DDL for Many2many fields.
|
||||
func (m *Model) Many2manyTableSQL() []string {
|
||||
var stmts []string
|
||||
for _, name := range m.fieldOrder {
|
||||
f := m.fields[name]
|
||||
if f.Type != TypeMany2many {
|
||||
continue
|
||||
}
|
||||
|
||||
comodel := Registry.Get(f.Comodel)
|
||||
if comodel == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine junction table name.
|
||||
// Mirrors: odoo/orm/fields_relational.py Many2many._table_name
|
||||
relation := f.Relation
|
||||
if relation == "" {
|
||||
// Default: alphabetically sorted model tables joined by underscore
|
||||
t1, t2 := m.table, comodel.Table()
|
||||
if t1 > t2 {
|
||||
t1, t2 = t2, t1
|
||||
}
|
||||
relation = fmt.Sprintf("%s_%s_rel", t1, t2)
|
||||
}
|
||||
|
||||
col1 := f.Column1
|
||||
if col1 == "" {
|
||||
col1 = m.table + "_id"
|
||||
}
|
||||
col2 := f.Column2
|
||||
if col2 == "" {
|
||||
col2 = comodel.Table() + "_id"
|
||||
}
|
||||
// Self-referential M2M: ensure distinct column names
|
||||
if col1 == col2 {
|
||||
col1 = m.table + "_src_id"
|
||||
col2 = m.table + "_dst_id"
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf(
|
||||
"CREATE TABLE IF NOT EXISTS %q (\n"+
|
||||
" %q integer NOT NULL REFERENCES %q(id) ON DELETE CASCADE,\n"+
|
||||
" %q integer NOT NULL REFERENCES %q(id) ON DELETE CASCADE,\n"+
|
||||
" PRIMARY KEY (%q, %q)\n"+
|
||||
")",
|
||||
relation, col1, m.table, col2, comodel.Table(), col1, col2,
|
||||
)
|
||||
stmts = append(stmts, stmt)
|
||||
}
|
||||
return stmts
|
||||
}
|
||||
Reference in New Issue
Block a user