chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 45bc449ea0
53 changed files with 12329 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Rezept-Inventur: findet Lücken und Ungereimtheiten in der Sammlung.
Liest nur. Schreibt nichts nach Tandoor — weder jetzt noch mit irgendeinem
Schalter. Das Ergebnis ist ein Bericht und eine CSV-Tabelle.
lint.py pruefen [--nur err,warn] [--ohne bild,zeit]
Standalone:
python3 plugins/tandoor-lint/tool/lint.py pruefen
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SUITE_ROOT = Path(__file__).resolve().parents[3]
if str(SUITE_ROOT) not in sys.path:
sys.path.insert(0, str(SUITE_ROOT))
from core.tandoor import TandoorClient, TandoorError, food_property_map # noqa: E402
from core.foodmatch import comparable # noqa: E402
def out(text: str = "") -> None:
print(text, flush=True)
def data_dir() -> Path:
configured = os.environ.get("DATA_DIR", "").strip()
base = Path(configured) if configured else SUITE_ROOT / "data" / "tandoor-lint"
base.mkdir(parents=True, exist_ok=True)
return base
# Jede Prüfung: Schlüssel, Schweregrad, Überschrift, was man tun sollte.
CHECKS: dict[str, dict[str, str]] = {
"quelle": {"severity": "warn", "title": "Keine Quelle hinterlegt",
"hint": "source_url leer — Herkunft später nicht mehr nachvollziehbar."},
"bild": {"severity": "info", "title": "Kein Bild",
"hint": "Rezept ohne Bild geht in der Übersicht unter."},
"portionen": {"severity": "err", "title": "Keine Portionsangabe",
"hint": "Ohne servings rechnet Tandoor Mengen und Nährwerte nicht um."},
"portionstext": {"severity": "info", "title": "Kein Portionstext",
"hint": "servings_text leer — es steht dann nur eine nackte Zahl da."},
"arbeitszeit": {"severity": "info", "title": "Keine Arbeitszeit",
"hint": "working_time 0 — Filter nach Zeit findet das Rezept nicht."},
"schlagwort": {"severity": "info", "title": "Keine Schlagworte",
"hint": "Ohne Keywords über Filter praktisch unauffindbar."},
"schritt_leer": {"severity": "err", "title": "Schritt ohne Anleitung",
"hint": "Ein Schritt ohne Text ist beim Kochen wertlos."},
"zutat_einheit": {"severity": "warn", "title": "Zutat ohne Einheit",
"hint": "Menge ohne Einheit lässt sich nicht umrechnen."},
"zutat_menge": {"severity": "warn", "title": "Zutat ohne Menge",
"hint": "amount 0, ohne dass „keine Menge“ gesetzt ist."},
"zutat_naehr": {"severity": "info", "title": "Zutat ohne Nährwerte",
"hint": "Solange die fehlen, bleibt der Rezeptwert unvollständig."},
"name_doppelt": {"severity": "warn", "title": "Rezeptname doppelt",
"hint": "Zwei Rezepte gleichen Namens — Verwechslungsgefahr."},
"food_verwaist": {"severity": "info", "title": "Zutat in keinem Rezept",
"hint": "numrecipe 0 — Karteileiche im Stammdatenbestand."},
"unit_verwaist": {"severity": "info", "title": "Einheit ungenutzt",
"hint": "Taucht in keinem Rezept auf."},
"kw_verwaist": {"severity": "info", "title": "Schlagwort ungenutzt",
"hint": "Hängt an keinem Rezept."},
}
ORDER = {"err": 0, "warn": 1, "info": 2}
def finding(check: str, scope: str, target: str, target_id: Any, detail: str) -> dict[str, Any]:
meta = CHECKS[check]
return {
"check": check, "severity": meta["severity"], "title": meta["title"],
"hint": meta["hint"], "scope": scope, "target": target,
"target_id": target_id, "detail": detail,
}
def scan(client: TandoorClient, args: argparse.Namespace) -> dict[str, Any]:
skip = {s.strip() for s in (args.ohne or "").split(",") if s.strip()}
unknown = skip - set(CHECKS)
if unknown:
raise SystemExit(f"Unbekannte Prüfung in --ohne: {sorted(unknown)}")
out("Stammdaten werden gelesen …")
foods = client.list_objects("food")
units = client.list_objects("unit")
keywords = client.list_objects("keyword")
out(f" {len(foods)} Zutaten · {len(units)} Einheiten · {len(keywords)} Schlagworte")
out("Rezeptliste wird gelesen …")
overview = client.list_objects("recipe")
out(f" {len(overview)} Rezepte")
food_props = {f["id"]: food_property_map(f) for f in foods}
prop_type_count = len(client.list_objects("property-type"))
findings: list[dict[str, Any]] = []
# ------------------------------------------------ doppelte Rezeptnamen
if "name_doppelt" not in skip:
by_name: dict[str, list[dict]] = defaultdict(list)
for r in overview:
by_name[comparable(r.get("name"))].append(r)
for name, group in by_name.items():
if len(group) > 1:
ids = ", ".join(str(r["id"]) for r in group)
for r in group:
findings.append(finding(
"name_doppelt", "Rezept", r.get("name") or "?", r["id"],
f"{len(group)}× derselbe Name (IDs {ids})",
))
# ------------------------------------------------------ Rezept-Details
out("Rezepte werden einzeln geprüft …")
used_units: set[int] = set()
used_keywords: set[int] = set()
checked = 0
unreadable: list[str] = []
for index, entry in enumerate(overview, start=1):
rid = entry.get("id")
try:
recipe = client.get_json(f"api/recipe/{rid}/")
except TandoorError as exc:
unreadable.append(f"{rid}: {exc}")
continue
checked += 1
if index % 25 == 0 or index == len(overview):
out(f" {index}/{len(overview)}")
name = recipe.get("name") or f"Rezept {rid}"
if "quelle" not in skip and not (recipe.get("source_url") or "").strip():
findings.append(finding("quelle", "Rezept", name, rid, "source_url ist leer"))
if "bild" not in skip and not recipe.get("image"):
findings.append(finding("bild", "Rezept", name, rid, "kein Bild hinterlegt"))
if "portionen" not in skip and not (recipe.get("servings") or 0):
findings.append(finding("portionen", "Rezept", name, rid, "servings ist 0"))
if "portionstext" not in skip and not (recipe.get("servings_text") or "").strip():
findings.append(finding("portionstext", "Rezept", name, rid, "servings_text ist leer"))
if "arbeitszeit" not in skip and not (recipe.get("working_time") or 0):
findings.append(finding("arbeitszeit", "Rezept", name, rid, "working_time ist 0"))
kws = recipe.get("keywords") or []
for kw in kws:
if isinstance(kw, dict) and isinstance(kw.get("id"), int):
used_keywords.add(kw["id"])
if "schlagwort" not in skip and not kws:
findings.append(finding("schlagwort", "Rezept", name, rid, "keine Keywords"))
for step_no, step in enumerate(recipe.get("steps") or [], start=1):
if "schritt_leer" not in skip and not (step.get("instruction") or "").strip():
findings.append(finding(
"schritt_leer", "Rezept", name, rid, f"Schritt {step_no} hat keinen Text"))
for ing in step.get("ingredients") or []:
zutat = (ing.get("food") or {}).get("name") or "?"
food_id = (ing.get("food") or {}).get("id")
unit = ing.get("unit")
if isinstance(unit, dict) and isinstance(unit.get("id"), int):
used_units.add(unit["id"])
if ing.get("is_header"):
continue
no_amount = bool(ing.get("no_amount"))
if "zutat_einheit" not in skip and not unit and not no_amount:
findings.append(finding(
"zutat_einheit", "Rezept", name, rid,
f"Schritt {step_no}: „{zutat}“ hat keine Einheit"))
if "zutat_menge" not in skip and not (ing.get("amount") or 0) and not no_amount:
findings.append(finding(
"zutat_menge", "Rezept", name, rid,
f"Schritt {step_no}: „{zutat}“ hat keine Menge"))
if "zutat_naehr" not in skip and isinstance(food_id, int):
werte = food_props.get(food_id, {})
fehlen = prop_type_count - sum(1 for v in werte.values() if v is not None)
if fehlen:
findings.append(finding(
"zutat_naehr", "Rezept", name, rid,
f"{zutat}“ fehlen {fehlen} von {prop_type_count} Eigenschaften"))
# ---------------------------------------------------------- Verwaistes
if "food_verwaist" not in skip:
for f in foods:
if not (f.get("numrecipe") or 0):
findings.append(finding(
"food_verwaist", "Zutat", f.get("name") or "?", f.get("id"),
"in keinem Rezept verwendet"))
if "unit_verwaist" not in skip:
for u in units:
if u.get("id") not in used_units:
findings.append(finding(
"unit_verwaist", "Einheit", u.get("name") or "?", u.get("id"),
"in keinem geprüften Rezept verwendet"))
if "kw_verwaist" not in skip:
for k in keywords:
if k.get("id") not in used_keywords:
findings.append(finding(
"kw_verwaist", "Schlagwort", k.get("name") or "?", k.get("id"),
"an keinem geprüften Rezept"))
if args.nur:
wanted = {s.strip() for s in args.nur.split(",") if s.strip()}
findings = [f for f in findings if f["severity"] in wanted]
findings.sort(key=lambda f: (ORDER[f["severity"]], f["check"], str(f["target"]).casefold()))
per_check = Counter(f["check"] for f in findings)
per_severity = Counter(f["severity"] for f in findings)
betroffene = {(f["scope"], f["target_id"]) for f in findings}
return {
"created_at": datetime.now(timezone.utc).isoformat(),
"summary": {
"recipes": len(overview),
"recipes_checked": checked,
"foods": len(foods), "units": len(units), "keywords": len(keywords),
"findings": len(findings),
"err": per_severity.get("err", 0),
"warn": per_severity.get("warn", 0),
"info": per_severity.get("info", 0),
"affected": len(betroffene),
"clean_recipes": len(overview) - len({
f["target_id"] for f in findings if f["scope"] == "Rezept"}),
"unreadable": unreadable,
},
"checks": [
{"key": key, **meta, "count": per_check.get(key, 0), "skipped": key in skip}
for key, meta in CHECKS.items()
],
"findings": findings,
}
def write_report(report: dict[str, Any], target: Path) -> None:
target.mkdir(parents=True, exist_ok=True)
(target / "bericht.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
with (target / "befunde.csv").open("w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle, delimiter=";")
writer.writerow(["Schweregrad", "Prüfung", "Was", "Bereich", "Name", "ID", "Detail"])
for f in report["findings"]:
writer.writerow([f["severity"], f["check"], f["title"],
f["scope"], f["target"], f["target_id"], f["detail"]])
def print_report(report: dict[str, Any]) -> None:
s = report["summary"]
out()
out("" * 64)
out(f" Rezepte geprüft {s['recipes_checked']} von {s['recipes']}")
out(f" ohne jeden Befund {s['clean_recipes']}")
out(f" Befunde gesamt {s['findings']}")
out(f" Fehler {s['err']}")
out(f" Warnungen {s['warn']}")
out(f" Hinweise {s['info']}")
out("" * 64)
out()
for check in sorted(report["checks"], key=lambda c: (ORDER[c["severity"]], -c["count"])):
if check["skipped"]:
out(f" {'übersprungen':<12} {check['title']}")
elif check["count"]:
out(f" {check['severity']:<5} {check['count']:>4} × {check['title']}")
out()
hart = [f for f in report["findings"] if f["severity"] == "err"][:15]
if hart:
out(" Fehler zuerst:")
for f in hart:
out(f" {f['scope']}{f['target']}“ [{f['target_id']}]: {f['detail']}")
if s["unreadable"]:
out()
out(f" {len(s['unreadable'])} Rezepte waren nicht lesbar:")
for line in s["unreadable"][:5]:
out(f" {line[:110]}")
def main() -> int:
parser = argparse.ArgumentParser(description="Rezept-Inventur (nur lesend)")
parser.add_argument("--base-url", default=None)
parser.add_argument("--token", default=None)
parser.add_argument("--auth-scheme", default=None)
parser.add_argument("--timeout", type=float, default=None)
parser.add_argument("--insecure", action="store_true")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("pruefen", help="Sammlung durchsehen")
p.add_argument("--nur", default="", help="nur diese Schweregrade: err,warn,info")
p.add_argument("--ohne", default="", help="diese Prüfungen auslassen, kommagetrennt")
args = parser.parse_args()
try:
client = TandoorClient.from_env(
base_url=args.base_url, token=args.token, auth_scheme=args.auth_scheme,
timeout=args.timeout, verify=False if args.insecure else None,
)
except TandoorError as exc:
out(f"Tandoor-Zugang fehlt: {exc}")
return 2
try:
report = scan(client, args)
except TandoorError as exc:
out(f"Tandoor meldet: {exc}")
return 1
except KeyboardInterrupt:
out("Abgebrochen.")
return 130
target = data_dir() / "bericht"
write_report(report, target)
print_report(report)
out()
out(f"Bericht: {target / 'bericht.json'}")
out(f"Tabelle: {target / 'befunde.csv'}")
out()
out("Es wurde nichts verändert — dieses Werkzeug liest nur.")
return 0
if __name__ == "__main__":
raise SystemExit(main())