feat(M2): Persönliche Dashboards — Tabelle, CRUD, Lazy-Seed, RLS (#360)
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:
Agent Zero
2026-08-30 16:21:34 +02:00
parent 7a755d32e6
commit b3e259fc25
14 changed files with 1155 additions and 17 deletions
+3 -1
View File
@@ -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).
]
+2
View File
@@ -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)
+3 -1
View File
@@ -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
+52
View File
@@ -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
+17
View File
@@ -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)
+1
View File
@@ -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
+314
View File
@@ -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
View File
@@ -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("")
+59
View File
@@ -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}