// 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" "odoo-go/pkg/orm" ) // handleReport serves HTML reports. // Route: /report/html// // Mirrors: odoo/addons/web/controllers/report.py report_routes() func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { // Parse URL: /report/html/account.report_invoice/1,2,3 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") // Expected: ["report", "html", "", ""] if len(parts) < 4 { http.Error(w, "Invalid report URL. Expected: /report/html//", 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 } // Render HTML report html := renderHTMLReport(reportName, modelName, records) w.Header().Set("Content-Type", "text/html; charset=utf-8") 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 { mapping := map[string]string{ "account.report_invoice": "account.move", "sale.report_saleorder": "sale.order", "stock.report_picking": "stock.picking", "purchase.report_purchaseorder": "purchase.order", "contacts.report_partner": "res.partner", } return mapping[reportName] } // renderHTMLReport generates a basic HTML report for the given records. // This is a minimal implementation — Odoo uses QWeb templates for real reports. func renderHTMLReport(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(`
`) // Use "name" or "display_name" as section title if available 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() } // htmlEscape escapes HTML special characters. func htmlEscape(s string) string { return template.HTMLEscapeString(s) }