chore: initial import
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Rezept-Inventur
|
||||
|
||||
Geht die Sammlung durch und meldet, was fehlt oder schief steht.
|
||||
|
||||
**Dieses Werkzeug liest nur.** Es gibt keinen Schalter, der etwas verändert,
|
||||
und keinen schreibenden Endpunkt. Ergebnis ist ein Bericht und eine CSV.
|
||||
|
||||
## Was geprüft wird
|
||||
|
||||
| Schwere | Prüfung | warum |
|
||||
|---|---|---|
|
||||
| Fehler | Keine Portionsangabe | ohne `servings` rechnet Tandoor nichts um |
|
||||
| Fehler | Schritt ohne Anleitung | beim Kochen wertlos |
|
||||
| Warnung | Keine Quelle | Herkunft nicht mehr nachvollziehbar |
|
||||
| Warnung | Zutat ohne Einheit | Menge lässt sich nicht umrechnen |
|
||||
| Warnung | Zutat ohne Menge | `amount` 0, ohne „keine Menge“ zu setzen |
|
||||
| Warnung | Rezeptname doppelt | Verwechslungsgefahr |
|
||||
| Hinweis | Kein Bild, kein Portionstext, keine Arbeitszeit, keine Schlagworte | |
|
||||
| Hinweis | Zutat ohne Nährwerte | Rezeptwert bleibt unvollständig |
|
||||
| Hinweis | Zutat / Einheit / Schlagwort ungenutzt | Karteileichen |
|
||||
|
||||
Befunde lassen sich in der Oberfläche nach Prüfung filtern und als CSV
|
||||
herunterladen.
|
||||
|
||||
## Dauer
|
||||
|
||||
Je Rezept eine Abfrage — die Rezeptliste allein enthält keine Schritte. Bei
|
||||
einigen hundert Rezepten dauert der Durchlauf entsprechend. Der Fortschritt
|
||||
steht im Log.
|
||||
|
||||
## Ohne Suite
|
||||
|
||||
export TANDOOR_URL=https://kitchen.example.de
|
||||
export TANDOOR_TOKEN=...
|
||||
python3 plugins/tandoor-lint/tool/lint.py pruefen
|
||||
python3 plugins/tandoor-lint/tool/lint.py pruefen --nur err,warn
|
||||
python3 plugins/tandoor-lint/tool/lint.py pruefen --ohne bild,arbeitszeit
|
||||
|
||||
## Dateien
|
||||
|
||||
data/tandoor-lint/bericht/bericht.json
|
||||
data/tandoor-lint/bericht/befunde.csv
|
||||
@@ -0,0 +1,104 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Plugin-Adapter für die Rezept-Inventur.
|
||||
|
||||
Nur lesend: Es gibt hier bewusst keinen Endpunkt, der etwas verändern könnte.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.jobs import job_router
|
||||
|
||||
TOOL = "lint.py"
|
||||
SEVERITIES = {"err", "warn", "info"}
|
||||
|
||||
|
||||
class ScanRequest(BaseModel):
|
||||
only: list[str] = Field(default_factory=list)
|
||||
skip: list[str] = Field(default_factory=list)
|
||||
insecure: bool = False
|
||||
|
||||
|
||||
def create_app(ctx):
|
||||
tool = ctx.path("tool", TOOL)
|
||||
report_file = ctx.data_dir / "bericht" / "bericht.json"
|
||||
csv_file = ctx.data_dir / "bericht" / "befunde.csv"
|
||||
|
||||
def tool_env() -> dict:
|
||||
"""
|
||||
Umgebung für den Subprozess.
|
||||
|
||||
Wichtig: DATA_DIR ausdrücklich mitgeben. Ohne das schreibt das Skript
|
||||
seinen Rückfallpfad neben die Anwendung — im Container ist der
|
||||
schreibgeschützt, und die Oberfläche würde den Bericht nie finden.
|
||||
"""
|
||||
env = ctx.settings.tool_env()
|
||||
env["DATA_DIR"] = str(ctx.data_dir)
|
||||
return env
|
||||
|
||||
app = FastAPI(title=ctx.meta.name, docs_url="/api/docs", redoc_url=None)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def index():
|
||||
return FileResponse(ctx.path("static", "index.html"))
|
||||
|
||||
@app.get("/api/state")
|
||||
def state() -> dict[str, Any]:
|
||||
report = None
|
||||
if report_file.is_file():
|
||||
try:
|
||||
report = json.loads(report_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
report = None
|
||||
running = [j.info() for j in ctx.jobs.running(ctx.id)]
|
||||
return {
|
||||
"report": report,
|
||||
"tandoor": ctx.settings.status()["tandoor"],
|
||||
"running": running[0] if running else None,
|
||||
"has_csv": csv_file.is_file(),
|
||||
}
|
||||
|
||||
@app.get("/api/export.csv")
|
||||
def export():
|
||||
if not csv_file.is_file():
|
||||
raise HTTPException(404, "Noch kein Bericht vorhanden.")
|
||||
return FileResponse(csv_file, media_type="text/csv", filename="befunde.csv")
|
||||
|
||||
@app.post("/api/run/scan")
|
||||
async def run_scan(request: ScanRequest) -> dict[str, Any]:
|
||||
if ctx.jobs.running(ctx.id):
|
||||
raise HTTPException(409, "Die Inventur läuft bereits.")
|
||||
if not ctx.settings.status()["tandoor"]:
|
||||
raise HTTPException(400, "Tandoor-URL und Token fehlen – siehe Einstellungen.")
|
||||
|
||||
argv = [sys.executable, str(tool)]
|
||||
if request.insecure:
|
||||
argv.append("--insecure")
|
||||
argv.append("pruefen")
|
||||
|
||||
bad = [s for s in request.only if s not in SEVERITIES]
|
||||
if bad:
|
||||
raise HTTPException(400, f"Unbekannter Schweregrad: {bad}")
|
||||
if request.only:
|
||||
argv += ["--nur", ",".join(request.only)]
|
||||
if request.skip:
|
||||
# Die Schlüssel prüft das Skript selbst; hier nur grob absichern.
|
||||
clean = [s for s in request.skip if s.replace("_", "").isalnum()]
|
||||
if clean:
|
||||
argv += ["--ohne", ",".join(clean)]
|
||||
|
||||
job = await ctx.jobs.start(
|
||||
plugin=ctx.id, label="Inventur der Sammlung", argv=argv,
|
||||
cwd=ctx.data_dir, env=tool_env(),
|
||||
)
|
||||
return job.info()
|
||||
|
||||
app.include_router(job_router(ctx.jobs, ctx.id))
|
||||
return app
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "tandoor-lint",
|
||||
"name": "Rezept-Inventur",
|
||||
"summary": "Findet fehlende Quellen, Bilder, Portionen, Einheiten, Dubletten",
|
||||
"description": "Geht die ganze Sammlung durch und meldet, was fehlt oder schief steht: Rezepte ohne Quelle oder Portionsangabe, Zutaten ohne Einheit, doppelte Rezeptnamen, verwaiste Stammdaten. Liest ausschließlich – verändert nichts.",
|
||||
"icon": "🔍",
|
||||
"category": "Tandoor",
|
||||
"version": "1.0.0",
|
||||
"entrypoint": "backend:create_app",
|
||||
"order": 15,
|
||||
"requires": ["tandoor"],
|
||||
"features": [
|
||||
"14 Prüfungen, nach Fehler / Warnung / Hinweis sortiert",
|
||||
"Nur lesend – kein Schalter kann etwas verändern",
|
||||
"Befunde filterbar, Export als CSV",
|
||||
"Zeigt auch verwaiste Zutaten, Einheiten und Schlagworte"
|
||||
],
|
||||
"docs": "TOOL-README.md"
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Rezept-Inventur</title>
|
||||
<link rel="stylesheet" href="/shared/boehmi.css">
|
||||
<script src="/shared/boehmi.js"></script>
|
||||
<script src="/shared/boehmi-runner.js"></script>
|
||||
<style>
|
||||
.facts { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 14px; }
|
||||
.fact { background: var(--bt-surface); border: 1px solid var(--bt-line); border-radius: var(--bt-r-lg); padding: 13px 14px; box-shadow: var(--bt-shadow); }
|
||||
.fact b { display: block; font-size: 24px; font-weight: 850; letter-spacing: -.03em; }
|
||||
.fact span { font-size: 10.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--bt-muted); }
|
||||
.fact.err b { color: var(--bt-err); }
|
||||
.fact.warn b { color: var(--bt-warn); }
|
||||
.fact.ok b { color: var(--bt-ok); }
|
||||
|
||||
.checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 7px; }
|
||||
.ck { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: center;
|
||||
padding: 8px 10px; border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
|
||||
background: var(--bt-surface); cursor: pointer; }
|
||||
.ck:hover { background: var(--bt-surface-2); }
|
||||
.ck .t { font-size: 12px; font-weight: 600; line-height: 1.25; }
|
||||
.ck .c { font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
|
||||
.ck.off { opacity: .45; }
|
||||
|
||||
.flist { border: 1px solid var(--bt-line); border-radius: var(--bt-r); overflow: hidden; }
|
||||
.fitem { display: grid; grid-template-columns: 56px 1fr auto; gap: 10px; align-items: baseline;
|
||||
padding: 9px 11px; border-bottom: 1px solid var(--bt-line-soft); font-size: 13px; }
|
||||
.fitem:last-child { border-bottom: none; }
|
||||
.fitem:hover { background: var(--bt-surface-2); }
|
||||
.fitem .who { font-weight: 650; }
|
||||
.fitem .det { color: var(--bt-muted); font-size: 12px; }
|
||||
.fitem .id { font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
|
||||
.sev { font-size: 9.5px; font-weight: 850; letter-spacing: .06em; text-transform: uppercase;
|
||||
padding: 2px 6px; border-radius: 4px; text-align: center; }
|
||||
.sev.err { color: var(--bt-err); background: var(--bt-err-soft); }
|
||||
.sev.warn { color: var(--bt-warn); background: var(--bt-warn-soft); }
|
||||
.sev.info { color: var(--bt-muted); background: var(--bt-bg-2); }
|
||||
.grp { padding: 9px 11px; background: var(--bt-surface-2); border-bottom: 1px solid var(--bt-line);
|
||||
font-size: 11px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--bt-muted); }
|
||||
.grp .h { text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--bt-muted); font-size: 11.5px; }
|
||||
@media (max-width: 900px) { .facts { grid-template-columns: repeat(2, 1fr); } .fitem { grid-template-columns: 52px 1fr; } .fitem .id { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body data-bt-title="Rezept-Inventur" data-bt-icon="🔍">
|
||||
|
||||
<main class="bt-main wide">
|
||||
<div class="bt-pagehead">
|
||||
<h1>Rezept-Inventur</h1>
|
||||
<p>Geht die Sammlung durch und meldet, was fehlt oder schief steht.
|
||||
Dieses Werkzeug liest ausschließlich — es verändert in Tandoor nichts.</p>
|
||||
</div>
|
||||
|
||||
<div id="warn" class="bt-notice err bt-hidden"></div>
|
||||
|
||||
<div class="bt-card">
|
||||
<div class="bt-row" style="align-items:center">
|
||||
<div style="flex:1">
|
||||
<h2 style="margin:0">Durchsehen</h2>
|
||||
<p class="hint" style="margin:4px 0 0">
|
||||
Je Rezept eine Abfrage — bei vielen Rezepten dauert das einen Moment.</p>
|
||||
</div>
|
||||
<button class="primary" id="scan">Inventur starten</button>
|
||||
<button class="ghost mini" id="cancel" disabled>Abbrechen</button>
|
||||
</div>
|
||||
<div id="status" class="bt-status bt-hidden">Bereit.</div>
|
||||
<div id="log" class="bt-log bt-hidden"></div>
|
||||
</div>
|
||||
|
||||
<div id="box" class="bt-empty">Noch keine Inventur gelaufen.</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let state = {};
|
||||
let report = null;
|
||||
const hidden = new Set();
|
||||
let search = "";
|
||||
|
||||
const runner = BT.Runner({
|
||||
endpoint: "/api/run/scan",
|
||||
log: $("log"), status: $("status"),
|
||||
onStart: () => {
|
||||
$("log").classList.remove("bt-hidden");
|
||||
$("status").classList.remove("bt-hidden");
|
||||
$("cancel").disabled = false;
|
||||
},
|
||||
onFinish: (info) => {
|
||||
$("cancel").disabled = true;
|
||||
BT.toast(info.status === "done" ? "Inventur fertig." : "Beendet: " + info.status,
|
||||
info.status === "done" ? "ok" : "err");
|
||||
load();
|
||||
},
|
||||
});
|
||||
$("scan").addEventListener("click", () => runner.start({ only: [], skip: [] }));
|
||||
$("cancel").addEventListener("click", () => runner.cancel());
|
||||
|
||||
function render() {
|
||||
if (!report) return;
|
||||
const s = report.summary;
|
||||
const aktive = report.checks.filter((c) => c.count > 0);
|
||||
|
||||
$("box").className = "";
|
||||
$("box").innerHTML = `
|
||||
<div class="facts">
|
||||
<div class="fact"><b>${s.recipes_checked}</b><span>Rezepte geprüft</span></div>
|
||||
<div class="fact ok"><b>${s.clean_recipes}</b><span>ohne Befund</span></div>
|
||||
<div class="fact err"><b>${s.err}</b><span>Fehler</span></div>
|
||||
<div class="fact warn"><b>${s.warn}</b><span>Warnungen</span></div>
|
||||
<div class="fact"><b>${s.info}</b><span>Hinweise</span></div>
|
||||
</div>
|
||||
|
||||
${s.unreadable?.length ? `<div class="bt-notice warn">
|
||||
${s.unreadable.length} Rezepte konnten nicht gelesen werden und fehlen im Bericht.
|
||||
</div>` : ""}
|
||||
|
||||
<section class="bt-card">
|
||||
<h2>Was wurde gefunden</h2>
|
||||
<p class="hint">Zum Ein- und Ausblenden anklicken.</p>
|
||||
<div class="checks">${aktive.map((c) => `
|
||||
<div class="ck ${hidden.has(c.key) ? "off" : ""}" data-ck="${c.key}" title="${BT.escape(c.hint)}">
|
||||
<span class="sev ${c.severity}">${c.severity}</span>
|
||||
<span class="t">${BT.escape(c.title)}</span>
|
||||
<span class="c">${c.count}</span>
|
||||
</div>`).join("") || `<span class="hint">Nichts gefunden — sauber.</span>`}</div>
|
||||
</section>
|
||||
|
||||
<section class="bt-card">
|
||||
<div class="bt-row" style="align-items:center;margin-bottom:9px">
|
||||
<h2 style="margin:0;flex:1">Befunde</h2>
|
||||
<input type="search" id="q" placeholder="Rezept oder Zutat …" style="max-width:230px" value="${BT.escape(search)}">
|
||||
<a class="bt-btn mini ghost" href="${BT.url("/api/export.csv")}" download>CSV</a>
|
||||
</div>
|
||||
<div class="flist" id="list"></div>
|
||||
<p class="hint" id="more" style="margin-top:8px"></p>
|
||||
</section>`;
|
||||
|
||||
$("q").addEventListener("input", (e) => { search = e.target.value; draw(); });
|
||||
document.querySelectorAll("[data-ck]").forEach((el) => el.addEventListener("click", () => {
|
||||
const k = el.dataset.ck;
|
||||
hidden.has(k) ? hidden.delete(k) : hidden.add(k);
|
||||
el.classList.toggle("off", hidden.has(k));
|
||||
draw();
|
||||
}));
|
||||
draw();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const q = search.toLowerCase();
|
||||
const rows = report.findings.filter((f) =>
|
||||
!hidden.has(f.check) &&
|
||||
(!q || String(f.target).toLowerCase().includes(q) || f.detail.toLowerCase().includes(q)));
|
||||
|
||||
const byCheck = {};
|
||||
rows.forEach((f) => (byCheck[f.check] ||= []).push(f));
|
||||
|
||||
let html = "";
|
||||
let shown = 0;
|
||||
for (const [key, items] of Object.entries(byCheck)) {
|
||||
const meta = report.checks.find((c) => c.key === key);
|
||||
html += `<div class="grp">${BT.escape(meta.title)} · ${items.length}
|
||||
<span class="h">— ${BT.escape(meta.hint)}</span></div>`;
|
||||
for (const f of items.slice(0, 60)) {
|
||||
shown++;
|
||||
html += `<div class="fitem">
|
||||
<span class="sev ${f.severity}">${f.severity}</span>
|
||||
<span><span class="who">${BT.escape(f.target)}</span>
|
||||
<span class="det"> — ${BT.escape(f.detail)}</span></span>
|
||||
<span class="id">${f.scope} ${f.target_id}</span>
|
||||
</div>`;
|
||||
}
|
||||
if (items.length > 60) {
|
||||
html += `<div class="fitem"><span></span>
|
||||
<span class="det">… und ${items.length - 60} weitere. Die vollständige Liste steht in der CSV.</span>
|
||||
<span></span></div>`;
|
||||
}
|
||||
}
|
||||
$("list").innerHTML = html || `<div class="bt-empty" style="border:none">Nichts übrig — Filter zurücksetzen?</div>`;
|
||||
$("more").textContent = `${rows.length} Befunde angezeigt, ${report.findings.length} insgesamt.`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
state = await BT.api("/api/state");
|
||||
report = state.report;
|
||||
$("warn").classList.toggle("bt-hidden", state.tandoor);
|
||||
if (!state.tandoor) {
|
||||
$("warn").innerHTML = `Tandoor-URL und Token fehlen. Bitte in den
|
||||
<a href="/settings">Einstellungen</a> hinterlegen.`;
|
||||
}
|
||||
$("scan").disabled = !state.tandoor;
|
||||
if (report) render();
|
||||
if (state.running) runner.attach(state.running.id);
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user