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:
Marc
2026-03-31 01:45:09 +02:00
commit 0ed29fe2fd
90 changed files with 12133 additions and 0 deletions

173
pkg/server/views.go Normal file
View File

@@ -0,0 +1,173 @@
package server
import (
"fmt"
"strings"
"odoo-go/pkg/orm"
)
// handleGetViews implements the get_views method.
// Mirrors: odoo/addons/base/models/ir_ui_view.py get_views()
func handleGetViews(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) {
// Parse views list: [[false, "list"], [false, "form"], [false, "search"]]
var viewRequests [][]interface{}
if len(params.Args) > 0 {
if vr, ok := params.Args[0].([]interface{}); ok {
viewRequests = make([][]interface{}, len(vr))
for i, v := range vr {
if pair, ok := v.([]interface{}); ok {
viewRequests[i] = pair
}
}
}
}
// Also check kwargs
if viewRequests == nil {
if vr, ok := params.KW["views"].([]interface{}); ok {
viewRequests = make([][]interface{}, len(vr))
for i, v := range vr {
if pair, ok := v.([]interface{}); ok {
viewRequests[i] = pair
}
}
}
}
views := make(map[string]interface{})
for _, vr := range viewRequests {
if len(vr) < 2 {
continue
}
viewType, _ := vr[1].(string)
if viewType == "" {
continue
}
// Try to load from ir_ui_view table
arch := loadViewArch(env, model, viewType)
if arch == "" {
// Generate default view
arch = generateDefaultView(model, viewType)
}
views[viewType] = map[string]interface{}{
"arch": arch,
"type": viewType,
"model": model,
"view_id": 0,
"field_parent": false,
}
}
// Build models dict with field metadata
models := map[string]interface{}{
model: map[string]interface{}{
"fields": fieldsGetForModel(model),
},
}
return map[string]interface{}{
"views": views,
"models": models,
}, nil
}
// loadViewArch tries to load a view from the ir_ui_view table.
func loadViewArch(env *orm.Environment, model, viewType string) string {
var arch string
err := env.Tx().QueryRow(env.Ctx(),
`SELECT arch FROM ir_ui_view WHERE model = $1 AND type = $2 AND active = true ORDER BY priority LIMIT 1`,
model, viewType,
).Scan(&arch)
if err != nil {
return ""
}
return arch
}
// generateDefaultView creates a minimal view XML for a model.
func generateDefaultView(modelName, viewType string) string {
m := orm.Registry.Get(modelName)
if m == nil {
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
}
switch viewType {
case "list", "tree":
return generateDefaultListView(m)
case "form":
return generateDefaultFormView(m)
case "search":
return generateDefaultSearchView(m)
case "kanban":
return generateDefaultKanbanView(m)
default:
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
}
}
func generateDefaultListView(m *orm.Model) string {
var fields []string
count := 0
for _, f := range m.Fields() {
if f.Name == "id" || !f.IsStored() || f.Name == "create_uid" || f.Name == "write_uid" ||
f.Name == "create_date" || f.Name == "write_date" || f.Type == orm.TypeBinary {
continue
}
fields = append(fields, fmt.Sprintf(`<field name="%s"/>`, f.Name))
count++
if count >= 8 {
break
}
}
return fmt.Sprintf("<list>\n %s\n</list>", strings.Join(fields, "\n "))
}
func generateDefaultFormView(m *orm.Model) string {
var fields []string
for _, f := range m.Fields() {
if f.Name == "id" || f.Name == "create_uid" || f.Name == "write_uid" ||
f.Name == "create_date" || f.Name == "write_date" || f.Type == orm.TypeBinary {
continue
}
if f.Type == orm.TypeOne2many || f.Type == orm.TypeMany2many {
continue // Skip relational fields in default form
}
fields = append(fields, fmt.Sprintf(` <field name="%s"/>`, f.Name))
if len(fields) >= 20 {
break
}
}
return fmt.Sprintf("<form>\n <sheet>\n <group>\n%s\n </group>\n </sheet>\n</form>",
strings.Join(fields, "\n"))
}
func generateDefaultSearchView(m *orm.Model) string {
var fields []string
// Add name field if it exists
if f := m.GetField("name"); f != nil {
fields = append(fields, `<field name="name"/>`)
}
if f := m.GetField("email"); f != nil {
fields = append(fields, `<field name="email"/>`)
}
if len(fields) == 0 {
fields = append(fields, `<field name="id"/>`)
}
return fmt.Sprintf("<search>\n %s\n</search>", strings.Join(fields, "\n "))
}
func generateDefaultKanbanView(m *orm.Model) string {
nameField := "name"
if f := m.GetField("name"); f == nil {
nameField = "id"
}
return fmt.Sprintf(`<kanban>
<templates>
<t t-name="card">
<field name="%s"/>
</t>
</templates>
</kanban>`, nameField)
}