Files
leocrm/app/routes/saved_views.py
T
Agent Zero b7b7d41c0c
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(#357): W4b — Saved-Views/Filters von contacts:read entkoppelt
- ENTITY_PLUGIN_OWNERS-Registry: trackt, welches Plugin welche Entity registriert
- get_entity_read_permission(): leitet die modul-korrekte Permission ab
  (contacts -> contacts:read, tasks -> tasks:read, ...) mit Core-Fallback
- registry.activate(): uebergibt plugin_name an register_entity_model
- saved_views.py + saved_filters.py: statische contacts:read-Dependencies
  durch dynamische _check_entity_read() ersetzt — Saved Views/Filters fuer
  fremde Entities brauchen jetzt die richtige modul-spezifische Permission

Verifikation: 10/11 tests/test_saved_filters.py passed (1 Vorbestand-
Failure per Stash bewiesen), create_app OK, ruff modified-files gruen.

fixes #357 (W4b-Teil)
2026-08-28 12:07:57 +02:00

219 lines
7.3 KiB
Python

"""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()