feat(M2): Persönliche Dashboards — Tabelle, CRUD, Lazy-Seed, RLS (#360)
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- dashboards-Tabelle (Layout JSONB, Tabs, is_default, partial unique name index) - 6 CRUD-Endpoints /api/v1/dashboards, Owner-only (saved_views-Präzedenz), Audit - Lazy Default-Seed aus MiniApp-Registry (permission-gefiltert, 12-Spalten-Flow) - CORE_PERMISSIONS dashboard:read/write (fixt Phantom-Permission in dashboard.py) - Migration 0144: RLS crm_api+crm_worker + konvergenter Fix der 3 Phase-L-Policies - Tests: test_dashboards_backend.py 23/23 (TDD rot->grün); Regression 162/163
This commit is contained in:
@@ -64,9 +64,11 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||
{"key": "workspaces:delete", "label": "Workspaces: Delete", "category": "core", "module": "workspaces"},
|
||||
{"key": "workspaces:assign_users", "label": "Workspaces: Assign Users", "category": "core", "module": "workspaces"},
|
||||
{"key": "workspaces:configure_modules", "label": "Workspaces: Configure Modules", "category": "core", "module": "workspaces"},
|
||||
{"key": "dashboard:read", "label": "Dashboard: Read", "category": "core", "module": "dashboard"},
|
||||
{"key": "dashboard:write", "label": "Dashboard: Write", "category": "core", "module": "dashboard"},
|
||||
{"key": "system:admin", "label": "System: Admin (cross-tenant)", "category": "system", "module": "system"},
|
||||
# NOTE: Plugin permissions (calendar, dms, mail, tasks, comm, automation, ai,
|
||||
# tags, entity_links, reports, search, mcp, permissions, agents, dashboard)
|
||||
# tags, entity_links, reports, search, mcp, permissions, agents)
|
||||
# are registered dynamically via register_plugin_permissions() from plugin
|
||||
# manifests at activation time. They are NOT hardcoded here (P0-4 fix).
|
||||
]
|
||||
|
||||
@@ -46,6 +46,7 @@ from app.routes import ( # noqa: E402
|
||||
currencies,
|
||||
custom_field_definitions,
|
||||
dashboard,
|
||||
dashboards,
|
||||
delegations,
|
||||
entity_history,
|
||||
entity_permissions,
|
||||
@@ -566,6 +567,7 @@ def create_app() -> FastAPI:
|
||||
# require_active_plugin("contacts") protection.
|
||||
app.include_router(entity_permissions.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(dashboards.router)
|
||||
app.include_router(entity_history.router)
|
||||
app.include_router(import_export.router)
|
||||
app.include_router(plugins.router)
|
||||
|
||||
@@ -17,12 +17,13 @@ from app.models.contact_folder import ContactFolder
|
||||
from app.models.contact_merge import ContactMergeHistory
|
||||
from app.models.currency import Currency
|
||||
from app.models.custom_field_definition import CustomFieldDefinition
|
||||
from app.models.dashboard import Dashboard
|
||||
from app.models.entity_history import EntityHistory
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.entity_policy import EntityPolicy
|
||||
from app.models.group import Group, UserGroup
|
||||
from app.models.notification import Notification, NotificationPreference, NotificationType
|
||||
from app.models.outbox import EventOutbox, OutboxDelivery
|
||||
from app.models.outbox import EventOutbox, OutboxDelivery # noqa: F401
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from app.models.permission_delegation import PermissionDelegation
|
||||
from app.models.permission_template import PermissionTemplate
|
||||
@@ -80,6 +81,7 @@ __all__ = [
|
||||
"WorkflowInstance",
|
||||
"WorkflowStepHistory",
|
||||
"SavedView",
|
||||
"Dashboard",
|
||||
]
|
||||
from app.models.entity_attachment import EntityAttachment # noqa: F401
|
||||
from app.models.workspace import ( # noqa: F401
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Dashboard model — personal per-user dashboards (Phase M2).
|
||||
|
||||
Dashboards are personal (user-owned) layouts of MiniApp instances: tabs,
|
||||
widget placements and per-instance settings, stored as JSONB. Access is
|
||||
owner-only (saved_views precedent) — the active workspace limits only the
|
||||
available widget types (Phase N), never this personal layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Index, String, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Dashboard(Base, TenantMixin):
|
||||
"""Personal dashboard — per-user layout of MiniApp instances."""
|
||||
|
||||
__tablename__ = "dashboards"
|
||||
__table_args__ = (
|
||||
# Partial unique: soft-deleted dashboards free their name (unlike the
|
||||
# saved_views plain constraint, which keeps names occupied forever).
|
||||
Index(
|
||||
"uq_dashboards_tenant_user_name",
|
||||
"tenant_id",
|
||||
"user_id",
|
||||
"name",
|
||||
unique=True,
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
Index("ix_dashboards_tenant_user", "tenant_id", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
# Layout JSONB (validated by app.schemas.dashboard.DashboardLayout):
|
||||
# {version, tabs: [{id, name, widgets: [{app_id, settings, col, row, spans}]}]}
|
||||
layout: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=dict
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
@@ -63,7 +63,7 @@ BEGIN
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
|
||||
EXECUTE format('DROP POLICY IF EXISTS %I ON %I', t || '_tenant_isolation', t);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY %I ON %I AS PERMISSIVE FOR ALL TO crm_api USING (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid) WITH CHECK (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid)',
|
||||
'CREATE POLICY %I ON %I AS PERMISSIVE FOR ALL TO crm_api, crm_worker USING (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid) WITH CHECK (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid)',
|
||||
t || '_tenant_isolation', t
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
|
||||
@@ -152,3 +152,20 @@ def reset_miniapp_registry() -> None:
|
||||
"""Reset the singleton instance (useful for tests)."""
|
||||
global _registry
|
||||
_registry = None
|
||||
|
||||
|
||||
def user_permits(current_user: dict[str, Any], app: dict[str, Any]) -> bool:
|
||||
"""Check whether *current_user* may see/use the MiniApp *app*.
|
||||
|
||||
Empty permission = visible to everyone; otherwise fail-closed check
|
||||
(system admins always pass). Shared by /api/v1/miniapps and the
|
||||
personal dashboard seed (Phase M2) so both apply identical rules.
|
||||
"""
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
required = app.get("permission") or ""
|
||||
if not required:
|
||||
return True
|
||||
if current_user.get("is_system_admin"):
|
||||
return True
|
||||
return check_permission(current_user, required)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.routes import (
|
||||
compliance, # noqa: F401
|
||||
currencies, # noqa: F401
|
||||
dashboard, # noqa: F401
|
||||
dashboards, # noqa: F401
|
||||
entity_history, # noqa: F401
|
||||
guests, # noqa: F401 # ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
||||
health, # noqa: F401
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Personal dashboards routes — CRUD for per-user dashboard layouts (Phase M2).
|
||||
|
||||
Dashboards are personal (saved_views precedent): every query is scoped to
|
||||
the current tenant AND user, so foreign dashboards answer 404. The lazy
|
||||
default seed on first GET derives the widget list from the MiniApp registry
|
||||
(permission-filtered, registry order, 12-column flow).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.db import get_db
|
||||
from app.deps import require_permission
|
||||
from app.models.dashboard import Dashboard
|
||||
from app.plugins.miniapp_registry import get_miniapp_registry, user_permits
|
||||
from app.schemas.dashboard import DashboardCreate, DashboardUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/v1/dashboards", tags=["dashboards"])
|
||||
|
||||
SEED_NAME = "Mein Dashboard"
|
||||
|
||||
|
||||
def _to_dict(d: Dashboard) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(d.id),
|
||||
"name": d.name,
|
||||
"layout": d.layout,
|
||||
"is_default": d.is_default,
|
||||
"user_id": str(d.user_id),
|
||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _parse_uuid(value: str, field: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
|
||||
) from None
|
||||
|
||||
|
||||
async def _get_owned(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, dash_id: str
|
||||
) -> Dashboard:
|
||||
did = _parse_uuid(dash_id, "dashboard_id")
|
||||
result = await db.execute(
|
||||
select(Dashboard).where(
|
||||
Dashboard.id == did,
|
||||
Dashboard.tenant_id == tenant_id,
|
||||
Dashboard.user_id == user_id,
|
||||
Dashboard.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
dashboard = result.scalar_one_or_none()
|
||||
if dashboard is None:
|
||||
raise HTTPException(404, detail={"detail": "Dashboard not found", "code": "not_found"})
|
||||
return dashboard
|
||||
|
||||
|
||||
async def _count_active(db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(Dashboard).where(
|
||||
Dashboard.tenant_id == tenant_id,
|
||||
Dashboard.user_id == user_id,
|
||||
Dashboard.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def _name_taken(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
exclude_id: uuid.UUID | None = None,
|
||||
) -> bool:
|
||||
query = select(func.count()).select_from(Dashboard).where(
|
||||
Dashboard.tenant_id == tenant_id,
|
||||
Dashboard.user_id == user_id,
|
||||
Dashboard.name == name,
|
||||
Dashboard.deleted_at.is_(None),
|
||||
)
|
||||
if exclude_id is not None:
|
||||
query = query.where(Dashboard.id != exclude_id)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0) > 0
|
||||
|
||||
|
||||
async def _seed_default_dashboard(
|
||||
db: AsyncSession, current_user: dict[str, Any]
|
||||
) -> Dashboard:
|
||||
"""Create the first personal dashboard from the MiniApp registry.
|
||||
|
||||
Widgets are placed in registry order using a 12-column flow: an app
|
||||
that no longer fits into the current row wraps to the next one.
|
||||
Apps the user has no permission for are filtered out (fail-closed).
|
||||
"""
|
||||
registry = get_miniapp_registry()
|
||||
apps = [
|
||||
a
|
||||
for a in registry.list_apps(host="dashboard")
|
||||
if user_permits(current_user, a) and "dashboard" in (a.get("hosts") or [])
|
||||
]
|
||||
apps.sort(key=lambda a: a.get("order", 100))
|
||||
|
||||
widgets: list[dict[str, Any]] = []
|
||||
col = 0
|
||||
row = 0
|
||||
for app in apps:
|
||||
col_span = max(1, min(12, int(app.get("col_span") or 1)))
|
||||
row_span = max(1, min(12, int(app.get("row_span") or 1)))
|
||||
if col + col_span > 12:
|
||||
row += 1
|
||||
col = 0
|
||||
widgets.append(
|
||||
{
|
||||
"app_id": app["app_id"],
|
||||
"settings": {},
|
||||
"col": col,
|
||||
"row": row,
|
||||
"col_span": col_span,
|
||||
"row_span": row_span,
|
||||
}
|
||||
)
|
||||
col += col_span
|
||||
|
||||
dashboard = Dashboard(
|
||||
tenant_id=uuid.UUID(current_user["tenant_id"]),
|
||||
user_id=uuid.UUID(current_user["user_id"]),
|
||||
name=SEED_NAME,
|
||||
layout={
|
||||
"version": 1,
|
||||
"tabs": [{"id": "start", "name": "Start", "widgets": widgets}],
|
||||
},
|
||||
is_default=True,
|
||||
)
|
||||
db.add(dashboard)
|
||||
await db.flush()
|
||||
await log_audit(
|
||||
db,
|
||||
dashboard.tenant_id,
|
||||
dashboard.user_id,
|
||||
"create",
|
||||
"dashboard",
|
||||
dashboard.id,
|
||||
details={"seeded": True, "widgets": len(widgets)},
|
||||
)
|
||||
return dashboard
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_dashboards(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("dashboard:read")),
|
||||
):
|
||||
"""List the current user's dashboards (lazy seed when none exist)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
result = await db.execute(
|
||||
select(Dashboard)
|
||||
.where(
|
||||
Dashboard.tenant_id == tenant_id,
|
||||
Dashboard.user_id == user_id,
|
||||
Dashboard.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Dashboard.created_at, Dashboard.id)
|
||||
)
|
||||
dashboards = list(result.scalars().all())
|
||||
if not dashboards:
|
||||
dashboards = [await _seed_default_dashboard(db, current_user)]
|
||||
return [_to_dict(d) for d in dashboards]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_dashboard(
|
||||
body: DashboardCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("dashboard:write")),
|
||||
):
|
||||
"""Create a new personal dashboard (starts with one empty tab)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
if await _name_taken(db, tenant_id, user_id, body.name):
|
||||
raise HTTPException(
|
||||
409, detail={"detail": "Dashboard name already exists", "code": "duplicate"}
|
||||
)
|
||||
|
||||
is_default = await _count_active(db, tenant_id, user_id) == 0
|
||||
dashboard = Dashboard(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
name=body.name,
|
||||
layout={"version": 1, "tabs": [{"id": "start", "name": "Start", "widgets": []}]},
|
||||
is_default=is_default,
|
||||
)
|
||||
db.add(dashboard)
|
||||
await db.flush()
|
||||
await log_audit(db, tenant_id, user_id, "create", "dashboard", dashboard.id)
|
||||
return _to_dict(dashboard)
|
||||
|
||||
|
||||
@router.get("/{dashboard_id}")
|
||||
async def get_dashboard(
|
||||
dashboard_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("dashboard:read")),
|
||||
):
|
||||
"""Get one dashboard (owner-only; foreign ids answer 404)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
|
||||
return _to_dict(dashboard)
|
||||
|
||||
|
||||
@router.put("/{dashboard_id}")
|
||||
async def update_dashboard(
|
||||
dashboard_id: str,
|
||||
body: DashboardUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("dashboard:write")),
|
||||
):
|
||||
"""Update name and/or layout (layout is validated -> 422 on bad input)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
|
||||
|
||||
if body.name is not None and body.name != dashboard.name:
|
||||
if await _name_taken(db, tenant_id, user_id, body.name, exclude_id=dashboard.id):
|
||||
raise HTTPException(
|
||||
409, detail={"detail": "Dashboard name already exists", "code": "duplicate"}
|
||||
)
|
||||
dashboard.name = body.name
|
||||
if body.layout is not None:
|
||||
dashboard.layout = body.layout.model_dump()
|
||||
await db.flush()
|
||||
await db.refresh(dashboard) # onupdate expires attributes (Phase L fix)
|
||||
await log_audit(db, tenant_id, user_id, "update", "dashboard", dashboard.id)
|
||||
return _to_dict(dashboard)
|
||||
|
||||
|
||||
@router.delete("/{dashboard_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_dashboard(
|
||||
dashboard_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("dashboard:write")),
|
||||
):
|
||||
"""Soft-delete a dashboard; deleting the default promotes the next one."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
|
||||
|
||||
was_default = dashboard.is_default
|
||||
dashboard.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
if was_default:
|
||||
result = await db.execute(
|
||||
select(Dashboard)
|
||||
.where(
|
||||
Dashboard.tenant_id == tenant_id,
|
||||
Dashboard.user_id == user_id,
|
||||
Dashboard.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Dashboard.created_at, Dashboard.id)
|
||||
.limit(1)
|
||||
)
|
||||
successor = result.scalar_one_or_none()
|
||||
if successor is not None:
|
||||
successor.is_default = True
|
||||
await db.flush()
|
||||
|
||||
await log_audit(db, tenant_id, user_id, "delete", "dashboard", dashboard.id)
|
||||
|
||||
|
||||
@router.post("/{dashboard_id}/set-default")
|
||||
async def set_default_dashboard(
|
||||
dashboard_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("dashboard:write")),
|
||||
):
|
||||
"""Mark a dashboard as the user's default (exactly one default)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
|
||||
|
||||
if not dashboard.is_default:
|
||||
result = await db.execute(
|
||||
select(Dashboard).where(
|
||||
Dashboard.tenant_id == tenant_id,
|
||||
Dashboard.user_id == user_id,
|
||||
Dashboard.deleted_at.is_(None),
|
||||
Dashboard.is_default.is_(True),
|
||||
)
|
||||
)
|
||||
for other in result.scalars().all():
|
||||
other.is_default = False
|
||||
dashboard.is_default = True
|
||||
await db.flush()
|
||||
await db.refresh(dashboard) # onupdate expires updated_at (Phase L fix)
|
||||
await log_audit(db, tenant_id, user_id, "update", "dashboard", dashboard.id)
|
||||
return _to_dict(dashboard)
|
||||
+4
-11
@@ -9,21 +9,14 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.permissions import check_permission
|
||||
from app.deps import get_current_user
|
||||
from app.plugins.miniapp_registry import get_miniapp_registry
|
||||
from app.plugins.miniapp_registry import get_miniapp_registry, user_permits
|
||||
|
||||
router = APIRouter(prefix="/api/v1/miniapps", tags=["miniapps"])
|
||||
|
||||
|
||||
def _user_permits(current_user: dict, app: dict) -> bool:
|
||||
"""Empty permission = visible to everyone; otherwise fail-closed check."""
|
||||
required = app.get("permission") or ""
|
||||
if not required:
|
||||
return True
|
||||
if current_user.get("is_system_admin"):
|
||||
return True
|
||||
return check_permission(current_user, required)
|
||||
# Backward-compatible alias (the canonical helper lives in the registry
|
||||
# module since Phase M2 — shared with the personal dashboard seed).
|
||||
_user_permits = user_permits
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Schemas for personal dashboards (Phase M2).
|
||||
|
||||
Layout is validated structurally (12-column grid bounds) before it is
|
||||
persisted; MiniApp existence and permissions are enforced at seed time and
|
||||
when the frontend resolves widget components via /api/v1/miniapps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DashboardWidget(BaseModel):
|
||||
"""A MiniApp instance placed on a dashboard tab."""
|
||||
|
||||
app_id: str = Field(..., min_length=1, max_length=80)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
col: int = Field(default=0, ge=0)
|
||||
row: int = Field(default=0, ge=0)
|
||||
col_span: int = Field(default=1, ge=1, le=12)
|
||||
row_span: int = Field(default=1, ge=1, le=12)
|
||||
|
||||
|
||||
class DashboardTab(BaseModel):
|
||||
"""A tab of a dashboard holding widget placements."""
|
||||
|
||||
id: str = Field(..., min_length=1, max_length=80)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
widgets: list[DashboardWidget] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DashboardLayout(BaseModel):
|
||||
"""Full persisted layout of a personal dashboard."""
|
||||
|
||||
version: int = Field(default=1, ge=1)
|
||||
tabs: list[DashboardTab] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DashboardCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class DashboardUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
layout: DashboardLayout | None = None
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
layout: dict[str, Any]
|
||||
is_default: bool
|
||||
user_id: str
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
Reference in New Issue
Block a user