update
This commit is contained in:
@@ -21,7 +21,8 @@ from agents import kill_process
|
||||
from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER
|
||||
from fsutil import atomic_write_text, atomic_write_json
|
||||
from jsonio import read_json_file as _json_datei
|
||||
from paths import arbeit_dir, bausteine_path, project_dir, subbausteine_path
|
||||
from paths import arbeit_dir, bausteine_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner
|
||||
from crawl import crawl
|
||||
from pipeline import (
|
||||
CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _rest_schema,
|
||||
_runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot,
|
||||
@@ -52,20 +53,45 @@ BAUSTEINE_STEPS = (
|
||||
)
|
||||
|
||||
|
||||
def lade_quelle(topic: str) -> dict:
|
||||
"""Persistierte Quellen-Wahl lesen. Fallback (Alt-Themen ohne quelle.json):
|
||||
existiert projects/<topic> → projekt, sonst thema."""
|
||||
q = _json_datei(quelle_path(topic))
|
||||
if isinstance(q, dict) and q.get("type") in ("thema", "projekt", "uni", "link"):
|
||||
return q
|
||||
if project_dir(topic).is_dir():
|
||||
return {"type": "projekt", "ort": f"projects/{topic}", "spec": ""}
|
||||
return {"type": "thema", "ort": "", "spec": ""}
|
||||
|
||||
|
||||
def quelle_ordner(topic: str) -> Path | None:
|
||||
"""Ordner-Quelle (projekt/uni → Pfad, link → Crawl-Ordner) — sonst None (thema)."""
|
||||
q = lade_quelle(topic)
|
||||
if q["type"] == "link":
|
||||
return quelle_crawl_dir(topic)
|
||||
if q["type"] in ("projekt", "uni"):
|
||||
return safe_ordner(q.get("ort", ""))
|
||||
return None
|
||||
|
||||
|
||||
def _crawl_fertig(topic: str) -> bool:
|
||||
return (quelle_crawl_dir(topic) / ".done").exists() # Marker erst bei sauberem Abschluss
|
||||
|
||||
|
||||
def _bausteine_steps(topic: str) -> tuple:
|
||||
"""Projekte haben einen zusätzlichen Schritt (Ergänzung), nach der Klärung eingefügt.
|
||||
"""Schritte je Quelle: link bekommt vorne „Quelle laden", projekt zusätzlich „Ergänzung".
|
||||
|
||||
Subbausteine + Stufen sind je drei Phasen (Finden, Wählen, Klären). Pro Phase
|
||||
laufen alle Pakete parallel; der Schritt bleibt, bis das letzte Paket fertig ist.
|
||||
"""
|
||||
q = lade_quelle(topic)
|
||||
base = ("Recherche", "Konsolidierung", "Klärung")
|
||||
rest = (
|
||||
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
|
||||
"Stufen finden", "Stufen wählen", "Stufen klären",
|
||||
)
|
||||
if project_dir(topic).is_dir():
|
||||
return base + ("Ergänzung",) + rest
|
||||
return base + rest
|
||||
mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest
|
||||
return (("Quelle laden",) if q["type"] == "link" else ()) + mitte
|
||||
|
||||
|
||||
def _step_idx(topic: str, name: str) -> int:
|
||||
@@ -111,18 +137,21 @@ def cancel_bausteine(topic: str) -> bool:
|
||||
def _resume_step(topic: str) -> int:
|
||||
"""Erster noch offener Schritt anhand der persistierten Zwischendateien."""
|
||||
files = _bausteine_files(topic)
|
||||
q = lade_quelle(topic)
|
||||
if q["type"] == "link" and not _crawl_fertig(topic):
|
||||
return _step_idx(topic, "Quelle laden")
|
||||
if sum(p.exists() for p in files["recherche"]) < 3:
|
||||
return 0
|
||||
return _step_idx(topic, "Recherche")
|
||||
if not files["recherche_mapping"].exists():
|
||||
return 1
|
||||
return _step_idx(topic, "Konsolidierung")
|
||||
mapping = _mapping_schema(_json_datei(files["recherche_mapping"]))
|
||||
geklaert = mapping is not None and (
|
||||
not mapping[1] # kein strittiger Rest
|
||||
or any((r := _runde_schema(_json_datei(p))) is not None and not r[1] for p in files["mapping"].values())
|
||||
)
|
||||
if not geklaert:
|
||||
return 2
|
||||
if project_dir(topic).is_dir() and not files["ergaenzung"].exists():
|
||||
return _step_idx(topic, "Klärung")
|
||||
if q["type"] == "projekt" and not files["ergaenzung"].exists():
|
||||
return _step_idx(topic, "Ergänzung")
|
||||
if _sidecar_schema(_json_datei(files["sidecar"])) is not None:
|
||||
return len(_bausteine_steps(topic))
|
||||
@@ -166,6 +195,8 @@ def reset_bausteine(topic: str) -> None:
|
||||
files = _bausteine_files(topic)
|
||||
files["final"].unlink(missing_ok=True)
|
||||
files["sidecar"].unlink(missing_ok=True) # liegt im Themen-Root, nicht in arbeit/
|
||||
quelle_path(topic).unlink(missing_ok=True)
|
||||
shutil.rmtree(quelle_crawl_dir(topic), ignore_errors=True) # gecrawlte Link-Quelle
|
||||
shutil.rmtree(files["arbeit"], ignore_errors=True)
|
||||
_bausteine_errors.pop(topic, None)
|
||||
|
||||
@@ -209,9 +240,12 @@ def _pdfs_konvertieren(project: Path) -> None:
|
||||
raise RuntimeError(f"PDF-Konvertierung fehlgeschlagen ({pdf.name}): {e}") from e
|
||||
|
||||
|
||||
def _build_recherche_prompt(topic: str, out_path: Path, instructions: str = "", project: Path | None = None) -> str:
|
||||
if project:
|
||||
source = _prompt("Bausteine-Quelle-Projekt", project=project)
|
||||
_QUELLE_TEMPLATE = {"projekt": "Bausteine-Quelle-Projekt", "uni": "Bausteine-Quelle-Uni", "link": "Bausteine-Quelle-Link"}
|
||||
|
||||
|
||||
def _build_recherche_prompt(topic: str, out_path: Path, instructions: str, typ: str, ordner: Path | None) -> str:
|
||||
if typ in _QUELLE_TEMPLATE:
|
||||
source = _prompt(_QUELLE_TEMPLATE[typ], project=ordner)
|
||||
else:
|
||||
source = _prompt("Bausteine-Quelle-Thema", topic=topic)
|
||||
return _prompt(
|
||||
@@ -337,7 +371,7 @@ async def _subbausteine_block(ctx: GenContext, set_p, files: dict, entries: dict
|
||||
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
||||
arbeit = files["arbeit"]
|
||||
idx = _titel_index(entries)
|
||||
caps = "files" if project_dir(topic).is_dir() else "full"
|
||||
caps = "files" if quelle_ordner(topic) else "full"
|
||||
nums = list(entries)
|
||||
chunks = _chunk_nums(nums, _n_chunks(len(nums)))
|
||||
n = len(chunks)
|
||||
@@ -548,7 +582,9 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
|
||||
files = _bausteine_files(topic)
|
||||
final_path = files["final"]
|
||||
project = project_dir(topic) if project_dir(topic).is_dir() else None
|
||||
q = lade_quelle(topic)
|
||||
ordner = quelle_ordner(topic) # projekt/uni/link → Ordner, thema → None
|
||||
instructions = q.get("spec") or instructions # persistierte Spezifikation bevorzugen (auch bei Resume)
|
||||
|
||||
def set_p(msg: str, step: int | None = None) -> None:
|
||||
_bausteine_progress[topic] = msg
|
||||
@@ -566,8 +602,18 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
try:
|
||||
async with _semaphore:
|
||||
files["arbeit"].mkdir(parents=True, exist_ok=True)
|
||||
if project:
|
||||
await asyncio.to_thread(_pdfs_konvertieren, project)
|
||||
# Link-Quelle: erst crawlen (gleiche Domain, begrenzt) → wird zur Ordner-Quelle.
|
||||
if q["type"] == "link" and not _crawl_fertig(topic):
|
||||
set_p("Quelle laden (Crawl)…", step=_step_idx(topic, "Quelle laden"))
|
||||
n = await asyncio.to_thread(crawl, q["ort"], ordner, cancelled=is_cancelled)
|
||||
if is_cancelled():
|
||||
abgebrochen()
|
||||
return
|
||||
if not n:
|
||||
_bausteine_errors[topic] = "Crawl ergab keine Inhalte — Link/Domain prüfen"
|
||||
return
|
||||
if ordner:
|
||||
await asyncio.to_thread(_pdfs_konvertieren, ordner)
|
||||
# „Neu erstellen": NUR wenn wirklich alles fertig ist (bausteine.md UND
|
||||
# Sidecar) → kompletter Frischstart. Liegt bausteine.md ohne Sidecar vor,
|
||||
# ist das ein Teil-Stand (Block B/C offen) → Resume, nicht wischen.
|
||||
@@ -587,13 +633,13 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
else:
|
||||
offen.append((i, path))
|
||||
vorhanden = len(recherchen)
|
||||
set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=0)
|
||||
set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=_step_idx(topic, "Recherche"))
|
||||
if vorhanden < 3:
|
||||
caps = "files" if project else "full"
|
||||
caps = "files" if ordner else "full"
|
||||
slots = [
|
||||
{
|
||||
"key": f"bausteine-{topic}-recherche-{i}",
|
||||
"prompt": _build_recherche_prompt(topic, path, instructions, project),
|
||||
"prompt": _build_recherche_prompt(topic, path, instructions, q["type"], ordner),
|
||||
"role": "quick", "capabilities": caps,
|
||||
"payload": (lambda result, p=path: _file_payload(p)),
|
||||
}
|
||||
@@ -616,7 +662,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
# für semantische Dubletten und Konsens/Rest-Teilung (fatal)
|
||||
mapping = _mapping_schema(_json_datei(files["recherche_mapping"]))
|
||||
if mapping is None:
|
||||
set_p("Konsolidiere Recherche…", step=1)
|
||||
set_p("Konsolidiere Recherche…", step=_step_idx(topic, "Konsolidierung"))
|
||||
files["recherche_mapping"].unlink(missing_ok=True)
|
||||
gemergt = _vormerge([_parse_auswahl(t) for t in recherchen])
|
||||
eintraege = "\n".join(f"{i}. {text} ({n}× genannt)" for i, (text, n) in enumerate(gemergt, 1))
|
||||
@@ -648,7 +694,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
while rest and runde < KONSENS_MAX_RUNDEN:
|
||||
runde += 1
|
||||
final_runde = runde == KONSENS_MAX_RUNDEN
|
||||
set_p(f"Klärung läuft (Runde {runde}/{KONSENS_MAX_RUNDEN})…", step=2)
|
||||
set_p(f"Klärung läuft (Runde {runde}/{KONSENS_MAX_RUNDEN})…", step=_step_idx(topic, "Klärung"))
|
||||
mapping_path = files["mapping"][runde]
|
||||
|
||||
# Resume: fertiges Runden-Mapping wird direkt übernommen
|
||||
@@ -732,8 +778,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
|
||||
# Nur Projekte: Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt,
|
||||
# ein Web-Agent ergänzt kanonisch fehlende Bausteine, markiert mit [Ergänzung].
|
||||
if project:
|
||||
set_p("Ergänze Themenfeld…", step=3)
|
||||
if q["type"] == "projekt":
|
||||
set_p("Ergänze Themenfeld…", step=_step_idx(topic, "Ergänzung"))
|
||||
erg_path = files["ergaenzung"]
|
||||
ergaenzungen = _ergaenzung_schema(_json_datei(erg_path))
|
||||
if ergaenzungen is None:
|
||||
|
||||
113
backend/crawl.py
Normal file
113
backend/crawl.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Geboundeter Domain-Crawler für Link-Quellen — rendert JS via Playwright (Chromium).
|
||||
|
||||
Lädt ab einer Start-URL Seiten + PDFs — NUR dieselbe Domain, begrenzte Tiefe und
|
||||
Seitenzahl. HTML-Seiten werden im Headless-Browser gerendert (für SPAs nötig), dann
|
||||
Links + Text aus dem fertigen DOM gezogen. PDFs werden direkt als Bytes geladen.
|
||||
Deterministisch, gebounded; läuft via asyncio.to_thread (Sync-API, kein Event-Loop).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urldefrag, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from fsutil import atomic_write_text
|
||||
|
||||
log = logging.getLogger("creator.crawl")
|
||||
|
||||
MAX_TIEFE = 3
|
||||
MAX_SEITEN = 500
|
||||
SEITE_TIMEOUT = 30 # Sekunden pro Seite (Render bzw. PDF-Download)
|
||||
MAX_BYTES = 10_000_000 # 10 MB Deckel pro PDF
|
||||
_UA = "Mozilla/5.0 (creator-lernbot)"
|
||||
|
||||
|
||||
def _fetch_bytes(url: str) -> bytes | None:
|
||||
"""PDF-Bytes per urllib laden (kein Rendering nötig). None bei Fehler/zu groß."""
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": _UA})
|
||||
with urlopen(req, timeout=SEITE_TIMEOUT) as resp:
|
||||
data = resp.read(MAX_BYTES + 1)
|
||||
return None if len(data) > MAX_BYTES else data
|
||||
except Exception as e:
|
||||
log.debug("crawl: PDF-Fetch fehlgeschlagen %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
def _name(url: str, ext: str) -> str:
|
||||
h = hashlib.md5(url.encode("utf-8")).hexdigest()[:8]
|
||||
slug = re.sub(r"[^a-zA-Z0-9]+", "-", urlparse(url).path).strip("-")[:60] or "index"
|
||||
return f"{h}-{slug}{ext}"
|
||||
|
||||
|
||||
def _is_pdf(url: str) -> bool:
|
||||
return url.lower().split("?")[0].rstrip("/").endswith(".pdf")
|
||||
|
||||
|
||||
def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten: int = MAX_SEITEN, cancelled=None) -> int:
|
||||
"""Crawlt ab start_url (nur gleiche Domain), rendert JS und legt Seiten/PDFs in `ziel` ab.
|
||||
|
||||
BFS bis `max_tiefe` / `max_seiten`. Fehler einzelner Seiten werden übersprungen.
|
||||
Schreibt am ENDE einen `.done`-Marker; ein Abbruch (`cancelled()` → True) lässt ihn weg,
|
||||
sodass ein Neustart neu crawlt. Gibt die Zahl gespeicherter Quellen zurück.
|
||||
"""
|
||||
# Lazy: so startet das Backend auch ohne installiertes Playwright; nur das Crawlen schlägt dann fehl.
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ziel.mkdir(parents=True, exist_ok=True)
|
||||
domain = urlparse(start_url).netloc
|
||||
gesehen: set[str] = set()
|
||||
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
|
||||
gespeichert = 0
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app)
|
||||
page = browser.new_page(user_agent=_UA)
|
||||
try:
|
||||
while queue and gespeichert < max_seiten:
|
||||
if cancelled and cancelled():
|
||||
return gespeichert # Abbruch → KEIN .done-Marker → Neustart crawlt neu
|
||||
url, tiefe = queue.pop(0)
|
||||
if url in gesehen:
|
||||
continue
|
||||
gesehen.add(url)
|
||||
|
||||
# PDFs brauchen kein Rendering — direkt laden.
|
||||
if _is_pdf(url):
|
||||
data = _fetch_bytes(url)
|
||||
if data:
|
||||
p = ziel / _name(url, ".pdf")
|
||||
if not p.exists():
|
||||
p.write_bytes(data)
|
||||
gespeichert += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
page.goto(url, wait_until="networkidle", timeout=SEITE_TIMEOUT * 1000)
|
||||
except Exception as e:
|
||||
log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen
|
||||
try:
|
||||
text = page.inner_text("body").strip()
|
||||
except Exception:
|
||||
text = ""
|
||||
if text:
|
||||
atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
gespeichert += 1
|
||||
if tiefe < max_tiefe:
|
||||
try:
|
||||
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
|
||||
except Exception:
|
||||
hrefs = []
|
||||
for href in hrefs:
|
||||
nxt = urldefrag(href)[0]
|
||||
if (nxt.startswith(("http://", "https://"))
|
||||
and urlparse(nxt).netloc == domain and nxt not in gesehen):
|
||||
queue.append((nxt, tiefe + 1))
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
(ziel / ".done").write_text("ok", encoding="utf-8") # sauber durchgelaufen
|
||||
log.info("crawl %s → %d Quellen in %s", start_url, gespeichert, ziel)
|
||||
return gespeichert
|
||||
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from agents import run_agent
|
||||
from bausteine import _pdfs_konvertieren
|
||||
from bausteine import _pdfs_konvertieren, quelle_ordner
|
||||
from config import (
|
||||
DEFAULT_PROVIDER, FORMAT_ANTEIL, KONSENS_GRACE, KONSENS_MAX_RUNDEN,
|
||||
TEMPLATES_DIR,
|
||||
@@ -771,7 +771,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
|
||||
|
||||
content_path = guide_content_path(topic, format_name)
|
||||
content_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
project = project_dir(topic) if project_dir(topic).is_dir() else None
|
||||
project = quelle_ordner(topic) # Ordner-Quelle (projekt/uni/link) → Pfad, sonst None
|
||||
|
||||
try:
|
||||
if is_guide_cancelled(guide_id):
|
||||
|
||||
@@ -10,6 +10,8 @@ FormatType = Literal[
|
||||
|
||||
ProviderType = Literal["claude", "minimax", "lokal"]
|
||||
|
||||
SourceType = Literal["thema", "projekt", "uni", "link"]
|
||||
|
||||
|
||||
class GuideCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
@@ -26,6 +28,8 @@ class BausteineCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
source_type: SourceType = "thema"
|
||||
source_ort: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class BausteineStep(BaseModel):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
from config import STORAGE_DIR, PROJECTS_DIR
|
||||
from config import STORAGE_DIR, PROJECTS_DIR, PROJECT_ROOT
|
||||
|
||||
THEMEN_DIR = STORAGE_DIR / "themen"
|
||||
|
||||
@@ -26,6 +26,28 @@ def subbausteine_path(topic: str) -> Path:
|
||||
return topic_dir(topic) / "subbausteine.json"
|
||||
|
||||
|
||||
def quelle_path(topic: str) -> Path:
|
||||
"""Persistierte Quellen-Wahl pro Thema: {type, ort, spec}."""
|
||||
return topic_dir(topic) / "quelle.json"
|
||||
|
||||
|
||||
def quelle_crawl_dir(topic: str) -> Path:
|
||||
"""Zielordner für gecrawlte Link-Quellen (Seiten + PDF-.txt)."""
|
||||
return topic_dir(topic) / "quelle"
|
||||
|
||||
|
||||
def safe_ordner(ort: str) -> Path | None:
|
||||
"""Ordnerpfad relativ zum Repo-Root, gesandboxt. None bei leer/Ausbruch (../, absolut außerhalb)."""
|
||||
if not ort or not ort.strip():
|
||||
return None
|
||||
p = (PROJECT_ROOT / ort.strip()).resolve()
|
||||
try:
|
||||
p.relative_to(PROJECT_ROOT)
|
||||
except ValueError:
|
||||
return None
|
||||
return p
|
||||
|
||||
|
||||
def guide_content_path(topic: str, format_name: str) -> Path:
|
||||
return topic_dir(topic) / "guides" / f"{format_name}.json"
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
aiosqlite
|
||||
playwright
|
||||
|
||||
@@ -35,7 +35,8 @@ from models import (
|
||||
BausteinChatRequest, BausteinChatResponse,
|
||||
BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse,
|
||||
)
|
||||
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir
|
||||
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir, quelle_path, safe_ordner
|
||||
from fsutil import atomic_write_json
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -130,6 +131,19 @@ async def create_bausteine(req: BausteineCreateRequest):
|
||||
if bausteine_status(topic)["generating"]:
|
||||
return {"ok": True, "status": "already_generating"}
|
||||
await create_topic(topic)
|
||||
qp = quelle_path(topic)
|
||||
# Quelle nur beim ERSTEN Mal festschreiben; ▶/Resume erhält die bestehende Wahl.
|
||||
if not qp.exists():
|
||||
typ, ort = req.source_type, req.source_ort.strip()
|
||||
if typ in ("projekt", "uni"):
|
||||
ordner = safe_ordner(ort)
|
||||
if ordner is None or not ordner.is_dir():
|
||||
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).")
|
||||
elif typ == "link":
|
||||
if not ort.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
|
||||
qp.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(qp, {"type": typ, "ort": ort, "spec": req.instructions.strip()})
|
||||
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user