BGG: Powered-by-Logo im Footer (Lizenzpflicht) + Erscheinungsjahr-Filter (Standard aktuelles Jahr+)

This commit is contained in:
Flo Hartmann
2026-08-25 12:11:06 +00:00
parent 667196a4ba
commit ca6d94938c
8 changed files with 69 additions and 4 deletions

1
.env
View File

@@ -1,2 +1,3 @@
SPIELE_SESSION_SECRET=48ebf7ddae261a931652b5a55cff708aa21cf4936a8bdeb287eeac7cec4ee3e2 SPIELE_SESSION_SECRET=48ebf7ddae261a931652b5a55cff708aa21cf4936a8bdeb287eeac7cec4ee3e2
SPIELE_INITIAL_ADMIN_PASSWORD=3fb93f84014ff45406b36ec3 SPIELE_INITIAL_ADMIN_PASSWORD=3fb93f84014ff45406b36ec3
SPIELE_BGG_TOKEN=6fa6f9f6-32fd-443d-a5c3-ac2527f6a6fc

View File

@@ -94,6 +94,7 @@ class NeuheitenPlugin(BasePlugin):
user: User = Depends(require_user), user: User = Depends(require_user),
q: str = "", q: str = "",
status: str = "", status: str = "",
jahr: int | None = None,
sort: str = STANDARD_SORTIERUNG[0], sort: str = STANDARD_SORTIERUNG[0],
richtung: str = STANDARD_SORTIERUNG[1], richtung: str = STANDARD_SORTIERUNG[1],
meldung: str = "", meldung: str = "",
@@ -118,6 +119,26 @@ class NeuheitenPlugin(BasePlugin):
) )
if status.strip(): if status.strip():
abfrage = abfrage.where(Neuheit.status == status.strip()) abfrage = abfrage.where(Neuheit.status == status.strip())
# Erscheinungsjahr-Filter: Standard „aktuelles Jahr und neuer“.
jahr_standard = datetime.now(timezone.utc).year
if jahr is None:
jahr_filter = jahr_standard
abfrage = abfrage.where(
or_(
Neuheit.erscheinungsjahr >= jahr_standard,
Neuheit.erscheinungsjahr.is_(None), # ohne Jahr nicht wegfiltern
)
)
else:
jahr_filter = jahr
abfrage = abfrage.where(Neuheit.erscheinungsjahr >= jahr)
jahr_optionen = [
j for j in db.scalars(
select(Neuheit.erscheinungsjahr).distinct().order_by(
Neuheit.erscheinungsjahr.desc()
)
) if j is not None
]
gesamt = db.scalar(select(func.count()).select_from(abfrage.subquery())) or 0 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())
@@ -138,6 +159,9 @@ class NeuheitenPlugin(BasePlugin):
"eintraege": eintraege, "eintraege": eintraege,
"q": q, "q": q,
"status_filter": status, "status_filter": status,
"jahr_filter": jahr_filter,
"jahr_standard": jahr_standard,
"jahr_optionen": jahr_optionen,
"sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0], "sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0],
"richtung": "ab" if abwaerts else "auf", "richtung": "ab" if abwaerts else "auf",
"status_optionen": status_optionen, "status_optionen": status_optionen,

View File

@@ -121,7 +121,7 @@
<form method="get" action="/neuheiten" <form method="get" action="/neuheiten"
hx-get="/neuheiten" hx-target="#neuheiten-liste" hx-swap="outerHTML" hx-get="/neuheiten" hx-target="#neuheiten-liste" hx-swap="outerHTML"
hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status']" hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status'], change from:find select[name='jahr']"
class="grid grid-cols-1 sm:flex sm:flex-wrap gap-3 mb-4"> class="grid grid-cols-1 sm:flex sm:flex-wrap gap-3 mb-4">
<input type="text" name="q" value="{{ q }}" placeholder="Suche in Titel, Verlag, Autor …" <input type="text" name="q" value="{{ q }}" placeholder="Suche in Titel, Verlag, Autor …"
class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 sm:w-80"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 sm:w-80">
@@ -131,6 +131,13 @@
<option value="{{ option }}" {% if option == status_filter %}selected{% endif %}>{{ option }}</option> <option value="{{ option }}" {% if option == status_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select name="jahr" class="w-full sm:w-auto min-h-[44px] md:min-h-0 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
<option value="{{ jahr_standard }}">ab {{ jahr_standard }}</option>
{% for jahr in jahr_optionen %}
<option value="{{ jahr }}" {% if jahr|string == jahr_filter|string %}selected{% endif %}>{{ jahr }}</option>
{% endfor %}
<option value="" {% if not jahr_filter %}selected{% endif %}>Alle Jahre</option>
</select>
<noscript> <noscript>
<button type="submit" class="bg-slate-200 hover:bg-slate-300 px-4 py-2 rounded-lg text-sm min-h-[44px] md:min-h-0">Filtern</button> <button type="submit" class="bg-slate-200 hover:bg-slate-300 px-4 py-2 rounded-lg text-sm min-h-[44px] md:min-h-0">Filtern</button>
</noscript> </noscript>

View File

@@ -16,6 +16,7 @@ from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -109,6 +110,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
redoc_url=None, redoc_url=None,
openapi_url=None, openapi_url=None,
) )
# Statische Kern-Dateien (z. B. „Powered by BGG“-Logo, Pflicht für
# öffentliche BGG-XML-API-Anwendungen).
statisch = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=statisch), name="statisch")
app.state.settings = settings app.state.settings = settings
app.state.engine = engine app.state.engine = engine
app.state.session_factory = session_factory app.state.session_factory = session_factory

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -73,6 +73,12 @@
</main> </main>
<footer class="text-center text-xs text-slate-400 py-4"> <footer class="text-center text-xs text-slate-400 py-4">
{{ app_name }} &middot; Plugin-Anzahl: {{ geladene_plugins|length }} {{ app_name }} &middot; Plugin-Anzahl: {{ geladene_plugins|length }}
<div class="mt-2">
<a href="https://boardgamegeek.com" target="_blank" rel="noopener noreferrer">
<img src="/static/powered-by-bgg.png" alt="Powered by BoardGameGeek"
width="120" height="35" class="inline-block opacity-80 hover:opacity-100">
</a>
</div>
</footer> </footer>
</body> </body>
</html> </html>

View File

@@ -435,7 +435,8 @@ def test_neuheitenliste_rendert_platzhalter_ohne_bild(app, client):
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten") antwort = client.get("/neuheiten")
assert antwort.status_code == 200 assert antwort.status_code == 200
assert "<img" not in antwort.text liste = antwort.text.split('<div id="neuheiten-liste"')[1].split("</main>")[0]
assert "<img" not in liste
assert "&#127922;" in antwort.text # Spielicon-Zeichen im Platzhalter-Div assert "&#127922;" in antwort.text # Spielicon-Zeichen im Platzhalter-Div

View File

@@ -4,6 +4,8 @@ Alle BGG-Antworten sind gemockt (FakeBggClient), kein echter Netzwerkverkehr.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from redaktionskern.app import create_app from redaktionskern.app import create_app
@@ -77,7 +79,7 @@ def test_liste_zeigt_eintraege_und_deutsche_beschriftung(app, client):
db.commit() db.commit()
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten") antwort = client.get("/neuheiten?jahr=1990")
assert antwort.status_code == 200 assert antwort.status_code == 200
for text in ( for text in (
@@ -135,12 +137,31 @@ def test_sortierung_nach_jahr_abwaerts(app, client):
db.commit() db.commit()
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten?sort=jahr&richtung=ab") antwort = client.get("/neuheiten?sort=jahr&richtung=ab&jahr=1990")
reihenfolge = [zeile for zeile in ("Neu", "Mittel", "Alt") if zeile] reihenfolge = [zeile for zeile in ("Neu", "Mittel", "Alt") if zeile]
position = [antwort.text.index(titel) for titel in reihenfolge] position = [antwort.text.index(titel) for titel in reihenfolge]
assert position == sorted(position) assert position == sorted(position)
def test_standard_jahr_filter_zeigt_nur_aktuelles_jahr_und_neuer(app, client):
"""Ohne jahr-Parameter: nur aktuelles Jahr und neuer (plus ohne Jahr)."""
with app.state.session_factory() as db:
db.add_all(
[
Neuheit(titel="Alt", bgg_id=1, erscheinungsjahr=1990),
Neuheit(titel="Aktuell", bgg_id=2, erscheinungsjahr=datetime.now(timezone.utc).year),
Neuheit(titel="Ohne Jahr", bgg_id=3),
]
)
db.commit()
melde_an(client)
antwort = client.get("/neuheiten")
assert "Aktuell" in antwort.text
assert "Ohne Jahr" in antwort.text
assert ">Alt<" not in antwort.text
def test_unbekannte_sortierung_faellt_auf_standard_zurueck(client): def test_unbekannte_sortierung_faellt_auf_standard_zurueck(client):
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten?sort=hack&richtung=auf") antwort = client.get("/neuheiten?sort=hack&richtung=auf")