Plugin archiv: 12-Monats-Autopilot mit Sicherungsdatei-Prinzip
- Eigene Migration 0001_archiv_tabellen: Spiegel-Tabellen archiv_neuheiten und archiv_planung (ohne FK, damit Archiv-Zeilen Benutzerlöschung überleben) - Service mit eingefrierbarer Uhr: Stichtag „jetzt minus 12 Monate“ in UTC, strenger Vergleich (genau 12 Monate bleibt aktiv); Neuheiten nach Erscheinungsjahr (großzügig Jahresende) sonst Eintragsdatum, Planung nach Eintragsdatum; Monatsarithmetik mit Klemmung (29. Februar) - Täglicher APScheduler-Cron-Job (Standard 03:00, konfigurierbar über SPIELE_ARCHIV_JOB_UHRZEIT / SPIELE_ARCHIV_JOB_AKTIV); je archiviertem Titel ein Audit-Log-Eintrag als System - Admin-Ansicht /archiv: vereinigte Liste beider Tabellen, Suche über Titel/Verlag/Autor, Herkunftsfilter, Wiederherstellen in die aktive Liste (BGG-Konflikt wird abgelehnt, verwaiste Rezensentenzuordnung übernimmt der Admin — gemeldet und auditiert); Rollen: nur Admin - Kern minimal erweitert: PluginContext.registry stellt Hintergrund-Jobs die Plugin-Registry bereit (keine Fachlogik im Kern) - 30 neue Tests mit eingefrorener Uhr (Grenzfälle, Zeitzonen, Schaltjahr, Job-Lauf, Wiederherstellen, Rollen, Suche/Filter); 171 Tests grün - README aktualisiert: neues Plugin-Kapitel, Env-Variablen, Fortschritt #7 ✅
This commit is contained in:
494
plugins/archiv/service.py
Normal file
494
plugins/archiv/service.py
Normal file
@@ -0,0 +1,494 @@
|
||||
"""Archivierungs-Logik des Plugins „archiv“.
|
||||
|
||||
Regeln (dokumentiert, von Tests mit eingefrorener Uhr abgedeckt):
|
||||
|
||||
- **Stichtag:** „jetzt minus 12 Monate“ in UTC. Archiviert wird, wer
|
||||
*streng älter* als der Stichtag ist — genau 12 Monate gilt noch nicht als
|
||||
„älter als 12 Monate“.
|
||||
- **Referenzdatum:**
|
||||
- Neuheiten: das Erscheinungsdatum. BoardGameGeek liefert nur das Jahr,
|
||||
daher großzügig das Ende des Erscheinungsjahres (31.12., 23:59:59) — ein
|
||||
Titel des Jahres J wird also erst nach dem 31.12. von J+1 archiviert.
|
||||
Fehlt das Jahr, zählt das Eintragsdatum (`erstellt_am`).
|
||||
- Planungseinträge: das Eintragsdatum (`erstellt_am`), unabhängig vom Status.
|
||||
- **Zeitzonen:** Verglichen wird durchgängig in UTC. Zeitstempel ohne
|
||||
Zeitzone aus der Datenbank gelten als UTC (SQLite CURRENT_TIMESTAMP ist
|
||||
UTC; für Postgres werden naive Werte equally behandelt). Bewohnte
|
||||
Zeitzonen werden vor dem Vergleich nach UTC verschoben.
|
||||
- **Sicherungsdatei-Prinzip:** Zeilen werden vollständig in die Spiegel-
|
||||
Tabellen kopiert und aus den aktiven Listen entfernt — nichts geht verloren.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, insert, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from redaktionskern.db import Base
|
||||
|
||||
from .models import ArchivNeuheit, ArchivPlanung
|
||||
|
||||
_logger = logging.getLogger("plugins.archiv")
|
||||
|
||||
QUELLE_NEUHEITEN = "neuheiten"
|
||||
QUELLE_PLANUNG = "planung"
|
||||
GUELTIGE_QUELLEN: tuple[str, ...] = (QUELLE_NEUHEITEN, QUELLE_PLANUNG)
|
||||
|
||||
#: Objekttypen für Audit-Log-Einträge (deckungsgleich mit den Partner-Plugins).
|
||||
OBJEKT_TYPEN: dict[str, str] = {
|
||||
QUELLE_NEUHEITEN: "neuheit",
|
||||
QUELLE_PLANUNG: "planungseintrag",
|
||||
}
|
||||
|
||||
ARCHIV_ZIEL = "archiv"
|
||||
GRUND_TEXT = "älter als 12 Monate"
|
||||
|
||||
STANDARD_MONATE = 12
|
||||
|
||||
|
||||
def vor_monaten(moment: datetime, monate: int) -> datetime:
|
||||
"""Zieht `monate` Kalendermonate ab; Monatstage werden geklemmt
|
||||
(z. B. 29. Februar − 12 Monate → 28. Februar des Vorjahres)."""
|
||||
monat = moment.month - monate
|
||||
jahr = moment.year
|
||||
while monat <= 0:
|
||||
monat += 12
|
||||
jahr -= 1
|
||||
tag = min(moment.day, calendar.monthrange(jahr, monat)[1])
|
||||
return moment.replace(year=jahr, month=monat, day=tag)
|
||||
|
||||
|
||||
def als_utc(moment: datetime | None) -> datetime | None:
|
||||
"""Naive Zeitstempel gelten als UTC; zeitzonenbewusste → UTC."""
|
||||
if moment is None:
|
||||
return None
|
||||
if moment.tzinfo is None:
|
||||
return moment.replace(tzinfo=timezone.utc)
|
||||
return moment.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def naiv_utc(moment: datetime | None) -> datetime | None:
|
||||
"""Nach UTC normalisiert und ohne Zeitzone (Speicherformat der App)."""
|
||||
utc = als_utc(moment)
|
||||
return None if utc is None else utc.replace(tzinfo=None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchivLauf:
|
||||
"""Zusammenfassung eines automatischen Archivierungs-Laufs."""
|
||||
|
||||
neuheiten_archiviert: int = 0
|
||||
planung_archiviert: int = 0
|
||||
|
||||
@property
|
||||
def gesamt(self) -> int:
|
||||
return self.neuheiten_archiviert + self.planung_archiviert
|
||||
|
||||
def als_text(self) -> str:
|
||||
return (
|
||||
f"{self.neuheiten_archiviert} Neuheiten und "
|
||||
f"{self.planung_archiviert} Planungseinträge archiviert"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchivEreignis:
|
||||
"""Ein archivierter Datensatz — Grundlage für je einen Audit-Log-Eintrag."""
|
||||
|
||||
objekt_typ: str
|
||||
objekt_id: int
|
||||
details: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Wiederherstellung:
|
||||
"""Ergebnis eines Wiederherstellen-Versuchs."""
|
||||
|
||||
ok: bool
|
||||
meldung: str
|
||||
ziel: str = ""
|
||||
neuer_id: int | None = None
|
||||
rezensent_ersetzt: bool = False
|
||||
titel: str = ""
|
||||
|
||||
|
||||
class ArchivService:
|
||||
"""Verschiebt veraltete Titel in die Archiv-Tabellen und zurück."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: sessionmaker,
|
||||
jetzt: Callable[[], datetime] | None = None,
|
||||
monate: int = STANDARD_MONATE,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
# Einfrierbare Uhr (Tests); Standard: aktuelle UTC-Zeit.
|
||||
self._jetzt_fabrik = jetzt or (lambda: datetime.now(timezone.utc))
|
||||
self.monate = max(int(monate), 0)
|
||||
|
||||
# ---------- Zeitlogik ----------
|
||||
|
||||
def jetzt(self) -> datetime:
|
||||
return als_utc(self._jetzt_fabrik()) # type: ignore[arg-type]
|
||||
|
||||
def stichtag(self) -> datetime:
|
||||
"""„jetzt − 12 Monate“; streng älter heißt archiviert."""
|
||||
return vor_monaten(self.jetzt(), self.monate)
|
||||
|
||||
def referenz_neuheit(self, zeile) -> datetime:
|
||||
"""Erscheinungsdatum (Ende des bekannten Jahres) oder Eintragsdatum."""
|
||||
jahr = zeile["erscheinungsjahr"]
|
||||
if jahr is not None:
|
||||
return datetime(int(jahr), 12, 31, 23, 59, 59, tzinfo=timezone.utc)
|
||||
erstellt = als_utc(zeile.get("erstellt_am"))
|
||||
return erstellt or self.jetzt()
|
||||
|
||||
def referenz_planung(self, zeile) -> datetime:
|
||||
"""Eintragsdatum des Planungseintrags."""
|
||||
erstellt = als_utc(zeile.get("erstellt_am"))
|
||||
return erstellt or self.jetzt()
|
||||
|
||||
def _grund_neuheit(self, zeile, referenz: datetime) -> str:
|
||||
if zeile["erscheinungsjahr"] is not None:
|
||||
return f"{GRUND_TEXT} (Erscheinungsjahr {zeile['erscheinungsjahr']})"
|
||||
return f"{GRUND_TEXT} (eingetragen am {referenz:%d.%m.%Y})"
|
||||
|
||||
def _grund_planung(self, zeile, referenz: datetime) -> str:
|
||||
return f"{GRUND_TEXT} (eingetragen am {referenz:%d.%m.%Y})"
|
||||
|
||||
# ---------- Automatischer Lauf ----------
|
||||
|
||||
def lauf(self) -> tuple[ArchivLauf, list[ArchivEreignis]]:
|
||||
"""Verschiebt alle fälligen Titel in die Archiv-Tabellen.
|
||||
|
||||
Die Audit-Log-Einträge schreibt der Aufrufer (Route oder Hintergrund-
|
||||
Job) über die öffentliche API des audit-log-Plugins — ein Ereignis
|
||||
pro archiviertem Titel.
|
||||
"""
|
||||
stichtag = self.stichtag()
|
||||
zeitpunkt = naiv_utc(self.jetzt())
|
||||
lauf = ArchivLauf()
|
||||
ereignisse: list[ArchivEreignis] = []
|
||||
|
||||
with self.session_factory() as db:
|
||||
db: Session
|
||||
neuheiten = Base.metadata.tables.get("neuheiten")
|
||||
if neuheiten is not None:
|
||||
try:
|
||||
zeilen = db.execute(select(neuheiten)).mappings().all()
|
||||
except SQLAlchemyError as exc:
|
||||
_logger.warning(
|
||||
"archiv: Tabelle 'neuheiten' nicht lesbar (%s) — "
|
||||
"Quelle übersprungen.",
|
||||
exc,
|
||||
)
|
||||
zeilen = []
|
||||
for zeile in zeilen:
|
||||
referenz = self.referenz_neuheit(zeile)
|
||||
if referenz >= stichtag:
|
||||
continue # noch keine 12 Monate alt (oder jünger)
|
||||
db.execute(
|
||||
insert(ArchivNeuheit).values(
|
||||
quell_id=zeile["id"],
|
||||
titel=zeile["titel"],
|
||||
verlag=zeile["verlag"],
|
||||
autor=zeile["autor"],
|
||||
erscheinungsjahr=zeile["erscheinungsjahr"],
|
||||
bgg_id=zeile["bgg_id"],
|
||||
status=zeile["status"],
|
||||
quelle=zeile["quelle"],
|
||||
erstellt_am=zeile["erstellt_am"],
|
||||
aktualisiert_am=zeile["aktualisiert_am"],
|
||||
archiviert_am=zeitpunkt,
|
||||
referenz_am=naiv_utc(referenz),
|
||||
grund=self._grund_neuheit(zeile, referenz),
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
delete(neuheiten).where(neuheiten.c.id == zeile["id"])
|
||||
)
|
||||
lauf.neuheiten_archiviert += 1
|
||||
ereignisse.append(
|
||||
ArchivEreignis(
|
||||
objekt_typ=OBJEKT_TYPEN[QUELLE_NEUHEITEN],
|
||||
objekt_id=zeile["id"],
|
||||
details={
|
||||
"titel": zeile["titel"],
|
||||
"herkunft": QUELLE_NEUHEITEN,
|
||||
"ziel": ARCHIV_ZIEL,
|
||||
"grund": self._grund_neuheit(zeile, referenz),
|
||||
"referenz": f"{naiv_utc(referenz):%Y-%m-%d}",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
planungsliste = Base.metadata.tables.get("planungsliste")
|
||||
if planungsliste is not None:
|
||||
try:
|
||||
zeilen = db.execute(select(planungsliste)).mappings().all()
|
||||
except SQLAlchemyError as exc:
|
||||
_logger.warning(
|
||||
"archiv: Tabelle 'planungsliste' nicht lesbar (%s) — "
|
||||
"Quelle übersprungen.",
|
||||
exc,
|
||||
)
|
||||
zeilen = []
|
||||
for zeile in zeilen:
|
||||
referenz = self.referenz_planung(zeile)
|
||||
if referenz >= stichtag:
|
||||
continue
|
||||
db.execute(
|
||||
insert(ArchivPlanung).values(
|
||||
quell_id=zeile["id"],
|
||||
titel=zeile["titel"],
|
||||
verlag=zeile["verlag"],
|
||||
autor=zeile["autor"],
|
||||
bgg_id=zeile["bgg_id"],
|
||||
ausgabe=zeile["ausgabe"],
|
||||
rezensent_id=zeile["rezensent_id"],
|
||||
status=zeile["status"],
|
||||
notizen=zeile["notizen"],
|
||||
quelle=zeile["quelle"],
|
||||
pruefung=zeile["pruefung"],
|
||||
erstellt_am=zeile["erstellt_am"],
|
||||
aktualisiert_am=zeile["aktualisiert_am"],
|
||||
archiviert_am=zeitpunkt,
|
||||
referenz_am=naiv_utc(referenz),
|
||||
grund=self._grund_planung(zeile, referenz),
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
delete(planungsliste).where(planungsliste.c.id == zeile["id"])
|
||||
)
|
||||
lauf.planung_archiviert += 1
|
||||
ereignisse.append(
|
||||
ArchivEreignis(
|
||||
objekt_typ=OBJEKT_TYPEN[QUELLE_PLANUNG],
|
||||
objekt_id=zeile["id"],
|
||||
details={
|
||||
"titel": zeile["titel"],
|
||||
"herkunft": QUELLE_PLANUNG,
|
||||
"ziel": ARCHIV_ZIEL,
|
||||
"grund": self._grund_planung(zeile, referenz),
|
||||
"referenz": f"{naiv_utc(referenz):%Y-%m-%d}",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
if lauf.gesamt:
|
||||
_logger.info("Automatische Archivierung: %s", lauf.als_text())
|
||||
return lauf, ereignisse
|
||||
|
||||
# ---------- Ansicht ----------
|
||||
|
||||
def ansicht(self, db: Session) -> list[dict]:
|
||||
"""Vereinigte Archiv-Ansicht beider Tabellen, neueste zuerst."""
|
||||
eintraege: list[dict] = []
|
||||
for a in db.scalars(
|
||||
select(ArchivNeuheit).order_by(
|
||||
ArchivNeuheit.archiviert_am.desc(), ArchivNeuheit.id.desc()
|
||||
)
|
||||
):
|
||||
eintraege.append(
|
||||
{
|
||||
"quelle": QUELLE_NEUHEITEN,
|
||||
"id": a.id,
|
||||
"titel": a.titel,
|
||||
"verlag": a.verlag,
|
||||
"autor": a.autor,
|
||||
"bgg_id": a.bgg_id,
|
||||
"jahr": a.erscheinungsjahr,
|
||||
"ausgabe": "",
|
||||
"status": a.status,
|
||||
"rezensent_id": None,
|
||||
"erstellt_am": a.erstellt_am,
|
||||
"archiviert_am": a.archiviert_am,
|
||||
"referenz_am": a.referenz_am,
|
||||
"grund": a.grund,
|
||||
}
|
||||
)
|
||||
for a in db.scalars(
|
||||
select(ArchivPlanung).order_by(
|
||||
ArchivPlanung.archiviert_am.desc(), ArchivPlanung.id.desc()
|
||||
)
|
||||
):
|
||||
eintraege.append(
|
||||
{
|
||||
"quelle": QUELLE_PLANUNG,
|
||||
"id": a.id,
|
||||
"titel": a.titel,
|
||||
"verlag": a.verlag,
|
||||
"autor": a.autor,
|
||||
"bgg_id": a.bgg_id,
|
||||
"jahr": None,
|
||||
"ausgabe": a.ausgabe,
|
||||
"status": a.status,
|
||||
"rezensent_id": a.rezensent_id,
|
||||
"erstellt_am": a.erstellt_am,
|
||||
"archiviert_am": a.archiviert_am,
|
||||
"referenz_am": a.referenz_am,
|
||||
"grund": a.grund,
|
||||
}
|
||||
)
|
||||
# Neueste Archivierung zuerst; bei Gleichstand die höhere ID zuerst.
|
||||
return sorted(
|
||||
eintraege,
|
||||
key=lambda e: (e["archiviert_am"] or datetime.min, e["id"]),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# ---------- Wiederherstellen ----------
|
||||
|
||||
def wiederherstellen(
|
||||
self,
|
||||
db: Session,
|
||||
quelle: str,
|
||||
archiv_id: int,
|
||||
*,
|
||||
ersatz_rezensent_id: int | None = None,
|
||||
jetzt: datetime | None = None,
|
||||
) -> Wiederherstellung:
|
||||
"""Schiebt einen archivierten Eintrag zurück in die aktive Liste.
|
||||
|
||||
Konflikte werden abgelehnt (keine Datenänderung im Archiv): existiert
|
||||
in der Neuheitenliste bereits ein aktiver Eintrag mit derselben BGG-ID,
|
||||
wird nicht wiederhergestellt. Existiert der zugeordnete Rezensent
|
||||
eines Planungseintrags nicht mehr, übernimmt `ersatz_rezensent_id`
|
||||
die Zuordnung (wird gemeldet und auditiert).
|
||||
"""
|
||||
zeitpunkt = naiv_utc(jetzt or self.jetzt())
|
||||
|
||||
if quelle == QUELLE_NEUHEITEN:
|
||||
return self._wiederherstelle_neuheit(db, archiv_id, zeitpunkt)
|
||||
if quelle == QUELLE_PLANUNG:
|
||||
return self._wiederherstelle_planung(
|
||||
db, archiv_id, zeitpunkt, ersatz_rezensent_id
|
||||
)
|
||||
return Wiederherstellung(False, f"Unbekannte Herkunft „{quelle}“.")
|
||||
|
||||
def _wiederherstelle_neuheit(
|
||||
self, db: Session, archiv_id: int, zeitpunkt: datetime
|
||||
) -> Wiederherstellung:
|
||||
archiv = db.get(ArchivNeuheit, archiv_id)
|
||||
if archiv is None:
|
||||
return Wiederherstellung(
|
||||
False, "Der archivierte Eintrag wurde nicht gefunden."
|
||||
)
|
||||
aktiv = Base.metadata.tables.get("neuheiten")
|
||||
if aktiv is None:
|
||||
return Wiederherstellung(
|
||||
False,
|
||||
"Die Neuheitenliste ist nicht verfügbar — "
|
||||
"Wiederherstellen nicht möglich.",
|
||||
)
|
||||
if archiv.bgg_id is not None:
|
||||
vorhanden = db.execute(
|
||||
select(aktiv.c.id, aktiv.c.titel).where(
|
||||
aktiv.c.bgg_id == archiv.bgg_id
|
||||
)
|
||||
).first()
|
||||
if vorhanden is not None:
|
||||
return Wiederherstellung(
|
||||
False,
|
||||
f"Es gibt bereits einen aktiven Neuheiten-Eintrag mit der "
|
||||
f"BGG-ID {archiv.bgg_id} („{vorhanden.titel}“) — "
|
||||
"Wiederherstellen abgebrochen.",
|
||||
)
|
||||
ergebnis = db.execute(
|
||||
insert(aktiv).values(
|
||||
titel=archiv.titel,
|
||||
verlag=archiv.verlag,
|
||||
autor=archiv.autor,
|
||||
erscheinungsjahr=archiv.erscheinungsjahr,
|
||||
bgg_id=archiv.bgg_id,
|
||||
status=archiv.status,
|
||||
quelle=archiv.quelle,
|
||||
erstellt_am=archiv.erstellt_am,
|
||||
aktualisiert_am=zeitpunkt,
|
||||
)
|
||||
)
|
||||
neuer_id = ergebnis.inserted_primary_key[0]
|
||||
titel = archiv.titel
|
||||
db.delete(archiv)
|
||||
db.commit()
|
||||
return Wiederherstellung(
|
||||
True,
|
||||
f"„{titel}“ wurde zurück in die Neuheitenliste verschoben.",
|
||||
ziel=QUELLE_NEUHEITEN,
|
||||
neuer_id=int(neuer_id),
|
||||
titel=titel,
|
||||
)
|
||||
|
||||
def _wiederherstelle_planung(
|
||||
self,
|
||||
db: Session,
|
||||
archiv_id: int,
|
||||
zeitpunkt: datetime,
|
||||
ersatz_rezensent_id: int | None,
|
||||
) -> Wiederherstellung:
|
||||
archiv = db.get(ArchivPlanung, archiv_id)
|
||||
if archiv is None:
|
||||
return Wiederherstellung(
|
||||
False, "Der archivierte Eintrag wurde nicht gefunden."
|
||||
)
|
||||
aktiv = Base.metadata.tables.get("planungsliste")
|
||||
if aktiv is None:
|
||||
return Wiederherstellung(
|
||||
False,
|
||||
"Die Planungsliste ist nicht verfügbar — "
|
||||
"Wiederherstellen nicht möglich.",
|
||||
)
|
||||
|
||||
rezensent_id = archiv.rezensent_id
|
||||
rezensent_ersetzt = False
|
||||
benutzer = Base.metadata.tables.get("users")
|
||||
if rezensent_id is not None and benutzer is not None:
|
||||
existiert = db.execute(
|
||||
select(benutzer.c.id).where(benutzer.c.id == rezensent_id)
|
||||
).first()
|
||||
if existiert is None and ersatz_rezensent_id is not None:
|
||||
rezensent_id = ersatz_rezensent_id
|
||||
rezensent_ersetzt = True
|
||||
|
||||
ergebnis = db.execute(
|
||||
insert(aktiv).values(
|
||||
titel=archiv.titel,
|
||||
verlag=archiv.verlag,
|
||||
autor=archiv.autor,
|
||||
bgg_id=archiv.bgg_id,
|
||||
ausgabe=archiv.ausgabe,
|
||||
rezensent_id=rezensent_id,
|
||||
status=archiv.status,
|
||||
notizen=archiv.notizen,
|
||||
quelle=archiv.quelle,
|
||||
pruefung=archiv.pruefung,
|
||||
erstellt_am=archiv.erstellt_am,
|
||||
aktualisiert_am=zeitpunkt,
|
||||
)
|
||||
)
|
||||
neuer_id = ergebnis.inserted_primary_key[0]
|
||||
titel = archiv.titel
|
||||
db.delete(archiv)
|
||||
db.commit()
|
||||
meldung = f"„{titel}“ wurde zurück in die Planungsliste verschoben."
|
||||
if rezensent_ersetzt:
|
||||
meldung += (
|
||||
" Der ursprüngliche Rezensent existierte nicht mehr — "
|
||||
"der Eintrag wurde dir zugeordnet."
|
||||
)
|
||||
return Wiederherstellung(
|
||||
True,
|
||||
meldung,
|
||||
ziel=QUELLE_PLANUNG,
|
||||
neuer_id=int(neuer_id),
|
||||
rezensent_ersetzt=rezensent_ersetzt,
|
||||
titel=titel,
|
||||
)
|
||||
Reference in New Issue
Block a user