Mehrere Dashboards und dieselbe Karte mehrfach
Zwei Grenzen sassen tief im Datenmodell. Erstens war "i" im gespeicherten Layout zugleich die Kartenart; da react-grid-layout eindeutige Kennungen verlangt, ging jede Art genau einmal - zwei Artikel gleichzeitig beobachten war damit unmoeglich. Zweitens war dashboard_layouts.user_id unique, also genau ein Dashboard je Benutzer. Jetzt ist "i" die Kennung dieser einen Karte und "type" ihre Art, dazu kommt "props" fuer das, was nur diese Karte angeht (etwa welcher Artikel). Aeltere Anordnungen haben kein "type"; fuer sie gilt die alte Kennung als Art, sodass gespeicherte Startseiten unveraendert weiterlaufen. Die Tabelle bekommt Name und Reihenfolge, das unique faellt. Sein Name haengt davon ab, wie die Tabelle entstanden ist, deshalb wird er in pg_constraint nachgeschlagen statt geraten. Eine Feinheit beim Anlegen: Wer noch auf der Admin-Vorgabe sitzt und sein erstes eigenes Dashboard anlegt, wuerde die Vorgabe schlagartig verlieren - sobald eigene Zeilen da sind, zaehlen nur noch die. Deshalb wird sie vorher uebernommen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -34,9 +34,11 @@ from ..models import (
|
||||
from ..schemas import (
|
||||
ActivityPoint,
|
||||
CategoryShare,
|
||||
DashboardLayoutIn,
|
||||
DashboardLayoutOut,
|
||||
DashboardCreate,
|
||||
DashboardListOut,
|
||||
DashboardOut,
|
||||
DashboardStats,
|
||||
DashboardUpdate,
|
||||
ExpirySplit,
|
||||
FlowPoint,
|
||||
TimelinePoint,
|
||||
@@ -53,8 +55,8 @@ ENFORCED_KEY = "dashboard_enforced"
|
||||
# Höhen zählen Rasterzeilen à 40 px; die Oberfläche hebt zu flache Karten
|
||||
# zusätzlich auf ihre Mindesthöhe an.
|
||||
BUILTIN_LAYOUT: list[dict] = [
|
||||
{"i": "actions", "x": 0, "y": 0, "w": 4, "h": 3},
|
||||
{"i": "status", "x": 4, "y": 0, "w": 8, "h": 3},
|
||||
{"i": "actions", "type": "actions", "x": 0, "y": 0, "w": 4, "h": 3},
|
||||
{"i": "status", "type": "status", "x": 4, "y": 0, "w": 8, "h": 3},
|
||||
# Bewusst die kombinierte Karte: "expiring" zeigt nur noch Laufendes, in der
|
||||
# Vorgabe soll aber auch Ueberfaelliges ohne Zutun sichtbar sein.
|
||||
{"i": "expiry-all", "x": 0, "y": 3, "w": 6, "h": 6},
|
||||
@@ -70,119 +72,209 @@ def _enforced(db: Session) -> bool:
|
||||
return bool(row and row.value == "1")
|
||||
|
||||
|
||||
def _load(db: Session, user_id: int | None) -> list[dict] | None:
|
||||
row = (
|
||||
db.query(DashboardLayout)
|
||||
.filter(DashboardLayout.user_id.is_(None) if user_id is None else DashboardLayout.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
def _normalize(layout: list[dict]) -> list[dict]:
|
||||
"""Karten auf die Instanz-Form bringen.
|
||||
|
||||
Frueher war ``i`` zugleich die Kartenart – dadurch ging jede Art genau
|
||||
einmal. Jetzt ist ``i`` die Kennung dieser einen Karte und ``type`` ihre
|
||||
Art. Aeltere gespeicherte Anordnungen haben kein ``type``; fuer sie ist die
|
||||
alte Kennung die Art.
|
||||
"""
|
||||
result: list[dict] = []
|
||||
for eintrag in layout:
|
||||
if not isinstance(eintrag, dict) or "i" not in eintrag:
|
||||
continue
|
||||
karte = dict(eintrag)
|
||||
karte.setdefault("type", karte["i"])
|
||||
karte.setdefault("props", {})
|
||||
result.append(karte)
|
||||
return result
|
||||
|
||||
|
||||
def _parse(row: DashboardLayout) -> list[dict]:
|
||||
try:
|
||||
data = json.loads(row.layout)
|
||||
except ValueError:
|
||||
return None
|
||||
return data if isinstance(data, list) else None
|
||||
return []
|
||||
return _normalize(data) if isinstance(data, list) else []
|
||||
|
||||
|
||||
def _store(db: Session, user_id: int | None, layout: list[dict]) -> None:
|
||||
row = (
|
||||
db.query(DashboardLayout)
|
||||
.filter(DashboardLayout.user_id.is_(None) if user_id is None else DashboardLayout.user_id == user_id)
|
||||
.first()
|
||||
def _rows(db: Session, user_id: int | None) -> list[DashboardLayout]:
|
||||
query = db.query(DashboardLayout)
|
||||
query = query.filter(
|
||||
DashboardLayout.user_id.is_(None)
|
||||
if user_id is None
|
||||
else DashboardLayout.user_id == user_id
|
||||
)
|
||||
text = json.dumps(layout, ensure_ascii=False)
|
||||
if row is None:
|
||||
db.add(DashboardLayout(user_id=user_id, layout=text))
|
||||
else:
|
||||
row.layout = text
|
||||
db.commit()
|
||||
return query.order_by(DashboardLayout.position, DashboardLayout.id).all()
|
||||
|
||||
|
||||
def _as_out(rows: list[DashboardLayout]) -> list[DashboardOut]:
|
||||
return [
|
||||
DashboardOut(id=row.id, name=row.name, position=row.position, layout=_parse(row))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _owned(db: Session, dashboard_id: int, user: User) -> DashboardLayout:
|
||||
row = db.get(DashboardLayout, dashboard_id)
|
||||
if row is None or row.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Dashboard nicht gefunden")
|
||||
return row
|
||||
|
||||
|
||||
def _guard_enforced(db: Session) -> None:
|
||||
if _enforced(db) and _rows(db, None):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"Die Startseite ist vom Administrator fest vorgegeben.",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Anordnung
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/layout", response_model=DashboardLayoutOut)
|
||||
def get_layout(
|
||||
@router.get("/layouts", response_model=DashboardListOut)
|
||||
def list_dashboards(
|
||||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||
) -> DashboardLayoutOut:
|
||||
"""Eigene Anordnung, sonst die Vorgabe, sonst die eingebaute."""
|
||||
) -> DashboardListOut:
|
||||
"""Eigene Dashboards, sonst die Vorgabe, sonst das eingebaute."""
|
||||
erzwungen = _enforced(db)
|
||||
vorgabe = _load(db, None)
|
||||
vorgabe = _rows(db, None)
|
||||
|
||||
if erzwungen and vorgabe is not None:
|
||||
return DashboardLayoutOut(layout=vorgabe, source="default", enforced=True, has_default=True)
|
||||
if erzwungen and vorgabe:
|
||||
return DashboardListOut(
|
||||
dashboards=_as_out(vorgabe), source="default", enforced=True, has_default=True
|
||||
)
|
||||
|
||||
eigene = _load(db, user.id)
|
||||
if eigene is not None:
|
||||
return DashboardLayoutOut(
|
||||
layout=eigene, source="user", enforced=erzwungen, has_default=vorgabe is not None
|
||||
eigene = _rows(db, user.id)
|
||||
if eigene:
|
||||
return DashboardListOut(
|
||||
dashboards=_as_out(eigene), source="user",
|
||||
enforced=erzwungen, has_default=bool(vorgabe),
|
||||
)
|
||||
if vorgabe is not None:
|
||||
return DashboardLayoutOut(
|
||||
layout=vorgabe, source="default", enforced=erzwungen, has_default=True
|
||||
if vorgabe:
|
||||
return DashboardListOut(
|
||||
dashboards=_as_out(vorgabe), source="default",
|
||||
enforced=erzwungen, has_default=True,
|
||||
)
|
||||
return DashboardLayoutOut(
|
||||
layout=BUILTIN_LAYOUT, source="builtin", enforced=erzwungen, has_default=False
|
||||
# Ohne alles das eingebaute Dashboard - mit id 0, weil es keine Zeile hat.
|
||||
return DashboardListOut(
|
||||
dashboards=[DashboardOut(id=0, name="Übersicht", position=0,
|
||||
layout=_normalize(BUILTIN_LAYOUT))],
|
||||
source="builtin", enforced=erzwungen, has_default=False,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/layout", response_model=DashboardLayoutOut)
|
||||
def put_layout(
|
||||
payload: DashboardLayoutIn,
|
||||
@router.post("/layouts", response_model=DashboardOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_dashboard(
|
||||
payload: DashboardCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> DashboardLayoutOut:
|
||||
if _enforced(db) and _load(db, None) is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"Die Startseite ist vom Administrator fest vorgegeben.",
|
||||
)
|
||||
_store(db, user.id, payload.layout)
|
||||
return get_layout(db=db, user=user)
|
||||
) -> DashboardOut:
|
||||
_guard_enforced(db)
|
||||
bestehende = _rows(db, user.id)
|
||||
# Legt der Benutzer sein erstes eigenes Dashboard an, waehrend er noch auf
|
||||
# der Vorgabe sitzt, wuerde diese sonst schlagartig verschwinden. Deshalb
|
||||
# wird sie vorher als eigene Dashboards uebernommen.
|
||||
if not bestehende:
|
||||
for row in _rows(db, None):
|
||||
db.add(DashboardLayout(user_id=user.id, name=row.name,
|
||||
position=row.position, layout=row.layout))
|
||||
db.flush()
|
||||
bestehende = _rows(db, user.id)
|
||||
|
||||
position = max((row.position for row in bestehende), default=-1) + 1
|
||||
neu = DashboardLayout(
|
||||
user_id=user.id, name=payload.name.strip(), position=position,
|
||||
layout=json.dumps(_normalize(payload.layout), ensure_ascii=False),
|
||||
)
|
||||
db.add(neu)
|
||||
db.commit()
|
||||
db.refresh(neu)
|
||||
return DashboardOut(id=neu.id, name=neu.name, position=neu.position, layout=_parse(neu))
|
||||
|
||||
|
||||
@router.delete("/layout")
|
||||
def reset_layout(
|
||||
@router.patch("/layouts/{dashboard_id}", response_model=DashboardOut)
|
||||
def update_dashboard(
|
||||
dashboard_id: int,
|
||||
payload: DashboardUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> DashboardOut:
|
||||
_guard_enforced(db)
|
||||
row = _owned(db, dashboard_id, user)
|
||||
|
||||
daten = payload.model_dump(exclude_unset=True)
|
||||
if daten.get("name"):
|
||||
row.name = daten["name"].strip()
|
||||
if daten.get("layout") is not None:
|
||||
row.layout = json.dumps(_normalize(daten["layout"]), ensure_ascii=False)
|
||||
if daten.get("position") is not None:
|
||||
row.position = daten["position"]
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return DashboardOut(id=row.id, name=row.name, position=row.position, layout=_parse(row))
|
||||
|
||||
|
||||
@router.delete("/layouts/{dashboard_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_dashboard(
|
||||
dashboard_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> Response:
|
||||
_guard_enforced(db)
|
||||
row = _owned(db, dashboard_id, user)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.delete("/layouts")
|
||||
def reset_dashboards(
|
||||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||
) -> Response:
|
||||
"""Eigene Anordnung verwerfen – danach gilt wieder die Vorgabe.
|
||||
"""Alle eigenen Dashboards verwerfen – danach gilt wieder die Vorgabe.
|
||||
|
||||
Der Statuscode steht bewusst am Response und nicht im Dekorator: Durch
|
||||
``from __future__ import annotations`` wird ``-> None`` zu einer Zeichenkette,
|
||||
die FastAPI zu ``NoneType`` auflöst und als Antwortmodell wertet – zusammen
|
||||
mit 204 (das keinen Rumpf haben darf) bricht der Start dann ab.
|
||||
"""
|
||||
row = db.query(DashboardLayout).filter(DashboardLayout.user_id == user.id).first()
|
||||
if row is not None:
|
||||
for row in _rows(db, user.id):
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.put("/layout/default", response_model=DashboardLayoutOut)
|
||||
def put_default_layout(
|
||||
payload: DashboardLayoutIn,
|
||||
@router.put("/layouts/default", response_model=DashboardListOut)
|
||||
def put_default_dashboards(
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
) -> DashboardLayoutOut:
|
||||
_store(db, None, payload.layout)
|
||||
return get_layout(db=db, user=admin)
|
||||
) -> DashboardListOut:
|
||||
"""Die eigenen Dashboards zur Vorgabe für alle machen."""
|
||||
for row in _rows(db, None):
|
||||
db.delete(row)
|
||||
for row in _rows(db, admin.id):
|
||||
db.add(DashboardLayout(user_id=None, name=row.name,
|
||||
position=row.position, layout=row.layout))
|
||||
db.commit()
|
||||
return list_dashboards(db=db, user=admin)
|
||||
|
||||
|
||||
@router.put("/layout/enforced", response_model=DashboardLayoutOut)
|
||||
@router.put("/layout/enforced", response_model=DashboardListOut)
|
||||
def set_enforced(
|
||||
value: bool,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
) -> DashboardLayoutOut:
|
||||
) -> DashboardListOut:
|
||||
row = db.get(Setting, ENFORCED_KEY)
|
||||
if row is None:
|
||||
db.add(Setting(key=ENFORCED_KEY, value="1" if value else "0"))
|
||||
else:
|
||||
row.value = "1" if value else "0"
|
||||
db.commit()
|
||||
return get_layout(db=db, user=admin)
|
||||
return list_dashboards(db=db, user=admin)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user