Python Lernhandbuch

Kapitel 17: Abschlussprojekt: Bestell-Portal

Musterlösung mit Erklärung

Vergleiche Schritt für Schritt mit deinem Programm in VS Code. Wenn alles passt, markiere die Übung als erledigt.
Aufgabe noch einmal lesen
  1. Auf dem SFTP-Server liegen im Ordner export CSV-Dateien mit den Spalten bestell_nr;kunde;betrag;datum. Das Datum steht als TT.MM.JJJJ, der Betrag mit Komma (12,50).
  2. Der Import-Job holt neue Dateien, prüft jede Zeile und schreibt gültige Zeilen in die Tabelle bestellungen. Läuft der Job zweimal mit denselben Daten, entstehen keine Duplikate (Schlüssel: bestell_nr).
  3. Fehlerhafte Zeilen werden mit Zeilennummer und Grund protokolliert und nicht importiert. Der Rest der Datei wird trotzdem verarbeitet.
  4. Die Webapp zeigt eine Tabelle mit Live-Suche (nach Kunde), die Summe der angezeigten Beträge und ein Balkendiagramm mit dem Umsatz pro Monat.
  5. Ein Knopf "Excel-Export" lädt die aktuell gefilterten Bestellungen als .xlsx herunter.
  6. Dazu gibt es eine JSON-API (GET /api/bestellungen) mit Fehlerantworten (404, 422).
  7. Alles startet mit docker compose up -d. Zugangsdaten stehen in .env.
text
bestell-portal/
├── pyproject.toml, uv.lock
├── Dockerfile, compose.yaml, .env, .dockerignore
├── main.py            # Webapp (Routen)
├── db.py              # Datenbankpool (Kapitel 14)
├── schema.py          # SQL-Schema, ohne Datenbankverbindung
├── importer.py        # SFTP holen, prüfen, importieren
├── templates/         # base.html, bestellungen.html, _zeilen.html
├── static/            # pico.min.css, htmx.min.js, chart.umd.min.js, app.css
└── tests/
    └── test_importer.py

Datenfluss: SFTP (CSV) → importer.py → Postgres (bestellungen) → main.py → Browser (Tabelle, Diagramm, Excel, JSON).

Arbeite in dieser Reihenfolge. Nach jedem Schritt läuft etwas Sichtbares.

  1. Datenbank: Tabelle bestellungen in Postgres anlegen, Testdaten per SQL einfügen.
  2. Webapp, nur lesen: Tabelle anzeigen, dann Suche, dann Summe.
  3. Import ohne SFTP: importer.py liest eine lokale CSV-Datei, prüft Zeilen, schreibt in die Datenbank. Mit pytest testen.
  4. SFTP dazu: Test-SFTP-Server (Kapitel 13) starten, Dateien herunterladen und archivieren.
  5. Export und Diagramm: Excel-Export, JSON-API, Monatsdiagramm.
  6. Docker: Alles in compose.yaml, Import als Dienst mit profiles.
  7. Aufräumen: Logging, Fehlerfälle prüfen, README mit Startanleitung schreiben.

Musterlösung: der Kern

Schema (schema.py):

python
SCHEMA = '''
CREATE TABLE IF NOT EXISTS bestellungen (
    bestell_nr TEXT PRIMARY KEY,
    kunde TEXT NOT NULL,
    betrag NUMERIC(10, 2) NOT NULL,
    datum DATE NOT NULL
)
'''

Prüfen und importieren (importer.py):

python
import csv
import logging
import os
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path

import psycopg

from schema import SCHEMA

log = logging.getLogger(__name__)


def parse_zeile(z):
    """Liefert (bestell_nr, kunde, betrag, datum) oder löst ValueError aus."""
    try:
        bestell_nr = z['bestell_nr'].strip()
        kunde = z['kunde'].strip()
        betrag = Decimal(z['betrag'].strip().replace(',', '.'))
        datum = datetime.strptime(z['datum'].strip(), '%d.%m.%Y').date()
    except (KeyError, AttributeError, InvalidOperation) as e:
        raise ValueError(f'Zeile unlesbar: {e!r}') from e
    if not bestell_nr or not kunde:
        raise ValueError('bestell_nr und kunde dürfen nicht leer sein')
    if betrag < 0:
        raise ValueError('Betrag darf nicht negativ sein')
    return bestell_nr, kunde, betrag, datum


def importiere_datei(conn, pfad):
    gut, abgelehnt = [], []
    with open(pfad, newline='', encoding='utf-8-sig') as f:
        for nr, zeile in enumerate(csv.DictReader(f, delimiter=';'), start=2):   # Zeile 1 ist die Kopfzeile
            try:
                gut.append(parse_zeile(zeile))
            except ValueError as e:
                abgelehnt.append((nr, str(e)))

    with conn.cursor() as cur:
        cur.executemany('''
            INSERT INTO bestellungen (bestell_nr, kunde, betrag, datum)
            VALUES (%s, %s, %s, %s)
            ON CONFLICT (bestell_nr) DO UPDATE
               SET kunde = EXCLUDED.kunde, betrag = EXCLUDED.betrag, datum = EXCLUDED.datum
        ''', gut)
    return len(gut), abgelehnt


def lauf(dateien):
    """Importiert eine Liste lokaler CSV-Dateien."""
    with psycopg.connect(os.environ['DATABASE_URL']) as conn:
        conn.execute(SCHEMA)
        conn.commit()
        for pfad in dateien:
            try:
                anzahl, abgelehnt = importiere_datei(conn, pfad)
                conn.commit()
            except Exception:      # bewusst breit: eine kaputte Datei darf die anderen nicht stoppen
                conn.rollback()
                log.exception('Import von %s fehlgeschlagen', Path(pfad).name)
                continue
            log.info('%s: %d Zeilen importiert, %d abgelehnt', Path(pfad).name, anzahl, len(abgelehnt))
            for nr, grund in abgelehnt:
                log.warning('%s Zeile %d: %s', Path(pfad).name, nr, grund)

Der SFTP-Teil kommt aus Kapitel 13: Mache aus der Schleife in main() eine Funktion hole_neue_dateien(sftp), die die lokalen Pfade der neu geholten Dateien als Liste zurückgibt, und rufe danach lauf(neue) auf.

Test (tests/test_importer.py):

python
from datetime import date
from decimal import Decimal

import pytest

from importer import parse_zeile


def test_gueltige_zeile():
    z = {'bestell_nr': ' A1 ', 'kunde': 'Anna', 'betrag': '12,50', 'datum': '20.10.2026'}
    assert parse_zeile(z) == ('A1', 'Anna', Decimal('12.50'), date(2026, 10, 20))


def test_ungueltiger_betrag():
    z = {'bestell_nr': 'A1', 'kunde': 'Anna', 'betrag': 'abc', 'datum': '20.10.2026'}
    with pytest.raises(ValueError):
        parse_zeile(z)

Installieren und ausführen: uv add --dev pytest, dann uv run pytest.

Excel-Export und Monatsdaten (main.py):

python
from io import BytesIO

from fastapi.responses import StreamingResponse
from openpyxl import Workbook
from openpyxl.styles import Font


def lade_bestellungen(q):
    with pool.connection() as conn:
        return conn.execute(
            'SELECT bestell_nr, kunde, betrag, datum FROM bestellungen '
            'WHERE kunde ILIKE %s ORDER BY datum DESC, bestell_nr',
            (f'%{q}%',),
        ).fetchall()


@app.get('/export.xlsx')
def excel_export(q: str = ''):
    wb = Workbook()
    ws = wb.active
    ws.title = 'Bestellungen'
    ws.append(['Bestellnr.', 'Kunde', 'Betrag', 'Datum'])
    for z in lade_bestellungen(q):
        ws.append([z['bestell_nr'], z['kunde'], float(z['betrag']), z['datum']])

    for zelle in ws[1]:
        zelle.font = Font(bold=True)
    for zeile in ws.iter_rows(min_row=2, min_col=3, max_col=3):
        zeile[0].number_format = '#,##0.00'
    for zeile in ws.iter_rows(min_row=2, min_col=4, max_col=4):
        zeile[0].number_format = 'DD.MM.YYYY'
    ws.freeze_panes = 'A2'

    puffer = BytesIO()
    wb.save(puffer)
    puffer.seek(0)
    return StreamingResponse(
        puffer,
        media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
        headers={'Content-Disposition': 'attachment; filename="bestellungen.xlsx"'},
    )


@app.get('/api/umsatz-pro-monat')
def umsatz_pro_monat():
    with pool.connection() as conn:
        return conn.execute('''
            SELECT to_char(datum, 'YYYY-MM') AS monat, SUM(betrag) AS umsatz
            FROM bestellungen
            GROUP BY monat
            ORDER BY monat
        ''').fetchall()

Den Excel-Knopf im Template verlinkst du mit der aktuellen Suche: <a role="button" href="/export.xlsx?q={{ q }}">Excel-Export</a>. Das Diagramm funktioniert wie in Kapitel 15, nur mit /api/umsatz-pro-monat, d.monat und d.umsatz.

Was passiert:

  • parse_zeile ist reine Logik ohne Datei- und Datenbankzugriff. Deshalb lässt sie sich mit zwei kleinen Tests absichern.
  • ON CONFLICT ... DO UPDATE macht den Import wiederholbar: Dieselbe Bestellnummer wird aktualisiert statt doppelt angelegt.
  • Fehlerhafte Zeilen werden gesammelt und protokolliert, die guten laufen durch. Die Zeilennummer zählt ab 2, weil Zeile 1 die Kopfzeile ist.
  • Das breite except Exception steht bewusst nur an dieser einen Stelle ganz oben, es protokolliert den kompletten Fehler mit log.exception und macht mit der nächsten Datei weiter.
  • Der Excel-Export schreibt die Datei in einen Speicherpuffer (BytesIO), nicht auf die Festplatte, und schickt ihn mit dem Header Content-Disposition als Download.

Erweiterungen

  • Eine zweite REST-API einbinden, etwa Wechselkurse, und Beträge in einer weiteren Währung anzeigen. Prüfe dafür die Dokumentation der API und lege Ergebnisse kurz im Speicher ab (functools.lru_cache), damit du sie nicht bei jeder Anfrage neu abrufst.
  • Eine Anmeldung einbauen (ein einfacher Benutzer mit Passwort in der .env, geprüft per HTTP-Basic-Auth von FastAPI).
  • Den Import per Cron stündlich laufen lassen und ein Protokoll der Läufe in einer eigenen Tabelle speichern.
  • Seitenweise Anzeige (Pagination) der Tabelle mit LIMIT und OFFSET.