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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user