Files
goodie/pkg/server/report.go
Marc e0d8bc81d3 PDF reports + View inheritance + Pivot/Graph views
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 <field name="X" position="...">
- 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) <noreply@anthropic.com>
2026-04-03 13:52:30 +02:00

445 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/<report_name>/<ids>
// 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", "<report_name>", "<ids>"]
if len(parts) < 4 {
http.Error(w, "Invalid report URL. Expected: /report/html/<report_name>/<ids>", 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/<report_name>/<ids>
// 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", "<report_name>", "<ids>"]
if len(parts) < 4 {
http.Error(w, "Invalid report URL. Expected: /report/pdf/<report_name>/<ids>", 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(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Report: `)
b.WriteString(htmlEscape(reportName))
b.WriteString(`</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; font-weight: bold; }
h1 { color: #333; }
h2 { color: #555; margin-top: 30px; }
.header { border-bottom: 2px solid #875A7B; padding-bottom: 10px; margin-bottom: 20px; }
.report-info { color: #888; font-size: 0.9em; margin-bottom: 30px; }
.record-section { page-break-after: always; margin-bottom: 40px; }
.record-section:last-child { page-break-after: avoid; }
.no-print { display: none; }
@media print {
body { margin: 0; }
.no-print { display: none; }
table { page-break-inside: auto; }
tr { page-break-inside: avoid; }
}
@page {
size: A4;
margin: 15mm;
}
</style>
</head>
<body>
<div class="header">
<h1>`)
b.WriteString(htmlEscape(reportName))
b.WriteString(`</h1>
<div class="report-info">Model: `)
b.WriteString(htmlEscape(modelName))
b.WriteString(fmt.Sprintf(` | Records: %d`, len(records)))
b.WriteString(`</div>
</div>
`)
for _, rec := range records {
b.WriteString(`<div class="record-section">`)
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("<h2>%s</h2>", htmlEscape(title)))
}
b.WriteString("<table>")
b.WriteString("<tr><th>Field</th><th>Value</th></tr>")
for key, val := range rec {
if key == "id" {
continue
}
valStr := fmt.Sprintf("%v", val)
if valStr == "<nil>" {
valStr = ""
}
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%s</td></tr>",
htmlEscape(key), htmlEscape(valStr)))
}
b.WriteString("</table>")
b.WriteString("</div>")
}
b.WriteString(`<script>window.addEventListener('load', function() { window.print(); });</script>
</body></html>`)
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(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Report: `)
b.WriteString(htmlEscape(reportName))
b.WriteString(`</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; font-weight: bold; }
h1 { color: #333; }
h2 { color: #555; margin-top: 30px; }
.header { border-bottom: 2px solid #875A7B; padding-bottom: 10px; margin-bottom: 20px; }
.report-info { color: #888; font-size: 0.9em; margin-bottom: 30px; }
.record-section { page-break-after: always; margin-bottom: 40px; }
.record-section:last-child { page-break-after: avoid; }
@media print {
body { margin: 0; }
.no-print { display: none; }
table { page-break-inside: auto; }
tr { page-break-inside: avoid; }
}
@page {
size: A4;
margin: 15mm;
}
</style>
</head>
<body>
<div class="header">
<h1>`)
b.WriteString(htmlEscape(reportName))
b.WriteString(`</h1>
<div class="report-info">Model: `)
b.WriteString(htmlEscape(modelName))
b.WriteString(fmt.Sprintf(` | Records: %d`, len(records)))
b.WriteString(`</div>
</div>
<div class="no-print">
<button onclick="window.print()">Print</button>
</div>
`)
for _, rec := range records {
b.WriteString(`<div class="record-section">`)
// 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("<h2>%s</h2>", htmlEscape(title)))
}
b.WriteString("<table>")
b.WriteString("<tr><th>Field</th><th>Value</th></tr>")
for key, val := range rec {
if key == "id" {
continue
}
valStr := fmt.Sprintf("%v", val)
if valStr == "<nil>" {
valStr = ""
}
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%s</td></tr>",
htmlEscape(key), htmlEscape(valStr)))
}
b.WriteString("</table>")
b.WriteString("</div>")
}
b.WriteString("</body></html>")
return b.String()
}
// htmlEscape escapes HTML special characters.
func htmlEscape(s string) string {
return template.HTMLEscapeString(s)
}