From b3e259fc25bcc209182a2a7a2bad6ef4ccc888b9 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 30 Aug 2026 16:21:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(M2):=20Pers=C3=B6nliche=20Dashboards=20?= =?UTF-8?q?=E2=80=94=20Tabelle,=20CRUD,=20Lazy-Seed,=20RLS=20(#360)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PROGRESS.md | 28 +- alembic/versions/0144_personal_dashboards.py | 111 ++++ app/core/permission_registry.py | 4 +- app/main.py | 2 + app/models/__init__.py | 4 +- app/models/dashboard.py | 52 ++ .../migrations/0003_documents_generator.sql | 2 +- app/plugins/miniapp_registry.py | 17 + app/routes/__init__.py | 1 + app/routes/dashboards.py | 314 ++++++++++ app/routes/miniapps.py | 15 +- app/schemas/dashboard.py | 59 ++ docs/api-documentation.md | 13 + tests/test_dashboards_backend.py | 550 ++++++++++++++++++ 14 files changed, 1155 insertions(+), 17 deletions(-) create mode 100644 alembic/versions/0144_personal_dashboards.py create mode 100644 app/models/dashboard.py create mode 100644 app/routes/dashboards.py create mode 100644 app/schemas/dashboard.py create mode 100644 tests/test_dashboards_backend.py diff --git a/PROGRESS.md b/PROGRESS.md index fa02939..ff11fc9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ **Kürzlich abgeschlossen:** Phase L vollständig (L1 Block-System, L2 Drag&Drop-Editor, L3 Renderer, L4 KI-Steuerung, L5 XRechnung-Format-Layer) — Details siehe Phase-L-Sections unten. **Offene Roadmap-Phasen (user-abgestimmt, startklar):** -- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓ erledigt** (Universal-Registry, `/api/v1/miniapps`, permission fail-closed — siehe Phase-M1-Section). **Nächster Schritt: M2 Dashboard-Backend** (dashboards-Tabelle pro User, Tabs, Layout JSONB, RLS, Dual-Path). +- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓ erledigt** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS — siehe Phase-M2-Section). **Nächster Schritt: M3 Dashboard-Builder-Frontend** (Edit-Modus, Drag&Drop, Widget-Palette aus /api/v1/miniapps, Tabs, generisches Settings-Form). - **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits. - **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog) @@ -83,7 +83,7 @@ **User-Vision:** Universelle MiniApps (Chat + Dashboard + Windows + AI-Agenten), Dashboard-Builder mit Edit-Modus/Drag&Drop/Resize/Tabs/pro-Widget-Settings, System-Dashboard-Teile zurück in Plugins (Core = reiner Host), Permission-Integration fail-closed. -**Status:** in_progress — M1 ✓ erledigt (siehe Phase-M1-Section unten). Offen: M2 (Dashboard-Backend), M3 (Builder-Frontend), M4 (System-Rückbau), M5 (Plugin-MiniApps), M6 (weitere Hosts). Details: PLATFORM_ROADMAP.md Phase M. +**Status:** in_progress — M1 ✓, M2 ✓ erledigt (siehe Sections unten). Offen: M3 (Builder-Frontend), M4 (System-Rückbau), M5 (Plugin-MiniApps), M6 (weitere Hosts). Details: PLATFORM_ROADMAP.md Phase M. **Live-Bestand analysiert (2026-08-29):** miniapp_registry (kommunikation, 92 Z.), MiniAppContribution (LÜCKE: kein permission-Feld), FrontendDashboardWidget (LÜCKE: kein settings_schema), MiniAppBlock.tsx (Chat-Host fertig), DashboardGrid + 4 Widgets, Dashboard.tsx (170 Z.) mit hardcodierten StatCards/ActivityFeed/System-Metrics (Rückbau-Bestand für M4), @dnd-kit vorhanden. @@ -102,7 +102,29 @@ - ✅ ruff clean (M1-Dateien); 2 Ruff-Funde in automation/knowledge = per Stash bewiesener Vorbestand - ✅ Doku: api-documentation.md (2 Endpoints), plugin-development-guide.md (MiniApp-Beitrag-Muster) -**Offen in Phase M:** M2 Dashboard-Backend (dashboards-Tabelle), M3 Builder-Frontend (konsumiert /api/v1/miniapps), M4 System-Rückbau, M5 Plugin-MiniApps, M6 weitere Hosts. +**Offen in Phase M:** M3 Builder-Frontend (konsumiert /api/v1/dashboards + /api/v1/miniapps), M4 System-Rückbau, M5 Plugin-MiniApps, M6 weitere Hosts. + +## Phase M2 — Dashboard-Backend (2026-08-30) ✅ + +**Spec:** [#360](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/360) | **Roadmap:** Phase M, M2 + +**Umgesetzt:** +- `app/models/dashboard.py`: `dashboards`-Tabelle (persönlich, saved_views-Präzedenz: user_id NOT NULL CASCADE, TenantMixin, kein OwnedMixin). Layout JSONB, `is_default`, partial unique index (tenant, user, name) WHERE deleted_at IS NULL — soft-deleted Boards geben Namen frei (Verbesserung ggü. saved_views-Wart). +- `app/schemas/dashboard.py`: DashboardLayout/Tab/Widget (12-Spalten-Grid: col/row ≥ 0, Spans 1-12) → 422 auf invalide Layouts, bevor persistiert wird. +- `app/routes/dashboards.py` (313 Z.): 6 Endpoints — GET (Liste + lazy Seed), POST (409 dup, erstes = default, ein leerer Start-Tab), GET/{id}, PUT/{id} (Name/Layout, db.refresh gegen MissingGreenlet), DELETE/{id} (Soft-Delete, Default-Promotion), POST/{id}/set-default (exakt ein Default). Owner-only (tenant + user_id Filter, fremde = 404), Audit-Log bei allen Mutationen. +- Lazy Default-Seed: erste GET-Abrufung erzeugt „Mein Dashboard“ aus MiniApp-Registry (permission-gefiltert via geteiltem `user_permits`, Registry-Order, 12-Spalten-Flow mit Wrap). +- `user_permits()` in miniapp_registry.py als geteilter Fail-Closed-Filter (miniapps.py behält `_user_permits`-Alias). +- CORE_PERMISSIONS: `dashboard:read`/`dashboard:write` — **fixt Phantom-Permission** (app/routes/dashboard.py verlangte dashboard:read, nirgends registriert → Nicht-Admins konnten sie nie erhalten). +- Migration `0144_personal_dashboards.py`: dashboards-Tabelle + RLS im 0090-Muster (**crm_api + crm_worker**) + **konvergenter Fix der 3 Phase-L-Policies** (letterheads/print_templates/document_assets waren `TO crm_api`-only — live auf Produktion gemessen, s. Verifikation). Plugin-SQL 0003 ebenfalls auf beide Rollen korrigiert. +- Doku: api-documentation.md (neue Core-Section dashboards, 6 Endpoints). + +**Verifiziert (2026-08-30):** +- TDD: Rot 21 failed/1 passed → ✅ Grün **23/23** (tests/test_dashboards_backend.py: Model/Permission-Unit 3, Layout-Validation 5, CRUD 9, Ownership/Isolation 4, RLS-Konvergenz 2) +- ✅ Live-Messung (psql): Produktion vor Fix — 3 Policies `{crm_api}`-only (letterheads, print_templates, document_assets); lokal nach 0144 — alle 4 Tabellen `{crm_api,crm_worker}` +- ✅ Regression: rls_coverage + miniapp_registry + dashboard + lifecycle + route_order 37/38 — 1 Failure (test_dashboard cross-tenant, POST /companies 405) = **per Stash bewiesener Vorbestand** (identischer Failure auf clean HEAD); solo 5/5 grün +- ✅ Regression Welle 2: rbac_comprehensive + arch_block_a **125/125** +- ✅ ruff clean (alle M2-Dateien inkl. Testdatei); create_app OK (85 Router-Routen) +- Ausstehend: Deploy + Produktions-Verifikation (wird nachgetragen) ## Phase N — Workspace-Scopes (2026-08-30 geplant, user-abgestimmt) diff --git a/alembic/versions/0144_personal_dashboards.py b/alembic/versions/0144_personal_dashboards.py new file mode 100644 index 0000000..ffede20 --- /dev/null +++ b/alembic/versions/0144_personal_dashboards.py @@ -0,0 +1,111 @@ +"""Personal dashboards table (Phase M2) + RLS policy-role convergence. + +Revision ID: 0144 +Revises: 0143 +Create Date: 2026-08-30 + +Part 1 — dashboards: personal per-user dashboard layouts (JSONB tabs / +widgets). RLS follows the 0090 fail-closed pattern scoped to BOTH runtime +roles (crm_api, crm_worker). + +Part 2 — convergence fix (measured live on production 2026-08-30): +migration 0143 created the letterheads/print_templates/document_assets +tenant-isolation policies with ``TO crm_api`` only, while the established +pattern (0090, verified by tests/test_rls_coverage.py) requires both +crm_api AND crm_worker. This migration recreates those policies with both +roles so both install paths (plugin-SQL 0003 / alembic 0143) converge. +""" + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +from alembic import op + +revision = "0144" +down_revision = "0143" +branch_labels = None +depends_on = None + +_TENANT_USING = ( + "tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid" +) + + +def _table_exists(conn, table_name: str) -> bool: + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + +def _create_policy(table: str) -> None: + op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}") + op.execute( + f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE " + f"FOR ALL TO crm_api, crm_worker " + f"USING ({_TENANT_USING}) " + f"WITH CHECK ({_TENANT_USING})" + ) + + +def _rls(table: str) -> None: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + _create_policy(table) + + +def upgrade() -> None: + conn = op.get_bind() + + # ── Part 1: dashboards table ── + if not _table_exists(conn, "dashboards"): + op.create_table( + "dashboards", + sa.Column("id", PGUUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("layout", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index( + "uq_dashboards_tenant_user_name", + "dashboards", + ["tenant_id", "user_id", "name"], + unique=True, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.create_index("ix_dashboards_tenant_user", "dashboards", ["tenant_id", "user_id"]) + _rls("dashboards") + else: + # Dual-path convergence: table exists (plugin SQL), ensure policy roles + _create_policy("dashboards") + + # ── Part 2: converge Phase L policies to crm_api + crm_worker ── + for table in ("letterheads", "print_templates", "document_assets"): + if _table_exists(conn, table): + _create_policy(table) + + +def downgrade() -> None: + conn = op.get_bind() + # Revert the convergence fix to the (buggy) Phase L state first… + for table in ("letterheads", "print_templates", "document_assets"): + if _table_exists(conn, table): + op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}") + op.execute( + f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE " + f"FOR ALL TO crm_api " + f"USING ({_TENANT_USING}) " + f"WITH CHECK ({_TENANT_USING})" + ) + if _table_exists(conn, "dashboards"): + op.execute("DROP POLICY IF EXISTS dashboards_tenant_isolation ON dashboards") + op.drop_index("ix_dashboards_tenant_user", table_name="dashboards") + op.drop_index("uq_dashboards_tenant_user_name", table_name="dashboards") + op.drop_table("dashboards") diff --git a/app/core/permission_registry.py b/app/core/permission_registry.py index 9402a3f..678eddc 100644 --- a/app/core/permission_registry.py +++ b/app/core/permission_registry.py @@ -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). ] diff --git a/app/main.py b/app/main.py index 4388e66..8972ace 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index dddbe2f..8c95a05 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/dashboard.py b/app/models/dashboard.py new file mode 100644 index 0000000..711b375 --- /dev/null +++ b/app/models/dashboard.py @@ -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 + ) diff --git a/app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql b/app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql index 32cc39d..367f0f7 100644 --- a/app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql +++ b/app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql @@ -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 diff --git a/app/plugins/miniapp_registry.py b/app/plugins/miniapp_registry.py index 114be1a..7c3bad4 100644 --- a/app/plugins/miniapp_registry.py +++ b/app/plugins/miniapp_registry.py @@ -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) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 7ea611a..89b4348 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -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 diff --git a/app/routes/dashboards.py b/app/routes/dashboards.py new file mode 100644 index 0000000..0194d6b --- /dev/null +++ b/app/routes/dashboards.py @@ -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) diff --git a/app/routes/miniapps.py b/app/routes/miniapps.py index a4bbd52..1ae8f96 100644 --- a/app/routes/miniapps.py +++ b/app/routes/miniapps.py @@ -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("") diff --git a/app/schemas/dashboard.py b/app/schemas/dashboard.py new file mode 100644 index 0000000..2db4d5d --- /dev/null +++ b/app/schemas/dashboard.py @@ -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} diff --git a/docs/api-documentation.md b/docs/api-documentation.md index 0c7c02a..4124de3 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -283,6 +283,19 @@ Generic CRUD for tenant-wide custom field definitions per entity type (W4b patte | GET | `/api/v1/system/dashboard` | Comprehensive system dashboard (admin-only). Returns: health, DB stats, Redis stats, worker stats, API stats, plugin stats, storage stats. | | GET | `/api/v1/system/alerts` | Active system alerts (admin-only). Returns alerts from Communication-System. | +### dashboards (6 endpoints) + +Personal per-user dashboards (Phase M2). Owner-only: every query is scoped to tenant + current user; foreign dashboards answer 404. First GET lazily seeds a default dashboard from the MiniApp registry (permission-filtered, registry order, 12-column flow). Requires `dashboard:read` / `dashboard:write` (core permissions). + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/dashboards` | List own dashboards (lazy default seed on first call). | +| POST | `/api/v1/dashboards` | Create dashboard (409 duplicate name; first one becomes default; starts with one empty tab). | +| GET | `/api/v1/dashboards/{id}` | Get one dashboard (owner-only, 404 foreign). | +| PUT | `/api/v1/dashboards/{id}` | Update name/layout (layout validated -> 422 on bad grid bounds). | +| DELETE | `/api/v1/dashboards/{id}` | Soft-delete (deleting the default promotes the next one). | +| POST | `/api/v1/dashboards/{id}/set-default` | Mark as the user's default (exactly one). | + --- ## Plugin Routes diff --git a/tests/test_dashboards_backend.py b/tests/test_dashboards_backend.py new file mode 100644 index 0000000..e6d55ff --- /dev/null +++ b/tests/test_dashboards_backend.py @@ -0,0 +1,550 @@ +"""M2 — Dashboard-Backend tests. + +Personal dashboards: per-user dashboards with tabs, JSONB layout, +CRUD + set-default endpoints (owner-only, saved_views precedent), +lazy default seed from the MiniApp registry (permission-filtered), +RLS fail-closed with the 0090 pattern (crm_api + crm_worker), and a +convergent fix for the Phase L policies that were created with only +crm_api (measured live on production 2026-08-30). +""" + +from __future__ import annotations + +import os +import uuid + +import pytest +from httpx import AsyncClient +from pydantic import ValidationError +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users + +os.environ.setdefault("RLS_TEST_ADMIN_DB_URL", "postgresql+asyncpg://postgres@localhost:5432/leocrm_test") + + +@pytest.fixture(autouse=True) +def _clean_miniapp_registry(): + """Fresh MiniApp registry per test (same pattern as test_miniapp_registry).""" + from app.plugins.miniapp_registry import reset_miniapp_registry + + reset_miniapp_registry() + yield + reset_miniapp_registry() + + +def _register_apps(*specs: tuple[str, str, int, int]) -> None: + """Register test MiniApps: (app_id, permission, order, col_span).""" + from app.plugins.miniapp_registry import get_miniapp_registry + + reg = get_miniapp_registry() + for app_id, permission, order, col_span in specs: + reg.register( + app_id=app_id, + name=app_id.replace("_", " ").title(), + plugin_name="test", + permission=permission, + col_span=col_span, + row_span=1, + hosts=["chat", "dashboard", "window"], + order=order, + ) + + +async def _make_user_with_dashboard_perms(db_session: AsyncSession, seed: dict, email: str, role_name: str): + """Create a tenant-A user whose role grants only dashboard:read/write.""" + from app.core.auth import hash_password + from app.models.role import Role + from app.models.user import User, UserTenant + + user = User( + email=email, + name=email.split("@")[0].title(), + password_hash=hash_password("TestPass123!"), + is_active=True, + preferences={}, + ) + db_session.add(user) + await db_session.flush() + role = Role( + tenant_id=seed["tenant_a"].id, + name=role_name, + permissions={"dashboard": {"read": True, "write": True}}, + denied_permissions=[], + field_permissions={}, + ) + db_session.add(role) + await db_session.flush() + db_session.add( + UserTenant( + user_id=user.id, + tenant_id=seed["tenant_a"].id, + is_default=True, + role=role_name, + role_id=role.id, + ) + ) + await db_session.commit() + return user + + +# ═══════════════════════════════════════════════════════════════ +# Unit: model, permissions, layout validation +# ═══════════════════════════════════════════════════════════════ + + +class TestModelUnit: + def test_dashboard_model_importable(self): + from app.models.dashboard import Dashboard + + assert Dashboard.__tablename__ == "dashboards" + + def test_dashboard_model_in_models_package(self): + import app.models as m + from app.models.dashboard import Dashboard + + assert m.Dashboard is Dashboard + assert "Dashboard" in m.__all__ + + def test_dashboard_permissions_registered_in_core(self): + """dashboard:read/write must be valid core permissions (phantom fix). + + Before M2, app/routes/dashboard.py required ``dashboard:read`` but it + was registered nowhere — non-admin users could never be granted it. + """ + from app.core.permission_registry import ( + CORE_PERMISSIONS, + PermissionRegistry, + ) + + keys = {p["key"] for p in CORE_PERMISSIONS} + assert "dashboard:read" in keys + assert "dashboard:write" in keys + reg = PermissionRegistry() + reg.initialize() + assert reg.is_valid("dashboard:read") + assert reg.is_valid("dashboard:write") + + +class TestLayoutValidation: + def test_valid_layout(self): + from app.schemas.dashboard import DashboardLayout + layout = DashboardLayout.model_validate( + { + "version": 1, + "tabs": [ + { + "id": "tab-1", + "name": "Start", + "widgets": [ + { + "app_id": "recent_contacts", + "settings": {"limit": 5}, + "col": 0, + "row": 0, + "col_span": 2, + "row_span": 1, + } + ], + } + ], + } + ) + assert layout.tabs[0].widgets[0].app_id == "recent_contacts" + + def test_empty_layout_ok(self): + from app.schemas.dashboard import DashboardLayout + layout = DashboardLayout.model_validate({"version": 1, "tabs": []}) + assert layout.tabs == [] + + def test_invalid_span_rejected(self): + from app.schemas.dashboard import DashboardLayout + with pytest.raises(ValidationError): + DashboardLayout.model_validate( + { + "version": 1, + "tabs": [ + {"id": "t", "name": "T", "widgets": [{"app_id": "a", "col_span": 13}]} + ], + } + ) + with pytest.raises(ValidationError): + DashboardLayout.model_validate( + { + "version": 1, + "tabs": [{"id": "t", "name": "T", "widgets": [{"app_id": "a", "col_span": 0}]}], + } + ) + + def test_negative_col_rejected(self): + from app.schemas.dashboard import DashboardLayout + with pytest.raises(ValidationError): + DashboardLayout.model_validate( + { + "version": 1, + "tabs": [{"id": "t", "name": "T", "widgets": [{"app_id": "a", "col": -1}]}], + } + ) + + def test_missing_app_id_rejected(self): + from app.schemas.dashboard import DashboardLayout + with pytest.raises(ValidationError): + DashboardLayout.model_validate( + {"version": 1, "tabs": [{"id": "t", "name": "T", "widgets": [{"col": 0, "row": 0}]}]} + ) + + +# ═══════════════════════════════════════════════════════════════ +# API: CRUD + defaults + ownership +# ═══════════════════════════════════════════════════════════════ + + +@pytest.mark.asyncio +class TestDashboardCrud: + async def test_requires_auth(self, client: AsyncClient, db_session): + await seed_tenant_and_users(db_session) + resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER) + assert resp.status_code == 401 + + async def test_requires_dashboard_read_permission(self, client: AsyncClient, db_session): + """Viewer role has no dashboard:read -> 403 (permission enforced).""" + await seed_tenant_and_users(db_session) + await login_client(client, "viewer@tenanta.com") + resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER) + assert resp.status_code == 403 + + async def test_create_and_list(self, client: AsyncClient, db_session): + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + + resp = await client.post( + "/api/v1/dashboards", + json={"name": "Vertrieb"}, + headers=ORIGIN_HEADER, + ) + assert resp.status_code == 201, resp.text + data = resp.json() + assert data["name"] == "Vertrieb" + assert data["is_default"] is True # first dashboard becomes default + assert data["layout"]["version"] == 1 + tabs = data["layout"]["tabs"] + assert len(tabs) == 1 # new dashboards start with an empty "Start" tab + assert tabs[0]["name"] == "Start" + assert tabs[0]["widgets"] == [] + + resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + async def test_lazy_seed_on_first_list(self, client: AsyncClient, db_session): + """First GET seeds a default dashboard from the registry (order-sorted). + + Seed layout is a 12-column flow: widgets are placed side by side in + registry order and wrap to the next row when the row is full. + """ + await seed_tenant_and_users(db_session) + _register_apps( + ("app_b", "", 20, 2), + ("app_a", "", 10, 2), + ("app_wide", "", 30, 12), + ) + await login_client(client, "admin@tenanta.com") + + resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + items = resp.json() + assert len(items) == 1 + seeded = items[0] + assert seeded["is_default"] is True + widgets = seeded["layout"]["tabs"][0]["widgets"] + app_ids = [w["app_id"] for w in widgets] + assert app_ids == ["app_a", "app_b", "app_wide"] # registry order + assert widgets[0]["col_span"] == 2 + assert widgets[0]["col"] == 0 and widgets[0]["row"] == 0 + assert widgets[1]["col"] == 2 and widgets[1]["row"] == 0 + assert widgets[2]["col"] == 0 and widgets[2]["row"] == 1 # 12-span wraps + + async def test_seed_filters_by_permission(self, client: AsyncClient, db_session): + """Seed only includes MiniApps the user may see (fail-closed filter).""" + seed = await seed_tenant_and_users(db_session) + _register_apps( + ("open_app", "", 10, 1), + ("tasks_app", "tasks:read", 20, 1), + ) + await _make_user_with_dashboard_perms(db_session, seed, "dash@tenanta.com", "dash_only") + + await login_client(client, "dash@tenanta.com") + resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + widgets = resp.json()[0]["layout"]["tabs"][0]["widgets"] + app_ids = {w["app_id"] for w in widgets} + assert "open_app" in app_ids + assert "tasks_app" not in app_ids # no tasks:read -> filtered out + + async def test_duplicate_name_409(self, client: AsyncClient, db_session): + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + body = {"name": "Vertrieb"} + r1 = await client.post("/api/v1/dashboards", json=body, headers=ORIGIN_HEADER) + assert r1.status_code == 201 + r2 = await client.post("/api/v1/dashboards", json=body, headers=ORIGIN_HEADER) + assert r2.status_code == 409 + + async def test_get_update_delete(self, client: AsyncClient, db_session): + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + + created = ( + await client.post("/api/v1/dashboards", json={"name": "Eins"}, headers=ORIGIN_HEADER) + ).json() + dash_id = created["id"] + + resp = await client.get(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + assert resp.json()["name"] == "Eins" + + new_layout = { + "version": 1, + "tabs": [ + { + "id": "tab-1", + "name": "Start", + "widgets": [{"app_id": "recent_contacts", "col": 0, "row": 0}], + } + ], + } + resp = await client.put( + f"/api/v1/dashboards/{dash_id}", + json={"name": "Eins Neu", "layout": new_layout}, + headers=ORIGIN_HEADER, + ) + assert resp.status_code == 200, resp.text + updated = resp.json() + assert updated["name"] == "Eins Neu" + assert updated["layout"]["tabs"][0]["widgets"][0]["app_id"] == "recent_contacts" + assert updated["layout"]["tabs"][0]["widgets"][0]["col_span"] == 1 + + # invalid layout -> 422 + bad_layout = {"version": 1, "tabs": [{"id": "t", "name": "T", "widgets": [{"app_id": "x", "col_span": 99}]}]} + resp = await client.put( + f"/api/v1/dashboards/{dash_id}", + json={"layout": bad_layout}, + headers=ORIGIN_HEADER, + ) + assert resp.status_code == 422 + + # invalid uuid -> 400 + resp = await client.get("/api/v1/dashboards/not-a-uuid", headers=ORIGIN_HEADER) + assert resp.status_code == 400 + + # unknown uuid -> 404 + resp = await client.get(f"/api/v1/dashboards/{uuid.uuid4()}", headers=ORIGIN_HEADER) + assert resp.status_code == 404 + + resp = await client.delete(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER) + assert resp.status_code == 204 + resp = await client.get(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER) + assert resp.status_code == 404 + + async def test_set_default_and_reassignment(self, client: AsyncClient, db_session): + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + + d1 = (await client.post("/api/v1/dashboards", json={"name": "Eins"}, headers=ORIGIN_HEADER)).json() + d2 = (await client.post("/api/v1/dashboards", json={"name": "Zwei"}, headers=ORIGIN_HEADER)).json() + assert d1["is_default"] is True + assert d2["is_default"] is False + + resp = await client.post(f"/api/v1/dashboards/{d2['id']}/set-default", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + assert resp.json()["is_default"] is True + + items = {d["id"]: d for d in (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json()} + assert items[d1["id"]]["is_default"] is False + assert items[d2["id"]]["is_default"] is True + + # deleting the default promotes the remaining dashboard + resp = await client.delete(f"/api/v1/dashboards/{d2['id']}", headers=ORIGIN_HEADER) + assert resp.status_code == 204 + items = (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json() + assert len(items) == 1 + assert items[0]["id"] == d1["id"] + assert items[0]["is_default"] is True + + async def test_delete_last_reseeds_on_next_list(self, client: AsyncClient, db_session): + """Empty list state is re-seeded on next GET (documented behaviour).""" + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + + items = (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json() + assert len(items) == 1 + dash_id = items[0]["id"] + + await client.delete(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER) + items = (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json() + assert len(items) == 1 # re-seeded + assert items[0]["is_default"] is True + + async def test_create_audit_logged(self, client: AsyncClient, db_session: AsyncSession): + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + + resp = await client.post("/api/v1/dashboards", json={"name": "Audit"}, headers=ORIGIN_HEADER) + assert resp.status_code == 201 + + result = await db_session.execute( + text("SELECT action, entity_type FROM audit_log WHERE entity_type = 'dashboard'") + ) + rows = result.fetchall() + assert any(r[0] == "create" for r in rows) + + +@pytest.mark.asyncio +class TestOwnershipAndIsolation: + async def test_other_user_gets_404(self, client: AsyncClient, db_session): + """Dashboards are personal (saved_views precedent): other users with + dashboard:read see 404 on foreign dashboards, never their content.""" + import httpx + from httpx import ASGITransport + + import app.main + + seed = await seed_tenant_and_users(db_session) + await _make_user_with_dashboard_perms(db_session, seed, "second@tenanta.com", "dash_second") + await login_client(client, "admin@tenanta.com") + created = ( + await client.post("/api/v1/dashboards", json={"name": "Mein Board"}, headers=ORIGIN_HEADER) + ).json() + + async with httpx.AsyncClient( + transport=ASGITransport(app=app.main.app), base_url="http://test" + ) as other: + await login_client(other, "second@tenanta.com") + resp = await other.get(f"/api/v1/dashboards/{created['id']}", headers=ORIGIN_HEADER) + assert resp.status_code == 404 + # second user only sees their own (seeded) dashboard + resp = await other.get("/api/v1/dashboards", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + items = resp.json() + assert all(d["id"] != created["id"] for d in items) + + async def test_editor_without_permission_gets_403(self, client: AsyncClient, db_session): + """Editor role has no dashboard:read at all -> 403 on every endpoint.""" + import httpx + from httpx import ASGITransport + + import app.main + + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + created = ( + await client.post("/api/v1/dashboards", json={"name": "Mein Board"}, headers=ORIGIN_HEADER) + ).json() + + async with httpx.AsyncClient( + transport=ASGITransport(app=app.main.app), base_url="http://test" + ) as other: + await login_client(other, "editor@tenanta.com") + resp = await other.get(f"/api/v1/dashboards/{created['id']}", headers=ORIGIN_HEADER) + assert resp.status_code == 403 + resp = await other.get("/api/v1/dashboards", headers=ORIGIN_HEADER) + assert resp.status_code == 403 + + async def test_cross_tenant_isolation(self, client: AsyncClient, db_session): + """Tenant B admin never sees tenant A dashboards (RLS + owner filter).""" + import httpx + from httpx import ASGITransport + + import app.main + + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + created = ( + await client.post("/api/v1/dashboards", json={"name": "Tenant A Board"}, headers=ORIGIN_HEADER) + ).json() + + async with httpx.AsyncClient( + transport=ASGITransport(app=app.main.app), base_url="http://test" + ) as other: + await login_client(other, "admin@tenantb.com") + resp = await other.get(f"/api/v1/dashboards/{created['id']}", headers=ORIGIN_HEADER) + assert resp.status_code == 404 + items = (await other.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json() + assert all(d["id"] != created["id"] for d in items) + + +# ═══════════════════════════════════════════════════════════════ +# Migration 0144: RLS convergence (crm_api + crm_worker) +# ═══════════════════════════════════════════════════════════════ + + +def _admin_db_available() -> bool: + try: + import asyncio + + from sqlalchemy.ext.asyncio import create_async_engine + + url = os.environ.get( + "RLS_TEST_ADMIN_DB_URL", + "postgresql+asyncpg://postgres@localhost:5432/leocrm_test", + ) + eng = create_async_engine(url, echo=False) + + async def _check(): + async with eng.connect() as conn: + await conn.execute(text("SELECT 1")) + + asyncio.run(_check()) + return True + except Exception: + return False + + +@pytest.mark.asyncio +@pytest.mark.skipif(not _admin_db_available(), reason="Admin DB not available") +class TestRlsConvergence: + async def test_dashboards_policy_scoped_to_both_roles(self): + from sqlalchemy.ext.asyncio import create_async_engine + + eng = create_async_engine( + os.environ["RLS_TEST_ADMIN_DB_URL"], echo=False + ) + async with eng.connect() as conn: + result = await conn.execute( + text( + "SELECT roles FROM pg_policies " + "WHERE tablename = 'dashboards' " + "AND policyname = 'dashboards_tenant_isolation'" + ) + ) + row = result.fetchone() + assert row is not None, "dashboards_tenant_isolation policy missing" + roles = set(row[0]) + assert "crm_api" in roles + assert "crm_worker" in roles + await eng.dispose() + + async def test_phase_l_policies_converged_to_both_roles(self): + """0143 created letterheads/print_templates/document_assets with only + crm_api (measured on production 2026-08-30). Migration 0144 converges + them to the 0090 pattern (crm_api + crm_worker).""" + from sqlalchemy.ext.asyncio import create_async_engine + + eng = create_async_engine( + os.environ["RLS_TEST_ADMIN_DB_URL"], echo=False + ) + async with eng.connect() as conn: + result = await conn.execute( + text( + "SELECT tablename, roles FROM pg_policies " + "WHERE tablename IN ('letterheads','print_templates','document_assets') " + "AND policyname LIKE '%tenant_isolation%'" + ) + ) + rows = result.fetchall() + assert len(rows) == 3 + for tablename, roles in rows: + assert "crm_api" in set(roles), f"{tablename}: crm_api missing" + assert "crm_worker" in set(roles), f"{tablename}: crm_worker missing" + await eng.dispose()