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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user