2025-12-19 02:07:25 +07:00
|
|
|
|
from datetime import datetime
|
2026-01-04 04:58:41 +07:00
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Form
|
2025-12-14 20:21:56 +07:00
|
|
|
|
from sqlalchemy import select, func
|
|
|
|
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
|
|
from pydantic import BaseModel, Field
|
2026-01-04 04:58:41 +07:00
|
|
|
|
from typing import Optional
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
2025-12-29 22:23:34 +03:00
|
|
|
|
from app.models import (
|
2026-01-05 07:15:50 +07:00
|
|
|
|
User, UserRole, Marathon, MarathonStatus, CertificationStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
|
2025-12-29 22:23:34 +03:00
|
|
|
|
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
|
|
|
|
|
|
)
|
2025-12-19 02:07:25 +07:00
|
|
|
|
from app.schemas import (
|
|
|
|
|
|
UserPublic, MessageResponse,
|
2025-12-20 00:34:22 +07:00
|
|
|
|
AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
|
2025-12-19 02:07:25 +07:00
|
|
|
|
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
|
|
|
|
|
|
StaticContentCreate, DashboardStats
|
|
|
|
|
|
)
|
2025-12-20 00:34:22 +07:00
|
|
|
|
from app.core.security import get_password_hash
|
2025-12-19 02:07:25 +07:00
|
|
|
|
from app.services.telegram_notifier import telegram_notifier
|
|
|
|
|
|
from app.core.rate_limit import limiter
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SetUserRole(BaseModel):
|
|
|
|
|
|
role: str = Field(..., pattern="^(user|admin)$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminMarathonResponse(BaseModel):
|
|
|
|
|
|
id: int
|
|
|
|
|
|
title: str
|
|
|
|
|
|
status: str
|
|
|
|
|
|
creator: UserPublic
|
|
|
|
|
|
participants_count: int
|
|
|
|
|
|
games_count: int
|
|
|
|
|
|
start_date: str | None
|
|
|
|
|
|
end_date: str | None
|
|
|
|
|
|
created_at: str
|
2026-01-05 07:15:50 +07:00
|
|
|
|
certification_status: str = "none"
|
|
|
|
|
|
is_certified: bool = False
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
class Config:
|
|
|
|
|
|
from_attributes = True
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
# ============ Helper Functions ============
|
|
|
|
|
|
async def log_admin_action(
|
|
|
|
|
|
db,
|
|
|
|
|
|
admin_id: int,
|
|
|
|
|
|
action: str,
|
|
|
|
|
|
target_type: str,
|
|
|
|
|
|
target_id: int,
|
|
|
|
|
|
details: dict | None = None,
|
|
|
|
|
|
ip_address: str | None = None
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Log an admin action."""
|
|
|
|
|
|
log = AdminLog(
|
|
|
|
|
|
admin_id=admin_id,
|
|
|
|
|
|
action=action,
|
|
|
|
|
|
target_type=target_type,
|
|
|
|
|
|
target_id=target_id,
|
|
|
|
|
|
details=details,
|
|
|
|
|
|
ip_address=ip_address,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(log)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-04 03:42:11 +07:00
|
|
|
|
def build_admin_user_response(user: User, marathons_count: int) -> AdminUserResponse:
|
|
|
|
|
|
"""Build AdminUserResponse from User model."""
|
|
|
|
|
|
return AdminUserResponse(
|
|
|
|
|
|
id=user.id,
|
|
|
|
|
|
login=user.login,
|
|
|
|
|
|
nickname=user.nickname,
|
|
|
|
|
|
role=user.role,
|
|
|
|
|
|
avatar_url=user.avatar_url,
|
|
|
|
|
|
telegram_id=user.telegram_id,
|
|
|
|
|
|
telegram_username=user.telegram_username,
|
|
|
|
|
|
marathons_count=marathons_count,
|
|
|
|
|
|
created_at=user.created_at.isoformat(),
|
|
|
|
|
|
is_banned=user.is_banned,
|
|
|
|
|
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
|
|
|
|
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
|
|
|
|
|
ban_reason=user.ban_reason,
|
|
|
|
|
|
notify_events=user.notify_events,
|
|
|
|
|
|
notify_disputes=user.notify_disputes,
|
|
|
|
|
|
notify_moderation=user.notify_moderation,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
@router.get("/users", response_model=list[AdminUserResponse])
|
|
|
|
|
|
async def list_users(
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
skip: int = Query(0, ge=0),
|
|
|
|
|
|
limit: int = Query(50, ge=1, le=100),
|
|
|
|
|
|
search: str | None = None,
|
2025-12-19 02:07:25 +07:00
|
|
|
|
banned_only: bool = False,
|
2025-12-14 20:21:56 +07:00
|
|
|
|
):
|
|
|
|
|
|
"""List all users. Admin only."""
|
2025-12-19 02:07:25 +07:00
|
|
|
|
require_admin_with_2fa(current_user)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
query = select(User).order_by(User.created_at.desc())
|
|
|
|
|
|
|
|
|
|
|
|
if search:
|
|
|
|
|
|
query = query.where(
|
|
|
|
|
|
(User.login.ilike(f"%{search}%")) |
|
|
|
|
|
|
(User.nickname.ilike(f"%{search}%"))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
if banned_only:
|
|
|
|
|
|
query = query.where(User.is_banned == True)
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
query = query.offset(skip).limit(limit)
|
|
|
|
|
|
result = await db.execute(query)
|
|
|
|
|
|
users = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
response = []
|
|
|
|
|
|
for user in users:
|
|
|
|
|
|
# Count marathons user participates in
|
|
|
|
|
|
marathons_count = await db.scalar(
|
|
|
|
|
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
|
|
|
|
|
)
|
2026-01-04 03:42:11 +07:00
|
|
|
|
response.append(build_admin_user_response(user, marathons_count))
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/users/{user_id}", response_model=AdminUserResponse)
|
|
|
|
|
|
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
|
|
|
|
|
"""Get user details. Admin only."""
|
2025-12-19 02:07:25 +07:00
|
|
|
|
require_admin_with_2fa(current_user)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
marathons_count = await db.scalar(
|
|
|
|
|
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-04 03:42:11 +07:00
|
|
|
|
return build_admin_user_response(user, marathons_count)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/users/{user_id}/role", response_model=AdminUserResponse)
|
|
|
|
|
|
async def set_user_role(
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
data: SetUserRole,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
2025-12-19 02:07:25 +07:00
|
|
|
|
request: Request,
|
2025-12-14 20:21:56 +07:00
|
|
|
|
):
|
|
|
|
|
|
"""Set user's global role. Admin only."""
|
2025-12-19 02:07:25 +07:00
|
|
|
|
require_admin_with_2fa(current_user)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
# Cannot change own role
|
|
|
|
|
|
if user_id == current_user.id:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Cannot change your own role")
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
old_role = user.role
|
2025-12-14 20:21:56 +07:00
|
|
|
|
user.role = data.role
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
await db.refresh(user)
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.USER_ROLE_CHANGE.value,
|
|
|
|
|
|
"user", user_id,
|
|
|
|
|
|
{"old_role": old_role, "new_role": data.role, "nickname": user.nickname},
|
|
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
marathons_count = await db.scalar(
|
|
|
|
|
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-04 03:42:11 +07:00
|
|
|
|
return build_admin_user_response(user, marathons_count)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/users/{user_id}", response_model=MessageResponse)
|
|
|
|
|
|
async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
|
|
|
|
|
"""Delete a user. Admin only."""
|
2025-12-19 02:07:25 +07:00
|
|
|
|
require_admin_with_2fa(current_user)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
# Cannot delete yourself
|
|
|
|
|
|
if user_id == current_user.id:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
# Cannot delete another admin
|
|
|
|
|
|
if user.role == UserRole.ADMIN.value:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Cannot delete another admin")
|
|
|
|
|
|
|
|
|
|
|
|
await db.delete(user)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
return MessageResponse(message="User deleted")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/marathons", response_model=list[AdminMarathonResponse])
|
|
|
|
|
|
async def list_marathons(
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
skip: int = Query(0, ge=0),
|
2026-01-04 04:16:54 +07:00
|
|
|
|
limit: int = Query(50, ge=1, le=200),
|
2025-12-14 20:21:56 +07:00
|
|
|
|
search: str | None = None,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""List all marathons. Admin only."""
|
2025-12-19 02:07:25 +07:00
|
|
|
|
require_admin_with_2fa(current_user)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
query = (
|
|
|
|
|
|
select(Marathon)
|
2026-01-05 07:15:50 +07:00
|
|
|
|
.options(
|
|
|
|
|
|
selectinload(Marathon.creator).selectinload(User.equipped_frame),
|
|
|
|
|
|
selectinload(Marathon.creator).selectinload(User.equipped_title),
|
|
|
|
|
|
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
|
|
|
|
|
|
selectinload(Marathon.creator).selectinload(User.equipped_background),
|
|
|
|
|
|
)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
.order_by(Marathon.created_at.desc())
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if search:
|
|
|
|
|
|
query = query.where(Marathon.title.ilike(f"%{search}%"))
|
|
|
|
|
|
|
|
|
|
|
|
query = query.offset(skip).limit(limit)
|
|
|
|
|
|
result = await db.execute(query)
|
|
|
|
|
|
marathons = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
response = []
|
|
|
|
|
|
for marathon in marathons:
|
|
|
|
|
|
participants_count = await db.scalar(
|
|
|
|
|
|
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon.id)
|
|
|
|
|
|
)
|
|
|
|
|
|
games_count = await db.scalar(
|
|
|
|
|
|
select(func.count()).select_from(Game).where(Game.marathon_id == marathon.id)
|
|
|
|
|
|
)
|
|
|
|
|
|
response.append(AdminMarathonResponse(
|
|
|
|
|
|
id=marathon.id,
|
|
|
|
|
|
title=marathon.title,
|
|
|
|
|
|
status=marathon.status,
|
|
|
|
|
|
creator=UserPublic.model_validate(marathon.creator),
|
|
|
|
|
|
participants_count=participants_count,
|
|
|
|
|
|
games_count=games_count,
|
|
|
|
|
|
start_date=marathon.start_date.isoformat() if marathon.start_date else None,
|
|
|
|
|
|
end_date=marathon.end_date.isoformat() if marathon.end_date else None,
|
|
|
|
|
|
created_at=marathon.created_at.isoformat(),
|
2026-01-05 07:15:50 +07:00
|
|
|
|
certification_status=marathon.certification_status,
|
|
|
|
|
|
is_certified=marathon.is_certified,
|
2025-12-14 20:21:56 +07:00
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
|
2025-12-19 02:07:25 +07:00
|
|
|
|
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession, request: Request):
|
2025-12-14 20:21:56 +07:00
|
|
|
|
"""Delete a marathon. Admin only."""
|
2025-12-19 02:07:25 +07:00
|
|
|
|
require_admin_with_2fa(current_user)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
|
|
|
|
|
marathon = result.scalar_one_or_none()
|
|
|
|
|
|
if not marathon:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
marathon_title = marathon.title
|
2025-12-14 20:21:56 +07:00
|
|
|
|
await db.delete(marathon)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.MARATHON_DELETE.value,
|
|
|
|
|
|
"marathon", marathon_id,
|
|
|
|
|
|
{"title": marathon_title},
|
|
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
return MessageResponse(message="Marathon deleted")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/stats")
|
|
|
|
|
|
async def get_stats(current_user: CurrentUser, db: DbSession):
|
|
|
|
|
|
"""Get platform statistics. Admin only."""
|
2025-12-19 02:07:25 +07:00
|
|
|
|
require_admin_with_2fa(current_user)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
users_count = await db.scalar(select(func.count()).select_from(User))
|
|
|
|
|
|
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
|
|
|
|
|
games_count = await db.scalar(select(func.count()).select_from(Game))
|
|
|
|
|
|
participants_count = await db.scalar(select(func.count()).select_from(Participant))
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"users_count": users_count,
|
|
|
|
|
|
"marathons_count": marathons_count,
|
|
|
|
|
|
"games_count": games_count,
|
|
|
|
|
|
"total_participations": participants_count,
|
|
|
|
|
|
}
|
2025-12-19 02:07:25 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============ Ban/Unban Users ============
|
|
|
|
|
|
@router.post("/users/{user_id}/ban", response_model=AdminUserResponse)
|
|
|
|
|
|
@limiter.limit("10/minute")
|
|
|
|
|
|
async def ban_user(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
data: BanUserRequest,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Ban a user. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
if user_id == current_user.id:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Cannot ban yourself")
|
|
|
|
|
|
|
|
|
|
|
|
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 user.role == UserRole.ADMIN.value:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Cannot ban another admin")
|
|
|
|
|
|
|
|
|
|
|
|
if user.is_banned:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="User is already banned")
|
|
|
|
|
|
|
|
|
|
|
|
user.is_banned = True
|
|
|
|
|
|
user.banned_at = datetime.utcnow()
|
|
|
|
|
|
# Normalize to naive datetime (remove tzinfo) to match banned_at
|
|
|
|
|
|
user.banned_until = data.banned_until.replace(tzinfo=None) if data.banned_until else None
|
|
|
|
|
|
user.banned_by_id = current_user.id
|
|
|
|
|
|
user.ban_reason = data.reason
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
await db.refresh(user)
|
|
|
|
|
|
|
|
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.USER_BAN.value,
|
|
|
|
|
|
"user", user_id,
|
|
|
|
|
|
{"nickname": user.nickname, "reason": data.reason},
|
|
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
marathons_count = await db.scalar(
|
|
|
|
|
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-04 03:42:11 +07:00
|
|
|
|
return build_admin_user_response(user, marathons_count)
|
2025-12-19 02:07:25 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
|
|
|
|
|
|
async def unban_user(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Unban a user. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
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.is_banned:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="User is not banned")
|
|
|
|
|
|
|
|
|
|
|
|
user.is_banned = False
|
|
|
|
|
|
user.banned_at = None
|
|
|
|
|
|
user.banned_until = None
|
|
|
|
|
|
user.banned_by_id = None
|
|
|
|
|
|
user.ban_reason = None
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
await db.refresh(user)
|
|
|
|
|
|
|
|
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.USER_UNBAN.value,
|
|
|
|
|
|
"user", user_id,
|
|
|
|
|
|
{"nickname": user.nickname},
|
|
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
marathons_count = await db.scalar(
|
|
|
|
|
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-04 03:42:11 +07:00
|
|
|
|
return build_admin_user_response(user, marathons_count)
|
2025-12-19 02:07:25 +07:00
|
|
|
|
|
|
|
|
|
|
|
2025-12-20 00:34:22 +07:00
|
|
|
|
# ============ Reset Password ============
|
|
|
|
|
|
@router.post("/users/{user_id}/reset-password", response_model=AdminUserResponse)
|
|
|
|
|
|
async def reset_user_password(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
data: AdminResetPasswordRequest,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Reset user password. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
# Hash and save new password
|
|
|
|
|
|
user.password_hash = get_password_hash(data.new_password)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
await db.refresh(user)
|
|
|
|
|
|
|
|
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.USER_PASSWORD_RESET.value,
|
|
|
|
|
|
"user", user_id,
|
|
|
|
|
|
{"nickname": user.nickname},
|
|
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Notify user via Telegram if linked
|
|
|
|
|
|
if user.telegram_id:
|
|
|
|
|
|
await telegram_notifier.send_message(
|
|
|
|
|
|
user.telegram_id,
|
|
|
|
|
|
"🔐 <b>Ваш пароль был сброшен</b>\n\n"
|
|
|
|
|
|
"Администратор установил вам новый пароль. "
|
|
|
|
|
|
"Если это были не вы, свяжитесь с поддержкой."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
marathons_count = await db.scalar(
|
|
|
|
|
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-04 03:42:11 +07:00
|
|
|
|
return build_admin_user_response(user, marathons_count)
|
2025-12-20 00:34:22 +07:00
|
|
|
|
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
# ============ Force Finish Marathon ============
|
|
|
|
|
|
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
|
|
|
|
|
|
async def force_finish_marathon(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
marathon_id: int,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Force finish a marathon. Admin only."""
|
2026-01-08 06:51:15 +07:00
|
|
|
|
from app.services.coins import coins_service
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
|
|
|
|
|
marathon = result.scalar_one_or_none()
|
|
|
|
|
|
if not marathon:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
|
|
|
|
|
|
|
|
|
|
|
if marathon.status == MarathonStatus.FINISHED.value:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Marathon is already finished")
|
|
|
|
|
|
|
|
|
|
|
|
old_status = marathon.status
|
|
|
|
|
|
marathon.status = MarathonStatus.FINISHED.value
|
|
|
|
|
|
marathon.end_date = datetime.utcnow()
|
2026-01-08 06:51:15 +07:00
|
|
|
|
|
|
|
|
|
|
# Award coins for top 3 places (only in certified marathons)
|
|
|
|
|
|
if marathon.is_certified:
|
|
|
|
|
|
top_result = await db.execute(
|
|
|
|
|
|
select(Participant)
|
|
|
|
|
|
.options(selectinload(Participant.user))
|
|
|
|
|
|
.where(Participant.marathon_id == marathon_id)
|
|
|
|
|
|
.order_by(Participant.total_points.desc())
|
|
|
|
|
|
.limit(3)
|
|
|
|
|
|
)
|
|
|
|
|
|
top_participants = top_result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
for place, participant in enumerate(top_participants, start=1):
|
|
|
|
|
|
if participant.total_points > 0:
|
|
|
|
|
|
await coins_service.award_marathon_place(
|
|
|
|
|
|
db, participant.user, marathon, place
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.MARATHON_FORCE_FINISH.value,
|
|
|
|
|
|
"marathon", marathon_id,
|
|
|
|
|
|
{"title": marathon.title, "old_status": old_status},
|
|
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Notify participants
|
|
|
|
|
|
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
|
|
|
|
|
|
|
|
|
|
|
|
return MessageResponse(message="Marathon finished")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============ Admin Logs ============
|
|
|
|
|
|
@router.get("/logs", response_model=AdminLogsListResponse)
|
|
|
|
|
|
async def get_logs(
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
skip: int = Query(0, ge=0),
|
|
|
|
|
|
limit: int = Query(50, ge=1, le=100),
|
|
|
|
|
|
action: str | None = None,
|
|
|
|
|
|
admin_id: int | None = None,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Get admin action logs. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
query = (
|
|
|
|
|
|
select(AdminLog)
|
|
|
|
|
|
.options(selectinload(AdminLog.admin))
|
|
|
|
|
|
.order_by(AdminLog.created_at.desc())
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if action:
|
|
|
|
|
|
query = query.where(AdminLog.action == action)
|
|
|
|
|
|
if admin_id:
|
|
|
|
|
|
query = query.where(AdminLog.admin_id == admin_id)
|
|
|
|
|
|
|
|
|
|
|
|
# Get total count
|
|
|
|
|
|
count_query = select(func.count()).select_from(AdminLog)
|
|
|
|
|
|
if action:
|
|
|
|
|
|
count_query = count_query.where(AdminLog.action == action)
|
|
|
|
|
|
if admin_id:
|
|
|
|
|
|
count_query = count_query.where(AdminLog.admin_id == admin_id)
|
|
|
|
|
|
total = await db.scalar(count_query)
|
|
|
|
|
|
|
|
|
|
|
|
query = query.offset(skip).limit(limit)
|
|
|
|
|
|
result = await db.execute(query)
|
|
|
|
|
|
logs = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
return AdminLogsListResponse(
|
|
|
|
|
|
logs=[
|
|
|
|
|
|
AdminLogResponse(
|
|
|
|
|
|
id=log.id,
|
|
|
|
|
|
admin_id=log.admin_id,
|
|
|
|
|
|
admin_nickname=log.admin.nickname if log.admin else None,
|
|
|
|
|
|
action=log.action,
|
|
|
|
|
|
target_type=log.target_type,
|
|
|
|
|
|
target_id=log.target_id,
|
|
|
|
|
|
details=log.details,
|
|
|
|
|
|
ip_address=log.ip_address,
|
|
|
|
|
|
created_at=log.created_at,
|
|
|
|
|
|
)
|
|
|
|
|
|
for log in logs
|
|
|
|
|
|
],
|
|
|
|
|
|
total=total or 0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============ Broadcast ============
|
|
|
|
|
|
@router.post("/broadcast/all", response_model=BroadcastResponse)
|
|
|
|
|
|
@limiter.limit("1/minute")
|
|
|
|
|
|
async def broadcast_to_all(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
2026-01-04 04:58:41 +07:00
|
|
|
|
message: str = Form(""),
|
|
|
|
|
|
media: list[UploadFile] = File(default=[]),
|
2025-12-19 02:07:25 +07:00
|
|
|
|
):
|
|
|
|
|
|
"""Send broadcast message to all users with Telegram linked. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
# Get all users with telegram_id
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
|
select(User).where(User.telegram_id.isnot(None))
|
|
|
|
|
|
)
|
|
|
|
|
|
users = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
total_count = len(users)
|
|
|
|
|
|
sent_count = 0
|
|
|
|
|
|
|
2026-01-04 04:58:41 +07:00
|
|
|
|
# Read media files if provided (up to 10 files, Telegram limit)
|
|
|
|
|
|
media_items = []
|
|
|
|
|
|
for file in media[:10]:
|
|
|
|
|
|
if file and file.filename:
|
|
|
|
|
|
file_data = await file.read()
|
|
|
|
|
|
content_type = file.content_type or ""
|
|
|
|
|
|
if content_type.startswith("image/"):
|
|
|
|
|
|
media_items.append({
|
|
|
|
|
|
"type": "photo",
|
|
|
|
|
|
"data": file_data,
|
|
|
|
|
|
"filename": file.filename,
|
|
|
|
|
|
"content_type": content_type
|
|
|
|
|
|
})
|
|
|
|
|
|
elif content_type.startswith("video/"):
|
|
|
|
|
|
media_items.append({
|
|
|
|
|
|
"type": "video",
|
|
|
|
|
|
"data": file_data,
|
|
|
|
|
|
"filename": file.filename,
|
|
|
|
|
|
"content_type": content_type
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
for user in users:
|
2026-01-04 04:58:41 +07:00
|
|
|
|
if await telegram_notifier.send_media_message(
|
|
|
|
|
|
user.telegram_id,
|
|
|
|
|
|
text=message if message.strip() else None,
|
|
|
|
|
|
media_items=media_items if media_items else None
|
|
|
|
|
|
):
|
2025-12-19 02:07:25 +07:00
|
|
|
|
sent_count += 1
|
|
|
|
|
|
|
|
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
|
|
|
|
|
|
"broadcast", 0,
|
2026-01-04 04:58:41 +07:00
|
|
|
|
{"message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)},
|
2025-12-19 02:07:25 +07:00
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/broadcast/marathon/{marathon_id}", response_model=BroadcastResponse)
|
|
|
|
|
|
@limiter.limit("3/minute")
|
|
|
|
|
|
async def broadcast_to_marathon(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
marathon_id: int,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
2026-01-04 04:58:41 +07:00
|
|
|
|
message: str = Form(""),
|
|
|
|
|
|
media: list[UploadFile] = File(default=[]),
|
2025-12-19 02:07:25 +07:00
|
|
|
|
):
|
|
|
|
|
|
"""Send broadcast message to marathon participants. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
# Check marathon exists
|
|
|
|
|
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
|
|
|
|
|
marathon = result.scalar_one_or_none()
|
|
|
|
|
|
if not marathon:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
|
|
|
|
|
|
2026-01-04 04:58:41 +07:00
|
|
|
|
# Get participants with telegram
|
2025-12-19 02:07:25 +07:00
|
|
|
|
total_result = await db.execute(
|
|
|
|
|
|
select(User)
|
|
|
|
|
|
.join(Participant, Participant.user_id == User.id)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
Participant.marathon_id == marathon_id,
|
|
|
|
|
|
User.telegram_id.isnot(None)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
users = total_result.scalars().all()
|
|
|
|
|
|
total_count = len(users)
|
|
|
|
|
|
|
2026-01-04 04:58:41 +07:00
|
|
|
|
# Read media files if provided (up to 10 files, Telegram limit)
|
|
|
|
|
|
media_items = []
|
|
|
|
|
|
for file in media[:10]:
|
|
|
|
|
|
if file and file.filename:
|
|
|
|
|
|
file_data = await file.read()
|
|
|
|
|
|
content_type = file.content_type or ""
|
|
|
|
|
|
if content_type.startswith("image/"):
|
|
|
|
|
|
media_items.append({
|
|
|
|
|
|
"type": "photo",
|
|
|
|
|
|
"data": file_data,
|
|
|
|
|
|
"filename": file.filename,
|
|
|
|
|
|
"content_type": content_type
|
|
|
|
|
|
})
|
|
|
|
|
|
elif content_type.startswith("video/"):
|
|
|
|
|
|
media_items.append({
|
|
|
|
|
|
"type": "video",
|
|
|
|
|
|
"data": file_data,
|
|
|
|
|
|
"filename": file.filename,
|
|
|
|
|
|
"content_type": content_type
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
sent_count = 0
|
|
|
|
|
|
for user in users:
|
|
|
|
|
|
if await telegram_notifier.send_media_message(
|
|
|
|
|
|
user.telegram_id,
|
|
|
|
|
|
text=message if message.strip() else None,
|
|
|
|
|
|
media_items=media_items if media_items else None
|
|
|
|
|
|
):
|
|
|
|
|
|
sent_count += 1
|
2025-12-19 02:07:25 +07:00
|
|
|
|
|
|
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
|
|
|
|
|
|
"marathon", marathon_id,
|
2026-01-04 04:58:41 +07:00
|
|
|
|
{"title": marathon.title, "message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)},
|
2025-12-19 02:07:25 +07:00
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============ Static Content ============
|
|
|
|
|
|
@router.get("/content", response_model=list[StaticContentResponse])
|
|
|
|
|
|
async def list_content(current_user: CurrentUser, db: DbSession):
|
|
|
|
|
|
"""List all static content. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
|
select(StaticContent).order_by(StaticContent.key)
|
|
|
|
|
|
)
|
|
|
|
|
|
return result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/content/{key}", response_model=StaticContentResponse)
|
|
|
|
|
|
async def get_content(key: str, current_user: CurrentUser, db: DbSession):
|
|
|
|
|
|
"""Get static content by key. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
|
select(StaticContent).where(StaticContent.key == key)
|
|
|
|
|
|
)
|
|
|
|
|
|
content = result.scalar_one_or_none()
|
|
|
|
|
|
if not content:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Content not found")
|
|
|
|
|
|
return content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/content/{key}", response_model=StaticContentResponse)
|
|
|
|
|
|
async def update_content(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
key: str,
|
|
|
|
|
|
data: StaticContentUpdate,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Update static content. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
|
select(StaticContent).where(StaticContent.key == key)
|
|
|
|
|
|
)
|
|
|
|
|
|
content = result.scalar_one_or_none()
|
|
|
|
|
|
if not content:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Content not found")
|
|
|
|
|
|
|
|
|
|
|
|
content.title = data.title
|
|
|
|
|
|
content.content = data.content
|
|
|
|
|
|
content.updated_by_id = current_user.id
|
|
|
|
|
|
content.updated_at = datetime.utcnow()
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
await db.refresh(content)
|
|
|
|
|
|
|
|
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
|
|
|
|
|
|
"content", content.id,
|
|
|
|
|
|
{"key": key, "title": data.title},
|
|
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/content", response_model=StaticContentResponse)
|
|
|
|
|
|
async def create_content(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
data: StaticContentCreate,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Create static content. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
# Check if key exists
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
|
select(StaticContent).where(StaticContent.key == data.key)
|
|
|
|
|
|
)
|
|
|
|
|
|
if result.scalar_one_or_none():
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Content with this key already exists")
|
|
|
|
|
|
|
|
|
|
|
|
content = StaticContent(
|
|
|
|
|
|
key=data.key,
|
|
|
|
|
|
title=data.title,
|
|
|
|
|
|
content=data.content,
|
|
|
|
|
|
updated_by_id=current_user.id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(content)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
await db.refresh(content)
|
|
|
|
|
|
|
|
|
|
|
|
return content
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-20 02:01:51 +07:00
|
|
|
|
@router.delete("/content/{key}", response_model=MessageResponse)
|
|
|
|
|
|
async def delete_content(
|
|
|
|
|
|
key: str,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Delete static content. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
|
select(StaticContent).where(StaticContent.key == key)
|
|
|
|
|
|
)
|
|
|
|
|
|
content = result.scalar_one_or_none()
|
|
|
|
|
|
if not content:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Content not found")
|
|
|
|
|
|
|
|
|
|
|
|
await db.delete(content)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
|
|
|
|
|
|
"static_content", content.id,
|
|
|
|
|
|
{"action": "delete", "key": key},
|
|
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {"message": f"Content '{key}' deleted successfully"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
# ============ Dashboard ============
|
|
|
|
|
|
@router.get("/dashboard", response_model=DashboardStats)
|
|
|
|
|
|
async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
|
|
|
|
|
"""Get dashboard statistics. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
users_count = await db.scalar(select(func.count()).select_from(User))
|
|
|
|
|
|
banned_users_count = await db.scalar(
|
|
|
|
|
|
select(func.count()).select_from(User).where(User.is_banned == True)
|
|
|
|
|
|
)
|
|
|
|
|
|
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
|
|
|
|
|
active_marathons_count = await db.scalar(
|
|
|
|
|
|
select(func.count()).select_from(Marathon).where(Marathon.status == MarathonStatus.ACTIVE.value)
|
|
|
|
|
|
)
|
|
|
|
|
|
games_count = await db.scalar(select(func.count()).select_from(Game))
|
|
|
|
|
|
total_participations = await db.scalar(select(func.count()).select_from(Participant))
|
|
|
|
|
|
|
|
|
|
|
|
# Get recent logs
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
|
select(AdminLog)
|
|
|
|
|
|
.options(selectinload(AdminLog.admin))
|
|
|
|
|
|
.order_by(AdminLog.created_at.desc())
|
|
|
|
|
|
.limit(10)
|
|
|
|
|
|
)
|
|
|
|
|
|
recent_logs = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
return DashboardStats(
|
|
|
|
|
|
users_count=users_count or 0,
|
|
|
|
|
|
banned_users_count=banned_users_count or 0,
|
|
|
|
|
|
marathons_count=marathons_count or 0,
|
|
|
|
|
|
active_marathons_count=active_marathons_count or 0,
|
|
|
|
|
|
games_count=games_count or 0,
|
|
|
|
|
|
total_participations=total_participations or 0,
|
|
|
|
|
|
recent_logs=[
|
|
|
|
|
|
AdminLogResponse(
|
|
|
|
|
|
id=log.id,
|
|
|
|
|
|
admin_id=log.admin_id,
|
|
|
|
|
|
admin_nickname=log.admin.nickname if log.admin else None,
|
|
|
|
|
|
action=log.action,
|
|
|
|
|
|
target_type=log.target_type,
|
|
|
|
|
|
target_id=log.target_id,
|
|
|
|
|
|
details=log.details,
|
|
|
|
|
|
ip_address=log.ip_address,
|
|
|
|
|
|
created_at=log.created_at,
|
|
|
|
|
|
)
|
|
|
|
|
|
for log in recent_logs
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2025-12-29 22:23:34 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============ Disputes Management ============
|
|
|
|
|
|
|
|
|
|
|
|
class AdminDisputeResponse(BaseModel):
|
|
|
|
|
|
id: int
|
|
|
|
|
|
assignment_id: int | None
|
|
|
|
|
|
bonus_assignment_id: int | None
|
|
|
|
|
|
marathon_id: int
|
|
|
|
|
|
marathon_title: str
|
|
|
|
|
|
challenge_title: str
|
|
|
|
|
|
participant_nickname: str
|
|
|
|
|
|
raised_by_nickname: str
|
|
|
|
|
|
reason: str
|
|
|
|
|
|
status: str
|
|
|
|
|
|
votes_valid: int
|
|
|
|
|
|
votes_invalid: int
|
|
|
|
|
|
created_at: str
|
|
|
|
|
|
expires_at: str
|
|
|
|
|
|
|
|
|
|
|
|
class Config:
|
|
|
|
|
|
from_attributes = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ResolveDisputeRequest(BaseModel):
|
|
|
|
|
|
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/disputes", response_model=list[AdminDisputeResponse])
|
|
|
|
|
|
async def list_disputes(
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
status: str = Query("pending", pattern="^(open|pending|all)$"),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""List all disputes. Admin only.
|
|
|
|
|
|
|
|
|
|
|
|
Status filter:
|
|
|
|
|
|
- pending: disputes waiting for admin decision (default)
|
|
|
|
|
|
- open: disputes still in voting phase
|
|
|
|
|
|
- all: all disputes
|
|
|
|
|
|
"""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
DISPUTE_WINDOW_HOURS = 24
|
|
|
|
|
|
|
|
|
|
|
|
query = (
|
|
|
|
|
|
select(Dispute)
|
|
|
|
|
|
.options(
|
|
|
|
|
|
selectinload(Dispute.raised_by),
|
|
|
|
|
|
selectinload(Dispute.votes),
|
|
|
|
|
|
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
|
|
|
|
|
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
|
|
|
|
|
selectinload(Dispute.assignment).selectinload(Assignment.game), # For playthrough
|
|
|
|
|
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
|
|
|
|
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
|
|
|
|
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
|
|
|
|
|
)
|
|
|
|
|
|
.order_by(Dispute.created_at.desc())
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if status == "pending":
|
|
|
|
|
|
# Disputes waiting for admin decision
|
|
|
|
|
|
query = query.where(Dispute.status == DisputeStatus.PENDING_ADMIN.value)
|
|
|
|
|
|
elif status == "open":
|
|
|
|
|
|
# Disputes still in voting phase
|
|
|
|
|
|
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
|
|
|
|
|
|
|
|
|
|
|
|
result = await db.execute(query)
|
|
|
|
|
|
disputes = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
response = []
|
|
|
|
|
|
for dispute in disputes:
|
|
|
|
|
|
# Get info based on dispute type
|
|
|
|
|
|
if dispute.bonus_assignment_id:
|
|
|
|
|
|
bonus = dispute.bonus_assignment
|
|
|
|
|
|
main_assignment = bonus.main_assignment
|
|
|
|
|
|
participant = main_assignment.participant
|
|
|
|
|
|
challenge_title = f"Бонус: {bonus.challenge.title}"
|
|
|
|
|
|
marathon_id = main_assignment.game.marathon_id
|
|
|
|
|
|
|
|
|
|
|
|
# Get marathon title
|
|
|
|
|
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
|
|
|
|
|
marathon = marathon_result.scalar_one_or_none()
|
|
|
|
|
|
marathon_title = marathon.title if marathon else "Unknown"
|
|
|
|
|
|
else:
|
|
|
|
|
|
assignment = dispute.assignment
|
|
|
|
|
|
participant = assignment.participant
|
|
|
|
|
|
if assignment.is_playthrough:
|
|
|
|
|
|
challenge_title = f"Прохождение: {assignment.game.title}"
|
|
|
|
|
|
marathon_id = assignment.game.marathon_id
|
|
|
|
|
|
else:
|
|
|
|
|
|
challenge_title = assignment.challenge.title
|
|
|
|
|
|
marathon_id = assignment.challenge.game.marathon_id
|
|
|
|
|
|
|
|
|
|
|
|
# Get marathon title
|
|
|
|
|
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
|
|
|
|
|
marathon = marathon_result.scalar_one_or_none()
|
|
|
|
|
|
marathon_title = marathon.title if marathon else "Unknown"
|
|
|
|
|
|
|
|
|
|
|
|
# Count votes
|
|
|
|
|
|
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
|
|
|
|
|
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
|
|
|
|
|
|
|
|
|
|
|
# Calculate expiry
|
|
|
|
|
|
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
|
|
|
|
|
|
|
|
|
|
|
|
response.append(AdminDisputeResponse(
|
|
|
|
|
|
id=dispute.id,
|
|
|
|
|
|
assignment_id=dispute.assignment_id,
|
|
|
|
|
|
bonus_assignment_id=dispute.bonus_assignment_id,
|
|
|
|
|
|
marathon_id=marathon_id,
|
|
|
|
|
|
marathon_title=marathon_title,
|
|
|
|
|
|
challenge_title=challenge_title,
|
|
|
|
|
|
participant_nickname=participant.user.nickname,
|
|
|
|
|
|
raised_by_nickname=dispute.raised_by.nickname,
|
|
|
|
|
|
reason=dispute.reason,
|
|
|
|
|
|
status=dispute.status,
|
|
|
|
|
|
votes_valid=votes_valid,
|
|
|
|
|
|
votes_invalid=votes_invalid,
|
|
|
|
|
|
created_at=dispute.created_at.isoformat(),
|
|
|
|
|
|
expires_at=expires_at.isoformat(),
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/disputes/{dispute_id}/resolve", response_model=MessageResponse)
|
|
|
|
|
|
async def resolve_dispute(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
dispute_id: int,
|
|
|
|
|
|
data: ResolveDisputeRequest,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Manually resolve a dispute. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
# Get dispute
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
|
select(Dispute)
|
|
|
|
|
|
.options(
|
|
|
|
|
|
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
|
|
|
|
|
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
|
|
|
|
|
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
|
|
|
|
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
|
|
|
|
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
|
|
|
|
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
|
|
|
|
|
)
|
|
|
|
|
|
.where(Dispute.id == dispute_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
dispute = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
if not dispute:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Dispute not found")
|
|
|
|
|
|
|
|
|
|
|
|
# Allow resolving disputes that are either open or pending admin decision
|
|
|
|
|
|
if dispute.status not in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
|
|
|
|
|
|
|
|
|
|
|
# Determine result
|
|
|
|
|
|
if data.is_valid:
|
|
|
|
|
|
result_status = DisputeStatus.RESOLVED_VALID.value
|
|
|
|
|
|
action_type = AdminActionType.DISPUTE_RESOLVE_VALID.value
|
|
|
|
|
|
else:
|
|
|
|
|
|
result_status = DisputeStatus.RESOLVED_INVALID.value
|
|
|
|
|
|
action_type = AdminActionType.DISPUTE_RESOLVE_INVALID.value
|
|
|
|
|
|
|
|
|
|
|
|
# Handle invalid proof
|
|
|
|
|
|
if dispute.bonus_assignment_id:
|
|
|
|
|
|
# Reset bonus assignment
|
|
|
|
|
|
bonus = dispute.bonus_assignment
|
|
|
|
|
|
main_assignment = bonus.main_assignment
|
|
|
|
|
|
participant = main_assignment.participant
|
|
|
|
|
|
|
|
|
|
|
|
# Only subtract points if main playthrough was already completed
|
|
|
|
|
|
# (bonus points are added only when main playthrough is completed)
|
|
|
|
|
|
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
|
|
|
|
|
points_to_subtract = bonus.points_earned
|
|
|
|
|
|
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
|
|
|
|
|
# Also reduce the points_earned on the main assignment
|
|
|
|
|
|
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
|
|
|
|
|
|
|
|
|
|
|
|
bonus.status = BonusAssignmentStatus.PENDING.value
|
|
|
|
|
|
bonus.proof_path = None
|
|
|
|
|
|
bonus.proof_url = None
|
|
|
|
|
|
bonus.proof_comment = None
|
|
|
|
|
|
bonus.points_earned = 0
|
|
|
|
|
|
bonus.completed_at = None
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Reset main assignment
|
|
|
|
|
|
assignment = dispute.assignment
|
|
|
|
|
|
participant = assignment.participant
|
|
|
|
|
|
|
|
|
|
|
|
# Subtract points
|
|
|
|
|
|
points_to_subtract = assignment.points_earned
|
|
|
|
|
|
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
|
|
|
|
|
|
|
|
|
|
|
# Reset streak - the completion was invalid
|
|
|
|
|
|
participant.current_streak = 0
|
|
|
|
|
|
|
|
|
|
|
|
# Reset assignment
|
|
|
|
|
|
assignment.status = AssignmentStatus.RETURNED.value
|
|
|
|
|
|
assignment.points_earned = 0
|
|
|
|
|
|
|
|
|
|
|
|
# For playthrough: reset all bonus assignments
|
|
|
|
|
|
if assignment.is_playthrough:
|
|
|
|
|
|
bonus_result = await db.execute(
|
|
|
|
|
|
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
|
|
|
|
|
)
|
|
|
|
|
|
for ba in bonus_result.scalars().all():
|
|
|
|
|
|
ba.status = BonusAssignmentStatus.PENDING.value
|
|
|
|
|
|
ba.proof_path = None
|
|
|
|
|
|
ba.proof_url = None
|
|
|
|
|
|
ba.proof_comment = None
|
|
|
|
|
|
ba.points_earned = 0
|
|
|
|
|
|
ba.completed_at = None
|
|
|
|
|
|
|
|
|
|
|
|
# Update dispute
|
|
|
|
|
|
dispute.status = result_status
|
|
|
|
|
|
dispute.resolved_at = datetime.utcnow()
|
|
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Get details for logging
|
|
|
|
|
|
if dispute.bonus_assignment_id:
|
|
|
|
|
|
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
|
|
|
|
|
|
marathon_id = dispute.bonus_assignment.main_assignment.game.marathon_id
|
|
|
|
|
|
elif dispute.assignment.is_playthrough:
|
|
|
|
|
|
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
|
|
|
|
|
|
marathon_id = dispute.assignment.game.marathon_id
|
|
|
|
|
|
else:
|
|
|
|
|
|
challenge_title = dispute.assignment.challenge.title
|
|
|
|
|
|
marathon_id = dispute.assignment.challenge.game.marathon_id
|
|
|
|
|
|
|
|
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, action_type,
|
|
|
|
|
|
"dispute", dispute_id,
|
|
|
|
|
|
{
|
|
|
|
|
|
"challenge_title": challenge_title,
|
|
|
|
|
|
"marathon_id": marathon_id,
|
|
|
|
|
|
"is_valid": data.is_valid,
|
|
|
|
|
|
},
|
|
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Send notification
|
|
|
|
|
|
from app.services.telegram_notifier import telegram_notifier
|
|
|
|
|
|
|
|
|
|
|
|
if dispute.bonus_assignment_id:
|
|
|
|
|
|
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
|
|
|
|
|
|
else:
|
|
|
|
|
|
participant_user_id = dispute.assignment.participant.user_id
|
|
|
|
|
|
|
|
|
|
|
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
|
|
|
|
|
marathon = marathon_result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
if marathon:
|
|
|
|
|
|
await telegram_notifier.notify_dispute_resolved(
|
|
|
|
|
|
db,
|
|
|
|
|
|
user_id=participant_user_id,
|
|
|
|
|
|
marathon_title=marathon.title,
|
|
|
|
|
|
challenge_title=challenge_title,
|
|
|
|
|
|
is_valid=data.is_valid
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return MessageResponse(
|
|
|
|
|
|
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
|
|
|
|
|
)
|
2026-01-05 07:15:50 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============ Marathon Certification ============
|
|
|
|
|
|
@router.post("/marathons/{marathon_id}/certify", response_model=MessageResponse)
|
|
|
|
|
|
async def certify_marathon(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
marathon_id: int,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Certify (verify) a marathon. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
|
|
|
|
|
marathon = result.scalar_one_or_none()
|
|
|
|
|
|
if not marathon:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
|
|
|
|
|
|
|
|
|
|
|
if marathon.certification_status == CertificationStatus.CERTIFIED.value:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Marathon is already certified")
|
|
|
|
|
|
|
|
|
|
|
|
marathon.certification_status = CertificationStatus.CERTIFIED.value
|
|
|
|
|
|
marathon.certified_at = datetime.utcnow()
|
|
|
|
|
|
marathon.certified_by_id = current_user.id
|
|
|
|
|
|
marathon.certification_rejection_reason = None
|
|
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.MARATHON_CERTIFY.value,
|
|
|
|
|
|
"marathon", marathon_id,
|
|
|
|
|
|
{"title": marathon.title},
|
|
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return MessageResponse(message="Marathon certified successfully")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/marathons/{marathon_id}/revoke-certification", response_model=MessageResponse)
|
|
|
|
|
|
async def revoke_marathon_certification(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
marathon_id: int,
|
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
|
db: DbSession,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Revoke certification from a marathon. Admin only."""
|
|
|
|
|
|
require_admin_with_2fa(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
|
|
|
|
|
marathon = result.scalar_one_or_none()
|
|
|
|
|
|
if not marathon:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
|
|
|
|
|
|
|
|
|
|
|
if marathon.certification_status != CertificationStatus.CERTIFIED.value:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Marathon is not certified")
|
|
|
|
|
|
|
|
|
|
|
|
marathon.certification_status = CertificationStatus.NONE.value
|
|
|
|
|
|
marathon.certified_at = None
|
|
|
|
|
|
marathon.certified_by_id = None
|
|
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Log action
|
|
|
|
|
|
await log_admin_action(
|
|
|
|
|
|
db, current_user.id, AdminActionType.MARATHON_REVOKE_CERTIFICATION.value,
|
|
|
|
|
|
"marathon", marathon_id,
|
|
|
|
|
|
{"title": marathon.title},
|
|
|
|
|
|
request.client.host if request.client else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return MessageResponse(message="Marathon certification revoked")
|