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:
parent
9556b09543
commit
101983c7af
2 changed files with 201 additions and 0 deletions
81
frappe/www/consolidated_printview.html
Normal file
81
frappe/www/consolidated_printview.html
Normal 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>
|
||||
120
frappe/www/consolidated_printview.py
Normal file
120
frappe/www/consolidated_printview.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue