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>
445 lines
12 KiB
Go
445 lines
12 KiB
Go
// 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)
|
||
}
|