Backend: gemeinsame Helfer in common.py

- Eine ValidationError-Klasse statt zwei (links.py, projects.py), ein
  Fehler-Handler in main.py
- clean_text() ersetzt vier fast gleiche Text-Prüfungen (Kanban, Haushalt,
  Reisen, Artikel); Fehlermeldungen nennen jetzt überall die Höchstlänge
- Zeitstempel (now_iso, iso_ago, iso_after) und Kontenliste (user_list)
  an einer Stelle; ungenutzte Importe entfernt
- Tests für die Helfer und für einheitliche 400-Fehler aus allen Bereichen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 18:10:43 +02:00
1 parent d28e6b2e4b
commit 4aba48ce51
14 files changed
+117 -86

No files matched your search

+1
View File
@@ -233,6 +233,7 @@ backend/
requirements.txt
app/main.py FastAPI: API-Routen, Uploads, Auslieferung des Frontends
app/config.py liest config.json
app/common.py Gemeinsame Helfer: Eingabefehler, Texte prüfen, Zeitstempel, Kontenliste
app/db.py SQLite-Schema (neuester Stand), Verbindungen, Übernahme alter JSON-Daten
app/migrations.py Nummerierte Umstellungen bestehender Datenbanken (Regel steht oben in der Datei)
app/auth.py Konten, Anmeldung, Benutzerverwaltung
+2 -1
View File
@@ -9,7 +9,8 @@ from collections import defaultdict
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from .config import LINKS as CONFIG_LINKS
from .db import get_meta, now_iso, transaction
from .common import now_iso
from .db import get_meta, transaction
router = APIRouter()
+48
View File
@@ -0,0 +1,48 @@
"""Gemeinsame Helfer fürs Backend: Eingabefehler, Texte prüfen, Zeitstempel, Kontenliste."""
import re
from datetime import datetime, timedelta, timezone
class ValidationError(ValueError):
"""Ungültige Eingabe; main.py macht daraus HTTP 400 mit {"error": Meldung}."""
def clean_text(value, field: str, max_len: int, *, required: bool = True, collapse: bool = True) -> str:
"""Text säubern und prüfen. collapse: Leerraum (auch Zeilenumbrüche) zu einem Leerzeichen zusammenfassen."""
if not isinstance(value, str):
value = ""
v = re.sub(r"\s+", " ", value).strip() if collapse else value.strip()
if required and not v:
raise ValidationError(f"{field} fehlt.")
if len(v) > max_len:
raise ValidationError(f"{field} ist zu lang (max. {max_len} Zeichen).")
return v
# ---------- Zeitstempel: ISO 8601 in UTC mit Millisekunden, z. B. 2026-10-15T08:57:47.533Z ----------
# Einheitliches Format, damit Vergleiche als Text in SQLite stimmen (done_at <= ? usw.).
def iso(moment: datetime) -> str:
return moment.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def now_iso() -> str:
return iso(datetime.now(timezone.utc))
def iso_ago(delta: timedelta) -> str:
return iso(datetime.now(timezone.utc) - delta)
def iso_after(value: str, delta: timedelta) -> str:
return iso(datetime.fromisoformat(value.replace("Z", "+00:00")) + delta)
# ---------- Konten ----------
def user_list(conn) -> list:
"""Alle Konten für Auswahlfelder (Bearbeiter, Freigaben), alphabetisch."""
rows = conn.execute("SELECT id, display_name FROM users ORDER BY display_name COLLATE NOCASE").fetchall()
return [{"id": u["id"], "name": u["display_name"]} for u in rows]
+1 -5
View File
@@ -4,7 +4,7 @@ import json
import re
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from datetime import datetime
from .config import DATA_DIR
@@ -202,10 +202,6 @@ CREATE TABLE IF NOT EXISTS meta (
"""
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB_FILE, timeout=10)
conn.row_factory = sqlite3.Row
+5 -16
View File
@@ -7,13 +7,12 @@ Wer eine Liste anlegt, legt fest, ob alle Konten sie sehen oder nur ausgewählte
Vorschläge angeboten – "milch" wird dabei automatisch zum bereits bekannten "Milch".
"""
import math
import re
from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .db import now_iso, transaction
from .links import ValidationError
from .common import ValidationError, clean_text, now_iso, user_list
from .db import transaction
router = APIRouter()
@@ -67,12 +66,7 @@ def _quantity(value, unit: str = "stk"):
def _clean_name(value) -> str:
name = re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else ""
if not name:
raise ValidationError("Produktname fehlt.")
if len(name) > MAX_NAME:
raise ValidationError(f"Produktname ist zu lang (max. {MAX_NAME} Zeichen).")
return name
return clean_text(value, "Produktname", MAX_NAME)
def _key(name: str) -> str:
@@ -168,11 +162,7 @@ def _shopping_list(conn, row, user: dict) -> dict:
def _list_fields(conn, data: dict, user: dict, owner_id) -> tuple:
"""Name, "für alle" und Mitglieder prüfen. Der Ersteller sieht seine Liste immer und steht nicht bei den Mitgliedern."""
name = re.sub(r"\s+", " ", data.get("name")).strip() if isinstance(data.get("name"), str) else ""
if not name:
raise ValidationError("Name der Liste fehlt.")
if len(name) > MAX_LIST_NAME:
raise ValidationError(f"Name ist zu lang (max. {MAX_LIST_NAME} Zeichen).")
name = clean_text(data.get("name"), "Name der Liste", MAX_LIST_NAME)
everyone = data.get("everyone", True)
if not isinstance(everyone, bool):
raise ValidationError("Ungültige Sichtbarkeit.")
@@ -211,11 +201,10 @@ def _state(conn, user: dict) -> dict:
).fetchall()
# Häufig genutzte Produkte zuerst vorschlagen
products = conn.execute("SELECT name, uses FROM products").fetchall()
users = conn.execute("SELECT id, display_name FROM users ORDER BY display_name COLLATE NOCASE").fetchall()
return {
"items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))],
"lists": [_shopping_list(conn, r, user) for r in lists],
"users": [{"id": u["id"], "name": u["display_name"]} for u in users],
"users": user_list(conn),
"products": [r["name"] for r in sorted(products, key=lambda r: (-r["uses"], _sort_key(r["name"])))],
}
+9 -29
View File
@@ -8,14 +8,13 @@ Erledigte Aufgaben wandern nach 2 Tagen ins Archiv und werden nach 14 Tagen gel
import asyncio
import logging
import math
import re
from datetime import datetime, timedelta, timezone
from datetime import timedelta
from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .db import now_iso, transaction
from .links import ValidationError
from .common import ValidationError, clean_text, iso_after, iso_ago, now_iso, user_list
from .db import transaction
router = APIRouter()
@@ -33,19 +32,10 @@ CLEANUP_SECONDS = 3600
log = logging.getLogger(__name__)
def _ago(delta: timedelta) -> str:
# Gleiches Format wie now_iso(), damit Textvergleich in SQLite stimmt
return (datetime.now(timezone.utc) - delta).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _iso_after(iso: str, delta: timedelta) -> str:
return (datetime.fromisoformat(iso.replace("Z", "+00:00")) + delta).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def cleanup(conn) -> int:
"""Seit 14 Tagen erledigte Aufgaben löschen."""
return conn.execute(
"DELETE FROM kanban_tasks WHERE status = 'erledigt' AND done_at <= ?", (_ago(DELETE_AFTER),)
"DELETE FROM kanban_tasks WHERE status = 'erledigt' AND done_at <= ?", (iso_ago(DELETE_AFTER),)
).rowcount
@@ -72,12 +62,7 @@ def _may_delete(user: dict, created_by) -> bool:
def _name(value, field="Name", max_len=MAX_NAME) -> str:
v = re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else ""
if not v:
raise ValidationError(f"{field} fehlt.")
if len(v) > max_len:
raise ValidationError(f"{field} ist zu lang (max. {max_len} Zeichen).")
return v
return clean_text(value, field, max_len)
def _description(value) -> str:
@@ -146,11 +131,6 @@ def _next(conn, table: str, where: str = "", args: tuple = ()) -> float:
# ---------- Ausgabe ----------
def _users(conn) -> list:
rows = conn.execute("SELECT id, display_name FROM users ORDER BY display_name COLLATE NOCASE").fetchall()
return [{"id": u["id"], "name": u["display_name"]} for u in rows]
def _project_links(conn) -> dict:
links = {}
for r in conn.execute(
@@ -199,7 +179,7 @@ def _overview(conn) -> dict:
return {
"projects": _projects(conn),
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
"users": _users(conn),
"users": user_list(conn),
}
@@ -236,7 +216,7 @@ def _board(conn, project_id: int) -> dict:
(project_id,),
).fetchall()
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
archive_before = _ago(ARCHIVE_AFTER)
archive_before = iso_ago(ARCHIVE_AFTER)
archived = sorted(
(r for r in tasks if r["status"] == "erledigt" and r["done_at"] and r["done_at"] <= archive_before),
key=lambda r: r["done_at"],
@@ -247,8 +227,8 @@ def _board(conn, project_id: int) -> dict:
"project": project,
"tasks": [_task(r) for r in tasks if r["id"] not in archived_ids],
# Archiv: nur mit eigenem Button sichtbar; deleteAt = automatisches Löschen
"archived": [{**_task(r), "deleteAt": _iso_after(r["done_at"], DELETE_AFTER)} for r in archived],
"users": _users(conn),
"archived": [{**_task(r), "deleteAt": iso_after(r["done_at"], DELETE_AFTER)} for r in archived],
"users": user_list(conn),
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
}
+1 -4
View File
@@ -5,6 +5,7 @@ from urllib.parse import urlparse
from fastapi import APIRouter, Body, Depends
from .auth import current_user
from .common import ValidationError
from .db import transaction
router = APIRouter()
@@ -14,10 +15,6 @@ MAX_LEN = 500
HOSTNAME = re.compile(r"[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]")
class ValidationError(ValueError):
pass
def normalize_url(value, field: str) -> str:
"""Prüft eine http(s)-Adresse; ergänzt fehlendes "http://". Leer bleibt leer."""
v = value.strip() if isinstance(value, str) else ""
+3 -3
View File
@@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
from . import auth, haushalt, kanban, links, monitoring, projects, reisen, rezept
from .auth import current_user
from .common import ValidationError
from .config import FRONTEND_DIST
from .tracking import track
@@ -44,9 +45,8 @@ async def http_error(_: Request, exc: HTTPException):
return JSONResponse({"error": exc.detail}, status_code=exc.status_code)
@app.exception_handler(projects.ValidationError)
@app.exception_handler(links.ValidationError)
async def validation_error(_: Request, exc: ValueError):
@app.exception_handler(ValidationError)
async def validation_error(_: Request, exc: ValidationError):
return JSONResponse({"error": str(exc)}, status_code=400)
+2 -1
View File
@@ -6,7 +6,8 @@ SCHEMA in db.py beschreibt immer den neuesten Stand. Bei einer Änderung am Sche
Jeder Schritt läuft genau einmal, in einer Transaktion mit dem Hochzählen der Nummer.
Tests: tests/test_migrationen.py (alte Stände nachbauen und umstellen).
"""
from .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE, now_iso
from .common import now_iso
from .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE
def start(conn) -> None:
+3 -2
View File
@@ -8,8 +8,9 @@ import httpx
from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .db import now_iso, transaction
from .links import ValidationError, normalize_url
from .common import ValidationError, now_iso
from .db import transaction
from .links import normalize_url
router = APIRouter()
log = logging.getLogger("p42.monitoring")
+4 -11
View File
@@ -9,7 +9,8 @@ from fastapi.responses import FileResponse
from .auth import current_user
from .config import DATA_DIR
from .db import now_iso, transaction
from .common import ValidationError, clean_text, now_iso
from .db import transaction
from .kanban import boards_for_article
router = APIRouter()
@@ -25,10 +26,6 @@ MAX_UPLOAD = 15 * 1024 * 1024
MIME = {"jpg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp"}
class ValidationError(ValueError):
pass
# ---------- Artikel-HTML bereinigen ----------
ALLOWED_TAGS = {"p", "br", "h2", "h3", "strong", "b", "em", "i", "u", "s", "ul", "ol", "li", "blockquote", "hr", "a", "img"}
@@ -59,12 +56,8 @@ def sanitize(content: str) -> str:
# ---------- Validierung ----------
def _text(value, field: str, max_len: int, required: bool = False) -> str:
v = value.strip() if isinstance(value, str) else ""
if required and not v:
raise ValidationError(f"{field} fehlt.")
if len(v) > max_len:
raise ValidationError(f"{field} ist zu lang (max. {max_len} Zeichen).")
return v
# Zeilenumbrüche in Kurzbeschreibung und Artikeltext bleiben erhalten
return clean_text(value, field, max_len, required=required, collapse=False)
def _image(value) -> str | None:
+3 -12
View File
@@ -19,8 +19,8 @@ from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .config import WIKIPEDIA_CONTACT
from .db import now_iso, transaction
from .links import ValidationError
from .common import ValidationError, clean_text, now_iso
from .db import transaction
router = APIRouter()
@@ -81,15 +81,6 @@ def get_travel(user: dict = Depends(current_user)):
return _state(conn, user["id"])
def _text(value, field: str, max_len: int, required: bool = True) -> str:
v = re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else ""
if required and not v:
raise ValidationError(f"{field} fehlt.")
if len(v) > max_len:
raise ValidationError(f"{field} ist zu lang.")
return v
def _number(value, field: str, low: float, high: float) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high:
raise ValidationError(f"Ungültiger Wert für {field}.")
@@ -116,7 +107,7 @@ def add_city(data: dict = Body(...), user: dict = Depends(current_user)):
# Hauptstadt kommt aus der Suche; fehlt die Angabe, trägt fill_capitals() sie nach
capital = int(data["capital"]) if isinstance(data.get("capital"), bool) else None
values = (
user["id"], geoname_id, _text(data.get("name"), "Name", 100), _text(data.get("admin1"), "Region", 100, False),
user["id"], geoname_id, clean_text(data.get("name"), "Name", 100), clean_text(data.get("admin1"), "Region", 100, required=False),
country, _number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
population, elevation, timezone, capital, now_iso(),
)
+2 -2
View File
@@ -12,12 +12,12 @@ import socket
from urllib.parse import urljoin, urlsplit
import httpx
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi import APIRouter, Body, Depends
from .auth import current_user
from .db import transaction
from .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit
from .links import ValidationError
from .common import ValidationError
router = APIRouter()
+33
View File
@@ -0,0 +1,33 @@
from datetime import timedelta
import pytest
from app.common import ValidationError, clean_text, iso_after, iso_ago, now_iso
def test_clean_text():
assert clean_text(" Milch \n frisch ", "Name", 20) == "Milch frisch"
assert clean_text(" Zeile 1\nZeile 2 ", "Text", 20, collapse=False) == "Zeile 1\nZeile 2"
assert clean_text(None, "Region", 10, required=False) == ""
with pytest.raises(ValidationError, match="Name fehlt"):
clean_text(" ", "Name", 10)
with pytest.raises(ValidationError, match=r"max\. 3 Zeichen"):
clean_text("abcd", "Name", 3)
def test_zeitstempel_vergleichbar_als_text():
assert iso_ago(timedelta(days=1)) < now_iso() < iso_after(now_iso(), timedelta(seconds=1))
assert now_iso().endswith("Z") and len(now_iso()) == 24
@pytest.mark.parametrize("method, url, body", [
("post", "/api/projects", {"title": ""}), # Artikel
("post", "/api/kanban/projekte", {"name": " "}), # Kanban
("post", "/api/haushalt/listen", {"name": ""}), # Haushalt
("put", "/api/links", {"jellyfin": "javascript:alert(1)"}), # Links
("post", "/api/reisen/staedte", {"geonameId": 1, "country": "DE", "name": "", "lat": 1, "lon": 1}),
])
def test_eingabefehler_einheitlich_400(login, method, url, body):
res = getattr(login("anna"), method)(url, json=body)
assert res.status_code == 400
assert res.json()["error"]