Replace dashboard PDF report with toner-friendly print template
7ee840fa
by Isaac Bythewood
· 12 days ago
Replace dashboard PDF report with toner-friendly print template
The PDF was generated by re-rendering the dark dashboard with a
print-tweaked stylesheet, which printed huge black backgrounds. Replace
it with a dedicated property_print.html: light theme, A4-paginated,
SVG line chart with a thin black stroke, three-column tables for
top-lists / breakdowns / UTM, percentage columns instead of doughnuts.
Also detect the chromium binary lazily so installing it after the
server is up no longer requires a restart.
modified
analytics/chromium.py
@@ -45,9 +45,6 @@ def _find_chromium() -> Optional[str]: return NoneCHROMIUM_BINARY = _find_chromium()class ChromiumError(RuntimeError): pass
@@ -76,12 +73,13 @@ def _html_tempfile(html: str) -> Iterator[str]:def _run(args: list[str], timeout: int) -> None: if not CHROMIUM_BINARY: binary = _find_chromium() if not binary: raise ChromiumError( "No chromium binary found on PATH (tried: chromium, " "chromium-browser, google-chrome)" ) cmd = [CHROMIUM_BINARY, *BASE_FLAGS, *args] cmd = [binary, *BASE_FLAGS, *args] try: subprocess.run(cmd, check=True, capture_output=True, timeout=timeout) except subprocess.TimeoutExpired as exc:
modified
properties/static_src/index.js
@@ -4,5 +4,3 @@ import "./scripts/property_date_select.js";import "./scripts/property_custom_cards.js";import "./scripts/property_is_public.js";import "./scripts/property_filters.js";import "./styles/print.scss";
deleted
properties/static_src/styles/print.scss
@@ -1,49 +0,0 @@@media print { @page { size: A4; margin: 12mm; } html, body { width: fit-content; height: fit-content; background: #fff !important; color: #000 !important; } body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .container { max-width: 100%; } .chart-panel { page-break-inside: avoid; } #chart-total-events { height: 300px; page-break-after: always; } #doughnut-graphs { width: 100%; display: flex; flex-wrap: wrap; margin-bottom: 1.5rem; page-break-after: always; } #doughnut-graphs > div { width: 50%; margin-bottom: 0; } #top-lists .col-12 { width: 50%; page-break-inside: avoid; }}
modified
properties/templates/properties/property.html
@@ -2,11 +2,6 @@{% load static %}{% block extra_css %}<link rel="stylesheet" href="{% static 'properties.css' %}">{% endblock %}{% block extra_js %}{{ total_events_graph|json_script:"chart-total-events-data" }}{{ total_events_by_browser|json_script:"chart-total-events-by-browser-data" }}
added
properties/templates/properties/property_print.html
@@ -0,0 +1,470 @@{% load static %}<!doctype html><html lang="en"><head><meta charset="utf-8"><title>{{ property.name }} · Analytics report</title><style> @page { size: A4; margin: 14mm; } @page :first { margin-top: 16mm; } * { box-sizing: border-box; } html, body { background: #fff; color: #000; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 9.5pt; line-height: 1.45; margin: 0; padding: 0; -webkit-print-color-adjust: exact; print-color-adjust: exact; } h1, h2, h3 { color: #000; margin: 0; font-weight: 700; } h1 { font-size: 22pt; letter-spacing: -0.01em; } h2 { font-size: 11pt; text-transform: uppercase; letter-spacing: 0.08em; margin: 16pt 0 6pt; padding-bottom: 3pt; border-bottom: 1px solid #000; } h3 { font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.08em; margin: 8pt 0 3pt; color: #333; } .header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 4pt; } .header .brand { font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.12em; color: #555; } .header .generated { font-size: 8pt; color: #555; text-align: right; } .meta { font-size: 9pt; margin-top: 4pt; border-top: 1px solid #000; border-bottom: 1px solid #000; padding: 6pt 0; display: grid; grid-template-columns: repeat(2, 1fr); gap: 2pt 16pt; } .meta dt { color: #555; font-size: 8pt; text-transform: uppercase; letter-spacing: 0.05em; margin: 0; } .meta dd { margin: 0 0 4pt; font-weight: 600; } .meta dd code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 8.5pt; font-weight: 400; } .metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4pt; } .metric { border: 1px solid #000; padding: 5pt 7pt; page-break-inside: avoid; } .metric .label { font-size: 7.5pt; text-transform: uppercase; letter-spacing: 0.05em; color: #555; line-height: 1.2; } .metric .value { font-size: 14pt; font-weight: 700; margin-top: 2pt; font-variant-numeric: tabular-nums; } .metric .delta { font-size: 7.5pt; color: #333; margin-top: 1pt; font-variant-numeric: tabular-nums; } .metric .delta.up::before { content: "▲ "; } .metric .delta.down::before { content: "▼ "; } .metric .delta.flat::before { content: "· "; } .chart-wrap { margin: 4pt 0 0; page-break-inside: avoid; } .chart { width: 100%; height: 90pt; display: block; } .chart-axis { display: flex; justify-content: space-between; font-size: 7.5pt; color: #555; margin-top: 2pt; font-variant-numeric: tabular-nums; } .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt 14pt; } .grid-2 > section { page-break-inside: avoid; } .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8pt 10pt; } .grid-3 > section { page-break-inside: avoid; min-width: 0; } table { width: 100%; border-collapse: collapse; font-size: 7.5pt; table-layout: fixed; } th, td { text-align: left; padding: 2pt 3pt; border-bottom: 1px solid #ddd; vertical-align: top; overflow-wrap: break-word; } th { font-weight: 700; border-bottom: 1px solid #000; text-transform: uppercase; letter-spacing: 0.04em; font-size: 6.5pt; color: #555; } td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; width: 22%; } td.pct, th.pct { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; color: #555; width: 18%; } td.code, td .code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 7pt; word-break: break-all; } tr:last-child td { border-bottom: none; } .empty { color: #888; font-style: italic; font-size: 8pt; } footer.report-footer { margin-top: 18pt; padding-top: 6pt; border-top: 1px solid #000; font-size: 7.5pt; color: #555; display: flex; justify-content: space-between; } .filter-banner { display: inline-block; border: 1px solid #000; padding: 1pt 6pt; font-size: 8pt; margin-top: 4pt; } .filter-banner strong { font-weight: 700; }</style></head><body><div class="header"> <div class="brand">// Analytics · property report</div> <div class="generated">Generated {% now "Y-m-d H:i" %}</div></div><h1>{{ property.name }}</h1><dl class="meta"> <div><dt>Property ID</dt><dd><code>{{ property.id }}</code></dd></div> <div><dt>Operator</dt><dd>{{ property.user.username }}</dd></div> <div><dt>Date range</dt><dd>{{ date_start }} → {{ date_end }} <span style="color:#555;font-weight:400">({{ date_range }} days)</span></dd></div> <div><dt>Live users · last 30m</dt><dd>{{ total_live_users }}</dd></div></dl>{% if filter_url %}<div class="filter-banner"><strong>Filter · url:</strong> <code>{{ filter_url }}</code></div>{% endif %}<h2>Metrics · period vs previous</h2><div class="metrics"> {% for card in event_cards %} <div class="metric"> <div class="label">{{ card.name }}</div> <div class="value">{{ card.value }}</div> {% with pc=card.percent_change|stringformat:"s" %} {% if card.percent_change == 0 %} <div class="delta flat">no change</div> {% elif pc|slice:":1" == "-" %} <div class="delta down">{{ card.percent_change }}% vs previous</div> {% else %} <div class="delta up">+{{ card.percent_change }}% vs previous</div> {% endif %} {% endwith %} </div> {% endfor %}</div><h2>Events over time</h2><div class="chart-wrap"> {% if chart_polyline %} <svg class="chart" viewBox="0 0 600 100" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Events over time"> <line x1="0" y1="99.5" x2="600" y2="99.5" stroke="#000" stroke-width="0.4" /> <polyline fill="none" stroke="#000" stroke-width="0.9" stroke-linejoin="round" stroke-linecap="round" points="{{ chart_polyline }}" /> </svg> <div class="chart-axis"> <span>{{ chart_label_start }}</span> <span>peak {{ chart_peak_count }} · {{ chart_peak_label }}</span> <span>{{ chart_label_end }}</span> </div> {% else %} <div class="empty">No events in this range.</div> {% endif %}</div><div class="grid-3"> {% if total_page_views_by_page_url %} <section> <h3>Top pages · page views</h3> <table> <thead><tr><th>URL</th><th class="num">Views</th></tr></thead> <tbody> {% for item in total_page_views_by_page_url %} <tr><td class="code">{{ item.label }}</td><td class="num">{{ item.count }}</td></tr> {% endfor %} </tbody> </table> </section> {% endif %} {% if total_events_by_page_url %} <section> <h3>Top pages · all events</h3> <table> <thead><tr><th>URL</th><th class="num">Events</th></tr></thead> <tbody> {% for item in total_events_by_page_url %} <tr><td class="code">{{ item.label }}</td><td class="num">{{ item.count }}</td></tr> {% endfor %} </tbody> </table> </section> {% endif %} {% if total_session_starts_by_referrer %} <section> <h3>Top referrers</h3> <table> <thead><tr><th>Referrer</th><th class="num">Sessions</th></tr></thead> <tbody> {% for item in total_session_starts_by_referrer %} <tr><td class="code">{{ item.label }}</td><td class="num">{{ item.count }}</td></tr> {% endfor %} </tbody> </table> </section> {% endif %} {% if total_events_by_custom_event %} <section> <h3>Top custom events</h3> <table> <thead><tr><th>Event</th><th class="num">Count</th></tr></thead> <tbody> {% for item in total_events_by_custom_event %} <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr> {% endfor %} </tbody> </table> </section> {% endif %}</div><h2>Visitor breakdown</h2><div class="grid-3"> {% if total_events_by_device %} <section> <h3>Device</h3> <table> <thead><tr><th>Type</th><th class="num">Events</th><th class="pct">%</th></tr></thead> <tbody> {% for item in total_events_by_device %} <tr> <td>{{ item.label }}</td> <td class="num">{{ item.count }}</td> <td class="pct">{% widthratio item.count breakdown_totals.device 100 %}%</td> </tr> {% endfor %} </tbody> </table> </section> {% endif %} {% if total_events_by_browser %} <section> <h3>Browser</h3> <table> <thead><tr><th>Name</th><th class="num">Events</th><th class="pct">%</th></tr></thead> <tbody> {% for item in total_events_by_browser %} <tr> <td>{{ item.label }}</td> <td class="num">{{ item.count }}</td> <td class="pct">{% widthratio item.count breakdown_totals.browser 100 %}%</td> </tr> {% endfor %} </tbody> </table> </section> {% endif %} {% if total_events_by_platform %} <section> <h3>Platform</h3> <table> <thead><tr><th>Name</th><th class="num">Events</th><th class="pct">%</th></tr></thead> <tbody> {% for item in total_events_by_platform %} <tr> <td>{{ item.label }}</td> <td class="num">{{ item.count }}</td> <td class="pct">{% widthratio item.count breakdown_totals.platform 100 %}%</td> </tr> {% endfor %} </tbody> </table> </section> {% endif %} {% if total_events_by_screen_size %} <section> <h3>Screen size</h3> <table> <thead><tr><th>Size</th><th class="num">Events</th><th class="pct">%</th></tr></thead> <tbody> {% for item in total_events_by_screen_size %} <tr> <td>{{ item.label }}</td> <td class="num">{{ item.count }}</td> <td class="pct">{% widthratio item.count breakdown_totals.screen_size 100 %}%</td> </tr> {% endfor %} </tbody> </table> </section> {% endif %}</div>{% if top_countries %}<h2>Geography</h2><div class="grid-2"> <section> <h3>Top countries</h3> <table> <thead><tr><th>Country</th><th class="num">Sessions</th></tr></thead> <tbody> {% for item in top_countries %} <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr> {% endfor %} </tbody> </table> </section></div>{% endif %}{% if total_page_views_by_utm_source or total_page_views_by_utm_medium or total_page_views_by_utm_campaign %}<h2>UTM attribution</h2><div class="grid-3"> {% if total_page_views_by_utm_source %} <section> <h3>Source</h3> <table> <thead><tr><th>Source</th><th class="num">Views</th></tr></thead> <tbody> {% for item in total_page_views_by_utm_source %} <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr> {% endfor %} </tbody> </table> </section> {% endif %} {% if total_page_views_by_utm_medium %} <section> <h3>Medium</h3> <table> <thead><tr><th>Medium</th><th class="num">Views</th></tr></thead> <tbody> {% for item in total_page_views_by_utm_medium %} <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr> {% endfor %} </tbody> </table> </section> {% endif %} {% if total_page_views_by_utm_campaign %} <section> <h3>Campaign</h3> <table> <thead><tr><th>Campaign</th><th class="num">Views</th></tr></thead> <tbody> {% for item in total_page_views_by_utm_campaign %} <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr> {% endfor %} </tbody> </table> </section> {% endif %}</div>{% endif %}<h2>Bot traffic <span style="font-size:8pt;font-weight:400;text-transform:none;letter-spacing:0;color:#555;">excluded from metrics above</span></h2>{% if bot_traffic.total > 0 %}<div class="grid-2"> <section> <h3>Top bots</h3> <table> <thead><tr><th>Bot</th><th class="num">Events</th></tr></thead> <tbody> <tr><td><strong>Total</strong></td><td class="num"><strong>{{ bot_traffic.total }}</strong></td></tr> {% for item in bot_traffic.top_bots %} <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr> {% endfor %} </tbody> </table> </section> {% if bot_traffic.top_pages %} <section> <h3>Top pages hit by bots</h3> <table> <thead><tr><th>URL</th><th class="num">Events</th></tr></thead> <tbody> {% for item in bot_traffic.top_pages %} <tr><td class="code">{{ item.label }}</td><td class="num">{{ item.count }}</td></tr> {% endfor %} </tbody> </table> </section> {% endif %}</div>{% else %}<div class="empty">No bot events in this range.</div>{% endif %}<footer class="report-footer"> <span>Analytics · self-hosted · {{ BASE_URL }}</span> <span>{{ property.name }} · {{ date_start }} → {{ date_end }}</span></footer></body></html>
modified
properties/views.py
@@ -20,6 +20,29 @@ from .models import PropertyDASHBOARD_CACHE_TTL = 300 # secondsdef _chart_polyline(points, width=600, height=100, padding=4): """SVG polyline points string for the events-over-time line chart on the printed report. Toner-friendly: thin black stroke, no fill, no gradient.""" if not points: return "" counts = [p["count"] for p in points] max_c = max(counts) or 1 n = len(counts) usable_h = height - 2 * padding if n == 1: x = width / 2 y = height - padding - (counts[0] / max_c) * usable_h return f"{x:.1f},{y:.1f}" return " ".join( f"{(i / (n - 1)) * width:.1f},{height - padding - (c / max_c) * usable_h:.1f}" for i, c in enumerate(counts) )def _breakdown_total(rows): return sum(r["count"] for r in rows) or 1def properties(request): if not request.user.is_authenticated: return redirect("/")
@@ -221,8 +244,30 @@ def property(request, property_id): response["Content-Disposition"] = f'inline; filename="{property_obj.name}.md"' return response if fmt == "pdf": context["print"] = True html = render_to_string("properties/property.html", context) graph = context.get("total_events_graph") or [] peak = max(graph, key=lambda p: p["count"]) if graph else None country_counts = context.get("session_starts_by_country") or {} top_countries = sorted( ({"label": code, "count": count} for code, count in country_counts.items()), key=lambda r: r["count"], reverse=True, )[:10] print_context = { **context, "chart_polyline": _chart_polyline(graph), "chart_label_start": graph[0]["label"] if graph else "", "chart_label_end": graph[-1]["label"] if graph else "", "chart_peak_count": peak["count"] if peak else 0, "chart_peak_label": peak["label"] if peak else "", "breakdown_totals": { "device": _breakdown_total(context.get("total_events_by_device") or []), "browser": _breakdown_total(context.get("total_events_by_browser") or []), "platform": _breakdown_total(context.get("total_events_by_platform") or []), "screen_size": _breakdown_total(context.get("total_events_by_screen_size") or []), }, "top_countries": top_countries, } html = render_to_string("properties/property_print.html", print_context) filename = f"reports/{uuid.uuid4()}.pdf" generate_pdf_from_html(html, filename) with open(default_storage.path(filename), "rb") as pdf: