Files
leocrm/app/routes/saved_views.py
T

197 lines
6.5 KiB
Python
Raw Normal View History

"""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
from app.services.entity_permission_service import (
check_entity_read_permission as _check_entity_read,
)
from app.services.entity_permission_service import (
validate_entity_type as _validate_entity_type,
)
router = APIRouter(prefix="/api/v1/saved-views", tags=["saved-views"])
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()