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>
This commit is contained in:
Marc
2026-04-03 13:52:30 +02:00
parent 2e5a550069
commit e0d8bc81d3
4 changed files with 725 additions and 255 deletions

View File

@@ -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/<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 {
@@ -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;
}
</style>
</head>