Eliminate Python dependency: embed frontend assets in odoo-go

- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/
  directory (2925 files, 43MB) — no more runtime reads from Python Odoo
- Replace OdooAddonsPath config with FrontendDir pointing to local frontend/
- Rewire bundle.go, static.go, templates.go, webclient.go to read from
  frontend/ instead of external Python Odoo addons directory
- Auto-detect frontend/ and build/ dirs relative to binary in main.go
- Delete obsolete Python helper scripts (tools/*.py)

The Go server is now fully self-contained: single binary + frontend/ folder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-03-31 23:09:12 +02:00
parent 0ed29fe2fd
commit 8741282322
2933 changed files with 280644 additions and 264 deletions

View File

@@ -24,6 +24,17 @@ type Server struct {
pool *pgxpool.Pool
mux *http.ServeMux
sessions *SessionStore
// xmlTemplateBundle is the JS source for the compiled XML templates,
// generated at startup by compileXMLTemplates(). It replaces the
// pre-compiled build/js/web/static/src/xml_templates_bundle.js that
// was previously produced by tools/compile_templates.py.
xmlTemplateBundle string
// jsBundle is the concatenated JS bundle built at startup. It contains
// all JS files (except module_loader.js) plus the XML template bundle,
// served as a single file to avoid hundreds of individual HTTP requests.
jsBundle string
}
// New creates a new server instance.
@@ -34,6 +45,16 @@ func New(cfg *tools.Config, pool *pgxpool.Pool) *Server {
mux: http.NewServeMux(),
sessions: NewSessionStore(24 * time.Hour),
}
// Compile XML templates to JS at startup, replacing the Python build step.
log.Println("odoo: compiling XML templates...")
s.xmlTemplateBundle = compileXMLTemplates(cfg.FrontendDir)
// Concatenate all JS files into a single bundle served at /web/assets/bundle.js.
// This reduces ~539 individual <script> requests to 1.
log.Println("odoo: building JS bundle...")
s.jsBundle = buildJSBundle(cfg, s.xmlTemplateBundle)
s.registerRoutes()
return s
}
@@ -69,6 +90,7 @@ func (s *Server) registerRoutes() {
// Action loading
s.mux.HandleFunc("/web/action/load", s.handleActionLoad)
s.mux.HandleFunc("/web/action/load_breadcrumbs", s.handleLoadBreadcrumbs)
// Database endpoints
s.mux.HandleFunc("/web/database/list", s.handleDBList)
@@ -77,9 +99,19 @@ func (s *Server) registerRoutes() {
s.mux.HandleFunc("/web/setup", s.handleSetup)
s.mux.HandleFunc("/web/setup/install", s.handleSetupInstall)
// Image serving (placeholder for uploaded images)
s.mux.HandleFunc("/web/image", s.handleImage)
s.mux.HandleFunc("/web/image/", s.handleImage)
// Concatenated JS bundle (all modules in one file)
s.mux.HandleFunc("/web/assets/bundle.js", s.handleJSBundle)
// PWA manifest
s.mux.HandleFunc("/web/manifest.webmanifest", s.handleManifest)
// File upload
s.mux.HandleFunc("/web/binary/upload_attachment", s.handleUpload)
// Health check
s.mux.HandleFunc("/health", s.handleHealth)
@@ -109,7 +141,7 @@ func (s *Server) Start() error {
srv := &http.Server{
Addr: addr,
Handler: AuthMiddleware(s.sessions, s.mux),
Handler: LoggingMiddleware(AuthMiddleware(s.sessions, s.mux)),
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
@@ -308,18 +340,140 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
case "fields_get":
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
case "web_search_read":
return handleWebSearchRead(env, params.Model, params)
case "web_read":
return handleWebRead(env, params.Model, params)
case "web_save":
// Combined create-or-update used by the Odoo web client.
// Mirrors: odoo/addons/web/models/models.py web_save()
ids := parseIDs(params.Args)
vals := parseValuesAt(params.Args, 1)
spec, _ := params.KW["specification"].(map[string]interface{})
if len(ids) > 0 && ids[0] > 0 {
// Update existing record(s)
err := rs.Browse(ids...).Write(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
} else {
// Create new record
created, err := rs.Create(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
ids = created.IDs()
}
// Return the saved record via web_read format
readParams := CallKWParams{
Model: params.Model,
Method: "web_read",
Args: []interface{}{ids},
KW: map[string]interface{}{"specification": spec},
}
return handleWebRead(env, params.Model, readParams)
case "get_views":
return handleGetViews(env, params.Model, params)
case "onchange":
// Basic onchange: return empty value dict
return map[string]interface{}{"value": map[string]interface{}{}}, nil
// Return default values and run onchange handlers for changed fields.
// Mirrors: odoo/orm/models.py BaseModel.onchange()
model := orm.Registry.Get(params.Model)
defaults := make(orm.Values)
if model != nil {
orm.ApplyDefaults(model, defaults)
// Call model-specific DefaultGet for dynamic defaults (DB lookups etc.)
if model.DefaultGet != nil {
for k, v := range model.DefaultGet(env, nil) {
if _, exists := defaults[k]; !exists {
defaults[k] = v
}
}
}
// Run onchange handlers for changed fields
// args[1] = current record values, args[2] = list of changed field names
if len(params.Args) >= 3 {
if vals, ok := params.Args[1].(map[string]interface{}); ok {
if fieldNames, ok := params.Args[2].([]interface{}); ok {
for _, fn := range fieldNames {
fname, _ := fn.(string)
if handler, exists := model.OnchangeHandlers[fname]; exists {
updates := handler(env, vals)
for k, v := range updates {
defaults[k] = v
}
}
}
}
}
}
}
return map[string]interface{}{
"value": defaults,
}, nil
case "default_get":
// Return default values for the requested fields.
// Mirrors: odoo/orm/models.py BaseModel.default_get()
model := orm.Registry.Get(params.Model)
defaults := make(orm.Values)
if model != nil {
orm.ApplyDefaults(model, defaults)
// Call model-specific DefaultGet for dynamic defaults (DB lookups etc.)
if model.DefaultGet != nil {
for k, v := range model.DefaultGet(env, nil) {
if _, exists := defaults[k]; !exists {
defaults[k] = v
}
}
}
}
return defaults, nil
case "search_read":
domain := parseDomain(params.Args)
@@ -364,6 +518,24 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
}
return true, nil
case "search":
domain := parseDomain(params.Args)
opts := orm.SearchOpts{}
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
}
found, err := rs.Search(domain, opts)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return found.IDs(), nil
case "search_count":
domain := parseDomain(params.Args)
count, err := rs.SearchCount(domain)
@@ -386,16 +558,43 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
return result, nil
case "name_search":
// Basic name_search: search by name, return [[id, "name"], ...]
// name_search: search by name, return [[id, "name"], ...]
// Mirrors: odoo/orm/models.py BaseModel._name_search()
nameStr := ""
if len(params.Args) > 0 {
nameStr, _ = params.Args[0].(string)
}
limit := 8
domain := orm.Domain{}
if nameStr != "" {
domain = orm.And(orm.Leaf("name", "ilike", nameStr))
// Also accept name from kwargs
if nameStr == "" {
if v, ok := params.KW["name"].(string); ok {
nameStr = v
}
}
limit := 8
if v, ok := params.KW["limit"].(float64); ok {
limit = int(v)
}
operator := "ilike"
if v, ok := params.KW["operator"].(string); ok {
operator = v
}
// Build domain: name condition + additional args domain
var nodes []orm.DomainNode
if nameStr != "" {
nodes = append(nodes, orm.Leaf("name", operator, nameStr))
}
// Parse extra domain from kwargs "args"
if extraDomainRaw, ok := params.KW["args"].([]interface{}); ok && len(extraDomainRaw) > 0 {
extraDomain := parseDomain([]interface{}{extraDomainRaw})
for _, n := range extraDomain {
nodes = append(nodes, n)
}
}
domain := orm.And(nodes...)
found, err := rs.Search(domain, orm.SearchOpts{Limit: limit})
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
@@ -408,8 +607,52 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
for id, name := range names {
nameResult = append(nameResult, []interface{}{id, name})
}
if nameResult == nil {
nameResult = [][]interface{}{}
}
return nameResult, nil
case "action_archive":
ids := parseIDs(params.Args)
if len(ids) > 0 {
rs.Browse(ids...).Write(orm.Values{"active": false})
}
return true, nil
case "action_unarchive":
ids := parseIDs(params.Args)
if len(ids) > 0 {
rs.Browse(ids...).Write(orm.Values{"active": true})
}
return true, nil
case "copy":
ids := parseIDs(params.Args)
if len(ids) == 0 {
return nil, &RPCError{Code: -32000, Message: "No record to copy"}
}
// Read the original record
records, err := rs.Browse(ids[0]).Read(nil)
if err != nil || len(records) == 0 {
return nil, &RPCError{Code: -32000, Message: "Record not found"}
}
// Remove id and unique fields, create copy
vals := records[0]
delete(vals, "id")
delete(vals, "create_uid")
delete(vals, "write_uid")
delete(vals, "create_date")
delete(vals, "write_date")
// Append "(copy)" to name if it exists
if name, ok := vals["name"].(string); ok {
vals["name"] = name + " (copy)"
}
created, err := rs.Create(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return created.ID(), nil
default:
// Try registered business methods on the model
model := orm.Registry.Get(params.Model)
@@ -546,6 +789,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
// --- Helpers ---
func (s *Server) writeJSONRPC(w http.ResponseWriter, id interface{}, result interface{}, rpcErr *RPCError) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
resp := JSONRPCResponse{
JSONRPC: "2.0",
@@ -614,11 +858,17 @@ func parseIDs(args []interface{}) []int64 {
ids[i] = int64(n)
case int64:
ids[i] = n
case int32:
ids[i] = int64(n)
}
}
return ids
case []int64:
return v
case float64:
return []int64{int64(v)}
case int64:
return []int64{v}
}
return nil
}