"""Plugin „planung“ — Planungsliste mit Zuordnung an Rezensenten. Zwei Eintragswege, beide laufen automatisch durch die dedup-Prüfung des dedup-Plugins: 1. **Verschiebung aus der Neuheitenliste** — Button „Zur Planung“ in der Neuheitenliste ruft `POST /planung/uebernehmen/` auf; der Neuheiten-Eintrag wechselt dabei in den Status „planung“. 2. **Händisches Nachtragen** über das Formular auf `/planung`. Bei einem Prüftreffer wird eine Benachrichtigung an den eintragenden Rezensenten verschickt (Plugin „benachrichtigung“, `send_notification`) und ein Audit-Log-Eintrag geschrieben (Plugin „audit-log“, `log`). Bei einem Verlags-Konflikt erscheint zuerst ein Auswahl-Dialog, welcher Verlag geführt wird; erst nach der Bestätigung wird der Eintrag angelegt. Rezensenten sehen die komplette Liste (mit Zuordnung), dürfen aber nur ihre eigenen Einträge bearbeiten; Admins und Redakteure alle. Beide Partner-Plugins werden über die Registry geholt (`registry.get(...)`); fehlen sie, entfallen Benachrichtigung/Audit bzw. die Prüfung, ohne dass der Eintrage-Vorgang bricht. """ from __future__ import annotations import logging from urllib.parse import quote from fastapi import Depends, Form, Request from fastapi.responses import RedirectResponse from sqlalchemy import select, update from sqlalchemy.orm import Session from redaktionskern.auth.deps import AccessDenied, get_db, require_user from redaktionskern.auth.models import Role, User from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.db import Base from .models import ( GUELTIGE_STATUS, STATUS_ANZEIGE, Planungseintrag, ) _logger = logging.getLogger("plugins.planung") REDAKTION = (Role.ADMIN.value, Role.REDAKTEUR.value) #: Konflikt-Anzeigenamen für UI/Benachrichtigung (bewusst als Kopie ohne #: Import-Abhängigkeit zum dedup-Plugin). KONFLIKT_ANZEIGE = { "verlag": "Verlags-Konflikt", "titel": "Titel-Variante", "besprochen": "Bereits besprochen", "planung": "Schon in Planung", } def _planungs_tabelle(conn) -> None: """Migration 0001: legt die Tabelle `planungsliste` an.""" Planungseintrag.__table__.create(conn, checkfirst=True) def _neuheiten_tabelle(): """Neuheiten-Tabelle über die gemeinsame Metadata (nur lesend/schreibend auf den Status — kein Import des neuheiten-Pakets nötig).""" return Base.metadata.tables.get("neuheiten") class PlanungPlugin(BasePlugin): name = "planung" title = "Planung" description = ( "Planungsliste mit Zuordnung an Rezensenten — Verschiebung aus den " "Neuheiten und händisches Nachtragen, automatisch durch die " "dedup-Prüfung." ) def __init__(self) -> None: super().__init__() self._routen_registrieren() # ---------- Plugin-Vertrag ---------- def migrations(self) -> list[Migration]: def bild_url_spalte(conn) -> None: """Migration 0002: Spalte bild_url für Coverbilder (nullable). Portabel (SQLite/Postgres): `ALTER TABLE … ADD COLUMN` mit nullable VARCHAR ohne Default; bestehende Zeilen bleiben erhalten. """ if conn.dialect.name == "sqlite": info = conn.exec_driver_sql( "PRAGMA table_info(planungsliste)" ).fetchall() if any(zeile[1] == "bild_url" for zeile in info): return else: existiert = conn.exec_driver_sql( "SELECT column_name FROM information_schema.columns " "WHERE table_name = 'planungsliste' AND column_name = 'bild_url'" ).scalar() if existiert is not None: return conn.exec_driver_sql( "ALTER TABLE planungsliste ADD COLUMN bild_url VARCHAR(500)" ) return [ Migration(version="0001_planungsliste", up=_planungs_tabelle), Migration(version="0002_bild_url", up=bild_url_spalte), ] def navigation(self) -> list[NavEntry]: return [NavEntry(label=self.title, url="/planung")] # ---------- Hilfsfunktionen ---------- @staticmethod def _darf_bearbeiten(user: User, eintrag: Planungseintrag) -> bool: return user.role in REDAKTION or eintrag.rezensent_id == user.id @classmethod def _pruefe_bearbeitbar(cls, user: User, eintrag: Planungseintrag) -> None: if not cls._darf_bearbeiten(user, eintrag): raise AccessDenied( "Rezensenten dürfen nur ihre eigenen Planungseinträge ändern." ) @staticmethod def _hole_dedup(request: Request): return request.app.state.registry.get("dedup") @staticmethod async def _benachrichtige( request: Request, user: User, titel: str, ergebnis ) -> None: """Versendet bei Prüftreffern eine Nachricht an den Eintragenden.""" benachrichtigung = request.app.state.registry.get("benachrichtigung") if benachrichtigung is None: return zeilen = [ f"Die Dedup-Prüfung für „{titel}“ hat {len(ergebnis.konflikte)} Treffer:" ] for konflikt in ergebnis.konflikte: anzeige = KONFLIKT_ANZEIGE.get(konflikt.art, konflikt.art) zeilen.append(f"• [{anzeige}] {konflikt.beschreibung}") try: await benachrichtigung.send_notification( user, f"Dedup-Hinweis: {titel}", "\n".join(zeilen), "dedup", ) except Exception: # Eine fehlgeschlagene Benachrichtigung darf den Eintrag nicht blockieren. _logger.exception("Benachrichtigung über Dedup-Treffer fehlgeschlagen.") @staticmethod async def _audit( request: Request, actor, action: str, objekt_id, details: dict | None = None, ) -> None: """Schreibt einen Audit-Log-Eintrag (best effort, wenn Plugin geladen).""" audit = request.app.state.registry.get("audit-log") if audit is None: return try: await audit.log(actor, action, "planungseintrag", objekt_id, details) except Exception: _logger.exception("Audit-Log-Eintrag für Planung fehlgeschlagen.") @staticmethod def _meldung(url: str, text: str, art: str = "meldung") -> RedirectResponse: return RedirectResponse(f"{url}?{art}={quote(text)}", status_code=303) def _dialog_antwort( self, request: Request, user: User, *, titel: str, ergebnis, aktion: str, versteckt: dict[str, str], abbrechen_url: str = "/planung", bild_url: str | None = None, ): """Auswahl-Dialog bei Verlags-Konflikt: Welcher Verlag wird geführt?""" return self.context.templates.TemplateResponse( request=request, name="planung/pruefung.html", context={ "user": user, "titel_headline": f"Dedup-Prüfung — {titel}", "spiel_titel": titel, "bild_url": bild_url, "konflikte": ergebnis.konflikte, "konflikt_anzeige": KONFLIKT_ANZEIGE, "titel_empfehlung": ergebnis.titel_empfehlung, "verlags_optionen": ergebnis.verlags_optionen, "aktion": aktion, "versteckt": versteckt, "abbrechen_url": abbrechen_url, }, ) # ---------- Routen ---------- def _routen_registrieren(self) -> None: @self.router.get("/planung") def liste( request: Request, db: Session = Depends(get_db), user: User = Depends(require_user), status: str = "", meldung: str = "", fehler: str = "", ): """Planungsliste: alle Einträge mit Zuordnung und Statuswechsel.""" abfrage = select(Planungseintrag) if status in GUELTIGE_STATUS: abfrage = abfrage.where(Planungseintrag.status == status) eintraege = db.scalars( abfrage.order_by( Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc() ) ).all() namen = { benutzer.id: (benutzer.display_name or benutzer.username) for benutzer in db.scalars(select(User)).all() } return self.context.templates.TemplateResponse( request=request, name="planung/index.html", context={ "user": user, "titel": self.title, "eintraege": eintraege, "namen": namen, "status_anzeige": STATUS_ANZEIGE, "status_filter": status if status in GUELTIGE_STATUS else "", "status_optionen": GUELTIGE_STATUS, "kann_bearbeiten": lambda eintrag: self._darf_bearbeiten(user, eintrag), "ist_redaktion": user.role in REDAKTION, "meldung": meldung[:300], "fehler": fehler[:300], "anzahl": len(eintraege), }, ) @self.router.post("/planung/neu") async def neu_anlegen( request: Request, db: Session = Depends(get_db), user: User = Depends(require_user), titel: str = Form(""), verlag: str = Form(""), ausgabe: str = Form(""), bgg_id: str = Form(""), verlag_wahl: str = Form(""), verlag_bestaetigt: str = Form(""), ): """Händisches Nachtragen eines Titels (durchläuft die dedup-Prüfung).""" roh_titel = titel.strip() if not roh_titel: return self._meldung("/planung", "Bitte einen Spieltitel angeben.", "fehler") try: id_wert = int(bgg_id.strip()) if bgg_id.strip() else None except ValueError: return self._meldung("/planung", "Die BGG-ID muss eine Zahl sein.", "fehler") dedup = self._hole_dedup(request) ergebnis = ( await dedup.check_titel(roh_titel, verlag.strip() or None, id_wert, user=user) if dedup is not None else None ) hat_verlagskonflikt = bool(ergebnis and ergebnis.hat_verlagskonflikt) # Verlags-Konflikt → Auswahl-Dialog statt sofortigem Anlegen. if hat_verlagskonflikt and verlag_bestaetigt != "1": return self._dialog_antwort( request, user, titel=roh_titel, ergebnis=ergebnis, aktion="/planung/neu", versteckt={ "titel": roh_titel, "ausgabe": ausgabe.strip(), "bgg_id": bgg_id.strip(), "verlag_bestaetigt": "1", }, ) gewaehlter_verlag = (verlag_wahl.strip() or verlag.strip()) or None eintrag = Planungseintrag( titel=roh_titel, verlag=gewaehlter_verlag, ausgabe=ausgabe.strip(), bgg_id=id_wert, rezensent_id=user.id, status="offen", quelle="manuell", pruefung=(ergebnis.als_liste() or None) if ergebnis is not None else None, ) db.add(eintrag) db.commit() details = { "titel": eintrag.titel, "verlag": eintrag.verlag, "ausgabe": eintrag.ausgabe, "quelle": eintrag.quelle, "rezensent_id": eintrag.rezensent_id, } if ergebnis is not None and ergebnis.hat_konflikte: details["dedup_konflikte"] = ergebnis.als_liste() await self._audit(request, user, "erstellt", eintrag.id, details) if ergebnis is not None and ergebnis.hat_konflikte: await self._benachrichtige(request, user, eintrag.titel, ergebnis) meldung = ( f"„{eintrag.titel}“ wurde eingetragen. Die Dedup-Prüfung hatte " "Treffer — du hast eine Benachrichtigung erhalten." ) else: meldung = f"„{eintrag.titel}“ wurde eingetragen." return self._meldung("/planung", meldung) @self.router.post("/planung/uebernehmen/{neuheit_id}") async def uebernehmen( request: Request, neuheit_id: int, db: Session = Depends(get_db), user: User = Depends(require_user), verlag_wahl: str = Form(""), verlag_bestaetigt: str = Form(""), ): """Verschiebung Neuheiten → Planung (durchläuft die dedup-Prüfung).""" tabelle = _neuheiten_tabelle() if tabelle is None: return self._meldung( "/planung", "Neuheitenliste nicht verfügbar — Verschiebung nicht möglich.", "fehler", ) zeile = db.execute( select(tabelle).where(tabelle.c.id == neuheit_id) ).first() if zeile is None: return self._meldung( "/neuheiten", "Dieser Neuheiten-Eintrag existiert nicht mehr.", "fehler" ) if zeile.status != "neuheit": return self._meldung( "/neuheiten", f"„{zeile.titel}“ ist nicht mehr als Neuheit verfügbar.", "fehler", ) dedup = self._hole_dedup(request) ergebnis = ( await dedup.check_titel(zeile.titel, zeile.verlag, zeile.bgg_id, user=user) if dedup is not None else None ) hat_verlagskonflikt = bool(ergebnis and ergebnis.hat_verlagskonflikt) # Verlags-Konflikt → Auswahl-Dialog statt sofortiger Übernahme. if hat_verlagskonflikt and verlag_bestaetigt != "1": return self._dialog_antwort( request, user, titel=zeile.titel, ergebnis=ergebnis, aktion=f"/planung/uebernehmen/{neuheit_id}", versteckt={"neuheit_id": str(neuheit_id), "verlag_bestaetigt": "1"}, abbrechen_url="/neuheiten", bild_url=zeile.bild_url, ) gewaehlter_verlag = (verlag_wahl.strip() or (zeile.verlag or "")) or None eintrag = Planungseintrag( titel=zeile.titel, verlag=gewaehlter_verlag, autor=zeile.autor, bgg_id=zeile.bgg_id, bild_url=zeile.bild_url, ausgabe="", rezensent_id=user.id, status="offen", quelle="neuheiten", pruefung=(ergebnis.als_liste() or None) if ergebnis is not None else None, ) db.add(eintrag) db.execute( update(tabelle) .where(tabelle.c.id == neuheit_id) .values(status="planung") ) db.commit() details = { "titel": eintrag.titel, "verlag": eintrag.verlag, "ausgabe": eintrag.ausgabe, "quelle": eintrag.quelle, "rezensent_id": eintrag.rezensent_id, "von": "neuheiten", "neuheit_id": neuheit_id, } if ergebnis is not None and ergebnis.hat_konflikte: details["dedup_konflikte"] = ergebnis.als_liste() await self._audit(request, user, "verschoben", eintrag.id, details) if ergebnis is not None and ergebnis.hat_konflikte: await self._benachrichtige(request, user, eintrag.titel, ergebnis) meldung = ( f"„{eintrag.titel}“ wurde in deine Planungsliste übernommen. " "Die Dedup-Prüfung hatte Treffer — du hast eine Benachrichtigung erhalten." ) else: meldung = f"„{eintrag.titel}“ wurde in deine Planungsliste übernommen." return self._meldung("/planung", meldung) @self.router.post("/planung/{eintrag_id}/status") async def status_wechseln( request: Request, eintrag_id: int, db: Session = Depends(get_db), user: User = Depends(require_user), status: str = Form(""), ): """Statuswechsel (offen → in Bearbeitung → abgeschlossen).""" eintrag = db.get(Planungseintrag, eintrag_id) if eintrag is None: return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler") self._pruefe_bearbeitbar(user, eintrag) if status not in GUELTIGE_STATUS: return self._meldung("/planung", "Unbekannter Status.", "fehler") alt = eintrag.status eintrag.status = status db.commit() await self._audit( request, user, "geaendert", eintrag.id, {"alt": {"status": alt}, "neu": {"status": status}}, ) return self._meldung( "/planung", f"Status von „{eintrag.titel}“: {STATUS_ANZEIGE.get(status, status)}.", ) @self.router.get("/planung/{eintrag_id}/bearbeiten") def bearbeiten_formular( request: Request, eintrag_id: int, db: Session = Depends(get_db), user: User = Depends(require_user), ): eintrag = db.get(Planungseintrag, eintrag_id) if eintrag is None: return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler") self._pruefe_bearbeitbar(user, eintrag) return self.context.templates.TemplateResponse( request=request, name="planung/bearbeiten.html", context={ "user": user, "titel_headline": f"Eintrag bearbeiten — {eintrag.titel}", "eintrag": eintrag, "abbrechen_url": "/planung", }, ) @self.router.post("/planung/{eintrag_id}/bearbeiten") async def bearbeiten_speichern( request: Request, eintrag_id: int, db: Session = Depends(get_db), user: User = Depends(require_user), titel: str = Form(""), verlag: str = Form(""), ausgabe: str = Form(""), notizen: str = Form(""), ): eintrag = db.get(Planungseintrag, eintrag_id) if eintrag is None: return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler") self._pruefe_bearbeitbar(user, eintrag) roh_titel = titel.strip() if not roh_titel: return self._meldung("/planung", "Bitte einen Spieltitel angeben.", "fehler") alt = { "titel": eintrag.titel, "verlag": eintrag.verlag, "ausgabe": eintrag.ausgabe, "notizen": eintrag.notizen, } eintrag.titel = roh_titel eintrag.verlag = verlag.strip() or None eintrag.ausgabe = ausgabe.strip() eintrag.notizen = notizen.strip() or None neu = { "titel": eintrag.titel, "verlag": eintrag.verlag, "ausgabe": eintrag.ausgabe, "notizen": eintrag.notizen, } geaendert = [k for k in alt if alt[k] != neu[k]] db.commit() if geaendert: # Nur geänderte Felder, im alt/neu-Format des Audit-Log-Plugins. await self._audit( request, user, "geaendert", eintrag.id, { "alt": {k: alt[k] for k in geaendert}, "neu": {k: neu[k] for k in geaendert}, }, ) return self._meldung("/planung", f"„{eintrag.titel}“ wurde gespeichert.") @self.router.post("/planung/{eintrag_id}/loeschen") async def loeschen( request: Request, eintrag_id: int, db: Session = Depends(get_db), user: User = Depends(require_user), ): eintrag = db.get(Planungseintrag, eintrag_id) if eintrag is None: return self._meldung("/planung", "Eintrag nicht gefunden.", "fehler") self._pruefe_bearbeitbar(user, eintrag) snapshot = { "titel": eintrag.titel, "verlag": eintrag.verlag, "ausgabe": eintrag.ausgabe, "status": eintrag.status, "rezensent_id": eintrag.rezensent_id, } db.delete(eintrag) db.commit() await self._audit(request, user, "geloescht", eintrag_id, snapshot) return self._meldung("/planung", f"„{snapshot['titel']}“ wurde gelöscht.") plugin = PlanungPlugin()