Musterlösung: der Kern
Schema (schema.py):
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):
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):
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):
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_zeileist reine Logik ohne Datei- und Datenbankzugriff. Deshalb lässt sie sich mit zwei kleinen Tests absichern.ON CONFLICT ... DO UPDATEmacht 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 Exceptionsteht bewusst nur an dieser einen Stelle ganz oben, es protokolliert den kompletten Fehler mitlog.exceptionund 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 HeaderContent-Dispositionals 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
LIMITundOFFSET.