Seitenleiste: Eintraege ausblendbar (Backend)

Zwei Ebenen in einer Tabelle, nach dem Vorbild von DashboardLayout:
user_id NULL ist die Vorgabe des Administrators, eine Zeile mit user_id
die zusaetzliche Auswahl eines Kontos. Beide Listen gelten zusammen - wer
etwas versteckt, versteckt es zusaetzlich; einblenden, was der
Administrator ausgeblendet hat, kann niemand. Das erspart einen dritten
Zustand je Eintrag.

Als Schluessel dient der Routenpfad; der ist ohnehin eindeutig, eine
zweite Benennung waere nur eine weitere Stelle zum Auseinanderlaufen.

Drei Routen: lesen (Vorgabe, eigene Auswahl und die fertige Vereinigung
in einem Zug), eigene Auswahl setzen, Vorgabe setzen (nur Admin).

Die Eindeutigkeit laeuft ueber einen Index auf coalesce(user_id, -1).
Ein schlichtes UNIQUE(user_id) haette zwei Vorgabezeilen zugelassen, weil
NULL in SQL nie gleich NULL ist - das war im Entwurf noch falsch.

Versteckt wird nur der Eintrag, nicht die Seite: Lesezeichen,
Dashboard-Karten und Lagerort-QR sollen weiter funktionieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-28 20:27:14 +01:00
co-authored by Claude Opus 5
parent 8f5d679d9d
commit be267e70a5
5 changed files with 305 additions and 0 deletions
+2
View File
@@ -21,6 +21,7 @@ from .routers import (
maintenance,
mealie,
measures,
navigation,
oidc,
package_types,
products,
@@ -346,3 +347,4 @@ app.include_router(field_definitions.router)
app.include_router(items.router)
app.include_router(loans.router)
app.include_router(verwendung.router)
app.include_router(navigation.router)
+43
View File
@@ -773,6 +773,49 @@ class DashboardLayout(Base):
)
class NavigationSichtbarkeit(Base):
"""Welche Eintraege der Seitenleiste jemand ausgeblendet hat.
``user_id = NULL`` ist die Vorgabe des Administrators: Was dort steht,
bekommt niemand zu sehen. Eine Zeile mit ``user_id`` ist die zusaetzliche
Auswahl eines Kontos.
**Beide Listen gelten zusammen.** Wer etwas versteckt, versteckt es
zusaetzlich; einblenden, was der Administrator ausgeblendet hat, kann
niemand. Das erspart einen dritten Zustand je Eintrag ("erben / zeigen /
verstecken") und laesst sich in einem Satz erklaeren.
Versteckt wird nur der EINTRAG, nicht die Seite: Lesezeichen,
Dashboard-Karten und Lagerort-QR verweisen weiter auf Routen, die hier
abgewaehlt sein koennen.
Vorbild ist :class:`DashboardLayout`: eigene Tabelle statt :class:`Setting`
(dessen ``value`` fasst 255 Zeichen und haelt genau einen Wert je
Schluessel), Nutzlast als JSON-Text. Als Schluessel dient der Routenpfad -
der ist ohnehin schon eindeutig, eine zweite Benennung waere nur eine
weitere Stelle, die auseinanderlaufen kann.
"""
__tablename__ = "navigation_sichtbarkeit"
__table_args__ = (
# Eine Zeile je Konto UND genau eine Vorgabezeile - beides mit einem
# Index. Ueber coalesce, weil NULL in SQL nie gleich NULL ist: ein
# schlichtes UNIQUE(user_id) liesse beliebig viele Vorgabezeilen zu.
Index("uq_navigation_zeile", text("coalesce(user_id, -1)"), unique=True),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
# JSON-Liste von Routenpfaden, z.B. ["/rezepte", "/shopping"].
versteckt: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
# Loescht man ein Konto, geht seine Zeile mit (ON DELETE CASCADE oben).
# In den Tests greift das nicht: SQLite erzwingt Fremdschluessel nur mit
# PRAGMA foreign_keys=ON, und das setzt die Testumgebung nicht.
class EtikettenVorlage(Base):
"""Eine gespeicherte Zusammenstellung fuer den Etikettenexport.
+105
View File
@@ -0,0 +1,105 @@
"""Welche Einträge der Seitenleiste jemand ausgeblendet hat.
Zwei Ebenen: die **Vorgabe des Administrators** gilt für alle (``user_id``
NULL), die **eigene Auswahl** eines Kontos kommt obendrauf. Beide Listen gelten
zusammen — wer etwas versteckt, versteckt es zusätzlich; einblenden, was der
Administrator ausgeblendet hat, kann niemand.
Die eigene Auswahl darf jeder für sich ändern (es betrifft nur seine Ansicht),
die Vorgabe nur ein Administrator.
Versteckt wird ausschliesslich der **Eintrag**, nicht die Seite: Lesezeichen,
Dashboard-Karten und Lagerort-QR verweisen weiter auf Routen, die hier
abgewählt sein können, und sollen weiter funktionieren.
"""
import json
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import NavigationSichtbarkeit, User
from ..schemas import NavigationOut, NavigationSetzen
router = APIRouter(prefix="/navigation", tags=["navigation"])
def _liste(zeile: NavigationSichtbarkeit | None) -> list[str]:
"""Die gespeicherte Liste – kaputter Inhalt gilt als leer.
Lieber eine vollständige Leiste als eine Fehlermeldung: Ein unlesbarer
Eintrag darf niemanden aussperren.
"""
if zeile is None:
return []
try:
werte = json.loads(zeile.versteckt)
except ValueError:
return []
return [w for w in werte if isinstance(w, str)] if isinstance(werte, list) else []
def _zeile(db: Session, user_id: int | None) -> NavigationSichtbarkeit | None:
return (
db.query(NavigationSichtbarkeit)
.filter(NavigationSichtbarkeit.user_id.is_(None) if user_id is None
else NavigationSichtbarkeit.user_id == user_id)
.first()
)
def _schreiben(db: Session, user_id: int | None, versteckt: list[str]) -> None:
"""Die Zeile setzen oder anlegen.
Doppelte und leere Einträge fallen weg; die Reihenfolge ist bedeutungslos
und wird sortiert, damit zwei gleiche Auswahlen auch gleich aussehen.
"""
sauber = sorted({w.strip() for w in versteckt if w and w.strip()})
zeile = _zeile(db, user_id)
if zeile is None:
zeile = NavigationSichtbarkeit(user_id=user_id, versteckt=json.dumps(sauber))
db.add(zeile)
else:
zeile.versteckt = json.dumps(sauber)
db.commit()
def _antwort(db: Session, user: User) -> NavigationOut:
vorgabe = _liste(_zeile(db, None))
eigen = _liste(_zeile(db, user.id))
return NavigationOut(
vorgabe=vorgabe, eigen=eigen, versteckt=sorted(set(vorgabe) | set(eigen))
)
@router.get("", response_model=NavigationOut)
def navigation_lesen(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> NavigationOut:
"""Vorgabe, eigene Auswahl und die fertige Vereinigung."""
return _antwort(db, user)
@router.put("", response_model=NavigationOut)
def eigene_auswahl_setzen(
payload: NavigationSetzen,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> NavigationOut:
"""Die eigene Auswahl – betrifft nur die eigene Ansicht."""
_schreiben(db, user.id, payload.versteckt)
return _antwort(db, user)
@router.put("/vorgabe", response_model=NavigationOut)
def vorgabe_setzen(
payload: NavigationSetzen,
db: Session = Depends(get_db),
user: User = Depends(require_admin),
) -> NavigationOut:
"""Die Vorgabe für alle."""
_schreiben(db, None, payload.versteckt)
return _antwort(db, user)
+18
View File
@@ -1154,6 +1154,24 @@ class ItemOut(BaseModel):
gehoert_zu: ItemKurz | None = None
# ---- Seitenleiste ----
class NavigationOut(BaseModel):
"""Was die Oberflaechen brauchen, in einem Zug.
``versteckt`` ist die fertige Vereinigung aus Vorgabe und eigener Auswahl –
die Leiste rechnet damit nichts mehr selbst. ``vorgabe`` und ``eigen``
stehen daneben, weil die Einstellungsseite bzw. die Kontoseite genau ihre
eigene Ebene zum Bearbeiten brauchen.
"""
vorgabe: list[str] = []
eigen: list[str] = []
versteckt: list[str] = []
class NavigationSetzen(BaseModel):
versteckt: list[str] = []
# ---- Etiketten-Vorlagen ----
class SpalteOut(BaseModel):
"""Eine waehlbare Spalte – fest oder ein eigenes Feld."""
+137
View File
@@ -0,0 +1,137 @@
"""Seitenleiste ausblenden: Vorgabe des Admins, eigene Auswahl, Vereinigung."""
import json
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from app.models import NavigationSichtbarkeit, Role, User
from app.routers.navigation import (
eigene_auswahl_setzen,
navigation_lesen,
vorgabe_setzen,
)
from app.schemas import NavigationSetzen
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def mitbewohner(db):
person = User(username="mitbewohner", password_hash="x", role=Role.user)
db.add(person)
db.commit()
db.refresh(person)
return person
def _setze_vorgabe(db, admin, werte):
return vorgabe_setzen(NavigationSetzen(versteckt=werte), db=db, user=admin)
def _setze_eigen(db, user, werte):
return eigene_auswahl_setzen(NavigationSetzen(versteckt=werte), db=db, user=user)
# ---- Vorgabe ----
def test_ohne_eintrag_ist_nichts_versteckt(db, admin):
out = navigation_lesen(db=db, user=admin)
assert out.vorgabe == [] and out.eigen == [] and out.versteckt == []
def test_vorgabe_gilt_fuer_alle(db, admin, mitbewohner):
_setze_vorgabe(db, admin, ["/rezepte"])
assert navigation_lesen(db=db, user=mitbewohner).versteckt == ["/rezepte"]
assert navigation_lesen(db=db, user=admin).versteckt == ["/rezepte"]
def test_normaler_nutzer_darf_die_vorgabe_nicht_setzen(db, mitbewohner):
"""require_admin greift – geprueft ueber die Rolle, wie in deps.py."""
from app.deps import require_admin
with pytest.raises(HTTPException) as fehler:
require_admin(mitbewohner)
assert fehler.value.status_code == 403
# ---- Eigene Auswahl ----
def test_eigene_auswahl_betrifft_nur_einen(db, admin, mitbewohner):
_setze_eigen(db, mitbewohner, ["/history"])
assert navigation_lesen(db=db, user=mitbewohner).versteckt == ["/history"]
assert navigation_lesen(db=db, user=admin).versteckt == []
def test_eigene_auswahl_kommt_zur_vorgabe_dazu(db, admin, mitbewohner):
_setze_vorgabe(db, admin, ["/rezepte"])
_setze_eigen(db, mitbewohner, ["/history"])
out = navigation_lesen(db=db, user=mitbewohner)
assert out.vorgabe == ["/rezepte"]
assert out.eigen == ["/history"]
assert out.versteckt == ["/history", "/rezepte"]
def test_nutzer_kann_die_vorgabe_nicht_aufheben(db, admin, mitbewohner):
"""Die eigene Auswahl addiert nur – sie nimmt nichts weg."""
_setze_vorgabe(db, admin, ["/shopping"])
_setze_eigen(db, mitbewohner, [])
assert navigation_lesen(db=db, user=mitbewohner).versteckt == ["/shopping"]
# ---- Speicherung ----
def test_zweimal_setzen_legt_keine_zweite_zeile_an(db, mitbewohner):
_setze_eigen(db, mitbewohner, ["/history"])
_setze_eigen(db, mitbewohner, ["/verliehen"])
zeilen = db.query(NavigationSichtbarkeit).all()
assert len(zeilen) == 1
assert json.loads(zeilen[0].versteckt) == ["/verliehen"]
def test_nur_eine_vorgabezeile(db, admin):
"""Der Teilindex muss eine zweite Vorgabe verhindern."""
_setze_vorgabe(db, admin, ["/rezepte"])
db.add(NavigationSichtbarkeit(user_id=None, versteckt='["/shopping"]'))
with pytest.raises(IntegrityError):
db.commit()
db.rollback()
def test_nur_eine_zeile_je_konto(db, mitbewohner):
_setze_eigen(db, mitbewohner, ["/history"])
db.add(NavigationSichtbarkeit(user_id=mitbewohner.id, versteckt="[]"))
with pytest.raises(IntegrityError):
db.commit()
db.rollback()
def test_doppelte_und_leere_eintraege_fallen_weg(db, mitbewohner):
out = _setze_eigen(db, mitbewohner, ["/history", "/history", "", " ", "/verliehen"])
assert out.eigen == ["/history", "/verliehen"]
def test_kaputter_inhalt_sperrt_niemanden_aus(db, mitbewohner):
"""Lieber die volle Leiste als eine Fehlermeldung."""
db.add(NavigationSichtbarkeit(user_id=mitbewohner.id, versteckt="kein JSON"))
db.commit()
assert navigation_lesen(db=db, user=mitbewohner).versteckt == []
def test_unbekannter_schluessel_wird_mitgefuehrt_aber_stoert_nicht(db, mitbewohner):
"""Ein umbenannter Eintrag darf die Leiste nicht zerreissen – der Server
kennt die Pfade gar nicht, die Oberflaeche ignoriert Unbekanntes."""
out = _setze_eigen(db, mitbewohner, ["/gibtsnichtmehr"])
assert out.versteckt == ["/gibtsnichtmehr"]