Files
einkaufsapp/backend/app/print_view.py
2026-08-08 20:31:58 +02:00

128 lines
4.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Druckansicht als eigenständige HTML-Seite.
Warum serverseitig, obwohl die App schon `@media print` mitbringt: Der
Ausdruck soll auch ohne geöffnete App möglich sein - aus einem Lesezeichen,
über einen öffentlichen Link, oder von einem Rechner, auf dem niemand die
PWA installiert hat. Die Gliederung stammt aus `app.list_view`, also
derselben Quelle wie die Bildschirmansicht; ein zweiter Sortieralgorithmus
würde mit der Zeit abweichen.
"""
from datetime import datetime
from jinja2 import Environment, select_autoescape
from app.config import settings
from app.schemas_shopping import ListView
# autoescape ist hier keine Formsache: Artikelnamen und Notizen sind
# freie Nutzereingaben und landen direkt im HTML.
_env = Environment(autoescape=select_autoescape(default=True, default_for_string=True))
_TEMPLATE = _env.from_string("""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>{{ view.list_name }} {{ app_name }}</title>
<!-- Stil und Skript liegen als eigene Dateien vor, nicht im Dokument:
Die Content-Security-Policy erlaubt weder unsafe-inline noch
Ereignisbehandler im Markup. Eingebetteter Stil käme unformatiert
an, ohne dass irgendwo ein Fehler sichtbar wäre. -->
<link rel="stylesheet" href="/print.css">
</head>
<body>
<div class="toolbar">
<button type="button" id="print-button">Drucken</button>
</div>
<header>
<h1>{{ view.list_name }}</h1>
<div class="meta">
Stand {{ printed_at }}
{%- if open_count %} · {{ open_count }} offene Position{{ "en" if open_count != 1 }}{% endif %}
{%- if not include_bought %} · gekaufte Artikel ausgeblendet{% endif %}
</div>
</header>
{% if not view.markets %}
<p class="empty">Die Liste ist leer.</p>
{% endif %}
{% for market in view.markets %}
<section class="market">
<h2>
<span>{{ market.market_name }}</span>
{% if market.total_cents %}<span class="sum">{{ money(market.total_cents) }}</span>{% endif %}
</h2>
{% for category in market.categories %}
<div class="category">
<h3>{{ category.category_name }}</h3>
<table>
<tbody>
{% for item in category.items %}
<tr class="{{ 'bought' if item.status == 'bought' }}">
<td class="box"><span></span></td>
<td class="count {{ 'many' if item.count > 1 }}">{{ item.count }}&times;</td>
<td class="name">
<span class="article">{{ item.article_name }}</span>
{% set detail = [] %}
{%- if item.pack_size %}{% set _ = detail.append(pack(item)) %}{% endif %}
{%- if item.variant %}{% set _ = detail.append(item.variant) %}{% endif %}
{%- if item.note %}{% set _ = detail.append(item.note) %}{% endif %}
{%- if detail %}<span class="detail">{{ detail | join(" · ") }}</span>{% endif %}
</td>
<td class="price">{{ money(item.total_cents) if item.total_cents else "" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</section>
{% endfor %}
<footer>
<span>Gesamt</span>
<span class="total">{{ money(view.grand_total_cents) }}</span>
</footer>
<p class="note">{{ app_name }} · erzeugt {{ printed_at }}</p>
<script src="/print.js"></script>
</body>
</html>
""")
def _money(cents: int | None) -> str:
value = (cents or 0) / 100
return f"{value:,.2f}".replace(",", "\u00a0").replace(".", ",")
def _pack(item) -> str:
"""Gebindegröße lesbar machen: 250 statt 250.000, 1,5 statt 1.500.
Nicht über rstrip("0"): Bei einem Wert ohne Dezimalpunkt - und den
liefert Decimal("250") - würde das die letzte Null abschneiden und
aus 250 g plötzlich 25 g machen. `normalize()` entfernt nur
tatsächlich überflüssige Nachkommastellen.
"""
value = item.pack_size
text = format(value.normalize(), "f") if hasattr(value, "normalize") else str(value)
return f"{text.replace('.', ',')} {item.pack_unit or ''}".strip()
def render_print(view: ListView, *, include_bought: bool = True) -> str:
open_count = sum(market.open_count for market in view.markets)
return _TEMPLATE.render(
view=view,
app_name=settings.app_name,
printed_at=datetime.now().strftime("%d.%m.%Y, %H:%M"),
open_count=open_count,
include_bought=include_bought,
money=_money,
pack=_pack,
)