From c32e4bb34edde13ae9fdeb0a17fe2fa53103c452 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Mon, 24 Aug 2026 10:55:22 +0200 Subject: [PATCH] =?UTF-8?q?refactor(d3):=20ARCH-051=20=E2=80=94=2014=20dic?= =?UTF-8?q?t-body-Routes=20auf=20Pydantic-Schemas=20umgestellt=20(entity?= =?UTF-8?q?=5Fpermissions=20bulk=20=C3=972,=20guests=20invite,=20users=20m?= =?UTF-8?q?enu-order,=20system=5Fsettings=20backup-config+dsar,=20knowledg?= =?UTF-8?q?e=20=C3=973,=20self=5Fimprovement=20=C3=975);=20DSAR-Export=20F?= =?UTF-8?q?821-Bug=20behoben=20(datetime/timezone=20undefined=20=E2=86=92?= =?UTF-8?q?=20NameError=20beim=20GDPR-Export),=20Zeitstempel=20auf=20datet?= =?UTF-8?q?ime.now(UTC);=20Validierung=20jetzt=20im=20Schema=20statt=20in?= =?UTF-8?q?=20Routen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 3 +- app/plugins/builtins/knowledge/routes.py | 65 ++++++---- .../builtins/self_improvement/routes.py | 112 ++++++++++-------- app/routes/entity_permissions.py | 42 +++++-- app/routes/guests.py | 19 +-- app/routes/system_settings.py | 50 +++++--- app/routes/users.py | 16 +-- 7 files changed, 189 insertions(+), 118 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 48f3251..e9889ec 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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-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-027–029/031–035/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 B ABGESCHLOSSEN** — Gate B bestanden (alle 5 Checks bewiesen). @@ -85,7 +86,7 @@ ### 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'. -- 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. - Geister-Komponenten: Backend-Manifeste referenzieren @/pages/AIAssistant + 5 Contact-Detail-Tabs, die im Frontend nicht existieren (siehe unten). diff --git a/app/plugins/builtins/knowledge/routes.py b/app/plugins/builtins/knowledge/routes.py index 8f20753..63348df 100644 --- a/app/plugins/builtins/knowledge/routes.py +++ b/app/plugins/builtins/knowledge/routes.py @@ -1,51 +1,70 @@ """Knowledge plugin routes — extraction, ask, review queue.""" from __future__ import annotations + import uuid + from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession + from app.core.db import get_db 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"]) + +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") async def extract( - body: dict, + body: KnowledgeExtractRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:read")), ): """Extract knowledge from a source (wiki article, dms file, mail, communication).""" 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( - db=db, tenant_id=tenant_id, source_type=source_type, source_id=sid, - source_title=source_title, source_text=source_text, + db=db, tenant_id=tenant_id, source_type=body.source_type, source_id=body.source_id, + 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, ) return result @router.post("/ask") async def ask( - body: dict, + body: KnowledgeAskRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:read")), ): """Ask a knowledge question — uses wiki + graph_rag as context.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - question = body.get("question") or body.get("query", "") - 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) + result = await ask_knowledge(db=db, tenant_id=tenant_id, question=body.question) return result @router.get("/review") @@ -62,22 +81,20 @@ async def review_queue( @router.post("/review/{extraction_id}") async def review( extraction_id: str, - body: dict, + body: KnowledgeReviewRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:write")), ): """Approve or reject a knowledge extraction.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - approved = body.get("approved", False) - notes = body.get("notes") try: eid = uuid.UUID(extraction_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid extraction_id", "code": "invalid_id"}) from None 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, - notes=notes, + notes=body.notes, ) if "error" in result: raise HTTPException(404, detail={"detail": result["error"], "code": "not_found"}) diff --git a/app/plugins/builtins/self_improvement/routes.py b/app/plugins/builtins/self_improvement/routes.py index db1b0c8..1b54529 100644 --- a/app/plugins/builtins/self_improvement/routes.py +++ b/app/plugins/builtins/self_improvement/routes.py @@ -6,6 +6,7 @@ from datetime import datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession 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"]) +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 # 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") async def collect( - body: dict, + body: CollectSignalsRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("automation:read")), ): """Collect improvement signals from existing system data.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - since_str = body.get("since") - 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) + result = await collect_signals(db=db, tenant_id=tenant_id, since=body.since, limit=body.limit) await db.commit() return result @@ -73,14 +106,13 @@ async def signals( @router.post("/patterns/detect") async def detect( - body: dict, + body: DetectPatternsRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("automation:read")), ): """Detect recurring patterns from collected signals.""" 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=min_occurrences) + result = await detect_patterns(db=db, tenant_id=tenant_id, min_occurrences=body.min_occurrences) await db.commit() return result @@ -103,7 +135,7 @@ async def patterns( @router.post("/proposals") async def create( - body: dict, + body: CreateProposalRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("automation:write")), ): @@ -111,35 +143,18 @@ async def create( tenant_id = uuid.UUID(current_user["tenant_id"]) 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( db=db, tenant_id=tenant_id, - pattern_id=pattern_id, - title=body["title"], - description=body.get("description", ""), - target_type=body["target_type"], - target_ref_id=target_ref_id, - target_name=body.get("target_name"), - proposed_config=body.get("proposed_config", {}), - rationale=body.get("rationale", ""), - expected_benefit=body.get("expected_benefit", ""), - risk_assessment=body.get("risk_assessment", ""), + pattern_id=body.pattern_id, + title=body.title, + description=body.description, + target_type=body.target_type, + target_ref_id=body.target_ref_id, + target_name=body.target_name, + proposed_config=body.proposed_config, + rationale=body.rationale, + expected_benefit=body.expected_benefit, + risk_assessment=body.risk_assessment, user_id=user_id, ) await db.commit() @@ -207,7 +222,7 @@ async def evaluate( @router.post("/proposals/{proposal_id}/request-approval") async def req_approval( proposal_id: str, - body: dict, + body: RequestApprovalRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("automation:write")), ): @@ -218,13 +233,7 @@ async def req_approval( pid = uuid.UUID(proposal_id) except ValueError: raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None - approver_id = None - 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) + result = await request_approval(db=db, tenant_id=tenant_id, proposal_id=pid, requested_by=user_id, approver_id=body.approver_id) if "error" in result: raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"}) await db.commit() @@ -258,7 +267,7 @@ async def activate( @router.post("/proposals/{proposal_id}/rollback") async def rollback( proposal_id: str, - body: dict, + body: RollbackProposalRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("automation:admin")), ): @@ -268,8 +277,7 @@ async def rollback( pid = uuid.UUID(proposal_id) except ValueError: 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=reason) + result = await rollback_proposal(db=db, tenant_id=tenant_id, proposal_id=pid, reason=body.reason) if "error" in result: raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"}) await db.commit() diff --git a/app/routes/entity_permissions.py b/app/routes/entity_permissions.py index b30ae5f..ff5f414 100644 --- a/app/routes/entity_permissions.py +++ b/app/routes/entity_permissions.py @@ -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: diff --git a/app/routes/guests.py b/app/routes/guests.py index d4c9bdf..cb13e2a 100644 --- a/app/routes/guests.py +++ b/app/routes/guests.py @@ -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"]) diff --git a/app/routes/system_settings.py b/app/routes/system_settings.py index 4a3d303..c10c93f 100644 --- a/app/routes/system_settings.py +++ b/app/routes/system_settings.py @@ -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: diff --git a/app/routes/users.py b/app/routes/users.py index 2343ff1..6bc99f0 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -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)