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

This commit is contained in:
Agent Zero
2026-08-24 10:55:22 +02:00
parent ef90d57f0a
commit c32e4bb34e
7 changed files with 189 additions and 118 deletions
+2 -1
View File
@@ -77,6 +77,7 @@
| D3-d | Systemischer P1-Bug: DMS/Mail überschrieben get_entity_models() nicht → 'dms_file'/'dms_folder'/'file'/'mail_account' fehlten im ENTITY_MODELS-Mapping → ValueError bei allen Entity-Freigaben/Berechtigungen zur Laufzeit (28 Mail-Test-Failures + 2 test_permissions-Failures, Stash-verifiziert) | ✅ Overrides ergänzt (DMS: dms_file/dms_folder/file-Alias; Mail: mail_account); test_permissions 22/22 grün; Resolver-Auflösung aller 4 Typen direkt bewiesen | — | | D3-d | Systemischer P1-Bug: DMS/Mail überschrieben get_entity_models() nicht → 'dms_file'/'dms_folder'/'file'/'mail_account' fehlten im ENTITY_MODELS-Mapping → ValueError bei allen Entity-Freigaben/Berechtigungen zur Laufzeit (28 Mail-Test-Failures + 2 test_permissions-Failures, Stash-verifiziert) | ✅ Overrides ergänzt (DMS: dms_file/dms_folder/file-Alias; Mail: mail_account); test_permissions 22/22 grün; Resolver-Auflösung aller 4 Typen direkt bewiesen | — |
| D3-e | conftest db_setup: pgvector-Extension fehlte nach DB-Recreate → alle create_all-Läufe scheiterten an 'type vector does not exist' | ✅ CREATE EXTENSION IF NOT EXISTS vector in db_setup-Fixture verankert (nach CREATE SCHEMA, vor alembic upgrade head) | — | | D3-e | conftest db_setup: pgvector-Extension fehlte nach DB-Recreate → alle create_all-Läufe scheiterten an 'type vector does not exist' | ✅ CREATE EXTENSION IF NOT EXISTS vector in db_setup-Fixture verankert (nach CREATE SCHEMA, vor alembic upgrade head) | — |
| D3-f | BUG-027029/031035/071 (falsche Test-Pfade/Payloads): Recherche zeigte — falsche Pfade existieren NICHT mehr in tests/, reale API hat korrekte Prefixe (/api/v1/user/preferences, /api/v1/permissions, /api/v1/mail) | ✅ Als obsolet/bereits behoben dokumentiert | — | | D3-f | BUG-027029/031035/071 (falsche Test-Pfade/Payloads): Recherche zeigte — falsche Pfade existieren NICHT mehr in tests/, reale API hat korrekte Prefixe (/api/v1/user/preferences, /api/v1/permissions, /api/v1/mail) | ✅ Als obsolet/bereits behoben dokumentiert | — |
| D3-g | 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); dabei DSAR-Export F821-Bug behoben (datetime/timezone undefined → NameError zur Laufzeit beim GDPR-Export) und Zeitstempel auf datetime.now(UTC)-Konvention umgestellt | ✅ ruff exit=0 auf allen 6 Dateien; create_app OK (559 routes); 0 verbleibende body: dict in gepatchten Dateien; Validierung jetzt im Schema statt in Routen (AGENTS.md-Konvention) | — |
**Block A ABGESCHLOSSEN** — Gate A bestanden (32f63ad). **Block A ABGESCHLOSSEN** — Gate A bestanden (32f63ad).
**Block B ABGESCHLOSSEN** — Gate B bestanden (alle 5 Checks bewiesen). **Block B ABGESCHLOSSEN** — Gate B bestanden (alle 5 Checks bewiesen).
@@ -85,7 +86,7 @@
### Bekannte Vorbestände (Block D Triage) ### Bekannte Vorbestände (Block D Triage)
- ~~9 Contact/Company-Test-Failures~~ ✅ GELÖST in D1-b (ContactCreate-Typ-Inferenz, 9d8da99) — Root-Cause war BUG-008-Validator-Default type='company'. - ~~9 Contact/Company-Test-Failures~~ ✅ GELÖST in D1-b (ContactCreate-Typ-Inferenz, 9d8da99) — Root-Cause war BUG-008-Validator-Default type='company'.
- test_mail: 28 Failures 'ValueError: Unknown entity type: mail_account' aus permission_resolver._get_entity_model — Stash-verifiziert als Vorbestand (2026-08-24); Permission-Resolver-Mapping kennt mail_account nicht → D3/D4-Thema. - ~~test_mail: 28 Failures 'Unknown entity type: mail_account'~~ ✅ Root-Cause in D3-d behoben (ef90d57): Mail/DMS get_entity_models-Overrides fehlten; Rest-Failures im vollen Mail-Lauf = IMAP-Netzwerk-Calls ohne Mocking (Test-Infrastruktur-Follow-up, kein Produktionsbug).
- 5 PluginLoader-Test-Failures sind Vorbestand (Stash-Verifikation): Tests erwarten UI-Text 'Failed to load plugin', aktueller Loader zeigt deutsche Texte. - 5 PluginLoader-Test-Failures sind Vorbestand (Stash-Verifikation): Tests erwarten UI-Text 'Failed to load plugin', aktueller Loader zeigt deutsche Texte.
- Geister-Komponenten: Backend-Manifeste referenzieren @/pages/AIAssistant + 5 Contact-Detail-Tabs, die im Frontend nicht existieren (siehe unten). - Geister-Komponenten: Backend-Manifeste referenzieren @/pages/AIAssistant + 5 Contact-Detail-Tabs, die im Frontend nicht existieren (siehe unten).
+41 -24
View File
@@ -1,51 +1,70 @@
"""Knowledge plugin routes — extraction, ask, review queue.""" """Knowledge plugin routes — extraction, ask, review queue."""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import require_permission from app.deps import require_permission
from app.plugins.builtins.knowledge.services import extract_knowledge, ask_knowledge, get_review_queue, review_extraction from app.plugins.builtins.knowledge.services import (
ask_knowledge,
extract_knowledge,
get_review_queue,
review_extraction,
)
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"]) router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
class KnowledgeExtractRequest(BaseModel):
"""Extract knowledge from a source."""
source_type: str = Field(..., min_length=1)
source_id: uuid.UUID
source_title: str | None = None
source_text: str = Field(..., min_length=1)
class KnowledgeAskRequest(BaseModel):
"""Ask a knowledge question."""
question: str = Field(..., min_length=1)
class KnowledgeReviewRequest(BaseModel):
"""Approve or reject a knowledge extraction."""
approved: bool = False
notes: str | None = None
@router.post("/extract") @router.post("/extract")
async def extract( async def extract(
body: dict, body: KnowledgeExtractRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("wiki:read")), current_user: dict = Depends(require_permission("wiki:read")),
): ):
"""Extract knowledge from a source (wiki article, dms file, mail, communication).""" """Extract knowledge from a source (wiki article, dms file, mail, communication)."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
source_type = body.get("source_type", "")
source_id = body.get("source_id", "")
source_title = body.get("source_title")
source_text = body.get("source_text", "")
if not source_type or not source_id or not source_text:
raise HTTPException(400, detail={"detail": "source_type, source_id, source_text required", "code": "missing_fields"})
try:
sid = uuid.UUID(source_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid source_id", "code": "invalid_id"}) from None
result = await extract_knowledge( result = await extract_knowledge(
db=db, tenant_id=tenant_id, source_type=source_type, source_id=sid, db=db, tenant_id=tenant_id, source_type=body.source_type, source_id=body.source_id,
source_title=source_title, source_text=source_text, source_title=body.source_title, source_text=body.source_text,
user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None, user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None,
) )
return result return result
@router.post("/ask") @router.post("/ask")
async def ask( async def ask(
body: dict, body: KnowledgeAskRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("wiki:read")), current_user: dict = Depends(require_permission("wiki:read")),
): ):
"""Ask a knowledge question — uses wiki + graph_rag as context.""" """Ask a knowledge question — uses wiki + graph_rag as context."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
question = body.get("question") or body.get("query", "") result = await ask_knowledge(db=db, tenant_id=tenant_id, question=body.question)
if not question:
raise HTTPException(400, detail={"detail": "question required", "code": "missing_question"})
result = await ask_knowledge(db=db, tenant_id=tenant_id, question=question)
return result return result
@router.get("/review") @router.get("/review")
@@ -62,22 +81,20 @@ async def review_queue(
@router.post("/review/{extraction_id}") @router.post("/review/{extraction_id}")
async def review( async def review(
extraction_id: str, extraction_id: str,
body: dict, body: KnowledgeReviewRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("wiki:write")), current_user: dict = Depends(require_permission("wiki:write")),
): ):
"""Approve or reject a knowledge extraction.""" """Approve or reject a knowledge extraction."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
approved = body.get("approved", False)
notes = body.get("notes")
try: try:
eid = uuid.UUID(extraction_id) eid = uuid.UUID(extraction_id)
except ValueError: except ValueError:
raise HTTPException(400, detail={"detail": "Invalid extraction_id", "code": "invalid_id"}) from None raise HTTPException(400, detail={"detail": "Invalid extraction_id", "code": "invalid_id"}) from None
result = await review_extraction( result = await review_extraction(
db=db, tenant_id=tenant_id, extraction_id=eid, approved=approved, db=db, tenant_id=tenant_id, extraction_id=eid, approved=body.approved,
user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None, user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None,
notes=notes, notes=body.notes,
) )
if "error" in result: if "error" in result:
raise HTTPException(404, detail={"detail": result["error"], "code": "not_found"}) raise HTTPException(404, detail={"detail": result["error"], "code": "not_found"})
+60 -52
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
@@ -28,6 +29,46 @@ from app.plugins.builtins.self_improvement.services import (
router = APIRouter(prefix="/api/v1/improvement", tags=["improvement"]) router = APIRouter(prefix="/api/v1/improvement", tags=["improvement"])
class CollectSignalsRequest(BaseModel):
"""Collect improvement signals from existing system data."""
since: datetime | None = None
limit: int = Field(100, ge=1, le=500)
class DetectPatternsRequest(BaseModel):
"""Detect recurring patterns from collected signals."""
min_occurrences: int = Field(2, ge=2)
class CreateProposalRequest(BaseModel):
"""Create a new improvement proposal."""
pattern_id: uuid.UUID | None = None
title: str = Field(..., min_length=1)
description: str = ""
target_type: str = Field(..., min_length=1)
target_ref_id: uuid.UUID | None = None
target_name: str | None = None
proposed_config: dict[str, Any] = Field(default_factory=dict)
rationale: str = ""
expected_benefit: str = ""
risk_assessment: str = ""
class RequestApprovalRequest(BaseModel):
"""Request human approval for a proposal."""
approver_id: uuid.UUID | None = None
class RollbackProposalRequest(BaseModel):
"""Rollback an active proposal."""
reason: str = ""
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
# J-SIGNAL: Signal Collection # J-SIGNAL: Signal Collection
# NOTE: /signals/collect must be defined before /signals to avoid route conflicts # NOTE: /signals/collect must be defined before /signals to avoid route conflicts
@@ -35,21 +76,13 @@ router = APIRouter(prefix="/api/v1/improvement", tags=["improvement"])
@router.post("/signals/collect") @router.post("/signals/collect")
async def collect( async def collect(
body: dict, body: CollectSignalsRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")), current_user: dict = Depends(require_permission("automation:read")),
): ):
"""Collect improvement signals from existing system data.""" """Collect improvement signals from existing system data."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
since_str = body.get("since") result = await collect_signals(db=db, tenant_id=tenant_id, since=body.since, limit=body.limit)
since = None
if since_str:
try:
since = datetime.fromisoformat(since_str)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid since format", "code": "invalid_date"}) from None
limit = min(body.get("limit", 100), 500)
result = await collect_signals(db=db, tenant_id=tenant_id, since=since, limit=limit)
await db.commit() await db.commit()
return result return result
@@ -73,14 +106,13 @@ async def signals(
@router.post("/patterns/detect") @router.post("/patterns/detect")
async def detect( async def detect(
body: dict, body: DetectPatternsRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")), current_user: dict = Depends(require_permission("automation:read")),
): ):
"""Detect recurring patterns from collected signals.""" """Detect recurring patterns from collected signals."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
min_occurrences = body.get("min_occurrences", 2) result = await detect_patterns(db=db, tenant_id=tenant_id, min_occurrences=body.min_occurrences)
result = await detect_patterns(db=db, tenant_id=tenant_id, min_occurrences=min_occurrences)
await db.commit() await db.commit()
return result return result
@@ -103,7 +135,7 @@ async def patterns(
@router.post("/proposals") @router.post("/proposals")
async def create( async def create(
body: dict, body: CreateProposalRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:write")), current_user: dict = Depends(require_permission("automation:write")),
): ):
@@ -111,35 +143,18 @@ async def create(
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None user_id = uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None
pattern_id = None
if body.get("pattern_id"):
try:
pattern_id = uuid.UUID(body["pattern_id"])
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid pattern_id", "code": "invalid_id"}) from None
target_ref_id = None
if body.get("target_ref_id"):
try:
target_ref_id = uuid.UUID(body["target_ref_id"])
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid target_ref_id", "code": "invalid_id"}) from None
if not body.get("title") or not body.get("target_type"):
raise HTTPException(400, detail={"detail": "title and target_type required", "code": "missing_fields"})
proposal = await create_proposal( proposal = await create_proposal(
db=db, tenant_id=tenant_id, db=db, tenant_id=tenant_id,
pattern_id=pattern_id, pattern_id=body.pattern_id,
title=body["title"], title=body.title,
description=body.get("description", ""), description=body.description,
target_type=body["target_type"], target_type=body.target_type,
target_ref_id=target_ref_id, target_ref_id=body.target_ref_id,
target_name=body.get("target_name"), target_name=body.target_name,
proposed_config=body.get("proposed_config", {}), proposed_config=body.proposed_config,
rationale=body.get("rationale", ""), rationale=body.rationale,
expected_benefit=body.get("expected_benefit", ""), expected_benefit=body.expected_benefit,
risk_assessment=body.get("risk_assessment", ""), risk_assessment=body.risk_assessment,
user_id=user_id, user_id=user_id,
) )
await db.commit() await db.commit()
@@ -207,7 +222,7 @@ async def evaluate(
@router.post("/proposals/{proposal_id}/request-approval") @router.post("/proposals/{proposal_id}/request-approval")
async def req_approval( async def req_approval(
proposal_id: str, proposal_id: str,
body: dict, body: RequestApprovalRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:write")), current_user: dict = Depends(require_permission("automation:write")),
): ):
@@ -218,13 +233,7 @@ async def req_approval(
pid = uuid.UUID(proposal_id) pid = uuid.UUID(proposal_id)
except ValueError: except ValueError:
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
approver_id = None result = await request_approval(db=db, tenant_id=tenant_id, proposal_id=pid, requested_by=user_id, approver_id=body.approver_id)
if body.get("approver_id"):
try:
approver_id = uuid.UUID(body["approver_id"])
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid approver_id", "code": "invalid_id"}) from None
result = await request_approval(db=db, tenant_id=tenant_id, proposal_id=pid, requested_by=user_id, approver_id=approver_id)
if "error" in result: if "error" in result:
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"}) raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
await db.commit() await db.commit()
@@ -258,7 +267,7 @@ async def activate(
@router.post("/proposals/{proposal_id}/rollback") @router.post("/proposals/{proposal_id}/rollback")
async def rollback( async def rollback(
proposal_id: str, proposal_id: str,
body: dict, body: RollbackProposalRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:admin")), current_user: dict = Depends(require_permission("automation:admin")),
): ):
@@ -268,8 +277,7 @@ async def rollback(
pid = uuid.UUID(proposal_id) pid = uuid.UUID(proposal_id)
except ValueError: except ValueError:
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
reason = body.get("reason", "") result = await rollback_proposal(db=db, tenant_id=tenant_id, proposal_id=pid, reason=body.reason)
result = await rollback_proposal(db=db, tenant_id=tenant_id, proposal_id=pid, reason=reason)
if "error" in result: if "error" in result:
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"}) raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
await db.commit() await db.commit()
+31 -11
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db 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"]) 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) # Rate limits for permission changes (prevent abuse/DoS)
_PERM_RATE_LIMIT_MAX = 50 # max changes per minute _PERM_RATE_LIMIT_MAX = 50 # max changes per minute
_PERM_RATE_LIMIT_WINDOW = 60 # 60 seconds _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) @router.post("/bulk", status_code=status.HTTP_201_CREATED)
async def bulk_share_permissions( async def bulk_share_permissions(
body: dict, body: BulkShareRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:write")), current_user: dict = Depends(require_permission("settings:write")),
): ):
@@ -290,11 +310,11 @@ async def bulk_share_permissions(
result = await bulk_permission_service.bulk_share( result = await bulk_permission_service.bulk_share(
db, db,
tenant_id, tenant_id,
body["entity_type"], body.entity_type,
body["entity_ids"], body.entity_ids,
body["principal_type"], body.principal_type,
body["principal_id"], body.principal_id,
body["level"], body.level,
created_by=user_id, created_by=user_id,
) )
return result return result
@@ -304,7 +324,7 @@ async def bulk_share_permissions(
@router.post("/bulk/unshare", status_code=status.HTTP_200_OK) @router.post("/bulk/unshare", status_code=status.HTTP_200_OK)
async def bulk_unshare_permissions( async def bulk_unshare_permissions(
body: dict, body: BulkUnshareRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:write")), current_user: dict = Depends(require_permission("settings:write")),
): ):
@@ -314,10 +334,10 @@ async def bulk_unshare_permissions(
result = await bulk_permission_service.bulk_unshare( result = await bulk_permission_service.bulk_unshare(
db, db,
tenant_id, tenant_id,
body["entity_type"], body.entity_type,
body["entity_ids"], body.entity_ids,
body["principal_type"], body.principal_type,
body["principal_id"], body.principal_id,
) )
return result return result
except (ValueError, KeyError) as e: except (ValueError, KeyError) as e:
+11 -8
View File
@@ -14,6 +14,7 @@ import secrets
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,6 +28,13 @@ router = APIRouter(prefix="/api/v1/guests", tags=["guests"])
settings = get_settings() 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: def _hash_token(token: str) -> str:
"""Hash a token using SHA-256.""" """Hash a token using SHA-256."""
return hashlib.sha256(token.encode()).hexdigest() return hashlib.sha256(token.encode()).hexdigest()
@@ -35,7 +43,7 @@ def _hash_token(token: str) -> str:
@router.post("/invite") @router.post("/invite")
async def invite_guest( async def invite_guest(
request: Request, request: Request,
body: dict, body: GuestInviteRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin), 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 ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
""" """
email = body.get("email", "") email = body.email
name = body.get("name", "") name = body.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"},
)
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
+35 -15
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select as sa_select from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import AsyncSession 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"]) 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) @router.get("", response_model=SystemSettingsResponse)
async def get_system_settings( async def get_system_settings(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -86,7 +102,7 @@ async def get_backup_config(
@router.put("/backup-config") @router.put("/backup-config")
async def update_backup_config( async def update_backup_config(
body: dict, body: BackupConfigRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:write")), current_user: dict = Depends(require_permission("settings:write")),
): ):
@@ -105,14 +121,15 @@ async def update_backup_config(
# Merge backup fields into existing data # Merge backup fields into existing data
data = dict(existing) data = dict(existing)
if "backup_enabled" in body: updates = body.model_dump(exclude_unset=True)
data["backup_enabled"] = bool(body["backup_enabled"]) for key in (
if "backup_interval" in body: "backup_enabled",
data["backup_interval"] = str(body["backup_interval"]) "backup_interval",
if "backup_retention_days" in body: "backup_retention_days",
data["backup_retention_days"] = int(body["backup_retention_days"]) "backup_destination",
if "backup_destination" in body: ):
data["backup_destination"] = str(body["backup_destination"]) if key in updates:
data[key] = updates[key]
return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data) return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data)
@@ -184,13 +201,16 @@ async def dsgvo_export(
- Calendar events - Calendar events
- Communication messages - Communication messages
""" """
import json
import io import io
import json
from datetime import UTC, datetime
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import select as sa_select 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.audit import AuditLog
from app.models.contact import Contact
from app.models.user import User
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
try: try:
@@ -198,7 +218,7 @@ async def dsgvo_export(
except ValueError: except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"}) from None 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 profile
user_result = await db.execute(sa_select(User).where(User.id == uid)) 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}") @router.post("/dsar/{user_id}")
async def dsar_request( async def dsar_request(
user_id: str, user_id: str,
body: dict, body: DsarRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("system:admin")), current_user: dict = Depends(require_permission("system:admin")),
): ):
@@ -254,7 +274,7 @@ async def dsar_request(
""" """
from app.core.jobs import enqueue_job from app.core.jobs import enqueue_job
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
request_type = body.get("type", "access") request_type = body.type
try: try:
uid = uuid.UUID(user_id) uid = uuid.UUID(user_id)
except ValueError: except ValueError:
+9 -7
View File
@@ -6,6 +6,7 @@ import uuid
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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"]) 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: def _parse_role_id(raw: str | None) -> uuid.UUID | None:
"""Convert a string body value into a UUID or 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") @router.put("/me/menu-order")
async def update_menu_order( async def update_menu_order(
body: dict, body: MenuOrderRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
"""Update the current user's menu order preference.""" """Update the current user's menu order preference."""
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
menu_order = body.get("menu_order") menu_order = body.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"},
)
result = await db.execute( result = await db.execute(
select(User).where(User.id == user_id) select(User).where(User.id == user_id)