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."""