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'", "NOT NULL DEFAULT 'day'",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INTEGER " "ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INTEGER "
"REFERENCES categories(id) ON DELETE SET NULL", "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: with engine.begin() as conn:
for stmt in stmts: for stmt in stmts:
conn.execute(text(stmt)) 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 @asynccontextmanager

View File

@@ -336,20 +336,28 @@ class BrandingAsset(Base):
class DashboardLayout(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 ``user_id = NULL`` ist die Vorgabe des Administrators: Sie dient neuen
Benutzern als Ausgangspunkt und lässt sich falls in den Einstellungen so Benutzern als Ausgangspunkt und lässt sich falls in den Einstellungen so
gesetzt auch verbindlich für alle machen. 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" __tablename__ = "dashboard_layouts"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int | None] = mapped_column( 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) layout: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now DateTime(timezone=True), default=_now, onupdate=_now

View File

@@ -34,9 +34,11 @@ from ..models import (
from ..schemas import ( from ..schemas import (
ActivityPoint, ActivityPoint,
CategoryShare, CategoryShare,
DashboardLayoutIn, DashboardCreate,
DashboardLayoutOut, DashboardListOut,
DashboardOut,
DashboardStats, DashboardStats,
DashboardUpdate,
ExpirySplit, ExpirySplit,
FlowPoint, FlowPoint,
TimelinePoint, TimelinePoint,
@@ -53,8 +55,8 @@ ENFORCED_KEY = "dashboard_enforced"
# Höhen zählen Rasterzeilen à 40 px; die Oberfläche hebt zu flache Karten # Höhen zählen Rasterzeilen à 40 px; die Oberfläche hebt zu flache Karten
# zusätzlich auf ihre Mindesthöhe an. # zusätzlich auf ihre Mindesthöhe an.
BUILTIN_LAYOUT: list[dict] = [ BUILTIN_LAYOUT: list[dict] = [
{"i": "actions", "x": 0, "y": 0, "w": 4, "h": 3}, {"i": "actions", "type": "actions", "x": 0, "y": 0, "w": 4, "h": 3},
{"i": "status", "x": 4, "y": 0, "w": 8, "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 # Bewusst die kombinierte Karte: "expiring" zeigt nur noch Laufendes, in der
# Vorgabe soll aber auch Ueberfaelliges ohne Zutun sichtbar sein. # Vorgabe soll aber auch Ueberfaelliges ohne Zutun sichtbar sein.
{"i": "expiry-all", "x": 0, "y": 3, "w": 6, "h": 6}, {"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") return bool(row and row.value == "1")
def _load(db: Session, user_id: int | None) -> list[dict] | None: def _normalize(layout: list[dict]) -> list[dict]:
row = ( """Karten auf die Instanz-Form bringen.
db.query(DashboardLayout)
.filter(DashboardLayout.user_id.is_(None) if user_id is None else DashboardLayout.user_id == user_id) Frueher war ``i`` zugleich die Kartenart dadurch ging jede Art genau
.first() einmal. Jetzt ist ``i`` die Kennung dieser einen Karte und ``type`` ihre
) Art. Aeltere gespeicherte Anordnungen haben kein ``type``; fuer sie ist die
if row is None: alte Kennung die Art.
return None """
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: try:
data = json.loads(row.layout) data = json.loads(row.layout)
except ValueError: except ValueError:
return None return []
return data if isinstance(data, list) else None return _normalize(data) if isinstance(data, list) else []
def _store(db: Session, user_id: int | None, layout: list[dict]) -> None: def _rows(db: Session, user_id: int | None) -> list[DashboardLayout]:
row = ( query = db.query(DashboardLayout)
db.query(DashboardLayout) query = query.filter(
.filter(DashboardLayout.user_id.is_(None) if user_id is None else DashboardLayout.user_id == user_id) DashboardLayout.user_id.is_(None)
.first() if user_id is None
else DashboardLayout.user_id == user_id
) )
text = json.dumps(layout, ensure_ascii=False) return query.order_by(DashboardLayout.position, DashboardLayout.id).all()
if row is None:
db.add(DashboardLayout(user_id=user_id, layout=text))
else: def _as_out(rows: list[DashboardLayout]) -> list[DashboardOut]:
row.layout = text return [
db.commit() 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 # Anordnung
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@router.get("/layout", response_model=DashboardLayoutOut) @router.get("/layouts", response_model=DashboardListOut)
def get_layout( def list_dashboards(
db: Session = Depends(get_db), user: User = Depends(get_current_user) db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> DashboardLayoutOut: ) -> DashboardListOut:
"""Eigene Anordnung, sonst die Vorgabe, sonst die eingebaute.""" """Eigene Dashboards, sonst die Vorgabe, sonst das eingebaute."""
erzwungen = _enforced(db) erzwungen = _enforced(db)
vorgabe = _load(db, None) vorgabe = _rows(db, None)
if erzwungen and vorgabe is not None: if erzwungen and vorgabe:
return DashboardLayoutOut(layout=vorgabe, source="default", enforced=True, has_default=True) return DashboardListOut(
dashboards=_as_out(vorgabe), source="default", enforced=True, has_default=True
)
eigene = _load(db, user.id) eigene = _rows(db, user.id)
if eigene is not None: if eigene:
return DashboardLayoutOut( return DashboardListOut(
layout=eigene, source="user", enforced=erzwungen, has_default=vorgabe is not None dashboards=_as_out(eigene), source="user",
enforced=erzwungen, has_default=bool(vorgabe),
) )
if vorgabe is not None: if vorgabe:
return DashboardLayoutOut( return DashboardListOut(
layout=vorgabe, source="default", enforced=erzwungen, has_default=True dashboards=_as_out(vorgabe), source="default",
enforced=erzwungen, has_default=True,
) )
return DashboardLayoutOut( # Ohne alles das eingebaute Dashboard - mit id 0, weil es keine Zeile hat.
layout=BUILTIN_LAYOUT, source="builtin", enforced=erzwungen, has_default=False 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) @router.post("/layouts", response_model=DashboardOut, status_code=status.HTTP_201_CREATED)
def put_layout( def create_dashboard(
payload: DashboardLayoutIn, payload: DashboardCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> DashboardLayoutOut: ) -> DashboardOut:
if _enforced(db) and _load(db, None) is not None: _guard_enforced(db)
raise HTTPException( bestehende = _rows(db, user.id)
status.HTTP_403_FORBIDDEN, # Legt der Benutzer sein erstes eigenes Dashboard an, waehrend er noch auf
"Die Startseite ist vom Administrator fest vorgegeben.", # der Vorgabe sitzt, wuerde diese sonst schlagartig verschwinden. Deshalb
) # wird sie vorher als eigene Dashboards uebernommen.
_store(db, user.id, payload.layout) if not bestehende:
return get_layout(db=db, user=user) 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") @router.patch("/layouts/{dashboard_id}", response_model=DashboardOut)
def reset_layout( 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) db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> Response: ) -> 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 Der Statuscode steht bewusst am Response und nicht im Dekorator: Durch
``from __future__ import annotations`` wird ``-> None`` zu einer Zeichenkette, ``from __future__ import annotations`` wird ``-> None`` zu einer Zeichenkette,
die FastAPI zu ``NoneType`` auflöst und als Antwortmodell wertet zusammen die FastAPI zu ``NoneType`` auflöst und als Antwortmodell wertet zusammen
mit 204 (das keinen Rumpf haben darf) bricht der Start dann ab. mit 204 (das keinen Rumpf haben darf) bricht der Start dann ab.
""" """
row = db.query(DashboardLayout).filter(DashboardLayout.user_id == user.id).first() for row in _rows(db, user.id):
if row is not None:
db.delete(row) db.delete(row)
db.commit() db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.put("/layout/default", response_model=DashboardLayoutOut) @router.put("/layouts/default", response_model=DashboardListOut)
def put_default_layout( def put_default_dashboards(
payload: DashboardLayoutIn,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: User = Depends(require_admin), admin: User = Depends(require_admin),
) -> DashboardLayoutOut: ) -> DashboardListOut:
_store(db, None, payload.layout) """Die eigenen Dashboards zur Vorgabe für alle machen."""
return get_layout(db=db, user=admin) 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( def set_enforced(
value: bool, value: bool,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: User = Depends(require_admin), admin: User = Depends(require_admin),
) -> DashboardLayoutOut: ) -> DashboardListOut:
row = db.get(Setting, ENFORCED_KEY) row = db.get(Setting, ENFORCED_KEY)
if row is None: if row is None:
db.add(Setting(key=ENFORCED_KEY, value="1" if value else "0")) db.add(Setting(key=ENFORCED_KEY, value="1" if value else "0"))
else: else:
row.value = "1" if value else "0" row.value = "1" if value else "0"
db.commit() 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] 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] layout: list[dict]
class DashboardListOut(BaseModel):
dashboards: list[DashboardOut]
# Woher die Anordnung stammt: eigene, Admin-Vorgabe oder eingebaut. # Woher die Anordnung stammt: eigene, Admin-Vorgabe oder eingebaut.
source: str source: str
# Ist die Vorgabe verbindlich, darf der Benutzer nicht umbauen. # Ist die Vorgabe verbindlich, darf der Benutzer nicht umbauen.
@@ -433,6 +452,13 @@ class DashboardLayoutOut(BaseModel):
has_default: bool has_default: bool
class DashboardLayoutOut(BaseModel):
layout: list[dict]
source: str
enforced: bool
has_default: bool
class DashboardStats(BaseModel): class DashboardStats(BaseModel):
products_in_stock: int # Artikel mit Bestand > 0 products_in_stock: int # Artikel mit Bestand > 0
article_units: float # Summe der Artikeleinheiten article_units: float # Summe der Artikeleinheiten

View File

@@ -188,54 +188,120 @@ def test_activity_zaehlt_ein_und_auslagerungen(db, rice, user):
# ---------------------------------------------------------------- Anordnung # ---------------------------------------------------------------- Anordnung
def _erstes(antwort):
return antwort.dashboards[0]
def test_layout_faellt_auf_die_eingebaute_zurueck(db, user): 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.source == "builtin"
assert antwort.enforced is False assert antwort.enforced is False
assert antwort.has_default 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): 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}] neu = dashboard.create_dashboard(
dashboard.put_layout(DashboardLayoutIn(layout=eigen), db=db, user=user) 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.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): def test_erzwungene_vorgabe_verhindert_eigene_anordnung(db, user):
from fastapi import HTTPException 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.create_dashboard(DashboardCreate(name="Vorgabe", layout=[]), db=db, user=user)
dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user) dashboard.put_default_dashboards(db=db, admin=user)
dashboard.set_enforced(True, 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.source == "default"
assert antwort.enforced is True assert antwort.enforced is True
assert antwort.layout == vorgabe
with pytest.raises(HTTPException) as fehler: 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 assert fehler.value.status_code == 403
def test_zuruecksetzen_stellt_die_vorgabe_wieder_her(db, user): 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.create_dashboard(DashboardCreate(name="Vorgabe", layout=[]), db=db, user=user)
dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user) dashboard.put_default_dashboards(db=db, admin=user)
dashboard.put_layout(DashboardLayoutIn(layout=[{"i": "shopping", "x": 0, "y": 0, "w": 4, "h": 3}]), dashboard.create_dashboard(DashboardCreate(name="Eigenes", layout=[]), db=db, user=user)
db=db, user=user) assert dashboard.list_dashboards(db=db, user=user).source == "user"
assert dashboard.get_layout(db=db, user=user).source == "user"
dashboard.reset_layout(db=db, user=user) dashboard.reset_dashboards(db=db, user=user)
antwort = dashboard.get_layout(db=db, user=user) antwort = dashboard.list_dashboards(db=db, user=user)
assert antwort.source == "default" 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