chore: initial import
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Einheiten-Umrechnungen — fehlende, sinnvolle Umrechnungen finden und eintragen.
|
||||
|
||||
Sinnvoll ist eine Umrechnung vor allem dort, wo eine Zutat in Rezepten mit
|
||||
mehreren, nicht ohne Weiteres ineinander umrechenbaren Einheiten vorkommt
|
||||
(z. B. „Stück“ und „g“) und dafür noch keine Umrechnung hinterlegt ist. Ohne
|
||||
sie kann Tandoor solche Mengen nicht skalieren oder auf die Einkaufsliste
|
||||
bringen.
|
||||
|
||||
WICHTIG: Die von der KI gelieferten Werte (z. B. „1 Zwiebel ≈ 110 g“) sind
|
||||
SCHÄTZUNGEN. Sie sind vor dem Eintragen zu prüfen.
|
||||
|
||||
Befehle:
|
||||
pruefen Rezepte, Einheiten und vorhandene Umrechnungen lesen
|
||||
vorschlagen ChatGPT schlägt Werte für die fehlenden Umrechnungen vor
|
||||
anwenden Freigegebene Umrechnungen anlegen
|
||||
zurueck Einen Lauf zurückspielen (löscht die angelegten Umrechnungen)
|
||||
probe OpenAI-Verbindung testen
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_PARENTS = Path(__file__).resolve().parents
|
||||
SUITE_ROOT = _PARENTS[3] if len(_PARENTS) > 3 else Path.cwd()
|
||||
if str(SUITE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SUITE_ROOT))
|
||||
|
||||
from core.tandoor import TandoorClient, TandoorError # noqa: E402
|
||||
from core import backups # noqa: E402
|
||||
from core import ai # noqa: E402
|
||||
|
||||
BATCH = 30
|
||||
|
||||
|
||||
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-conversions"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def make_client(args: argparse.Namespace) -> TandoorClient:
|
||||
base = args.base_url or os.environ.get("TANDOOR_URL", "")
|
||||
token = args.token or os.environ.get("TANDOOR_TOKEN", "")
|
||||
scheme = args.auth_scheme or os.environ.get("TANDOOR_AUTH_SCHEME", "Bearer")
|
||||
return TandoorClient(base, token, auth_scheme=scheme,
|
||||
verify=not args.insecure, timeout=args.timeout)
|
||||
|
||||
|
||||
def _unit_of(ing: dict) -> tuple[int, str] | None:
|
||||
u = ing.get("unit")
|
||||
if isinstance(u, dict) and isinstance(u.get("id"), int):
|
||||
return u["id"], (u.get("name") or "")
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Prüfen
|
||||
|
||||
# Einheiten, die bereits „gewichtsartig“ und generisch nach Gramm umrechenbar
|
||||
# sind — dafür braucht es keine food-spezifische Umrechnung.
|
||||
_GRAM_COMPATIBLE = {
|
||||
"g", "gramm", "gram", "gramme", "kg", "kilo", "kilogramm", "kilogram",
|
||||
"mg", "milligramm", "dag", "dkg", "dekagramm", "pfund",
|
||||
}
|
||||
|
||||
|
||||
def _norm(name: str) -> str:
|
||||
return (name or "").strip().casefold()
|
||||
|
||||
|
||||
def _find_gram_unit(units: list[dict]) -> dict | None:
|
||||
# Bevorzugt exakt „g“, sonst „gramm“/„gram“.
|
||||
for wanted in ("g",):
|
||||
for u in units:
|
||||
if _norm(u.get("name")) == wanted:
|
||||
return u
|
||||
for u in units:
|
||||
if _norm(u.get("name")) in ("gramm", "gram", "gramme"):
|
||||
return u
|
||||
return None
|
||||
|
||||
|
||||
def scan(client: TandoorClient) -> dict[str, Any]:
|
||||
out("Einheiten werden gelesen …")
|
||||
units = client.list_objects("unit")
|
||||
unit_name = {u["id"]: (u.get("name") or "") for u in units}
|
||||
gram = _find_gram_unit(units)
|
||||
if not gram:
|
||||
out(" ⚠ Keine Gramm-Einheit („g“) in Tandoor gefunden — bitte zuerst "
|
||||
"eine Einheit „g“ anlegen. Es kann nichts nach Gramm umgerechnet werden.")
|
||||
gram_id = gram["id"] if gram else None
|
||||
gram_name = gram.get("name") if gram else "g"
|
||||
|
||||
# Alle Einheiten, die ohne food-spezifisches Wissen bei Gramm ankommen
|
||||
# (Gramm selbst + generische Gewichtseinheiten).
|
||||
gram_seed = {gram_id} if gram_id else set()
|
||||
for u in units:
|
||||
if _norm(u.get("name")) in _GRAM_COMPATIBLE:
|
||||
gram_seed.add(u["id"])
|
||||
|
||||
out("Vorhandene Umrechnungen werden gelesen …")
|
||||
existing = client.list_objects("unit-conversion")
|
||||
# Kanten (welche Einheiten sind durch eine Umrechnung verbunden) je Zutat,
|
||||
# plus globale Umrechnungen (food=None), die für alle Zutaten gelten.
|
||||
edges: dict[int | None, list[tuple[int, int]]] = {}
|
||||
conv_units: dict[int, set[int]] = {} # food_id -> Einheiten aus Umrechnungen
|
||||
conv_food_name: dict[int, str] = {}
|
||||
for uc in existing:
|
||||
food = uc.get("food") or {}
|
||||
fid = food.get("id") if isinstance(food, dict) else None
|
||||
bu = (uc.get("base_unit") or {}).get("id")
|
||||
cu = (uc.get("converted_unit") or {}).get("id")
|
||||
if not (bu and cu):
|
||||
continue
|
||||
edges.setdefault(fid, []).append((bu, cu))
|
||||
if isinstance(fid, int):
|
||||
conv_units.setdefault(fid, set()).update({bu, cu})
|
||||
if food.get("name"):
|
||||
conv_food_name[fid] = food["name"]
|
||||
out(f" {len(existing)} Umrechnungen vorhanden.")
|
||||
|
||||
out("Rezepte werden gelesen (welche Zutat mit welchen Einheiten) …")
|
||||
overview = client.list_objects("recipe")
|
||||
used: dict[int, set[int]] = {}
|
||||
food_name: dict[int, str] = {}
|
||||
for i, entry in enumerate(overview, start=1):
|
||||
try:
|
||||
recipe = client.get_json(f"api/recipe/{entry['id']}/")
|
||||
except TandoorError:
|
||||
continue
|
||||
for step in recipe.get("steps") or []:
|
||||
for ing in step.get("ingredients") or []:
|
||||
food = ing.get("food") or {}
|
||||
fid = food.get("id")
|
||||
unit = _unit_of(ing)
|
||||
if isinstance(fid, int) and unit:
|
||||
used.setdefault(fid, set()).add(unit[0])
|
||||
unit_name.setdefault(unit[0], unit[1])
|
||||
food_name.setdefault(fid, food.get("name") or "")
|
||||
if i % 25 == 0 or i == len(overview):
|
||||
out(f" {i}/{len(overview)}")
|
||||
|
||||
def reaches_gram(food_id: int) -> set[int]:
|
||||
"""Alle Einheiten, die für diese Zutat (transitiv über vorhandene
|
||||
Umrechnungen + globale) bei Gramm ankommen."""
|
||||
reached = set(gram_seed)
|
||||
kanten = edges.get(food_id, []) + edges.get(None, [])
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for a, b in kanten:
|
||||
if a in reached and b not in reached:
|
||||
reached.add(b); changed = True
|
||||
elif b in reached and a not in reached:
|
||||
reached.add(a); changed = True
|
||||
return reached
|
||||
|
||||
counter = {"n": 0}
|
||||
|
||||
def new_aid() -> str:
|
||||
counter["n"] += 1
|
||||
return f"u{counter['n']:04d}-{secrets.token_hex(2)}"
|
||||
|
||||
# Relevante Zutaten: kommen in Rezepten vor ODER haben bereits Umrechnungen.
|
||||
alle_foods = set(used) | set(conv_units)
|
||||
kandidaten = []
|
||||
ohne_gramm_einheit = False
|
||||
for fid in alle_foods:
|
||||
name = food_name.get(fid) or conv_food_name.get(fid) or f"#{fid}"
|
||||
relevant = set(used.get(fid, set())) | set(conv_units.get(fid, set()))
|
||||
reached = reaches_gram(fid) if gram_id else set()
|
||||
for uid in sorted(relevant, key=lambda x: (unit_name.get(x, "") or "").casefold()):
|
||||
uname = unit_name.get(uid, str(uid))
|
||||
if not gram_id:
|
||||
ohne_gramm_einheit = True
|
||||
continue
|
||||
if uid == gram_id or _norm(uname) in _GRAM_COMPATIBLE:
|
||||
continue # schon gewichtsartig / Gramm
|
||||
if uid in reached:
|
||||
continue # erreicht Gramm bereits (auch mehrstufig)
|
||||
in_recipe = uid in used.get(fid, set())
|
||||
kandidaten.append({
|
||||
"aid": new_aid(),
|
||||
"food_id": fid,
|
||||
"food_name": name,
|
||||
"unit": {"id": uid, "name": uname},
|
||||
"gram_id": gram_id,
|
||||
"gram_name": gram_name,
|
||||
"quelle": "rezept" if in_recipe else "umrechnung",
|
||||
"base_amount": 1,
|
||||
"grams": None,
|
||||
"reason": "",
|
||||
"ai": False,
|
||||
"accept": False,
|
||||
})
|
||||
kandidaten.sort(key=lambda k: ((k["food_name"] or "").casefold(), k["unit"]["name"]))
|
||||
aus_umr = sum(1 for k in kandidaten if k["quelle"] == "umrechnung")
|
||||
out(f" {len(kandidaten)} Einheiten ohne Weg zu Gramm "
|
||||
f"({len(kandidaten) - aus_umr} aus Rezepten, {aus_umr} aus vorhandenen Umrechnungen).")
|
||||
|
||||
return {
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"tandoor": client.base_url,
|
||||
"existing_count": len(existing),
|
||||
"gram_id": gram_id,
|
||||
"gram_name": gram_name,
|
||||
"candidates": kandidaten,
|
||||
}
|
||||
|
||||
|
||||
def print_scan(plan: dict[str, Any]) -> None:
|
||||
out()
|
||||
out("─" * 60)
|
||||
out(f" {plan['existing_count']} vorhandene Umrechnungen · "
|
||||
f"{len(plan['candidates'])} ohne Weg zu Gramm")
|
||||
out("─" * 60)
|
||||
for k in plan["candidates"][:20]:
|
||||
marke = "" if k.get("quelle") == "rezept" else " (aus Umrechnung)"
|
||||
out(f" {k['food_name']} [{k['food_id']}]: "
|
||||
f"1 {k['unit']['name']} = ? {plan.get('gram_name', 'g')}{marke}")
|
||||
if len(plan["candidates"]) > 20:
|
||||
out(f" … und {len(plan['candidates']) - 20} weitere")
|
||||
|
||||
|
||||
# ------------------------------------------------------------- KI-Vorschlag
|
||||
|
||||
def vorschlagen(plan: dict[str, Any], model: str) -> int:
|
||||
gram_name = plan.get("gram_name", "g")
|
||||
ziele = [k for k in plan["candidates"] if k.get("grams") is None]
|
||||
if not ziele:
|
||||
out("Nichts offen.")
|
||||
return 0
|
||||
|
||||
system = (
|
||||
"Du bist Experte für Lebensmittel und Küchenmengen. Für jede Zutat und "
|
||||
"ihre Einheit gibst du an, wie viel GRAMM eine typische Menge dieser "
|
||||
"Einheit wiegt (z. B. „1 Stück Zwiebel ≈ 110 g“, „1 EL Öl ≈ 9 g“, "
|
||||
"„1 Prise Salz ≈ 0,4 g“). Die Werte sind Schätzungen für übliche Größen. "
|
||||
"Gib pro Eintrag zusätzlich an, auf welche Ausgangsmenge (base_amount) "
|
||||
"sich das Gramm-Gewicht bezieht — meist 1, bei sehr kleinen Mengen darf "
|
||||
"es auch 100 sein (z. B. 100 ml). Kannst du eine Zutat/Einheit gar nicht "
|
||||
"sinnvoll schätzen, setze grams auf null. Antworte nur mit JSON."
|
||||
)
|
||||
gesetzt = 0
|
||||
for start in range(0, len(ziele), BATCH):
|
||||
teil = ziele[start:start + BATCH]
|
||||
anfrage = [{"id": k["aid"], "zutat": k["food_name"], "einheit": k["unit"]["name"]}
|
||||
for k in teil]
|
||||
user = (
|
||||
f"Zieleinheit ist immer „{gram_name}“ (Gramm). Gib ein JSON-Objekt "
|
||||
"zurück: Schlüssel ist die „id“, Wert ist "
|
||||
'{"base_amount": Zahl (meist 1), "grams": Gramm für diese '
|
||||
'base_amount (oder null), "grund": "kurz"}.\n\nEinträge:\n'
|
||||
+ json.dumps(anfrage, ensure_ascii=False)
|
||||
)
|
||||
antwort = ai.chat_json(
|
||||
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||
model,
|
||||
)
|
||||
if not isinstance(antwort, dict):
|
||||
continue
|
||||
for k in teil:
|
||||
d = antwort.get(k["aid"])
|
||||
k["ai"] = True
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
k["reason"] = str(d.get("grund") or "")[:200]
|
||||
try:
|
||||
grams = float(d["grams"])
|
||||
base = float(d.get("base_amount") or 1)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if grams <= 0 or base <= 0:
|
||||
continue
|
||||
k["base_amount"] = base
|
||||
k["grams"] = grams
|
||||
k["accept"] = True
|
||||
gesetzt += 1
|
||||
return gesetzt
|
||||
|
||||
|
||||
# --------------------------------------------------------------- Anwenden
|
||||
|
||||
def anwenden(client: TandoorClient, plan: dict[str, Any], args: argparse.Namespace) -> int:
|
||||
gram_id = plan.get("gram_id")
|
||||
gram_name = plan.get("gram_name", "g")
|
||||
if not gram_id:
|
||||
out("Keine Gramm-Einheit in Tandoor — bitte zuerst „g“ anlegen. Abbruch.")
|
||||
return 1
|
||||
|
||||
ziele = [k for k in plan["candidates"]
|
||||
if k.get("accept") and k.get("grams") and k.get("base_amount")]
|
||||
if not ziele:
|
||||
out("Nichts angehakt (oder ohne Wert).")
|
||||
return 0
|
||||
|
||||
out(f"Modus: {'ANWENDEN' if args.apply else 'TROCKENÜBUNG'}")
|
||||
out(f"Tandoor: {client.base_url}")
|
||||
out(f"{len(ziele)} Umrechnungen nach {gram_name}")
|
||||
out()
|
||||
|
||||
run = data_dir() / "laeufe" / stamp()
|
||||
if args.apply:
|
||||
run.mkdir(parents=True, exist_ok=True)
|
||||
manifest: dict[str, Any] = {
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"plugin": "tandoor-conversions",
|
||||
"label": "Umrechnungen anlegen",
|
||||
"tandoor": client.base_url,
|
||||
"mode": "apply" if args.apply else "dry",
|
||||
"steps": [],
|
||||
}
|
||||
|
||||
done, failed = 0, 0
|
||||
for nummer, k in enumerate(ziele, start=1):
|
||||
text = (f"{k['base_amount']:g} {k['unit']['name']} "
|
||||
f"= {k['grams']:g} {gram_name}")
|
||||
prefix = f"[{nummer}/{len(ziele)}] „{k['food_name']}“: {text}"
|
||||
|
||||
payload = {
|
||||
"food": {"id": k["food_id"], "name": k["food_name"]},
|
||||
"base_amount": k["base_amount"],
|
||||
"base_unit": {"id": k["unit"]["id"], "name": k["unit"]["name"]},
|
||||
"converted_amount": k["grams"],
|
||||
"converted_unit": {"id": gram_id, "name": gram_name},
|
||||
}
|
||||
|
||||
if not args.apply:
|
||||
out(f"{prefix}: würde anlegen")
|
||||
done += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
created = client.post_json("api/unit-conversion/", payload)
|
||||
except TandoorError as exc:
|
||||
out(f"{prefix}: FEHLER — {exc}")
|
||||
failed += 1
|
||||
if not args.continue_on_error:
|
||||
return 1
|
||||
continue
|
||||
|
||||
new_id = created.get("id") if isinstance(created, dict) else None
|
||||
manifest["steps"].append({
|
||||
"n": nummer,
|
||||
"action": "create_conversion",
|
||||
"conversion_id": new_id,
|
||||
"food_name": k["food_name"],
|
||||
"text": text,
|
||||
"restore_level": "voll" if new_id else "nein",
|
||||
"status": "done",
|
||||
})
|
||||
backups.write_manifest(run, manifest)
|
||||
out(f"{prefix}: ✓ angelegt (id {new_id})")
|
||||
done += 1
|
||||
|
||||
out()
|
||||
out("─" * 60)
|
||||
verb = "angelegt" if args.apply else "würden angelegt"
|
||||
out(f" {done} {verb} · {failed} Fehler")
|
||||
if args.apply and manifest["steps"]:
|
||||
out(f" Sicherung: {run}")
|
||||
out("─" * 60)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------ Zurückspielen
|
||||
|
||||
def restore(client: TandoorClient, args: argparse.Namespace) -> int:
|
||||
run = backups.resolve_run(data_dir() / "laeufe", args.lauf)
|
||||
manifest = backups.read_manifest(run)
|
||||
if not manifest:
|
||||
raise SystemExit("Kein manifest.json in diesem Lauf.")
|
||||
|
||||
out(f"Modus: {'ZURÜCKSPIELEN' if args.apply else 'VORSCHAU'}")
|
||||
zurueck, fehler = 0, 0
|
||||
for step in reversed(manifest.get("steps", [])):
|
||||
if step.get("action") != "create_conversion":
|
||||
continue
|
||||
cid = step.get("conversion_id")
|
||||
prefix = f"[{step['n']}] „{step['food_name']}“: {step.get('text','')}"
|
||||
if not cid:
|
||||
out(f"{prefix}: keine ID gespeichert — nicht löschbar")
|
||||
continue
|
||||
if not args.apply:
|
||||
out(f"{prefix}: würde Umrechnung {cid} löschen")
|
||||
zurueck += 1
|
||||
continue
|
||||
try:
|
||||
client.delete(f"api/unit-conversion/{cid}/")
|
||||
out(f"{prefix}: ✓ gelöscht")
|
||||
zurueck += 1
|
||||
except TandoorError as exc:
|
||||
out(f"{prefix}: FEHLER — {exc}")
|
||||
fehler += 1
|
||||
|
||||
if args.apply:
|
||||
backups.mark_restored(run, {"zurueck": zurueck, "fehler": fehler})
|
||||
out(f"{zurueck} gelöscht · {fehler} Fehler")
|
||||
return 1 if fehler else 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- CLI
|
||||
|
||||
def load_plan(path: str) -> tuple[Path, dict[str, Any]]:
|
||||
file = Path(path).expanduser().resolve()
|
||||
if not file.is_file():
|
||||
raise SystemExit(f"Plandatei nicht gefunden: {file}")
|
||||
return file, json.loads(file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Einheiten-Umrechnungen")
|
||||
parser.add_argument("--base-url", default="")
|
||||
parser.add_argument("--token", default="")
|
||||
parser.add_argument("--auth-scheme", default="")
|
||||
parser.add_argument("--timeout", type=float, default=45.0)
|
||||
parser.add_argument("--insecure", action="store_true")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("pruefen", help="fehlende Umrechnungen finden")
|
||||
|
||||
p = sub.add_parser("vorschlagen", help="ChatGPT schlägt Werte vor")
|
||||
p.add_argument("--plan", required=True)
|
||||
p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5"))
|
||||
|
||||
p = sub.add_parser("anwenden", help="freigegebene Umrechnungen anlegen")
|
||||
p.add_argument("--plan", required=True)
|
||||
p.add_argument("--apply", action="store_true")
|
||||
p.add_argument("--continue-on-error", action="store_true")
|
||||
|
||||
p = sub.add_parser("zurueck", help="einen Lauf zurückspielen")
|
||||
p.add_argument("--lauf", required=True)
|
||||
p.add_argument("--apply", action="store_true")
|
||||
|
||||
p = sub.add_parser("probe", help="OpenAI-Verbindung testen")
|
||||
p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5"))
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "probe":
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
out("OPENAI_API_KEY fehlt — siehe Einstellungen.")
|
||||
return 2
|
||||
ok, meldung = ai.probe(args.model)
|
||||
out(meldung)
|
||||
return 0 if ok else 1
|
||||
|
||||
if args.command == "vorschlagen":
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
out("OPENAI_API_KEY fehlt — siehe Einstellungen.")
|
||||
return 2
|
||||
file, plan = load_plan(args.plan)
|
||||
out(f"KI schlägt Umrechnungen vor (Modell {args.model}) …")
|
||||
n = vorschlagen(plan, args.model)
|
||||
plan["ai_prefilled_at"] = datetime.now(timezone.utc).isoformat()
|
||||
file.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
out(f"{n} Umrechnungen vorgeschlagen. Werte sind SCHÄTZUNGEN — bitte prüfen.")
|
||||
return 0
|
||||
|
||||
client = make_client(args)
|
||||
|
||||
if args.command == "pruefen":
|
||||
plan = scan(client)
|
||||
file = data_dir() / "plaene" / f"{stamp()}.json"
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print_scan(plan)
|
||||
out()
|
||||
out(f"Plan: {file}")
|
||||
return 0
|
||||
|
||||
if args.command == "anwenden":
|
||||
_file, plan = load_plan(args.plan)
|
||||
return anwenden(client, plan, args)
|
||||
|
||||
if args.command == "zurueck":
|
||||
return restore(client, args)
|
||||
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user