"""Saved views routes — CRUD for reusable view configurations. W4b: Permissions are derived from the entity type's owning plugin instead of a hardcoded ``contacts:read`` (Spec #357/Kritikpunkt 13). """ from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import get_current_user from app.models.saved_view import SavedView router = APIRouter(prefix="/api/v1/saved-views", tags=["saved-views"]) VALID_ENTITY_TYPES = None # Dynamic — validated against ENTITY_MODELS at runtime def _validate_entity_type(entity_type: str) -> None: """Validate entity_type against ENTITY_MODELS. Raises HTTPException if invalid.""" from app.services.entity_permission_service import ENTITY_MODELS if entity_type not in ENTITY_MODELS: from fastapi import HTTPException valid = sorted(ENTITY_MODELS.keys()) raise HTTPException(400, detail={ "detail": f"Invalid entity_type: {entity_type}", "code": "invalid_entity_type", "valid_types": valid, }) def _check_entity_read(current_user: dict, entity_type: str) -> None: """Check that the user has read permission for the entity type.""" from app.core.permissions import check_permission from app.services.entity_permission_service import get_entity_read_permission perm = get_entity_read_permission(entity_type) if not check_permission(current_user, perm): raise HTTPException(403, detail={ "detail": f"Permission '{perm}' required", "code": "forbidden", }) class SavedViewCreate(BaseModel): """Schema for creating a saved view.""" name: str = Field(..., min_length=1, max_length=100) entity_type: str = Field(..., min_length=1, max_length=50) view_config: dict[str, Any] = Field(default_factory=dict) class SavedViewUpdate(BaseModel): """Schema for updating a saved view.""" name: str | None = Field(default=None, min_length=1, max_length=100) view_config: dict[str, Any] | None = None def _view_to_dict(v: SavedView) -> dict[str, Any]: return { "id": str(v.id), "name": v.name, "entity_type": v.entity_type, "view_config": v.view_config, "user_id": str(v.user_id), "created_at": v.created_at.isoformat() if v.created_at else None, "updated_at": v.updated_at.isoformat() if v.updated_at else None, } @router.get("") async def list_saved_views( entity_type: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """List saved views for the current user, optionally filtered by entity_type.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) if entity_type: _check_entity_read(current_user, entity_type) try: query = select(SavedView).where( SavedView.tenant_id == tenant_id, SavedView.user_id == user_id, SavedView.deleted_at.is_(None), ) if entity_type: query = query.where(SavedView.entity_type == entity_type) query = query.order_by(SavedView.name) result = await db.execute(query) views = result.scalars().all() return [_view_to_dict(v) for v in views] except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) from e @router.post("", status_code=status.HTTP_201_CREATED) async def create_saved_view( body: SavedViewCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Create a new saved view for the current user.""" _validate_entity_type(body.entity_type) _check_entity_read(current_user, body.entity_type) tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: # Check uniqueness within user+entity existing = await db.execute( select(SavedView).where( SavedView.tenant_id == tenant_id, SavedView.user_id == user_id, SavedView.entity_type == body.entity_type, SavedView.name == body.name, SavedView.deleted_at.is_(None), ) ) if existing.scalar_one_or_none() is not None: raise HTTPException(409, detail={"detail": "View name already exists", "code": "duplicate"}) saved = SavedView( tenant_id=tenant_id, user_id=user_id, name=body.name, entity_type=body.entity_type, view_config=body.view_config, ) db.add(saved) await db.flush() return _view_to_dict(saved) except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) from e @router.put("/{view_id}") async def update_saved_view( view_id: str, body: SavedViewUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Update a saved view.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: try: vid = uuid.UUID(view_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid view_id", "code": "invalid_id"}) from None result = await db.execute( select(SavedView).where( SavedView.id == vid, SavedView.tenant_id == tenant_id, SavedView.user_id == user_id, SavedView.deleted_at.is_(None), ) ) saved = result.scalar_one_or_none() if saved is None: raise HTTPException(404, detail={"detail": "Saved view not found", "code": "not_found"}) _check_entity_read(current_user, saved.entity_type) if body.name is not None: saved.name = body.name if body.view_config is not None: saved.view_config = body.view_config await db.flush() return _view_to_dict(saved) except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) from e @router.delete("/{view_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_saved_view( view_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Delete a saved view (soft-delete).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: vid = uuid.UUID(view_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid view_id", "code": "invalid_id"}) from None result = await db.execute( select(SavedView).where( SavedView.id == vid, SavedView.tenant_id == tenant_id, SavedView.user_id == user_id, SavedView.deleted_at.is_(None), ) ) saved = result.scalar_one_or_none() if saved is None: raise HTTPException(404, detail={"detail": "Saved view not found", "code": "not_found"}) _check_entity_read(current_user, saved.entity_type) saved.deleted_at = datetime.now(UTC) await db.flush()