feat(#357): W4b — Saved-Views/Filters von contacts:read entkoppelt
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- 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)
This commit is contained in:
+43
-25
@@ -1,9 +1,13 @@
|
||||
"""Saved filters routes — CRUD for reusable filter criteria."""
|
||||
"""Saved filters routes — CRUD for reusable filter criteria.
|
||||
|
||||
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
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
@@ -12,7 +16,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.deps import get_current_user
|
||||
from app.models.saved_filter import SavedFilter
|
||||
|
||||
router = APIRouter(prefix="/api/v1/saved-filters", tags=["saved-filters"])
|
||||
@@ -33,6 +37,19 @@ def _validate_entity_type(entity_type: str) -> None:
|
||||
})
|
||||
|
||||
|
||||
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 SavedFilterCreate(BaseModel):
|
||||
"""Schema for creating a saved filter."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
@@ -68,6 +85,9 @@ async def list_saved_filters(
|
||||
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(SavedFilter).where(
|
||||
SavedFilter.tenant_id == tenant_id,
|
||||
@@ -85,7 +105,7 @@ async def list_saved_filters(
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))])
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_saved_filter(
|
||||
body: SavedFilterCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -93,6 +113,7 @@ async def create_saved_filter(
|
||||
):
|
||||
"""Create a new saved filter 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"])
|
||||
|
||||
@@ -124,7 +145,7 @@ async def create_saved_filter(
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/{filter_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))])
|
||||
@router.delete("/{filter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_saved_filter(
|
||||
filter_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -135,26 +156,23 @@ async def delete_saved_filter(
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
try:
|
||||
try:
|
||||
fid = uuid.UUID(filter_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, detail={"detail": "Invalid filter_id", "code": "invalid_id"}) from None
|
||||
fid = uuid.UUID(filter_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, detail={"detail": "Invalid filter_id", "code": "invalid_id"}) from None
|
||||
|
||||
result = await db.execute(
|
||||
select(SavedFilter).where(
|
||||
SavedFilter.id == fid,
|
||||
SavedFilter.tenant_id == tenant_id,
|
||||
SavedFilter.user_id == user_id,
|
||||
SavedFilter.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(
|
||||
select(SavedFilter).where(
|
||||
SavedFilter.id == fid,
|
||||
SavedFilter.tenant_id == tenant_id,
|
||||
SavedFilter.user_id == user_id,
|
||||
SavedFilter.deleted_at.is_(None),
|
||||
)
|
||||
saved = result.scalar_one_or_none()
|
||||
if saved is None:
|
||||
raise HTTPException(404, detail={"detail": "Saved filter not found", "code": "not_found"})
|
||||
)
|
||||
saved = result.scalar_one_or_none()
|
||||
if saved is None:
|
||||
raise HTTPException(404, detail={"detail": "Saved filter not found", "code": "not_found"})
|
||||
|
||||
from datetime import datetime
|
||||
saved.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
_check_entity_read(current_user, saved.entity_type)
|
||||
saved.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
+46
-27
@@ -1,18 +1,22 @@
|
||||
"""Saved views routes — CRUD for reusable view configurations."""
|
||||
"""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
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
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, require_permission
|
||||
from app.deps import get_current_user
|
||||
from app.models.saved_view import SavedView
|
||||
|
||||
router = APIRouter(prefix="/api/v1/saved-views", tags=["saved-views"])
|
||||
@@ -33,6 +37,19 @@ def _validate_entity_type(entity_type: str) -> None:
|
||||
})
|
||||
|
||||
|
||||
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)
|
||||
@@ -68,6 +85,9 @@ async def list_saved_views(
|
||||
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,
|
||||
@@ -85,7 +105,7 @@ async def list_saved_views(
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))])
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_saved_view(
|
||||
body: SavedViewCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -93,6 +113,7 @@ async def create_saved_view(
|
||||
):
|
||||
"""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"])
|
||||
|
||||
@@ -124,7 +145,7 @@ async def create_saved_view(
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.put("/{view_id}", dependencies=[Depends(require_permission("contacts:read"))])
|
||||
@router.put("/{view_id}")
|
||||
async def update_saved_view(
|
||||
view_id: str,
|
||||
body: SavedViewUpdate,
|
||||
@@ -153,6 +174,8 @@ async def update_saved_view(
|
||||
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:
|
||||
@@ -163,7 +186,7 @@ async def update_saved_view(
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/{view_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))])
|
||||
@router.delete("/{view_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_saved_view(
|
||||
view_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -174,26 +197,22 @@ async def delete_saved_view(
|
||||
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
|
||||
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),
|
||||
)
|
||||
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"})
|
||||
)
|
||||
saved = result.scalar_one_or_none()
|
||||
if saved is None:
|
||||
raise HTTPException(404, detail={"detail": "Saved view not found", "code": "not_found"})
|
||||
|
||||
from datetime import datetime
|
||||
saved.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
_check_entity_read(current_user, saved.entity_type)
|
||||
saved.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
Reference in New Issue
Block a user