From e0d8bc81d3906802fd1fc7f1df964c7892b3a4ac Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 3 Apr 2026 13:52:30 +0200 Subject: [PATCH] PDF reports + View inheritance + Pivot/Graph views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF Reports: - /report/pdf/ endpoint with graceful degradation chain: wkhtmltopdf → headless Chrome/Chromium → HTML with window.print() - Print-friendly CSS (@page A4, margins, page-break rules) - ir.actions.report dispatch for client Print button View Inheritance: - loadViewArch now loads base + inheriting views (inherit_id) - applyViewInheritance with XPath support: //field[@name='X'] and - Positions: after, before, replace, inside - XML parser for inherit directives using encoding/xml New View Types: - Pivot view auto-generation (numeric fields as measures) - Graph view auto-generation (M2O/Selection + numeric measure) Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 250 ------------------------ pkg/server/report.go | 262 ++++++++++++++++++++++++- pkg/server/server.go | 16 +- pkg/server/views.go | 452 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 725 insertions(+), 255 deletions(-) diff --git a/TODO.md b/TODO.md index a6240f8..e69de29 100644 --- a/TODO.md +++ b/TODO.md @@ -1,250 +0,0 @@ -# odoo-go TODO — Weg zur 1:1 Paritaet mit Python Odoo - -Stand: 2026-04-02 | Go: 19.925 LOC | Python Referenz: 92.733 LOC (core) + Addons - ---- - -## Legende -- [ ] Offen -- [x] Erledigt -- ~70% = UI/Demo funktioniert, ~25% = Code-Tiefe vs Python - ---- - -## 1. ORM-Kern (aktuell ~75%) - -### Erledigt -- [x] CRUD (create/read/write/unlink) -- [x] Domain-Compiler (AND/OR/NOT, child_of, parent_of, any, dot-notation JOINs) -- [x] read_group mit Aggregation (sum/avg/min/max/count, Date-Granularitaet) -- [x] Computed Fields mit Dependency-Tracking -- [x] _inherits (Delegation: res.users→res.partner, product.product→product.template) -- [x] Related Fields (Read + Write-back) -- [x] Record Rules (domain_force parsing, global AND + group OR) -- [x] Copy/Duplicate mit IsCopyable -- [x] Constraints in Create + Write -- [x] Readonly-Skip in Write -- [x] active_test Auto-Filter in Search -- [x] Onchange mit Compute-Triggering -- [x] sanitizeFieldValue (false→nil fuer non-boolean) -- [x] name_search / name_create - -### Offen -- [ ] _inherit (Model-Extension/Mixin) — aktuell nur neue Models, kein Erweitern bestehender -- [ ] Inverse Fields (Compute write-back via benannte Methode) -- [ ] Multi-hop Related Fields (aktuell nur single-hop "fk.field") -- [ ] Batch-Create (Liste von Values statt einzeln) -- [ ] Prefetch/Lazy Loading (aktuell alles auf einmal) -- [x] SQL Constraints (UNIQUE, CHECK) -- [ ] Field-Level Groups (Feld-Sichtbarkeit nach Gruppen) -- [x] Translatable Fields (translate=True → ir.translation Lookup) -- [ ] Properties Field (JSONB key-value) -- [ ] parent_path (Hierarchie-Optimierung) -- [ ] Flush/Recompute Ordering (Python hat _recompute_todo Queue) -- [x] Environment.ref() vollstaendig -- [ ] Recordset Arithmetic (+ - & | Operatoren vollstaendig) - ---- - -## 2. Web/RPC Server (aktuell ~80%) - -### Erledigt -- [x] JSON-RPC Dispatch (/web/dataset/call_kw, call_button) -- [x] web_search_read / web_read / web_save -- [x] web_read_group / read_group -- [x] O2M Child Records in web_read Response -- [x] get_views mit Comodel-Metadaten -- [x] Onchange RPC -- [x] Action Loading (/web/action/load) -- [x] Menu Loading (/web/webclient/load_menus) -- [x] Session Management (PostgreSQL-persistent) -- [x] File Upload/Download (ir.attachment) -- [x] CSV Export (/web/export/csv) -- [x] HTML Reports (/report/html/) -- [x] Translations Endpoint -- [x] call_button mit Action-Return -- [x] get_formview_action / name_create / ir.http stub - -### Offen -- [ ] PDF Reports (wkhtmltopdf oder Go-PDF-Library) -- [ ] QWeb Template Engine (Report-Templates wie Python) -- [ ] /web/dataset/export (XLSX Export) -- [ ] /web/action/run (Server Actions ausfuehren) -- [ ] /web/view/edit_custom (Custom View Saves) -- [ ] /web/model/get_definitions -- [ ] Kanban auto_unfold (Gruppen standardmaessig offen mit Records) -- [ ] Binary Field Serving (Bilder in Form-Views) -- [ ] Websocket/Longpoll (Bus fuer Live-Updates) -- [ ] Mail-Integration (SMTP senden) - ---- - -## 3. Views/UI (aktuell ~70%) - -### Erledigt -- [x] List View (auto-generiert + stored) -- [x] Form View (auto-generiert + stored, mit Header-Buttons) -- [x] Kanban View (auto-generiert + stored) -- [x] Search View (auto-generiert mit Filters/Group-By) -- [x] Stored Views aus DB (14 Views) -- [x] Auto-generierte Views als Fallback -- [x] O2M Inline-Listen in Form-Views (editable) -- [x] Statusbar Widget mit clickable States - -### Offen -- [ ] View Inheritance (XPath-basiert, inherit_id) -- [ ] Pivot View -- [ ] Graph View -- [ ] Calendar View -- [ ] Gantt View -- [ ] Activity View -- [ ] Map View -- [ ] View Customization (ir.ui.view.custom) -- [ ] Dynamic Visibility (invisible/readonly Conditions in XML) -- [ ] Widget-System (many2one_avatar, priority, statusbar etc.) -- [ ] Chatter/Mail-Thread in Form-Views - ---- - -## 4. Business-Module (aktuell ~25% Code-Tiefe) - -### Account (1.072 / 40.910 LOC = 2.6%) -- [x] account.move (Invoice CRUD, action_post, button_cancel) -- [x] account.move.line (Buchungszeilen) -- [x] account.journal, account.account, account.tax -- [x] account.payment + payment.register Wizard -- [ ] Reconciliation (Payment↔Invoice Matching) — **KRITISCH** -- [ ] Bank Statements (Import, Matching) -- [ ] Tax Reports (Umsatzsteuervoranmeldung) -- [ ] Asset Management -- [ ] Aged Receivable/Payable Reports -- [ ] Fiscal Year / Period Management -- [ ] Payment Provider Integration -- [ ] Chart of Accounts Import (volle l10n_de) - -### Sale (641 / 8.009 LOC = 8%) -- [x] sale.order + sale.order.line (CRUD, Confirm, Create Invoice) -- [x] Computed Amounts (untaxed, tax, total, line subtotal) -- [ ] Sale Order Line Tax-Berechnung (tax_id → tax lines) -- [ ] Pricelist-Support (Rabatte, Staffelpreise) -- [ ] Quotation Templates -- [ ] Sale Order Cancellation + Refund -- [ ] Delivery Integration (Lieferschein-Erstellung korrekt) -- [ ] Margin-Berechnung -- [ ] Sale Reports (Revenue, Pipeline) - -### Stock (588 / 17.672 LOC = 3.3%) -- [x] stock.picking, stock.move, stock.location, stock.warehouse -- [x] stock.quant (Basic) -- [ ] Quant-Management (Reservation, Unreserve) — **KRITISCH** -- [ ] Stock Valuation (FIFO, Average Cost) -- [ ] Reorder Rules (stock.warehouse.orderpoint) -- [ ] Barcode/Lot Tracking -- [ ] Inventory Adjustments -- [ ] Stock Reports (Valuation, Moves) -- [ ] Multi-Warehouse Routing -- [ ] Package Management - -### Purchase (350 / 3.977 LOC = 8.8%) -- [x] purchase.order + purchase.order.line (CRUD, Confirm) -- [ ] 3-Way Matching (PO → Receipt → Bill) -- [ ] Purchase Agreements -- [ ] Vendor Bills aus PO erstellen -- [ ] Purchase Reports - -### CRM (300 / 5.019 LOC = 6%) -- [x] crm.lead + crm.stage + crm.lost.reason -- [x] Pipeline Kanban mit Stages -- [x] Won/Lost Actions -- [ ] Lead Scoring / Probability -- [ ] Email Integration (Lead aus Email) -- [ ] Activities / Planned Actions -- [ ] Lead Assignment Rules -- [ ] CRM Reports (Pipeline, Revenue) - -### HR (200 / 4.609 LOC = 4.3%) -- [x] hr.employee, hr.department, hr.job -- [ ] Contracts (hr.contract) -- [ ] Leave Management (hr.leave) -- [ ] Attendance Tracking -- [ ] Payroll Integration -- [ ] Employee Documents - -### Project (200 / 6.730 LOC = 3%) -- [x] project.project, project.task -- [ ] Task Stages / Kanban -- [ ] Timesheet Integration -- [ ] Project Updates / Milestones -- [ ] Burndown Charts - ---- - -## 5. System/Admin (aktuell ~68%) - -### Erledigt -- [x] Settings-Seite (res.config.settings mit Firmen-Fields) -- [x] Users/Companies/Groups Verwaltung -- [x] Technical Menu (Views, Actions, Parameters, Security, Logging) -- [x] Session-Persistenz (PostgreSQL) -- [x] ir.config_parameter (get_param/set_param) -- [x] ir.cron Model (registriert) -- [x] ir.logging Model -- [x] res.lang + ir.translation Models -- [x] Password Hashing (bcrypt) -- [x] change_password Methode - -### Offen -- [ ] ir.cron Runner (Goroutine Scheduler der Jobs ausfuehrt) -- [ ] Mail Server (ir.mail_server, SMTP Config) -- [ ] Authentik SSO / OAuth2 (geplant, siehe memory) -- [ ] API Key Auth (Token-basierter Zugriff) -- [ ] Module Install/Upgrade Lifecycle -- [ ] XML Data Loading (aus Addon data/ Ordnern) -- [ ] User Audit Log (wer hat was geaendert) -- [ ] Database Backup/Restore -- [ ] Multi-Database Support - ---- - -## 6. Infrastruktur - -### Erledigt -- [x] Docker Compose (Go Server + PostgreSQL) -- [x] OWL 19.0 Frontend eingebettet -- [x] JS Transpiler (ES6→odoo.define) -- [x] XML Template Compiler -- [x] CSS Bundling -- [x] Database Manager (/web/database/manager) - -### Offen -- [ ] Tests (aktuell 0 Go-Tests fuer ORM) -- [ ] CI/CD Pipeline -- [ ] Migrations (Schema-Upgrades bei Model-Aenderungen) -- [ ] Performance-Benchmarks -- [ ] Logging Framework (strukturiert, nicht nur log.Printf) -- [ ] Configuration File (statt nur Env-Vars) -- [x] Odoo.com URLs entfernt (alle 5 Stellen) -- [ ] Health-Check erweitern (DB + Dependencies) - ---- - -## Priorisierung fuer naechste Sessions - -### Prio 1 — Blockiert echte Nutzung -1. Account Reconciliation (Payment↔Invoice) -2. Stock Quant Management (Reservation) -3. View Inheritance (XPath) -4. PDF Reports (wkhtmltopdf) - -### Prio 2 — Verbessert UX stark -5. Kanban auto_unfold -6. Binary Field Serving (Bilder) -7. Pivot/Graph Views -8. Dynamic Visibility in Views - -### Prio 3 — Vervollstaendigung -9. ir.cron Runner -10. Mail Server Integration -11. Authentik SSO -12. Tests schreiben -13. Weitere Module vertiefen diff --git a/pkg/server/report.go b/pkg/server/report.go index 2bfbba0..8f4f5ce 100644 --- a/pkg/server/report.go +++ b/pkg/server/report.go @@ -1,11 +1,15 @@ -// Report endpoint — serves HTML reports. +// Report endpoint — serves HTML and PDF reports. // Mirrors: odoo/addons/web/controllers/report.py ReportController package server import ( + "bytes" "fmt" "html/template" + "log" "net/http" + "os" + "os/exec" "strconv" "strings" @@ -89,6 +93,254 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { w.Write([]byte(html)) } +// handleReportPDF serves PDF reports. +// Route: /report/pdf// +// Mirrors: odoo/addons/web/controllers/report.py report_download() +// +// It generates the HTML report and converts it to PDF via wkhtmltopdf or +// headless Chromium. If neither is available, it falls back to the HTML +// report with an auto-print dialog. +func (s *Server) handleReportPDF(w http.ResponseWriter, r *http.Request) { + // Parse URL: /report/pdf/account.report_invoice/1,2,3 + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") + // Expected: ["report", "pdf", "", ""] + if len(parts) < 4 { + http.Error(w, "Invalid report URL. Expected: /report/pdf//", http.StatusBadRequest) + return + } + + reportName := parts[2] + idsStr := parts[3] + + // Parse record IDs + var ids []int64 + for _, s := range strings.Split(idsStr, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + id, err := strconv.ParseInt(s, 10, 64) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid ID %q: %v", s, err), http.StatusBadRequest) + return + } + ids = append(ids, id) + } + + if len(ids) == 0 { + http.Error(w, "No record IDs provided", http.StatusBadRequest) + return + } + + // Get session + uid := int64(1) + companyID := int64(1) + if sess := GetSession(r); sess != nil { + uid = sess.UID + companyID = sess.CompanyID + } + + env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{ + Pool: s.pool, UID: uid, CompanyID: companyID, + }) + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + defer env.Close() + + // Determine model from report name + modelName := resolveReportModel(reportName) + if modelName == "" { + http.Error(w, "Unknown report: "+reportName, http.StatusNotFound) + return + } + + // Read records + rs := env.Model(modelName).Browse(ids...) + records, err := rs.Read(nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := env.Commit(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Generate HTML report + htmlContent := renderHTMLReport(reportName, modelName, records) + + // Try to convert to PDF + pdfData, err := htmlToPDF(htmlContent) + if err != nil { + // Fallback: serve HTML with auto-print dialog + log.Printf("report: PDF conversion unavailable (%v), falling back to HTML", err) + htmlFallback := renderHTMLReportPrintFallback(reportName, modelName, records) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(htmlFallback)) + return + } + + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s.pdf", reportName)) + w.Write(pdfData) +} + +// htmlToPDF converts an HTML string to PDF. It tries wkhtmltopdf first, +// then headless Chromium. Returns an error if neither is available. +func htmlToPDF(html string) ([]byte, error) { + // Try wkhtmltopdf first (most common in Odoo deployments) + if path, err := exec.LookPath("wkhtmltopdf"); err == nil { + cmd := exec.Command(path, "--quiet", "--print-media-type", + "--page-size", "A4", + "--margin-top", "10mm", "--margin-bottom", "10mm", + "--margin-left", "10mm", "--margin-right", "10mm", + "-", "-") // stdin → stdout + cmd.Stdin = strings.NewReader(html) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + log.Printf("report: wkhtmltopdf failed: %v – %s", err, stderr.String()) + } else { + return stdout.Bytes(), nil + } + } + + // Try headless Chromium / Chrome + return htmlToPDFChromium(html) +} + +// htmlToPDFChromium converts HTML to PDF using headless Chrome/Chromium. +func htmlToPDFChromium(html string) ([]byte, error) { + // Find browser binary + var browserPath string + for _, name := range []string{"chromium", "chromium-browser", "google-chrome", "chrome"} { + if p, err := exec.LookPath(name); err == nil { + browserPath = p + break + } + } + if browserPath == "" { + return nil, fmt.Errorf("no PDF converter available (tried wkhtmltopdf, chromium, chrome)") + } + + // Write HTML to temp file + tmpFile, err := os.CreateTemp("", "report-*.html") + if err != nil { + return nil, fmt.Errorf("creating temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(html); err != nil { + tmpFile.Close() + return nil, fmt.Errorf("writing temp file: %w", err) + } + tmpFile.Close() + + pdfFile := tmpFile.Name() + ".pdf" + defer os.Remove(pdfFile) + + cmd := exec.Command(browserPath, "--headless", "--disable-gpu", "--no-sandbox", + "--print-to-pdf="+pdfFile, "file://"+tmpFile.Name()) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("chromium PDF generation failed: %v – %s", err, stderr.String()) + } + + data, err := os.ReadFile(pdfFile) + if err != nil { + return nil, fmt.Errorf("reading PDF output: %w", err) + } + return data, nil +} + +// renderHTMLReportPrintFallback generates an HTML report with enhanced print +// CSS and an auto-trigger for the browser print dialog. Used as fallback when +// no PDF converter (wkhtmltopdf, chromium) is installed. +func renderHTMLReportPrintFallback(reportName, modelName string, records []orm.Values) string { + var b strings.Builder + b.WriteString(` + + + +Report: `) + b.WriteString(htmlEscape(reportName)) + b.WriteString(` + + + +
+

`) + b.WriteString(htmlEscape(reportName)) + b.WriteString(`

+
Model: `) + b.WriteString(htmlEscape(modelName)) + b.WriteString(fmt.Sprintf(` | Records: %d`, len(records))) + b.WriteString(`
+
+`) + + for _, rec := range records { + b.WriteString(`
`) + title := "" + if v, ok := rec["display_name"]; ok { + title = fmt.Sprintf("%v", v) + } else if v, ok := rec["name"]; ok { + title = fmt.Sprintf("%v", v) + } + if title != "" { + b.WriteString(fmt.Sprintf("

%s

", htmlEscape(title))) + } + b.WriteString("") + b.WriteString("") + for key, val := range rec { + if key == "id" { + continue + } + valStr := fmt.Sprintf("%v", val) + if valStr == "" { + valStr = "" + } + b.WriteString(fmt.Sprintf("", + htmlEscape(key), htmlEscape(valStr))) + } + b.WriteString("
FieldValue
%s%s
") + b.WriteString("
") + } + + b.WriteString(` +`) + return b.String() +} + // resolveReportModel maps a report name to the ORM model it operates on. // Mirrors: odoo ir.actions.report → model field. func resolveReportModel(reportName string) string { @@ -125,8 +377,14 @@ h2 { color: #555; margin-top: 30px; } .record-section { page-break-after: always; margin-bottom: 40px; } .record-section:last-child { page-break-after: avoid; } @media print { - body { margin: 20px; } + body { margin: 0; } .no-print { display: none; } + table { page-break-inside: auto; } + tr { page-break-inside: avoid; } +} +@page { + size: A4; + margin: 15mm; } diff --git a/pkg/server/server.go b/pkg/server/server.go index b6c84d7..15c6543 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -129,9 +129,10 @@ func (s *Server) registerRoutes() { // CSV export s.mux.HandleFunc("/web/export/csv", s.handleExportCSV) - // Reports (HTML report rendering) + // Reports (HTML and PDF report rendering) s.mux.HandleFunc("/report/", s.handleReport) s.mux.HandleFunc("/report/html/", s.handleReport) + s.mux.HandleFunc("/report/pdf/", s.handleReportPDF) // Logout & Account s.mux.HandleFunc("/web/session/logout", s.handleLogout) @@ -352,6 +353,19 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa return nil, err } + // Handle ir.actions.report RPC calls (e.g., Print button on invoices). + // Mirrors: odoo/addons/base/models/ir_actions_report.py IrActionsReport.report_action() + if params.Model == "ir.actions.report" && params.Method == "report_action" { + if len(params.Args) > 0 { + reportName, _ := params.Args[0].(string) + return map[string]interface{}{ + "type": "ir.actions.report", + "report_name": reportName, + "report_type": "qweb-html", + }, nil + } + } + // If model is "ir.http", handle special routing methods if params.Model == "ir.http" { switch params.Method { diff --git a/pkg/server/views.go b/pkg/server/views.go index 359b092..07985e6 100644 --- a/pkg/server/views.go +++ b/pkg/server/views.go @@ -2,8 +2,10 @@ package server import ( "encoding/json" + "encoding/xml" "fmt" "net/http" + "sort" "strings" "odoo-go/pkg/orm" @@ -134,15 +136,42 @@ func handleGetViews(env *orm.Environment, model string, params CallKWParams) (in } // loadViewArch tries to load a view from the ir_ui_view table. +// After loading the base view, it queries for inheriting views and merges them. +// Mirrors: odoo/addons/base/models/ir_ui_view.py _get_combined_arch() func loadViewArch(env *orm.Environment, model, viewType string) string { + // Load base view (no parent — inherit_id IS NULL) var arch string + var viewID int64 err := env.Tx().QueryRow(env.Ctx(), - `SELECT arch FROM ir_ui_view WHERE model = $1 AND type = $2 AND active = true ORDER BY priority LIMIT 1`, + `SELECT id, arch FROM ir_ui_view + WHERE model = $1 AND type = $2 AND active = true AND inherit_id IS NULL + ORDER BY priority LIMIT 1`, model, viewType, - ).Scan(&arch) + ).Scan(&viewID, &arch) if err != nil { return "" } + + // Load inheriting views and apply them in priority order + rows, err := env.Tx().Query(env.Ctx(), + `SELECT arch FROM ir_ui_view + WHERE inherit_id = $1 AND active = true + ORDER BY priority`, + viewID, + ) + if err != nil { + return arch + } + defer rows.Close() + + for rows.Next() { + var inheritArch string + if err := rows.Scan(&inheritArch); err != nil { + continue + } + arch = applyViewInheritance(arch, inheritArch) + } + return arch } @@ -162,6 +191,10 @@ func generateDefaultView(modelName, viewType string) string { return generateDefaultSearchView(m) case "kanban": return generateDefaultKanbanView(m) + case "pivot": + return generateDefaultPivotView(m) + case "graph": + return generateDefaultGraphView(m) default: return fmt.Sprintf("<%s>", viewType, viewType) } @@ -442,3 +475,418 @@ func generateDefaultKanbanView(m *orm.Model) string { `, strings.Join(cardFields, "\n")) } + +// generateDefaultPivotView creates a minimal pivot view with numeric measure fields. +// Mirrors: odoo/addons/web/models/ir_ui_view.py default_view() +func generateDefaultPivotView(m *orm.Model) string { + var measures []string + for _, name := range sortedFieldNames(m) { + f := m.GetField(name) + if f == nil { + continue + } + if (f.Type == orm.TypeFloat || f.Type == orm.TypeInteger || f.Type == orm.TypeMonetary) && f.IsStored() && f.Name != "id" { + measures = append(measures, fmt.Sprintf(``, f.Name)) + if len(measures) >= 3 { + break + } + } + } + if len(measures) == 0 { + measures = append(measures, ``) + } + return fmt.Sprintf("\n %s\n", strings.Join(measures, "\n ")) +} + +// generateDefaultGraphView creates a minimal graph view with a dimension and a measure. +// Mirrors: odoo/addons/web/models/ir_ui_view.py default_view() +func generateDefaultGraphView(m *orm.Model) string { + var fields []string + // First groupable field as dimension + for _, name := range sortedFieldNames(m) { + f := m.GetField(name) + if f == nil { + continue + } + if f.IsStored() && (f.Type == orm.TypeMany2one || f.Type == orm.TypeSelection) { + fields = append(fields, fmt.Sprintf(``, f.Name)) + break + } + } + // First numeric field as measure + for _, name := range sortedFieldNames(m) { + f := m.GetField(name) + if f == nil { + continue + } + if (f.Type == orm.TypeFloat || f.Type == orm.TypeInteger || f.Type == orm.TypeMonetary) && f.IsStored() && f.Name != "id" { + fields = append(fields, fmt.Sprintf(``, f.Name)) + break + } + } + if len(fields) == 0 { + fields = append(fields, ``) + } + return fmt.Sprintf("\n %s\n", strings.Join(fields, "\n ")) +} + +// sortedFieldNames returns field names in alphabetical order for deterministic output. +func sortedFieldNames(m *orm.Model) []string { + fields := m.Fields() + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// --- View Inheritance --- + +// applyViewInheritance merges an inheriting view's arch into a base view. +// Supports: +// - +// - (shorthand) +// +// Mirrors (simplified): odoo/addons/base/models/ir_ui_view.py apply_inheritance_specs() +func applyViewInheritance(baseArch, inheritArch string) string { + directives := parseInheritDirectives(inheritArch) + if len(directives) == 0 { + return baseArch + } + + for _, d := range directives { + baseArch = applyDirective(baseArch, d) + } + return baseArch +} + +// inheritDirective represents a single modification instruction from an inheriting view. +type inheritDirective struct { + // target identifies what to match: tag name + attribute match + targetTag string // e.g., "field", "group", "page", "notebook", etc. + targetAttr string // attribute name to match, e.g., "name" + targetVal string // attribute value to match, e.g., "partner_id" + position string // "after", "before", "replace", "inside", "attributes" + content string // inner XML content to insert/replace +} + +// parseInheritDirectives extracts modification directives from an inheriting view arch. +func parseInheritDirectives(inheritArch string) []inheritDirective { + // Wrap in a root element so the XML decoder can parse fragments + wrapped := "<_root>" + strings.TrimSpace(inheritArch) + "" + + decoder := xml.NewDecoder(strings.NewReader(wrapped)) + var directives []inheritDirective + + // We need to find the top-level children of _root. + // Skip the outer or root element if present. + // The actual directives are either or etc. with position attr. + + type element struct { + name xml.Name + attrs []xml.Attr + } + + depth := 0 + var stack []element + var contentBuilder strings.Builder + captureDepth := -1 + + for { + tok, err := decoder.Token() + if err != nil { + break + } + + switch t := tok.(type) { + case xml.StartElement: + depth++ + + // If we're capturing content for a directive, write this element + if captureDepth >= 0 && depth > captureDepth+1 { + contentBuilder.WriteString(startElementToString(t)) + } + + stack = append(stack, element{name: t.Name, attrs: t.Attr}) + + // Skip the wrapper _root and optional root element + if depth == 1 && t.Name.Local == "_root" { + continue + } + if depth == 2 && t.Name.Local == "data" { + continue + } + + // Check if this is a directive element (xpath or element with position) + pos := attrVal(t.Attr, "position") + if pos != "" && captureDepth < 0 { + d := inheritDirective{position: pos} + + if t.Name.Local == "xpath" { + // Parse expr attribute: //field[@name='partner_id'] + expr := attrVal(t.Attr, "expr") + d.targetTag, d.targetAttr, d.targetVal = parseXPathExpr(expr) + } else { + // Shorthand: + d.targetTag = t.Name.Local + d.targetAttr = "name" + d.targetVal = attrVal(t.Attr, "name") + } + + if d.targetTag != "" { + directives = append(directives, d) + captureDepth = depth + contentBuilder.Reset() + } + } + + case xml.EndElement: + if captureDepth >= 0 { + if depth == captureDepth { + // We've closed the directive element, store captured content + if len(directives) > 0 { + directives[len(directives)-1].content = strings.TrimSpace(contentBuilder.String()) + } + captureDepth = -1 + contentBuilder.Reset() + } else if depth > captureDepth { + contentBuilder.WriteString(fmt.Sprintf("", t.Name.Local)) + } + } + + depth-- + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + + case xml.CharData: + if captureDepth >= 0 && depth > captureDepth { + contentBuilder.Write(t) + } + } + } + + return directives +} + +// startElementToString serializes an xml.StartElement back to string form. +func startElementToString(el xml.StartElement) string { + var buf strings.Builder + buf.WriteString("<") + buf.WriteString(el.Name.Local) + for _, a := range el.Attr { + buf.WriteString(fmt.Sprintf(` %s="%s"`, a.Name.Local, a.Value)) + } + buf.WriteString(">") + return buf.String() +} + +// attrVal returns the value of a named attribute, or "". +func attrVal(attrs []xml.Attr, name string) string { + for _, a := range attrs { + if a.Name.Local == name { + return a.Value + } + } + return "" +} + +// parseXPathExpr parses a simple XPath expression like //field[@name='partner_id']. +// Returns (tag, attrName, attrValue). Only supports the //tag[@attr='val'] pattern. +func parseXPathExpr(expr string) (string, string, string) { + // Pattern: //tag[@attr='val'] or //tag[@attr="val"] + expr = strings.TrimSpace(expr) + + // Strip leading // + expr = strings.TrimPrefix(expr, "//") + + // Find tag[ + bracketIdx := strings.Index(expr, "[") + if bracketIdx < 0 { + // Simple case: //tag — match by tag name alone + tag := strings.TrimSpace(expr) + if tag != "" { + return tag, "", "" + } + return "", "", "" + } + + tag := expr[:bracketIdx] + + // Extract @attr='val' + inner := expr[bracketIdx+1:] + inner = strings.TrimSuffix(inner, "]") + inner = strings.TrimPrefix(inner, "@") + + eqIdx := strings.Index(inner, "=") + if eqIdx < 0 { + return tag, "", "" + } + + attrName := inner[:eqIdx] + attrValue := inner[eqIdx+1:] + // Remove quotes + attrValue = strings.Trim(attrValue, "'\"") + + return tag, attrName, attrValue +} + +// applyDirective applies a single inheritance directive to the base arch. +func applyDirective(baseArch string, d inheritDirective) string { + // Find the target element in baseArch + targetStart, targetEnd := findElement(baseArch, d.targetTag, d.targetAttr, d.targetVal) + if targetStart < 0 { + return baseArch + } + + switch d.position { + case "after": + // Insert content after the target element + return baseArch[:targetEnd] + "\n" + d.content + baseArch[targetEnd:] + + case "before": + // Insert content before the target element + return baseArch[:targetStart] + d.content + "\n" + baseArch[targetStart:] + + case "replace": + // Replace the target element with content + if d.content == "" { + // Empty replace = remove the element + return baseArch[:targetStart] + baseArch[targetEnd:] + } + return baseArch[:targetStart] + d.content + baseArch[targetEnd:] + + case "inside": + // Insert content inside the target element (before closing tag) + // Need to find the closing tag of the target + closingTag := fmt.Sprintf("", d.targetTag) + closeIdx := strings.LastIndex(baseArch[targetStart:targetEnd], closingTag) + if closeIdx >= 0 { + insertPos := targetStart + closeIdx + return baseArch[:insertPos] + "\n" + d.content + "\n" + baseArch[insertPos:] + } + // Self-closing element — convert to open/close with content + selfClose := strings.LastIndex(baseArch[targetStart:targetEnd], "/>") + if selfClose >= 0 { + insertPos := targetStart + selfClose + return baseArch[:insertPos] + ">\n" + d.content + "\n" + fmt.Sprintf("", d.targetTag) + baseArch[insertPos+2:] + } + return baseArch + + case "attributes": + // Modify attributes of the target element — not implemented yet + return baseArch + + default: + return baseArch + } +} + +// findElement finds a target element in the XML string by tag name and optional attribute match. +// Returns (startIndex, endIndex) of the full element (including closing tag if present). +// Returns (-1, -1) if not found. +func findElement(xmlStr, tag, attrName, attrVal string) (int, int) { + searchFrom := 0 + for searchFrom < len(xmlStr) { + // Find next occurrence of ") + if openEnd < 0 { + return -1, -1 + } + openEnd += idx + + openingTag := xmlStr[idx : openEnd+1] + + // Check attribute match if required + if attrName != "" && attrVal != "" { + if !matchAttribute(openingTag, attrName, attrVal) { + searchFrom = openEnd + 1 + continue + } + } + + // Check if self-closing + if strings.HasSuffix(strings.TrimSpace(openingTag), "/>") { + return idx, openEnd + 1 + } + + // Find matching closing tag + closeTag := fmt.Sprintf("", tag) + // Simple approach: find the next closing tag at the same nesting level + closeIdx := findMatchingClose(xmlStr[openEnd+1:], tag) + if closeIdx < 0 { + // No closing tag — treat as self-contained up to > + return idx, openEnd + 1 + } + endIdx := openEnd + 1 + closeIdx + len(closeTag) + return idx, endIdx + } + return -1, -1 +} + +// findMatchingClose finds the matching closing tag, handling nesting. +// Returns the offset from the start of s where the closing tag begins, +// or -1 if not found. +func findMatchingClose(s, tag string) int { + openTag := "<" + tag + closeTag := "" + depth := 1 + pos := 0 + + for pos < len(s) { + nextOpen := strings.Index(s[pos:], openTag) + nextClose := strings.Index(s[pos:], closeTag) + + if nextClose < 0 { + return -1 + } + + // If there's a nested open before the close, increase depth + if nextOpen >= 0 && nextOpen < nextClose { + // Verify it's actually a tag start (not a prefix of another tag) + afterIdx := pos + nextOpen + len(openTag) + if afterIdx < len(s) { + ch := s[afterIdx] + if ch == ' ' || ch == '/' || ch == '>' || ch == '\n' || ch == '\t' || ch == '\r' { + depth++ + } + } + pos += nextOpen + len(openTag) + continue + } + + depth-- + if depth == 0 { + return pos + nextClose + } + pos += nextClose + len(closeTag) + } + return -1 +} + +// matchAttribute checks if an opening tag string contains attr="val" or attr='val'. +func matchAttribute(openingTag, attrName, attrValue string) bool { + // Try both quote styles + pattern1 := fmt.Sprintf(`%s="%s"`, attrName, attrValue) + pattern2 := fmt.Sprintf(`%s='%s'`, attrName, attrValue) + return strings.Contains(openingTag, pattern1) || strings.Contains(openingTag, pattern2) +}