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:
1 parent
d28e6b2e4b
commit
4aba48ce51
14 files changed
+117
-86
No files matched your search
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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],
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user