diff --git a/pkg/server/action.go b/pkg/server/action.go index 574e73f..6d82811 100644 --- a/pkg/server/action.go +++ b/pkg/server/action.go @@ -2,7 +2,9 @@ package server import ( "encoding/json" + "fmt" "net/http" + "strings" ) // handleLoadBreadcrumbs returns breadcrumb data for the current navigation path. @@ -22,8 +24,12 @@ func (s *Server) handleLoadBreadcrumbs(w http.ResponseWriter, r *http.Request) { s.writeJSONRPC(w, req.ID, []interface{}{}, nil) } -// handleActionLoad loads an action definition by ID. +// handleActionLoad loads an action definition by ID from the database. // Mirrors: odoo/addons/web/controllers/action.py Action.load() +// +// The action_id can be: +// - An integer (database ID): SELECT directly from ir_act_window +// - A string (XML ID like "base.action_res_users"): resolve via ir_model_data first func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -42,258 +48,162 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) { } json.Unmarshal(req.Params, ¶ms) - // Parse action_id from params (can be float64 from JSON or string) - actionID := 0 + ctx := r.Context() + var actionID int64 + switch v := params.ActionID.(type) { case float64: - actionID = int(v) + actionID = int64(v) case string: - // Try to parse numeric string - for _, c := range v { - if c >= '0' && c <= '9' { - actionID = actionID*10 + int(c-'0') - } else { - actionID = 0 - break + // Try numeric string first + if id, ok := parseNumericString(v); ok { + actionID = id + } else { + // XML ID: "module.name" → look up in ir_model_data + parts := strings.SplitN(v, ".", 2) + if len(parts) == 2 { + err := s.pool.QueryRow(ctx, + `SELECT res_id FROM ir_model_data + WHERE module = $1 AND name = $2 AND model = 'ir.actions.act_window'`, + parts[0], parts[1]).Scan(&actionID) + if err != nil { + s.writeJSONRPC(w, req.ID, nil, &RPCError{ + Code: -32000, + Message: fmt.Sprintf("Action not found: %s", v), + }) + return + } } } } - // Action definitions by ID - actions := map[int]map[string]interface{}{ - 1: { - "id": 1, - "type": "ir.actions.act_window", - "name": "Contacts", - "res_model": "res.partner", - "view_mode": "list,kanban,form", - "views": [][]interface{}{{nil, "list"}, {nil, "kanban"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "contacts.action_contacts", - }, - 2: { - "id": 2, - "type": "ir.actions.act_window", - "name": "Invoices", - "res_model": "account.move", - "view_mode": "list,form", - "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": `[("move_type","in",["out_invoice","out_refund"])]`, - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "account.action_move_out_invoice_type", - }, - 3: { - "id": 3, - "type": "ir.actions.act_window", - "name": "Sale Orders", - "res_model": "sale.order", - "view_mode": "list,form", - "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "sale.action_quotations_with_onboarding", - }, - 4: { - "id": 4, - "type": "ir.actions.act_window", - "name": "CRM Pipeline", - "res_model": "crm.lead", - "view_mode": "kanban,list,form", - "views": [][]interface{}{{nil, "kanban"}, {nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "crm.crm_lead_all_pipeline", - }, - 5: { - "id": 5, - "type": "ir.actions.act_window", - "name": "Transfers", - "res_model": "stock.picking", - "view_mode": "list,form", - "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "stock.action_picking_tree_all", - }, - 6: { - "id": 6, - "type": "ir.actions.act_window", - "name": "Products", - "res_model": "product.template", - "view_mode": "list,form", - "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "stock.action_product_template", - }, - 7: { - "id": 7, - "type": "ir.actions.act_window", - "name": "Purchase Orders", - "res_model": "purchase.order", - "view_mode": "list,form", - "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "purchase.action_purchase_orders", - }, - 8: { - "id": 8, - "type": "ir.actions.act_window", - "name": "Employees", - "res_model": "hr.employee", - "view_mode": "list,form", - "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "hr.action_hr_employee", - }, - 9: { - "id": 9, - "type": "ir.actions.act_window", - "name": "Departments", - "res_model": "hr.department", - "view_mode": "list,form", - "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "hr.action_hr_department", - }, - 10: { - "id": 10, - "type": "ir.actions.act_window", - "name": "Projects", - "res_model": "project.project", - "view_mode": "list,form", - "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "project.action_project", - }, - 11: { - "id": 11, - "type": "ir.actions.act_window", - "name": "Tasks", - "res_model": "project.task", - "view_mode": "list,form", - "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "help": "", - "xml_id": "project.action_project_task", - }, - 12: { - "id": 12, - "type": "ir.actions.act_window", - "name": "Vehicles", - "res_model": "fleet.vehicle", - "view_mode": "list,form", - "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, - "search_view_id": false, - "domain": "[]", - "context": "{}", - "target": "current", - "limit": 80, - "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", - }, + if actionID == 0 { + s.writeJSONRPC(w, req.ID, nil, &RPCError{ + Code: -32000, + Message: "Invalid action_id", + }) + return } - action, ok := actions[actionID] - if !ok { - // Default to Contacts if unknown action ID - action = actions[1] + // Load action from ir_act_window table + var ( + id int64 + name string + actType string + resModel string + viewMode string + resID *int64 + domain *string + actCtx *string + target *string + limit *int + searchVID *string + help *string + ) + + err := s.pool.QueryRow(ctx, + `SELECT id, name, type, res_model, view_mode, + res_id, domain, context, target, "limit", + search_view_id, help + FROM ir_act_window WHERE id = $1`, actionID, + ).Scan(&id, &name, &actType, &resModel, &viewMode, + &resID, &domain, &actCtx, &target, &limit, + &searchVID, &help) + + if err != nil { + s.writeJSONRPC(w, req.ID, nil, &RPCError{ + Code: -32000, + Message: fmt.Sprintf("Action %d not found", actionID), + }) + return + } + + // Look up xml_id from ir_model_data + xmlID := "" + _ = s.pool.QueryRow(ctx, + `SELECT module || '.' || name FROM ir_model_data + WHERE model = 'ir.actions.act_window' AND res_id = $1 + LIMIT 1`, id).Scan(&xmlID) + + // Build views array from view_mode string (e.g. "list,kanban,form" → [[nil,"list"],[nil,"kanban"],[nil,"form"]]) + views := buildViewsFromMode(viewMode) + + // Assemble action response in the format the webclient expects + action := map[string]interface{}{ + "id": id, + "type": coalesce(actType, "ir.actions.act_window"), + "name": name, + "res_model": resModel, + "view_mode": coalesce(viewMode, "list,form"), + "views": views, + "search_view_id": false, + "domain": coalesce(deref(domain), "[]"), + "context": coalesce(deref(actCtx), "{}"), + "target": coalesce(deref(target), "current"), + "limit": coalesceInt(limit, 80), + "help": deref(help), + "xml_id": xmlID, + } + + // Include res_id if set (for single-record actions like Settings) + if resID != nil && *resID != 0 { + action["res_id"] = *resID } s.writeJSONRPC(w, req.ID, action, nil) } + +// parseNumericString tries to parse a string as a positive integer. +func parseNumericString(s string) (int64, bool) { + if len(s) == 0 { + return 0, false + } + var n int64 + for _, c := range s { + if c < '0' || c > '9' { + return 0, false + } + n = n*10 + int64(c-'0') + } + return n, true +} + +// buildViewsFromMode converts a view_mode string like "list,kanban,form" +// into the webclient format: [[nil,"list"],[nil,"kanban"],[nil,"form"]]. +func buildViewsFromMode(viewMode string) [][]interface{} { + modes := strings.Split(viewMode, ",") + views := make([][]interface{}, 0, len(modes)) + for _, m := range modes { + m = strings.TrimSpace(m) + if m != "" { + views = append(views, []interface{}{nil, m}) + } + } + return views +} + +// coalesce returns the first non-empty string. +func coalesce(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// coalesceInt returns *p if non-nil, otherwise the fallback. +func coalesceInt(p *int, fallback int) int { + if p != nil { + return *p + } + return fallback +} + +// deref returns the value of a string pointer, or "" if nil. +func deref(p *string) string { + if p != nil { + return *p + } + return "" +} diff --git a/pkg/server/menus.go b/pkg/server/menus.go index f640cbe..d887d20 100644 --- a/pkg/server/menus.go +++ b/pkg/server/menus.go @@ -1,379 +1,177 @@ package server import ( + "context" "encoding/json" + "fmt" + "log" "net/http" + "strconv" + "strings" ) +// menuRow holds a single row from the ir_ui_menu table joined with ir_model_data. +type menuRow struct { + ID int + Name string + ParentID *int // NULL for top-level menus + Sequence int + Action *string // e.g. "ir.actions.act_window,1" + WebIcon *string + XMLID string // e.g. "contacts.menu_contacts" from ir_model_data +} + // handleLoadMenus returns the menu tree for the webclient. // Mirrors: odoo/addons/web/controllers/home.py Home.web_load_menus() +// +// Loads menus from the ir_ui_menu table, builds the parent/child tree, +// and returns the JSON map keyed by string IDs that the webclient expects. func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=3600") - // Build menu tree from database or hardcoded defaults - menus := map[string]interface{}{ - "root": map[string]interface{}{ - "id": "root", - "name": "root", - "children": []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 100}, - "appID": false, - "xmlid": "", - "actionID": false, - "actionModel": false, - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - // Contacts - "1": map[string]interface{}{ - "id": 1, - "name": "Contacts", - "children": []int{10}, - "appID": 1, - "xmlid": "contacts.menu_contacts", - "actionID": 1, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": "fa-address-book,#71639e,#FFFFFF", - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "10": map[string]interface{}{ - "id": 10, - "name": "Contacts", - "children": []int{}, - "appID": 1, - "xmlid": "contacts.menu_contacts_list", - "actionID": 1, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - // Invoicing - "2": map[string]interface{}{ - "id": 2, - "name": "Invoicing", - "children": []int{20}, - "appID": 2, - "xmlid": "account.menu_finance", - "actionID": 2, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": "fa-book,#71639e,#FFFFFF", - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "20": map[string]interface{}{ - "id": 20, - "name": "Invoices", - "children": []int{}, - "appID": 2, - "xmlid": "account.menu_finance_invoices", - "actionID": 2, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - // Sales - "3": map[string]interface{}{ - "id": 3, - "name": "Sales", - "children": []int{30}, - "appID": 3, - "xmlid": "sale.menu_sale_root", - "actionID": 3, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": "fa-bar-chart,#71639e,#FFFFFF", - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "30": map[string]interface{}{ - "id": 30, - "name": "Orders", - "children": []int{}, - "appID": 3, - "xmlid": "sale.menu_sale_orders", - "actionID": 3, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - // CRM - "4": map[string]interface{}{ - "id": 4, - "name": "CRM", - "children": []int{40}, - "appID": 4, - "xmlid": "crm.menu_crm_root", - "actionID": 4, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": "fa-star,#71639e,#FFFFFF", - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "40": map[string]interface{}{ - "id": 40, - "name": "Pipeline", - "children": []int{}, - "appID": 4, - "xmlid": "crm.menu_crm_pipeline", - "actionID": 4, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - // Inventory / Stock - "5": map[string]interface{}{ - "id": 5, - "name": "Inventory", - "children": []int{50, 51}, - "appID": 5, - "xmlid": "stock.menu_stock_root", - "actionID": 5, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": "fa-cubes,#71639e,#FFFFFF", - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "50": map[string]interface{}{ - "id": 50, - "name": "Transfers", - "children": []int{}, - "appID": 5, - "xmlid": "stock.menu_stock_transfers", - "actionID": 5, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "51": map[string]interface{}{ - "id": 51, - "name": "Products", - "children": []int{}, - "appID": 5, - "xmlid": "stock.menu_stock_products", - "actionID": 6, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - // Purchase - "6": map[string]interface{}{ - "id": 6, - "name": "Purchase", - "children": []int{60}, - "appID": 6, - "xmlid": "purchase.menu_purchase_root", - "actionID": 7, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": "fa-shopping-cart,#71639e,#FFFFFF", - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "60": map[string]interface{}{ - "id": 60, - "name": "Purchase Orders", - "children": []int{}, - "appID": 6, - "xmlid": "purchase.menu_purchase_orders", - "actionID": 7, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - // Employees / HR - "7": map[string]interface{}{ - "id": 7, - "name": "Employees", - "children": []int{70, 71}, - "appID": 7, - "xmlid": "hr.menu_hr_root", - "actionID": 8, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": "fa-users,#71639e,#FFFFFF", - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "70": map[string]interface{}{ - "id": 70, - "name": "Employees", - "children": []int{}, - "appID": 7, - "xmlid": "hr.menu_hr_employees", - "actionID": 8, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "71": map[string]interface{}{ - "id": 71, - "name": "Departments", - "children": []int{}, - "appID": 7, - "xmlid": "hr.menu_hr_departments", - "actionID": 9, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - // Project - "8": map[string]interface{}{ - "id": 8, - "name": "Project", - "children": []int{80, 81}, - "appID": 8, - "xmlid": "project.menu_project_root", - "actionID": 10, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": "fa-puzzle-piece,#71639e,#FFFFFF", - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "80": map[string]interface{}{ - "id": 80, - "name": "Projects", - "children": []int{}, - "appID": 8, - "xmlid": "project.menu_projects", - "actionID": 10, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "81": map[string]interface{}{ - "id": 81, - "name": "Tasks", - "children": []int{}, - "appID": 8, - "xmlid": "project.menu_project_tasks", - "actionID": 11, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - // Fleet - "9": map[string]interface{}{ - "id": 9, - "name": "Fleet", - "children": []int{90}, - "appID": 9, - "xmlid": "fleet.menu_fleet_root", - "actionID": 12, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": "fa-car,#71639e,#FFFFFF", - "webIconData": nil, - "webIconDataMimetype": nil, - "backgroundImage": nil, - }, - "90": map[string]interface{}{ - "id": 90, - "name": "Vehicles", - "children": []int{}, - "appID": 9, - "xmlid": "fleet.menu_fleet_vehicles", - "actionID": 12, - "actionModel": "ir.actions.act_window", - "actionPath": false, - "webIcon": nil, - "webIconData": nil, - "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, - }, + ctx := r.Context() + menus, err := s.loadWebMenus(ctx) + if err != nil { + log.Printf("menus: error loading menus: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return } json.NewEncoder(w).Encode(menus) } + +// loadWebMenus queries ir_ui_menu and builds the JSON structure the webclient expects. +// Mirrors: odoo/addons/base/models/ir_ui_menu.py IrUiMenu.load_web_menus() +func (s *Server) loadWebMenus(ctx context.Context) (map[string]interface{}, error) { + // Query all active menus, joined with ir_model_data for the xmlid. + rows, err := s.pool.Query(ctx, ` + SELECT m.id, m.name, m.parent_id, m.sequence, m.action, m.web_icon, + COALESCE(d.module || '.' || d.name, '') AS xmlid + FROM ir_ui_menu m + LEFT JOIN ir_model_data d + ON d.model = 'ir.ui.menu' AND d.res_id = m.id + WHERE m.active = true + ORDER BY m.sequence, m.id`) + if err != nil { + return nil, fmt.Errorf("query ir_ui_menu: %w", err) + } + defer rows.Close() + + var allMenus []menuRow + for rows.Next() { + var mr menuRow + if err := rows.Scan(&mr.ID, &mr.Name, &mr.ParentID, &mr.Sequence, &mr.Action, &mr.WebIcon, &mr.XMLID); err != nil { + return nil, fmt.Errorf("scan menu row: %w", err) + } + allMenus = append(allMenus, mr) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate menu rows: %w", err) + } + + // Build children map: parent_id → list of child IDs + childrenOf := make(map[int][]int) + for _, m := range allMenus { + if m.ParentID != nil { + childrenOf[*m.ParentID] = append(childrenOf[*m.ParentID], m.ID) + } + } + + // Find the root app ID for each menu by walking up to the top-level ancestor. + // Top-level menus (parent_id IS NULL) are their own appID. + menuByID := make(map[int]*menuRow, len(allMenus)) + for i := range allMenus { + menuByID[allMenus[i].ID] = &allMenus[i] + } + + appIDOf := func(id int) int { + cur := id + for { + m, ok := menuByID[cur] + if !ok || m.ParentID == nil { + return cur + } + cur = *m.ParentID + } + } + + // Build the result map + result := make(map[string]interface{}, len(allMenus)+1) + + // Collect top-level menu IDs (parent_id IS NULL) for the root entry + var rootChildren []int + for _, m := range allMenus { + if m.ParentID == nil { + rootChildren = append(rootChildren, m.ID) + } + } + + // "root" pseudo-entry + result["root"] = map[string]interface{}{ + "id": "root", + "name": "root", + "children": rootChildren, + "appID": false, + "xmlid": "", + "actionID": false, + "actionModel": false, + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + } + + // Each menu entry + for _, m := range allMenus { + children := childrenOf[m.ID] + if children == nil { + children = []int{} + } + + actionID, actionModel := parseMenuAction(m.Action) + appID := appIDOf(m.ID) + + var webIcon interface{} + if m.WebIcon != nil && *m.WebIcon != "" { + webIcon = *m.WebIcon + } + + result[strconv.Itoa(m.ID)] = map[string]interface{}{ + "id": m.ID, + "name": m.Name, + "children": children, + "appID": appID, + "xmlid": m.XMLID, + "actionID": actionID, + "actionModel": actionModel, + "actionPath": false, + "webIcon": webIcon, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + } + } + + return result, nil +} + +// parseMenuAction parses the Odoo action reference format "ir.actions.act_window,123" +// into an action ID (int or false) and action model (string or false). +// Mirrors: odoo/addons/base/models/ir_ui_menu.py _compute_action() +func parseMenuAction(action *string) (interface{}, interface{}) { + if action == nil || *action == "" { + return false, false + } + parts := strings.SplitN(*action, ",", 2) + if len(parts) != 2 { + return false, false + } + model := parts[0] + id, err := strconv.Atoi(parts[1]) + if err != nil { + return false, false + } + return id, model +} diff --git a/pkg/service/db.go b/pkg/service/db.go index f956ab2..46c1e22 100644 --- a/pkg/service/db.go +++ b/pkg/service/db.go @@ -253,16 +253,23 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err // 10. UI Views for key models seedViews(ctx, tx) - // 11. Demo data + // 11. Actions (ir_act_window + ir_model_data for XML IDs) + seedActions(ctx, tx) + + // 12. Menus (ir_ui_menu + ir_model_data for XML IDs) + seedMenus(ctx, tx) + + // 13. Demo data if cfg.DemoData { seedDemoData(ctx, tx) } - // 12. Reset sequences (each individually — pgx doesn't support multi-statement) + // 14. Reset sequences (each individually — pgx doesn't support multi-statement) seqs := []string{ "res_currency", "res_country", "res_partner", "res_company", "res_users", "ir_sequence", "account_journal", "account_account", "account_tax", "sale_order", "sale_order_line", "account_move", + "ir_act_window", "ir_model_data", "ir_ui_menu", } for _, table := range seqs { tx.Exec(ctx, fmt.Sprintf( @@ -384,6 +391,160 @@ func seedViews(ctx context.Context, tx pgx.Tx) { log.Println("db: UI views seeded") } +// seedActions inserts action records into ir_act_window and their XML IDs into ir_model_data. +// Mirrors: odoo/addons/base/data/ir_actions_data.xml, contacts/data/..., account/data/..., etc. +func seedActions(ctx context.Context, tx pgx.Tx) { + log.Println("db: seeding actions...") + + // Action definitions: id, name, res_model, view_mode, domain, context, target, limit, res_id + type actionDef struct { + ID int + Name string + ResModel string + ViewMode string + Domain string + Context string + Target string + Limit int + ResID int + // XML ID parts + Module string + XMLName string + } + + actions := []actionDef{ + {1, "Contacts", "res.partner", "list,kanban,form", "[]", "{}", "current", 80, 0, "contacts", "action_contacts"}, + {2, "Invoices", "account.move", "list,form", `[("move_type","in",["out_invoice","out_refund"])]`, "{}", "current", 80, 0, "account", "action_move_out_invoice_type"}, + {3, "Sale Orders", "sale.order", "list,form", "[]", "{}", "current", 80, 0, "sale", "action_quotations_with_onboarding"}, + {4, "CRM Pipeline", "crm.lead", "kanban,list,form", "[]", "{}", "current", 80, 0, "crm", "crm_lead_all_pipeline"}, + {5, "Transfers", "stock.picking", "list,form", "[]", "{}", "current", 80, 0, "stock", "action_picking_tree_all"}, + {6, "Products", "product.template", "list,form", "[]", "{}", "current", 80, 0, "stock", "action_product_template"}, + {7, "Purchase Orders", "purchase.order", "list,form", "[]", "{}", "current", 80, 0, "purchase", "action_purchase_orders"}, + {8, "Employees", "hr.employee", "list,form", "[]", "{}", "current", 80, 0, "hr", "action_hr_employee"}, + {9, "Departments", "hr.department", "list,form", "[]", "{}", "current", 80, 0, "hr", "action_hr_department"}, + {10, "Projects", "project.project", "list,form", "[]", "{}", "current", 80, 0, "project", "action_project"}, + {11, "Tasks", "project.task", "list,form", "[]", "{}", "current", 80, 0, "project", "action_project_task"}, + {12, "Vehicles", "fleet.vehicle", "list,form", "[]", "{}", "current", 80, 0, "fleet", "action_fleet_vehicle"}, + {100, "Settings", "res.company", "form", "[]", "{}", "current", 80, 1, "base", "action_res_company_form"}, + {101, "Users", "res.users", "list,form", "[]", "{}", "current", 80, 0, "base", "action_res_users"}, + {102, "Sequences", "ir.sequence", "list,form", "[]", "{}", "current", 80, 0, "base", "ir_sequence_form"}, + } + + for _, a := range actions { + tx.Exec(ctx, ` + INSERT INTO ir_act_window (id, name, type, res_model, view_mode, res_id, domain, context, target, "limit") + VALUES ($1, $2, 'ir.actions.act_window', $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO NOTHING`, + a.ID, a.Name, a.ResModel, a.ViewMode, a.ResID, a.Domain, a.Context, a.Target, a.Limit) + + tx.Exec(ctx, ` + INSERT INTO ir_model_data (module, name, model, res_id, noupdate) + VALUES ($1, $2, 'ir.actions.act_window', $3, true) + ON CONFLICT DO NOTHING`, + a.Module, a.XMLName, a.ID) + } + + log.Printf("db: seeded %d actions with XML IDs", len(actions)) +} + +// seedMenus creates menu records in ir_ui_menu and their XML IDs in ir_model_data. +// Mirrors: odoo/addons/*/data/*_menu.xml — menus are loaded from XML data files. +// +// The action field stores Odoo reference format: "ir.actions.act_window," +// pointing to the action IDs seeded by seedActions(). +// parent_id is NULL for top-level app menus, or references the parent menu ID. +func seedMenus(ctx context.Context, tx pgx.Tx) { + log.Println("db: seeding menus...") + + type menuDef struct { + ID int + Name string + ParentID *int // nil = top-level + Sequence int + Action string // "ir.actions.act_window," or "" + WebIcon string // FontAwesome icon for top-level menus + // XML ID parts for ir_model_data + Module string + XMLName string + } + + // Helper to create a pointer to int + p := func(id int) *int { return &id } + + menus := []menuDef{ + // ── Contacts ────────────────────────────────────────────── + {1, "Contacts", nil, 10, "ir.actions.act_window,1", "fa-address-book,#71639e,#FFFFFF", "contacts", "menu_contacts"}, + {10, "Contacts", p(1), 10, "ir.actions.act_window,1", "", "contacts", "menu_contacts_list"}, + + // ── Invoicing ──────────────────────────────────────────── + {2, "Invoicing", nil, 20, "ir.actions.act_window,2", "fa-book,#71639e,#FFFFFF", "account", "menu_finance"}, + {20, "Invoices", p(2), 10, "ir.actions.act_window,2", "", "account", "menu_finance_invoices"}, + + // ── Sales ──────────────────────────────────────────────── + {3, "Sales", nil, 30, "ir.actions.act_window,3", "fa-bar-chart,#71639e,#FFFFFF", "sale", "menu_sale_root"}, + {30, "Orders", p(3), 10, "ir.actions.act_window,3", "", "sale", "menu_sale_orders"}, + + // ── CRM ────────────────────────────────────────────────── + {4, "CRM", nil, 40, "ir.actions.act_window,4", "fa-star,#71639e,#FFFFFF", "crm", "menu_crm_root"}, + {40, "Pipeline", p(4), 10, "ir.actions.act_window,4", "", "crm", "menu_crm_pipeline"}, + + // ── Inventory ──────────────────────────────────────────── + {5, "Inventory", nil, 50, "ir.actions.act_window,5", "fa-cubes,#71639e,#FFFFFF", "stock", "menu_stock_root"}, + {50, "Transfers", p(5), 10, "ir.actions.act_window,5", "", "stock", "menu_stock_transfers"}, + {51, "Products", p(5), 20, "ir.actions.act_window,6", "", "stock", "menu_stock_products"}, + + // ── Purchase ───────────────────────────────────────────── + {6, "Purchase", nil, 60, "ir.actions.act_window,7", "fa-shopping-cart,#71639e,#FFFFFF", "purchase", "menu_purchase_root"}, + {60, "Purchase Orders", p(6), 10, "ir.actions.act_window,7", "", "purchase", "menu_purchase_orders"}, + + // ── Employees / HR ─────────────────────────────────────── + {7, "Employees", nil, 70, "ir.actions.act_window,8", "fa-users,#71639e,#FFFFFF", "hr", "menu_hr_root"}, + {70, "Employees", p(7), 10, "ir.actions.act_window,8", "", "hr", "menu_hr_employees"}, + {71, "Departments", p(7), 20, "ir.actions.act_window,9", "", "hr", "menu_hr_departments"}, + + // ── Project ────────────────────────────────────────────── + {8, "Project", nil, 80, "ir.actions.act_window,10", "fa-puzzle-piece,#71639e,#FFFFFF", "project", "menu_project_root"}, + {80, "Projects", p(8), 10, "ir.actions.act_window,10", "", "project", "menu_projects"}, + {81, "Tasks", p(8), 20, "ir.actions.act_window,11", "", "project", "menu_project_tasks"}, + + // ── Fleet ──────────────────────────────────────────────── + {9, "Fleet", nil, 90, "ir.actions.act_window,12", "fa-car,#71639e,#FFFFFF", "fleet", "menu_fleet_root"}, + {90, "Vehicles", p(9), 10, "ir.actions.act_window,12", "", "fleet", "menu_fleet_vehicles"}, + + // ── Settings ───────────────────────────────────────────── + {100, "Settings", nil, 100, "ir.actions.act_window,100", "fa-cog,#71639e,#FFFFFF", "base", "menu_administration"}, + {101, "Users & Companies", p(100), 10, "ir.actions.act_window,101", "", "base", "menu_users"}, + {102, "Technical", p(100), 20, "ir.actions.act_window,102", "", "base", "menu_custom"}, + } + + for _, m := range menus { + // Insert the menu record + var actionVal *string + if m.Action != "" { + actionVal = &m.Action + } + var webIconVal *string + if m.WebIcon != "" { + webIconVal = &m.WebIcon + } + + tx.Exec(ctx, ` + INSERT INTO ir_ui_menu (id, name, parent_id, sequence, action, web_icon, active) + VALUES ($1, $2, $3, $4, $5, $6, true) + ON CONFLICT (id) DO NOTHING`, + m.ID, m.Name, m.ParentID, m.Sequence, actionVal, webIconVal) + + // Insert the XML ID into ir_model_data + tx.Exec(ctx, ` + INSERT INTO ir_model_data (module, name, model, res_id, noupdate) + VALUES ($1, $2, 'ir.ui.menu', $3, true) + ON CONFLICT DO NOTHING`, + m.Module, m.XMLName, m.ID) + } + + log.Printf("db: seeded %d menus with XML IDs", len(menus)) +} + // seedDemoData creates example records for testing. func seedDemoData(ctx context.Context, tx pgx.Tx) { log.Println("db: loading demo data...")