332 lines
14 KiB
Python
332 lines
14 KiB
Python
#!/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())
|