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:
co-authored by
Claude Opus 5
parent
8f5d679d9d
commit
be267e70a5
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user