Neuheiten+Planung: Pagination (50/Seite, Infinite Scroll) + quellen_url in Planung mit Quellen-Link

This commit is contained in:
Flo Hartmann
2026-08-24 22:44:18 +00:00
parent fa453518d4
commit aea52d1563
6 changed files with 69 additions and 6 deletions

View File

@@ -98,10 +98,12 @@ class NeuheitenPlugin(BasePlugin):
richtung: str = STANDARD_SORTIERUNG[1], richtung: str = STANDARD_SORTIERUNG[1],
meldung: str = "", meldung: str = "",
fehler: str = "", fehler: str = "",
seite_nr: int = 1,
): ):
spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]]) spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]])
abwaerts = richtung == "ab" if sort in SORTIERBAR else False abwaerts = richtung == "ab" if sort in SORTIERBAR else False
spalte_sortiert = spalte.desc() if abwaerts else spalte.asc() spalte_sortiert = spalte.desc() if abwaerts else spalte.asc()
pro_seite = 50
with self.context.session_factory() as db: with self.context.session_factory() as db:
abfrage = select(Neuheit) abfrage = select(Neuheit)
@@ -116,8 +118,11 @@ class NeuheitenPlugin(BasePlugin):
) )
if status.strip(): if status.strip():
abfrage = abfrage.where(Neuheit.status == status.strip()) abfrage = abfrage.where(Neuheit.status == status.strip())
gesamt = db.scalar(select(func.count()).select_from(abfrage.subquery())) or 0
eintraege = db.scalars( eintraege = db.scalars(
abfrage.order_by(spalte_sortiert, Neuheit.id.asc()) abfrage.order_by(spalte_sortiert, Neuheit.id.asc())
.offset((max(seite_nr, 1) - 1) * pro_seite)
.limit(pro_seite)
).all() ).all()
status_optionen = [ status_optionen = [
zeile for zeile in db.scalars( zeile for zeile in db.scalars(
@@ -126,6 +131,7 @@ class NeuheitenPlugin(BasePlugin):
] ]
quellen_zeilen = self._quellen_uebersicht(db) quellen_zeilen = self._quellen_uebersicht(db)
hat_weitere = max(seite_nr, 1) * pro_seite < gesamt
kontext = { kontext = {
"user": user, "user": user,
"titel": self.title, "titel": self.title,
@@ -143,7 +149,9 @@ class NeuheitenPlugin(BasePlugin):
), ),
"suchbegriffe": ", ".join(self._suchbegriffe) or "", "suchbegriffe": ", ".join(self._suchbegriffe) or "",
"sync_aktiv": self._scheduler is not None and self._scheduler.running, "sync_aktiv": self._scheduler is not None and self._scheduler.running,
"anzahl": len(eintraege), "anzahl": gesamt,
"hat_weitere": hat_weitere,
"naechste_seite": max(seite_nr, 1) + 1,
"quellen_zeilen": quellen_zeilen, "quellen_zeilen": quellen_zeilen,
"quellen_sync_aktiv": ( "quellen_sync_aktiv": (
os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip() os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip()

View File

@@ -119,3 +119,10 @@
</table> </table>
</div> </div>
</div> </div>
{% if hat_weitere %}
<div id="lade-mehr"
hx-get="/neuheiten?seite_nr={{ naechste_seite }}&q={{ q }}&status={{ status_filter }}&sort={{ sort }}&richtung={{ richtung }}"
hx-trigger="revealed delay:150ms" hx-swap="outerHTML">
<div class="py-6 text-center text-sm text-slate-400">Lade weitere Eintr&auml;ge&nbsp;&hellip;</div>
</div>
{% endif %}

View File

@@ -28,7 +28,7 @@ from urllib.parse import quote
from fastapi import Depends, Form, Request from fastapi import Depends, Form, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from redaktionskern.auth.deps import AccessDenied, get_db, require_user from redaktionskern.auth.deps import AccessDenied, get_db, require_user
@@ -106,9 +106,29 @@ class PlanungPlugin(BasePlugin):
"ALTER TABLE planungsliste ADD COLUMN bild_url VARCHAR(500)" "ALTER TABLE planungsliste ADD COLUMN bild_url VARCHAR(500)"
) )
def quellen_url_spalte(conn) -> None:
"""Migration 0003: Spalte quellen_url (Link zur Herkunftsquelle)."""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql(
"PRAGMA table_info(planungsliste)"
).fetchall()
if any(zeile[1] == "quellen_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 = 'quellen_url'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE planungsliste ADD COLUMN quellen_url VARCHAR(500)"
)
return [ return [
Migration(version="0001_planungsliste", up=_planungs_tabelle), Migration(version="0001_planungsliste", up=_planungs_tabelle),
Migration(version="0002_bild_url", up=bild_url_spalte), Migration(version="0002_bild_url", up=bild_url_spalte),
Migration(version="0003_quellen_url", up=quellen_url_spalte),
] ]
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
@@ -220,15 +240,22 @@ class PlanungPlugin(BasePlugin):
status: str = "", status: str = "",
meldung: str = "", meldung: str = "",
fehler: str = "", fehler: str = "",
seite_nr: int = 1,
): ):
"""Planungsliste: alle Einträge mit Zuordnung und Statuswechsel.""" """Planungsliste: Einträge paginiert (50/Seite) mit Zuordnung."""
pro_seite = 50
abfrage = select(Planungseintrag) abfrage = select(Planungseintrag)
if status in GUELTIGE_STATUS: if status in GUELTIGE_STATUS:
abfrage = abfrage.where(Planungseintrag.status == status) abfrage = abfrage.where(Planungseintrag.status == status)
gesamt = db.scalar(
select(func.count()).select_from(abfrage.subquery())
) or 0
eintraege = db.scalars( eintraege = db.scalars(
abfrage.order_by( abfrage.order_by(
Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc() Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc()
) )
.offset((max(seite_nr, 1) - 1) * pro_seite)
.limit(pro_seite)
).all() ).all()
namen = { namen = {
benutzer.id: (benutzer.display_name or benutzer.username) benutzer.id: (benutzer.display_name or benutzer.username)
@@ -249,7 +276,9 @@ class PlanungPlugin(BasePlugin):
"ist_redaktion": user.role in REDAKTION, "ist_redaktion": user.role in REDAKTION,
"meldung": meldung[:300], "meldung": meldung[:300],
"fehler": fehler[:300], "fehler": fehler[:300],
"anzahl": len(eintraege), "anzahl": gesamt,
"hat_weitere": max(seite_nr, 1) * pro_seite < gesamt,
"naechste_seite": max(seite_nr, 1) + 1,
}, },
) )
@@ -392,6 +421,7 @@ class PlanungPlugin(BasePlugin):
autor=zeile.autor, autor=zeile.autor,
bgg_id=zeile.bgg_id, bgg_id=zeile.bgg_id,
bild_url=zeile.bild_url, bild_url=zeile.bild_url,
quellen_url=zeile.quellen_url,
ausgabe="", ausgabe="",
rezensent_id=user.id, rezensent_id=user.id,
status="offen", status="offen",

View File

@@ -50,6 +50,7 @@ class Planungseintrag(Base):
bgg_id: Mapped[int | None] = mapped_column(Integer) bgg_id: Mapped[int | None] = mapped_column(Integer)
#: URL des Coverbildes (aus der Neuheitenliste übernommen oder leer). #: URL des Coverbildes (aus der Neuheitenliste übernommen oder leer).
bild_url: Mapped[str | None] = mapped_column(String(500)) bild_url: Mapped[str | None] = mapped_column(String(500))
quellen_url: Mapped[str | None] = mapped_column(String(500))
#: Magazin-Ausgabe, z. B. „3/2025“ (mehrere Ausgaben parallel möglich). #: Magazin-Ausgabe, z. B. „3/2025“ (mehrere Ausgaben parallel möglich).
ausgabe: Mapped[str] = mapped_column(String(100), default="") ausgabe: Mapped[str] = mapped_column(String(100), default="")
rezensent_id: Mapped[int] = mapped_column(ForeignKey("users.id")) rezensent_id: Mapped[int] = mapped_column(ForeignKey("users.id"))

View File

@@ -76,6 +76,10 @@
{% if eintrag.bgg_id %} {% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener" <a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-0.5 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a> class="ml-0.5 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% elif eintrag.quellen_url %}
<a href="{{ eintrag.quellen_url }}" target="_blank" rel="noopener"
title="Zur Quelle ({{ eintrag.quelle }})"
class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %} {% endif %}
{% if eintrag.pruefung %} {% if eintrag.pruefung %}
<span class="ml-0.5 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs align-middle" <span class="ml-0.5 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs align-middle"
@@ -159,6 +163,10 @@
{% if eintrag.bgg_id %} {% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener" <a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a> class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% elif eintrag.quellen_url %}
<a href="{{ eintrag.quellen_url }}" target="_blank" rel="noopener"
title="Zur Quelle ({{ eintrag.quelle }})"
class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %} {% endif %}
{% if eintrag.pruefung %} {% if eintrag.pruefung %}
<span class="ml-1 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs" <span class="ml-1 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs"
@@ -216,4 +224,11 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% if hat_weitere %}
<div id="planung-lade-mehr"
hx-get="/planung?seite_nr={{ naechste_seite }}&status={{ status_filter }}"
hx-trigger="revealed delay:150ms" hx-swap="outerHTML">
<div class="py-6 text-center text-sm text-slate-400">Lade weitere Eintr&auml;ge&nbsp;&hellip;</div>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -174,8 +174,10 @@ def test_migration_legt_planungsliste_an(app):
text("SELECT version FROM schema_migrations WHERE plugin='planung'") text("SELECT version FROM schema_migrations WHERE plugin='planung'")
).fetchall() ).fetchall()
assert "planungsliste" in tabellen assert "planungsliste" in tabellen
# 0002_bild_url: Coverbild-Spalte (seit der Cover-Erweiterung). # 0002_bild_url + 0003_quellen_url: Coverbild- und Quellen-Link-Spalten.
assert [z[0] for z in protokoll] == ["0001_planungsliste", "0002_bild_url"] assert [z[0] for z in protokoll] == [
"0001_planungsliste", "0002_bild_url", "0003_quellen_url",
]
def test_anonym_wird_zum_login_umgeleitet(client): def test_anonym_wird_zum_login_umgeleitet(client):