- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
472 lines
15 KiB
Go
472 lines
15 KiB
Go
package server
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"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 if post-setup wizard is needed (first login, company not configured)
|
|
if s.isPostSetupNeeded() {
|
|
if sess := GetSession(r); sess != nil {
|
|
http.Redirect(w, r, "/web/setup/wizard", 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: "%s",
|
|
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(), sess.CSRFToken, 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{} {
|
|
// Build allowed_company_ids from session (populated at login)
|
|
allowedIDs := sess.AllowedCompanyIDs
|
|
if len(allowedIDs) == 0 {
|
|
allowedIDs = []int64{sess.CompanyID}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"session_id": sess.ID,
|
|
"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": allowedIDs,
|
|
},
|
|
"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": "",
|
|
"notification_type": "email",
|
|
"display_switch_company_menu": len(allowedIDs) > 1,
|
|
"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": s.buildUserCompanies(sess.CompanyID, allowedIDs),
|
|
"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 translations for the requested language.
|
|
// Mirrors: odoo/addons/web/controllers/webclient.py translations()
|
|
//
|
|
// The web client calls this with params like {mods: ["web","base",...], lang: "de_DE"}.
|
|
// We load translations from ir_translation table and return them in Odoo's format.
|
|
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")
|
|
|
|
// Determine requested language from POST body or session context
|
|
lang := "en_US"
|
|
if r.Method == http.MethodPost {
|
|
var req JSONRPCRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
|
|
var params struct {
|
|
Mods []string `json:"mods"`
|
|
Lang string `json:"lang"`
|
|
}
|
|
if err := json.Unmarshal(req.Params, ¶ms); err == nil && params.Lang != "" {
|
|
lang = params.Lang
|
|
}
|
|
}
|
|
}
|
|
if q := r.URL.Query().Get("lang"); q != "" {
|
|
lang = q
|
|
}
|
|
|
|
// Default lang parameters (English)
|
|
langParams := 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,
|
|
}
|
|
|
|
// Try to load language parameters from res_lang
|
|
var dateFormat, timeFormat, decimalPoint, thousandsSep, direction string
|
|
err := s.pool.QueryRow(r.Context(),
|
|
`SELECT date_format, time_format, decimal_point, thousands_sep, direction
|
|
FROM res_lang WHERE code = $1 AND active = true`, lang,
|
|
).Scan(&dateFormat, &timeFormat, &decimalPoint, &thousandsSep, &direction)
|
|
if err == nil {
|
|
// Convert Go-style format markers to Python-style (double-%) for the web client
|
|
langParams["date_format"] = dateFormat
|
|
langParams["time_format"] = timeFormat
|
|
langParams["decimal_point"] = decimalPoint
|
|
langParams["thousands_sep"] = thousandsSep
|
|
langParams["direction"] = direction
|
|
}
|
|
|
|
// Load translations from ir_translation
|
|
modules := make(map[string]interface{})
|
|
multiLang := false
|
|
|
|
// Check if translations exist for this language
|
|
rows, err := s.pool.Query(r.Context(),
|
|
`SELECT COALESCE(module, ''), src, value FROM ir_translation
|
|
WHERE lang = $1 AND value != '' AND value IS NOT NULL
|
|
ORDER BY module, name`, lang)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
// Group translations by module
|
|
modMessages := make(map[string][]map[string]string)
|
|
for rows.Next() {
|
|
var module, src, value string
|
|
if err := rows.Scan(&module, &src, &value); err != nil {
|
|
continue
|
|
}
|
|
if module == "" {
|
|
module = "web"
|
|
}
|
|
modMessages[module] = append(modMessages[module], map[string]string{
|
|
"id": src,
|
|
"string": value,
|
|
})
|
|
}
|
|
for mod, msgs := range modMessages {
|
|
modules[mod] = map[string]interface{}{
|
|
"messages": msgs,
|
|
}
|
|
multiLang = true
|
|
}
|
|
}
|
|
|
|
// Check if more than one active language exists
|
|
var langCount int
|
|
if err := s.pool.QueryRow(r.Context(),
|
|
`SELECT COUNT(*) FROM res_lang WHERE active = true`).Scan(&langCount); err == nil {
|
|
if langCount > 1 {
|
|
multiLang = true
|
|
}
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"lang": lang,
|
|
"hash": fmt.Sprintf("odoo-go-%s", lang),
|
|
"lang_parameters": langParams,
|
|
"modules": modules,
|
|
"multi_lang": multiLang,
|
|
})
|
|
}
|
|
|
|
// buildUserCompanies queries company data and builds the user_companies dict
|
|
// for the session_info response. Mirrors: odoo/addons/web/models/ir_http.py
|
|
func (s *Server) buildUserCompanies(currentCompanyID int64, allowedIDs []int64) map[string]interface{} {
|
|
allowedCompanies := make(map[string]interface{})
|
|
|
|
// Batch query all companies at once
|
|
rows, err := s.pool.Query(context.Background(),
|
|
`SELECT id, COALESCE(name, 'Company'), COALESCE(currency_id, 1)
|
|
FROM res_company WHERE id = ANY($1)`, allowedIDs)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var cid, currencyID int64
|
|
var name string
|
|
if rows.Scan(&cid, &name, ¤cyID) == nil {
|
|
allowedCompanies[fmt.Sprintf("%d", cid)] = map[string]interface{}{
|
|
"id": cid,
|
|
"name": name,
|
|
"sequence": 10,
|
|
"child_ids": []int64{},
|
|
"parent_id": false,
|
|
"currency_id": currencyID,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback for any IDs not found in DB
|
|
for _, cid := range allowedIDs {
|
|
key := fmt.Sprintf("%d", cid)
|
|
if _, exists := allowedCompanies[key]; !exists {
|
|
allowedCompanies[key] = map[string]interface{}{
|
|
"id": cid, "name": fmt.Sprintf("Company %d", cid),
|
|
"sequence": 10, "child_ids": []int64{}, "parent_id": false, "currency_id": int64(1),
|
|
}
|
|
}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"current_company": currentCompanyID,
|
|
"allowed_companies": allowedCompanies,
|
|
"disallowed_ancestor_companies": map[string]interface{}{},
|
|
}
|
|
}
|
|
|
|
// handleSwitchCompany switches the active company for the current session.
|
|
func (s *Server) handleSwitchCompany(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
sess := GetSession(r)
|
|
if sess == nil {
|
|
s.writeJSONRPC(w, nil, nil, &RPCError{Code: 100, Message: "Not authenticated"})
|
|
return
|
|
}
|
|
|
|
var req JSONRPCRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
|
return
|
|
}
|
|
|
|
var params struct {
|
|
CompanyID int64 `json:"company_id"`
|
|
}
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.CompanyID == 0 {
|
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid company_id"})
|
|
return
|
|
}
|
|
|
|
// Validate company is in allowed list
|
|
allowed := false
|
|
for _, cid := range sess.AllowedCompanyIDs {
|
|
if cid == params.CompanyID {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if !allowed {
|
|
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: 403, Message: "Company not in allowed list"})
|
|
return
|
|
}
|
|
|
|
// Update session
|
|
sess.CompanyID = params.CompanyID
|
|
|
|
// Persist to DB
|
|
if s.sessions.pool != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
s.sessions.pool.Exec(ctx,
|
|
`UPDATE sessions SET company_id = $1 WHERE id = $2`, params.CompanyID, sess.ID)
|
|
}
|
|
|
|
s.writeJSONRPC(w, req.ID, map[string]interface{}{
|
|
"company_id": params.CompanyID,
|
|
"result": true,
|
|
}, nil)
|
|
}
|