Files
goodie/pkg/server/webclient.go
Marc 822a91f8cf Replace custom setup wizard with Database Manager (like Python Odoo)
Flow now mirrors Python Odoo exactly:
1. Empty DB → /web redirects to /web/database/manager
2. User fills: master_pwd, email (login), password, phone, lang, country, demo
3. Backend creates admin user, company, seeds chart of accounts
4. Auto-login → redirect to /odoo (webclient)

Removed:
- Custom /web/setup wizard
- Auto-seed on startup

Added:
- /web/database/manager (mirrors odoo/addons/web/controllers/database.py)
- /web/database/create (mirrors exp_create_database)
- Auto-login after DB creation with session cookie

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:38:16 +02:00

282 lines
9.7 KiB
Go

package server
import (
"bufio"
"embed"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"odoo-go/pkg/tools"
)
//go:embed assets_js.txt
var assetsJSFile embed.FS
//go:embed assets_css.txt
var assetsCSSFile embed.FS
//go:embed assets_xml.txt
var assetsXMLFile embed.FS
var jsFiles []string
var cssFiles []string
var xmlFiles []string
func init() {
jsFiles = loadAssetList("assets_js.txt", assetsJSFile)
cssFiles = loadAssetList("assets_css.txt", assetsCSSFile)
xmlFiles = loadAssetList("assets_xml.txt", assetsXMLFile)
}
// loadXMLTemplate reads an XML template file from the frontend directory.
func loadXMLTemplate(cfg *tools.Config, urlPath string) string {
if cfg.FrontendDir == "" {
return ""
}
rel := strings.TrimPrefix(urlPath, "/")
fullPath := filepath.Join(cfg.FrontendDir, rel)
data, err := os.ReadFile(fullPath)
if err == nil {
return string(data)
}
return ""
}
func loadAssetList(name string, fs embed.FS) []string {
data, err := fs.ReadFile(name)
if err != nil {
return nil
}
var files []string
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" && !strings.HasPrefix(line, "#") {
files = append(files, line)
}
}
return files
}
// handleWebClient serves the Odoo webclient HTML shell.
// Mirrors: odoo/addons/web/controllers/home.py Home.web_client()
func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
// Check if database needs initialization
// Mirrors: odoo/addons/web/controllers/home.py ensure_db()
if s.isSetupNeeded() {
http.Redirect(w, r, "/web/database/manager", http.StatusFound)
return
}
// Check authentication
sess := GetSession(r)
if sess == nil {
// Try cookie directly
cookie, err := r.Cookie("session_id")
if err != nil || cookie.Value == "" {
http.Redirect(w, r, "/web/login", http.StatusFound)
return
}
sess = s.sessions.Get(cookie.Value)
if sess == nil {
http.Redirect(w, r, "/web/login", http.StatusFound)
return
}
}
sessionInfo := s.buildSessionInfo(sess)
sessionInfoJSON, _ := json.Marshal(sessionInfo)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
// Build script tags: module_loader must load first (defines odoo.loader),
// then the concatenated bundle serves everything else in one request.
// We suppress transient missing-dependency errors during loading by
// temporarily replacing reportErrors with a no-op, then restore it
// after the bundle has loaded.
var scriptTags strings.Builder
cacheBuster := fmt.Sprintf("?v=%d", time.Now().Unix())
// 1) module_loader.js — must run first to define odoo.define/odoo.loader
scriptTags.WriteString(fmt.Sprintf(" <script type=\"text/javascript\" src=\"/web/static/src/module_loader.js%s\"></script>\n", cacheBuster))
// 2) Suppress transient reportErrors while the bundle loads
scriptTags.WriteString(" <script>if (odoo.loader) { odoo.loader.__origReportErrors = odoo.loader.reportErrors.bind(odoo.loader); odoo.loader.reportErrors = function() {}; }</script>\n")
// 3) The concatenated JS bundle (all other modules + XML templates)
scriptTags.WriteString(fmt.Sprintf(" <script type=\"text/javascript\" src=\"/web/assets/bundle.js%s\"></script>\n", cacheBuster))
// 4) Restore reportErrors and run a final check for genuine errors
scriptTags.WriteString(" <script>if (odoo.loader && odoo.loader.__origReportErrors) { odoo.loader.reportErrors = odoo.loader.__origReportErrors; odoo.loader.reportErrors(odoo.loader.findErrors()); }</script>\n")
// Build link tags for CSS: compiled SCSS bundle + individual CSS files
var linkTags strings.Builder
// Main compiled SCSS bundle (Bootstrap + Odoo core styles)
linkTags.WriteString(fmt.Sprintf(" <link rel=\"stylesheet\" href=\"/web/static/odoo_web.css%s\"/>\n", cacheBuster))
// Additional plain CSS files
for _, src := range cssFiles {
if strings.HasSuffix(src, ".css") {
linkTags.WriteString(fmt.Sprintf(" <link rel=\"stylesheet\" href=\"%s%s\"/>\n", src, cacheBuster))
}
}
// XML templates are compiled to JS (registerTemplate calls) and included
// in the JS bundle as xml_templates_bundle.js — no inline XML needed.
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Odoo</title>
<link rel="shortcut icon" href="/web/static/img/favicon.ico" type="image/x-icon"/>
%s
<script>
var odoo = {
csrf_token: "dummy",
debug: "assets",
__session_info__: %s,
reloadMenus: function() {
return fetch("/web/webclient/load_menus", {
method: "GET",
headers: {"Content-Type": "application/json"}
}).then(function(r) { return r.json(); });
}
};
odoo.loadMenusPromise = odoo.reloadMenus();
// Catch unhandled errors and log them
window.addEventListener('unhandledrejection', function(e) {
console.error('[odoo-go] Unhandled rejection:', e.reason);
});
// Patch OWL to prevent infinite error-dialog recursion.
window.__errorDialogCount = 0;
document.addEventListener('DOMContentLoaded', function() {
if (typeof owl !== 'undefined' && owl.App) {
var _orig = owl.App.prototype.handleError;
owl.App.prototype.handleError = function() {
window.__errorDialogCount++;
if (window.__errorDialogCount > 3) {
console.error('[odoo-go] Error dialog recursion stopped.');
return;
}
return _orig.apply(this, arguments);
};
}
});
</script>
%s</head>
<body class="o_web_client">
</body>
</html>`, linkTags.String(), sessionInfoJSON, scriptTags.String())
}
// buildSessionInfo constructs the session_info JSON object expected by the webclient.
// Mirrors: odoo/addons/web/models/ir_http.py session_info()
func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
return map[string]interface{}{
"uid": sess.UID,
"is_system": sess.UID == 1,
"is_admin": sess.UID == 1,
"is_public": false,
"is_internal_user": true,
"user_context": map[string]interface{}{
"lang": "en_US",
"tz": "UTC",
"allowed_company_ids": []int64{sess.CompanyID},
},
"db": s.config.DBName,
"registry_hash": fmt.Sprintf("odoo-go-%d", time.Now().Unix()),
"server_version": "19.0-go",
"server_version_info": []interface{}{19, 0, 0, "final", 0, "g"},
"name": sess.Login,
"username": sess.Login,
"partner_id": sess.UID + 1, // Simplified mapping
"partner_display_name": sess.Login,
"partner_write_date": "2026-01-01 00:00:00",
"quick_login": true,
"web.base.url": fmt.Sprintf("http://localhost:%d", s.config.HTTPPort),
"active_ids_limit": 20000,
"max_file_upload_size": 134217728,
"home_action_id": 1,
"current_menu": 1,
"support_url": "",
"test_mode": false,
"show_effect": true,
"currencies": map[string]interface{}{
"1": map[string]interface{}{
"id": 1, "name": "EUR", "symbol": "€",
"position": "after", "digits": []int{69, 2},
},
},
"bundle_params": map[string]interface{}{
"lang": "en_US",
"debug": "assets",
},
"user_companies": map[string]interface{}{
"current_company": sess.CompanyID,
"allowed_companies": map[string]interface{}{
fmt.Sprintf("%d", sess.CompanyID): map[string]interface{}{
"id": sess.CompanyID,
"name": "My Company",
"sequence": 10,
"child_ids": []int64{},
"parent_id": false,
"currency_id": 1,
},
},
"disallowed_ancestor_companies": map[string]interface{}{},
},
"user_settings": map[string]interface{}{
"id": 1,
"user_id": map[string]interface{}{"id": sess.UID, "display_name": sess.Login},
},
"view_info": map[string]interface{}{
"list": map[string]interface{}{"display_name": "List", "icon": "oi oi-view-list", "multi_record": true},
"form": map[string]interface{}{"display_name": "Form", "icon": "fa fa-address-card", "multi_record": false},
"kanban": map[string]interface{}{"display_name": "Kanban", "icon": "oi oi-view-kanban", "multi_record": true},
"graph": map[string]interface{}{"display_name": "Graph", "icon": "fa fa-area-chart", "multi_record": true},
"pivot": map[string]interface{}{"display_name": "Pivot", "icon": "oi oi-view-pivot", "multi_record": true},
"calendar": map[string]interface{}{"display_name": "Calendar", "icon": "fa fa-calendar", "multi_record": true},
"search": map[string]interface{}{"display_name": "Search", "icon": "oi oi-search", "multi_record": true},
},
"groups": map[string]interface{}{
"base.group_allow_export": true,
"base.group_user": true,
"base.group_system": true,
},
}
}
// handleTranslations returns empty English translations.
// Mirrors: odoo/addons/web/controllers/webclient.py translations()
func (s *Server) handleTranslations(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
json.NewEncoder(w).Encode(map[string]interface{}{
"lang": "en_US",
"hash": "odoo-go-empty",
"lang_parameters": map[string]interface{}{
"direction": "ltr",
"date_format": "%%m/%%d/%%Y",
"time_format": "%%H:%%M:%%S",
"grouping": "[3,0]",
"decimal_point": ".",
"thousands_sep": ",",
"week_start": 1,
},
"modules": map[string]interface{}{},
"multi_lang": false,
})
}