from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response from sqlalchemy import select, func from app.api.deps import DbSession, CurrentUser from app.core.config import settings from app.core.security import verify_password, get_password_hash from app.models import User, Participant, Assignment, Marathon from app.models.assignment import AssignmentStatus from app.models.marathon import MarathonStatus from app.schemas import ( UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse, PasswordChange, UserStats, UserProfilePublic, ) from app.services.storage import storage_service router = APIRouter(prefix="/users", tags=["users"]) @router.get("/{user_id}", response_model=UserPublic) async def get_user(user_id: int, db: DbSession, current_user: CurrentUser): """Get user profile. Requires authentication.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", ) return UserPublic.model_validate(user) @router.get("/{user_id}/avatar") async def get_user_avatar(user_id: int, db: DbSession): """Stream user avatar from storage""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") if not user.avatar_path: raise HTTPException(status_code=404, detail="User has no avatar") # Get file from storage file_data = await storage_service.get_file(user.avatar_path, "avatars") if not file_data: raise HTTPException(status_code=404, detail="Avatar not found in storage") content, content_type = file_data return Response( content=content, media_type=content_type, headers={ "Cache-Control": "public, max-age=3600", } ) @router.patch("/me", response_model=UserPrivate) async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession): """Update current user's profile""" if data.nickname is not None: current_user.nickname = data.nickname await db.commit() await db.refresh(current_user) return UserPrivate.model_validate(current_user) @router.post("/me/avatar", response_model=UserPrivate) async def upload_avatar( current_user: CurrentUser, db: DbSession, file: UploadFile = File(...), ): """Upload current user's avatar""" # Validate file if not file.content_type.startswith("image/"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="File must be an image", ) contents = await file.read() if len(contents) > settings.MAX_UPLOAD_SIZE: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", ) # Get file extension ext = file.filename.split(".")[-1].lower() if file.filename else "jpg" if ext not in settings.ALLOWED_IMAGE_EXTENSIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", ) # Delete old avatar if exists if current_user.avatar_path: await storage_service.delete_file(current_user.avatar_path) # Upload file filename = storage_service.generate_filename(current_user.id, file.filename) file_path = await storage_service.upload_file( content=contents, folder="avatars", filename=filename, content_type=file.content_type or "image/jpeg", ) # Update user current_user.avatar_path = file_path await db.commit() await db.refresh(current_user) return UserPrivate.model_validate(current_user) @router.post("/me/telegram", response_model=MessageResponse) async def link_telegram( data: TelegramLink, current_user: CurrentUser, db: DbSession, ): # Check if telegram_id already linked to another user result = await db.execute( select(User).where(User.telegram_id == data.telegram_id, User.id != current_user.id) ) if result.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="This Telegram account is already linked to another user", ) current_user.telegram_id = data.telegram_id current_user.telegram_username = data.telegram_username await db.commit() return MessageResponse(message="Telegram account linked successfully") @router.post("/me/telegram/unlink", response_model=MessageResponse) async def unlink_telegram( current_user: CurrentUser, db: DbSession, ): if not current_user.telegram_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Telegram account is not linked", ) current_user.telegram_id = None current_user.telegram_username = None await db.commit() return MessageResponse(message="Telegram account unlinked successfully") @router.post("/me/password", response_model=MessageResponse) async def change_password( data: PasswordChange, current_user: CurrentUser, db: DbSession, ): """Смена пароля текущего пользователя""" if not verify_password(data.current_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный текущий пароль", ) if data.current_password == data.new_password: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Новый пароль должен отличаться от текущего", ) current_user.password_hash = get_password_hash(data.new_password) await db.commit() return MessageResponse(message="Пароль успешно изменен") @router.get("/me/stats", response_model=UserStats) async def get_my_stats(current_user: CurrentUser, db: DbSession): """Получить свою статистику""" return await _get_user_stats(current_user.id, db) @router.get("/{user_id}/stats", response_model=UserStats) async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser): """Получить статистику пользователя. Requires authentication.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", ) return await _get_user_stats(user_id, db) @router.get("/{user_id}/profile", response_model=UserProfilePublic) async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser): """Получить публичный профиль пользователя со статистикой. Requires authentication.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", ) stats = await _get_user_stats(user_id, db) return UserProfilePublic( id=user.id, nickname=user.nickname, avatar_url=user.avatar_url, created_at=user.created_at, stats=stats, ) async def _get_user_stats(user_id: int, db) -> UserStats: """Вспомогательная функция для подсчета статистики пользователя""" # 1. Количество марафонов (участий) marathons_result = await db.execute( select(func.count(Participant.id)) .where(Participant.user_id == user_id) ) marathons_count = marathons_result.scalar() or 0 # 2. Количество побед (1 место в завершенных марафонах) wins_count = 0 user_participations = await db.execute( select(Participant) .join(Marathon, Marathon.id == Participant.marathon_id) .where( Participant.user_id == user_id, Marathon.status == MarathonStatus.FINISHED.value ) ) for participation in user_participations.scalars(): # Для каждого марафона проверяем, был ли пользователь первым max_points_result = await db.execute( select(func.max(Participant.total_points)) .where(Participant.marathon_id == participation.marathon_id) ) max_points = max_points_result.scalar() or 0 if participation.total_points == max_points and max_points > 0: # Проверяем что он единственный с такими очками (не ничья) count_with_max = await db.execute( select(func.count(Participant.id)) .where( Participant.marathon_id == participation.marathon_id, Participant.total_points == max_points ) ) if count_with_max.scalar() == 1: wins_count += 1 # 3. Выполненных заданий completed_result = await db.execute( select(func.count(Assignment.id)) .join(Participant, Participant.id == Assignment.participant_id) .where( Participant.user_id == user_id, Assignment.status == AssignmentStatus.COMPLETED.value ) ) completed_assignments = completed_result.scalar() or 0 # 4. Всего очков заработано points_result = await db.execute( select(func.coalesce(func.sum(Assignment.points_earned), 0)) .join(Participant, Participant.id == Assignment.participant_id) .where( Participant.user_id == user_id, Assignment.status == AssignmentStatus.COMPLETED.value ) ) total_points_earned = points_result.scalar() or 0 return UserStats( marathons_count=marathons_count, wins_count=wins_count, completed_assignments=completed_assignments, total_points_earned=total_points_earned, )