2025-12-17 00:04:14 +07:00
|
|
|
|
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
|
2025-12-16 22:12:12 +07:00
|
|
|
|
from sqlalchemy import select, func
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
from app.api.deps import DbSession, CurrentUser
|
|
|
|
|
|
from app.core.config import settings
|
2025-12-16 22:12:12 +07:00
|
|
|
|
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, UserUpdate, TelegramLink, MessageResponse,
|
|
|
|
|
|
PasswordChange, UserStats, UserProfilePublic,
|
|
|
|
|
|
)
|
2025-12-16 01:25:21 +07:00
|
|
|
|
from app.services.storage import storage_service
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/users", tags=["users"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{user_id}", response_model=UserPublic)
|
|
|
|
|
|
async def get_user(user_id: int, db: DbSession):
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-17 00:04:14 +07:00
|
|
|
|
@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",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
|
@router.patch("/me", response_model=UserPublic)
|
|
|
|
|
|
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
|
|
|
|
|
|
if data.nickname is not None:
|
|
|
|
|
|
current_user.nickname = data.nickname
|
|
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
await db.refresh(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
return UserPublic.model_validate(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/me/avatar", response_model=UserPublic)
|
|
|
|
|
|
async def upload_avatar(
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
file: UploadFile = File(...),
|
|
|
|
|
|
):
|
|
|
|
|
|
# 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}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-16 01:25:21 +07:00
|
|
|
|
# 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",
|
|
|
|
|
|
)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
# Update user
|
2025-12-16 01:25:21 +07:00
|
|
|
|
current_user.avatar_path = file_path
|
2025-12-14 02:38:35 +07:00
|
|
|
|
await db.commit()
|
|
|
|
|
|
await db.refresh(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
return UserPublic.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")
|
2025-12-16 20:06:16 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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")
|
2025-12-16 22:12:12 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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):
|
|
|
|
|
|
"""Получить статистику пользователя"""
|
|
|
|
|
|
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):
|
|
|
|
|
|
"""Получить публичный профиль пользователя со статистикой"""
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|