"""User Preferences routes — per-user UI settings via API, tenant-scoped with RBAC.""" from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import require_permission from app.models.user_preference import UserPreference router = APIRouter(prefix="/api/v1/user/preferences", tags=["user-preferences"]) # ─── Schemas ─── class PreferenceValue(BaseModel): """Arbitrary JSON value for a preference key.""" value: Any = Field(..., description="JSON value for the preference key") class PreferenceResponse(BaseModel): """Single preference entry response.""" key: str value: Any updated_at: str | None = None class PreferenceListResponse(BaseModel): """All preferences for the current user.""" preferences: list[PreferenceResponse] # ─── Helpers ─── def _pref_to_response(p: UserPreference) -> PreferenceResponse: """Convert UserPreference model to response schema.""" updated_at = None try: updated_at = p.updated_at.isoformat() if p.updated_at else None except Exception: pass return PreferenceResponse( key=p.key, value=p.value, updated_at=updated_at, ) # ─── Endpoints ─── @router.get("") async def list_user_preferences( db: AsyncSession = Depends(get_db), current_user: dict[str, Any] = Depends( require_permission("user_preferences:read") ), ) -> PreferenceListResponse: """Get all preferences for the current user.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) result = await db.execute( select(UserPreference) .where( UserPreference.tenant_id == tenant_id, UserPreference.user_id == user_id, UserPreference.deleted_at.is_(None), ) .order_by(UserPreference.key) ) prefs = result.scalars().all() return PreferenceListResponse( preferences=[_pref_to_response(p) for p in prefs] ) @router.get("/{key}") async def get_user_preference( key: str, db: AsyncSession = Depends(get_db), current_user: dict[str, Any] = Depends( require_permission("user_preferences:read") ), ) -> PreferenceResponse: """Get a single preference by key.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) result = await db.execute( select(UserPreference).where( UserPreference.tenant_id == tenant_id, UserPreference.user_id == user_id, UserPreference.key == key, UserPreference.deleted_at.is_(None), ) ) pref = result.scalar_one_or_none() if pref is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={ "detail": f"Preference '{key}' not found", "code": "not_found", }, ) return _pref_to_response(pref) @router.put("/{key}") async def upsert_user_preference( key: str, body: PreferenceValue, db: AsyncSession = Depends(get_db), current_user: dict[str, Any] = Depends( require_permission("user_preferences:write") ), ) -> PreferenceResponse: """Create or update a preference by key (upsert).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) result = await db.execute( select(UserPreference).where( UserPreference.tenant_id == tenant_id, UserPreference.user_id == user_id, UserPreference.key == key, UserPreference.deleted_at.is_(None), ) ) pref = result.scalar_one_or_none() if pref is None: pref = UserPreference( tenant_id=tenant_id, user_id=user_id, key=key, value=body.value, ) db.add(pref) else: pref.value = body.value await db.commit() await db.refresh(pref) return _pref_to_response(pref) @router.delete("/{key}", status_code=status.HTTP_204_NO_CONTENT) async def delete_user_preference( key: str, db: AsyncSession = Depends(get_db), current_user: dict[str, Any] = Depends( require_permission("user_preferences:write") ), ): """Delete a preference by key (soft-delete).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) result = await db.execute( select(UserPreference).where( UserPreference.tenant_id == tenant_id, UserPreference.user_id == user_id, UserPreference.key == key, UserPreference.deleted_at.is_(None), ) ) pref = result.scalar_one_or_none() if pref is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={ "detail": f"Preference '{key}' not found", "code": "not_found", }, ) # Soft-delete via TenantMixin's deleted_at from datetime import UTC, datetime pref.deleted_at = datetime.now(UTC) await db.commit() return None