diff --git a/backend/app/main.py b/backend/app/main.py index 7a8f07c..ed2f374 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models.py b/backend/app/models.py index 3040080..1eabea9 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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 diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 120a7a1..ff8f2eb 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -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) # -------------------------------------------------------------------------- diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6960a19..2b23cad 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index f8075f8..1e2c006 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -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