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:
Scarriffle
2026-07-23 19:11:49 +02:00
parent b2a38501d2
commit f62289f975
5 changed files with 301 additions and 93 deletions

View File

@@ -61,10 +61,26 @@ def _ensure_schema() -> None:
"NOT NULL DEFAULT 'day'",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INTEGER "
"REFERENCES categories(id) ON DELETE SET NULL",
# Mehrere Dashboards je Benutzer: Name und Reihenfolge kommen dazu.
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS name VARCHAR(80) "
"NOT NULL DEFAULT 'Übersicht'",
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS position INTEGER "
"NOT NULL DEFAULT 0",
]
with engine.begin() as conn:
for stmt in stmts:
conn.execute(text(stmt))
# Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer.
# Der Name der Beschraenkung haengt davon ab, wie die Tabelle entstanden
# ist, deshalb wird er nachgeschlagen statt geraten.
namen = conn.execute(
text(
"SELECT conname FROM pg_constraint "
"WHERE conrelid = 'dashboard_layouts'::regclass AND contype = 'u'"
)
).scalars().all()
for name in namen:
conn.execute(text(f'ALTER TABLE dashboard_layouts DROP CONSTRAINT "{name}"'))
@asynccontextmanager

View File

@@ -336,20 +336,28 @@ class BrandingAsset(Base):
class DashboardLayout(Base):
"""Anordnung der Karten auf der Startseite.
"""Ein Dashboard: benannte Anordnung von Karten.
``user_id = NULL`` ist die Vorgabe des Administrators: Sie dient neuen
Benutzern als Ausgangspunkt und lässt sich falls in den Einstellungen so
gesetzt auch verbindlich für alle machen.
Jeder Benutzer darf **mehrere** Dashboards haben (deshalb steht auf
``user_id`` kein unique mehr); ``position`` bestimmt ihre Reihenfolge in der
Seitenleiste.
"""
__tablename__ = "dashboard_layouts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
# JSON-Text: [{"i": "expiring", "x": 0, "y": 0, "w": 4, "h": 3, "props": {}}]
name: Mapped[str] = mapped_column(String(80), nullable=False, default="Übersicht")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# JSON-Text. "i" ist die Kennung *dieser Karte*, "type" ihre Art - erst
# dadurch laesst sich dieselbe Art mehrfach auf ein Dashboard legen:
# [{"i": "k3", "type": "expiring", "x": 0, "y": 0, "w": 4, "h": 3, "props": {}}]
layout: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now

View File

@@ -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)
# --------------------------------------------------------------------------

View File

@@ -424,8 +424,27 @@ class DashboardLayoutIn(BaseModel):
layout: list[dict]
class DashboardLayoutOut(BaseModel):
class DashboardCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
layout: list[dict] = []
class DashboardUpdate(BaseModel):
"""Umbenennen und/oder umbauen nur mitgeschickte Felder zaehlen."""
name: str | None = Field(default=None, min_length=1, max_length=80)
layout: list[dict] | None = None
position: int | None = None
class DashboardOut(BaseModel):
id: int
name: str
position: int
layout: list[dict]
class DashboardListOut(BaseModel):
dashboards: list[DashboardOut]
# Woher die Anordnung stammt: eigene, Admin-Vorgabe oder eingebaut.
source: str
# Ist die Vorgabe verbindlich, darf der Benutzer nicht umbauen.
@@ -433,6 +452,13 @@ class DashboardLayoutOut(BaseModel):
has_default: bool
class DashboardLayoutOut(BaseModel):
layout: list[dict]
source: str
enforced: bool
has_default: bool
class DashboardStats(BaseModel):
products_in_stock: int # Artikel mit Bestand > 0
article_units: float # Summe der Artikeleinheiten

View File

@@ -188,54 +188,120 @@ def test_activity_zaehlt_ein_und_auslagerungen(db, rice, user):
# ---------------------------------------------------------------- Anordnung
def _erstes(antwort):
return antwort.dashboards[0]
def test_layout_faellt_auf_die_eingebaute_zurueck(db, user):
antwort = dashboard.get_layout(db=db, user=user)
antwort = dashboard.list_dashboards(db=db, user=user)
assert antwort.source == "builtin"
assert antwort.enforced is False
assert antwort.has_default is False
assert any(k["i"] == "expiry-all" for k in antwort.layout)
assert len(antwort.dashboards) == 1
assert any(k["type"] == "expiry-all" for k in _erstes(antwort).layout)
def test_eigene_anordnung_wird_gespeichert(db, user):
from app.schemas import DashboardLayoutIn
from app.schemas import DashboardCreate, DashboardUpdate
eigen = [{"i": "shopping", "x": 0, "y": 0, "w": 6, "h": 3}]
dashboard.put_layout(DashboardLayoutIn(layout=eigen), db=db, user=user)
neu = dashboard.create_dashboard(
DashboardCreate(name="Vorräte", layout=[]), db=db, user=user
)
eigen = [{"i": "k1", "type": "shopping", "x": 0, "y": 0, "w": 6, "h": 3, "props": {}}]
dashboard.update_dashboard(neu.id, DashboardUpdate(layout=eigen), db=db, user=user)
antwort = dashboard.get_layout(db=db, user=user)
antwort = dashboard.list_dashboards(db=db, user=user)
assert antwort.source == "user"
assert antwort.layout == eigen
assert _erstes(antwort).name == "Vorräte"
assert _erstes(antwort).layout == eigen
def test_dieselbe_kartenart_mehrfach(db, user):
"""Der eigentliche Zweck der Instanz-Kennung: zwei Karten derselben Art,
jede auf einen anderen Artikel eingestellt."""
from app.schemas import DashboardCreate
layout = [
{"i": "a", "type": "product-timeline", "x": 0, "y": 0, "w": 6, "h": 6,
"props": {"product_id": 1}},
{"i": "b", "type": "product-timeline", "x": 6, "y": 0, "w": 6, "h": 6,
"props": {"product_id": 2}},
]
neu = dashboard.create_dashboard(
DashboardCreate(name="Beobachtung", layout=layout), db=db, user=user
)
assert len(neu.layout) == 2
assert {k["props"]["product_id"] for k in neu.layout} == {1, 2}
def test_alte_anordnung_bekommt_die_art_aus_der_kennung(db):
"""Gespeicherte Anordnungen von frueher haben kein "type" - dort war die
Kennung die Art. Ohne das Nachziehen waere jede alte Karte unbekannt."""
alt = [{"i": "shopping", "x": 0, "y": 0, "w": 6, "h": 3}]
normalisiert = dashboard._normalize(alt)
assert normalisiert[0]["type"] == "shopping"
assert normalisiert[0]["props"] == {}
def test_erzwungene_vorgabe_verhindert_eigene_anordnung(db, user):
from fastapi import HTTPException
from app.schemas import DashboardLayoutIn
from app.schemas import DashboardCreate
vorgabe = [{"i": "status", "x": 0, "y": 0, "w": 12, "h": 1}]
dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user)
dashboard.create_dashboard(DashboardCreate(name="Vorgabe", layout=[]), db=db, user=user)
dashboard.put_default_dashboards(db=db, admin=user)
dashboard.set_enforced(True, db=db, admin=user)
antwort = dashboard.get_layout(db=db, user=user)
antwort = dashboard.list_dashboards(db=db, user=user)
assert antwort.source == "default"
assert antwort.enforced is True
assert antwort.layout == vorgabe
with pytest.raises(HTTPException) as fehler:
dashboard.put_layout(DashboardLayoutIn(layout=[]), db=db, user=user)
dashboard.create_dashboard(DashboardCreate(name="X", layout=[]), db=db, user=user)
assert fehler.value.status_code == 403
def test_zuruecksetzen_stellt_die_vorgabe_wieder_her(db, user):
from app.schemas import DashboardLayoutIn
from app.schemas import DashboardCreate
vorgabe = [{"i": "status", "x": 0, "y": 0, "w": 12, "h": 1}]
dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user)
dashboard.put_layout(DashboardLayoutIn(layout=[{"i": "shopping", "x": 0, "y": 0, "w": 4, "h": 3}]),
db=db, user=user)
assert dashboard.get_layout(db=db, user=user).source == "user"
dashboard.create_dashboard(DashboardCreate(name="Vorgabe", layout=[]), db=db, user=user)
dashboard.put_default_dashboards(db=db, admin=user)
dashboard.create_dashboard(DashboardCreate(name="Eigenes", layout=[]), db=db, user=user)
assert dashboard.list_dashboards(db=db, user=user).source == "user"
dashboard.reset_layout(db=db, user=user)
antwort = dashboard.get_layout(db=db, user=user)
dashboard.reset_dashboards(db=db, user=user)
antwort = dashboard.list_dashboards(db=db, user=user)
assert antwort.source == "default"
assert antwort.layout == vorgabe
assert _erstes(antwort).name == "Vorgabe"
def test_erstes_eigenes_uebernimmt_die_vorgabe(db, user):
"""Sonst verschwaende das Anlegen eines zweiten Dashboards die Vorgabe:
Sobald eigene Zeilen existieren, zaehlen nur noch die."""
from app.schemas import DashboardCreate
dashboard.create_dashboard(DashboardCreate(name="Vorgabe", layout=[]), db=db, user=user)
dashboard.put_default_dashboards(db=db, admin=user)
dashboard.reset_dashboards(db=db, user=user)
assert dashboard.list_dashboards(db=db, user=user).source == "default"
dashboard.create_dashboard(DashboardCreate(name="Getränke", layout=[]), db=db, user=user)
antwort = dashboard.list_dashboards(db=db, user=user)
assert [d.name for d in antwort.dashboards] == ["Vorgabe", "Getränke"]
def test_fremdes_dashboard_ist_unsichtbar(db, user):
from fastapi import HTTPException
from app.models import Role, User
from app.schemas import DashboardCreate, DashboardUpdate
fremd = User(username="andere", password_hash="x", role=Role.user)
db.add(fremd)
db.commit()
db.refresh(fremd)
meins = dashboard.create_dashboard(DashboardCreate(name="Meins", layout=[]), db=db, user=user)
with pytest.raises(HTTPException) as fehler:
dashboard.update_dashboard(meins.id, DashboardUpdate(name="Geklaut"), db=db, user=fremd)
assert fehler.value.status_code == 404