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:
377
pkg/orm/field.go
Normal file
377
pkg/orm/field.go
Normal file
@@ -0,0 +1,377 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Field defines a model field.
|
||||
// Mirrors: odoo/orm/fields.py Field class
|
||||
//
|
||||
// Odoo field declaration:
|
||||
//
|
||||
// name = fields.Char(string='Name', required=True, index=True)
|
||||
//
|
||||
// Go equivalent:
|
||||
//
|
||||
// Char("name", FieldOpts{String: "Name", Required: true, Index: true})
|
||||
type Field struct {
|
||||
Name string // Technical name (e.g., "name", "partner_id")
|
||||
Type FieldType // Field type (TypeChar, TypeMany2one, etc.)
|
||||
String string // Human-readable label
|
||||
|
||||
// Constraints
|
||||
Required bool
|
||||
Readonly bool
|
||||
Index bool // Create database index
|
||||
Unique bool // Unique constraint (not in Odoo, but useful)
|
||||
Size int // Max length for Char fields
|
||||
Default Value // Default value or function name
|
||||
Help string // Tooltip / help text
|
||||
|
||||
// Computed fields
|
||||
// Mirrors: odoo/orm/fields.py Field.compute, Field.inverse, Field.depends
|
||||
Compute string // Method name to compute this field
|
||||
Inverse string // Method name to write back computed value
|
||||
Depends []string // Field names this compute depends on
|
||||
Store bool // Store computed value in DB (default: false for computed)
|
||||
Precompute bool // Compute before first DB flush
|
||||
|
||||
// Related fields
|
||||
// Mirrors: odoo/orm/fields.py Field.related
|
||||
Related string // Dot-separated path (e.g., "partner_id.name")
|
||||
|
||||
// Selection
|
||||
Selection []SelectionItem
|
||||
|
||||
// Relational fields
|
||||
// Mirrors: odoo/orm/fields_relational.py
|
||||
Comodel string // Target model name (e.g., "res.company")
|
||||
InverseField string // For One2many: field name in comodel pointing back
|
||||
Relation string // For Many2many: junction table name
|
||||
Column1 string // For Many2many: column name for this model's FK
|
||||
Column2 string // For Many2many: column name for comodel's FK
|
||||
Domain Domain // Default domain filter for relational fields
|
||||
OnDelete OnDelete // For Many2one: what happens on target deletion
|
||||
|
||||
// Monetary
|
||||
CurrencyField string // Field name holding the currency_id
|
||||
|
||||
// Translation
|
||||
Translate bool // Field supports multi-language
|
||||
|
||||
// Groups (access control)
|
||||
Groups string // Comma-separated group XML IDs
|
||||
|
||||
// Internal
|
||||
model *Model // Back-reference to owning model
|
||||
column string // SQL column name (usually same as Name)
|
||||
}
|
||||
|
||||
// Column returns the SQL column name for this field.
|
||||
func (f *Field) Column() string {
|
||||
if f.column != "" {
|
||||
return f.column
|
||||
}
|
||||
return f.Name
|
||||
}
|
||||
|
||||
// SQLType returns the PostgreSQL type for this field.
|
||||
func (f *Field) SQLType() string {
|
||||
if f.Type == TypeChar && f.Size > 0 {
|
||||
return fmt.Sprintf("varchar(%d)", f.Size)
|
||||
}
|
||||
return f.Type.SQLType()
|
||||
}
|
||||
|
||||
// IsStored returns true if this field has a database column.
|
||||
func (f *Field) IsStored() bool {
|
||||
// Computed fields without Store are not stored
|
||||
if f.Compute != "" && !f.Store {
|
||||
return false
|
||||
}
|
||||
// Related fields without Store are not stored
|
||||
if f.Related != "" && !f.Store {
|
||||
return false
|
||||
}
|
||||
return f.Type.IsStored()
|
||||
}
|
||||
|
||||
// IsRelational returns true for relational field types.
|
||||
func (f *Field) IsRelational() bool {
|
||||
return f.Type.IsRelational()
|
||||
}
|
||||
|
||||
// --- Field constructors ---
|
||||
// Mirror Odoo's fields.Char(), fields.Integer(), etc.
|
||||
|
||||
// FieldOpts holds optional parameters for field constructors.
|
||||
type FieldOpts struct {
|
||||
String string
|
||||
Required bool
|
||||
Readonly bool
|
||||
Index bool
|
||||
Size int
|
||||
Default Value
|
||||
Help string
|
||||
Compute string
|
||||
Inverse string
|
||||
Depends []string
|
||||
Store bool
|
||||
Precompute bool
|
||||
Related string
|
||||
Selection []SelectionItem
|
||||
Comodel string
|
||||
InverseField string
|
||||
Relation string
|
||||
Column1 string
|
||||
Column2 string
|
||||
Domain Domain
|
||||
OnDelete OnDelete
|
||||
CurrencyField string
|
||||
Translate bool
|
||||
Groups string
|
||||
}
|
||||
|
||||
func newField(name string, typ FieldType, opts FieldOpts) *Field {
|
||||
f := &Field{
|
||||
Name: name,
|
||||
Type: typ,
|
||||
String: opts.String,
|
||||
Required: opts.Required,
|
||||
Readonly: opts.Readonly,
|
||||
Index: opts.Index,
|
||||
Size: opts.Size,
|
||||
Default: opts.Default,
|
||||
Help: opts.Help,
|
||||
Compute: opts.Compute,
|
||||
Inverse: opts.Inverse,
|
||||
Depends: opts.Depends,
|
||||
Store: opts.Store,
|
||||
Precompute: opts.Precompute,
|
||||
Related: opts.Related,
|
||||
Selection: opts.Selection,
|
||||
Comodel: opts.Comodel,
|
||||
InverseField: opts.InverseField,
|
||||
Relation: opts.Relation,
|
||||
Column1: opts.Column1,
|
||||
Column2: opts.Column2,
|
||||
Domain: opts.Domain,
|
||||
OnDelete: opts.OnDelete,
|
||||
CurrencyField: opts.CurrencyField,
|
||||
Translate: opts.Translate,
|
||||
Groups: opts.Groups,
|
||||
column: name,
|
||||
}
|
||||
if f.String == "" {
|
||||
f.String = name
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Char creates a character field. Mirrors: fields.Char
|
||||
func Char(name string, opts FieldOpts) *Field {
|
||||
return newField(name, TypeChar, opts)
|
||||
}
|
||||
|
||||
// Text creates a text field. Mirrors: fields.Text
|
||||
func Text(name string, opts FieldOpts) *Field {
|
||||
return newField(name, TypeText, opts)
|
||||
}
|
||||
|
||||
// HTML creates an HTML field. Mirrors: fields.Html
|
||||
func HTML(name string, opts FieldOpts) *Field {
|
||||
return newField(name, TypeHTML, opts)
|
||||
}
|
||||
|
||||
// Integer creates an integer field. Mirrors: fields.Integer
|
||||
func Integer(name string, opts FieldOpts) *Field {
|
||||
return newField(name, TypeInteger, opts)
|
||||
}
|
||||
|
||||
// Float creates a float field. Mirrors: fields.Float
|
||||
func Float(name string, opts FieldOpts) *Field {
|
||||
return newField(name, TypeFloat, opts)
|
||||
}
|
||||
|
||||
// Monetary creates a monetary field. Mirrors: fields.Monetary
|
||||
func Monetary(name string, opts FieldOpts) *Field {
|
||||
return newField(name, TypeMonetary, opts)
|
||||
}
|
||||
|
||||
// Boolean creates a boolean field. Mirrors: fields.Boolean
|
||||
func Boolean(name string, opts FieldOpts) *Field {
|
||||
return newField(name, TypeBoolean, opts)
|
||||
}
|
||||
|
||||
// Date creates a date field. Mirrors: fields.Date
|
||||
func Date(name string, opts FieldOpts) *Field {
|
||||
return newField(name, TypeDate, opts)
|
||||
}
|
||||
|
||||
// Datetime creates a datetime field. Mirrors: fields.Datetime
|
||||
func Datetime(name string, opts FieldOpts) *Field {
|
||||
return newField(name, TypeDatetime, opts)
|
||||
}
|
||||
|
||||
// Binary creates a binary field. Mirrors: fields.Binary
|
||||
func Binary(name string, opts FieldOpts) *Field {
|
||||
return newField(name, TypeBinary, opts)
|
||||
}
|
||||
|
||||
// Selection creates a selection field. Mirrors: fields.Selection
|
||||
func Selection(name string, selection []SelectionItem, opts FieldOpts) *Field {
|
||||
opts.Selection = selection
|
||||
return newField(name, TypeSelection, opts)
|
||||
}
|
||||
|
||||
// Json creates a JSON field. Mirrors: fields.Json
|
||||
func Json(name string, opts FieldOpts) *Field {
|
||||
return newField(name, TypeJson, opts)
|
||||
}
|
||||
|
||||
// Many2one creates a many-to-one relational field. Mirrors: fields.Many2one
|
||||
func Many2one(name string, comodel string, opts FieldOpts) *Field {
|
||||
opts.Comodel = comodel
|
||||
if opts.OnDelete == "" {
|
||||
opts.OnDelete = OnDeleteSetNull
|
||||
}
|
||||
f := newField(name, TypeMany2one, opts)
|
||||
f.Index = true // M2O fields are always indexed in Odoo
|
||||
return f
|
||||
}
|
||||
|
||||
// One2many creates a one-to-many relational field. Mirrors: fields.One2many
|
||||
func One2many(name string, comodel string, inverseField string, opts FieldOpts) *Field {
|
||||
opts.Comodel = comodel
|
||||
opts.InverseField = inverseField
|
||||
return newField(name, TypeOne2many, opts)
|
||||
}
|
||||
|
||||
// Many2many creates a many-to-many relational field. Mirrors: fields.Many2many
|
||||
func Many2many(name string, comodel string, opts FieldOpts) *Field {
|
||||
opts.Comodel = comodel
|
||||
return newField(name, TypeMany2many, opts)
|
||||
}
|
||||
|
||||
// --- Default & Validation Helpers ---
|
||||
|
||||
// ResolveDefault returns the concrete default value for this field.
|
||||
// Handles: literal values, "today" sentinel for date/datetime fields.
|
||||
func (f *Field) ResolveDefault() Value {
|
||||
if f.Default == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := f.Default.(type) {
|
||||
case string:
|
||||
if v == "today" && (f.Type == TypeDate || f.Type == TypeDatetime) {
|
||||
return time.Now().Format("2006-01-02")
|
||||
}
|
||||
return v
|
||||
case bool, int, int64, float64:
|
||||
return v
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyDefaults sets default values on vals for any stored field that is
|
||||
// missing from vals and has a non-nil Default.
|
||||
// Mirrors: odoo/orm/models.py BaseModel._add_missing_default_values()
|
||||
func ApplyDefaults(m *Model, vals Values) {
|
||||
for _, name := range m.fieldOrder {
|
||||
f := m.fields[name]
|
||||
if name == "id" || !f.IsStored() || f.Default == nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := vals[name]; exists {
|
||||
continue
|
||||
}
|
||||
if resolved := f.ResolveDefault(); resolved != nil {
|
||||
vals[name] = resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateRequired checks that all required stored fields have a non-nil value in vals.
|
||||
// Returns an error describing the first missing required field.
|
||||
// Mirrors: odoo/orm/models.py BaseModel._check_required()
|
||||
func ValidateRequired(m *Model, vals Values, isCreate bool) error {
|
||||
for _, name := range m.fieldOrder {
|
||||
f := m.fields[name]
|
||||
if !f.Required || !f.IsStored() || name == "id" {
|
||||
continue
|
||||
}
|
||||
// Magic fields are auto-set
|
||||
if name == "create_uid" || name == "write_uid" || name == "create_date" || name == "write_date" {
|
||||
continue
|
||||
}
|
||||
if isCreate {
|
||||
val, exists := vals[name]
|
||||
if !exists || val == nil {
|
||||
return fmt.Errorf("orm: field '%s' is required on %s", name, m.Name())
|
||||
}
|
||||
}
|
||||
// On write: only check if the field is explicitly set to nil
|
||||
if !isCreate {
|
||||
val, exists := vals[name]
|
||||
if exists && val == nil {
|
||||
return fmt.Errorf("orm: field '%s' cannot be set to null on %s (required)", name, m.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// JunctionTable returns the M2M junction table name for this field.
|
||||
func (f *Field) JunctionTable() string {
|
||||
if f.Relation != "" {
|
||||
return f.Relation
|
||||
}
|
||||
if f.model == nil || f.Comodel == "" {
|
||||
return ""
|
||||
}
|
||||
comodel := Registry.Get(f.Comodel)
|
||||
if comodel == nil {
|
||||
return ""
|
||||
}
|
||||
t1, t2 := f.model.Table(), comodel.Table()
|
||||
if t1 > t2 {
|
||||
t1, t2 = t2, t1
|
||||
}
|
||||
return fmt.Sprintf("%s_%s_rel", t1, t2)
|
||||
}
|
||||
|
||||
// JunctionCol1 returns this model's FK column in the junction table.
|
||||
func (f *Field) JunctionCol1() string {
|
||||
if f.Column1 != "" {
|
||||
return f.Column1
|
||||
}
|
||||
if f.model == nil {
|
||||
return ""
|
||||
}
|
||||
col := f.model.Table() + "_id"
|
||||
// Self-referential
|
||||
comodel := Registry.Get(f.Comodel)
|
||||
if comodel != nil && f.model.Table() == comodel.Table() {
|
||||
col = f.model.Table() + "_src_id"
|
||||
}
|
||||
return col
|
||||
}
|
||||
|
||||
// JunctionCol2 returns the comodel's FK column in the junction table.
|
||||
func (f *Field) JunctionCol2() string {
|
||||
if f.Column2 != "" {
|
||||
return f.Column2
|
||||
}
|
||||
comodel := Registry.Get(f.Comodel)
|
||||
if comodel == nil {
|
||||
return ""
|
||||
}
|
||||
col := comodel.Table() + "_id"
|
||||
if f.model != nil && f.model.Table() == comodel.Table() {
|
||||
col = comodel.Table() + "_dst_id"
|
||||
}
|
||||
return col
|
||||
}
|
||||
Reference in New Issue
Block a user