Compare commits
4 Commits
e43e579329
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 481bdabaa8 | |||
| 8e634994bd | |||
| 33f49f4e47 | |||
| 57bad3b4a8 |
10
.env.example
10
.env.example
@@ -10,6 +10,7 @@ OPENAI_API_KEY=sk-...
|
||||
|
||||
# Telegram Bot
|
||||
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
||||
BOT_API_SECRET=change_me_random_secret_for_bot_api
|
||||
|
||||
# S3 Storage - FirstVDS (set S3_ENABLED=true to use)
|
||||
S3_ENABLED=false
|
||||
@@ -20,5 +21,14 @@ S3_SECRET_ACCESS_KEY=your-secret-access-key
|
||||
S3_ENDPOINT_URL=https://s3.firstvds.ru
|
||||
S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru
|
||||
|
||||
# Backup Service
|
||||
TELEGRAM_ADMIN_ID=947392854
|
||||
S3_BACKUP_PREFIX=backups/
|
||||
BACKUP_RETENTION_DAYS=14
|
||||
|
||||
# Status Service (optional - for external monitoring)
|
||||
EXTERNAL_URL=https://your-domain.com
|
||||
PUBLIC_URL=https://your-domain.com
|
||||
|
||||
# Frontend (for build)
|
||||
VITE_API_URL=/api/v1
|
||||
|
||||
23
Makefile
23
Makefile
@@ -31,6 +31,12 @@ help:
|
||||
@echo " make shell - Open backend shell"
|
||||
@echo " make frontend-sh - Open frontend shell"
|
||||
@echo ""
|
||||
@echo " Backup:"
|
||||
@echo " make backup-now - Run backup immediately"
|
||||
@echo " make backup-list - List available backups in S3"
|
||||
@echo " make backup-restore - Restore from backup (interactive)"
|
||||
@echo " make backup-logs - Show backup service logs"
|
||||
@echo ""
|
||||
@echo " Cleanup:"
|
||||
@echo " make clean - Stop and remove containers, volumes"
|
||||
@echo " make prune - Remove unused Docker resources"
|
||||
@@ -137,3 +143,20 @@ test-backend:
|
||||
# Production
|
||||
prod:
|
||||
$(DC) -f docker-compose.yml up -d --build
|
||||
|
||||
# Backup
|
||||
backup-now:
|
||||
$(DC) exec backup python /app/backup.py
|
||||
|
||||
backup-list:
|
||||
$(DC) exec backup python /app/restore.py
|
||||
|
||||
backup-restore:
|
||||
@read -p "Backup filename: " file; \
|
||||
$(DC) exec -it backup python /app/restore.py "$$file"
|
||||
|
||||
backup-logs:
|
||||
$(DC) logs -f backup
|
||||
|
||||
backup-shell:
|
||||
$(DC) exec backup bash
|
||||
|
||||
28
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
28
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Add challenge proposals support
|
||||
|
||||
Revision ID: 011_add_challenge_proposals
|
||||
Revises: 010_add_telegram_profile
|
||||
Create Date: 2024-12-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '011_add_challenge_proposals'
|
||||
down_revision: Union[str, None] = '010_add_telegram_profile'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
||||
op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('challenges', 'status')
|
||||
op.drop_column('challenges', 'proposed_by_id')
|
||||
32
backend/alembic/versions/012_add_user_banned.py
Normal file
32
backend/alembic/versions/012_add_user_banned.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Add user banned fields
|
||||
|
||||
Revision ID: 012_add_user_banned
|
||||
Revises: 011_add_challenge_proposals
|
||||
Create Date: 2024-12-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '012_add_user_banned'
|
||||
down_revision: Union[str, None] = '011_add_challenge_proposals'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False))
|
||||
op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True))
|
||||
op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
||||
op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('users', 'ban_reason')
|
||||
op.drop_column('users', 'banned_by_id')
|
||||
op.drop_column('users', 'banned_at')
|
||||
op.drop_column('users', 'is_banned')
|
||||
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Add admin_logs table
|
||||
|
||||
Revision ID: 013_add_admin_logs
|
||||
Revises: 012_add_user_banned
|
||||
Create Date: 2024-12-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '013_add_admin_logs'
|
||||
down_revision: Union[str, None] = '012_add_user_banned'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx['name'] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not table_exists('admin_logs'):
|
||||
op.create_table(
|
||||
'admin_logs',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('admin_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('action', sa.String(50), nullable=False),
|
||||
sa.Column('target_type', sa.String(50), nullable=False),
|
||||
sa.Column('target_id', sa.Integer(), nullable=False),
|
||||
sa.Column('details', sa.JSON(), nullable=True),
|
||||
sa.Column('ip_address', sa.String(50), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
if not index_exists('admin_logs', 'ix_admin_logs_admin_id'):
|
||||
op.create_index('ix_admin_logs_admin_id', 'admin_logs', ['admin_id'])
|
||||
if not index_exists('admin_logs', 'ix_admin_logs_action'):
|
||||
op.create_index('ix_admin_logs_action', 'admin_logs', ['action'])
|
||||
if not index_exists('admin_logs', 'ix_admin_logs_created_at'):
|
||||
op.create_index('ix_admin_logs_created_at', 'admin_logs', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_admin_logs_created_at', 'admin_logs')
|
||||
op.drop_index('ix_admin_logs_action', 'admin_logs')
|
||||
op.drop_index('ix_admin_logs_admin_id', 'admin_logs')
|
||||
op.drop_table('admin_logs')
|
||||
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Add admin_2fa_sessions table
|
||||
|
||||
Revision ID: 014_add_admin_2fa
|
||||
Revises: 013_add_admin_logs
|
||||
Create Date: 2024-12-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '014_add_admin_2fa'
|
||||
down_revision: Union[str, None] = '013_add_admin_logs'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx['name'] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not table_exists('admin_2fa_sessions'):
|
||||
op.create_table(
|
||||
'admin_2fa_sessions',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('code', sa.String(6), nullable=False),
|
||||
sa.Column('telegram_sent', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('is_verified', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_user_id'):
|
||||
op.create_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions', ['user_id'])
|
||||
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_expires_at'):
|
||||
op.create_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions', ['expires_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions')
|
||||
op.drop_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions')
|
||||
op.drop_table('admin_2fa_sessions')
|
||||
54
backend/alembic/versions/015_add_static_content.py
Normal file
54
backend/alembic/versions/015_add_static_content.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Add static_content table
|
||||
|
||||
Revision ID: 015_add_static_content
|
||||
Revises: 014_add_admin_2fa
|
||||
Create Date: 2024-12-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '015_add_static_content'
|
||||
down_revision: Union[str, None] = '014_add_admin_2fa'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx['name'] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not table_exists('static_content'):
|
||||
op.create_table(
|
||||
'static_content',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('key', sa.String(100), unique=True, nullable=False),
|
||||
sa.Column('title', sa.String(200), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('updated_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
if not index_exists('static_content', 'ix_static_content_key'):
|
||||
op.create_index('ix_static_content_key', 'static_content', ['key'], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_static_content_key', 'static_content')
|
||||
op.drop_table('static_content')
|
||||
36
backend/alembic/versions/016_add_banned_until.py
Normal file
36
backend/alembic/versions/016_add_banned_until.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Add banned_until field
|
||||
|
||||
Revision ID: 016_add_banned_until
|
||||
Revises: 015_add_static_content
|
||||
Create Date: 2024-12-19
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '016_add_banned_until'
|
||||
down_revision: Union[str, None] = '015_add_static_content'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists('users', 'banned_until'):
|
||||
op.add_column('users', sa.Column('banned_until', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists('users', 'banned_until'):
|
||||
op.drop_column('users', 'banned_until')
|
||||
32
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
32
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Make admin_id nullable in admin_logs for system actions
|
||||
|
||||
Revision ID: 017_admin_logs_nullable_admin_id
|
||||
Revises: 016_add_banned_until
|
||||
Create Date: 2024-12-19
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '017_admin_logs_nullable_admin_id'
|
||||
down_revision: Union[str, None] = '016_add_banned_until'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Make admin_id nullable for system actions (like auto-unban)
|
||||
op.alter_column('admin_logs', 'admin_id',
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert to not nullable (will fail if there are NULL values)
|
||||
op.alter_column('admin_logs', 'admin_id',
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False)
|
||||
@@ -1,13 +1,15 @@
|
||||
from typing import Annotated
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, status, Header
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
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()
|
||||
|
||||
@@ -42,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
|
||||
|
||||
|
||||
@@ -55,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,
|
||||
@@ -145,3 +206,21 @@ async def require_creator(
|
||||
# Type aliases for cleaner dependency injection
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
async def verify_bot_secret(
|
||||
x_bot_secret: str | None = Header(None, alias="X-Bot-Secret")
|
||||
) -> None:
|
||||
"""Verify that request comes from trusted bot using secret key."""
|
||||
if not settings.BOT_API_SECRET:
|
||||
# If secret is not configured, skip check (for development)
|
||||
return
|
||||
|
||||
if x_bot_secret != settings.BOT_API_SECRET:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid or missing bot secret"
|
||||
)
|
||||
|
||||
|
||||
BotSecretDep = Annotated[None, Depends(verify_bot_secret)]
|
||||
|
||||
@@ -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,16 +1,22 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
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.models import User
|
||||
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPublic
|
||||
from app.core.rate_limit import limiter
|
||||
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"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
async def register(data: UserRegister, db: DbSession):
|
||||
@limiter.limit("5/minute")
|
||||
async def register(request: Request, data: UserRegister, db: DbSession):
|
||||
# Check if login already exists
|
||||
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
||||
if result.scalar_one_or_none():
|
||||
@@ -34,12 +40,13 @@ async def register(data: UserRegister, db: DbSession):
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
user=UserPublic.model_validate(user),
|
||||
user=UserPrivate.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(data: UserLogin, db: DbSession):
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def login(request: Request, data: UserLogin, db: DbSession):
|
||||
# Find user
|
||||
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -50,15 +57,109 @@ async def login(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)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
user=UserPublic.model_validate(user),
|
||||
user=UserPrivate.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserPublic)
|
||||
@router.get("/me", response_model=UserPrivate)
|
||||
async def get_me(current_user: CurrentUser):
|
||||
return UserPublic.model_validate(current_user)
|
||||
"""Get current user's full profile (including private data)"""
|
||||
return UserPrivate.model_validate(current_user)
|
||||
|
||||
@@ -3,7 +3,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
|
||||
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge
|
||||
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge, User
|
||||
from app.models.challenge import ChallengeStatus
|
||||
from app.schemas import (
|
||||
ChallengeCreate,
|
||||
ChallengeUpdate,
|
||||
@@ -15,7 +16,9 @@ from app.schemas import (
|
||||
ChallengesSaveRequest,
|
||||
ChallengesGenerateRequest,
|
||||
)
|
||||
from app.schemas.challenge import ChallengePropose, ProposedByUser
|
||||
from app.services.gpt import gpt_service
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
router = APIRouter(tags=["challenges"])
|
||||
|
||||
@@ -23,7 +26,7 @@ router = APIRouter(tags=["challenges"])
|
||||
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.options(selectinload(Challenge.game))
|
||||
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||
.where(Challenge.id == challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one_or_none()
|
||||
@@ -32,9 +35,36 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
||||
return challenge
|
||||
|
||||
|
||||
def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeResponse:
|
||||
"""Helper to build ChallengeResponse with proposed_by"""
|
||||
proposed_by = None
|
||||
if challenge.proposed_by:
|
||||
proposed_by = ProposedByUser(
|
||||
id=challenge.proposed_by.id,
|
||||
nickname=challenge.proposed_by.nickname
|
||||
)
|
||||
|
||||
return ChallengeResponse(
|
||||
id=challenge.id,
|
||||
title=challenge.title,
|
||||
description=challenge.description,
|
||||
type=challenge.type,
|
||||
difficulty=challenge.difficulty,
|
||||
points=challenge.points,
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
status=challenge.status,
|
||||
proposed_by=proposed_by,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
|
||||
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""List challenges for a game. Participants can view challenges for approved games only."""
|
||||
"""List challenges for a game. Participants can view approved and pending challenges."""
|
||||
# Get game and check access
|
||||
result = await db.execute(
|
||||
select(Game).where(Game.id == game_id)
|
||||
@@ -54,30 +84,17 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
|
||||
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Game not accessible")
|
||||
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.where(Challenge.game_id == game_id)
|
||||
.order_by(Challenge.difficulty, Challenge.created_at)
|
||||
)
|
||||
# Get challenges with proposed_by
|
||||
query = select(Challenge).options(selectinload(Challenge.proposed_by)).where(Challenge.game_id == game_id)
|
||||
|
||||
# Regular participants see approved and pending challenges (but not rejected)
|
||||
if not current_user.is_admin and participant and not participant.is_organizer:
|
||||
query = query.where(Challenge.status.in_([ChallengeStatus.APPROVED.value, ChallengeStatus.PENDING.value]))
|
||||
|
||||
result = await db.execute(query.order_by(Challenge.status.desc(), Challenge.difficulty, Challenge.created_at))
|
||||
challenges = result.scalars().all()
|
||||
|
||||
return [
|
||||
ChallengeResponse(
|
||||
id=c.id,
|
||||
title=c.title,
|
||||
description=c.description,
|
||||
type=c.type,
|
||||
difficulty=c.difficulty,
|
||||
points=c.points,
|
||||
estimated_time=c.estimated_time,
|
||||
proof_type=c.proof_type,
|
||||
proof_hint=c.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||
is_generated=c.is_generated,
|
||||
created_at=c.created_at,
|
||||
)
|
||||
for c in challenges
|
||||
]
|
||||
return [build_challenge_response(c, game) for c in challenges]
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
||||
@@ -94,36 +111,21 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
|
||||
if not current_user.is_admin and not participant:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
# Get all challenges from approved games in this marathon
|
||||
# Get all approved challenges from approved games in this marathon
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.join(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Challenge.game))
|
||||
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.status == GameStatus.APPROVED.value,
|
||||
Challenge.status == ChallengeStatus.APPROVED.value,
|
||||
)
|
||||
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
|
||||
)
|
||||
challenges = result.scalars().all()
|
||||
|
||||
return [
|
||||
ChallengeResponse(
|
||||
id=c.id,
|
||||
title=c.title,
|
||||
description=c.description,
|
||||
type=c.type,
|
||||
difficulty=c.difficulty,
|
||||
points=c.points,
|
||||
estimated_time=c.estimated_time,
|
||||
proof_type=c.proof_type,
|
||||
proof_hint=c.proof_hint,
|
||||
game=GameShort(id=c.game.id, title=c.game.title, cover_url=None),
|
||||
is_generated=c.is_generated,
|
||||
created_at=c.created_at,
|
||||
)
|
||||
for c in challenges
|
||||
]
|
||||
return [build_challenge_response(c, c.game) for c in challenges]
|
||||
|
||||
|
||||
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
||||
@@ -166,25 +168,13 @@ async def create_challenge(
|
||||
proof_type=data.proof_type.value,
|
||||
proof_hint=data.proof_hint,
|
||||
is_generated=False,
|
||||
status=ChallengeStatus.APPROVED.value, # Organizer-created challenges are auto-approved
|
||||
)
|
||||
db.add(challenge)
|
||||
await db.commit()
|
||||
await db.refresh(challenge)
|
||||
|
||||
return ChallengeResponse(
|
||||
id=challenge.id,
|
||||
title=challenge.title,
|
||||
description=challenge.description,
|
||||
type=challenge.type,
|
||||
difficulty=challenge.difficulty,
|
||||
points=challenge.points,
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
)
|
||||
return build_challenge_response(challenge, game)
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
||||
@@ -386,26 +376,12 @@ async def update_challenge(
|
||||
await db.commit()
|
||||
await db.refresh(challenge)
|
||||
|
||||
game = challenge.game
|
||||
return ChallengeResponse(
|
||||
id=challenge.id,
|
||||
title=challenge.title,
|
||||
description=challenge.description,
|
||||
type=challenge.type,
|
||||
difficulty=challenge.difficulty,
|
||||
points=challenge.points,
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
)
|
||||
return build_challenge_response(challenge, challenge.game)
|
||||
|
||||
|
||||
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
||||
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""Delete a challenge. Organizers only."""
|
||||
"""Delete a challenge. Organizers can delete any, participants can delete their own pending."""
|
||||
challenge = await get_challenge_or_404(db, challenge_id)
|
||||
|
||||
# Check marathon is in preparing state
|
||||
@@ -414,10 +390,206 @@ async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbS
|
||||
if marathon.status != MarathonStatus.PREPARING.value:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
||||
|
||||
# Only organizers can delete challenges
|
||||
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||
participant = await get_participant(db, current_user.id, challenge.game.marathon_id)
|
||||
|
||||
# Check permissions
|
||||
if current_user.is_admin or (participant and participant.is_organizer):
|
||||
# Organizers can delete any challenge
|
||||
pass
|
||||
elif challenge.proposed_by_id == current_user.id and challenge.status == ChallengeStatus.PENDING.value:
|
||||
# Participants can delete their own pending challenges
|
||||
pass
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="You can only delete your own pending challenges")
|
||||
|
||||
await db.delete(challenge)
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Challenge deleted")
|
||||
|
||||
|
||||
# ============ Proposed challenges endpoints ============
|
||||
|
||||
@router.post("/games/{game_id}/propose-challenge", response_model=ChallengeResponse)
|
||||
async def propose_challenge(
|
||||
game_id: int,
|
||||
data: ChallengePropose,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Propose a challenge for a game. Participants only, during PREPARING phase."""
|
||||
# Get game
|
||||
result = await db.execute(select(Game).where(Game.id == game_id))
|
||||
game = result.scalar_one_or_none()
|
||||
if not game:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
|
||||
# Check marathon is in preparing state
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
||||
marathon = result.scalar_one()
|
||||
if marathon.status != MarathonStatus.PREPARING.value:
|
||||
raise HTTPException(status_code=400, detail="Cannot propose challenges to active or finished marathon")
|
||||
|
||||
# Check user is participant
|
||||
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||
if not participant and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
# Can only propose challenges to approved games
|
||||
if game.status != GameStatus.APPROVED.value:
|
||||
raise HTTPException(status_code=400, detail="Can only propose challenges to approved games")
|
||||
|
||||
challenge = Challenge(
|
||||
game_id=game_id,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
type=data.type.value,
|
||||
difficulty=data.difficulty.value,
|
||||
points=data.points,
|
||||
estimated_time=data.estimated_time,
|
||||
proof_type=data.proof_type.value,
|
||||
proof_hint=data.proof_hint,
|
||||
is_generated=False,
|
||||
proposed_by_id=current_user.id,
|
||||
status=ChallengeStatus.PENDING.value,
|
||||
)
|
||||
db.add(challenge)
|
||||
await db.commit()
|
||||
await db.refresh(challenge)
|
||||
|
||||
# Load proposed_by relationship
|
||||
challenge.proposed_by = current_user
|
||||
|
||||
return build_challenge_response(challenge, game)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/proposed-challenges", response_model=list[ChallengeResponse])
|
||||
async def list_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""List all pending proposed challenges for a marathon. Organizers only."""
|
||||
# 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")
|
||||
|
||||
# Only organizers can see all proposed challenges
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
|
||||
# Get all pending challenges from approved games
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.join(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.status == GameStatus.APPROVED.value,
|
||||
Challenge.status == ChallengeStatus.PENDING.value,
|
||||
)
|
||||
.order_by(Challenge.created_at.desc())
|
||||
)
|
||||
challenges = result.scalars().all()
|
||||
|
||||
return [build_challenge_response(c, c.game) for c in challenges]
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/my-proposed-challenges", response_model=list[ChallengeResponse])
|
||||
async def list_my_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""List current user's proposed challenges for a marathon."""
|
||||
# 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")
|
||||
|
||||
# Check user is participant
|
||||
participant = await get_participant(db, current_user.id, marathon_id)
|
||||
if not participant and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
# Get user's proposed challenges
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.join(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Challenge.proposed_by_id == current_user.id,
|
||||
)
|
||||
.order_by(Challenge.created_at.desc())
|
||||
)
|
||||
challenges = result.scalars().all()
|
||||
|
||||
return [build_challenge_response(c, c.game) for c in challenges]
|
||||
|
||||
|
||||
@router.patch("/challenges/{challenge_id}/approve", response_model=ChallengeResponse)
|
||||
async def approve_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""Approve a proposed challenge. Organizers only."""
|
||||
challenge = await get_challenge_or_404(db, challenge_id)
|
||||
|
||||
# Check marathon is in preparing state
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||
marathon = result.scalar_one()
|
||||
if marathon.status != MarathonStatus.PREPARING.value:
|
||||
raise HTTPException(status_code=400, detail="Cannot approve challenges in active or finished marathon")
|
||||
|
||||
# Only organizers can approve
|
||||
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||
|
||||
if challenge.status != ChallengeStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||
|
||||
challenge.status = ChallengeStatus.APPROVED.value
|
||||
await db.commit()
|
||||
await db.refresh(challenge)
|
||||
|
||||
# Send Telegram notification to proposer
|
||||
if challenge.proposed_by_id:
|
||||
await telegram_notifier.notify_challenge_approved(
|
||||
db,
|
||||
challenge.proposed_by_id,
|
||||
marathon.title,
|
||||
challenge.game.title,
|
||||
challenge.title
|
||||
)
|
||||
|
||||
return build_challenge_response(challenge, challenge.game)
|
||||
|
||||
|
||||
@router.patch("/challenges/{challenge_id}/reject", response_model=ChallengeResponse)
|
||||
async def reject_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""Reject a proposed challenge. Organizers only."""
|
||||
challenge = await get_challenge_or_404(db, challenge_id)
|
||||
|
||||
# Check marathon is in preparing state
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||
marathon = result.scalar_one()
|
||||
if marathon.status != MarathonStatus.PREPARING.value:
|
||||
raise HTTPException(status_code=400, detail="Cannot reject challenges in active or finished marathon")
|
||||
|
||||
# Only organizers can reject
|
||||
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||
|
||||
if challenge.status != ChallengeStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||
|
||||
# Save info for notification before changing status
|
||||
proposer_id = challenge.proposed_by_id
|
||||
game_title = challenge.game.title
|
||||
challenge_title = challenge.title
|
||||
|
||||
challenge.status = ChallengeStatus.REJECTED.value
|
||||
await db.commit()
|
||||
await db.refresh(challenge)
|
||||
|
||||
# Send Telegram notification to proposer
|
||||
if proposer_id:
|
||||
await telegram_notifier.notify_challenge_rejected(
|
||||
db,
|
||||
proposer_id,
|
||||
marathon.title,
|
||||
game_title,
|
||||
challenge_title
|
||||
)
|
||||
|
||||
return build_challenge_response(challenge, challenge.game)
|
||||
|
||||
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
|
||||
@@ -1,7 +1,8 @@
|
||||
from datetime import timedelta
|
||||
import secrets
|
||||
import string
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -10,6 +11,10 @@ from app.api.deps import (
|
||||
require_participant, require_organizer, require_creator,
|
||||
get_participant,
|
||||
)
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
# Optional auth for endpoints that need it conditionally
|
||||
optional_auth = HTTPBearer(auto_error=False)
|
||||
from app.models import (
|
||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||
@@ -188,6 +193,15 @@ async def create_marathon(
|
||||
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
|
||||
# For private marathons, require participation (or admin/creator)
|
||||
if not marathon.is_public and not current_user.is_admin:
|
||||
participation = await get_participation(db, current_user.id, marathon_id)
|
||||
if not participation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You are not a participant of this private marathon",
|
||||
)
|
||||
|
||||
# Count participants and approved games
|
||||
participants_count = await db.scalar(
|
||||
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
|
||||
@@ -428,7 +442,16 @@ async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db:
|
||||
|
||||
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
|
||||
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
|
||||
# For private marathons, require participation (or admin)
|
||||
if not marathon.is_public and not current_user.is_admin:
|
||||
participation = await get_participation(db, current_user.id, marathon_id)
|
||||
if not participation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You are not a participant of this private marathon",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
@@ -497,8 +520,42 @@ async def set_participant_role(
|
||||
|
||||
|
||||
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
|
||||
async def get_leaderboard(marathon_id: int, db: DbSession):
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
async def get_leaderboard(
|
||||
marathon_id: int,
|
||||
db: DbSession,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_auth),
|
||||
):
|
||||
"""
|
||||
Get marathon leaderboard.
|
||||
Public marathons: no auth required.
|
||||
Private marathons: requires auth + participation check.
|
||||
"""
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
|
||||
# For private marathons, require authentication and participation
|
||||
if not marathon.is_public:
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required for private marathon leaderboard",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id = int(payload.get("sub"))
|
||||
participant = await get_participant(db, user_id, marathon_id)
|
||||
if not participant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You are not a participant of this marathon",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
|
||||
@@ -5,7 +5,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.api.deps import DbSession, CurrentUser, BotSecretDep
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_telegram_link_token, verify_telegram_link_token
|
||||
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
|
||||
@@ -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}")
|
||||
|
||||
@@ -94,7 +94,7 @@ async def generate_link_token(current_user: CurrentUser):
|
||||
|
||||
|
||||
@router.post("/confirm-link", response_model=TelegramLinkResponse)
|
||||
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
|
||||
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession, _: BotSecretDep):
|
||||
"""Confirm Telegram account linking (called by bot)."""
|
||||
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
|
||||
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
|
||||
@@ -145,7 +145,7 @@ async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
|
||||
|
||||
|
||||
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
|
||||
async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
|
||||
async def get_user_by_telegram_id(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||
"""Get user by Telegram ID."""
|
||||
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
|
||||
|
||||
@@ -168,7 +168,7 @@ async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
|
||||
|
||||
|
||||
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
|
||||
async def unlink_telegram(telegram_id: int, db: DbSession):
|
||||
async def unlink_telegram(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||
"""Unlink Telegram account."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
@@ -187,7 +187,7 @@ async def unlink_telegram(telegram_id: int, db: DbSession):
|
||||
|
||||
|
||||
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
|
||||
async def get_user_marathons(telegram_id: int, db: DbSession):
|
||||
async def get_user_marathons(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||
"""Get user's marathons by Telegram ID."""
|
||||
# Get user
|
||||
result = await db.execute(
|
||||
@@ -231,7 +231,7 @@ async def get_user_marathons(telegram_id: int, db: DbSession):
|
||||
|
||||
|
||||
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
|
||||
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession):
|
||||
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||
"""Get marathon details for user by Telegram ID."""
|
||||
# Get user
|
||||
result = await db.execute(
|
||||
@@ -341,7 +341,7 @@ async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession
|
||||
|
||||
|
||||
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
|
||||
async def get_user_stats(telegram_id: int, db: DbSession):
|
||||
async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||
"""Get user's overall statistics by Telegram ID."""
|
||||
# Get user
|
||||
result = await db.execute(
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.models import User, Participant, Assignment, Marathon
|
||||
from app.models.assignment import AssignmentStatus
|
||||
from app.models.marathon import MarathonStatus
|
||||
from app.schemas import (
|
||||
UserPublic, UserUpdate, TelegramLink, MessageResponse,
|
||||
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
|
||||
PasswordChange, UserStats, UserProfilePublic,
|
||||
)
|
||||
from app.services.storage import storage_service
|
||||
@@ -17,7 +17,8 @@ router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserPublic)
|
||||
async def get_user(user_id: int, db: DbSession):
|
||||
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||
"""Get user profile. Requires authentication."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@@ -58,23 +59,25 @@ async def get_user_avatar(user_id: int, db: DbSession):
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserPublic)
|
||||
@router.patch("/me", response_model=UserPrivate)
|
||||
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
|
||||
"""Update current user's profile"""
|
||||
if data.nickname is not None:
|
||||
current_user.nickname = data.nickname
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return UserPublic.model_validate(current_user)
|
||||
return UserPrivate.model_validate(current_user)
|
||||
|
||||
|
||||
@router.post("/me/avatar", response_model=UserPublic)
|
||||
@router.post("/me/avatar", response_model=UserPrivate)
|
||||
async def upload_avatar(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""Upload current user's avatar"""
|
||||
# Validate file
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(
|
||||
@@ -115,7 +118,7 @@ async def upload_avatar(
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return UserPublic.model_validate(current_user)
|
||||
return UserPrivate.model_validate(current_user)
|
||||
|
||||
|
||||
@router.post("/me/telegram", response_model=MessageResponse)
|
||||
@@ -193,8 +196,8 @@ async def get_my_stats(current_user: CurrentUser, db: DbSession):
|
||||
|
||||
|
||||
@router.get("/{user_id}/stats", response_model=UserStats)
|
||||
async def get_user_stats(user_id: int, db: DbSession):
|
||||
"""Получить статистику пользователя"""
|
||||
async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||
"""Получить статистику пользователя. Requires authentication."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
@@ -207,8 +210,8 @@ async def get_user_stats(user_id: int, db: DbSession):
|
||||
|
||||
|
||||
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
|
||||
async def get_user_profile(user_id: int, db: DbSession):
|
||||
"""Получить публичный профиль пользователя со статистикой"""
|
||||
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||
"""Получить публичный профиль пользователя со статистикой. Requires authentication."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class Settings(BaseSettings):
|
||||
TELEGRAM_BOT_TOKEN: str = ""
|
||||
TELEGRAM_BOT_USERNAME: str = ""
|
||||
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
|
||||
BOT_API_SECRET: str = "" # Secret key for bot-to-backend communication
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL: str = "http://localhost:3000"
|
||||
|
||||
5
backend/app/core/rate_limit.py
Normal file
5
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
# Rate limiter using client IP address as key
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
@@ -1,7 +1,10 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -14,6 +17,7 @@ from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import engine, Base, async_session_maker
|
||||
from app.core.rate_limit import limiter
|
||||
from app.api.v1 import router as api_router
|
||||
from app.services.event_scheduler import event_scheduler
|
||||
from app.services.dispute_scheduler import dispute_scheduler
|
||||
@@ -49,6 +53,10 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Rate limiting
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -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])
|
||||
@@ -29,6 +29,12 @@ class ProofType(str, Enum):
|
||||
STEAM = "steam"
|
||||
|
||||
|
||||
class ChallengeStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class Challenge(Base):
|
||||
__tablename__ = "challenges"
|
||||
|
||||
@@ -45,8 +51,13 @@ class Challenge(Base):
|
||||
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Proposed challenges support
|
||||
proposed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="approved") # pending, approved, rejected
|
||||
|
||||
# Relationships
|
||||
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
||||
proposed_by: Mapped["User"] = relationship("User", foreign_keys=[proposed_by_id])
|
||||
assignments: Mapped[list["Assignment"]] = relationship(
|
||||
"Assignment",
|
||||
back_populates="challenge"
|
||||
|
||||
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:
|
||||
|
||||
@@ -3,7 +3,7 @@ from app.schemas.user import (
|
||||
UserLogin,
|
||||
UserUpdate,
|
||||
UserPublic,
|
||||
UserWithTelegram,
|
||||
UserPrivate,
|
||||
TokenResponse,
|
||||
TelegramLink,
|
||||
PasswordChange,
|
||||
@@ -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
|
||||
@@ -88,7 +104,7 @@ __all__ = [
|
||||
"UserLogin",
|
||||
"UserUpdate",
|
||||
"UserPublic",
|
||||
"UserWithTelegram",
|
||||
"UserPrivate",
|
||||
"TokenResponse",
|
||||
"TelegramLink",
|
||||
"PasswordChange",
|
||||
@@ -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] = []
|
||||
@@ -1,10 +1,19 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.challenge import ChallengeType, Difficulty, ProofType
|
||||
from app.models.challenge import ChallengeType, Difficulty, ProofType, ChallengeStatus
|
||||
from app.schemas.game import GameShort
|
||||
|
||||
|
||||
class ProposedByUser(BaseModel):
|
||||
"""Minimal user info for proposed challenges"""
|
||||
id: int
|
||||
nickname: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ChallengeBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=100)
|
||||
description: str = Field(..., min_length=1)
|
||||
@@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase):
|
||||
game: GameShort
|
||||
is_generated: bool
|
||||
created_at: datetime
|
||||
status: str = "approved"
|
||||
proposed_by: ProposedByUser | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ChallengePropose(ChallengeBase):
|
||||
"""Schema for proposing a challenge by a participant"""
|
||||
pass
|
||||
|
||||
|
||||
class ChallengeGenerated(BaseModel):
|
||||
"""Schema for GPT-generated challenges"""
|
||||
title: str
|
||||
|
||||
@@ -29,30 +29,30 @@ class UserUpdate(BaseModel):
|
||||
|
||||
|
||||
class UserPublic(UserBase):
|
||||
"""Public user info visible to other users - minimal data"""
|
||||
id: int
|
||||
login: str
|
||||
avatar_url: str | None = None
|
||||
role: str = "user"
|
||||
telegram_id: int | None = None
|
||||
telegram_username: str | None = None
|
||||
telegram_first_name: str | None = None
|
||||
telegram_last_name: str | None = None
|
||||
telegram_avatar_url: str | None = None
|
||||
telegram_avatar_url: str | None = None # Only TG avatar is public
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserWithTelegram(UserPublic):
|
||||
class UserPrivate(UserPublic):
|
||||
"""Full user info visible only to the user themselves"""
|
||||
login: str
|
||||
telegram_id: int | None = None
|
||||
telegram_username: str | None = None
|
||||
telegram_first_name: str | None = None
|
||||
telegram_last_name: str | None = None
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserPublic
|
||||
user: UserPrivate
|
||||
|
||||
|
||||
class TelegramLink(BaseModel):
|
||||
|
||||
@@ -276,6 +276,42 @@ class TelegramNotifier:
|
||||
)
|
||||
return await self.notify_user(db, user_id, message)
|
||||
|
||||
async def notify_challenge_approved(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
marathon_title: str,
|
||||
game_title: str,
|
||||
challenge_title: str
|
||||
) -> bool:
|
||||
"""Notify user that their proposed challenge was approved."""
|
||||
message = (
|
||||
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
|
||||
f"Марафон: {marathon_title}\n"
|
||||
f"Игра: {game_title}\n"
|
||||
f"Задание: {challenge_title}\n\n"
|
||||
f"Теперь оно доступно для всех участников."
|
||||
)
|
||||
return await self.notify_user(db, user_id, message)
|
||||
|
||||
async def notify_challenge_rejected(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
marathon_title: str,
|
||||
game_title: str,
|
||||
challenge_title: str
|
||||
) -> bool:
|
||||
"""Notify user that their proposed challenge was rejected."""
|
||||
message = (
|
||||
f"❌ <b>Твой челлендж отклонён</b>\n\n"
|
||||
f"Марафон: {marathon_title}\n"
|
||||
f"Игра: {game_title}\n"
|
||||
f"Задание: {challenge_title}\n\n"
|
||||
f"Ты можешь предложить другой челлендж."
|
||||
)
|
||||
return await self.notify_user(db, user_id, message)
|
||||
|
||||
|
||||
# Global instance
|
||||
telegram_notifier = TelegramNotifier()
|
||||
|
||||
@@ -31,5 +31,8 @@ python-magic==0.4.27
|
||||
# S3 Storage
|
||||
boto3==1.34.0
|
||||
|
||||
# Rate limiting
|
||||
slowapi==0.1.9
|
||||
|
||||
# Utils
|
||||
python-dotenv==1.0.0
|
||||
|
||||
30
backup-service/Dockerfile
Normal file
30
backup-service/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install PostgreSQL client (for pg_dump and psql) and cron
|
||||
RUN apt-get update && apt-get install -y \
|
||||
postgresql-client \
|
||||
cron \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Make scripts executable
|
||||
RUN chmod +x backup.py restore.py
|
||||
|
||||
# Setup cron
|
||||
COPY crontab /etc/cron.d/backup-cron
|
||||
RUN chmod 0644 /etc/cron.d/backup-cron
|
||||
RUN crontab /etc/cron.d/backup-cron
|
||||
|
||||
# Create log file
|
||||
RUN touch /var/log/cron.log
|
||||
|
||||
# Start cron in foreground and tail logs
|
||||
CMD ["sh", "-c", "printenv > /etc/environment && cron && tail -f /var/log/cron.log"]
|
||||
217
backup-service/backup.py
Normal file
217
backup-service/backup.py
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PostgreSQL Backup Service for WebApp.
|
||||
|
||||
- Creates pg_dump backup
|
||||
- Compresses with gzip
|
||||
- Uploads to S3 FirstVDS
|
||||
- Rotates old backups (configurable retention)
|
||||
- Sends Telegram notifications
|
||||
"""
|
||||
import gzip
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import boto3
|
||||
import httpx
|
||||
from botocore.config import Config as BotoConfig
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from config import config
|
||||
|
||||
|
||||
def create_s3_client():
|
||||
"""Initialize S3 client (same pattern as backend storage.py)."""
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=config.S3_ENDPOINT_URL,
|
||||
aws_access_key_id=config.S3_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
|
||||
region_name=config.S3_REGION or "us-east-1",
|
||||
config=BotoConfig(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
|
||||
def send_telegram_notification(message: str, is_error: bool = False) -> None:
|
||||
"""Send notification to Telegram admin."""
|
||||
if not config.TELEGRAM_BOT_TOKEN or not config.TELEGRAM_ADMIN_ID:
|
||||
print("Telegram not configured, skipping notification")
|
||||
return
|
||||
|
||||
emoji = "\u274c" if is_error else "\u2705"
|
||||
text = f"{emoji} *Database Backup*\n\n{message}"
|
||||
|
||||
url = f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
data = {
|
||||
"chat_id": config.TELEGRAM_ADMIN_ID,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
}
|
||||
|
||||
try:
|
||||
response = httpx.post(url, json=data, timeout=30)
|
||||
response.raise_for_status()
|
||||
print("Telegram notification sent")
|
||||
except Exception as e:
|
||||
print(f"Failed to send Telegram notification: {e}")
|
||||
|
||||
|
||||
def create_backup() -> tuple[str, bytes]:
|
||||
"""Create pg_dump backup and compress it."""
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"marathon_backup_{timestamp}.sql.gz"
|
||||
|
||||
# Build pg_dump command
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = config.DB_PASSWORD
|
||||
|
||||
cmd = [
|
||||
"pg_dump",
|
||||
"-h",
|
||||
config.DB_HOST,
|
||||
"-p",
|
||||
config.DB_PORT,
|
||||
"-U",
|
||||
config.DB_USER,
|
||||
"-d",
|
||||
config.DB_NAME,
|
||||
"--no-owner",
|
||||
"--no-acl",
|
||||
"-F",
|
||||
"p", # plain SQL format
|
||||
]
|
||||
|
||||
print(f"Running pg_dump for database {config.DB_NAME}...")
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"pg_dump failed: {result.stderr.decode()}")
|
||||
|
||||
# Compress the output
|
||||
print("Compressing backup...")
|
||||
compressed = gzip.compress(result.stdout, compresslevel=9)
|
||||
|
||||
return filename, compressed
|
||||
|
||||
|
||||
def upload_to_s3(s3_client, filename: str, data: bytes) -> str:
|
||||
"""Upload backup to S3."""
|
||||
key = f"{config.S3_BACKUP_PREFIX}{filename}"
|
||||
|
||||
print(f"Uploading to S3: {key}...")
|
||||
s3_client.put_object(
|
||||
Bucket=config.S3_BUCKET_NAME,
|
||||
Key=key,
|
||||
Body=data,
|
||||
ContentType="application/gzip",
|
||||
)
|
||||
|
||||
return key
|
||||
|
||||
|
||||
def rotate_old_backups(s3_client) -> int:
|
||||
"""Delete backups older than BACKUP_RETENTION_DAYS."""
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(
|
||||
days=config.BACKUP_RETENTION_DAYS
|
||||
)
|
||||
deleted_count = 0
|
||||
|
||||
print(f"Rotating backups older than {config.BACKUP_RETENTION_DAYS} days...")
|
||||
|
||||
# List all objects with backup prefix
|
||||
try:
|
||||
paginator = s3_client.get_paginator("list_objects_v2")
|
||||
pages = paginator.paginate(
|
||||
Bucket=config.S3_BUCKET_NAME,
|
||||
Prefix=config.S3_BACKUP_PREFIX,
|
||||
)
|
||||
|
||||
for page in pages:
|
||||
for obj in page.get("Contents", []):
|
||||
last_modified = obj["LastModified"]
|
||||
if last_modified.tzinfo is None:
|
||||
last_modified = last_modified.replace(tzinfo=timezone.utc)
|
||||
|
||||
if last_modified < cutoff_date:
|
||||
s3_client.delete_object(
|
||||
Bucket=config.S3_BUCKET_NAME,
|
||||
Key=obj["Key"],
|
||||
)
|
||||
deleted_count += 1
|
||||
print(f"Deleted old backup: {obj['Key']}")
|
||||
except ClientError as e:
|
||||
print(f"Error during rotation: {e}")
|
||||
|
||||
return deleted_count
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main backup routine."""
|
||||
start_time = datetime.now()
|
||||
|
||||
print(f"{'=' * 50}")
|
||||
print(f"Backup started at {start_time}")
|
||||
print(f"{'=' * 50}")
|
||||
|
||||
try:
|
||||
# Validate configuration
|
||||
if not config.S3_BUCKET_NAME:
|
||||
raise Exception("S3_BUCKET_NAME is not configured")
|
||||
if not config.S3_ACCESS_KEY_ID:
|
||||
raise Exception("S3_ACCESS_KEY_ID is not configured")
|
||||
if not config.S3_SECRET_ACCESS_KEY:
|
||||
raise Exception("S3_SECRET_ACCESS_KEY is not configured")
|
||||
if not config.S3_ENDPOINT_URL:
|
||||
raise Exception("S3_ENDPOINT_URL is not configured")
|
||||
|
||||
# Create S3 client
|
||||
s3_client = create_s3_client()
|
||||
|
||||
# Create backup
|
||||
filename, data = create_backup()
|
||||
size_mb = len(data) / (1024 * 1024)
|
||||
print(f"Backup created: {filename} ({size_mb:.2f} MB)")
|
||||
|
||||
# Upload to S3
|
||||
s3_key = upload_to_s3(s3_client, filename, data)
|
||||
print(f"Uploaded to S3: {s3_key}")
|
||||
|
||||
# Rotate old backups
|
||||
deleted_count = rotate_old_backups(s3_client)
|
||||
print(f"Deleted {deleted_count} old backups")
|
||||
|
||||
# Calculate duration
|
||||
duration = datetime.now() - start_time
|
||||
|
||||
# Send success notification
|
||||
message = (
|
||||
f"Backup completed successfully!\n\n"
|
||||
f"*File:* `{filename}`\n"
|
||||
f"*Size:* {size_mb:.2f} MB\n"
|
||||
f"*Duration:* {duration.seconds}s\n"
|
||||
f"*Deleted old:* {deleted_count} files"
|
||||
)
|
||||
send_telegram_notification(message, is_error=False)
|
||||
|
||||
print(f"{'=' * 50}")
|
||||
print("Backup completed successfully!")
|
||||
print(f"{'=' * 50}")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Backup failed!\n\n*Error:* `{str(e)}`"
|
||||
send_telegram_notification(error_msg, is_error=True)
|
||||
print(f"{'=' * 50}")
|
||||
print(f"Backup failed: {e}")
|
||||
print(f"{'=' * 50}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
33
backup-service/config.py
Normal file
33
backup-service/config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Configuration for backup service."""
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Backup service configuration from environment variables."""
|
||||
|
||||
# Database
|
||||
DB_HOST: str = os.getenv("DB_HOST", "db")
|
||||
DB_PORT: str = os.getenv("DB_PORT", "5432")
|
||||
DB_NAME: str = os.getenv("DB_NAME", "marathon")
|
||||
DB_USER: str = os.getenv("DB_USER", "marathon")
|
||||
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "123")
|
||||
|
||||
# S3
|
||||
S3_BUCKET_NAME: str = os.getenv("S3_BUCKET_NAME", "")
|
||||
S3_REGION: str = os.getenv("S3_REGION", "ru-1")
|
||||
S3_ACCESS_KEY_ID: str = os.getenv("S3_ACCESS_KEY_ID", "")
|
||||
S3_SECRET_ACCESS_KEY: str = os.getenv("S3_SECRET_ACCESS_KEY", "")
|
||||
S3_ENDPOINT_URL: str = os.getenv("S3_ENDPOINT_URL", "")
|
||||
S3_BACKUP_PREFIX: str = os.getenv("S3_BACKUP_PREFIX", "backups/")
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_ADMIN_ID: str = os.getenv("TELEGRAM_ADMIN_ID", "947392854")
|
||||
|
||||
# Backup settings
|
||||
BACKUP_RETENTION_DAYS: int = int(os.getenv("BACKUP_RETENTION_DAYS", "14"))
|
||||
|
||||
|
||||
config = Config()
|
||||
4
backup-service/crontab
Normal file
4
backup-service/crontab
Normal file
@@ -0,0 +1,4 @@
|
||||
# Backup cron job
|
||||
# Run backup daily at 3:00 AM UTC
|
||||
0 3 * * * /usr/local/bin/python /app/backup.py >> /var/log/cron.log 2>&1
|
||||
# Empty line required at end of crontab
|
||||
2
backup-service/requirements.txt
Normal file
2
backup-service/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
boto3==1.34.0
|
||||
httpx==0.26.0
|
||||
158
backup-service/restore.py
Normal file
158
backup-service/restore.py
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Restore PostgreSQL database from S3 backup.
|
||||
|
||||
Usage:
|
||||
python restore.py - List available backups
|
||||
python restore.py <filename> - Restore from specific backup
|
||||
"""
|
||||
import gzip
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as BotoConfig
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from config import config
|
||||
|
||||
|
||||
def create_s3_client():
|
||||
"""Initialize S3 client."""
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=config.S3_ENDPOINT_URL,
|
||||
aws_access_key_id=config.S3_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
|
||||
region_name=config.S3_REGION or "us-east-1",
|
||||
config=BotoConfig(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
|
||||
def list_backups(s3_client) -> list[tuple[str, float, str]]:
|
||||
"""List all available backups."""
|
||||
print("Available backups:\n")
|
||||
|
||||
try:
|
||||
paginator = s3_client.get_paginator("list_objects_v2")
|
||||
pages = paginator.paginate(
|
||||
Bucket=config.S3_BUCKET_NAME,
|
||||
Prefix=config.S3_BACKUP_PREFIX,
|
||||
)
|
||||
|
||||
backups = []
|
||||
for page in pages:
|
||||
for obj in page.get("Contents", []):
|
||||
filename = obj["Key"].replace(config.S3_BACKUP_PREFIX, "")
|
||||
size_mb = obj["Size"] / (1024 * 1024)
|
||||
modified = obj["LastModified"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
backups.append((filename, size_mb, modified))
|
||||
|
||||
# Sort by date descending (newest first)
|
||||
backups.sort(key=lambda x: x[2], reverse=True)
|
||||
|
||||
for filename, size_mb, modified in backups:
|
||||
print(f" {filename} ({size_mb:.2f} MB) - {modified}")
|
||||
|
||||
return backups
|
||||
|
||||
except ClientError as e:
|
||||
print(f"Error listing backups: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def restore_backup(s3_client, filename: str) -> None:
|
||||
"""Download and restore backup."""
|
||||
key = f"{config.S3_BACKUP_PREFIX}{filename}"
|
||||
|
||||
print(f"Downloading {filename} from S3...")
|
||||
try:
|
||||
response = s3_client.get_object(
|
||||
Bucket=config.S3_BUCKET_NAME,
|
||||
Key=key,
|
||||
)
|
||||
compressed_data = response["Body"].read()
|
||||
except ClientError as e:
|
||||
raise Exception(f"Failed to download backup: {e}")
|
||||
|
||||
print("Decompressing...")
|
||||
sql_data = gzip.decompress(compressed_data)
|
||||
|
||||
print(f"Restoring to database {config.DB_NAME}...")
|
||||
|
||||
# Build psql command
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = config.DB_PASSWORD
|
||||
|
||||
cmd = [
|
||||
"psql",
|
||||
"-h",
|
||||
config.DB_HOST,
|
||||
"-p",
|
||||
config.DB_PORT,
|
||||
"-U",
|
||||
config.DB_USER,
|
||||
"-d",
|
||||
config.DB_NAME,
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
env=env,
|
||||
input=sql_data,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode()
|
||||
# psql may return warnings that aren't fatal errors
|
||||
if "ERROR" in stderr:
|
||||
raise Exception(f"psql restore failed: {stderr}")
|
||||
else:
|
||||
print(f"Warnings: {stderr}")
|
||||
|
||||
print("Restore completed successfully!")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main restore routine."""
|
||||
# Validate configuration
|
||||
if not config.S3_BUCKET_NAME:
|
||||
print("Error: S3_BUCKET_NAME is not configured")
|
||||
return 1
|
||||
|
||||
s3_client = create_s3_client()
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
# List available backups
|
||||
backups = list_backups(s3_client)
|
||||
if backups:
|
||||
print(f"\nTo restore, run: python restore.py <filename>")
|
||||
else:
|
||||
print("No backups found.")
|
||||
return 0
|
||||
|
||||
filename = sys.argv[1]
|
||||
|
||||
# Confirm restore
|
||||
print(f"WARNING: This will restore database from {filename}")
|
||||
print("This may overwrite existing data!")
|
||||
print()
|
||||
|
||||
confirm = input("Type 'yes' to continue: ")
|
||||
|
||||
if confirm.lower() != "yes":
|
||||
print("Restore cancelled.")
|
||||
return 0
|
||||
|
||||
try:
|
||||
restore_backup(s3_client, filename)
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Restore failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -5,6 +5,7 @@ class Settings(BaseSettings):
|
||||
TELEGRAM_BOT_TOKEN: str
|
||||
API_URL: str = "http://backend:8000"
|
||||
BOT_USERNAME: str = "" # Will be set dynamically on startup
|
||||
BOT_API_SECRET: str = "" # Secret for backend API communication
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
@@ -32,6 +32,11 @@ class APIClient:
|
||||
session = await self._get_session()
|
||||
url = f"{self.base_url}/api/v1{endpoint}"
|
||||
|
||||
# Add bot secret header for authentication
|
||||
headers = kwargs.pop("headers", {})
|
||||
if settings.BOT_API_SECRET:
|
||||
headers["X-Bot-Secret"] = settings.BOT_API_SECRET
|
||||
|
||||
logger.info(f"[APIClient] {method} {url}")
|
||||
if 'json' in kwargs:
|
||||
logger.info(f"[APIClient] Request body: {kwargs['json']}")
|
||||
@@ -39,7 +44,7 @@ class APIClient:
|
||||
logger.info(f"[APIClient] Request params: {kwargs['params']}")
|
||||
|
||||
try:
|
||||
async with session.request(method, url, **kwargs) as response:
|
||||
async with session.request(method, url, headers=headers, **kwargs) as response:
|
||||
logger.info(f"[APIClient] Response status: {response.status}")
|
||||
response_text = await response.text()
|
||||
logger.info(f"[APIClient] Response body: {response_text[:500]}")
|
||||
|
||||
@@ -27,7 +27,8 @@ services:
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
|
||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
|
||||
BOT_API_SECRET: ${BOT_API_SECRET:-}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
# S3 Storage
|
||||
S3_ENABLED: ${S3_ENABLED:-false}
|
||||
@@ -81,6 +82,7 @@ services:
|
||||
environment:
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- API_URL=http://backend:8000
|
||||
- BOT_API_SECRET=${BOT_API_SECRET:-}
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
@@ -94,7 +96,13 @@ services:
|
||||
BACKEND_URL: http://backend:8000
|
||||
FRONTEND_URL: http://frontend:80
|
||||
BOT_URL: http://bot:8080
|
||||
EXTERNAL_URL: ${EXTERNAL_URL:-}
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
CHECK_INTERVAL: "30"
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
|
||||
volumes:
|
||||
- status_data:/app/data
|
||||
ports:
|
||||
- "8001:8001"
|
||||
depends_on:
|
||||
@@ -103,5 +111,31 @@ services:
|
||||
- bot
|
||||
restart: unless-stopped
|
||||
|
||||
backup:
|
||||
build:
|
||||
context: ./backup-service
|
||||
dockerfile: Dockerfile
|
||||
container_name: marathon-backup
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: marathon
|
||||
DB_USER: marathon
|
||||
DB_PASSWORD: ${DB_PASSWORD:-marathon}
|
||||
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
|
||||
S3_REGION: ${S3_REGION:-ru-1}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
|
||||
S3_BACKUP_PREFIX: ${S3_BACKUP_PREFIX:-backups/}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
|
||||
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
status_data:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
||||
import { BannedScreen } from '@/components/BannedScreen'
|
||||
|
||||
// Layout
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
@@ -23,6 +24,17 @@ import { NotFoundPage } from '@/pages/NotFoundPage'
|
||||
import { TeapotPage } from '@/pages/TeapotPage'
|
||||
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
||||
|
||||
// Admin Pages
|
||||
import {
|
||||
AdminLayout,
|
||||
AdminDashboardPage,
|
||||
AdminUsersPage,
|
||||
AdminMarathonsPage,
|
||||
AdminLogsPage,
|
||||
AdminBroadcastPage,
|
||||
AdminContentPage,
|
||||
} from '@/pages/admin'
|
||||
|
||||
// Protected route wrapper
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
@@ -46,6 +58,19 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const banInfo = useAuthStore((state) => state.banInfo)
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
|
||||
// Show banned screen if user is authenticated and banned
|
||||
if (isAuthenticated && banInfo) {
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
<BannedScreen banInfo={banInfo} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
@@ -159,6 +184,23 @@ function App() {
|
||||
<Route path="500" element={<ServerErrorPage />} />
|
||||
<Route path="error" element={<ServerErrorPage />} />
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||
<Route path="content" element={<AdminContentPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 404 - must be last */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import client from './client'
|
||||
import type { AdminUser, AdminMarathon, UserRole, PlatformStats } from '@/types'
|
||||
import type {
|
||||
AdminUser,
|
||||
AdminMarathon,
|
||||
UserRole,
|
||||
PlatformStats,
|
||||
AdminLogsResponse,
|
||||
BroadcastResponse,
|
||||
StaticContent,
|
||||
DashboardStats
|
||||
} from '@/types'
|
||||
|
||||
export const adminApi = {
|
||||
// Dashboard
|
||||
getDashboard: async (): Promise<DashboardStats> => {
|
||||
const response = await client.get<DashboardStats>('/admin/dashboard')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Users
|
||||
listUsers: async (skip = 0, limit = 50, search?: string): Promise<AdminUser[]> => {
|
||||
const params: Record<string, unknown> = { skip, limit }
|
||||
listUsers: async (skip = 0, limit = 50, search?: string, bannedOnly = false): Promise<AdminUser[]> => {
|
||||
const params: Record<string, unknown> = { skip, limit, banned_only: bannedOnly }
|
||||
if (search) params.search = search
|
||||
const response = await client.get<AdminUser[]>('/admin/users', { params })
|
||||
return response.data
|
||||
@@ -24,6 +39,19 @@ export const adminApi = {
|
||||
await client.delete(`/admin/users/${id}`)
|
||||
},
|
||||
|
||||
banUser: async (id: number, reason: string, bannedUntil?: string): Promise<AdminUser> => {
|
||||
const response = await client.post<AdminUser>(`/admin/users/${id}/ban`, {
|
||||
reason,
|
||||
banned_until: bannedUntil || null,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
unbanUser: async (id: number): Promise<AdminUser> => {
|
||||
const response = await client.post<AdminUser>(`/admin/users/${id}/unban`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Marathons
|
||||
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
||||
const params: Record<string, unknown> = { skip, limit }
|
||||
@@ -36,9 +64,62 @@ export const adminApi = {
|
||||
await client.delete(`/admin/marathons/${id}`)
|
||||
},
|
||||
|
||||
forceFinishMarathon: async (id: number): Promise<void> => {
|
||||
await client.post(`/admin/marathons/${id}/force-finish`)
|
||||
},
|
||||
|
||||
// Stats
|
||||
getStats: async (): Promise<PlatformStats> => {
|
||||
const response = await client.get<PlatformStats>('/admin/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Logs
|
||||
getLogs: async (skip = 0, limit = 50, action?: string, adminId?: number): Promise<AdminLogsResponse> => {
|
||||
const params: Record<string, unknown> = { skip, limit }
|
||||
if (action) params.action = action
|
||||
if (adminId) params.admin_id = adminId
|
||||
const response = await client.get<AdminLogsResponse>('/admin/logs', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Broadcast
|
||||
broadcastToAll: async (message: string): Promise<BroadcastResponse> => {
|
||||
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message })
|
||||
return response.data
|
||||
},
|
||||
|
||||
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => {
|
||||
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Static Content
|
||||
listContent: async (): Promise<StaticContent[]> => {
|
||||
const response = await client.get<StaticContent[]>('/admin/content')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getContent: async (key: string): Promise<StaticContent> => {
|
||||
const response = await client.get<StaticContent>(`/admin/content/${key}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||
const response = await client.put<StaticContent>(`/admin/content/${key}`, { title, content })
|
||||
return response.data
|
||||
},
|
||||
|
||||
createContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// Public content API (no auth required)
|
||||
export const contentApi = {
|
||||
getPublicContent: async (key: string): Promise<StaticContent> => {
|
||||
const response = await client.get<StaticContent>(`/content/${key}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import client from './client'
|
||||
import type { TokenResponse, User } from '@/types'
|
||||
import type { TokenResponse, LoginResponse, User } from '@/types'
|
||||
|
||||
export interface RegisterData {
|
||||
login: string
|
||||
@@ -18,8 +18,15 @@ export const authApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
login: async (data: LoginData): Promise<TokenResponse> => {
|
||||
const response = await client.post<TokenResponse>('/auth/login', data)
|
||||
login: async (data: LoginData): Promise<LoginResponse> => {
|
||||
const response = await client.post<LoginResponse>('/auth/login', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
verify2FA: async (sessionId: number, code: string): Promise<TokenResponse> => {
|
||||
const response = await client.post<TokenResponse>('/auth/2fa/verify', null, {
|
||||
params: { session_id: sessionId, code }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { useAuthStore, type BanInfo } from '@/store/auth'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
@@ -18,10 +19,20 @@ client.interceptors.request.use((config) => {
|
||||
return config
|
||||
})
|
||||
|
||||
// Helper to check if detail is ban info object
|
||||
function isBanInfo(detail: unknown): detail is BanInfo {
|
||||
return (
|
||||
typeof detail === 'object' &&
|
||||
detail !== null &&
|
||||
'banned_at' in detail &&
|
||||
'reason' in detail
|
||||
)
|
||||
}
|
||||
|
||||
// Response interceptor to handle errors
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<{ detail: string }>) => {
|
||||
(error: AxiosError<{ detail: string | BanInfo }>) => {
|
||||
// Unauthorized - redirect to login
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
@@ -29,6 +40,15 @@ client.interceptors.response.use(
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
// Forbidden - check if user is banned
|
||||
if (error.response?.status === 403) {
|
||||
const detail = error.response.data?.detail
|
||||
if (isBanInfo(detail)) {
|
||||
// User is banned - set ban info in store
|
||||
useAuthStore.getState().setBanned(detail)
|
||||
}
|
||||
}
|
||||
|
||||
// Server error or network error - redirect to 500 page
|
||||
if (
|
||||
error.response?.status === 500 ||
|
||||
|
||||
@@ -79,6 +79,11 @@ export const gamesApi = {
|
||||
await client.delete(`/challenges/${id}`)
|
||||
},
|
||||
|
||||
updateChallenge: async (id: number, data: Partial<CreateChallengeData>): Promise<Challenge> => {
|
||||
const response = await client.patch<Challenge>(`/challenges/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
|
||||
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
||||
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
|
||||
@@ -89,4 +94,30 @@ export const gamesApi = {
|
||||
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Proposed challenges
|
||||
proposeChallenge: async (gameId: number, data: CreateChallengeData): Promise<Challenge> => {
|
||||
const response = await client.post<Challenge>(`/games/${gameId}/propose-challenge`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/proposed-challenges`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getMyProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/my-proposed-challenges`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
approveChallenge: async (id: number): Promise<Challenge> => {
|
||||
const response = await client.patch<Challenge>(`/challenges/${id}/approve`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
rejectChallenge: async (id: number): Promise<Challenge> => {
|
||||
const response = await client.patch<Challenge>(`/challenges/${id}/reject`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export { marathonsApi } from './marathons'
|
||||
export { gamesApi } from './games'
|
||||
export { wheelApi } from './wheel'
|
||||
export { feedApi } from './feed'
|
||||
export { adminApi } from './admin'
|
||||
export { adminApi, contentApi } from './admin'
|
||||
export { eventsApi } from './events'
|
||||
export { challengesApi } from './challenges'
|
||||
export { assignmentsApi } from './assignments'
|
||||
|
||||
130
frontend/src/components/BannedScreen.tsx
Normal file
130
frontend/src/components/BannedScreen.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Ban, LogOut, Calendar, Clock, AlertTriangle, Sparkles } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
|
||||
interface BanInfo {
|
||||
banned_at: string | null
|
||||
banned_until: string | null
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
interface BannedScreenProps {
|
||||
banInfo: BanInfo
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Moscow',
|
||||
}) + ' (МСК)'
|
||||
}
|
||||
|
||||
export function BannedScreen({ banInfo }: BannedScreenProps) {
|
||||
const logout = useAuthStore((state) => state.logout)
|
||||
|
||||
const bannedAtFormatted = formatDate(banInfo.banned_at)
|
||||
const bannedUntilFormatted = formatDate(banInfo.banned_until)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 flex flex-col items-center justify-center text-center px-4">
|
||||
{/* Background effects */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-red-500/5 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-orange-500/5 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="relative mb-8">
|
||||
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border-2 border-red-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(239,68,68,0.2)]">
|
||||
<Ban className="w-16 h-16 text-red-400" />
|
||||
</div>
|
||||
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-red-500/20 border border-red-500/40 flex items-center justify-center animate-pulse">
|
||||
<AlertTriangle className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
{/* Decorative dots */}
|
||||
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-red-500/50 animate-pulse" />
|
||||
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-orange-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||
</div>
|
||||
|
||||
{/* Title with glow */}
|
||||
<div className="relative mb-4">
|
||||
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 via-orange-400 to-red-400">
|
||||
Аккаунт заблокирован
|
||||
</h1>
|
||||
<div className="absolute inset-0 text-4xl font-bold text-red-500/20 blur-xl">
|
||||
Аккаунт заблокирован
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 mb-8 max-w-md">
|
||||
Ваш доступ к платформе был ограничен администрацией.
|
||||
</p>
|
||||
|
||||
{/* Ban Info Card */}
|
||||
<div className="glass rounded-2xl p-6 mb-8 max-w-md w-full border border-red-500/20 text-left space-y-4">
|
||||
{bannedAtFormatted && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-dark-700/50">
|
||||
<Calendar className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Дата блокировки</p>
|
||||
<p className="text-white font-medium">{bannedAtFormatted}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-dark-700/50">
|
||||
<Clock className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Срок</p>
|
||||
<p className={`font-medium ${bannedUntilFormatted ? 'text-amber-400' : 'text-red-400'}`}>
|
||||
{bannedUntilFormatted ? `до ${bannedUntilFormatted}` : 'Навсегда'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{banInfo.reason && (
|
||||
<div className="pt-4 border-t border-dark-600">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Причина</p>
|
||||
<p className="text-white bg-dark-700/50 rounded-xl p-4 border border-dark-600">
|
||||
{banInfo.reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info text */}
|
||||
<p className="text-gray-500 text-sm mb-8 max-w-md">
|
||||
{banInfo.banned_until
|
||||
? 'Ваш аккаунт будет автоматически разблокирован по истечении срока.'
|
||||
: 'Если вы считаете, что блокировка ошибочна, обратитесь к администрации.'}
|
||||
</p>
|
||||
|
||||
{/* Logout button */}
|
||||
<NeonButton
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={logout}
|
||||
icon={<LogOut className="w-5 h-5" />}
|
||||
>
|
||||
Выйти из аккаунта
|
||||
</NeonButton>
|
||||
|
||||
{/* Decorative sparkles */}
|
||||
<div className="absolute top-1/4 left-1/4 opacity-20">
|
||||
<Sparkles className="w-6 h-6 text-red-400 animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute bottom-1/3 right-1/4 opacity-20">
|
||||
<Sparkles className="w-4 h-4 text-orange-400 animate-pulse" style={{ animationDelay: '1s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Gamepad2, LogOut, Trophy, User, Menu, X } from 'lucide-react'
|
||||
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield } from 'lucide-react'
|
||||
import { TelegramLink } from '@/components/TelegramLink'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
@@ -74,6 +74,21 @@ export function Layout() {
|
||||
<span>Марафоны</span>
|
||||
</Link>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
|
||||
location.pathname.startsWith('/admin')
|
||||
? 'text-purple-400 bg-purple-500/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
<span>Админка</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
|
||||
<Link
|
||||
to="/profile"
|
||||
@@ -144,6 +159,20 @@ export function Layout() {
|
||||
<Trophy className="w-5 h-5" />
|
||||
<span>Марафоны</span>
|
||||
</Link>
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||
location.pathname.startsWith('/admin')
|
||||
? 'text-purple-400 bg-purple-500/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
<span>Админка</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/profile"
|
||||
className={clsx(
|
||||
|
||||
@@ -61,6 +61,27 @@ export function LobbyPage() {
|
||||
})
|
||||
const [isCreatingChallenge, setIsCreatingChallenge] = useState(false)
|
||||
|
||||
// Edit challenge
|
||||
const [editingChallengeId, setEditingChallengeId] = useState<number | null>(null)
|
||||
const [editChallenge, setEditChallenge] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'completion',
|
||||
difficulty: 'medium',
|
||||
points: 50,
|
||||
estimated_time: 30,
|
||||
proof_type: 'screenshot',
|
||||
proof_hint: '',
|
||||
})
|
||||
const [isUpdatingChallenge, setIsUpdatingChallenge] = useState(false)
|
||||
|
||||
// Proposed challenges
|
||||
const [proposedChallenges, setProposedChallenges] = useState<Challenge[]>([])
|
||||
const [myProposedChallenges, setMyProposedChallenges] = useState<Challenge[]>([])
|
||||
const [approvingChallengeId, setApprovingChallengeId] = useState<number | null>(null)
|
||||
const [isProposingChallenge, setIsProposingChallenge] = useState(false)
|
||||
const [editingProposedId, setEditingProposedId] = useState<number | null>(null)
|
||||
|
||||
// Start marathon
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
|
||||
@@ -84,6 +105,23 @@ export function LobbyPage() {
|
||||
} catch {
|
||||
setPendingGames([])
|
||||
}
|
||||
// Load proposed challenges for organizers
|
||||
try {
|
||||
const proposed = await gamesApi.getProposedChallenges(parseInt(id))
|
||||
setProposedChallenges(proposed)
|
||||
} catch {
|
||||
setProposedChallenges([])
|
||||
}
|
||||
}
|
||||
|
||||
// Load my proposed challenges for all participants
|
||||
if (marathonData.my_participation) {
|
||||
try {
|
||||
const myProposed = await gamesApi.getMyProposedChallenges(parseInt(id))
|
||||
setMyProposedChallenges(myProposed)
|
||||
} catch {
|
||||
setMyProposedChallenges([])
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
@@ -249,6 +287,206 @@ export function LobbyPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartEditChallenge = (challenge: Challenge) => {
|
||||
setEditingChallengeId(challenge.id)
|
||||
setEditChallenge({
|
||||
title: challenge.title,
|
||||
description: challenge.description,
|
||||
type: challenge.type,
|
||||
difficulty: challenge.difficulty,
|
||||
points: challenge.points,
|
||||
estimated_time: challenge.estimated_time || 30,
|
||||
proof_type: challenge.proof_type,
|
||||
proof_hint: challenge.proof_hint || '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateChallenge = async (challengeId: number, gameId: number) => {
|
||||
if (!editChallenge.title.trim() || !editChallenge.description.trim()) {
|
||||
toast.warning('Заполните название и описание')
|
||||
return
|
||||
}
|
||||
|
||||
setIsUpdatingChallenge(true)
|
||||
try {
|
||||
await gamesApi.updateChallenge(challengeId, {
|
||||
title: editChallenge.title.trim(),
|
||||
description: editChallenge.description.trim(),
|
||||
type: editChallenge.type,
|
||||
difficulty: editChallenge.difficulty,
|
||||
points: editChallenge.points,
|
||||
estimated_time: editChallenge.estimated_time || undefined,
|
||||
proof_type: editChallenge.proof_type,
|
||||
proof_hint: editChallenge.proof_hint.trim() || undefined,
|
||||
})
|
||||
toast.success('Задание обновлено')
|
||||
setEditingChallengeId(null)
|
||||
const challenges = await gamesApi.getChallenges(gameId)
|
||||
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось обновить задание')
|
||||
} finally {
|
||||
setIsUpdatingChallenge(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadProposedChallenges = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const proposed = await gamesApi.getProposedChallenges(parseInt(id))
|
||||
setProposedChallenges(proposed)
|
||||
} catch (error) {
|
||||
console.error('Failed to load proposed challenges:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApproveChallenge = async (challengeId: number) => {
|
||||
setApprovingChallengeId(challengeId)
|
||||
try {
|
||||
await gamesApi.approveChallenge(challengeId)
|
||||
toast.success('Задание одобрено')
|
||||
await loadProposedChallenges()
|
||||
// Reload challenges for the game
|
||||
const challenge = proposedChallenges.find(c => c.id === challengeId)
|
||||
if (challenge) {
|
||||
const challenges = await gamesApi.getChallenges(challenge.game.id)
|
||||
setGameChallenges(prev => ({ ...prev, [challenge.game.id]: challenges }))
|
||||
}
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось одобрить задание')
|
||||
} finally {
|
||||
setApprovingChallengeId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRejectChallenge = async (challengeId: number) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Отклонить задание?',
|
||||
message: 'Задание будет удалено.',
|
||||
confirmText: 'Отклонить',
|
||||
cancelText: 'Отмена',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
setApprovingChallengeId(challengeId)
|
||||
try {
|
||||
await gamesApi.rejectChallenge(challengeId)
|
||||
toast.success('Задание отклонено')
|
||||
await loadProposedChallenges()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось отклонить задание')
|
||||
} finally {
|
||||
setApprovingChallengeId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartEditProposed = (challenge: Challenge) => {
|
||||
setEditingProposedId(challenge.id)
|
||||
setEditChallenge({
|
||||
title: challenge.title,
|
||||
description: challenge.description,
|
||||
type: challenge.type,
|
||||
difficulty: challenge.difficulty,
|
||||
points: challenge.points,
|
||||
estimated_time: challenge.estimated_time || 30,
|
||||
proof_type: challenge.proof_type,
|
||||
proof_hint: challenge.proof_hint || '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateProposedChallenge = async (challengeId: number) => {
|
||||
if (!editChallenge.title.trim() || !editChallenge.description.trim()) {
|
||||
toast.warning('Заполните название и описание')
|
||||
return
|
||||
}
|
||||
|
||||
setIsUpdatingChallenge(true)
|
||||
try {
|
||||
await gamesApi.updateChallenge(challengeId, {
|
||||
title: editChallenge.title.trim(),
|
||||
description: editChallenge.description.trim(),
|
||||
type: editChallenge.type,
|
||||
difficulty: editChallenge.difficulty,
|
||||
points: editChallenge.points,
|
||||
estimated_time: editChallenge.estimated_time || undefined,
|
||||
proof_type: editChallenge.proof_type,
|
||||
proof_hint: editChallenge.proof_hint.trim() || undefined,
|
||||
})
|
||||
toast.success('Задание обновлено')
|
||||
setEditingProposedId(null)
|
||||
await loadProposedChallenges()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось обновить задание')
|
||||
} finally {
|
||||
setIsUpdatingChallenge(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProposeChallenge = async (gameId: number) => {
|
||||
if (!newChallenge.title.trim() || !newChallenge.description.trim()) {
|
||||
toast.warning('Заполните название и описание')
|
||||
return
|
||||
}
|
||||
|
||||
setIsProposingChallenge(true)
|
||||
try {
|
||||
await gamesApi.proposeChallenge(gameId, {
|
||||
title: newChallenge.title.trim(),
|
||||
description: newChallenge.description.trim(),
|
||||
type: newChallenge.type,
|
||||
difficulty: newChallenge.difficulty,
|
||||
points: newChallenge.points,
|
||||
estimated_time: newChallenge.estimated_time || undefined,
|
||||
proof_type: newChallenge.proof_type,
|
||||
proof_hint: newChallenge.proof_hint.trim() || undefined,
|
||||
})
|
||||
toast.success('Задание предложено на модерацию')
|
||||
setNewChallenge({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'completion',
|
||||
difficulty: 'medium',
|
||||
points: 50,
|
||||
estimated_time: 30,
|
||||
proof_type: 'screenshot',
|
||||
proof_hint: '',
|
||||
})
|
||||
setAddingChallengeToGameId(null)
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось предложить задание')
|
||||
} finally {
|
||||
setIsProposingChallenge(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteMyProposedChallenge = async (challengeId: number) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Удалить предложение?',
|
||||
message: 'Предложенное задание будет удалено.',
|
||||
confirmText: 'Удалить',
|
||||
cancelText: 'Отмена',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await gamesApi.deleteChallenge(challengeId)
|
||||
toast.success('Предложение удалено')
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось удалить предложение')
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateChallenges = async () => {
|
||||
if (!id) return
|
||||
|
||||
@@ -476,14 +714,114 @@ export function LobbyPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{gameChallenges[game.id]?.length > 0 ? (
|
||||
gameChallenges[game.id].map((challenge) => (
|
||||
{(() => {
|
||||
// For organizers: hide pending challenges (they see them in separate block)
|
||||
// For regular users: hide their own pending/rejected challenges (they see them in "My proposals")
|
||||
// but show their own approved challenges in both places
|
||||
const visibleChallenges = isOrganizer
|
||||
? gameChallenges[game.id]?.filter(c => c.status !== 'pending') || []
|
||||
: gameChallenges[game.id]?.filter(c =>
|
||||
!(c.proposed_by?.id === user?.id && c.status !== 'approved')
|
||||
) || []
|
||||
|
||||
return visibleChallenges.length > 0 ? (
|
||||
visibleChallenges.map((challenge) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="flex items-start justify-between gap-3 p-3 bg-dark-700/50 rounded-lg border border-dark-600"
|
||||
className="p-3 bg-dark-700/50 rounded-lg border border-dark-600"
|
||||
>
|
||||
{editingChallengeId === challenge.id ? (
|
||||
// Edit form
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Название задания"
|
||||
value={editChallenge.title}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, title: e.target.value }))}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Описание"
|
||||
value={editChallenge.description}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="input w-full resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип</label>
|
||||
<select
|
||||
value={editChallenge.type}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="completion">Прохождение</option>
|
||||
<option value="no_death">Без смертей</option>
|
||||
<option value="speedrun">Спидран</option>
|
||||
<option value="collection">Коллекция</option>
|
||||
<option value="achievement">Достижение</option>
|
||||
<option value="challenge_run">Челлендж-ран</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
|
||||
<select
|
||||
value={editChallenge.difficulty}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="easy">Легко</option>
|
||||
<option value="medium">Средне</option>
|
||||
<option value="hard">Сложно</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editChallenge.points}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||
<select
|
||||
value={editChallenge.proof_type}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="screenshot">Скриншот</option>
|
||||
<option value="video">Видео</option>
|
||||
<option value="steam">Steam</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => handleUpdateChallenge(challenge.id, game.id)}
|
||||
isLoading={isUpdatingChallenge}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
>
|
||||
Сохранить
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingChallengeId(null)}
|
||||
>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Display challenge
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
{challenge.status === 'pending' && getStatusBadge('pending')}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
@@ -500,17 +838,32 @@ export function LobbyPage() {
|
||||
<Sparkles className="w-3 h-3" /> ИИ
|
||||
</span>
|
||||
)}
|
||||
{challenge.proposed_by && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<User className="w-3 h-3" /> {challenge.proposed_by.nickname}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
|
||||
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
|
||||
</div>
|
||||
{isOrganizer && (
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => handleStartEditChallenge(challenge)}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
|
||||
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
||||
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
@@ -518,15 +871,16 @@ export function LobbyPage() {
|
||||
<p className="text-center text-gray-500 py-4 text-sm">
|
||||
Нет заданий
|
||||
</p>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Add challenge form */}
|
||||
{isOrganizer && game.status === 'approved' && (
|
||||
{/* Add/Propose challenge form */}
|
||||
{game.status === 'approved' && (
|
||||
addingChallengeToGameId === game.id ? (
|
||||
<div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
|
||||
<h4 className="font-semibold text-white text-sm flex items-center gap-2">
|
||||
<Plus className="w-4 h-4 text-neon-400" />
|
||||
Новое задание
|
||||
{isOrganizer ? 'Новое задание' : 'Предложить задание'}
|
||||
</h4>
|
||||
<Input
|
||||
placeholder="Название задания"
|
||||
@@ -613,6 +967,7 @@ export function LobbyPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isOrganizer ? (
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => handleCreateChallenge(game.id)}
|
||||
@@ -622,6 +977,17 @@ export function LobbyPage() {
|
||||
>
|
||||
Добавить
|
||||
</NeonButton>
|
||||
) : (
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => handleProposeChallenge(game.id)}
|
||||
isLoading={isProposingChallenge}
|
||||
disabled={!newChallenge.title || !newChallenge.description}
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
Предложить
|
||||
</NeonButton>
|
||||
)}
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -630,6 +996,11 @@ export function LobbyPage() {
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
{!isOrganizer && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Задание будет отправлено на модерацию организаторам
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
@@ -640,7 +1011,7 @@ export function LobbyPage() {
|
||||
className="w-full mt-2 p-3 rounded-lg border-2 border-dashed border-dark-600 text-gray-400 hover:text-neon-400 hover:border-neon-500/30 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить задание вручную
|
||||
{isOrganizer ? 'Добавить задание вручную' : 'Предложить задание'}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
@@ -721,6 +1092,233 @@ export function LobbyPage() {
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Proposed challenges for moderation */}
|
||||
{isOrganizer && proposedChallenges.length > 0 && (
|
||||
<GlassCard className="mb-8 border-accent-500/30">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-accent-400">Предложенные задания</h3>
|
||||
<p className="text-sm text-gray-400">{proposedChallenges.length} заданий ожидают</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{proposedChallenges.map((challenge) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
||||
>
|
||||
{editingProposedId === challenge.id ? (
|
||||
// Edit form
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||
{challenge.game.title}
|
||||
</span>
|
||||
<Input
|
||||
placeholder="Название задания"
|
||||
value={editChallenge.title}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, title: e.target.value }))}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Описание"
|
||||
value={editChallenge.description}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="input w-full resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип</label>
|
||||
<select
|
||||
value={editChallenge.type}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="completion">Прохождение</option>
|
||||
<option value="no_death">Без смертей</option>
|
||||
<option value="speedrun">Спидран</option>
|
||||
<option value="collection">Коллекция</option>
|
||||
<option value="achievement">Достижение</option>
|
||||
<option value="challenge_run">Челлендж-ран</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
|
||||
<select
|
||||
value={editChallenge.difficulty}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="easy">Легко</option>
|
||||
<option value="medium">Средне</option>
|
||||
<option value="hard">Сложно</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editChallenge.points}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||
<select
|
||||
value={editChallenge.proof_type}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="screenshot">Скриншот</option>
|
||||
<option value="video">Видео</option>
|
||||
<option value="steam">Steam</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => handleUpdateProposedChallenge(challenge.id)}
|
||||
isLoading={isUpdatingChallenge}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
>
|
||||
Сохранить
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingProposedId(null)}
|
||||
>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
{challenge.proposed_by && (
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Display
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||
{challenge.game.title}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}`}>
|
||||
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||
</span>
|
||||
<span className="text-xs text-neon-400 font-semibold">
|
||||
+{challenge.points}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
||||
<p className="text-sm text-gray-400 mb-2">{challenge.description}</p>
|
||||
{challenge.proposed_by && (
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => handleStartEditProposed(challenge)}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApproveChallenge(challenge.id)}
|
||||
disabled={approvingChallengeId === challenge.id}
|
||||
className="p-2 rounded-lg text-green-400 hover:bg-green-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{approvingChallengeId === challenge.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectChallenge(challenge.id)}
|
||||
disabled={approvingChallengeId === challenge.id}
|
||||
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* My proposed challenges (for non-organizers) */}
|
||||
{!isOrganizer && myProposedChallenges.length > 0 && (
|
||||
<GlassCard className="mb-8 border-neon-500/30">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-neon-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neon-400">Мои предложения</h3>
|
||||
<p className="text-sm text-gray-400">{myProposedChallenges.length} заданий</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{myProposedChallenges.map((challenge) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="flex items-start justify-between gap-3 p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||
{challenge.game.title}
|
||||
</span>
|
||||
{getStatusBadge(challenge.status)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}`}>
|
||||
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||
</span>
|
||||
<span className="text-xs text-neon-400 font-semibold">
|
||||
+{challenge.points}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
||||
<p className="text-sm text-gray-400">{challenge.description}</p>
|
||||
</div>
|
||||
{challenge.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleDeleteMyProposedChallenge(challenge.id)}
|
||||
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Generate challenges */}
|
||||
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
||||
<GlassCard className="mb-8">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from 'zod'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { marathonsApi } from '@/api'
|
||||
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
||||
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target } from 'lucide-react'
|
||||
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target, Shield, ArrowLeft } from 'lucide-react'
|
||||
|
||||
const loginSchema = z.object({
|
||||
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
|
||||
@@ -17,8 +17,9 @@ type LoginForm = z.infer<typeof loginSchema>
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { login, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
|
||||
const { login, verify2FA, cancel2FA, pending2FA, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [twoFACode, setTwoFACode] = useState('')
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -32,7 +33,12 @@ export function LoginPage() {
|
||||
setSubmitError(null)
|
||||
clearError()
|
||||
try {
|
||||
await login(data)
|
||||
const result = await login(data)
|
||||
|
||||
// If 2FA required, don't navigate
|
||||
if (result.requires2FA) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for pending invite code
|
||||
const pendingCode = consumePendingInviteCode()
|
||||
@@ -52,6 +58,24 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handle2FASubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitError(null)
|
||||
clearError()
|
||||
try {
|
||||
await verify2FA(twoFACode)
|
||||
navigate('/marathons')
|
||||
} catch {
|
||||
setSubmitError(error || 'Неверный код')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel2FA = () => {
|
||||
cancel2FA()
|
||||
setTwoFACode('')
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
const features = [
|
||||
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
|
||||
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
|
||||
@@ -113,6 +137,63 @@ export function LoginPage() {
|
||||
|
||||
{/* Form Block (right) */}
|
||||
<GlassCard className="p-8">
|
||||
{pending2FA ? (
|
||||
// 2FA Form
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center">
|
||||
<Shield className="w-8 h-8 text-neon-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Двухфакторная аутентификация</h2>
|
||||
<p className="text-gray-400">Введите код из Telegram</p>
|
||||
</div>
|
||||
|
||||
{/* 2FA Form */}
|
||||
<form onSubmit={handle2FASubmit} className="space-y-5">
|
||||
{(submitError || error) && (
|
||||
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{submitError || error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Код подтверждения"
|
||||
placeholder="000000"
|
||||
value={twoFACode}
|
||||
onChange={(e) => setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<NeonButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
disabled={twoFACode.length !== 6}
|
||||
icon={<Shield className="w-5 h-5" />}
|
||||
>
|
||||
Подтвердить
|
||||
</NeonButton>
|
||||
</form>
|
||||
|
||||
{/* Back button */}
|
||||
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
|
||||
<button
|
||||
onClick={handleCancel2FA}
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm flex items-center justify-center gap-2 mx-auto"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Вернуться к входу
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Regular Login Form
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
|
||||
@@ -168,6 +249,8 @@ export function LoginPage() {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
|
||||
190
frontend/src/pages/admin/AdminBroadcastPage.tsx
Normal file
190
frontend/src/pages/admin/AdminBroadcastPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminMarathon } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { Send, Users, Trophy, AlertTriangle } from 'lucide-react'
|
||||
|
||||
export function AdminBroadcastPage() {
|
||||
const [message, setMessage] = useState('')
|
||||
const [targetType, setTargetType] = useState<'all' | 'marathon'>('all')
|
||||
const [marathonId, setMarathonId] = useState<number | null>(null)
|
||||
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const [loadingMarathons, setLoadingMarathons] = useState(false)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (targetType === 'marathon') {
|
||||
loadMarathons()
|
||||
}
|
||||
}, [targetType])
|
||||
|
||||
const loadMarathons = async () => {
|
||||
setLoadingMarathons(true)
|
||||
try {
|
||||
const data = await adminApi.listMarathons(0, 100)
|
||||
setMarathons(data.filter(m => m.status === 'active'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load marathons:', err)
|
||||
} finally {
|
||||
setLoadingMarathons(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim()) {
|
||||
toast.error('Введите сообщение')
|
||||
return
|
||||
}
|
||||
|
||||
if (targetType === 'marathon' && !marathonId) {
|
||||
toast.error('Выберите марафон')
|
||||
return
|
||||
}
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
let result
|
||||
if (targetType === 'all') {
|
||||
result = await adminApi.broadcastToAll(message)
|
||||
} else {
|
||||
result = await adminApi.broadcastToMarathon(marathonId!, message)
|
||||
}
|
||||
|
||||
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
|
||||
setMessage('')
|
||||
} catch (err) {
|
||||
console.error('Failed to send broadcast:', err)
|
||||
toast.error('Ошибка отправки')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-pink-500/20 border border-pink-500/30">
|
||||
<Send className="w-6 h-6 text-pink-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Target Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Кому отправить
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTargetType('all')
|
||||
setMarathonId(null)
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||
targetType === 'all'
|
||||
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-5 h-5" />
|
||||
<span className="font-medium">Всем пользователям</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTargetType('marathon')}
|
||||
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||
targetType === 'marathon'
|
||||
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Trophy className="w-5 h-5" />
|
||||
<span className="font-medium">Участникам марафона</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marathon Selection */}
|
||||
{targetType === 'marathon' && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Выберите марафон
|
||||
</label>
|
||||
{loadingMarathons ? (
|
||||
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" />
|
||||
) : (
|
||||
<select
|
||||
value={marathonId || ''}
|
||||
onChange={(e) => setMarathonId(Number(e.target.value) || null)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
>
|
||||
<option value="">Выберите марафон...</option>
|
||||
{marathons.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.title} ({m.participants_count} участников)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{marathons.length === 0 && !loadingMarathons && (
|
||||
<p className="text-sm text-gray-500">Нет активных марафонов</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Сообщение
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<p className="text-gray-500">
|
||||
Поддерживается HTML: <b>, <i>, <code>, <a href>
|
||||
</p>
|
||||
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
|
||||
{message.length} / 2000
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
<NeonButton
|
||||
size="lg"
|
||||
color="purple"
|
||||
onClick={handleSend}
|
||||
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
|
||||
isLoading={sending}
|
||||
icon={<Send className="w-5 h-5" />}
|
||||
className="w-full"
|
||||
>
|
||||
{sending ? 'Отправка...' : 'Отправить рассылку'}
|
||||
</NeonButton>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="glass rounded-xl p-4 border border-amber-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-amber-400 font-medium mb-1">Обратите внимание</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Сообщение будет отправлено только пользователям с привязанным Telegram.
|
||||
Рассылка ограничена: 1 сообщение всем в минуту, 3 сообщения марафону в минуту.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
261
frontend/src/pages/admin/AdminContentPage.tsx
Normal file
261
frontend/src/pages/admin/AdminContentPage.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { StaticContent } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { FileText, Plus, Pencil, X, Save, Code } from 'lucide-react'
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminContentPage() {
|
||||
const [contents, setContents] = useState<StaticContent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<StaticContent | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [formKey, setFormKey] = useState('')
|
||||
const [formTitle, setFormTitle] = useState('')
|
||||
const [formContent, setFormContent] = useState('')
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
loadContents()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const loadContents = async () => {
|
||||
try {
|
||||
const data = await adminApi.listContent()
|
||||
setContents(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load contents:', err)
|
||||
toast.error('Ошибка загрузки контента')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (content: StaticContent) => {
|
||||
setEditing(content)
|
||||
setFormKey(content.key)
|
||||
setFormTitle(content.title)
|
||||
setFormContent(content.content)
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setCreating(true)
|
||||
setEditing(null)
|
||||
setFormKey('')
|
||||
setFormTitle('')
|
||||
setFormContent('')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(null)
|
||||
setCreating(false)
|
||||
setFormKey('')
|
||||
setFormTitle('')
|
||||
setFormContent('')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formTitle.trim() || !formContent.trim()) {
|
||||
toast.error('Заполните все поля')
|
||||
return
|
||||
}
|
||||
|
||||
if (creating && !formKey.trim()) {
|
||||
toast.error('Введите ключ')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (creating) {
|
||||
const newContent = await adminApi.createContent(formKey, formTitle, formContent)
|
||||
setContents([...contents, newContent])
|
||||
toast.success('Контент создан')
|
||||
} else if (editing) {
|
||||
const updated = await adminApi.updateContent(editing.key, formTitle, formContent)
|
||||
setContents(contents.map(c => c.id === updated.id ? updated : c))
|
||||
toast.success('Контент обновлён')
|
||||
}
|
||||
handleCancel()
|
||||
} catch (err) {
|
||||
console.error('Failed to save content:', err)
|
||||
toast.error('Ошибка сохранения')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-neon-500/20 border border-neon-500/30">
|
||||
<FileText className="w-6 h-6 text-neon-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Статический контент</h1>
|
||||
</div>
|
||||
<NeonButton onClick={handleCreate} icon={<Plus className="w-4 h-4" />}>
|
||||
Добавить
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Content List */}
|
||||
<div className="space-y-4">
|
||||
{contents.length === 0 ? (
|
||||
<div className="glass rounded-xl border border-dark-600 p-8 text-center">
|
||||
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-400">Нет статического контента</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Создайте первую страницу</p>
|
||||
</div>
|
||||
) : (
|
||||
contents.map((content) => (
|
||||
<div
|
||||
key={content.id}
|
||||
className={`glass rounded-xl border p-5 cursor-pointer transition-all duration-200 ${
|
||||
editing?.id === content.id
|
||||
? 'border-accent-500/50 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||
: 'border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => handleEdit(content)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Code className="w-4 h-4 text-neon-400" />
|
||||
<p className="text-sm text-neon-400 font-mono">{content.key}</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white truncate">{content.title}</h3>
|
||||
<p className="text-sm text-gray-400 mt-2 line-clamp-2">
|
||||
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(content)
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors ml-3"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
|
||||
Обновлено: {formatDate(content.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
{(editing || creating) && (
|
||||
<div className="glass rounded-xl border border-dark-600 p-6 sticky top-6 h-fit">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
{creating ? (
|
||||
<>
|
||||
<Plus className="w-5 h-5 text-neon-400" />
|
||||
Новый контент
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="w-5 h-5 text-accent-400" />
|
||||
Редактирование
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-dark-600/50 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{creating && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Ключ
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formKey}
|
||||
onChange={(e) => setFormKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
|
||||
placeholder="about-page"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white font-mono placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1.5">
|
||||
Только буквы, цифры, дефисы и подчеркивания
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Заголовок
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
placeholder="Заголовок страницы"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Содержимое (HTML)
|
||||
</label>
|
||||
<textarea
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={14}
|
||||
placeholder="<p>HTML контент...</p>"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 font-mono text-sm resize-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NeonButton
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
isLoading={saving}
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
207
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
207
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { DashboardStats } from '@/types'
|
||||
import { Users, Trophy, Gamepad2, UserCheck, Ban, Activity, TrendingUp } from 'lucide-react'
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
user_ban: 'Бан пользователя',
|
||||
user_unban: 'Разбан пользователя',
|
||||
user_role_change: 'Изменение роли',
|
||||
marathon_force_finish: 'Принудительное завершение',
|
||||
marathon_delete: 'Удаление марафона',
|
||||
content_update: 'Обновление контента',
|
||||
broadcast_all: 'Рассылка всем',
|
||||
broadcast_marathon: 'Рассылка марафону',
|
||||
admin_login: 'Вход админа',
|
||||
admin_2fa_success: '2FA успех',
|
||||
admin_2fa_fail: '2FA неудача',
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
user_ban: 'text-red-400',
|
||||
user_unban: 'text-green-400',
|
||||
user_role_change: 'text-accent-400',
|
||||
marathon_force_finish: 'text-orange-400',
|
||||
marathon_delete: 'text-red-400',
|
||||
content_update: 'text-neon-400',
|
||||
broadcast_all: 'text-pink-400',
|
||||
broadcast_marathon: 'text-pink-400',
|
||||
admin_login: 'text-blue-400',
|
||||
admin_2fa_success: 'text-green-400',
|
||||
admin_2fa_fail: 'text-red-400',
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
gradient,
|
||||
glowColor
|
||||
}: {
|
||||
icon: typeof Users
|
||||
label: string
|
||||
value: number
|
||||
gradient: string
|
||||
glowColor: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`glass rounded-xl p-5 border border-dark-600 hover:border-dark-500 transition-all duration-300 hover:shadow-[0_0_20px_${glowColor}]`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-xl ${gradient} shadow-lg`}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{label}</p>
|
||||
<p className="text-2xl font-bold text-white">{value.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminDashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
}, [])
|
||||
|
||||
const loadDashboard = async () => {
|
||||
try {
|
||||
const data = await adminApi.getDashboard()
|
||||
setStats(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
Не удалось загрузить данные
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
|
||||
<TrendingUp className="w-6 h-6 text-accent-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Дашборд</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
icon={Users}
|
||||
label="Всего пользователей"
|
||||
value={stats.users_count}
|
||||
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||
glowColor="rgba(59,130,246,0.15)"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Ban}
|
||||
label="Заблокировано"
|
||||
value={stats.banned_users_count}
|
||||
gradient="bg-gradient-to-br from-red-500 to-red-600"
|
||||
glowColor="rgba(239,68,68,0.15)"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Trophy}
|
||||
label="Всего марафонов"
|
||||
value={stats.marathons_count}
|
||||
gradient="bg-gradient-to-br from-accent-500 to-pink-500"
|
||||
glowColor="rgba(139,92,246,0.15)"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Активных марафонов"
|
||||
value={stats.active_marathons_count}
|
||||
gradient="bg-gradient-to-br from-green-500 to-emerald-600"
|
||||
glowColor="rgba(34,197,94,0.15)"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Gamepad2}
|
||||
label="Всего игр"
|
||||
value={stats.games_count}
|
||||
gradient="bg-gradient-to-br from-orange-500 to-amber-500"
|
||||
glowColor="rgba(249,115,22,0.15)"
|
||||
/>
|
||||
<StatCard
|
||||
icon={UserCheck}
|
||||
label="Участий в марафонах"
|
||||
value={stats.total_participations}
|
||||
gradient="bg-gradient-to-br from-neon-500 to-cyan-500"
|
||||
glowColor="rgba(34,211,238,0.15)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Logs */}
|
||||
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||
<div className="p-4 border-b border-dark-600">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-accent-400" />
|
||||
Последние действия
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{stats.recent_logs.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-4">Нет записей</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{stats.recent_logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="flex items-start justify-between p-4 bg-dark-700/50 hover:bg-dark-700 rounded-xl border border-dark-600 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className={`font-medium ${ACTION_COLORS[log.action] || 'text-white'}`}>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
<span className="text-gray-500">Админ:</span> {log.admin_nickname}
|
||||
<span className="text-gray-600 mx-2">•</span>
|
||||
<span className="text-gray-500">{log.target_type}</span> #{log.target_id}
|
||||
</p>
|
||||
{log.details && (
|
||||
<p className="text-xs text-gray-500 mt-2 font-mono bg-dark-800 rounded px-2 py-1 inline-block">
|
||||
{JSON.stringify(log.details)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap ml-4">
|
||||
{formatDate(log.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
169
frontend/src/pages/admin/AdminLayout.tsx
Normal file
169
frontend/src/pages/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Outlet, NavLink, Navigate, Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Trophy,
|
||||
ScrollText,
|
||||
Send,
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
Shield,
|
||||
MessageCircle,
|
||||
Sparkles,
|
||||
Lock
|
||||
} from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
||||
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
||||
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
||||
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
||||
]
|
||||
|
||||
export function AdminLayout() {
|
||||
const user = useAuthStore((state) => state.user)
|
||||
|
||||
// Only admins can access
|
||||
if (!user || user.role !== 'admin') {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
// Admin without Telegram - show warning
|
||||
if (!user.telegram_id) {
|
||||
return (
|
||||
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
|
||||
{/* Background effects */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/3 -left-32 w-96 h-96 bg-amber-500/5 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-1/3 -right-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="relative mb-8 animate-float">
|
||||
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border border-amber-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(245,158,11,0.15)]">
|
||||
<Lock className="w-16 h-16 text-amber-400" />
|
||||
</div>
|
||||
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-accent-500/20 border border-accent-500/30 flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-accent-400" />
|
||||
</div>
|
||||
{/* Decorative dots */}
|
||||
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-amber-500/50 animate-pulse" />
|
||||
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-accent-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||
</div>
|
||||
|
||||
{/* Title with glow */}
|
||||
<div className="relative mb-4">
|
||||
<h1 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 via-orange-400 to-accent-400">
|
||||
Требуется Telegram
|
||||
</h1>
|
||||
<div className="absolute inset-0 text-3xl font-bold text-amber-500/20 blur-xl">
|
||||
Требуется Telegram
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 mb-2 max-w-md">
|
||||
Для доступа к админ-панели необходимо привязать Telegram-аккаунт.
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mb-8 max-w-md">
|
||||
Это требуется для двухфакторной аутентификации при входе.
|
||||
</p>
|
||||
|
||||
{/* Info card */}
|
||||
<div className="glass rounded-xl p-4 mb-8 max-w-md border border-amber-500/20">
|
||||
<div className="flex items-center gap-2 text-amber-400 mb-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Двухфакторная аутентификация</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
После привязки Telegram при входе в админ-панель вам будет отправляться код подтверждения.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-4">
|
||||
<Link to="/profile">
|
||||
<NeonButton size="lg" color="purple" icon={<MessageCircle className="w-5 h-5" />}>
|
||||
Привязать Telegram
|
||||
</NeonButton>
|
||||
</Link>
|
||||
<Link to="/marathons">
|
||||
<NeonButton size="lg" variant="secondary" icon={<ArrowLeft className="w-5 h-5" />}>
|
||||
На сайт
|
||||
</NeonButton>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Decorative sparkles */}
|
||||
<div className="absolute top-1/4 left-1/4 opacity-20">
|
||||
<Sparkles className="w-6 h-6 text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute bottom-1/3 right-1/4 opacity-20">
|
||||
<Sparkles className="w-4 h-4 text-accent-400 animate-pulse" style={{ animationDelay: '1s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[calc(100vh-64px)]">
|
||||
{/* Background effects */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-0 -left-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-0 -right-32 w-96 h-96 bg-neon-500/5 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 glass border-r border-dark-600 flex flex-col relative z-10">
|
||||
<div className="p-4 border-b border-dark-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-500 to-pink-500 flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-pink-400">
|
||||
Админ-панель
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30 shadow-[0_0_10px_rgba(139,92,246,0.15)]'
|
||||
: 'text-gray-400 hover:bg-dark-600/50 hover:text-white border border-transparent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-dark-600">
|
||||
<NavLink
|
||||
to="/marathons"
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-gray-400 hover:text-neon-400 transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span className="font-medium">Вернуться на сайт</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-6 overflow-auto relative z-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
frontend/src/pages/admin/AdminLogsPage.tsx
Normal file
208
frontend/src/pages/admin/AdminLogsPage.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminLog } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { ChevronLeft, ChevronRight, Filter, ScrollText } from 'lucide-react'
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
user_ban: 'Бан пользователя',
|
||||
user_unban: 'Разбан пользователя',
|
||||
user_auto_unban: 'Авто-разбан (система)',
|
||||
user_role_change: 'Изменение роли',
|
||||
marathon_force_finish: 'Принудительное завершение',
|
||||
marathon_delete: 'Удаление марафона',
|
||||
content_update: 'Обновление контента',
|
||||
broadcast_all: 'Рассылка всем',
|
||||
broadcast_marathon: 'Рассылка марафону',
|
||||
admin_login: 'Вход админа',
|
||||
admin_2fa_success: '2FA успех',
|
||||
admin_2fa_fail: '2FA неудача',
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
user_ban: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
user_unban: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||
user_auto_unban: 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30',
|
||||
user_role_change: 'bg-accent-500/20 text-accent-400 border border-accent-500/30',
|
||||
marathon_force_finish: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
|
||||
marathon_delete: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
content_update: 'bg-neon-500/20 text-neon-400 border border-neon-500/30',
|
||||
broadcast_all: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
|
||||
broadcast_marathon: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
|
||||
admin_login: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||
admin_2fa_success: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||
admin_2fa_fail: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminLogsPage() {
|
||||
const [logs, setLogs] = useState<AdminLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionFilter, setActionFilter] = useState<string>('')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const LIMIT = 30
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.getLogs(page * LIMIT, LIMIT, actionFilter || undefined)
|
||||
setLogs(data.logs)
|
||||
setTotal(data.total)
|
||||
} catch (err) {
|
||||
console.error('Failed to load logs:', err)
|
||||
toast.error('Ошибка загрузки логов')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, actionFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [loadLogs])
|
||||
|
||||
const totalPages = Math.ceil(total / LIMIT)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-orange-500/20 border border-orange-500/30">
|
||||
<ScrollText className="w-6 h-6 text-orange-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Логи действий</h1>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 bg-dark-700/50 px-3 py-1.5 rounded-lg border border-dark-600">
|
||||
Всего: <span className="text-white font-medium">{total}</span> записей
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => {
|
||||
setActionFilter(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
className="bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors min-w-[200px]"
|
||||
>
|
||||
<option value="">Все действия</option>
|
||||
{Object.entries(ACTION_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-dark-700/50 border-b border-dark-600">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Дата</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Админ</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действие</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Цель</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Детали</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-600">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
Логи не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-dark-700/30 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-gray-400 whitespace-nowrap font-mono">
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">
|
||||
{log.admin_nickname ? (
|
||||
<span className="text-white">{log.admin_nickname}</span>
|
||||
) : (
|
||||
<span className="text-cyan-400 italic">Система</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex px-2.5 py-1 text-xs font-medium rounded-lg ${ACTION_COLORS[log.action] || 'bg-dark-600/50 text-gray-400 border border-dark-500'}`}>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">
|
||||
<span className="text-gray-500">{log.target_type}</span>
|
||||
<span className="text-neon-400 font-mono ml-1">#{log.target_id}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 max-w-xs">
|
||||
{log.details ? (
|
||||
<span className="font-mono text-xs bg-dark-700/50 px-2 py-1 rounded truncate block">
|
||||
{JSON.stringify(log.details)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 font-mono">
|
||||
{log.ip_address || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Назад
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Страница <span className="text-white font-medium">{page + 1}</span> из <span className="text-white font-medium">{totalPages || 1}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
242
frontend/src/pages/admin/AdminMarathonsPage.tsx
Normal file
242
frontend/src/pages/admin/AdminMarathonsPage.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminMarathon } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2 } from 'lucide-react'
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
|
||||
preparing: {
|
||||
label: 'Подготовка',
|
||||
icon: Loader2,
|
||||
className: 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
|
||||
},
|
||||
active: {
|
||||
label: 'Активный',
|
||||
icon: Clock,
|
||||
className: 'bg-green-500/20 text-green-400 border border-green-500/30'
|
||||
},
|
||||
finished: {
|
||||
label: 'Завершён',
|
||||
icon: CheckCircle,
|
||||
className: 'bg-dark-600/50 text-gray-400 border border-dark-500'
|
||||
},
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return '—'
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminMarathonsPage() {
|
||||
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const LIMIT = 20
|
||||
|
||||
const loadMarathons = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listMarathons(page * LIMIT, LIMIT, search || undefined)
|
||||
setMarathons(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load marathons:', err)
|
||||
toast.error('Ошибка загрузки марафонов')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => {
|
||||
loadMarathons()
|
||||
}, [loadMarathons])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(0)
|
||||
loadMarathons()
|
||||
}
|
||||
|
||||
const handleDelete = async (marathon: AdminMarathon) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Удалить марафон',
|
||||
message: `Вы уверены, что хотите удалить марафон "${marathon.title}"? Это действие необратимо.`,
|
||||
confirmText: 'Удалить',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await adminApi.deleteMarathon(marathon.id)
|
||||
setMarathons(marathons.filter(m => m.id !== marathon.id))
|
||||
toast.success('Марафон удалён')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete marathon:', err)
|
||||
toast.error('Ошибка удаления')
|
||||
}
|
||||
}
|
||||
|
||||
const handleForceFinish = async (marathon: AdminMarathon) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Завершить марафон',
|
||||
message: `Принудительно завершить марафон "${marathon.title}"? Участники получат уведомление.`,
|
||||
confirmText: 'Завершить',
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await adminApi.forceFinishMarathon(marathon.id)
|
||||
setMarathons(marathons.map(m =>
|
||||
m.id === marathon.id ? { ...m, status: 'finished' } : m
|
||||
))
|
||||
toast.success('Марафон завершён')
|
||||
} catch (err) {
|
||||
console.error('Failed to finish marathon:', err)
|
||||
toast.error('Ошибка завершения')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
|
||||
<Trophy className="w-6 h-6 text-accent-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Марафоны</h1>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по названию..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl pl-10 pr-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<NeonButton type="submit" color="purple">
|
||||
Найти
|
||||
</NeonButton>
|
||||
</form>
|
||||
|
||||
{/* Marathons Table */}
|
||||
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-dark-700/50 border-b border-dark-600">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">ID</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-600">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : marathons.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
Марафоны не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
marathons.map((marathon) => {
|
||||
const statusConfig = STATUS_CONFIG[marathon.status] || STATUS_CONFIG.finished
|
||||
const StatusIcon = statusConfig.icon
|
||||
return (
|
||||
<tr key={marathon.id} className="hover:bg-dark-700/30 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{marathon.id}</td>
|
||||
<td className="px-4 py-3 text-sm text-white font-medium">{marathon.title}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-300">{marathon.creator.nickname}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg ${statusConfig.className}`}>
|
||||
<StatusIcon className={`w-3 h-3 ${marathon.status === 'preparing' ? 'animate-spin' : ''}`} />
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">
|
||||
<span className="text-gray-500">{formatDate(marathon.start_date)}</span>
|
||||
<span className="text-gray-600 mx-1">→</span>
|
||||
<span className="text-gray-500">{formatDate(marathon.end_date)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{marathon.status !== 'finished' && (
|
||||
<button
|
||||
onClick={() => handleForceFinish(marathon)}
|
||||
className="p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
|
||||
title="Завершить марафон"
|
||||
>
|
||||
<StopCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(marathon)}
|
||||
className="p-2 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Назад
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Страница <span className="text-white font-medium">{page + 1}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={marathons.length < LIMIT}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
398
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
398
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminUser, UserRole } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X } from 'lucide-react'
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [bannedOnly, setBannedOnly] = useState(false)
|
||||
const [page, setPage] = useState(0)
|
||||
const [banModalUser, setBanModalUser] = useState<AdminUser | null>(null)
|
||||
const [banReason, setBanReason] = useState('')
|
||||
const [banDuration, setBanDuration] = useState<string>('permanent')
|
||||
const [banCustomDate, setBanCustomDate] = useState('')
|
||||
const [banning, setBanning] = useState(false)
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const LIMIT = 20
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listUsers(page * LIMIT, LIMIT, search || undefined, bannedOnly)
|
||||
setUsers(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err)
|
||||
toast.error('Ошибка загрузки пользователей')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, search, bannedOnly])
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [loadUsers])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(0)
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const handleBan = async () => {
|
||||
if (!banModalUser || !banReason.trim()) return
|
||||
|
||||
let bannedUntil: string | undefined
|
||||
if (banDuration !== 'permanent') {
|
||||
const now = new Date()
|
||||
if (banDuration === '1d') {
|
||||
now.setDate(now.getDate() + 1)
|
||||
bannedUntil = now.toISOString()
|
||||
} else if (banDuration === '7d') {
|
||||
now.setDate(now.getDate() + 7)
|
||||
bannedUntil = now.toISOString()
|
||||
} else if (banDuration === '30d') {
|
||||
now.setDate(now.getDate() + 30)
|
||||
bannedUntil = now.toISOString()
|
||||
} else if (banDuration === 'custom' && banCustomDate) {
|
||||
bannedUntil = new Date(banCustomDate).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
setBanning(true)
|
||||
try {
|
||||
const updated = await adminApi.banUser(banModalUser.id, banReason, bannedUntil)
|
||||
setUsers(users.map(u => u.id === updated.id ? updated : u))
|
||||
toast.success(`Пользователь ${updated.nickname} заблокирован`)
|
||||
setBanModalUser(null)
|
||||
setBanReason('')
|
||||
setBanDuration('permanent')
|
||||
setBanCustomDate('')
|
||||
} catch (err) {
|
||||
console.error('Failed to ban user:', err)
|
||||
toast.error('Ошибка блокировки')
|
||||
} finally {
|
||||
setBanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnban = async (user: AdminUser) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Разблокировать пользователя',
|
||||
message: `Вы уверены, что хотите разблокировать ${user.nickname}?`,
|
||||
confirmText: 'Разблокировать',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const updated = await adminApi.unbanUser(user.id)
|
||||
setUsers(users.map(u => u.id === updated.id ? updated : u))
|
||||
toast.success(`Пользователь ${updated.nickname} разблокирован`)
|
||||
} catch (err) {
|
||||
console.error('Failed to unban user:', err)
|
||||
toast.error('Ошибка разблокировки')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleChange = async (user: AdminUser, newRole: UserRole) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Изменить роль',
|
||||
message: `Изменить роль ${user.nickname} на ${newRole === 'admin' ? 'Администратор' : 'Пользователь'}?`,
|
||||
confirmText: 'Изменить',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const updated = await adminApi.setUserRole(user.id, newRole)
|
||||
setUsers(users.map(u => u.id === updated.id ? updated : u))
|
||||
toast.success(`Роль ${updated.nickname} изменена`)
|
||||
} catch (err) {
|
||||
console.error('Failed to change role:', err)
|
||||
toast.error('Ошибка изменения роли')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/20 border border-blue-500/30">
|
||||
<Users className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Пользователи</h1>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по логину или никнейму..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl pl-10 pr-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<NeonButton type="submit" color="purple">
|
||||
Найти
|
||||
</NeonButton>
|
||||
</form>
|
||||
|
||||
<label className="flex items-center gap-2 text-gray-300 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bannedOnly}
|
||||
onChange={(e) => {
|
||||
setBannedOnly(e.target.checked)
|
||||
setPage(0)
|
||||
}}
|
||||
className="w-4 h-4 rounded border-dark-600 bg-dark-700 text-accent-500 focus:ring-accent-500/50 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="group-hover:text-white transition-colors">Только заблокированные</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-dark-700/50 border-b border-dark-600">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">ID</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Логин</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Никнейм</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Роль</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Telegram</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Марафоны</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-600">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
Пользователи не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-dark-700/30 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{user.id}</td>
|
||||
<td className="px-4 py-3 text-sm text-white">{user.login}</td>
|
||||
<td className="px-4 py-3 text-sm text-white font-medium">{user.nickname}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg ${
|
||||
user.role === 'admin'
|
||||
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
||||
: 'bg-dark-600/50 text-gray-400 border border-dark-500'
|
||||
}`}>
|
||||
{user.role === 'admin' && <Shield className="w-3 h-3" />}
|
||||
{user.role === 'admin' ? 'Админ' : 'Пользователь'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">
|
||||
{user.telegram_username ? (
|
||||
<span className="text-neon-400">@{user.telegram_username}</span>
|
||||
) : (
|
||||
<span className="text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{user.marathons_count}</td>
|
||||
<td className="px-4 py-3">
|
||||
{user.is_banned ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
|
||||
<Ban className="w-3 h-3" />
|
||||
Заблокирован
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
Активен
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{user.is_banned ? (
|
||||
<button
|
||||
onClick={() => handleUnban(user)}
|
||||
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
|
||||
title="Разблокировать"
|
||||
>
|
||||
<UserCheck className="w-4 h-4" />
|
||||
</button>
|
||||
) : user.role !== 'admin' ? (
|
||||
<button
|
||||
onClick={() => setBanModalUser(user)}
|
||||
className="p-2 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors"
|
||||
title="Заблокировать"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{user.role === 'admin' ? (
|
||||
<button
|
||||
onClick={() => handleRoleChange(user, 'user')}
|
||||
className="p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
|
||||
title="Снять права админа"
|
||||
>
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleRoleChange(user, 'admin')}
|
||||
className="p-2 text-accent-400 hover:bg-accent-500/20 rounded-lg transition-colors"
|
||||
title="Сделать админом"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Назад
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Страница <span className="text-white font-medium">{page + 1}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={users.length < LIMIT}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ban Modal */}
|
||||
{banModalUser && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Ban className="w-5 h-5 text-red-400" />
|
||||
Заблокировать {banModalUser.nickname}?
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setBanModalUser(null)
|
||||
setBanReason('')
|
||||
setBanDuration('permanent')
|
||||
setBanCustomDate('')
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ban Duration */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Срок блокировки
|
||||
</label>
|
||||
<select
|
||||
value={banDuration}
|
||||
onChange={(e) => setBanDuration(e.target.value)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
>
|
||||
<option value="permanent">Навсегда</option>
|
||||
<option value="1d">1 день</option>
|
||||
<option value="7d">7 дней</option>
|
||||
<option value="30d">30 дней</option>
|
||||
<option value="custom">Указать дату</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom Date */}
|
||||
{banDuration === 'custom' && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Разблокировать
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={banCustomDate}
|
||||
onChange={(e) => setBanCustomDate(e.target.value)}
|
||||
min={new Date().toISOString().slice(0, 16)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reason */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Причина
|
||||
</label>
|
||||
<textarea
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
placeholder="Причина блокировки..."
|
||||
rows={3}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<NeonButton
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setBanModalUser(null)
|
||||
setBanReason('')
|
||||
setBanDuration('permanent')
|
||||
setBanCustomDate('')
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
variant="danger"
|
||||
onClick={handleBan}
|
||||
disabled={!banReason.trim() || banning || (banDuration === 'custom' && !banCustomDate)}
|
||||
isLoading={banning}
|
||||
icon={<Ban className="w-4 h-4" />}
|
||||
>
|
||||
Заблокировать
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
frontend/src/pages/admin/index.ts
Normal file
7
frontend/src/pages/admin/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { AdminLayout } from './AdminLayout'
|
||||
export { AdminDashboardPage } from './AdminDashboardPage'
|
||||
export { AdminUsersPage } from './AdminUsersPage'
|
||||
export { AdminMarathonsPage } from './AdminMarathonsPage'
|
||||
export { AdminLogsPage } from './AdminLogsPage'
|
||||
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
||||
export { AdminContentPage } from './AdminContentPage'
|
||||
@@ -3,6 +3,21 @@ import { persist } from 'zustand/middleware'
|
||||
import type { User } from '@/types'
|
||||
import { authApi, type RegisterData, type LoginData } from '@/api/auth'
|
||||
|
||||
interface Pending2FA {
|
||||
sessionId: number
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
requires2FA: boolean
|
||||
sessionId?: number
|
||||
}
|
||||
|
||||
export interface BanInfo {
|
||||
banned_at: string | null
|
||||
banned_until: string | null
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
token: string | null
|
||||
@@ -11,8 +26,12 @@ interface AuthState {
|
||||
error: string | null
|
||||
pendingInviteCode: string | null
|
||||
avatarVersion: number
|
||||
pending2FA: Pending2FA | null
|
||||
banInfo: BanInfo | null
|
||||
|
||||
login: (data: LoginData) => Promise<void>
|
||||
login: (data: LoginData) => Promise<LoginResult>
|
||||
verify2FA: (code: string) => Promise<void>
|
||||
cancel2FA: () => void
|
||||
register: (data: RegisterData) => Promise<void>
|
||||
logout: () => void
|
||||
clearError: () => void
|
||||
@@ -20,6 +39,8 @@ interface AuthState {
|
||||
consumePendingInviteCode: () => string | null
|
||||
updateUser: (updates: Partial<User>) => void
|
||||
bumpAvatarVersion: () => void
|
||||
setBanned: (banInfo: BanInfo) => void
|
||||
clearBanned: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
@@ -32,11 +53,25 @@ export const useAuthStore = create<AuthState>()(
|
||||
error: null,
|
||||
pendingInviteCode: null,
|
||||
avatarVersion: 0,
|
||||
pending2FA: null,
|
||||
banInfo: null,
|
||||
|
||||
login: async (data) => {
|
||||
set({ isLoading: true, error: null })
|
||||
set({ isLoading: true, error: null, pending2FA: null })
|
||||
try {
|
||||
const response = await authApi.login(data)
|
||||
|
||||
// Check if 2FA is required
|
||||
if (response.requires_2fa && response.two_factor_session_id) {
|
||||
set({
|
||||
isLoading: false,
|
||||
pending2FA: { sessionId: response.two_factor_session_id },
|
||||
})
|
||||
return { requires2FA: true, sessionId: response.two_factor_session_id }
|
||||
}
|
||||
|
||||
// Regular login (no 2FA)
|
||||
if (response.access_token && response.user) {
|
||||
localStorage.setItem('token', response.access_token)
|
||||
set({
|
||||
user: response.user,
|
||||
@@ -44,6 +79,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
return { requires2FA: false }
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
set({
|
||||
@@ -54,6 +91,37 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
verify2FA: async (code) => {
|
||||
const pending = get().pending2FA
|
||||
if (!pending) {
|
||||
throw new Error('No pending 2FA session')
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const response = await authApi.verify2FA(pending.sessionId, code)
|
||||
localStorage.setItem('token', response.access_token)
|
||||
set({
|
||||
user: response.user,
|
||||
token: response.access_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
pending2FA: null,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
set({
|
||||
error: error.response?.data?.detail || '2FA verification failed',
|
||||
isLoading: false,
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
cancel2FA: () => {
|
||||
set({ pending2FA: null, error: null })
|
||||
},
|
||||
|
||||
register: async (data) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
@@ -81,6 +149,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
banInfo: null,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -104,6 +173,14 @@ export const useAuthStore = create<AuthState>()(
|
||||
bumpAvatarVersion: () => {
|
||||
set({ avatarVersion: get().avatarVersion + 1 })
|
||||
},
|
||||
|
||||
setBanned: (banInfo) => {
|
||||
set({ banInfo })
|
||||
},
|
||||
|
||||
clearBanned: () => {
|
||||
set({ banInfo: null })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
// User types
|
||||
export type UserRole = 'user' | 'admin'
|
||||
|
||||
export interface User {
|
||||
// Public user info (visible to other users)
|
||||
export interface UserPublic {
|
||||
id: number
|
||||
login: string
|
||||
nickname: string
|
||||
avatar_url: string | null
|
||||
role: UserRole
|
||||
telegram_id: number | null
|
||||
telegram_username: string | null
|
||||
telegram_first_name: string | null
|
||||
telegram_last_name: string | null
|
||||
telegram_avatar_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Full user info (only for own profile from /auth/me)
|
||||
export interface User extends UserPublic {
|
||||
login?: string // Only visible to self
|
||||
telegram_id?: number | null // Only visible to self
|
||||
telegram_username?: string | null // Only visible to self
|
||||
telegram_first_name?: string | null // Only visible to self
|
||||
telegram_last_name?: string | null // Only visible to self
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
user: User
|
||||
}
|
||||
|
||||
// Login response (may require 2FA for admins)
|
||||
export interface LoginResponse {
|
||||
access_token?: string | null
|
||||
token_type: string
|
||||
user?: User | null
|
||||
requires_2fa: boolean
|
||||
two_factor_session_id?: number | null
|
||||
}
|
||||
|
||||
// Marathon types
|
||||
export type MarathonStatus = 'preparing' | 'active' | 'finished'
|
||||
export type ParticipantRole = 'participant' | 'organizer'
|
||||
@@ -130,6 +144,13 @@ export type ChallengeType =
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard'
|
||||
export type ProofType = 'screenshot' | 'video' | 'steam'
|
||||
|
||||
export type ChallengeStatus = 'pending' | 'approved' | 'rejected'
|
||||
|
||||
export interface ProposedByUser {
|
||||
id: number
|
||||
nickname: string
|
||||
}
|
||||
|
||||
export interface Challenge {
|
||||
id: number
|
||||
game: GameShort
|
||||
@@ -143,6 +164,8 @@ export interface Challenge {
|
||||
proof_hint: string | null
|
||||
is_generated: boolean
|
||||
created_at: string
|
||||
status: ChallengeStatus
|
||||
proposed_by: ProposedByUser | null
|
||||
}
|
||||
|
||||
export interface ChallengePreview {
|
||||
@@ -390,6 +413,10 @@ export interface AdminUser {
|
||||
telegram_username: string | null
|
||||
marathons_count: number
|
||||
created_at: string
|
||||
is_banned: boolean
|
||||
banned_at: string | null
|
||||
banned_until: string | null // null = permanent ban
|
||||
ban_reason: string | null
|
||||
}
|
||||
|
||||
export interface AdminMarathon {
|
||||
@@ -411,6 +438,64 @@ export interface PlatformStats {
|
||||
total_participations: number
|
||||
}
|
||||
|
||||
// Admin action log types
|
||||
export type AdminActionType =
|
||||
| 'user_ban'
|
||||
| 'user_unban'
|
||||
| 'user_role_change'
|
||||
| 'marathon_force_finish'
|
||||
| 'marathon_delete'
|
||||
| 'content_update'
|
||||
| 'broadcast_all'
|
||||
| 'broadcast_marathon'
|
||||
| 'admin_login'
|
||||
| 'admin_2fa_success'
|
||||
| 'admin_2fa_fail'
|
||||
|
||||
export interface AdminLog {
|
||||
id: number
|
||||
admin_id: number
|
||||
admin_nickname: string
|
||||
action: AdminActionType
|
||||
target_type: string
|
||||
target_id: number
|
||||
details: Record<string, unknown> | null
|
||||
ip_address: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminLogsResponse {
|
||||
logs: AdminLog[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// Broadcast types
|
||||
export interface BroadcastResponse {
|
||||
sent_count: number
|
||||
total_count: number
|
||||
}
|
||||
|
||||
// Static content types
|
||||
export interface StaticContent {
|
||||
id: number
|
||||
key: string
|
||||
title: string
|
||||
content: string
|
||||
updated_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Dashboard stats
|
||||
export interface DashboardStats {
|
||||
users_count: number
|
||||
banned_users_count: number
|
||||
marathons_count: number
|
||||
active_marathons_count: number
|
||||
games_count: number
|
||||
total_participations: number
|
||||
recent_logs: AdminLog[]
|
||||
}
|
||||
|
||||
// Dispute types
|
||||
export type DisputeStatus = 'open' | 'valid' | 'invalid'
|
||||
|
||||
|
||||
20
nginx.conf
20
nginx.conf
@@ -17,6 +17,10 @@ http {
|
||||
# File upload limit (15 MB)
|
||||
client_max_body_size 15M;
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api_general:10m rate=60r/m;
|
||||
|
||||
upstream backend {
|
||||
server backend:8000;
|
||||
}
|
||||
@@ -37,8 +41,22 @@ http {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
# Auth API - strict rate limit (10 req/min with burst of 5)
|
||||
location /api/v1/auth {
|
||||
limit_req zone=api_auth burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Backend API - general rate limit (60 req/min with burst of 20)
|
||||
location /api {
|
||||
limit_req zone=api_general burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -6,6 +6,9 @@ WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
|
||||
85
status-service/alerts.py
Normal file
85
status-service/alerts.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Telegram alerting for status changes."""
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_ADMIN_ID = os.getenv("TELEGRAM_ADMIN_ID", "")
|
||||
|
||||
|
||||
async def send_telegram_alert(message: str, is_recovery: bool = False) -> bool:
|
||||
"""Send alert to Telegram."""
|
||||
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_ADMIN_ID:
|
||||
print("Telegram alerting not configured")
|
||||
return False
|
||||
|
||||
emoji = "\u2705" if is_recovery else "\u26a0\ufe0f"
|
||||
text = f"{emoji} *Status Alert*\n\n{message}"
|
||||
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
data = {
|
||||
"chat_id": TELEGRAM_ADMIN_ID,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
print(f"Telegram alert sent: {message[:50]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to send Telegram alert: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def alert_service_down(service_name: str, display_name: str, message: Optional[str]):
|
||||
"""Alert when service goes down."""
|
||||
now = datetime.now().strftime("%d.%m.%Y %H:%M:%S")
|
||||
text = (
|
||||
f"*{display_name}* is DOWN\n\n"
|
||||
f"Time: `{now}`\n"
|
||||
)
|
||||
if message:
|
||||
text += f"Error: `{message}`"
|
||||
|
||||
await send_telegram_alert(text, is_recovery=False)
|
||||
|
||||
|
||||
async def alert_service_recovered(service_name: str, display_name: str, downtime_minutes: int):
|
||||
"""Alert when service recovers."""
|
||||
now = datetime.now().strftime("%d.%m.%Y %H:%M:%S")
|
||||
text = (
|
||||
f"*{display_name}* is back ONLINE\n\n"
|
||||
f"Time: `{now}`\n"
|
||||
f"Downtime: `{downtime_minutes} min`"
|
||||
)
|
||||
|
||||
await send_telegram_alert(text, is_recovery=True)
|
||||
|
||||
|
||||
async def alert_ssl_expiring(domain: str, days_left: int):
|
||||
"""Alert when SSL certificate is expiring soon."""
|
||||
text = (
|
||||
f"*SSL Certificate Expiring*\n\n"
|
||||
f"Domain: `{domain}`\n"
|
||||
f"Days left: `{days_left}`\n\n"
|
||||
f"Please renew the certificate!"
|
||||
)
|
||||
|
||||
await send_telegram_alert(text, is_recovery=False)
|
||||
|
||||
|
||||
async def alert_ssl_expired(domain: str):
|
||||
"""Alert when SSL certificate has expired."""
|
||||
text = (
|
||||
f"*SSL Certificate EXPIRED*\n\n"
|
||||
f"Domain: `{domain}`\n\n"
|
||||
f"Certificate has expired! Site may show security warnings."
|
||||
)
|
||||
|
||||
await send_telegram_alert(text, is_recovery=False)
|
||||
261
status-service/database.py
Normal file
261
status-service/database.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""SQLite database for storing metrics history."""
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import json
|
||||
|
||||
|
||||
DB_PATH = Path("/app/data/metrics.db")
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
"""Get database connection."""
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize database tables."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Metrics history table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
service_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
latency_ms REAL,
|
||||
message TEXT,
|
||||
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Incidents table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS incidents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
service_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at TIMESTAMP,
|
||||
notified BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
""")
|
||||
|
||||
# SSL certificates table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ssl_certificates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL UNIQUE,
|
||||
issuer TEXT,
|
||||
expires_at TIMESTAMP,
|
||||
days_until_expiry INTEGER,
|
||||
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_service_time
|
||||
ON metrics(service_name, checked_at DESC)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_service
|
||||
ON incidents(service_name, started_at DESC)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_metric(service_name: str, status: str, latency_ms: Optional[float], message: Optional[str]):
|
||||
"""Save a metric record."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO metrics (service_name, status, latency_ms, message) VALUES (?, ?, ?, ?)",
|
||||
(service_name, status, latency_ms, message)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_latency_history(service_name: str, hours: int = 24) -> list[dict]:
|
||||
"""Get latency history for a service."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
since = datetime.now() - timedelta(hours=hours)
|
||||
cursor.execute("""
|
||||
SELECT latency_ms, status, checked_at
|
||||
FROM metrics
|
||||
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
|
||||
ORDER BY checked_at ASC
|
||||
""", (service_name, since.isoformat()))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
{
|
||||
"latency_ms": row["latency_ms"],
|
||||
"status": row["status"],
|
||||
"checked_at": row["checked_at"]
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def get_uptime_stats(service_name: str, hours: int = 24) -> dict:
|
||||
"""Calculate uptime statistics for a service."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
since = datetime.now() - timedelta(hours=hours)
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'operational' THEN 1 ELSE 0 END) as successful
|
||||
FROM metrics
|
||||
WHERE service_name = ? AND checked_at > ?
|
||||
""", (service_name, since.isoformat()))
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
total = row["total"] or 0
|
||||
successful = row["successful"] or 0
|
||||
|
||||
return {
|
||||
"total_checks": total,
|
||||
"successful_checks": successful,
|
||||
"uptime_percent": (successful / total * 100) if total > 0 else 100.0
|
||||
}
|
||||
|
||||
|
||||
def get_avg_latency(service_name: str, hours: int = 24) -> Optional[float]:
|
||||
"""Get average latency for a service."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
since = datetime.now() - timedelta(hours=hours)
|
||||
cursor.execute("""
|
||||
SELECT AVG(latency_ms) as avg_latency
|
||||
FROM metrics
|
||||
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
|
||||
""", (service_name, since.isoformat()))
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
return row["avg_latency"]
|
||||
|
||||
|
||||
def create_incident(service_name: str, status: str, message: Optional[str]) -> int:
|
||||
"""Create a new incident."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO incidents (service_name, status, message) VALUES (?, ?, ?)",
|
||||
(service_name, status, message)
|
||||
)
|
||||
incident_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return incident_id
|
||||
|
||||
|
||||
def resolve_incident(service_name: str):
|
||||
"""Resolve open incidents for a service."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE incidents
|
||||
SET resolved_at = CURRENT_TIMESTAMP
|
||||
WHERE service_name = ? AND resolved_at IS NULL
|
||||
""", (service_name,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_open_incident(service_name: str) -> Optional[dict]:
|
||||
"""Get open incident for a service."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM incidents
|
||||
WHERE service_name = ? AND resolved_at IS NULL
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
""", (service_name,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
def mark_incident_notified(incident_id: int):
|
||||
"""Mark incident as notified."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE incidents SET notified = TRUE WHERE id = ?", (incident_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_recent_incidents(limit: int = 10) -> list[dict]:
|
||||
"""Get recent incidents."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM incidents
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def save_ssl_info(domain: str, issuer: str, expires_at: datetime, days_until_expiry: int):
|
||||
"""Save SSL certificate info."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO ssl_certificates
|
||||
(domain, issuer, expires_at, days_until_expiry, checked_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
""", (domain, issuer, expires_at.isoformat(), days_until_expiry))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_ssl_info(domain: str) -> Optional[dict]:
|
||||
"""Get SSL certificate info."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM ssl_certificates WHERE domain = ?", (domain,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_old_metrics(days: int = 1):
|
||||
"""Delete metrics older than specified days (default: 24 hours)."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
cursor.execute("DELETE FROM metrics WHERE checked_at < ?", (cutoff.isoformat(),))
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return deleted
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Status monitoring service with persistence and alerting."""
|
||||
import os
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -8,52 +9,81 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from monitors import ServiceMonitor, ServiceStatus
|
||||
from monitors import ServiceMonitor
|
||||
from database import init_db, get_recent_incidents, get_latency_history, cleanup_old_metrics
|
||||
|
||||
|
||||
# Configuration
|
||||
BACKEND_URL = os.getenv("BACKEND_URL", "http://backend:8000")
|
||||
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://frontend:80")
|
||||
BOT_URL = os.getenv("BOT_URL", "http://bot:8080")
|
||||
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "30"))
|
||||
EXTERNAL_URL = os.getenv("EXTERNAL_URL", "") # Public URL for external checks
|
||||
PUBLIC_URL = os.getenv("PUBLIC_URL", "") # Public HTTPS URL for SSL checks
|
||||
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "600")) # 10 minutes
|
||||
|
||||
# Initialize monitor
|
||||
monitor = ServiceMonitor()
|
||||
|
||||
# Background task reference
|
||||
background_task: Optional[asyncio.Task] = None
|
||||
cleanup_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
async def periodic_health_check():
|
||||
"""Background task to check services periodically"""
|
||||
"""Background task to check services periodically."""
|
||||
while True:
|
||||
try:
|
||||
await monitor.check_all_services(
|
||||
backend_url=BACKEND_URL,
|
||||
frontend_url=FRONTEND_URL,
|
||||
bot_url=BOT_URL
|
||||
bot_url=BOT_URL,
|
||||
external_url=EXTERNAL_URL,
|
||||
public_url=PUBLIC_URL
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Health check error: {e}")
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
|
||||
async def periodic_cleanup():
|
||||
"""Background task to cleanup old metrics (hourly)."""
|
||||
while True:
|
||||
await asyncio.sleep(3600) # 1 hour
|
||||
try:
|
||||
deleted = cleanup_old_metrics(days=1) # Keep only last 24 hours
|
||||
print(f"Cleaned up {deleted} old metrics")
|
||||
except Exception as e:
|
||||
print(f"Cleanup error: {e}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup and shutdown events"""
|
||||
global background_task
|
||||
"""Startup and shutdown events."""
|
||||
global background_task, cleanup_task
|
||||
|
||||
# Initialize database
|
||||
init_db()
|
||||
print("Database initialized")
|
||||
|
||||
# Start background health checks
|
||||
background_task = asyncio.create_task(periodic_health_check())
|
||||
cleanup_task = asyncio.create_task(periodic_cleanup())
|
||||
|
||||
yield
|
||||
# Cancel background task on shutdown
|
||||
if background_task:
|
||||
background_task.cancel()
|
||||
|
||||
# Cancel background tasks on shutdown
|
||||
for task in [background_task, cleanup_task]:
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await background_task
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Status Monitor",
|
||||
description="Service health monitoring",
|
||||
description="Service health monitoring with persistence and alerting",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
@@ -62,9 +92,11 @@ templates = Jinja2Templates(directory="templates")
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def status_page(request: Request):
|
||||
"""Main status page"""
|
||||
"""Main status page."""
|
||||
services = monitor.get_all_statuses()
|
||||
overall_status = monitor.get_overall_status()
|
||||
ssl_status = monitor.get_ssl_status()
|
||||
incidents = get_recent_incidents(limit=5)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
@@ -72,6 +104,8 @@ async def status_page(request: Request):
|
||||
"request": request,
|
||||
"services": services,
|
||||
"overall_status": overall_status,
|
||||
"ssl_status": ssl_status,
|
||||
"incidents": incidents,
|
||||
"last_check": monitor.last_check,
|
||||
"check_interval": CHECK_INTERVAL
|
||||
}
|
||||
@@ -80,30 +114,52 @@ async def status_page(request: Request):
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_status():
|
||||
"""API endpoint for service statuses"""
|
||||
"""API endpoint for service statuses."""
|
||||
services = monitor.get_all_statuses()
|
||||
overall_status = monitor.get_overall_status()
|
||||
ssl_status = monitor.get_ssl_status()
|
||||
|
||||
return {
|
||||
"overall_status": overall_status,
|
||||
"overall_status": overall_status.value,
|
||||
"services": {name: status.to_dict() for name, status in services.items()},
|
||||
"ssl": ssl_status,
|
||||
"last_check": monitor.last_check.isoformat() if monitor.last_check else None,
|
||||
"check_interval_seconds": CHECK_INTERVAL
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/history/{service_name}")
|
||||
async def api_history(service_name: str, hours: int = 24):
|
||||
"""API endpoint for service latency history."""
|
||||
history = get_latency_history(service_name, hours=hours)
|
||||
return {
|
||||
"service": service_name,
|
||||
"hours": hours,
|
||||
"data": history
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/incidents")
|
||||
async def api_incidents(limit: int = 20):
|
||||
"""API endpoint for recent incidents."""
|
||||
incidents = get_recent_incidents(limit=limit)
|
||||
return {"incidents": incidents}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
"""Health check for this service"""
|
||||
"""Health check for this service."""
|
||||
return {"status": "ok", "service": "status-monitor"}
|
||||
|
||||
|
||||
@app.post("/api/refresh")
|
||||
async def refresh_status():
|
||||
"""Force refresh all service statuses"""
|
||||
"""Force refresh all service statuses."""
|
||||
await monitor.check_all_services(
|
||||
backend_url=BACKEND_URL,
|
||||
frontend_url=FRONTEND_URL,
|
||||
bot_url=BOT_URL
|
||||
bot_url=BOT_URL,
|
||||
external_url=EXTERNAL_URL,
|
||||
public_url=PUBLIC_URL
|
||||
)
|
||||
return {"status": "refreshed"}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
"""Service monitoring with persistence and alerting."""
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
import httpx
|
||||
|
||||
from database import (
|
||||
save_metric, get_latency_history, get_uptime_stats, get_avg_latency,
|
||||
create_incident, resolve_incident, get_open_incident, mark_incident_notified
|
||||
)
|
||||
from alerts import alert_service_down, alert_service_recovered
|
||||
from ssl_monitor import check_and_alert_ssl, SSLInfo
|
||||
|
||||
|
||||
class Status(str, Enum):
|
||||
OPERATIONAL = "operational"
|
||||
@@ -25,11 +33,17 @@ class ServiceStatus:
|
||||
uptime_percent: float = 100.0
|
||||
message: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
avg_latency_24h: Optional[float] = None
|
||||
latency_history: list = None
|
||||
|
||||
# For uptime calculation
|
||||
# For uptime calculation (in-memory, backed by DB)
|
||||
total_checks: int = 0
|
||||
successful_checks: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
if self.latency_history is None:
|
||||
self.latency_history = []
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
@@ -40,7 +54,8 @@ class ServiceStatus:
|
||||
"last_incident": self.last_incident.isoformat() if self.last_incident else None,
|
||||
"uptime_percent": round(self.uptime_percent, 2),
|
||||
"message": self.message,
|
||||
"version": self.version
|
||||
"version": self.version,
|
||||
"avg_latency_24h": round(self.avg_latency_24h, 2) if self.avg_latency_24h else None,
|
||||
}
|
||||
|
||||
def update_uptime(self, is_success: bool):
|
||||
@@ -69,12 +84,17 @@ class ServiceMonitor:
|
||||
"bot": ServiceStatus(
|
||||
name="bot",
|
||||
display_name="Telegram Bot"
|
||||
)
|
||||
),
|
||||
"external": ServiceStatus(
|
||||
name="external",
|
||||
display_name="External Access"
|
||||
),
|
||||
}
|
||||
self.last_check: Optional[datetime] = None
|
||||
self.ssl_info: Optional[SSLInfo] = None
|
||||
|
||||
async def check_backend(self, url: str) -> tuple[Status, Optional[float], Optional[str], Optional[str]]:
|
||||
"""Check backend API health"""
|
||||
"""Check backend API health."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
start = datetime.now()
|
||||
@@ -92,9 +112,7 @@ class ServiceMonitor:
|
||||
return Status.DOWN, None, str(e)[:100], None
|
||||
|
||||
async def check_database(self, backend_url: str) -> tuple[Status, Optional[float], Optional[str]]:
|
||||
"""Check database through backend"""
|
||||
# We check database indirectly - if backend is up, DB is likely up
|
||||
# Could add a specific /health/db endpoint to backend later
|
||||
"""Check database through backend."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
start = datetime.now()
|
||||
@@ -109,7 +127,7 @@ class ServiceMonitor:
|
||||
return Status.DOWN, None, "Cannot reach backend"
|
||||
|
||||
async def check_frontend(self, url: str) -> tuple[Status, Optional[float], Optional[str]]:
|
||||
"""Check frontend availability"""
|
||||
"""Check frontend availability."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
start = datetime.now()
|
||||
@@ -126,7 +144,7 @@ class ServiceMonitor:
|
||||
return Status.DOWN, None, str(e)[:100]
|
||||
|
||||
async def check_bot(self, url: str) -> tuple[Status, Optional[float], Optional[str]]:
|
||||
"""Check Telegram bot health"""
|
||||
"""Check Telegram bot health."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
start = datetime.now()
|
||||
@@ -142,8 +160,93 @@ class ServiceMonitor:
|
||||
except Exception as e:
|
||||
return Status.DOWN, None, str(e)[:100]
|
||||
|
||||
async def check_all_services(self, backend_url: str, frontend_url: str, bot_url: str):
|
||||
"""Check all services concurrently"""
|
||||
async def check_external(self, url: str) -> tuple[Status, Optional[float], Optional[str]]:
|
||||
"""Check external (public) URL availability."""
|
||||
if not url:
|
||||
return Status.UNKNOWN, None, "Not configured"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||
start = datetime.now()
|
||||
response = await client.get(url)
|
||||
latency = (datetime.now() - start).total_seconds() * 1000
|
||||
|
||||
if response.status_code == 200:
|
||||
return Status.OPERATIONAL, latency, None
|
||||
else:
|
||||
return Status.DEGRADED, latency, f"HTTP {response.status_code}"
|
||||
except httpx.TimeoutException:
|
||||
return Status.DOWN, None, "Timeout"
|
||||
except Exception as e:
|
||||
return Status.DOWN, None, str(e)[:100]
|
||||
|
||||
async def _process_check_result(
|
||||
self,
|
||||
service_name: str,
|
||||
result: tuple,
|
||||
now: datetime
|
||||
):
|
||||
"""Process check result with DB persistence and alerting."""
|
||||
if isinstance(result, Exception):
|
||||
return
|
||||
|
||||
if len(result) == 4:
|
||||
status, latency, message, version = result
|
||||
else:
|
||||
status, latency, message = result
|
||||
version = None
|
||||
|
||||
svc = self.services[service_name]
|
||||
was_down = svc.status in (Status.DOWN, Status.DEGRADED)
|
||||
is_down = status in (Status.DOWN, Status.DEGRADED)
|
||||
|
||||
# Update service status
|
||||
svc.status = status
|
||||
svc.latency_ms = latency
|
||||
svc.message = message
|
||||
if version:
|
||||
svc.version = version
|
||||
svc.last_check = now
|
||||
svc.update_uptime(status == Status.OPERATIONAL)
|
||||
|
||||
# Save metric to database
|
||||
save_metric(service_name, status.value, latency, message)
|
||||
|
||||
# Load historical data
|
||||
svc.latency_history = get_latency_history(service_name, hours=24)
|
||||
svc.avg_latency_24h = get_avg_latency(service_name, hours=24)
|
||||
|
||||
# Update uptime from DB
|
||||
stats = get_uptime_stats(service_name, hours=24)
|
||||
if stats["total_checks"] > 0:
|
||||
svc.uptime_percent = stats["uptime_percent"]
|
||||
|
||||
# Handle incident tracking and alerting
|
||||
if is_down and not was_down:
|
||||
# Service just went down
|
||||
svc.last_incident = now
|
||||
incident_id = create_incident(service_name, status.value, message)
|
||||
await alert_service_down(service_name, svc.display_name, message)
|
||||
mark_incident_notified(incident_id)
|
||||
|
||||
elif not is_down and was_down:
|
||||
# Service recovered
|
||||
open_incident = get_open_incident(service_name)
|
||||
if open_incident:
|
||||
started_at = datetime.fromisoformat(open_incident["started_at"])
|
||||
downtime_minutes = int((now - started_at).total_seconds() / 60)
|
||||
resolve_incident(service_name)
|
||||
await alert_service_recovered(service_name, svc.display_name, downtime_minutes)
|
||||
|
||||
async def check_all_services(
|
||||
self,
|
||||
backend_url: str,
|
||||
frontend_url: str,
|
||||
bot_url: str,
|
||||
external_url: str = "",
|
||||
public_url: str = ""
|
||||
):
|
||||
"""Check all services concurrently."""
|
||||
now = datetime.now()
|
||||
|
||||
# Run all checks concurrently
|
||||
@@ -152,61 +255,18 @@ class ServiceMonitor:
|
||||
self.check_database(backend_url),
|
||||
self.check_frontend(frontend_url),
|
||||
self.check_bot(bot_url),
|
||||
self.check_external(external_url),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Process backend result
|
||||
if not isinstance(results[0], Exception):
|
||||
status, latency, message, version = results[0]
|
||||
svc = self.services["backend"]
|
||||
was_down = svc.status == Status.DOWN
|
||||
svc.status = status
|
||||
svc.latency_ms = latency
|
||||
svc.message = message
|
||||
svc.version = version
|
||||
svc.last_check = now
|
||||
svc.update_uptime(status == Status.OPERATIONAL)
|
||||
if status != Status.OPERATIONAL and not was_down:
|
||||
svc.last_incident = now
|
||||
# Process results
|
||||
service_names = ["backend", "database", "frontend", "bot", "external"]
|
||||
for i, service_name in enumerate(service_names):
|
||||
await self._process_check_result(service_name, results[i], now)
|
||||
|
||||
# Process database result
|
||||
if not isinstance(results[1], Exception):
|
||||
status, latency, message = results[1]
|
||||
svc = self.services["database"]
|
||||
was_down = svc.status == Status.DOWN
|
||||
svc.status = status
|
||||
svc.latency_ms = latency
|
||||
svc.message = message
|
||||
svc.last_check = now
|
||||
svc.update_uptime(status == Status.OPERATIONAL)
|
||||
if status != Status.OPERATIONAL and not was_down:
|
||||
svc.last_incident = now
|
||||
|
||||
# Process frontend result
|
||||
if not isinstance(results[2], Exception):
|
||||
status, latency, message = results[2]
|
||||
svc = self.services["frontend"]
|
||||
was_down = svc.status == Status.DOWN
|
||||
svc.status = status
|
||||
svc.latency_ms = latency
|
||||
svc.message = message
|
||||
svc.last_check = now
|
||||
svc.update_uptime(status == Status.OPERATIONAL)
|
||||
if status != Status.OPERATIONAL and not was_down:
|
||||
svc.last_incident = now
|
||||
|
||||
# Process bot result
|
||||
if not isinstance(results[3], Exception):
|
||||
status, latency, message = results[3]
|
||||
svc = self.services["bot"]
|
||||
was_down = svc.status == Status.DOWN
|
||||
svc.status = status
|
||||
svc.latency_ms = latency
|
||||
svc.message = message
|
||||
svc.last_check = now
|
||||
svc.update_uptime(status == Status.OPERATIONAL)
|
||||
if status != Status.OPERATIONAL and not was_down:
|
||||
svc.last_incident = now
|
||||
# Check SSL certificate (if public URL is HTTPS)
|
||||
if public_url and public_url.startswith("https://"):
|
||||
self.ssl_info = await check_and_alert_ssl(public_url)
|
||||
|
||||
self.last_check = now
|
||||
|
||||
@@ -214,8 +274,12 @@ class ServiceMonitor:
|
||||
return self.services
|
||||
|
||||
def get_overall_status(self) -> Status:
|
||||
"""Get overall system status based on all services"""
|
||||
statuses = [svc.status for svc in self.services.values()]
|
||||
"""Get overall system status based on all services."""
|
||||
# Exclude external from overall status if not configured
|
||||
statuses = [
|
||||
svc.status for name, svc in self.services.items()
|
||||
if name != "external" or svc.status != Status.UNKNOWN
|
||||
]
|
||||
|
||||
if all(s == Status.OPERATIONAL for s in statuses):
|
||||
return Status.OPERATIONAL
|
||||
@@ -225,3 +289,17 @@ class ServiceMonitor:
|
||||
return Status.DEGRADED
|
||||
else:
|
||||
return Status.UNKNOWN
|
||||
|
||||
def get_ssl_status(self) -> Optional[dict]:
|
||||
"""Get SSL certificate status."""
|
||||
if not self.ssl_info:
|
||||
return None
|
||||
|
||||
return {
|
||||
"domain": self.ssl_info.domain,
|
||||
"issuer": self.ssl_info.issuer,
|
||||
"expires_at": self.ssl_info.expires_at.isoformat(),
|
||||
"days_until_expiry": self.ssl_info.days_until_expiry,
|
||||
"is_valid": self.ssl_info.is_valid,
|
||||
"error": self.ssl_info.error
|
||||
}
|
||||
|
||||
140
status-service/ssl_monitor.py
Normal file
140
status-service/ssl_monitor.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""SSL certificate monitoring."""
|
||||
import ssl
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from database import save_ssl_info, get_ssl_info
|
||||
from alerts import alert_ssl_expiring, alert_ssl_expired
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSLInfo:
|
||||
domain: str
|
||||
issuer: str
|
||||
expires_at: datetime
|
||||
days_until_expiry: int
|
||||
is_valid: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def check_ssl_certificate(url: str) -> Optional[SSLInfo]:
|
||||
"""Check SSL certificate for a URL."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
return None
|
||||
|
||||
# Skip non-HTTPS or localhost
|
||||
if parsed.scheme != "https" or hostname in ("localhost", "127.0.0.1"):
|
||||
return None
|
||||
|
||||
context = ssl.create_default_context()
|
||||
conn = context.wrap_socket(
|
||||
socket.socket(socket.AF_INET),
|
||||
server_hostname=hostname
|
||||
)
|
||||
conn.settimeout(10.0)
|
||||
|
||||
try:
|
||||
conn.connect((hostname, parsed.port or 443))
|
||||
cert = conn.getpeercert()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not cert:
|
||||
return SSLInfo(
|
||||
domain=hostname,
|
||||
issuer="Unknown",
|
||||
expires_at=datetime.now(timezone.utc),
|
||||
days_until_expiry=0,
|
||||
is_valid=False,
|
||||
error="No certificate found"
|
||||
)
|
||||
|
||||
# Parse expiry date
|
||||
not_after = cert.get("notAfter", "")
|
||||
expires_at = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Calculate days until expiry
|
||||
now = datetime.now(timezone.utc)
|
||||
days_until_expiry = (expires_at - now).days
|
||||
|
||||
# Get issuer
|
||||
issuer_parts = cert.get("issuer", ())
|
||||
issuer = "Unknown"
|
||||
for part in issuer_parts:
|
||||
for key, value in part:
|
||||
if key == "organizationName":
|
||||
issuer = value
|
||||
break
|
||||
|
||||
return SSLInfo(
|
||||
domain=hostname,
|
||||
issuer=issuer,
|
||||
expires_at=expires_at,
|
||||
days_until_expiry=days_until_expiry,
|
||||
is_valid=days_until_expiry > 0
|
||||
)
|
||||
|
||||
except ssl.SSLCertVerificationError as e:
|
||||
hostname = urlparse(url).hostname or url
|
||||
return SSLInfo(
|
||||
domain=hostname,
|
||||
issuer="Invalid",
|
||||
expires_at=datetime.now(timezone.utc),
|
||||
days_until_expiry=0,
|
||||
is_valid=False,
|
||||
error=f"SSL verification failed: {str(e)[:100]}"
|
||||
)
|
||||
except Exception as e:
|
||||
hostname = urlparse(url).hostname or url
|
||||
return SSLInfo(
|
||||
domain=hostname,
|
||||
issuer="Unknown",
|
||||
expires_at=datetime.now(timezone.utc),
|
||||
days_until_expiry=0,
|
||||
is_valid=False,
|
||||
error=str(e)[:100]
|
||||
)
|
||||
|
||||
|
||||
async def check_and_alert_ssl(url: str, warn_days: int = 14) -> Optional[SSLInfo]:
|
||||
"""Check SSL and send alerts if needed."""
|
||||
ssl_info = check_ssl_certificate(url)
|
||||
|
||||
if not ssl_info:
|
||||
return None
|
||||
|
||||
# Save to database
|
||||
save_ssl_info(
|
||||
domain=ssl_info.domain,
|
||||
issuer=ssl_info.issuer,
|
||||
expires_at=ssl_info.expires_at,
|
||||
days_until_expiry=ssl_info.days_until_expiry
|
||||
)
|
||||
|
||||
# Check if we need to alert
|
||||
prev_info = get_ssl_info(ssl_info.domain)
|
||||
|
||||
if ssl_info.days_until_expiry <= 0:
|
||||
# Certificate expired
|
||||
await alert_ssl_expired(ssl_info.domain)
|
||||
elif ssl_info.days_until_expiry <= warn_days:
|
||||
# Certificate expiring soon - alert once per day
|
||||
should_alert = True
|
||||
if prev_info and prev_info.get("checked_at"):
|
||||
# Check if we already alerted today
|
||||
last_check = datetime.fromisoformat(prev_info["checked_at"])
|
||||
if (datetime.now() - last_check).days < 1:
|
||||
should_alert = False
|
||||
|
||||
if should_alert:
|
||||
await alert_ssl_expiring(ssl_info.domain, ssl_info.days_until_expiry)
|
||||
|
||||
return ssl_info
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>System Status</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -19,7 +20,7 @@
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
@@ -39,6 +40,13 @@
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin: 30px 0 16px 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.overall-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -174,8 +182,9 @@
|
||||
|
||||
.service-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
@@ -212,6 +221,132 @@
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Latency chart */
|
||||
.latency-chart {
|
||||
height: 60px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* SSL Card */
|
||||
.ssl-card {
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
border: 1px solid rgba(100, 116, 139, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ssl-card.warning {
|
||||
border-color: rgba(250, 204, 21, 0.3);
|
||||
}
|
||||
|
||||
.ssl-card.danger {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.ssl-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ssl-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.ssl-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ssl-badge.valid {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.ssl-badge.expiring {
|
||||
background: rgba(250, 204, 21, 0.15);
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.ssl-badge.expired {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.ssl-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Incidents */
|
||||
.incidents-list {
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
border: 1px solid rgba(100, 116, 139, 0.2);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.incident-item {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(100, 116, 139, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.incident-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.incident-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.incident-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.incident-dot.resolved {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.incident-dot.open {
|
||||
background: #ef4444;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.incident-service {
|
||||
font-weight: 500;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.incident-message {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.incident-time {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.no-incidents {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -292,8 +427,42 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{% if ssl_status %}
|
||||
<div class="ssl-card {% if ssl_status.days_until_expiry <= 0 %}danger{% elif ssl_status.days_until_expiry <= 14 %}warning{% endif %}">
|
||||
<div class="ssl-header">
|
||||
<span class="ssl-title">SSL Certificate</span>
|
||||
<span class="ssl-badge {% if ssl_status.days_until_expiry <= 0 %}expired{% elif ssl_status.days_until_expiry <= 14 %}expiring{% else %}valid{% endif %}">
|
||||
{% if ssl_status.days_until_expiry <= 0 %}
|
||||
Expired
|
||||
{% elif ssl_status.days_until_expiry <= 14 %}
|
||||
Expiring Soon
|
||||
{% else %}
|
||||
Valid
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ssl-info">
|
||||
<div class="metric">
|
||||
<div class="metric-label">Domain</div>
|
||||
<div class="metric-value">{{ ssl_status.domain }}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Issuer</div>
|
||||
<div class="metric-value">{{ ssl_status.issuer }}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Days Left</div>
|
||||
<div class="metric-value {% if ssl_status.days_until_expiry <= 0 %}bad{% elif ssl_status.days_until_expiry <= 14 %}warning{% else %}good{% endif %}">
|
||||
{{ ssl_status.days_until_expiry }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="services-grid">
|
||||
{% for name, service in services.items() %}
|
||||
{% if service.status.value != 'unknown' or name != 'external' %}
|
||||
<div class="service-card">
|
||||
<div class="service-header">
|
||||
<span class="service-name">{{ service.display_name }}</span>
|
||||
@@ -322,7 +491,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Uptime</div>
|
||||
<div class="metric-label">Avg 24h</div>
|
||||
<div class="metric-value {% if service.avg_latency_24h and service.avg_latency_24h < 200 %}good{% elif service.avg_latency_24h and service.avg_latency_24h < 500 %}warning{% elif service.avg_latency_24h %}bad{% endif %}">
|
||||
{% if service.avg_latency_24h %}
|
||||
{{ "%.0f"|format(service.avg_latency_24h) }} ms
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Uptime 24h</div>
|
||||
<div class="metric-value {% if service.uptime_percent >= 99 %}good{% elif service.uptime_percent >= 95 %}warning{% else %}bad{% endif %}">
|
||||
{{ "%.1f"|format(service.uptime_percent) }}%
|
||||
</div>
|
||||
@@ -333,20 +512,49 @@
|
||||
<div class="metric-value">{{ service.version }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if service.last_incident %}
|
||||
<div class="metric">
|
||||
<div class="metric-label">Last Incident</div>
|
||||
<div class="metric-value warning">{{ service.last_incident.strftime('%d.%m %H:%M') }}</div>
|
||||
</div>
|
||||
{% if service.latency_history and service.latency_history|length > 1 %}
|
||||
<div class="latency-chart">
|
||||
<canvas id="chart-{{ name }}"></canvas>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if service.message %}
|
||||
<div class="service-message">{{ service.message }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h2>Recent Incidents</h2>
|
||||
<div class="incidents-list">
|
||||
{% if incidents and incidents|length > 0 %}
|
||||
{% for incident in incidents %}
|
||||
<div class="incident-item">
|
||||
<div class="incident-info">
|
||||
<span class="incident-dot {% if incident.resolved_at %}resolved{% else %}open{% endif %}"></span>
|
||||
<div>
|
||||
<div class="incident-service">{{ incident.service_name | title }}</div>
|
||||
<div class="incident-message">{{ incident.message or 'Service unavailable' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="incident-time">
|
||||
{{ incident.started_at[:16].replace('T', ' ') }}
|
||||
{% if incident.resolved_at %}
|
||||
- Resolved
|
||||
{% else %}
|
||||
- Ongoing
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="no-incidents">
|
||||
No recent incidents
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<center>
|
||||
<button class="refresh-btn" onclick="refreshStatus(this)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -363,6 +571,55 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Initialize latency charts
|
||||
{% for name, service in services.items() %}
|
||||
{% if service.latency_history and service.latency_history|length > 1 %}
|
||||
(function() {
|
||||
const ctx = document.getElementById('chart-{{ name }}').getContext('2d');
|
||||
const data = {{ service.latency_history | tojson }};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.map(d => ''),
|
||||
datasets: [{
|
||||
data: data.map(d => d.latency_ms),
|
||||
borderColor: '#00d4ff',
|
||||
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => ctx.raw.toFixed(0) + ' ms'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: {
|
||||
display: false,
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
async function refreshStatus(btn) {
|
||||
btn.classList.add('loading');
|
||||
btn.disabled = true;
|
||||
|
||||
Reference in New Issue
Block a user