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:
+25
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user