feat: add consolidated print backend route

Adds a new WWW page at /consolidated_printview that renders multiple
documents of the same DocType in a single continuous HTML layout with
no forced page breaks between records.

- get_context() iterates over a JSON list of names, renders each doc
  via the existing get_rendered_template() pipeline (falls back to
  WeasyPrint for beta print-format-builder formats), and joins the
  results with a thin visual separator
- Per-document page-break-after is suppressed via CSS override so all
  records flow as one uninterrupted printable page
- trigger_print=1 fires window.print() automatically, matching the
  behaviour of the existing single-doc /printview route
- Permission and render errors are caught per-document and logged so
  one bad record does not abort the whole batch
- Access log entry recorded for auditing (first 10 doc names)

Made-with: Cursor
This commit is contained in:
dataCenter430 2026-03-02 21:45:07 +01:00
parent 9556b09543
commit 101983c7af
2 changed files with 201 additions and 0 deletions

View file

@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="{{ lang }}" dir="{{ layout_direction }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<meta name="generator" content="frappe">
{{ include_style('print.bundle.css') }}
{% if print_style %}
<style>
{{ print_style }}
</style>
{% endif %}
<style>
/* ── Consolidated Print overrides ───────────────────────────────────────
Remove forced page breaks that the standard per-document template inserts
between its internal "pages". Documents flow continuously. */
.consolidated-doc .page-break {
page-break-after: auto !important;
break-after: auto !important;
}
/* Visual separator shown on screen between documents (hidden when printing) */
.consolidated-doc-separator {
border: none;
border-top: 2px dashed #ccc;
margin: 24px 0;
}
.consolidated-doc:not(:last-child) {
margin-bottom: 20px;
}
@media print {
/* Thin rule visible in the printed output so documents are distinguishable */
.consolidated-doc:not(:last-child)::after {
content: "";
display: block;
border-top: 1px solid #888;
margin: 12px 0;
}
}
</style>
</head>
<body>
<div class="action-banner print-hide">
<a class="p-2" onclick="window.print();">
{{ _("Print") }}
</a>
</div>
<div class="print-format-gutter">
<div class="print-format">
{{ body }}
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Mirror the footer-positioning logic from printview.html for each doc
document.querySelectorAll('.consolidated-doc').forEach(function(docEl) {
const pageDiv = docEl.querySelector('.page-break');
if (pageDiv) {
pageDiv.style.display = 'flex';
pageDiv.style.flexDirection = 'column';
}
const footerHtml = docEl.getElementById
? docEl.getElementById('footer-html')
: docEl.querySelector('#footer-html');
if (footerHtml) {
footerHtml.classList.add('hidden-pdf');
footerHtml.classList.remove('visible-pdf');
footerHtml.style.order = 1;
footerHtml.style.marginTop = '20px';
}
});
});
</script>
</body>
{%- if doc_count -%}
<!-- consolidated: {{ doc_count }} documents -->
{%- endif -%}
</html>

View file

@ -0,0 +1,120 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import json
import frappe
from frappe import _
from frappe.core.doctype.access_log.access_log import make_access_log
from frappe.utils import cint
from frappe.utils.jinja_globals import is_rtl
from frappe.www.printview import (
get_print_format_doc,
get_print_style,
get_rendered_template,
set_link_titles,
trigger_print_script,
)
no_cache = 1
MAX_CONSOLIDATED_PRINT_DOCS = 100
def get_context(context):
"""Build context for consolidated (multi-document, no page-break) print."""
doctype = frappe.form_dict.get("doctype")
names_json = frappe.form_dict.get("names")
if not doctype or not names_json:
return {
"body": "<h1>Error</h1><p>Parameters <code>doctype</code> and <code>names</code> are required.</p>",
"print_style": "",
"title": "Error",
"lang": "en",
"layout_direction": "ltr",
"doc_count": 0,
}
try:
names = json.loads(names_json)
except (json.JSONDecodeError, TypeError):
frappe.throw(_("Invalid names parameter: must be a JSON array of document names"))
if not isinstance(names, list) or len(names) == 0:
frappe.throw(_("At least one document name is required"))
if len(names) > MAX_CONSOLIDATED_PRINT_DOCS:
frappe.throw(
_("Cannot consolidate more than {0} documents at a time").format(MAX_CONSOLIDATED_PRINT_DOCS)
)
format_name = frappe.form_dict.get("format") or None
no_letterhead = frappe.form_dict.get("no_letterhead", 0)
letterhead = frappe.form_dict.get("letterhead") or None
trigger_print = cint(frappe.form_dict.get("trigger_print", 0))
meta = frappe.get_meta(doctype)
print_format = get_print_format_doc(format_name, meta=meta)
all_html_parts = []
for name in names:
try:
doc = frappe.get_lazy_doc(doctype, name)
set_link_titles(doc)
html = _get_doc_print_html(doc, print_format, meta, no_letterhead, letterhead)
all_html_parts.append(html)
except frappe.PermissionError:
frappe.log_error(
title="Consolidated Print Permission Error",
message=f"No permission to print {doctype} {name}",
)
except Exception:
frappe.log_error(
title="Consolidated Print Render Error",
message=f"Error rendering {doctype} {name}",
)
separator = '<div class="consolidated-doc-separator print-hide"></div>'
body = separator.join(
f'<div class="consolidated-doc">{html}</div>' for html in all_html_parts
)
if trigger_print:
body += trigger_print_script
make_access_log(
doctype=doctype,
document=", ".join(names[:10]),
file_type="PDF",
method="Print",
page=f"Consolidated Print Format: {getattr(print_format, 'name', 'Standard')} ({len(names)} docs)",
)
return {
"body": body,
"print_style": get_print_style(frappe.form_dict.get("style"), print_format),
"title": f"Consolidated Print — {doctype}",
"lang": frappe.local.lang,
"layout_direction": "rtl" if is_rtl() else "ltr",
"doctype": doctype,
"doc_count": len(all_html_parts),
}
def _get_doc_print_html(doc, print_format, meta, no_letterhead, letterhead):
"""Return rendered HTML for a single document, handling both standard and beta print formats."""
if print_format and print_format.get("print_format_builder_beta"):
from frappe.utils.weasyprint import get_html
return get_html(doctype=doc.doctype, name=doc.name, print_format=print_format.name)
return get_rendered_template(
doc=doc,
print_format=print_format,
meta=meta,
no_letterhead=no_letterhead,
letterhead=letterhead,
trigger_print=False,
)