diff --git a/backend/app/main.py b/backend/app/main.py index 4b24790..f8277f2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index ab4b597..0c1ff04 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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. diff --git a/backend/app/routers/navigation.py b/backend/app/routers/navigation.py new file mode 100644 index 0000000..7fd5417 --- /dev/null +++ b/backend/app/routers/navigation.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e05272d..6c98424 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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.""" diff --git a/backend/tests/test_navigation.py b/backend/tests/test_navigation.py new file mode 100644 index 0000000..c066042 --- /dev/null +++ b/backend/tests/test_navigation.py @@ -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"]