diff --git a/odoo-server b/odoo-server
index 72e8a6b..1300e5a 100755
Binary files a/odoo-server and b/odoo-server differ
diff --git a/pkg/server/action.go b/pkg/server/action.go
index 5135e1e..574e73f 100644
--- a/pkg/server/action.go
+++ b/pkg/server/action.go
@@ -241,6 +241,52 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
"help": "",
"xml_id": "fleet.action_fleet_vehicle",
},
+ 100: {
+ "id": 100,
+ "type": "ir.actions.act_window",
+ "name": "Settings",
+ "res_model": "res.company",
+ "res_id": 1,
+ "view_mode": "form",
+ "views": [][]interface{}{{nil, "form"}},
+ "search_view_id": false,
+ "domain": "[]",
+ "context": "{}",
+ "target": "current",
+ "limit": 80,
+ "help": "",
+ "xml_id": "base.action_res_company_form",
+ },
+ 101: {
+ "id": 101,
+ "type": "ir.actions.act_window",
+ "name": "Users",
+ "res_model": "res.users",
+ "view_mode": "list,form",
+ "views": [][]interface{}{{nil, "list"}, {nil, "form"}},
+ "search_view_id": false,
+ "domain": "[]",
+ "context": "{}",
+ "target": "current",
+ "limit": 80,
+ "help": "",
+ "xml_id": "base.action_res_users",
+ },
+ 102: {
+ "id": 102,
+ "type": "ir.actions.act_window",
+ "name": "Sequences",
+ "res_model": "ir.sequence",
+ "view_mode": "list,form",
+ "views": [][]interface{}{{nil, "list"}, {nil, "form"}},
+ "search_view_id": false,
+ "domain": "[]",
+ "context": "{}",
+ "target": "current",
+ "limit": 80,
+ "help": "",
+ "xml_id": "base.ir_sequence_form",
+ },
}
action, ok := actions[actionID]
diff --git a/pkg/server/image.go b/pkg/server/image.go
index 0d5d44e..bc0ea2f 100644
--- a/pkg/server/image.go
+++ b/pkg/server/image.go
@@ -2,16 +2,54 @@ package server
import (
"net/http"
+ "strings"
)
-// handleImage serves placeholder images for model records.
-// The real Odoo serves actual uploaded images from ir.attachment.
-// For now, return a 1x1 transparent PNG placeholder.
+// handleImage serves images for model records.
+// Mirrors: odoo/addons/web/controllers/binary.py content_image()
+// For now, return SVG placeholders that look like real Odoo avatars.
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
- // 1x1 transparent PNG
+ field := r.URL.Query().Get("field")
+ model := r.URL.Query().Get("model")
+
+ // Also parse path-style URLs: /web/image/res.partner/2/avatar_128
+ path := strings.TrimPrefix(r.URL.Path, "/web/image/")
+ if model == "" && path != "" && path != r.URL.Path {
+ pathParts := strings.Split(path, "/")
+ if len(pathParts) >= 3 {
+ model = pathParts[0]
+ field = pathParts[2]
+ } else if len(pathParts) >= 1 {
+ model = pathParts[0]
+ }
+ }
+
+ // Company logo: return a styled SVG with company initial
+ if model == "res.company" && (field == "logo" || field == "logo_web") {
+ w.Header().Set("Content-Type", "image/svg+xml")
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write([]byte(``))
+ return
+ }
+
+ // User avatar: return a colored circle with initial
+ if (model == "res.partner" || model == "res.users") &&
+ (field == "avatar_128" || field == "avatar_256" || field == "image_128" || field == "image_256" || strings.HasPrefix(field, "avatar") || strings.HasPrefix(field, "image")) {
+ w.Header().Set("Content-Type", "image/svg+xml")
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write([]byte(``))
+ return
+ }
+
+ // Default: 1x1 transparent PNG
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=3600")
- // Minimal valid 1x1 transparent PNG (67 bytes)
png := []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
diff --git a/pkg/server/menus.go b/pkg/server/menus.go
index c2e67b8..f640cbe 100644
--- a/pkg/server/menus.go
+++ b/pkg/server/menus.go
@@ -16,7 +16,7 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) {
"root": map[string]interface{}{
"id": "root",
"name": "root",
- "children": []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
+ "children": []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 100},
"appID": false,
"xmlid": "",
"actionID": false,
@@ -330,6 +330,49 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) {
"webIconDataMimetype": nil,
"backgroundImage": nil,
},
+ // Settings
+ "100": map[string]interface{}{
+ "id": 100,
+ "name": "Settings",
+ "children": []int{101, 102},
+ "appID": 100,
+ "xmlid": "base.menu_administration",
+ "actionID": 100,
+ "actionModel": "ir.actions.act_window",
+ "actionPath": false,
+ "webIcon": "fa-cog,#71639e,#FFFFFF",
+ "webIconData": nil,
+ "webIconDataMimetype": nil,
+ "backgroundImage": nil,
+ },
+ "101": map[string]interface{}{
+ "id": 101,
+ "name": "Users & Companies",
+ "children": []int{},
+ "appID": 100,
+ "xmlid": "base.menu_users",
+ "actionID": 101,
+ "actionModel": "ir.actions.act_window",
+ "actionPath": false,
+ "webIcon": nil,
+ "webIconData": nil,
+ "webIconDataMimetype": nil,
+ "backgroundImage": nil,
+ },
+ "102": map[string]interface{}{
+ "id": 102,
+ "name": "Technical",
+ "children": []int{},
+ "appID": 100,
+ "xmlid": "base.menu_custom",
+ "actionID": 102,
+ "actionModel": "ir.actions.act_window",
+ "actionPath": false,
+ "webIcon": nil,
+ "webIconData": nil,
+ "webIconDataMimetype": nil,
+ "backgroundImage": nil,
+ },
}
json.NewEncoder(w).Encode(menus)
diff --git a/pkg/server/middleware.go b/pkg/server/middleware.go
index 6ffd091..41344e7 100644
--- a/pkg/server/middleware.go
+++ b/pkg/server/middleware.go
@@ -47,6 +47,7 @@ func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
if path == "/health" ||
path == "/web/login" ||
path == "/web/session/authenticate" ||
+ path == "/web/session/logout" ||
strings.HasPrefix(path, "/web/database/") ||
path == "/web/webclient/version_info" ||
strings.Contains(path, "/static/") {
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 60998e5..984b0ec 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -119,6 +119,9 @@ func (s *Server) registerRoutes() {
// File upload
s.mux.HandleFunc("/web/binary/upload_attachment", s.handleUpload)
+ // Logout
+ s.mux.HandleFunc("/web/session/logout", s.handleLogout)
+
// Health check
s.mux.HandleFunc("/health", s.handleHealth)
@@ -796,6 +799,22 @@ func (s *Server) handleVersionInfo(w http.ResponseWriter, r *http.Request) {
}, nil)
}
+// handleLogout destroys the session and redirects to login.
+// Mirrors: odoo/addons/web/controllers/home.py Home.web_logout()
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+ if cookie, err := r.Cookie("session_id"); err == nil && cookie.Value != "" {
+ s.sessions.Delete(cookie.Value)
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: "session_id",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ })
+ http.Redirect(w, r, "/web/login", http.StatusFound)
+}
+
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
err := s.pool.Ping(context.Background())
if err != nil {