refactor(d3): ARCH-051 — 14 dict-body-Routes auf Pydantic-Schemas umgestellt (entity_permissions bulk ×2, guests invite, users menu-order, system_settings backup-config+dsar, knowledge ×3, self_improvement ×5); DSAR-Export F821-Bug behoben (datetime/timezone undefined → NameError beim GDPR-Export), Zeitstempel auf datetime.now(UTC); Validierung jetzt im Schema statt in Routen
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
@@ -18,6 +19,25 @@ from app.services import bulk_permission_service, entity_permission_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/permissions", tags=["entity-permissions"])
|
||||
|
||||
|
||||
class BulkShareRequest(BaseModel):
|
||||
"""Bulk-share multiple entities with a principal."""
|
||||
|
||||
entity_type: str = Field(..., min_length=1)
|
||||
entity_ids: list[str] = Field(..., min_length=1)
|
||||
principal_type: str = Field(..., pattern="^(user|group|guest)$")
|
||||
principal_id: str = Field(..., min_length=1)
|
||||
level: str = Field(..., pattern="^(read|write|admin|delete|owner)$")
|
||||
|
||||
|
||||
class BulkUnshareRequest(BaseModel):
|
||||
"""Bulk-remove permissions for a principal from multiple entities."""
|
||||
|
||||
entity_type: str = Field(..., min_length=1)
|
||||
entity_ids: list[str] = Field(..., min_length=1)
|
||||
principal_type: str = Field(..., pattern="^(user|group|guest)$")
|
||||
principal_id: str = Field(..., min_length=1)
|
||||
|
||||
# Rate limits for permission changes (prevent abuse/DoS)
|
||||
_PERM_RATE_LIMIT_MAX = 50 # max changes per minute
|
||||
_PERM_RATE_LIMIT_WINDOW = 60 # 60 seconds
|
||||
@@ -279,7 +299,7 @@ async def list_entity_registry(
|
||||
|
||||
@router.post("/bulk", status_code=status.HTTP_201_CREATED)
|
||||
async def bulk_share_permissions(
|
||||
body: dict,
|
||||
body: BulkShareRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("settings:write")),
|
||||
):
|
||||
@@ -290,11 +310,11 @@ async def bulk_share_permissions(
|
||||
result = await bulk_permission_service.bulk_share(
|
||||
db,
|
||||
tenant_id,
|
||||
body["entity_type"],
|
||||
body["entity_ids"],
|
||||
body["principal_type"],
|
||||
body["principal_id"],
|
||||
body["level"],
|
||||
body.entity_type,
|
||||
body.entity_ids,
|
||||
body.principal_type,
|
||||
body.principal_id,
|
||||
body.level,
|
||||
created_by=user_id,
|
||||
)
|
||||
return result
|
||||
@@ -304,7 +324,7 @@ async def bulk_share_permissions(
|
||||
|
||||
@router.post("/bulk/unshare", status_code=status.HTTP_200_OK)
|
||||
async def bulk_unshare_permissions(
|
||||
body: dict,
|
||||
body: BulkUnshareRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("settings:write")),
|
||||
):
|
||||
@@ -314,10 +334,10 @@ async def bulk_unshare_permissions(
|
||||
result = await bulk_permission_service.bulk_unshare(
|
||||
db,
|
||||
tenant_id,
|
||||
body["entity_type"],
|
||||
body["entity_ids"],
|
||||
body["principal_type"],
|
||||
body["principal_id"],
|
||||
body.entity_type,
|
||||
body.entity_ids,
|
||||
body.principal_type,
|
||||
body.principal_id,
|
||||
)
|
||||
return result
|
||||
except (ValueError, KeyError) as e:
|
||||
|
||||
+11
-8
@@ -14,6 +14,7 @@ import secrets
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -27,6 +28,13 @@ router = APIRouter(prefix="/api/v1/guests", tags=["guests"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class GuestInviteRequest(BaseModel):
|
||||
"""Invite a guest user."""
|
||||
|
||||
email: EmailStr
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
"""Hash a token using SHA-256."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
@@ -35,7 +43,7 @@ def _hash_token(token: str) -> str:
|
||||
@router.post("/invite")
|
||||
async def invite_guest(
|
||||
request: Request,
|
||||
body: dict,
|
||||
body: GuestInviteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
@@ -43,13 +51,8 @@ async def invite_guest(
|
||||
|
||||
⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
||||
"""
|
||||
email = body.get("email", "")
|
||||
name = body.get("name", "")
|
||||
if not email or not name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"detail": "Email and name required", "code": "missing_fields"},
|
||||
)
|
||||
email = body.email
|
||||
name = body.name
|
||||
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -18,6 +19,21 @@ from app.services import system_settings_service
|
||||
router = APIRouter(prefix="/api/v1/system-settings", tags=["system-settings"])
|
||||
|
||||
|
||||
class BackupConfigRequest(BaseModel):
|
||||
"""Update backup configuration (all fields optional)."""
|
||||
|
||||
backup_enabled: bool | None = None
|
||||
backup_interval: str | None = Field(None, max_length=20)
|
||||
backup_retention_days: int | None = Field(None, ge=1, le=365)
|
||||
backup_destination: str | None = Field(None, max_length=20)
|
||||
|
||||
|
||||
class DsarRequest(BaseModel):
|
||||
"""Submit a Data Subject Access Request."""
|
||||
|
||||
type: str = Field("access", pattern="^(access|deletion|rectification)$")
|
||||
|
||||
|
||||
@router.get("", response_model=SystemSettingsResponse)
|
||||
async def get_system_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -86,7 +102,7 @@ async def get_backup_config(
|
||||
|
||||
@router.put("/backup-config")
|
||||
async def update_backup_config(
|
||||
body: dict,
|
||||
body: BackupConfigRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("settings:write")),
|
||||
):
|
||||
@@ -105,14 +121,15 @@ async def update_backup_config(
|
||||
|
||||
# Merge backup fields into existing data
|
||||
data = dict(existing)
|
||||
if "backup_enabled" in body:
|
||||
data["backup_enabled"] = bool(body["backup_enabled"])
|
||||
if "backup_interval" in body:
|
||||
data["backup_interval"] = str(body["backup_interval"])
|
||||
if "backup_retention_days" in body:
|
||||
data["backup_retention_days"] = int(body["backup_retention_days"])
|
||||
if "backup_destination" in body:
|
||||
data["backup_destination"] = str(body["backup_destination"])
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
for key in (
|
||||
"backup_enabled",
|
||||
"backup_interval",
|
||||
"backup_retention_days",
|
||||
"backup_destination",
|
||||
):
|
||||
if key in updates:
|
||||
data[key] = updates[key]
|
||||
|
||||
return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data)
|
||||
|
||||
@@ -184,13 +201,16 @@ async def dsgvo_export(
|
||||
- Calendar events
|
||||
- Communication messages
|
||||
"""
|
||||
import json
|
||||
import io
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select as sa_select
|
||||
from app.models.user import User
|
||||
from app.models.contact import Contact
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.contact import Contact
|
||||
from app.models.user import User
|
||||
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
@@ -198,7 +218,7 @@ async def dsgvo_export(
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"}) from None
|
||||
|
||||
export_data = {"user_id": str(uid), "exported_at": datetime.now(timezone.utc).isoformat(), "data": {}}
|
||||
export_data = {"user_id": str(uid), "exported_at": datetime.now(UTC).isoformat(), "data": {}}
|
||||
|
||||
# User profile
|
||||
user_result = await db.execute(sa_select(User).where(User.id == uid))
|
||||
@@ -243,7 +263,7 @@ async def dsgvo_export(
|
||||
@router.post("/dsar/{user_id}")
|
||||
async def dsar_request(
|
||||
user_id: str,
|
||||
body: dict,
|
||||
body: DsarRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("system:admin")),
|
||||
):
|
||||
@@ -254,7 +274,7 @@ async def dsar_request(
|
||||
"""
|
||||
from app.core.jobs import enqueue_job
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
request_type = body.get("type", "access")
|
||||
request_type = body.type
|
||||
try:
|
||||
uid = uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
|
||||
+9
-7
@@ -6,6 +6,7 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -23,6 +24,12 @@ from app.services.user_service import _UNSET, user_service
|
||||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||
|
||||
|
||||
class MenuOrderRequest(BaseModel):
|
||||
"""Update the current user's menu order preference."""
|
||||
|
||||
menu_order: list[str] = Field(..., min_length=0)
|
||||
|
||||
|
||||
def _parse_role_id(raw: str | None) -> uuid.UUID | None:
|
||||
"""Convert a string body value into a UUID or None.
|
||||
|
||||
@@ -368,19 +375,14 @@ async def get_menu_order(
|
||||
|
||||
@router.put("/me/menu-order")
|
||||
async def update_menu_order(
|
||||
body: dict,
|
||||
body: MenuOrderRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update the current user's menu order preference."""
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
menu_order = body.get("menu_order")
|
||||
if not isinstance(menu_order, list) or not all(isinstance(x, str) for x in menu_order):
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={"detail": "menu_order must be a list of strings", "code": "invalid_format"},
|
||||
)
|
||||
menu_order = body.menu_order
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
|
||||
Reference in New Issue
Block a user