Add admin panel
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
from typing import Annotated
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, status, Header
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
@@ -8,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_access_token
|
||||
from app.models import User, Participant, Marathon, UserRole, ParticipantRole
|
||||
from app.models import User, Participant, Marathon, UserRole, ParticipantRole, AdminLog, AdminActionType
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
@@ -43,6 +44,50 @@ async def get_current_user(
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Check if user is banned
|
||||
if user.is_banned:
|
||||
# Auto-unban if ban expired
|
||||
if user.banned_until and datetime.utcnow() > user.banned_until:
|
||||
# Save ban info for logging before clearing
|
||||
old_ban_reason = user.ban_reason
|
||||
old_banned_until = user.banned_until.isoformat() if user.banned_until else None
|
||||
|
||||
user.is_banned = False
|
||||
user.banned_at = None
|
||||
user.banned_until = None
|
||||
user.banned_by_id = None
|
||||
user.ban_reason = None
|
||||
|
||||
# Log system auto-unban action
|
||||
log = AdminLog(
|
||||
admin_id=None, # System action, no admin
|
||||
action=AdminActionType.USER_AUTO_UNBAN.value,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
details={
|
||||
"nickname": user.nickname,
|
||||
"reason": old_ban_reason,
|
||||
"banned_until": old_banned_until,
|
||||
"system": True,
|
||||
},
|
||||
ip_address=None,
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
else:
|
||||
# Still banned - return ban info in error
|
||||
ban_info = {
|
||||
"banned_at": user.banned_at.isoformat() if user.banned_at else None,
|
||||
"banned_until": user.banned_until.isoformat() if user.banned_until else None,
|
||||
"reason": user.ban_reason,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ban_info,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -56,6 +101,21 @@ def require_admin(user: User) -> User:
|
||||
return user
|
||||
|
||||
|
||||
def require_admin_with_2fa(user: User) -> User:
|
||||
"""Check if user is admin with Telegram linked (2FA enabled)"""
|
||||
if not user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
if not user.telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Для доступа к админ-панели необходимо привязать Telegram в профиле",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def get_participant(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -15,3 +15,4 @@ router.include_router(admin.router)
|
||||
router.include_router(events.router)
|
||||
router.include_router(assignments.router)
|
||||
router.include_router(telegram.router)
|
||||
router.include_router(content.router)
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser, require_admin
|
||||
from app.models import User, UserRole, Marathon, Participant, Game
|
||||
from app.schemas import UserPublic, MarathonListItem, MessageResponse
|
||||
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
|
||||
from app.schemas import (
|
||||
UserPublic, MessageResponse,
|
||||
AdminUserResponse, BanUserRequest, AdminLogResponse, AdminLogsListResponse,
|
||||
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
|
||||
StaticContentCreate, DashboardStats
|
||||
)
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
from app.core.rate_limit import limiter
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -14,21 +22,6 @@ class SetUserRole(BaseModel):
|
||||
role: str = Field(..., pattern="^(user|admin)$")
|
||||
|
||||
|
||||
class AdminUserResponse(BaseModel):
|
||||
id: int
|
||||
login: str
|
||||
nickname: str
|
||||
role: str
|
||||
avatar_url: str | None = None
|
||||
telegram_id: int | None = None
|
||||
telegram_username: str | None = None
|
||||
marathons_count: int = 0
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminMarathonResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
@@ -44,6 +37,29 @@ class AdminMarathonResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ 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()
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserResponse])
|
||||
async def list_users(
|
||||
current_user: CurrentUser,
|
||||
@@ -51,9 +67,10 @@ async def list_users(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
search: str | None = None,
|
||||
banned_only: bool = False,
|
||||
):
|
||||
"""List all users. Admin only."""
|
||||
require_admin(current_user)
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
query = select(User).order_by(User.created_at.desc())
|
||||
|
||||
@@ -63,6 +80,9 @@ async def list_users(
|
||||
(User.nickname.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
if banned_only:
|
||||
query = query.where(User.is_banned == True)
|
||||
|
||||
query = query.offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
@@ -83,6 +103,10 @@ async def list_users(
|
||||
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,
|
||||
))
|
||||
|
||||
return response
|
||||
@@ -91,7 +115,7 @@ async def list_users(
|
||||
@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."""
|
||||
require_admin(current_user)
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -112,6 +136,10 @@ async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -121,9 +149,10 @@ async def set_user_role(
|
||||
data: SetUserRole,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
request: Request,
|
||||
):
|
||||
"""Set user's global role. Admin only."""
|
||||
require_admin(current_user)
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Cannot change own role
|
||||
if user_id == current_user.id:
|
||||
@@ -134,10 +163,19 @@ async def set_user_role(
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
old_role = user.role
|
||||
user.role = data.role
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
marathons_count = await db.scalar(
|
||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||
)
|
||||
@@ -152,13 +190,17 @@ async def set_user_role(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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."""
|
||||
require_admin(current_user)
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Cannot delete yourself
|
||||
if user_id == current_user.id:
|
||||
@@ -188,7 +230,7 @@ async def list_marathons(
|
||||
search: str | None = None,
|
||||
):
|
||||
"""List all marathons. Admin only."""
|
||||
require_admin(current_user)
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
query = (
|
||||
select(Marathon)
|
||||
@@ -227,25 +269,34 @@ async def list_marathons(
|
||||
|
||||
|
||||
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
|
||||
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession, request: Request):
|
||||
"""Delete a marathon. Admin only."""
|
||||
require_admin(current_user)
|
||||
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")
|
||||
|
||||
marathon_title = marathon.title
|
||||
await db.delete(marathon)
|
||||
await db.commit()
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
return MessageResponse(message="Marathon deleted")
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats(current_user: CurrentUser, db: DbSession):
|
||||
"""Get platform statistics. Admin only."""
|
||||
require_admin(current_user)
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
users_count = await db.scalar(select(func.count()).select_from(User))
|
||||
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
||||
@@ -258,3 +309,439 @@ async def get_stats(current_user: CurrentUser, db: DbSession):
|
||||
"games_count": games_count,
|
||||
"total_participations": participants_count,
|
||||
}
|
||||
|
||||
|
||||
# ============ 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)
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
)
|
||||
|
||||
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=None,
|
||||
banned_until=None,
|
||||
ban_reason=None,
|
||||
)
|
||||
|
||||
|
||||
# ============ 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."""
|
||||
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()
|
||||
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,
|
||||
data: BroadcastRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""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
|
||||
|
||||
for user in users:
|
||||
if await telegram_notifier.send_message(user.telegram_id, data.message):
|
||||
sent_count += 1
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
|
||||
"broadcast", 0,
|
||||
{"message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||
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,
|
||||
data: BroadcastRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""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")
|
||||
|
||||
# Get participants count
|
||||
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)
|
||||
|
||||
sent_count = await telegram_notifier.notify_marathon_participants(
|
||||
db, marathon_id, data.message
|
||||
)
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
|
||||
"marathon", marathon_id,
|
||||
{"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||
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
|
||||
|
||||
|
||||
# ============ 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
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Request
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||
from app.core.rate_limit import limiter
|
||||
from app.models import User
|
||||
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate
|
||||
from app.models import User, UserRole, Admin2FASession
|
||||
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -40,7 +44,7 @@ async def register(request: Request, data: UserRegister, db: DbSession):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def login(request: Request, data: UserLogin, db: DbSession):
|
||||
# Find user
|
||||
@@ -53,6 +57,99 @@ async def login(request: Request, data: UserLogin, db: DbSession):
|
||||
detail="Incorrect login or password",
|
||||
)
|
||||
|
||||
# Check if user is banned
|
||||
if user.is_banned:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Your account has been banned",
|
||||
)
|
||||
|
||||
# If admin with Telegram linked, require 2FA
|
||||
if user.role == UserRole.ADMIN.value and user.telegram_id:
|
||||
# Generate 6-digit code
|
||||
code = "".join([str(secrets.randbelow(10)) for _ in range(6)])
|
||||
|
||||
# Create 2FA session (expires in 5 minutes)
|
||||
session = Admin2FASession(
|
||||
user_id=user.id,
|
||||
code=code,
|
||||
expires_at=datetime.utcnow() + timedelta(minutes=5),
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
# Send code to Telegram
|
||||
message = f"🔐 <b>Код подтверждения для входа в админку</b>\n\nВаш код: <code>{code}</code>\n\nКод действителен 5 минут."
|
||||
sent = await telegram_notifier.send_message(user.telegram_id, message)
|
||||
|
||||
if sent:
|
||||
session.telegram_sent = True
|
||||
await db.commit()
|
||||
|
||||
return LoginResponse(
|
||||
requires_2fa=True,
|
||||
two_factor_session_id=session.id,
|
||||
)
|
||||
|
||||
# Regular user or admin without Telegram - generate token immediately
|
||||
# Admin without Telegram can login but admin panel will check for Telegram
|
||||
access_token = create_access_token(subject=user.id)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
user=UserPrivate.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/2fa/verify", response_model=TokenResponse)
|
||||
@limiter.limit("5/minute")
|
||||
async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession):
|
||||
"""Verify 2FA code and return JWT token."""
|
||||
# Find session
|
||||
result = await db.execute(
|
||||
select(Admin2FASession).where(Admin2FASession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid session",
|
||||
)
|
||||
|
||||
if session.is_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Session already verified",
|
||||
)
|
||||
|
||||
if datetime.utcnow() > session.expires_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Code expired",
|
||||
)
|
||||
|
||||
if session.code != code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid code",
|
||||
)
|
||||
|
||||
# Mark as verified
|
||||
session.is_verified = True
|
||||
await db.commit()
|
||||
|
||||
# Get user
|
||||
result = await db.execute(select(User).where(User.id == session.user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Generate token
|
||||
access_token = create_access_token(subject=user.id)
|
||||
|
||||
|
||||
20
backend/app/api/v1/content.py
Normal file
20
backend/app/api/v1/content.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.models import StaticContent
|
||||
from app.schemas import StaticContentResponse
|
||||
|
||||
router = APIRouter(prefix="/content", tags=["content"])
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=StaticContentResponse)
|
||||
async def get_public_content(key: str, db: DbSession):
|
||||
"""Get public static content by key. No authentication required."""
|
||||
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
|
||||
@@ -86,7 +86,7 @@ async def generate_link_token(current_user: CurrentUser):
|
||||
)
|
||||
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
|
||||
|
||||
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot"
|
||||
bot_username = settings.TELEGRAM_BOT_USERNAME or "BCMarathonbot"
|
||||
bot_url = f"https://t.me/{bot_username}?start={token}"
|
||||
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ from app.models.activity import Activity, ActivityType
|
||||
from app.models.event import Event, EventType
|
||||
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
|
||||
from app.models.admin_log import AdminLog, AdminActionType
|
||||
from app.models.admin_2fa import Admin2FASession
|
||||
from app.models.static_content import StaticContent
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -35,4 +38,8 @@ __all__ = [
|
||||
"DisputeStatus",
|
||||
"DisputeComment",
|
||||
"DisputeVote",
|
||||
"AdminLog",
|
||||
"AdminActionType",
|
||||
"Admin2FASession",
|
||||
"StaticContent",
|
||||
]
|
||||
|
||||
20
backend/app/models/admin_2fa.py
Normal file
20
backend/app/models/admin_2fa.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Integer, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Admin2FASession(Base):
|
||||
__tablename__ = "admin_2fa_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
code: Mapped[str] = mapped_column(String(6), nullable=False)
|
||||
telegram_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
||||
46
backend/app/models/admin_log.py
Normal file
46
backend/app/models/admin_log.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, DateTime, Integer, ForeignKey, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AdminActionType(str, Enum):
|
||||
# User actions
|
||||
USER_BAN = "user_ban"
|
||||
USER_UNBAN = "user_unban"
|
||||
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
|
||||
USER_ROLE_CHANGE = "user_role_change"
|
||||
|
||||
# Marathon actions
|
||||
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
||||
MARATHON_DELETE = "marathon_delete"
|
||||
|
||||
# Content actions
|
||||
CONTENT_UPDATE = "content_update"
|
||||
|
||||
# Broadcast actions
|
||||
BROADCAST_ALL = "broadcast_all"
|
||||
BROADCAST_MARATHON = "broadcast_marathon"
|
||||
|
||||
# Auth actions
|
||||
ADMIN_LOGIN = "admin_login"
|
||||
ADMIN_2FA_SUCCESS = "admin_2fa_success"
|
||||
ADMIN_2FA_FAIL = "admin_2fa_fail"
|
||||
|
||||
|
||||
class AdminLog(Base):
|
||||
__tablename__ = "admin_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
admin_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True) # Nullable for system actions
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
target_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
# Relationships
|
||||
admin: Mapped["User"] = relationship("User", foreign_keys=[admin_id])
|
||||
20
backend/app/models/static_content.py
Normal file
20
backend/app/models/static_content.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Integer, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class StaticContent(Base):
|
||||
__tablename__ = "static_content"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
updated_by: Mapped["User | None"] = relationship("User", foreign_keys=[updated_by_id])
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, BigInteger, DateTime
|
||||
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -27,6 +27,13 @@ class User(Base):
|
||||
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Ban fields
|
||||
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
banned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
banned_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = permanent
|
||||
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Relationships
|
||||
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||
"Marathon",
|
||||
@@ -47,6 +54,11 @@ class User(Base):
|
||||
back_populates="approved_by",
|
||||
foreign_keys="Game.approved_by_id"
|
||||
)
|
||||
banned_by: Mapped["User | None"] = relationship(
|
||||
"User",
|
||||
remote_side="User.id",
|
||||
foreign_keys=[banned_by_id]
|
||||
)
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
|
||||
@@ -81,6 +81,22 @@ from app.schemas.dispute import (
|
||||
AssignmentDetailResponse,
|
||||
ReturnedAssignmentResponse,
|
||||
)
|
||||
from app.schemas.admin import (
|
||||
BanUserRequest,
|
||||
AdminUserResponse,
|
||||
AdminLogResponse,
|
||||
AdminLogsListResponse,
|
||||
BroadcastRequest,
|
||||
BroadcastResponse,
|
||||
StaticContentResponse,
|
||||
StaticContentUpdate,
|
||||
StaticContentCreate,
|
||||
TwoFactorInitiateRequest,
|
||||
TwoFactorInitiateResponse,
|
||||
TwoFactorVerifyRequest,
|
||||
LoginResponse,
|
||||
DashboardStats,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# User
|
||||
@@ -157,4 +173,19 @@ __all__ = [
|
||||
"DisputeResponse",
|
||||
"AssignmentDetailResponse",
|
||||
"ReturnedAssignmentResponse",
|
||||
# Admin
|
||||
"BanUserRequest",
|
||||
"AdminUserResponse",
|
||||
"AdminLogResponse",
|
||||
"AdminLogsListResponse",
|
||||
"BroadcastRequest",
|
||||
"BroadcastResponse",
|
||||
"StaticContentResponse",
|
||||
"StaticContentUpdate",
|
||||
"StaticContentCreate",
|
||||
"TwoFactorInitiateRequest",
|
||||
"TwoFactorInitiateResponse",
|
||||
"TwoFactorVerifyRequest",
|
||||
"LoginResponse",
|
||||
"DashboardStats",
|
||||
]
|
||||
|
||||
119
backend/app/schemas/admin.py
Normal file
119
backend/app/schemas/admin.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ============ User Ban ============
|
||||
class BanUserRequest(BaseModel):
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
banned_until: datetime | None = None # None = permanent ban
|
||||
|
||||
|
||||
class AdminUserResponse(BaseModel):
|
||||
id: int
|
||||
login: str
|
||||
nickname: str
|
||||
role: str
|
||||
avatar_url: str | None = None
|
||||
telegram_id: int | None = None
|
||||
telegram_username: str | None = None
|
||||
marathons_count: int = 0
|
||||
created_at: str
|
||||
is_banned: bool = False
|
||||
banned_at: str | None = None
|
||||
banned_until: str | None = None # None = permanent
|
||||
ban_reason: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Admin Logs ============
|
||||
class AdminLogResponse(BaseModel):
|
||||
id: int
|
||||
admin_id: int | None = None # Nullable for system actions
|
||||
admin_nickname: str | None = None # Nullable for system actions
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: int
|
||||
details: dict | None = None
|
||||
ip_address: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminLogsListResponse(BaseModel):
|
||||
logs: list[AdminLogResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ============ Broadcast ============
|
||||
class BroadcastRequest(BaseModel):
|
||||
message: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class BroadcastResponse(BaseModel):
|
||||
sent_count: int
|
||||
total_count: int
|
||||
|
||||
|
||||
# ============ Static Content ============
|
||||
class StaticContentResponse(BaseModel):
|
||||
id: int
|
||||
key: str
|
||||
title: str
|
||||
content: str
|
||||
updated_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class StaticContentUpdate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
content: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class StaticContentCreate(BaseModel):
|
||||
key: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-z0-9_-]+$")
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
content: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ============ 2FA ============
|
||||
class TwoFactorInitiateRequest(BaseModel):
|
||||
pass # No additional data needed
|
||||
|
||||
|
||||
class TwoFactorInitiateResponse(BaseModel):
|
||||
session_id: int
|
||||
expires_at: datetime
|
||||
message: str = "Code sent to Telegram"
|
||||
|
||||
|
||||
class TwoFactorVerifyRequest(BaseModel):
|
||||
session_id: int
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Login response that may require 2FA"""
|
||||
access_token: str | None = None
|
||||
token_type: str = "bearer"
|
||||
user: Any = None # UserPrivate
|
||||
requires_2fa: bool = False
|
||||
two_factor_session_id: int | None = None
|
||||
|
||||
|
||||
# ============ Dashboard Stats ============
|
||||
class DashboardStats(BaseModel):
|
||||
users_count: int
|
||||
banned_users_count: int
|
||||
marathons_count: int
|
||||
active_marathons_count: int
|
||||
games_count: int
|
||||
total_participations: int
|
||||
recent_logs: list[AdminLogResponse] = []
|
||||
Reference in New Issue
Block a user