Add admin panel

This commit is contained in:
2025-12-19 02:07:25 +07:00
parent 8e634994bd
commit 481bdabaa8
40 changed files with 3526 additions and 112 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
],
)

View File

@@ -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)

View 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

View File

@@ -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}")