Files
headquarter/apps/api/src/api/users.py
T
Fusion 40a940304b docs: comprehensive API documentation
- Create enhanced health endpoints with /health and /health/db
- Add comprehensive docstrings to all API endpoints
- Add Pydantic response models with Field descriptions
- Create apps/api/README.md with setup guide
- Create ADR-001 for session auth decision
- Create ADR-002 for async SQLAlchemy decision
- Quality gates: Python syntax OK, TypeScript OK
2026-05-19 21:31:20 +02:00

157 lines
4.5 KiB
Python

import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.user import User
router = APIRouter(prefix="/users", tags=["users"])
UPLOAD_DIR = Path("uploads/avatars")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
avatar_url: str | None
class UserProfileUpdate(BaseModel):
name: str | None = None
email: str | None = None
@router.get(
"/me",
response_model=UserProfileResponse,
summary="Get current user profile",
description="Retrieve the profile of the currently authenticated user.",
)
async def get_profile(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Get the current user's profile.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
The user's profile information.
"""
return await _get_user(session, user_id)
@router.put(
"/me",
response_model=UserProfileResponse,
summary="Update user profile",
description="Update the current user's profile information.",
)
async def update_profile(
data: UserProfileUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Update the current user's profile.
Args:
data: Profile update data with optional name and email.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated user profile.
"""
user = await _get_user(session, user_id)
if data.name is not None:
if len(data.name.strip()) == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
user.name = data.name.strip()
if data.email is not None:
if "@" not in data.email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
user.email = data.email.strip()
await session.commit()
await session.refresh(user)
return user
@router.post(
"/me/avatar",
response_model=UserProfileResponse,
summary="Upload avatar",
description="Upload a profile avatar image (PNG or JPG, max 2MB).",
)
async def upload_avatar(
file: UploadFile,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Upload a profile avatar image.
Args:
file: The image file to upload (PNG or JPG, max 2MB).
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated user profile with new avatar URL.
"""
user = await _get_user(session, user_id)
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"invalid file type: {file.content_type}. only png and jpg allowed",
)
content = await file.read()
if len(content) > MAX_AVATAR_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="file too large. max size is 2mb",
)
# Delete old avatar if exists
if user.avatar_url:
old_path = UPLOAD_DIR / Path(user.avatar_url).name
if old_path.exists():
old_path.unlink()
# Save new avatar with UUID filename
filename_part = file.filename or "avatar.png"
ext = filename_part.split(".")[-1].lower() if "." in filename_part else "png"
if ext not in {"png", "jpg", "jpeg"}:
ext = "png"
filename = f"{uuid.uuid4()}.{ext}"
file_path = UPLOAD_DIR / filename
file_path.write_bytes(content)
user.avatar_url = f"/uploads/avatars/{filename}"
await session.commit()
await session.refresh(user)
return user