Compare commits

...

34 Commits

Author SHA1 Message Date
e43e579329 Finish 2025-12-17 22:13:39 +07:00
967176fab8 service 2025-12-17 22:10:01 +07:00
f371178518 500 2025-12-17 21:50:10 +07:00
3920a9bf8c teapot 2025-12-17 21:38:43 +07:00
790b2d6083 ПОЧТИ ГОТОВО 2025-12-17 20:59:47 +07:00
675a0fea0c PIZDEC 2025-12-17 20:29:22 +07:00
0b3837b08e Zaebalsya 2025-12-17 20:19:26 +07:00
7e7cdbcd76 Fix 2025-12-17 19:50:55 +07:00
debdd66458 Fix UI 2025-12-17 18:27:09 +07:00
332491454d Redesign p1 2025-12-17 02:03:33 +07:00
11f7b59471 Fix telegram avatar 2025-12-17 01:06:03 +07:00
1c07d8c5ff Fix avatars upload 2025-12-17 00:04:14 +07:00
895e296f44 Fixes 2025-12-16 22:43:03 +07:00
696dc714c4 Update GPT and add Profile 2025-12-16 22:12:12 +07:00
08b96fd1f7 Fix marathon deletion 2025-12-16 21:15:18 +07:00
ca41c207b3 Add info if linked acc 2025-12-16 20:59:50 +07:00
412de3bf05 Add telegram bot 2025-12-16 20:06:16 +07:00
9fd93a185c Improved prompt for GPT 2025-12-16 03:53:53 +07:00
fe6012b7a3 Add manual add for challanges 2025-12-16 03:27:57 +07:00
a199952383 Change points balance 2025-12-16 03:06:26 +07:00
e32df4d95e Fix dispute 2025-12-16 02:35:59 +07:00
f57a2ba9ea Add marathon finish button and system 2025-12-16 02:22:12 +07:00
d96f8de568 Add limits for content + fix video playback 2025-12-16 02:01:03 +07:00
574140e67d Add modals 2025-12-16 01:50:40 +07:00
87ecd9756c Moved to S3 2025-12-16 01:33:29 +07:00
c7966656d8 Add dispute system 2025-12-16 00:33:50 +07:00
339a212e57 Change rematch event to change game 2025-12-15 23:50:37 +07:00
07e02ce32d Common enemy rework 2025-12-15 23:03:59 +07:00
9a037cb34f Add events history 2025-12-15 22:31:42 +07:00
4239ea8516 Add events 2025-12-15 03:50:04 +07:00
1a882fb2e0 Add game roll wheel 2025-12-14 21:41:49 +07:00
5db2f9c48d Add invite links 2025-12-14 20:39:26 +07:00
d0b8eca600 Add 3 roles, settings for marathons 2025-12-14 20:21:56 +07:00
bb9e9a6e1d Add challenges preview + makefile 2025-12-14 03:23:50 +07:00
131 changed files with 22673 additions and 1206 deletions

41
.dockerignore Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
node_modules
*/node_modules
# Build outputs
dist
build
*.pyc
__pycache__
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# Logs
*.log
npm-debug.log*
# Environment files (keep .env.example)
.env
.env.local
.env.*.local
# OS files
.DS_Store
Thumbs.db
# Test & coverage
coverage
.pytest_cache
.coverage
# Misc
*.md
!README.md

View File

@@ -11,5 +11,14 @@ OPENAI_API_KEY=sk-...
# Telegram Bot
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
# S3 Storage - FirstVDS (set S3_ENABLED=true to use)
S3_ENABLED=false
S3_BUCKET_NAME=your-bucket-name
S3_REGION=ru-1
S3_ACCESS_KEY_ID=your-access-key-id
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
# Frontend (for build)
VITE_API_URL=/api/v1

139
Makefile Normal file
View File

@@ -0,0 +1,139 @@
.PHONY: help dev up down build build-no-cache logs restart clean migrate shell db-shell frontend-shell backend-shell lint test
DC = sudo docker-compose
# Default target
help:
@echo "Marathon WebApp - Available commands:"
@echo ""
@echo " Development:"
@echo " make dev - Start all services in development mode"
@echo " make up - Start all services (detached)"
@echo " make down - Stop all services"
@echo " make restart - Restart all services"
@echo " make logs - Show logs (all services)"
@echo " make logs-b - Show backend logs"
@echo " make logs-f - Show frontend logs"
@echo ""
@echo " Build:"
@echo " make build - Build all containers (with cache)"
@echo " make build-no-cache - Build all containers (no cache)"
@echo " make reup - Rebuild with cache: down + build + up"
@echo " make rebuild - Full rebuild: down + build --no-cache + up"
@echo " make rebuild-frontend - Rebuild only frontend"
@echo " make rebuild-backend - Rebuild only backend"
@echo ""
@echo " Database:"
@echo " make migrate - Run database migrations"
@echo " make db-shell - Open PostgreSQL shell"
@echo ""
@echo " Shell access:"
@echo " make shell - Open backend shell"
@echo " make frontend-sh - Open frontend shell"
@echo ""
@echo " Cleanup:"
@echo " make clean - Stop and remove containers, volumes"
@echo " make prune - Remove unused Docker resources"
# Development
dev:
$(DC) up
up:
$(DC) up -d
down:
$(DC) down
restart:
$(DC) restart
logs:
$(DC) logs -f
logs-b:
$(DC) logs -f backend
logs-f:
$(DC) logs -f frontend
# Build
build:
$(DC) build
build-no-cache:
$(DC) build --no-cache
reup:
$(DC) down
$(DC) build
$(DC) up -d
rebuild:
$(DC) down
$(DC) build --no-cache
$(DC) up -d
rebuild-frontend:
$(DC) down
sudo docker rmi marathon-frontend || true
$(DC) build --no-cache frontend
$(DC) up -d
rebuild-backend:
$(DC) down
sudo docker rmi marathon-backend || true
$(DC) build --no-cache backend
$(DC) up -d
# Database
migrate:
$(DC) exec backend alembic upgrade head
migrate-new:
@read -p "Migration message: " msg; \
$(DC) exec backend alembic revision --autogenerate -m "$$msg"
db-shell:
$(DC) exec db psql -U marathon -d marathon
# Shell access
shell:
$(DC) exec backend bash
backend-shell: shell
frontend-sh:
$(DC) exec frontend sh
# Cleanup
clean:
$(DC) down -v --remove-orphans
prune:
sudo docker system prune -f
# Local development (without Docker)
install:
cd backend && pip install -r requirements.txt
cd frontend && npm install
run-backend:
cd backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
run-frontend:
cd frontend && npm run dev
# Linting and testing
lint-backend:
cd backend && ruff check app
lint-frontend:
cd frontend && npm run lint
test-backend:
cd backend && pytest
# Production
prod:
$(DC) -f docker-compose.yml up -d --build

389
REDESIGN_PLAN.md Normal file
View File

@@ -0,0 +1,389 @@
# План редизайна фронтенда Game Marathon
## Концепция дизайна
**Стиль:** Минималистичный геймерский дизайн
- Темная база с неоновыми акцентами (cyan/purple/pink градиенты)
- Glitch эффекты на заголовках и при hover
- Glassmorphism для карточек (blur + transparency)
- Subtle grain/noise текстура на фоне
- Геометрические паттерны и линии
- Микро-анимации везде
**Цветовая палитра:**
```
Background: #0a0a0f (почти черный с синим оттенком)
Surface: #12121a (карточки)
Border: #1e1e2e (границы)
Primary: #00f0ff (cyan неон)
Secondary: #a855f7 (purple)
Accent: #f0abfc (pink)
Success: #22c55e
Error: #ef4444
Text: #e2e8f0
Text Muted: #64748b
```
---
## Фаза 1: Базовая инфраструктура
### 1.1 Обновление Tailwind Config
- [ ] Новая цветовая палитра (neon colors)
- [ ] Кастомные анимации:
- `glitch` - glitch эффект для текста
- `glow-pulse` - пульсация свечения
- `float` - плавное парение
- `slide-in-left/right/up/down` - слайды
- `scale-in` - появление с масштабом
- `shimmer` - блик на элементах
- [ ] Кастомные backdrop-blur классы
- [ ] Градиентные утилиты
### 1.2 Глобальные стили (index.css)
- [ ] CSS переменные для цветов
- [ ] Glitch keyframes анимация
- [ ] Noise/grain overlay
- [ ] Glow эффекты (box-shadow неон)
- [ ] Custom scrollbar (неоновый)
- [ ] Selection стили (выделение текста)
### 1.3 Новые UI компоненты
- [ ] `GlitchText` - текст с glitch эффектом
- [ ] `NeonButton` - кнопка с неоновым свечением
- [ ] `GlassCard` - карточка с glassmorphism
- [ ] `AnimatedCounter` - анимированные числа
- [ ] `ProgressBar` - неоновый прогресс бар
- [ ] `Badge` - бейджи со свечением
- [ ] `Skeleton` - скелетоны загрузки
- [ ] `Tooltip` - тултипы
- [ ] `Tabs` - табы с анимацией
- [ ] `Modal` - переработанная модалка
---
## Фаза 2: Layout и навигация
### 2.1 Новый Header
- [ ] Sticky header с blur при скролле
- [ ] Логотип с glitch эффектом при hover
- [ ] Анимированная навигация (underline slide)
- [ ] Notification bell с badge
- [ ] User dropdown с аватаром
- [ ] Mobile hamburger menu с slide-in
### 2.2 Sidebar (новый компонент)
- [ ] Collapsible sidebar для desktop
- [ ] Иконки с tooltip
- [ ] Active state с неоновой подсветкой
- [ ] Quick stats внизу
### 2.3 Footer
- [ ] Минималистичный footer
- [ ] Social links
- [ ] Version info
---
## Фаза 3: Страницы
### 3.1 HomePage (полный редизайн)
```
┌─────────────────────────────────────────────┐
│ HERO SECTION │
│ ┌─────────────────────────────────────┐ │
│ │ Animated background (particles/ │ │
│ │ geometric shapes) │ │
│ │ │ │
│ │ GAME <glitch>MARATHON</glitch> │ │
│ │ │ │
│ │ Tagline with typing effect │ │
│ │ │ │
│ │ [Start Playing] [Watch Demo] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ FEATURES SECTION (3 glass cards) │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ Icon │ │ Icon │ │ Icon │ hover: │
│ │ Title │ │ Title │ │ Title │ lift + │
│ │ Desc │ │ Desc │ │ Desc │ glow │
│ └───────┘ └───────┘ └───────┘ │
├─────────────────────────────────────────────┤
│ HOW IT WORKS (timeline style) │
│ ○───────○───────○───────○ │
│ 1 2 3 4 │
│ Create Add Spin Win │
├─────────────────────────────────────────────┤
│ LIVE STATS (animated counters) │
│ [ 1,234 Marathons ] [ 5,678 Challenges ] │
├─────────────────────────────────────────────┤
│ CTA SECTION │
│ Ready to compete? [Join Now] │
└─────────────────────────────────────────────┘
```
### 3.2 Login/Register Pages
- [ ] Centered card с glassmorphism
- [ ] Animated background (subtle)
- [ ] Form inputs с glow при focus
- [ ] Password strength indicator
- [ ] Social login buttons (future)
- [ ] Smooth transitions между login/register
### 3.3 MarathonsPage (Dashboard)
```
┌─────────────────────────────────────────────┐
│ Header: "My Marathons" + Create button │
├─────────────────────────────────────────────┤
│ Quick Stats Bar │
│ [Active: 2] [Completed: 5] [Total Wins: 3]│
├─────────────────────────────────────────────┤
│ Filters/Tabs: All | Active | Completed │
├─────────────────────────────────────────────┤
│ Marathon Cards Grid (2-3 columns) │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Cover image/ │ │ │ │
│ │ gradient │ │ │ │
│ │ ──────────── │ │ │ │
│ │ Title │ │ │ │
│ │ Status badge │ │ │ │
│ │ Participants │ │ │ │
│ │ Progress bar │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
├─────────────────────────────────────────────┤
│ Join by Code (expandable section) │
└─────────────────────────────────────────────┘
```
### 3.4 MarathonPage (Detail)
```
┌─────────────────────────────────────────────┐
│ Hero Banner │
│ ┌─────────────────────────────────────┐ │
│ │ Background gradient + pattern │ │
│ │ Marathon Title (large) │ │
│ │ Status | Dates | Participants │ │
│ │ [Play] [Leaderboard] [Settings] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Event Banner (if active) - animated │
├────────────────────┬────────────────────────┤
│ Main Content │ Sidebar │
│ ┌──────────────┐ │ ┌──────────────────┐ │
│ │ Your Stats │ │ │ Activity Feed │ │
│ │ Points/Streak│ │ │ (scrollable) │ │
│ └──────────────┘ │ │ │ │
│ ┌──────────────┐ │ │ │ │
│ │ Quick Actions│ │ │ │ │
│ └──────────────┘ │ │ │ │
│ ┌──────────────┐ │ │ │ │
│ │ Games List │ │ │ │ │
│ │ (collapsible)│ │ │ │ │
│ └──────────────┘ │ └──────────────────┘ │
└────────────────────┴────────────────────────┘
```
### 3.5 PlayPage (Game Screen) - ГЛАВНАЯ СТРАНИЦА
```
┌─────────────────────────────────────────────┐
│ Top Bar: Points | Streak | Event Timer │
├─────────────────────────────────────────────┤
│ ┌─────────────────────────────────────┐ │
│ │ SPIN WHEEL │ │
│ │ (redesigned, neon style) │ │
│ │ ┌─────────┐ │ │
│ │ │ WHEEL │ │ │
│ │ │ │ │ │
│ │ └─────────┘ │ │
│ │ [SPIN BUTTON] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Active Challenge Card (если есть) │
│ ┌─────────────────────────────────────┐ │
│ │ Game: [Title] | Difficulty: [★★★] │ │
│ │ ─────────────────────────────────── │ │
│ │ Challenge Title │ │
│ │ Description... │ │
│ │ ─────────────────────────────────── │ │
│ │ Points: 100 | Time: ~2h │ │
│ │ ─────────────────────────────────── │ │
│ │ Proof Upload Area │ │
│ │ [File] [URL] [Comment] │ │
│ │ ─────────────────────────────────── │ │
│ │ [Complete ✓] [Skip ✗] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Mini Leaderboard (top 3 + you) │
└─────────────────────────────────────────────┘
```
### 3.6 LeaderboardPage
- [ ] Animated podium для top 3
- [ ] Table с hover эффектами
- [ ] Highlight для текущего пользователя
- [ ] Streak fire animation
- [ ] Sorting/filtering
### 3.7 ProfilePage
```
┌─────────────────────────────────────────────┐
│ Profile Header │
│ ┌─────────────────────────────────────┐ │
│ │ Avatar (large, glow border) │ │
│ │ Nickname [Edit] │ │
│ │ Member since: Date │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Stats Cards (animated counters) │
│ [Marathons] [Wins] [Challenges] [Points] │
├─────────────────────────────────────────────┤
│ Achievements Section (future) │
├─────────────────────────────────────────────┤
│ Telegram Connection Card │
├─────────────────────────────────────────────┤
│ Security Section (password change) │
└─────────────────────────────────────────────┘
```
### 3.8 LobbyPage
- [ ] Step-by-step wizard UI
- [ ] Game cards grid с preview
- [ ] Challenge preview с difficulty badges
- [ ] AI generation progress animation
- [ ] Launch countdown
---
## Фаза 4: Специальные компоненты
### 4.1 SpinWheel (полный редизайн)
- [ ] 3D perspective эффект
- [ ] Неоновые сегменты с названиями игр
- [ ] Particle effects при кручении
- [ ] Glow trail за указателем
- [ ] Sound effects (optional)
- [ ] Confetti при выигрыше
### 4.2 EventBanner (переработка)
- [ ] Pulsating glow border
- [ ] Countdown timer с flip animation
- [ ] Event-specific icons/colors
- [ ] Dismiss animation
### 4.3 ActivityFeed (переработка)
- [ ] Timeline style
- [ ] Avatar circles
- [ ] Type-specific icons
- [ ] Hover для деталей
- [ ] New items animation (slide-in)
### 4.4 Challenge Cards
- [ ] Difficulty stars/badges
- [ ] Progress indicator
- [ ] Expandable details
- [ ] Proof preview thumbnail
---
## Фаза 5: Анимации и эффекты
### 5.1 Page Transitions
- [ ] Framer Motion page transitions
- [ ] Fade + slide between routes
- [ ] Loading skeleton screens
### 5.2 Micro-interactions
- [ ] Button press effects
- [ ] Input focus glow
- [ ] Success checkmark animation
- [ ] Error shake animation
- [ ] Loading spinners (custom)
### 5.3 Background Effects
- [ ] Animated gradient mesh
- [ ] Floating particles (optional)
- [ ] Grid pattern overlay
- [ ] Noise texture
### 5.4 Special Effects
- [ ] Glitch text на заголовках
- [ ] Neon glow на важных элементах
- [ ] Shimmer effect на loading
- [ ] Confetti на achievements
---
## Фаза 6: Responsive и Polish
### 6.1 Mobile Optimization
- [ ] Touch-friendly targets
- [ ] Swipe gestures
- [ ] Bottom navigation (mobile)
- [ ] Collapsible sections
### 6.2 Accessibility
- [ ] Keyboard navigation
- [ ] Focus indicators
- [ ] Screen reader support
- [ ] Reduced motion option
### 6.3 Performance
- [ ] Lazy loading images
- [ ] Code splitting
- [ ] Animation optimization
- [ ] Bundle size check
---
## Порядок реализации
### Sprint 1: Фундамент (2-3 дня)
1. Tailwind config + colors
2. Global CSS + animations
3. Base UI components (Button, Card, Input)
4. GlitchText component
5. Updated Layout/Header
### Sprint 2: Core Pages (3-4 дня)
1. HomePage с hero
2. Login/Register
3. MarathonsPage dashboard
4. Profile page
### Sprint 3: Game Flow (3-4 дня)
1. MarathonPage detail
2. SpinWheel redesign
3. PlayPage
4. LeaderboardPage
### Sprint 4: Polish (2-3 дня)
1. LobbyPage
2. Event components
3. Activity feed
4. Animations & transitions
### Sprint 5: Finalization (1-2 дня)
1. Mobile testing
2. Performance optimization
3. Bug fixes
4. Final polish
---
## Референсы для вдохновления
- Cyberpunk 2077 UI
- Discord dark theme
- Valorant game UI
- Steam profile pages
- Twitch streaming UI
- Epic Games Store
---
## Технические заметки
**Framer Motion:** Использовать для page transitions и сложных анимаций
**CSS:** Использовать для простых transitions и hover эффектов
**Tailwind:** Основной инструмент для стилей
**Custom Hooks:** useAnimation, useGlitch для переиспользования логики

View File

@@ -0,0 +1,72 @@
"""Add roles system
Revision ID: 001_add_roles
Revises:
Create Date: 2024-12-14
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '001_add_roles'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add role column to users table
op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user'))
# Add role column to participants table
op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant'))
# Rename organizer_id to creator_id in marathons table
op.alter_column('marathons', 'organizer_id', new_column_name='creator_id')
# Update existing participants: set role='organizer' for marathon creators
op.execute("""
UPDATE participants p
SET role = 'organizer'
FROM marathons m
WHERE p.marathon_id = m.id AND p.user_id = m.creator_id
""")
# Add status column to games table
op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved'))
# Rename added_by_id to proposed_by_id in games table
op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id')
# Add approved_by_id column to games table
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_games_approved_by_id',
'games', 'users',
['approved_by_id'], ['id'],
ondelete='SET NULL'
)
def downgrade() -> None:
# Remove approved_by_id from games
op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey')
op.drop_column('games', 'approved_by_id')
# Rename proposed_by_id back to added_by_id
op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id')
# Remove status from games
op.drop_column('games', 'status')
# Rename creator_id back to organizer_id
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
# Remove role from participants
op.drop_column('participants', 'role')
# Remove role from users
op.drop_column('users', 'role')

View File

@@ -0,0 +1,32 @@
"""Add marathon settings (is_public, game_proposal_mode)
Revision ID: 002_marathon_settings
Revises: 001_add_roles
Create Date: 2024-12-14
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '002_marathon_settings'
down_revision: Union[str, None] = '001_add_roles'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add is_public column to marathons table (default False = private)
op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
# Add game_proposal_mode column to marathons table
# 'all_participants' - anyone can propose games (with moderation)
# 'organizer_only' - only organizers can add games
op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants'))
def downgrade() -> None:
op.drop_column('marathons', 'game_proposal_mode')
op.drop_column('marathons', 'is_public')

View File

@@ -0,0 +1,38 @@
"""Create admin user
Revision ID: 003_create_admin
Revises: 002_marathon_settings
Create Date: 2024-12-14
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from passlib.context import CryptContext
# revision identifiers, used by Alembic.
revision: str = '003_create_admin'
down_revision: Union[str, None] = '002_marathon_settings'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def upgrade() -> None:
# Hash the password
password_hash = pwd_context.hash("RPQ586qq")
# Insert admin user (ignore if already exists)
op.execute(f"""
INSERT INTO users (login, password_hash, nickname, role, created_at)
VALUES ('admin', '{password_hash}', 'Admin', 'admin', NOW())
ON CONFLICT (login) DO UPDATE SET
password_hash = '{password_hash}',
role = 'admin'
""")
def downgrade() -> None:
op.execute("DELETE FROM users WHERE login = 'admin'")

View File

@@ -0,0 +1,60 @@
"""Add events table and auto_events_enabled to marathons
Revision ID: 004_add_events
Revises: 003_create_admin
Create Date: 2024-12-14
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '004_add_events'
down_revision: Union[str, None] = '003_create_admin'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create events table if it doesn't exist
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
if 'events' not in tables:
op.create_table(
'events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('marathon_id', sa.Integer(), nullable=False),
sa.Column('type', sa.String(30), nullable=False),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_by_id', sa.Integer(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['marathon_id'], ['marathons.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'),
)
# Create index if it doesn't exist
indexes = [idx['name'] for idx in inspector.get_indexes('events')]
if 'ix_events_marathon_id' not in indexes:
op.create_index('ix_events_marathon_id', 'events', ['marathon_id'])
# Add auto_events_enabled to marathons if it doesn't exist
columns = [col['name'] for col in inspector.get_columns('marathons')]
if 'auto_events_enabled' not in columns:
op.add_column(
'marathons',
sa.Column('auto_events_enabled', sa.Boolean(), nullable=False, server_default='true')
)
def downgrade() -> None:
op.drop_column('marathons', 'auto_events_enabled')
op.drop_index('ix_events_marathon_id', table_name='events')
op.drop_table('events')

View File

@@ -0,0 +1,34 @@
"""Add event_type to assignments
Revision ID: 005_add_assignment_event_type
Revises: 004_add_events
Create Date: 2024-12-14
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '005_add_assignment_event_type'
down_revision: Union[str, None] = '004_add_events'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add event_type column to assignments if it doesn't exist
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('assignments')]
if 'event_type' not in columns:
op.add_column(
'assignments',
sa.Column('event_type', sa.String(30), nullable=True)
)
def downgrade() -> None:
op.drop_column('assignments', 'event_type')

View File

@@ -0,0 +1,54 @@
"""Add swap_requests table for two-sided swap confirmation
Revision ID: 006_add_swap_requests
Revises: 005_assignment_event
Create Date: 2024-12-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '006_add_swap_requests'
down_revision: Union[str, None] = '005_add_assignment_event_type'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create swap_requests table if it doesn't exist
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
if 'swap_requests' not in tables:
op.create_table(
'swap_requests',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('event_id', sa.Integer(), nullable=False),
sa.Column('from_participant_id', sa.Integer(), nullable=False),
sa.Column('to_participant_id', sa.Integer(), nullable=False),
sa.Column('from_assignment_id', sa.Integer(), nullable=False),
sa.Column('to_assignment_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('responded_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['from_participant_id'], ['participants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['to_participant_id'], ['participants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['from_assignment_id'], ['assignments.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['to_assignment_id'], ['assignments.id'], ondelete='CASCADE'),
)
op.create_index('ix_swap_requests_event_id', 'swap_requests', ['event_id'])
op.create_index('ix_swap_requests_from_participant_id', 'swap_requests', ['from_participant_id'])
op.create_index('ix_swap_requests_to_participant_id', 'swap_requests', ['to_participant_id'])
def downgrade() -> None:
op.drop_index('ix_swap_requests_to_participant_id', table_name='swap_requests')
op.drop_index('ix_swap_requests_from_participant_id', table_name='swap_requests')
op.drop_index('ix_swap_requests_event_id', table_name='swap_requests')
op.drop_table('swap_requests')

View File

@@ -0,0 +1,54 @@
"""Add is_event_assignment and event_id to assignments for Common Enemy support
Revision ID: 007_add_event_assignment_fields
Revises: 006_add_swap_requests
Create Date: 2024-12-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '007_add_event_assignment_fields'
down_revision: Union[str, None] = '006_add_swap_requests'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add is_event_assignment column with default False
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('assignments')]
if 'is_event_assignment' not in columns:
op.add_column(
'assignments',
sa.Column('is_event_assignment', sa.Boolean(), nullable=False, server_default=sa.false())
)
op.create_index('ix_assignments_is_event_assignment', 'assignments', ['is_event_assignment'])
if 'event_id' not in columns:
op.add_column(
'assignments',
sa.Column('event_id', sa.Integer(), nullable=True)
)
op.create_foreign_key(
'fk_assignments_event_id',
'assignments',
'events',
['event_id'],
['id'],
ondelete='SET NULL'
)
op.create_index('ix_assignments_event_id', 'assignments', ['event_id'])
def downgrade() -> None:
op.drop_index('ix_assignments_event_id', table_name='assignments')
op.drop_constraint('fk_assignments_event_id', 'assignments', type_='foreignkey')
op.drop_column('assignments', 'event_id')
op.drop_index('ix_assignments_is_event_assignment', table_name='assignments')
op.drop_column('assignments', 'is_event_assignment')

View File

@@ -0,0 +1,41 @@
"""Rename rematch event type to game_choice
Revision ID: 008_rename_to_game_choice
Revises: 007_add_event_assignment_fields
Create Date: 2024-12-15
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "008_rename_to_game_choice"
down_revision = "007_add_event_assignment_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Update event type from 'rematch' to 'game_choice' in events table
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
# Update event_type in assignments table
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
# Update activity data that references rematch event
op.execute("""
UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"game_choice"')
WHERE data->>'event_type' = 'rematch'
""")
def downgrade() -> None:
# Revert event type from 'game_choice' to 'rematch'
op.execute("UPDATE events SET type = 'rematch' WHERE type = 'game_choice'")
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
op.execute("""
UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"rematch"')
WHERE data->>'event_type' = 'game_choice'
""")

View File

@@ -0,0 +1,81 @@
"""Add disputes tables for proof verification system
Revision ID: 009_add_disputes
Revises: 008_rename_to_game_choice
Create Date: 2024-12-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '009_add_disputes'
down_revision: Union[str, None] = '008_rename_to_game_choice'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
# Create disputes table
if 'disputes' not in tables:
op.create_table(
'disputes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('assignment_id', sa.Integer(), nullable=False),
sa.Column('raised_by_id', sa.Integer(), nullable=False),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('status', sa.String(20), nullable=False, server_default='open'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('resolved_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['assignment_id'], ['assignments.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['raised_by_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('assignment_id', name='uq_dispute_assignment'),
)
op.create_index('ix_disputes_assignment_id', 'disputes', ['assignment_id'])
# Create dispute_comments table
if 'dispute_comments' not in tables:
op.create_table(
'dispute_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dispute_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
)
op.create_index('ix_dispute_comments_dispute_id', 'dispute_comments', ['dispute_id'])
# Create dispute_votes table
if 'dispute_votes' not in tables:
op.create_table(
'dispute_votes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dispute_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('vote', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('dispute_id', 'user_id', name='uq_dispute_vote_user'),
)
op.create_index('ix_dispute_votes_dispute_id', 'dispute_votes', ['dispute_id'])
def downgrade() -> None:
op.drop_index('ix_dispute_votes_dispute_id', table_name='dispute_votes')
op.drop_table('dispute_votes')
op.drop_index('ix_dispute_comments_dispute_id', table_name='dispute_comments')
op.drop_table('dispute_comments')
op.drop_index('ix_disputes_assignment_id', table_name='disputes')
op.drop_table('disputes')

View File

@@ -0,0 +1,30 @@
"""Add telegram profile fields to users
Revision ID: 010_add_telegram_profile
Revises: 009_add_disputes
Create Date: 2024-12-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '010_add_telegram_profile'
down_revision: Union[str, None] = '009_add_disputes'
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('telegram_first_name', sa.String(100), nullable=True))
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'telegram_avatar_url')
op.drop_column('users', 'telegram_last_name')
op.drop_column('users', 'telegram_first_name')

View File

@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models import User
from app.models import User, Participant, Marathon, UserRole, ParticipantRole
security = HTTPBearer()
@@ -45,6 +45,103 @@ async def get_current_user(
return user
def require_admin(user: User) -> User:
"""Check if user is admin"""
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
async def get_participant(
db: AsyncSession,
user_id: int,
marathon_id: int,
) -> Participant | None:
"""Get participant record for user in marathon"""
result = await db.execute(
select(Participant).where(
Participant.user_id == user_id,
Participant.marathon_id == marathon_id,
)
)
return result.scalar_one_or_none()
async def require_participant(
db: AsyncSession,
user_id: int,
marathon_id: int,
) -> Participant:
"""Require user to be participant of marathon"""
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",
)
return participant
async def require_organizer(
db: AsyncSession,
user: User,
marathon_id: int,
) -> Participant:
"""Require user to be organizer of marathon (or admin)"""
if user.is_admin:
# Admins can act as organizers
participant = await get_participant(db, user.id, marathon_id)
if participant:
return participant
# Create virtual participant for admin
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")
# Return a temporary object for admin
return Participant(
user_id=user.id,
marathon_id=marathon_id,
role=ParticipantRole.ORGANIZER.value
)
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",
)
if not participant.is_organizer:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only organizers can perform this action",
)
return participant
async def require_creator(
db: AsyncSession,
user: User,
marathon_id: int,
) -> Marathon:
"""Require user to be creator of marathon (or admin)"""
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 not user.is_admin and marathon.creator_id != user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the creator can perform this action",
)
return marathon
# Type aliases for cleaner dependency injection
CurrentUser = Annotated[User, Depends(get_current_user)]
DbSession = Annotated[AsyncSession, Depends(get_db)]

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram
router = APIRouter(prefix="/api/v1")
@@ -11,3 +11,7 @@ router.include_router(games.router)
router.include_router(challenges.router)
router.include_router(wheel.router)
router.include_router(feed.router)
router.include_router(admin.router)
router.include_router(events.router)
router.include_router(assignments.router)
router.include_router(telegram.router)

260
backend/app/api/v1/admin.py Normal file
View File

@@ -0,0 +1,260 @@
from fastapi import APIRouter, HTTPException, Query
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
router = APIRouter(prefix="/admin", tags=["admin"])
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
status: str
creator: UserPublic
participants_count: int
games_count: int
start_date: str | None
end_date: str | None
created_at: str
class Config:
from_attributes = True
@router.get("/users", response_model=list[AdminUserResponse])
async def list_users(
current_user: CurrentUser,
db: DbSession,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: str | None = None,
):
"""List all users. Admin only."""
require_admin(current_user)
query = select(User).order_by(User.created_at.desc())
if search:
query = query.where(
(User.login.ilike(f"%{search}%")) |
(User.nickname.ilike(f"%{search}%"))
)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
users = result.scalars().all()
response = []
for user in users:
# Count marathons user participates in
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
response.append(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(),
))
return response
@router.get("/users/{user_id}", response_model=AdminUserResponse)
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
"""Get user details. Admin only."""
require_admin(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")
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(),
)
@router.patch("/users/{user_id}/role", response_model=AdminUserResponse)
async def set_user_role(
user_id: int,
data: SetUserRole,
current_user: CurrentUser,
db: DbSession,
):
"""Set user's global role. Admin only."""
require_admin(current_user)
# Cannot change own role
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.role = data.role
await db.commit()
await db.refresh(user)
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(),
)
@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)
# Cannot delete yourself
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Cannot delete another admin
if user.role == UserRole.ADMIN.value:
raise HTTPException(status_code=400, detail="Cannot delete another admin")
await db.delete(user)
await db.commit()
return MessageResponse(message="User deleted")
@router.get("/marathons", response_model=list[AdminMarathonResponse])
async def list_marathons(
current_user: CurrentUser,
db: DbSession,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: str | None = None,
):
"""List all marathons. Admin only."""
require_admin(current_user)
query = (
select(Marathon)
.options(selectinload(Marathon.creator))
.order_by(Marathon.created_at.desc())
)
if search:
query = query.where(Marathon.title.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit)
result = await db.execute(query)
marathons = result.scalars().all()
response = []
for marathon in marathons:
participants_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon.id)
)
games_count = await db.scalar(
select(func.count()).select_from(Game).where(Game.marathon_id == marathon.id)
)
response.append(AdminMarathonResponse(
id=marathon.id,
title=marathon.title,
status=marathon.status,
creator=UserPublic.model_validate(marathon.creator),
participants_count=participants_count,
games_count=games_count,
start_date=marathon.start_date.isoformat() if marathon.start_date else None,
end_date=marathon.end_date.isoformat() if marathon.end_date else None,
created_at=marathon.created_at.isoformat(),
))
return response
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Delete a marathon. Admin only."""
require_admin(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")
await db.delete(marathon)
await db.commit()
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)
users_count = await db.scalar(select(func.count()).select_from(User))
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
games_count = await db.scalar(select(func.count()).select_from(Game))
participants_count = await db.scalar(select(func.count()).select_from(Participant))
return {
"users_count": users_count,
"marathons_count": marathons_count,
"games_count": games_count,
"total_participations": participants_count,
}

View File

@@ -0,0 +1,556 @@
"""
Assignment details and dispute system endpoints.
"""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
Assignment, AssignmentStatus, Participant, Challenge, User, Marathon,
Dispute, DisputeStatus, DisputeComment, DisputeVote,
)
from app.schemas import (
AssignmentDetailResponse, DisputeCreate, DisputeResponse,
DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate,
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
)
from app.schemas.user import UserPublic
from app.services.storage import storage_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(tags=["assignments"])
# Dispute window: 24 hours after completion
DISPUTE_WINDOW_HOURS = 24
def user_to_public(user: User) -> UserPublic:
"""Convert User model to UserPublic schema"""
return UserPublic(
id=user.id,
login=user.login,
nickname=user.nickname,
avatar_url=None,
role=user.role,
created_at=user.created_at,
)
def build_dispute_response(dispute: Dispute, current_user_id: int) -> DisputeResponse:
"""Build DisputeResponse from Dispute model"""
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
my_vote = None
for v in dispute.votes:
if v.user_id == current_user_id:
my_vote = v.vote
break
# Ensure expires_at has UTC timezone info for correct frontend parsing
created_at_utc = dispute.created_at.replace(tzinfo=timezone.utc) if dispute.created_at.tzinfo is None else dispute.created_at
expires_at = created_at_utc + timedelta(hours=DISPUTE_WINDOW_HOURS)
return DisputeResponse(
id=dispute.id,
raised_by=user_to_public(dispute.raised_by),
reason=dispute.reason,
status=dispute.status,
comments=[
DisputeCommentResponse(
id=c.id,
user=user_to_public(c.user),
text=c.text,
created_at=c.created_at,
)
for c in sorted(dispute.comments, key=lambda x: x.created_at)
],
votes=[
{
"user": user_to_public(v.user),
"vote": v.vote,
"created_at": v.created_at,
}
for v in dispute.votes
],
votes_valid=votes_valid,
votes_invalid=votes_invalid,
my_vote=my_vote,
expires_at=expires_at,
created_at=dispute.created_at,
resolved_at=dispute.resolved_at,
)
@router.get("/assignments/{assignment_id}", response_model=AssignmentDetailResponse)
async def get_assignment_detail(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get detailed information about an assignment including proofs and dispute"""
# Get assignment with all relationships
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Assignment.dispute).selectinload(Dispute.raised_by),
selectinload(Assignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
selectinload(Assignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Build response
challenge = assignment.challenge
game = challenge.game
owner_user = assignment.participant.user
# Determine if user can dispute
can_dispute = False
if (
assignment.status == AssignmentStatus.COMPLETED.value
and assignment.completed_at
and assignment.participant.user_id != current_user.id
and assignment.dispute is None
):
time_since_completion = datetime.utcnow() - assignment.completed_at
can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
# Build proof URLs
proof_image_url = storage_service.get_url(assignment.proof_path, "proofs")
return AssignmentDetailResponse(
id=assignment.id,
challenge=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=storage_service.get_url(game.cover_path, "covers"),
),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
participant=user_to_public(owner_user),
status=assignment.status,
proof_url=assignment.proof_url,
proof_image_url=proof_image_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
can_dispute=can_dispute,
dispute=build_dispute_response(assignment.dispute, current_user.id) if assignment.dispute else None,
)
@router.get("/assignments/{assignment_id}/proof-media")
async def get_assignment_proof_media(
assignment_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Stream the proof media (image or video) for an assignment with Range support"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Check if proof exists
if not assignment.proof_path:
raise HTTPException(status_code=404, detail="No proof media for this assignment")
# Get file from storage
file_data = await storage_service.get_file(assignment.proof_path, "proofs")
if not file_data:
raise HTTPException(status_code=404, detail="Proof media not found in storage")
content, content_type = file_data
file_size = len(content)
# Check if it's a video and handle Range requests
is_video = content_type.startswith("video/")
if is_video:
range_header = request.headers.get("range")
if range_header:
# Parse range header
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if range_match[1] else file_size - 1
# Ensure valid range
if start >= file_size:
raise HTTPException(status_code=416, detail="Range not satisfiable")
end = min(end, file_size - 1)
chunk = content[start:end + 1]
return Response(
content=chunk,
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(len(chunk)),
"Cache-Control": "public, max-age=31536000",
}
)
# No range header - return full video with Accept-Ranges
return Response(
content=content,
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Cache-Control": "public, max-age=31536000",
}
)
# For images, just return the content
return Response(
content=content,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=31536000",
}
)
# Keep old endpoint for backwards compatibility
@router.get("/assignments/{assignment_id}/proof-image")
async def get_assignment_proof_image(
assignment_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Deprecated: Use proof-media instead. Redirects to proof-media."""
return await get_assignment_proof_media(assignment_id, request, current_user, db)
@router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse)
async def create_dispute(
assignment_id: int,
data: DisputeCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a dispute against an assignment's proof"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.participant),
selectinload(Assignment.dispute),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Validate
if assignment.status != AssignmentStatus.COMPLETED.value:
raise HTTPException(status_code=400, detail="Can only dispute completed assignments")
if assignment.participant.user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot dispute your own assignment")
if assignment.dispute:
raise HTTPException(status_code=400, detail="A dispute already exists for this assignment")
if not assignment.completed_at:
raise HTTPException(status_code=400, detail="Assignment has no completion date")
time_since_completion = datetime.utcnow() - assignment.completed_at
if time_since_completion >= timedelta(hours=DISPUTE_WINDOW_HOURS):
raise HTTPException(status_code=400, detail="Dispute window has expired (24 hours)")
# Create dispute
dispute = Dispute(
assignment_id=assignment_id,
raised_by_id=current_user.id,
reason=data.reason,
status=DisputeStatus.OPEN.value,
)
db.add(dispute)
await db.commit()
await db.refresh(dispute)
# Send notification to assignment owner
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_dispute_raised(
db,
user_id=assignment.participant.user_id,
marathon_title=marathon.title,
challenge_title=assignment.challenge.title,
assignment_id=assignment_id
)
# Load relationships for response
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.raised_by),
selectinload(Dispute.comments).selectinload(DisputeComment.user),
selectinload(Dispute.votes).selectinload(DisputeVote.user),
)
.where(Dispute.id == dispute.id)
)
dispute = result.scalar_one()
return build_dispute_response(dispute, current_user.id)
@router.post("/disputes/{dispute_id}/comments", response_model=DisputeCommentResponse)
async def add_dispute_comment(
dispute_id: int,
data: DisputeCommentCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Add a comment to a dispute discussion"""
# Get dispute with assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
if dispute.status != DisputeStatus.OPEN.value:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Create comment
comment = DisputeComment(
dispute_id=dispute_id,
user_id=current_user.id,
text=data.text,
)
db.add(comment)
await db.commit()
await db.refresh(comment)
# Get user for response
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one()
return DisputeCommentResponse(
id=comment.id,
user=user_to_public(user),
text=comment.text,
created_at=comment.created_at,
)
@router.post("/disputes/{dispute_id}/vote", response_model=MessageResponse)
async def vote_on_dispute(
dispute_id: int,
data: DisputeVoteCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Vote on a dispute (True = valid/proof is OK, False = invalid/proof is not OK)"""
# Get dispute with assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
if dispute.status != DisputeStatus.OPEN.value:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Check if user already voted
result = await db.execute(
select(DisputeVote).where(
DisputeVote.dispute_id == dispute_id,
DisputeVote.user_id == current_user.id,
)
)
existing_vote = result.scalar_one_or_none()
if existing_vote:
# Update existing vote
existing_vote.vote = data.vote
existing_vote.created_at = datetime.utcnow()
else:
# Create new vote
vote = DisputeVote(
dispute_id=dispute_id,
user_id=current_user.id,
vote=data.vote,
)
db.add(vote)
await db.commit()
vote_text = "валидным" if data.vote else "невалидным"
return MessageResponse(message=f"Вы проголосовали: пруф {vote_text}")
@router.get("/marathons/{marathon_id}/returned-assignments", response_model=list[ReturnedAssignmentResponse])
async def get_returned_assignments(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get current user's returned assignments that need to be redone"""
# Check user is participant
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Get returned assignments
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.dispute),
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.RETURNED.value,
)
.order_by(Assignment.completed_at.asc()) # Oldest first
)
assignments = result.scalars().all()
return [
ReturnedAssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"),
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
original_completed_at=a.completed_at,
dispute_reason=a.dispute.reason if a.dispute else "Оспорено",
)
for a in assignments
]

View File

@@ -2,21 +2,23 @@ from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Marathon, MarathonStatus, Game, Challenge, Participant
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge
from app.schemas import (
ChallengeCreate,
ChallengeUpdate,
ChallengeResponse,
MessageResponse,
GameShort,
ChallengePreview,
ChallengesPreviewResponse,
ChallengesSaveRequest,
ChallengesGenerateRequest,
)
from app.services.gpt import GPTService
from app.services.gpt import gpt_service
router = APIRouter(tags=["challenges"])
gpt_service = GPTService()
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
result = await db.execute(
@@ -30,21 +32,9 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
return challenge
async def check_participant(db, user_id: int, marathon_id: int) -> Participant:
result = await db.execute(
select(Participant).where(
Participant.user_id == user_id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
return participant
@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."""
# Get game and check access
result = await db.execute(
select(Game).where(Game.id == game_id)
@@ -53,7 +43,16 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
if not game:
raise HTTPException(status_code=404, detail="Game not found")
await check_participant(db, current_user.id, game.marathon_id)
participant = await get_participant(db, current_user.id, game.marathon_id)
# Check access
if not current_user.is_admin:
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Regular participants can only see challenges for approved games or their own games
if not participant.is_organizer:
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)
@@ -81,6 +80,52 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
]
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""List all challenges for a marathon (from all approved games). Participants 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")
# Check user is participant or admin
participant = await get_participant(db, current_user.id, marathon_id)
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
result = await db.execute(
select(Challenge)
.join(Game, Challenge.game_id == Game.id)
.options(selectinload(Challenge.game))
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.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
]
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
async def create_challenge(
game_id: int,
@@ -88,6 +133,7 @@ async def create_challenge(
current_user: CurrentUser,
db: DbSession,
):
"""Create a challenge for a game. Organizers only."""
# Get game and check access
result = await db.execute(
select(Game).where(Game.id == game_id)
@@ -102,7 +148,12 @@ async def create_challenge(
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
await check_participant(db, current_user.id, game.marathon_id)
# Only organizers can add challenges
await require_organizer(db, current_user, game.marathon_id)
# Can only add challenges to approved games
if game.status != GameStatus.APPROVED.value:
raise HTTPException(status_code=400, detail="Can only add challenges to approved games")
challenge = Challenge(
game_id=game_id,
@@ -136,9 +187,14 @@ async def create_challenge(
)
@router.post("/marathons/{marathon_id}/generate-challenges", response_model=MessageResponse)
async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Generate challenges for all games in marathon using GPT"""
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
async def preview_challenges(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
data: ChallengesGenerateRequest | None = None,
):
"""Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only."""
# Check marathon
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
@@ -148,32 +204,64 @@ async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: D
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot generate challenges for active or finished marathon")
await check_participant(db, current_user.id, marathon_id)
# Only organizers can generate challenges
await require_organizer(db, current_user, marathon_id)
# Get all games
result = await db.execute(
select(Game).where(Game.marathon_id == marathon_id)
# Get only APPROVED games
query = select(Game).where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
)
# Filter by specific game IDs if provided
if data and data.game_ids:
query = query.where(Game.id.in_(data.game_ids))
result = await db.execute(query)
games = result.scalars().all()
if not games:
raise HTTPException(status_code=400, detail="No games in marathon")
raise HTTPException(status_code=400, detail="No approved games found")
generated_count = 0
# Build games list for generation (skip games that already have challenges, unless specific IDs requested)
games_to_generate = []
game_map = {}
for game in games:
# Check if game already has challenges
# If specific games requested, generate even if they have challenges
if data and data.game_ids:
games_to_generate.append({
"id": game.id,
"title": game.title,
"genre": game.genre
})
game_map[game.id] = game.title
else:
# Otherwise only generate for games without challenges
existing = await db.scalar(
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
)
if existing:
continue # Skip if already has challenges
if not existing:
games_to_generate.append({
"id": game.id,
"title": game.title,
"genre": game.genre
})
game_map[game.id] = game.title
if not games_to_generate:
return ChallengesPreviewResponse(challenges=[])
# Generate challenges for all games in one API call
preview_challenges = []
try:
challenges_data = await gpt_service.generate_challenges(game.title, game.genre)
challenges_by_game = await gpt_service.generate_challenges(games_to_generate)
for game_id, challenges_data in challenges_by_game.items():
game_title = game_map.get(game_id, "Unknown")
for ch_data in challenges_data:
challenge = Challenge(
game_id=game.id,
preview_challenges.append(ChallengePreview(
game_id=game_id,
game_title=game_title,
title=ch_data.title,
description=ch_data.description,
type=ch_data.type,
@@ -182,18 +270,81 @@ async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: D
estimated_time=ch_data.estimated_time,
proof_type=ch_data.proof_type,
proof_hint=ch_data.proof_hint,
))
except Exception as e:
print(f"Error generating challenges: {e}")
return ChallengesPreviewResponse(challenges=preview_challenges)
@router.post("/marathons/{marathon_id}/save-challenges", response_model=MessageResponse)
async def save_challenges(
marathon_id: int,
data: ChallengesSaveRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Save previewed challenges to database. Organizers only."""
# Check marathon
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.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
# Only organizers can save challenges
await require_organizer(db, current_user, marathon_id)
# Verify all games belong to this marathon AND are approved
result = await db.execute(
select(Game.id).where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
)
)
valid_game_ids = set(row[0] for row in result.fetchall())
saved_count = 0
for ch_data in data.challenges:
if ch_data.game_id not in valid_game_ids:
continue # Skip challenges for invalid/unapproved games
# Validate type
ch_type = ch_data.type
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
ch_type = "completion"
# Validate difficulty
difficulty = ch_data.difficulty
if difficulty not in ["easy", "medium", "hard"]:
difficulty = "medium"
# Validate proof_type
proof_type = ch_data.proof_type
if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot"
challenge = Challenge(
game_id=ch_data.game_id,
title=ch_data.title[:100],
description=ch_data.description,
type=ch_type,
difficulty=difficulty,
points=max(1, min(500, ch_data.points)),
estimated_time=ch_data.estimated_time,
proof_type=proof_type,
proof_hint=ch_data.proof_hint,
is_generated=True,
)
db.add(challenge)
generated_count += 1
except Exception as e:
# Log error but continue with other games
print(f"Error generating challenges for {game.title}: {e}")
saved_count += 1
await db.commit()
return MessageResponse(message=f"Generated {generated_count} challenges")
return MessageResponse(message=f"Сохранено {saved_count} заданий")
@router.patch("/challenges/{challenge_id}", response_model=ChallengeResponse)
@@ -203,6 +354,7 @@ async def update_challenge(
current_user: CurrentUser,
db: DbSession,
):
"""Update a challenge. Organizers only."""
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
@@ -211,7 +363,8 @@ async def update_challenge(
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update challenges in active or finished marathon")
await check_participant(db, current_user.id, challenge.game.marathon_id)
# Only organizers can update challenges
await require_organizer(db, current_user, challenge.game.marathon_id)
if data.title is not None:
challenge.title = data.title
@@ -252,6 +405,7 @@ async def update_challenge(
@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."""
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
@@ -260,7 +414,8 @@ 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")
await check_participant(db, current_user.id, challenge.game.marathon_id)
# Only organizers can delete challenges
await require_organizer(db, current_user, challenge.game.marathon_id)
await db.delete(challenge)
await db.commit()

1173
backend/app/api/v1/events.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,8 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Activity, Participant
from app.models import Activity, Participant, Dispute, ActivityType
from app.models.dispute import DisputeStatus
from app.schemas import FeedResponse, ActivityResponse, UserPublic
router = APIRouter(tags=["feed"])
@@ -44,16 +45,40 @@ async def get_feed(
)
activities = result.scalars().all()
items = [
# Get assignment_ids from complete activities to check for disputes
complete_assignment_ids = []
for a in activities:
if a.type == ActivityType.COMPLETE.value and a.data and a.data.get("assignment_id"):
complete_assignment_ids.append(a.data["assignment_id"])
# Get disputes for these assignments
disputes_map: dict[int, str] = {}
if complete_assignment_ids:
result = await db.execute(
select(Dispute).where(Dispute.assignment_id.in_(complete_assignment_ids))
)
for dispute in result.scalars().all():
disputes_map[dispute.assignment_id] = dispute.status
items = []
for a in activities:
data = dict(a.data) if a.data else {}
# Add dispute status to complete activities
if a.type == ActivityType.COMPLETE.value and a.data and a.data.get("assignment_id"):
assignment_id = a.data["assignment_id"]
if assignment_id in disputes_map:
data["dispute_status"] = disputes_map[assignment_id]
items.append(
ActivityResponse(
id=a.id,
type=a.type,
user=UserPublic.model_validate(a.user),
data=a.data,
data=data if data else None,
created_at=a.created_at,
)
for a in activities
]
)
return FeedResponse(
items=items,

View File

@@ -1,13 +1,16 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
import uuid
from pathlib import Path
from app.api.deps import DbSession, CurrentUser
from app.api.deps import (
DbSession, CurrentUser,
require_participant, require_organizer, get_participant,
)
from app.core.config import settings
from app.models import Marathon, MarathonStatus, Game, Challenge, Participant
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
from app.services.storage import storage_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(tags=["games"])
@@ -15,7 +18,10 @@ router = APIRouter(tags=["games"])
async def get_game_or_404(db, game_id: int) -> Game:
result = await db.execute(
select(Game)
.options(selectinload(Game.added_by_user))
.options(
selectinload(Game.proposed_by),
selectinload(Game.approved_by),
)
.where(Game.id == game_id)
)
game = result.scalar_one_or_none()
@@ -24,47 +30,84 @@ async def get_game_or_404(db, game_id: int) -> Game:
return game
async def check_participant(db, user_id: int, marathon_id: int) -> Participant:
result = await db.execute(
select(Participant).where(
Participant.user_id == user_id,
Participant.marathon_id == marathon_id,
def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
"""Convert Game model to GameResponse schema"""
return GameResponse(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
genre=game.genre,
status=game.status,
proposed_by=UserPublic.model_validate(game.proposed_by) if game.proposed_by else None,
approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None,
challenges_count=challenges_count,
created_at=game.created_at,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
return participant
@router.get("/marathons/{marathon_id}/games", response_model=list[GameResponse])
async def list_games(marathon_id: int, current_user: CurrentUser, db: DbSession):
await check_participant(db, current_user.id, marathon_id)
async def list_games(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
status_filter: str | None = Query(None, alias="status"),
):
"""List games in marathon. Organizers/admins see all, participants see only approved."""
# Admins can view without being 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")
result = await db.execute(
query = (
select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge)
.options(selectinload(Game.added_by_user))
.options(
selectinload(Game.proposed_by),
selectinload(Game.approved_by),
)
.where(Game.marathon_id == marathon_id)
.group_by(Game.id)
.order_by(Game.created_at.desc())
)
games = []
for row in result.all():
game = row[0]
games.append(GameResponse(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
download_url=game.download_url,
genre=game.genre,
added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
challenges_count=row[1],
created_at=game.created_at,
))
# Filter by status if provided
is_organizer = current_user.is_admin or (participant and participant.is_organizer)
if status_filter:
query = query.where(Game.status == status_filter)
elif not is_organizer:
# Regular participants only see approved games + their own pending games
query = query.where(
(Game.status == GameStatus.APPROVED.value) |
(Game.proposed_by_id == current_user.id)
)
return games
result = await db.execute(query)
return [game_to_response(row[0], row[1]) for row in result.all()]
@router.get("/marathons/{marathon_id}/games/pending", response_model=list[GameResponse])
async def list_pending_games(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""List pending games for moderation. Organizers only."""
await require_organizer(db, current_user, marathon_id)
result = await db.execute(
select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge)
.options(
selectinload(Game.proposed_by),
selectinload(Game.approved_by),
)
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.PENDING.value,
)
.group_by(Game.id)
.order_by(Game.created_at.desc())
)
return [game_to_response(row[0], row[1]) for row in result.all()]
@router.post("/marathons/{marathon_id}/games", response_model=GameResponse)
@@ -74,6 +117,7 @@ async def add_game(
current_user: CurrentUser,
db: DbSession,
):
"""Propose a new game. Organizers can auto-approve."""
# Check marathon exists and is preparing
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
@@ -83,16 +127,36 @@ async def add_game(
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot add games to active or finished marathon")
await check_participant(db, current_user.id, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check if user can propose games based on marathon settings
is_organizer = participant.is_organizer or current_user.is_admin
if marathon.game_proposal_mode == GameProposalMode.ORGANIZER_ONLY.value and not is_organizer:
raise HTTPException(status_code=403, detail="Only organizers can add games to this marathon")
# Organizers can auto-approve their games
game_status = GameStatus.APPROVED.value if is_organizer else GameStatus.PENDING.value
game = Game(
marathon_id=marathon_id,
title=data.title,
download_url=data.download_url,
genre=data.genre,
added_by_id=current_user.id,
proposed_by_id=current_user.id,
status=game_status,
approved_by_id=current_user.id if is_organizer else None,
)
db.add(game)
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.ADD_GAME.value,
data={"title": game.title, "status": game_status},
)
db.add(activity)
await db.commit()
await db.refresh(game)
@@ -102,7 +166,9 @@ async def add_game(
cover_url=None,
download_url=game.download_url,
genre=game.genre,
added_by=UserPublic.model_validate(current_user),
status=game.status,
proposed_by=UserPublic.model_validate(current_user),
approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
challenges_count=0,
created_at=game.created_at,
)
@@ -111,22 +177,21 @@ async def add_game(
@router.get("/games/{game_id}", response_model=GameResponse)
async def get_game(game_id: int, current_user: CurrentUser, db: DbSession):
game = await get_game_or_404(db, game_id)
await check_participant(db, current_user.id, game.marathon_id)
participant = await get_participant(db, current_user.id, game.marathon_id)
# Check access: organizers see all, participants see approved + own
if not current_user.is_admin:
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
if not participant.is_organizer:
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
raise HTTPException(status_code=403, detail="Game not found")
challenges_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
)
return GameResponse(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
download_url=game.download_url,
genre=game.genre,
added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
challenges_count=challenges_count,
created_at=game.created_at,
)
return game_to_response(game, challenges_count)
@router.patch("/games/{game_id}", response_model=GameResponse)
@@ -144,9 +209,16 @@ async def update_game(
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update games in active or finished marathon")
# Only the one who added or organizer can update
if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only the one who added the game or organizer can update it")
participant = await get_participant(db, current_user.id, game.marathon_id)
# Only the one who proposed, organizers, or admin can update
can_update = (
current_user.is_admin or
(participant and participant.is_organizer) or
game.proposed_by_id == current_user.id
)
if not can_update:
raise HTTPException(status_code=403, detail="Only the one who proposed the game or organizer can update it")
if data.title is not None:
game.title = data.title
@@ -170,9 +242,16 @@ async def delete_game(game_id: int, current_user: CurrentUser, db: DbSession):
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot delete games from active or finished marathon")
# Only the one who added or organizer can delete
if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only the one who added the game or organizer can delete it")
participant = await get_participant(db, current_user.id, game.marathon_id)
# Only the one who proposed, organizers, or admin can delete
can_delete = (
current_user.is_admin or
(participant and participant.is_organizer) or
game.proposed_by_id == current_user.id
)
if not can_delete:
raise HTTPException(status_code=403, detail="Only the one who proposed the game or organizer can delete it")
await db.delete(game)
await db.commit()
@@ -180,6 +259,100 @@ async def delete_game(game_id: int, current_user: CurrentUser, db: DbSession):
return MessageResponse(message="Game deleted")
@router.post("/games/{game_id}/approve", response_model=GameResponse)
async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession):
"""Approve a pending game. Organizers only."""
game = await get_game_or_404(db, game_id)
await require_organizer(db, current_user, game.marathon_id)
if game.status != GameStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Game is not pending")
# Get marathon title for notification
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = marathon_result.scalar_one()
# Save proposer id before status change
proposer_id = game.proposed_by_id
game.status = GameStatus.APPROVED.value
game.approved_by_id = current_user.id
# Log activity
activity = Activity(
marathon_id=game.marathon_id,
user_id=current_user.id,
type=ActivityType.APPROVE_GAME.value,
data={"title": game.title},
)
db.add(activity)
await db.commit()
await db.refresh(game)
# Notify proposer (if not self-approving)
if proposer_id and proposer_id != current_user.id:
await telegram_notifier.notify_game_approved(
db, proposer_id, marathon.title, game.title
)
# Need to reload relationships
game = await get_game_or_404(db, game_id)
challenges_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
)
return game_to_response(game, challenges_count)
@router.post("/games/{game_id}/reject", response_model=GameResponse)
async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
"""Reject a pending game. Organizers only."""
game = await get_game_or_404(db, game_id)
await require_organizer(db, current_user, game.marathon_id)
if game.status != GameStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Game is not pending")
# Get marathon title for notification
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = marathon_result.scalar_one()
# Save proposer id and game title before changes
proposer_id = game.proposed_by_id
game_title = game.title
game.status = GameStatus.REJECTED.value
# Log activity
activity = Activity(
marathon_id=game.marathon_id,
user_id=current_user.id,
type=ActivityType.REJECT_GAME.value,
data={"title": game.title},
)
db.add(activity)
await db.commit()
await db.refresh(game)
# Notify proposer
if proposer_id and proposer_id != current_user.id:
await telegram_notifier.notify_game_rejected(
db, proposer_id, marathon.title, game_title
)
# Need to reload relationships
game = await get_game_or_404(db, game_id)
challenges_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
)
return game_to_response(game, challenges_count)
@router.post("/games/{game_id}/cover", response_model=GameResponse)
async def upload_cover(
game_id: int,
@@ -188,7 +361,7 @@ async def upload_cover(
file: UploadFile = File(...),
):
game = await get_game_or_404(db, game_id)
await check_participant(db, current_user.id, game.marathon_id)
await require_participant(db, current_user.id, game.marathon_id)
# Validate file
if not file.content_type.startswith("image/"):
@@ -208,15 +381,20 @@ async def upload_cover(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
)
# Save file
filename = f"{game_id}_{uuid.uuid4().hex}.{ext}"
filepath = Path(settings.UPLOAD_DIR) / "covers" / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
# Delete old cover if exists
if game.cover_path:
await storage_service.delete_file(game.cover_path)
with open(filepath, "wb") as f:
f.write(contents)
# Upload file
filename = storage_service.generate_filename(game_id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="covers",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
game.cover_path = str(filepath)
game.cover_path = file_path
await db.commit()
return await get_game(game_id, current_user, db)

View File

@@ -1,35 +1,77 @@
from datetime import timedelta
import secrets
import string
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Marathon, Participant, MarathonStatus, Game, Assignment, AssignmentStatus, Activity, ActivityType
from app.api.deps import (
DbSession, CurrentUser,
require_participant, require_organizer, require_creator,
get_participant,
)
from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
)
from app.schemas import (
MarathonCreate,
MarathonUpdate,
MarathonResponse,
MarathonListItem,
MarathonPublicInfo,
JoinMarathon,
ParticipantInfo,
ParticipantWithUser,
LeaderboardEntry,
MessageResponse,
UserPublic,
SetParticipantRole,
)
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/marathons", tags=["marathons"])
# Public endpoint (no auth required)
@router.get("/by-code/{invite_code}", response_model=MarathonPublicInfo)
async def get_marathon_by_code(invite_code: str, db: DbSession):
"""Get public marathon info by invite code. No authentication required."""
result = await db.execute(
select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant)
.options(selectinload(Marathon.creator))
.where(func.upper(Marathon.invite_code) == invite_code.upper())
.group_by(Marathon.id)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="Marathon not found")
marathon = row[0]
participants_count = row[1]
return MarathonPublicInfo(
id=marathon.id,
title=marathon.title,
description=marathon.description,
status=marathon.status,
participants_count=participants_count,
creator_nickname=marathon.creator.nickname,
)
def generate_invite_code() -> str:
return secrets.token_urlsafe(8)
"""Generate a clean 8-character uppercase alphanumeric code."""
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.organizer))
.options(selectinload(Marathon.creator))
.where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
@@ -50,13 +92,24 @@ async def get_participation(db, user_id: int, marathon_id: int) -> Participant |
@router.get("", response_model=list[MarathonListItem])
async def list_marathons(current_user: CurrentUser, db: DbSession):
"""Get all marathons where user is participant or organizer"""
"""Get all marathons where user is participant, creator, or public marathons"""
# Admin can see all marathons
if current_user.is_admin:
result = await db.execute(
select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant)
.group_by(Marathon.id)
.order_by(Marathon.created_at.desc())
)
else:
# User can see: own marathons, participated marathons, and public marathons
result = await db.execute(
select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant)
.where(
(Marathon.organizer_id == current_user.id) |
(Participant.user_id == current_user.id)
(Marathon.creator_id == current_user.id) |
(Participant.user_id == current_user.id) |
(Marathon.is_public == True)
)
.group_by(Marathon.id)
.order_by(Marathon.created_at.desc())
@@ -69,6 +122,7 @@ async def list_marathons(current_user: CurrentUser, db: DbSession):
id=marathon.id,
title=marathon.title,
status=marathon.status,
is_public=marathon.is_public,
participants_count=row[1],
start_date=marathon.start_date,
end_date=marathon.end_date,
@@ -90,18 +144,21 @@ async def create_marathon(
marathon = Marathon(
title=data.title,
description=data.description,
organizer_id=current_user.id,
creator_id=current_user.id,
invite_code=generate_invite_code(),
is_public=data.is_public,
game_proposal_mode=data.game_proposal_mode,
start_date=start_date,
end_date=end_date,
)
db.add(marathon)
await db.flush()
# Auto-add organizer as participant
# Auto-add creator as organizer participant
participant = Participant(
user_id=current_user.id,
marathon_id=marathon.id,
role=ParticipantRole.ORGANIZER.value, # Creator is organizer
)
db.add(participant)
@@ -112,9 +169,12 @@ async def create_marathon(
id=marathon.id,
title=marathon.title,
description=marathon.description,
organizer=UserPublic.model_validate(current_user),
creator=UserPublic.model_validate(current_user),
status=marathon.status,
invite_code=marathon.invite_code,
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=1,
@@ -128,12 +188,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)
# Count participants and games
# Count participants and approved games
participants_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
)
games_count = await db.scalar(
select(func.count()).select_from(Game).where(Game.marathon_id == marathon_id)
select(func.count()).select_from(Game).where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
)
)
# Get user's participation
@@ -143,9 +206,12 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
id=marathon.id,
title=marathon.title,
description=marathon.description,
organizer=UserPublic.model_validate(marathon.organizer),
creator=UserPublic.model_validate(marathon.creator),
status=marathon.status,
invite_code=marathon.invite_code,
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=participants_count,
@@ -162,11 +228,10 @@ async def update_marathon(
current_user: CurrentUser,
db: DbSession,
):
# Require organizer role
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can update marathon")
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update active or finished marathon")
@@ -177,6 +242,12 @@ async def update_marathon(
if data.start_date is not None:
# Strip timezone info for naive datetime columns
marathon.start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date
if data.is_public is not None:
marathon.is_public = data.is_public
if data.game_proposal_mode is not None:
marathon.game_proposal_mode = data.game_proposal_mode
if data.auto_events_enabled is not None:
marathon.auto_events_enabled = data.auto_events_enabled
await db.commit()
@@ -185,11 +256,10 @@ async def update_marathon(
@router.delete("/{marathon_id}", response_model=MessageResponse)
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
# Only creator or admin can delete
await require_creator(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can delete marathon")
await db.delete(marathon)
await db.commit()
@@ -198,20 +268,40 @@ async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
@router.post("/{marathon_id}/start", response_model=MarathonResponse)
async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
# Require organizer role
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can start marathon")
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
# Check if there are games with challenges
games_count = await db.scalar(
select(func.count()).select_from(Game).where(Game.marathon_id == marathon_id)
# Check if there are approved games
games_result = await db.execute(
select(Game).where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
)
)
approved_games = games_result.scalars().all()
if len(approved_games) == 0:
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
# Check that all approved games have at least one challenge
games_without_challenges = []
for game in approved_games:
challenge_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
if challenge_count == 0:
games_without_challenges.append(game.title)
if games_without_challenges:
games_list = ", ".join(games_without_challenges)
raise HTTPException(
status_code=400,
detail=f"У следующих игр нет челленджей: {games_list}"
)
if games_count == 0:
raise HTTPException(status_code=400, detail="Add at least one game before starting")
marathon.status = MarathonStatus.ACTIVE.value
@@ -226,16 +316,18 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
await db.commit()
# Send Telegram notifications
await telegram_notifier.notify_marathon_start(db, marathon_id, marathon.title)
return await get_marathon(marathon_id, current_user, db)
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
# Require organizer role
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can finish marathon")
if marathon.status != MarathonStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Marathon is not active")
@@ -252,13 +344,16 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
await db.commit()
# Send Telegram notifications
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
return await get_marathon(marathon_id, current_user, db)
@router.post("/join", response_model=MarathonResponse)
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
result = await db.execute(
select(Marathon).where(Marathon.invite_code == data.invite_code)
select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper())
)
marathon = result.scalar_one_or_none()
@@ -276,6 +371,44 @@ async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSes
participant = Participant(
user_id=current_user.id,
marathon_id=marathon.id,
role=ParticipantRole.PARTICIPANT.value, # Regular participant
)
db.add(participant)
# Log activity
activity = Activity(
marathon_id=marathon.id,
user_id=current_user.id,
type=ActivityType.JOIN.value,
data={"nickname": current_user.nickname},
)
db.add(activity)
await db.commit()
return await get_marathon(marathon.id, current_user, db)
@router.post("/{marathon_id}/join", response_model=MarathonResponse)
async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Join a public marathon without invite code"""
marathon = await get_marathon_or_404(db, marathon_id)
if not marathon.is_public:
raise HTTPException(status_code=403, detail="This marathon is private. Use invite code to join.")
if marathon.status == MarathonStatus.FINISHED.value:
raise HTTPException(status_code=400, detail="Marathon has already finished")
# Check if already participant
existing = await get_participation(db, current_user.id, marathon.id)
if existing:
raise HTTPException(status_code=400, detail="Already joined this marathon")
participant = Participant(
user_id=current_user.id,
marathon_id=marathon.id,
role=ParticipantRole.PARTICIPANT.value,
)
db.add(participant)
@@ -308,6 +441,7 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
return [
ParticipantWithUser(
id=p.id,
role=p.role,
total_points=p.total_points,
current_streak=p.current_streak,
drop_count=p.drop_count,
@@ -318,6 +452,50 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
]
@router.patch("/{marathon_id}/participants/{user_id}/role", response_model=ParticipantWithUser)
async def set_participant_role(
marathon_id: int,
user_id: int,
data: SetParticipantRole,
current_user: CurrentUser,
db: DbSession,
):
"""Set participant's role (only creator can do this)"""
# Only creator can change roles
marathon = await require_creator(db, current_user, marathon_id)
# Cannot change creator's role
if user_id == marathon.creator_id:
raise HTTPException(status_code=400, detail="Cannot change creator's role")
# Get participant
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
participant.role = data.role
await db.commit()
await db.refresh(participant)
return ParticipantWithUser(
id=participant.id,
role=participant.role,
total_points=participant.total_points,
current_streak=participant.current_streak,
drop_count=participant.drop_count,
joined_at=participant.joined_at,
user=UserPublic.model_validate(participant.user),
)
@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)

View File

@@ -0,0 +1,393 @@
import logging
from fastapi import APIRouter
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/telegram", tags=["telegram"])
# Schemas
class TelegramLinkToken(BaseModel):
token: str
bot_url: str
class TelegramConfirmLink(BaseModel):
token: str
telegram_id: int
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
telegram_avatar_url: str | None = None
class TelegramLinkResponse(BaseModel):
success: bool
nickname: str | None = None
error: str | None = None
class TelegramUserResponse(BaseModel):
id: int
nickname: str
login: str
avatar_url: str | None = None
class Config:
from_attributes = True
class TelegramMarathonResponse(BaseModel):
id: int
title: str
status: str
total_points: int
position: int
class Config:
from_attributes = True
class TelegramMarathonDetails(BaseModel):
marathon: dict
participant: dict
position: int
active_events: list[dict]
current_assignment: dict | None
class TelegramStatsResponse(BaseModel):
marathons_completed: int
marathons_active: int
challenges_completed: int
total_points: int
best_streak: int
# Endpoints
@router.post("/generate-link-token", response_model=TelegramLinkToken)
async def generate_link_token(current_user: CurrentUser):
"""Generate a one-time token for Telegram account linking."""
logger.info(f"[TG_LINK] Generating link token for user {current_user.id} ({current_user.nickname})")
# Create a short token (≤64 chars) for Telegram deep link
token = create_telegram_link_token(
user_id=current_user.id,
expire_minutes=settings.TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES
)
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot"
bot_url = f"https://t.me/{bot_username}?start={token}"
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
return TelegramLinkToken(token=token, bot_url=bot_url)
@router.post("/confirm-link", response_model=TelegramLinkResponse)
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
"""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}")
logger.info(f"[TG_CONFIRM] telegram_username: {data.telegram_username}")
logger.info(f"[TG_CONFIRM] token: {data.token}")
# Verify short token and extract user_id
user_id = verify_telegram_link_token(data.token)
logger.info(f"[TG_CONFIRM] Verified user_id: {user_id}")
if user_id is None:
logger.error(f"[TG_CONFIRM] FAILED: Token invalid or expired")
return TelegramLinkResponse(success=False, error="Ссылка недействительна или устарела")
# Get user
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
logger.info(f"[TG_CONFIRM] Found user: {user.nickname if user else 'NOT FOUND'}")
if not user:
logger.error(f"[TG_CONFIRM] FAILED: User not found")
return TelegramLinkResponse(success=False, error="Пользователь не найден")
# Check if telegram_id already linked to another user
result = await db.execute(
select(User).where(User.telegram_id == data.telegram_id, User.id != user_id)
)
existing_user = result.scalar_one_or_none()
if existing_user:
logger.error(f"[TG_CONFIRM] FAILED: Telegram already linked to user {existing_user.id}")
return TelegramLinkResponse(
success=False,
error="Этот Telegram аккаунт уже привязан к другому пользователю"
)
# Link account
logger.info(f"[TG_CONFIRM] Linking telegram_id={data.telegram_id} to user_id={user_id}")
user.telegram_id = data.telegram_id
user.telegram_username = data.telegram_username
user.telegram_first_name = data.telegram_first_name
user.telegram_last_name = data.telegram_last_name
user.telegram_avatar_url = data.telegram_avatar_url
await db.commit()
logger.info(f"[TG_CONFIRM] SUCCESS! User {user.nickname} linked to Telegram {data.telegram_id}")
return TelegramLinkResponse(success=True, nickname=user.nickname)
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
"""Get user by Telegram ID."""
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
logger.info(f"[TG_USER] No user found for telegram_id={telegram_id}")
return None
logger.info(f"[TG_USER] Found user: {user.id} ({user.nickname})")
return TelegramUserResponse(
id=user.id,
nickname=user.nickname,
login=user.login,
avatar_url=user.avatar_url
)
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
async def unlink_telegram(telegram_id: int, db: DbSession):
"""Unlink Telegram account."""
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return TelegramLinkResponse(success=False, error="Аккаунт не найден")
user.telegram_id = None
user.telegram_username = None
await db.commit()
return TelegramLinkResponse(success=True)
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
async def get_user_marathons(telegram_id: int, db: DbSession):
"""Get user's marathons by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return []
# Get participations with marathons
result = await db.execute(
select(Participant, Marathon)
.join(Marathon, Participant.marathon_id == Marathon.id)
.where(Participant.user_id == user.id)
.order_by(Marathon.created_at.desc())
)
participations = result.all()
marathons = []
for participant, marathon in participations:
# Calculate position
position_result = await db.execute(
select(func.count(Participant.id) + 1)
.where(
Participant.marathon_id == marathon.id,
Participant.total_points > participant.total_points
)
)
position = position_result.scalar() or 1
marathons.append(TelegramMarathonResponse(
id=marathon.id,
title=marathon.title,
status=marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
total_points=participant.total_points,
position=position
))
return marathons
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession):
"""Get marathon details for user by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
# Get marathon
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
return None
# Get participant
result = await db.execute(
select(Participant)
.where(Participant.marathon_id == marathon_id, Participant.user_id == user.id)
)
participant = result.scalar_one_or_none()
if not participant:
return None
# Calculate position
position_result = await db.execute(
select(func.count(Participant.id) + 1)
.where(
Participant.marathon_id == marathon_id,
Participant.total_points > participant.total_points
)
)
position = position_result.scalar() or 1
# Get active events
result = await db.execute(
select(Event)
.where(Event.marathon_id == marathon_id, Event.is_active == True)
)
active_events = result.scalars().all()
events_data = [
{
"id": e.id,
"type": e.type.value if hasattr(e.type, 'value') else e.type,
"start_time": e.start_time.isoformat() if e.start_time else None,
"end_time": e.end_time.isoformat() if e.end_time else None
}
for e in active_events
]
# Get current assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == "active"
)
.order_by(Assignment.started_at.desc())
.limit(1)
)
assignment = result.scalar_one_or_none()
assignment_data = None
if assignment:
challenge = assignment.challenge
game = challenge.game if challenge else None
assignment_data = {
"id": assignment.id,
"status": assignment.status.value if hasattr(assignment.status, 'value') else assignment.status,
"challenge": {
"id": challenge.id if challenge else None,
"title": challenge.title if challenge else None,
"difficulty": challenge.difficulty.value if challenge and hasattr(challenge.difficulty, 'value') else (challenge.difficulty if challenge else None),
"points": challenge.points if challenge else None,
"game": {
"id": game.id if game else None,
"title": game.title if game else None
}
} if challenge else None
}
return TelegramMarathonDetails(
marathon={
"id": marathon.id,
"title": marathon.title,
"status": marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
"description": marathon.description
},
participant={
"total_points": participant.total_points,
"current_streak": participant.current_streak,
"drop_count": participant.drop_count
},
position=position,
active_events=events_data,
current_assignment=assignment_data
)
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
async def get_user_stats(telegram_id: int, db: DbSession):
"""Get user's overall statistics by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
# Get participations
result = await db.execute(
select(Participant, Marathon)
.join(Marathon, Participant.marathon_id == Marathon.id)
.where(Participant.user_id == user.id)
)
participations = result.all()
marathons_completed = 0
marathons_active = 0
total_points = 0
best_streak = 0
for participant, marathon in participations:
status = marathon.status.value if hasattr(marathon.status, 'value') else marathon.status
if status == "finished":
marathons_completed += 1
elif status == "active":
marathons_active += 1
total_points += participant.total_points
if participant.current_streak > best_streak:
best_streak = participant.current_streak
# Count completed assignments
result = await db.execute(
select(func.count(Assignment.id))
.join(Participant, Assignment.participant_id == Participant.id)
.where(Participant.user_id == user.id, Assignment.status == "completed")
)
challenges_completed = result.scalar() or 0
return TelegramStatsResponse(
marathons_completed=marathons_completed,
marathons_active=marathons_active,
challenges_completed=challenges_completed,
total_points=total_points,
best_streak=best_streak
)

View File

@@ -1,12 +1,17 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File
from sqlalchemy import select
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
from sqlalchemy import select, func
from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
from app.models import User
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse
from app.core.security import verify_password, get_password_hash
from app.models import User, Participant, Assignment, Marathon
from app.models.assignment import AssignmentStatus
from app.models.marathon import MarathonStatus
from app.schemas import (
UserPublic, UserUpdate, TelegramLink, MessageResponse,
PasswordChange, UserStats, UserProfilePublic,
)
from app.services.storage import storage_service
router = APIRouter(prefix="/users", tags=["users"])
@@ -25,6 +30,34 @@ async def get_user(user_id: int, db: DbSession):
return UserPublic.model_validate(user)
@router.get("/{user_id}/avatar")
async def get_user_avatar(user_id: int, db: DbSession):
"""Stream user avatar from storage"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.avatar_path:
raise HTTPException(status_code=404, detail="User has no avatar")
# Get file from storage
file_data = await storage_service.get_file(user.avatar_path, "avatars")
if not file_data:
raise HTTPException(status_code=404, detail="Avatar not found in storage")
content, content_type = file_data
return Response(
content=content,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=3600",
}
)
@router.patch("/me", response_model=UserPublic)
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
if data.nickname is not None:
@@ -64,16 +97,21 @@ async def upload_avatar(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
)
# Save file
filename = f"{current_user.id}_{uuid.uuid4().hex}.{ext}"
filepath = Path(settings.UPLOAD_DIR) / "avatars" / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
# Delete old avatar if exists
if current_user.avatar_path:
await storage_service.delete_file(current_user.avatar_path)
with open(filepath, "wb") as f:
f.write(contents)
# Upload file
filename = storage_service.generate_filename(current_user.id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="avatars",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
# Update user
current_user.avatar_path = str(filepath)
current_user.avatar_path = file_path
await db.commit()
await db.refresh(current_user)
@@ -102,3 +140,161 @@ async def link_telegram(
await db.commit()
return MessageResponse(message="Telegram account linked successfully")
@router.post("/me/telegram/unlink", response_model=MessageResponse)
async def unlink_telegram(
current_user: CurrentUser,
db: DbSession,
):
if not current_user.telegram_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Telegram account is not linked",
)
current_user.telegram_id = None
current_user.telegram_username = None
await db.commit()
return MessageResponse(message="Telegram account unlinked successfully")
@router.post("/me/password", response_model=MessageResponse)
async def change_password(
data: PasswordChange,
current_user: CurrentUser,
db: DbSession,
):
"""Смена пароля текущего пользователя"""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Неверный текущий пароль",
)
if data.current_password == data.new_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Новый пароль должен отличаться от текущего",
)
current_user.password_hash = get_password_hash(data.new_password)
await db.commit()
return MessageResponse(message="Пароль успешно изменен")
@router.get("/me/stats", response_model=UserStats)
async def get_my_stats(current_user: CurrentUser, db: DbSession):
"""Получить свою статистику"""
return await _get_user_stats(current_user.id, db)
@router.get("/{user_id}/stats", response_model=UserStats)
async def get_user_stats(user_id: int, db: DbSession):
"""Получить статистику пользователя"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return await _get_user_stats(user_id, db)
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
async def get_user_profile(user_id: int, db: DbSession):
"""Получить публичный профиль пользователя со статистикой"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
stats = await _get_user_stats(user_id, db)
return UserProfilePublic(
id=user.id,
nickname=user.nickname,
avatar_url=user.avatar_url,
created_at=user.created_at,
stats=stats,
)
async def _get_user_stats(user_id: int, db) -> UserStats:
"""Вспомогательная функция для подсчета статистики пользователя"""
# 1. Количество марафонов (участий)
marathons_result = await db.execute(
select(func.count(Participant.id))
.where(Participant.user_id == user_id)
)
marathons_count = marathons_result.scalar() or 0
# 2. Количество побед (1 место в завершенных марафонах)
wins_count = 0
user_participations = await db.execute(
select(Participant)
.join(Marathon, Marathon.id == Participant.marathon_id)
.where(
Participant.user_id == user_id,
Marathon.status == MarathonStatus.FINISHED.value
)
)
for participation in user_participations.scalars():
# Для каждого марафона проверяем, был ли пользователь первым
max_points_result = await db.execute(
select(func.max(Participant.total_points))
.where(Participant.marathon_id == participation.marathon_id)
)
max_points = max_points_result.scalar() or 0
if participation.total_points == max_points and max_points > 0:
# Проверяем что он единственный с такими очками (не ничья)
count_with_max = await db.execute(
select(func.count(Participant.id))
.where(
Participant.marathon_id == participation.marathon_id,
Participant.total_points == max_points
)
)
if count_with_max.scalar() == 1:
wins_count += 1
# 3. Выполненных заданий
completed_result = await db.execute(
select(func.count(Assignment.id))
.join(Participant, Participant.id == Assignment.participant_id)
.where(
Participant.user_id == user_id,
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
completed_assignments = completed_result.scalar() or 0
# 4. Всего очков заработано
points_result = await db.execute(
select(func.coalesce(func.sum(Assignment.points_earned), 0))
.join(Participant, Participant.id == Assignment.participant_id)
.where(
Participant.user_id == user_id,
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
total_points_earned = points_result.scalar() or 0
return UserStats(
marathons_count=marathons_count,
wins_count=wins_count,
completed_assignments=completed_assignments,
total_points_earned=total_points_earned,
)

View File

@@ -3,20 +3,21 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
import uuid
from pathlib import Path
from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType
Assignment, AssignmentStatus, Activity, ActivityType,
EventType, Difficulty, User
)
from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult,
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
)
from app.services.points import PointsService
from app.services.events import event_service
from app.services.storage import storage_service
router = APIRouter(tags=["wheel"])
@@ -36,7 +37,14 @@ async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Particip
return participant
async def get_active_assignment(db, participant_id: int) -> Assignment | None:
async def get_active_assignment(db, participant_id: int, is_event: bool = False) -> Assignment | None:
"""Get active assignment for participant.
Args:
db: Database session
participant_id: Participant ID
is_event: If True, get event assignment (Common Enemy). If False, get regular assignment.
"""
result = await db.execute(
select(Assignment)
.options(
@@ -45,11 +53,45 @@ async def get_active_assignment(db, participant_id: int) -> Assignment | None:
.where(
Assignment.participant_id == participant_id,
Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_event_assignment == is_event,
)
)
return result.scalar_one_or_none()
async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment | None:
"""Get the oldest returned assignment that needs to be redone."""
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant_id,
Assignment.status == AssignmentStatus.RETURNED.value,
Assignment.is_event_assignment == False,
)
.order_by(Assignment.completed_at.asc()) # Oldest first
.limit(1)
)
return result.scalar_one_or_none()
async def activate_returned_assignment(db, returned_assignment: Assignment) -> None:
"""
Re-activate a returned assignment.
Simply changes the status back to ACTIVE.
"""
returned_assignment.status = AssignmentStatus.ACTIVE.value
returned_assignment.started_at = datetime.utcnow()
# Clear previous proof data for fresh attempt
returned_assignment.proof_path = None
returned_assignment.proof_url = None
returned_assignment.proof_comment = None
returned_assignment.completed_at = None
returned_assignment.points_earned = 0
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Spin the wheel to get a random game and challenge"""
@@ -62,14 +104,40 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if marathon.status != MarathonStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Marathon is not active")
# Check if marathon has expired by end_date
if marathon.end_date and datetime.utcnow() > marathon.end_date:
raise HTTPException(status_code=400, detail="Marathon has ended")
participant = await get_participant_or_403(db, current_user.id, marathon_id)
# Check no active assignment
active = await get_active_assignment(db, participant.id)
# Check no active regular assignment (event assignments are separate)
active = await get_active_assignment(db, participant.id, is_event=False)
if active:
raise HTTPException(status_code=400, detail="You already have an active assignment")
# Get all games with challenges
# Check active event
active_event = await event_service.get_active_event(db, marathon_id)
game = None
challenge = None
# Handle special event cases (excluding Common Enemy - it has separate flow)
if active_event:
if active_event.type == EventType.JACKPOT.value:
# Jackpot: Get hard challenge only
challenge = await event_service.get_random_hard_challenge(db, marathon_id)
if challenge:
# Load game for challenge
result = await db.execute(
select(Game).where(Game.id == challenge.game_id)
)
game = result.scalar_one_or_none()
# Consume jackpot (one-time use)
await event_service.consume_jackpot(db, active_event.id)
# Note: Common Enemy is handled separately via event-assignment endpoints
# Normal random selection if no special event handling
if not game or not challenge:
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
@@ -80,46 +148,61 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if not games:
raise HTTPException(status_code=400, detail="No games with challenges available")
# Random selection
game = random.choice(games)
challenge = random.choice(game.challenges)
# Create assignment
# Create assignment (store event_type for jackpot multiplier on completion)
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge.id,
status=AssignmentStatus.ACTIVE.value,
event_type=active_event.type if active_event else None,
)
db.add(assignment)
# Log activity
activity_data = {
"game": game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": challenge.points,
}
if active_event:
activity_data["event_type"] = active_event.type
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.SPIN.value,
data={
"game": game.title,
"challenge": challenge.title,
},
data=activity_data,
)
db.add(activity)
await db.commit()
await db.refresh(assignment)
# Calculate drop penalty
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count)
# Calculate drop penalty (considers active event for double_risk)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
# Get challenges count (avoid lazy loading in async context)
challenges_count = 0
if 'challenges' in game.__dict__:
challenges_count = len(game.challenges)
else:
challenges_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
return SpinResult(
assignment_id=assignment.id,
game=GameResponse(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
genre=game.genre,
added_by=None,
challenges_count=len(game.challenges),
challenges_count=challenges_count,
created_at=game.created_at,
),
challenge=ChallengeResponse(
@@ -143,9 +226,9 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Get current active assignment"""
"""Get current active regular assignment (not event assignments)"""
participant = await get_participant_or_403(db, current_user.id, marathon_id)
assignment = await get_active_assignment(db, participant.id)
assignment = await get_active_assignment(db, participant.id, is_event=False)
if not assignment:
return None
@@ -153,6 +236,10 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
challenge = assignment.challenge
game = challenge.game
# Calculate drop penalty (considers active event for double_risk)
active_event = await event_service.get_active_event(db, marathon_id)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
return AssignmentResponse(
id=assignment.id,
challenge=ChallengeResponse(
@@ -170,12 +257,13 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
created_at=challenge.created_at,
),
status=assignment.status,
proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url,
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
drop_penalty=drop_penalty,
)
@@ -188,7 +276,7 @@ async def complete_assignment(
comment: str | None = Form(None),
proof_file: UploadFile | None = File(None),
):
"""Complete an assignment with proof"""
"""Complete a regular assignment with proof (not event assignments)"""
# Get assignment
result = await db.execute(
select(Assignment)
@@ -209,6 +297,10 @@ async def complete_assignment(
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
# Event assignments should be completed via /event-assignments/{id}/complete
if assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
# Need either file or URL
if not proof_file and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
@@ -229,14 +321,16 @@ async def complete_assignment(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
)
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}"
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
# Upload file to storage
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="proofs",
filename=filename,
content_type=proof_file.content_type or "application/octet-stream",
)
with open(filepath, "wb") as f:
f.write(contents)
assignment.proof_path = str(filepath)
assignment.proof_path = file_path
else:
assignment.proof_url = proof_url
@@ -246,9 +340,42 @@ async def complete_assignment(
participant = assignment.participant
challenge = assignment.challenge
total_points, streak_bonus = points_service.calculate_completion_points(
challenge.points, participant.current_streak
# Get marathon_id for activity and event check
result = await db.execute(
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
)
full_challenge = result.scalar_one()
marathon_id = full_challenge.game.marathon_id
# Check active event for point multipliers
active_event = await event_service.get_active_event(db, marathon_id)
# For jackpot: use the event_type stored in assignment (since event may be over)
# For other events: use the currently active event
effective_event = active_event
# Handle assignment-level event types (jackpot)
if assignment.event_type == EventType.JACKPOT.value:
# Create a mock event object for point calculation
class MockEvent:
def __init__(self, event_type):
self.type = event_type
effective_event = MockEvent(assignment.event_type)
total_points, streak_bonus, event_bonus = points_service.calculate_completion_points(
challenge.points, participant.current_streak, effective_event
)
# Handle common enemy bonus
common_enemy_bonus = 0
common_enemy_closed = False
common_enemy_winners = None
if active_event and active_event.type == EventType.COMMON_ENEMY.value:
common_enemy_bonus, common_enemy_closed, common_enemy_winners = await event_service.record_common_enemy_completion(
db, active_event, participant.id, current_user.id
)
total_points += common_enemy_bonus
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
@@ -261,27 +388,76 @@ async def complete_assignment(
participant.current_streak += 1
participant.drop_count = 0 # Reset drop counter on success
# Get marathon_id for activity
result = await db.execute(
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
)
full_challenge = result.scalar_one()
# Log activity
activity = Activity(
marathon_id=full_challenge.game.marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data={
activity_data = {
"assignment_id": assignment.id,
"game": full_challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": total_points,
"streak": participant.current_streak,
},
}
# Log event info (use assignment's event_type for jackpot, active_event for others)
if assignment.event_type == EventType.JACKPOT.value:
activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus
elif active_event:
activity_data["event_type"] = active_event.type
activity_data["event_bonus"] = event_bonus
if common_enemy_bonus:
activity_data["common_enemy_bonus"] = common_enemy_bonus
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data=activity_data,
)
db.add(activity)
# If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners:
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
# Load winner nicknames
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids))
)
users_map = {u.id: u.nickname for u in users_result.scalars().all()}
winners_data = [
{
"user_id": w["user_id"],
"nickname": users_map.get(w["user_id"], "Unknown"),
"rank": w["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
}
for w in common_enemy_winners
]
print(f"[COMMON_ENEMY] Creating event_end activity with winners: {winners_data}")
event_end_activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id, # Last completer triggers the close
type=ActivityType.EVENT_END.value,
data={
"event_type": EventType.COMMON_ENEMY.value,
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
"auto_closed": True,
"winners": winners_data,
},
)
db.add(event_end_activity)
await db.commit()
# Check for returned assignments and activate the oldest one
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment:
await activate_returned_assignment(db, returned_assignment)
await db.commit()
print(f"[WHEEL] Auto-activated returned assignment {returned_assignment.id} for participant {participant.id}")
return CompleteResult(
points_earned=total_points,
streak_bonus=streak_bonus,
@@ -314,9 +490,13 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
raise HTTPException(status_code=400, detail="Assignment is not active")
participant = assignment.participant
marathon_id = assignment.challenge.game.marathon_id
# Calculate penalty
penalty = points_service.calculate_drop_penalty(participant.drop_count)
# Check active event for free drops (double_risk)
active_event = await event_service.get_active_event(db, marathon_id)
# Calculate penalty (0 if double_risk event is active)
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
@@ -328,14 +508,22 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
participant.drop_count += 1
# Log activity
activity_data = {
"game": assignment.challenge.game.title,
"challenge": assignment.challenge.title,
"difficulty": assignment.challenge.difficulty,
"penalty": penalty,
}
if active_event:
activity_data["event_type"] = active_event.type
if active_event.type == EventType.DOUBLE_RISK.value:
activity_data["free_drop"] = True
activity = Activity(
marathon_id=assignment.challenge.game.marathon_id,
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.DROP.value,
data={
"challenge": assignment.challenge.title,
"penalty": penalty,
},
data=activity_data,
)
db.add(activity)
@@ -393,7 +581,7 @@ async def get_my_history(
created_at=a.challenge.created_at,
),
status=a.status,
proof_url=f"/uploads/proofs/{a.proof_path.split('/')[-1]}" if a.proof_path else a.proof_url,
proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
proof_comment=a.proof_comment,
points_earned=a.points_earned,
streak_at_completion=a.streak_at_completion,

View File

@@ -20,13 +20,29 @@ class Settings(BaseSettings):
# Telegram
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_BOT_USERNAME: str = ""
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
# Frontend
FRONTEND_URL: str = "http://localhost:3000"
# Uploads
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 MB
MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024 # 5 MB for avatars
MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB
MAX_VIDEO_SIZE: int = 30 * 1024 * 1024 # 30 MB
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}
ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"}
# S3 Storage (FirstVDS)
S3_ENABLED: bool = False
S3_BUCKET_NAME: str = ""
S3_REGION: str = "ru-1"
S3_ACCESS_KEY_ID: str = ""
S3_SECRET_ACCESS_KEY: str = ""
S3_ENDPOINT_URL: str = ""
S3_PUBLIC_URL: str = ""
@property
def ALLOWED_EXTENSIONS(self) -> set:
return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS

View File

@@ -1,3 +1,8 @@
import base64
import hashlib
import hmac
import struct
import time
from datetime import datetime, timedelta
from typing import Any
@@ -35,3 +40,71 @@ def decode_access_token(token: str) -> dict | None:
return payload
except jwt.JWTError:
return None
def create_telegram_link_token(user_id: int, expire_minutes: int = 10) -> str:
"""
Create a short token for Telegram account linking.
Format: base64url encoded binary data (no separators).
Structure: user_id (4 bytes) + expire_at (4 bytes) + signature (8 bytes) = 16 bytes -> 22 chars base64url.
"""
expire_at = int(time.time()) + (expire_minutes * 60)
# Pack user_id and expire_at as unsigned 32-bit integers (8 bytes total)
data = struct.pack(">II", user_id, expire_at)
# Create HMAC signature (take first 8 bytes)
signature = hmac.new(
settings.SECRET_KEY.encode(),
data,
hashlib.sha256
).digest()[:8]
# Combine data + signature (16 bytes)
token_bytes = data + signature
# Encode as base64url without padding
token = base64.urlsafe_b64encode(token_bytes).decode().rstrip("=")
return token
def verify_telegram_link_token(token: str) -> int | None:
"""
Verify Telegram link token and return user_id if valid.
Returns None if token is invalid or expired.
"""
try:
# Add padding if needed for base64 decoding
padding = 4 - (len(token) % 4)
if padding != 4:
token += "=" * padding
token_bytes = base64.urlsafe_b64decode(token)
if len(token_bytes) != 16:
return None
# Unpack data
data = token_bytes[:8]
provided_signature = token_bytes[8:]
user_id, expire_at = struct.unpack(">II", data)
# Check expiration
if time.time() > expire_at:
return None
# Verify signature
expected_signature = hmac.new(
settings.SECRET_KEY.encode(),
data,
hashlib.sha256
).digest()[:8]
if not hmac.compare_digest(provided_signature, expected_signature):
return None
return user_id
except (ValueError, struct.error, Exception):
return None

View File

@@ -1,13 +1,22 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from app.core.config import settings
from app.core.database import engine, Base
from app.core.database import engine, Base, async_session_maker
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
@asynccontextmanager
@@ -22,9 +31,15 @@ async def lifespan(app: FastAPI):
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
# Start schedulers
await event_scheduler.start(async_session_maker)
await dispute_scheduler.start(async_session_maker)
yield
# Shutdown
await event_scheduler.stop()
await dispute_scheduler.stop()
await engine.dispose()

View File

@@ -1,17 +1,24 @@
from app.models.user import User
from app.models.marathon import Marathon, MarathonStatus
from app.models.participant import Participant
from app.models.game import Game
from app.models.user import User, UserRole
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
from app.models.participant import Participant, ParticipantRole
from app.models.game import Game, GameStatus
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
from app.models.assignment import Assignment, AssignmentStatus
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
__all__ = [
"User",
"UserRole",
"Marathon",
"MarathonStatus",
"GameProposalMode",
"Participant",
"ParticipantRole",
"Game",
"GameStatus",
"Challenge",
"ChallengeType",
"Difficulty",
@@ -20,4 +27,12 @@ __all__ = [
"AssignmentStatus",
"Activity",
"ActivityType",
"Event",
"EventType",
"SwapRequest",
"SwapRequestStatus",
"Dispute",
"DisputeStatus",
"DisputeComment",
"DisputeVote",
]

View File

@@ -13,6 +13,13 @@ class ActivityType(str, Enum):
DROP = "drop"
START_MARATHON = "start_marathon"
FINISH_MARATHON = "finish_marathon"
ADD_GAME = "add_game"
APPROVE_GAME = "approve_game"
REJECT_GAME = "reject_game"
EVENT_START = "event_start"
EVENT_END = "event_end"
SWAP = "swap"
GAME_CHOICE = "game_choice"
class Activity(Base):

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -10,6 +10,7 @@ class AssignmentStatus(str, Enum):
ACTIVE = "active"
COMPLETED = "completed"
DROPPED = "dropped"
RETURNED = "returned" # Disputed and needs to be redone
class Assignment(Base):
@@ -19,6 +20,9 @@ class Assignment(Base):
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -30,3 +34,5 @@ class Assignment(Base):
# Relationships
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True)

View File

@@ -0,0 +1,66 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class DisputeStatus(str, Enum):
OPEN = "open"
RESOLVED_VALID = "valid"
RESOLVED_INVALID = "invalid"
class Dispute(Base):
"""Dispute against a completed assignment's proof"""
__tablename__ = "disputes"
id: Mapped[int] = mapped_column(primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True)
raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
reason: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute")
raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id])
comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan")
votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan")
class DisputeComment(Base):
"""Comment in a dispute discussion"""
__tablename__ = "dispute_comments"
id: Mapped[int] = mapped_column(primary_key=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
text: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="comments")
user: Mapped["User"] = relationship("User")
class DisputeVote(Base):
"""Vote in a dispute (valid or invalid)"""
__tablename__ = "dispute_votes"
id: Mapped[int] = mapped_column(primary_key=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
vote: Mapped[bool] = mapped_column(Boolean, nullable=False) # True = valid, False = invalid
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Unique constraint: one vote per user per dispute
__table_args__ = (
UniqueConstraint("dispute_id", "user_id", name="uq_dispute_vote_user"),
)
# Relationships
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="votes")
user: Mapped["User"] = relationship("User")

View File

@@ -0,0 +1,40 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, ForeignKey, JSON, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class EventType(str, Enum):
GOLDEN_HOUR = "golden_hour" # x1.5 очков
COMMON_ENEMY = "common_enemy" # общий челлендж для всех
DOUBLE_RISK = "double_risk" # дропы бесплатны, x0.5 очков
JACKPOT = "jackpot" # x3 за сложный челлендж
SWAP = "swap" # обмен заданиями
GAME_CHOICE = "game_choice" # выбор игры (2-3 челленджа на выбор)
class Event(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(primary_key=True)
marathon_id: Mapped[int] = mapped_column(
ForeignKey("marathons.id", ondelete="CASCADE"),
index=True
)
type: Mapped[str] = mapped_column(String(30), nullable=False)
start_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
end_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_by_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events")
created_by: Mapped["User | None"] = relationship("User")
assignments: Mapped[list["Assignment"]] = relationship("Assignment", back_populates="event")

View File

@@ -1,10 +1,17 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class GameStatus(str, Enum):
PENDING = "pending" # Предложена участником, ждёт модерации
APPROVED = "approved" # Одобрена организатором
REJECTED = "rejected" # Отклонена
class Game(Base):
__tablename__ = "games"
@@ -14,14 +21,33 @@ class Game(Base):
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
download_url: Mapped[str] = mapped_column(Text, nullable=False)
genre: Mapped[str | None] = mapped_column(String(50), nullable=True)
added_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
status: Mapped[str] = mapped_column(String(20), default=GameStatus.PENDING.value)
proposed_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
added_by_user: Mapped["User"] = relationship("User", back_populates="added_games")
proposed_by: Mapped["User"] = relationship(
"User",
back_populates="proposed_games",
foreign_keys=[proposed_by_id]
)
approved_by: Mapped["User | None"] = relationship(
"User",
back_populates="approved_games",
foreign_keys=[approved_by_id]
)
challenges: Mapped[list["Challenge"]] = relationship(
"Challenge",
back_populates="game",
cascade="all, delete-orphan"
)
@property
def is_approved(self) -> bool:
return self.status == GameStatus.APPROVED.value
@property
def is_pending(self) -> bool:
return self.status == GameStatus.PENDING.value

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -12,24 +12,32 @@ class MarathonStatus(str, Enum):
FINISHED = "finished"
class GameProposalMode(str, Enum):
ALL_PARTICIPANTS = "all_participants"
ORGANIZER_ONLY = "organizer_only"
class Marathon(Base):
__tablename__ = "marathons"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
organizer_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
creator_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), default=MarathonStatus.PREPARING.value)
invite_code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
game_proposal_mode: Mapped[str] = mapped_column(String(20), default=GameProposalMode.ALL_PARTICIPANTS.value)
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
organizer: Mapped["User"] = relationship(
creator: Mapped["User"] = relationship(
"User",
back_populates="organized_marathons",
foreign_keys=[organizer_id]
back_populates="created_marathons",
foreign_keys=[creator_id]
)
participants: Mapped[list["Participant"]] = relationship(
"Participant",
@@ -46,3 +54,8 @@ class Marathon(Base):
back_populates="marathon",
cascade="all, delete-orphan"
)
events: Mapped[list["Event"]] = relationship(
"Event",
back_populates="marathon",
cascade="all, delete-orphan"
)

View File

@@ -1,10 +1,16 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint
from enum import Enum
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ParticipantRole(str, Enum):
PARTICIPANT = "participant"
ORGANIZER = "organizer"
class Participant(Base):
__tablename__ = "participants"
__table_args__ = (
@@ -14,6 +20,7 @@ class Participant(Base):
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True)
role: Mapped[str] = mapped_column(String(20), default=ParticipantRole.PARTICIPANT.value)
total_points: Mapped[int] = mapped_column(Integer, default=0)
current_streak: Mapped[int] = mapped_column(Integer, default=0)
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
@@ -27,3 +34,7 @@ class Participant(Base):
back_populates="participant",
cascade="all, delete-orphan"
)
@property
def is_organizer(self) -> bool:
return self.role == ParticipantRole.ORGANIZER.value

View File

@@ -0,0 +1,62 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class SwapRequestStatus(str, Enum):
PENDING = "pending"
ACCEPTED = "accepted"
DECLINED = "declined"
CANCELLED = "cancelled" # Cancelled by requester or event ended
class SwapRequest(Base):
__tablename__ = "swap_requests"
id: Mapped[int] = mapped_column(primary_key=True)
event_id: Mapped[int] = mapped_column(
ForeignKey("events.id", ondelete="CASCADE"),
index=True
)
from_participant_id: Mapped[int] = mapped_column(
ForeignKey("participants.id", ondelete="CASCADE"),
index=True
)
to_participant_id: Mapped[int] = mapped_column(
ForeignKey("participants.id", ondelete="CASCADE"),
index=True
)
from_assignment_id: Mapped[int] = mapped_column(
ForeignKey("assignments.id", ondelete="CASCADE")
)
to_assignment_id: Mapped[int] = mapped_column(
ForeignKey("assignments.id", ondelete="CASCADE")
)
status: Mapped[str] = mapped_column(
String(20),
default=SwapRequestStatus.PENDING.value
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
responded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
event: Mapped["Event"] = relationship("Event")
from_participant: Mapped["Participant"] = relationship(
"Participant",
foreign_keys=[from_participant_id]
)
to_participant: Mapped["Participant"] = relationship(
"Participant",
foreign_keys=[to_participant_id]
)
from_assignment: Mapped["Assignment"] = relationship(
"Assignment",
foreign_keys=[from_assignment_id]
)
to_assignment: Mapped["Assignment"] = relationship(
"Assignment",
foreign_keys=[to_assignment_id]
)

View File

@@ -1,10 +1,16 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, BigInteger, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class UserRole(str, Enum):
USER = "user"
ADMIN = "admin"
class User(Base):
__tablename__ = "users"
@@ -15,19 +21,41 @@ class User(Base):
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True)
telegram_first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
telegram_last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
telegram_avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
organized_marathons: Mapped[list["Marathon"]] = relationship(
created_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon",
back_populates="organizer",
foreign_keys="Marathon.organizer_id"
back_populates="creator",
foreign_keys="Marathon.creator_id"
)
participations: Mapped[list["Participant"]] = relationship(
"Participant",
back_populates="user"
)
added_games: Mapped[list["Game"]] = relationship(
proposed_games: Mapped[list["Game"]] = relationship(
"Game",
back_populates="added_by_user"
back_populates="proposed_by",
foreign_keys="Game.proposed_by_id"
)
approved_games: Mapped[list["Game"]] = relationship(
"Game",
back_populates="approved_by",
foreign_keys="Game.approved_by_id"
)
@property
def is_admin(self) -> bool:
return self.role == UserRole.ADMIN.value
@property
def avatar_url(self) -> str | None:
if self.avatar_path:
# Lazy import to avoid circular dependency
from app.services.storage import storage_service
return storage_service.get_url(self.avatar_path, "avatars")
return None

View File

@@ -6,16 +6,21 @@ from app.schemas.user import (
UserWithTelegram,
TokenResponse,
TelegramLink,
PasswordChange,
UserStats,
UserProfilePublic,
)
from app.schemas.marathon import (
MarathonCreate,
MarathonUpdate,
MarathonResponse,
MarathonListItem,
MarathonPublicInfo,
ParticipantInfo,
ParticipantWithUser,
JoinMarathon,
LeaderboardEntry,
SetParticipantRole,
)
from app.schemas.game import (
GameCreate,
@@ -28,6 +33,11 @@ from app.schemas.challenge import (
ChallengeUpdate,
ChallengeResponse,
ChallengeGenerated,
ChallengePreview,
ChallengesPreviewResponse,
ChallengeSaveItem,
ChallengesSaveRequest,
ChallengesGenerateRequest,
)
from app.schemas.assignment import (
CompleteAssignment,
@@ -35,16 +45,42 @@ from app.schemas.assignment import (
SpinResult,
CompleteResult,
DropResult,
EventAssignmentResponse,
)
from app.schemas.activity import (
ActivityResponse,
FeedResponse,
)
from app.schemas.event import (
EventCreate,
EventResponse,
EventEffects,
ActiveEventResponse,
SwapRequest,
SwapCandidate,
CommonEnemyLeaderboard,
EVENT_INFO,
COMMON_ENEMY_BONUSES,
SwapRequestCreate,
SwapRequestResponse,
SwapRequestChallengeInfo,
MySwapRequests,
)
from app.schemas.common import (
MessageResponse,
ErrorResponse,
PaginationParams,
)
from app.schemas.dispute import (
DisputeCreate,
DisputeCommentCreate,
DisputeVoteCreate,
DisputeCommentResponse,
DisputeVoteResponse,
DisputeResponse,
AssignmentDetailResponse,
ReturnedAssignmentResponse,
)
__all__ = [
# User
@@ -55,15 +91,20 @@ __all__ = [
"UserWithTelegram",
"TokenResponse",
"TelegramLink",
"PasswordChange",
"UserStats",
"UserProfilePublic",
# Marathon
"MarathonCreate",
"MarathonUpdate",
"MarathonResponse",
"MarathonListItem",
"MarathonPublicInfo",
"ParticipantInfo",
"ParticipantWithUser",
"JoinMarathon",
"LeaderboardEntry",
"SetParticipantRole",
# Game
"GameCreate",
"GameUpdate",
@@ -74,17 +115,46 @@ __all__ = [
"ChallengeUpdate",
"ChallengeResponse",
"ChallengeGenerated",
"ChallengePreview",
"ChallengesPreviewResponse",
"ChallengeSaveItem",
"ChallengesSaveRequest",
"ChallengesGenerateRequest",
# Assignment
"CompleteAssignment",
"AssignmentResponse",
"SpinResult",
"CompleteResult",
"DropResult",
"EventAssignmentResponse",
# Activity
"ActivityResponse",
"FeedResponse",
# Event
"EventCreate",
"EventResponse",
"EventEffects",
"ActiveEventResponse",
"SwapRequest",
"SwapCandidate",
"CommonEnemyLeaderboard",
"EVENT_INFO",
"COMMON_ENEMY_BONUSES",
"SwapRequestCreate",
"SwapRequestResponse",
"SwapRequestChallengeInfo",
"MySwapRequests",
# Common
"MessageResponse",
"ErrorResponse",
"PaginationParams",
# Dispute
"DisputeCreate",
"DisputeCommentCreate",
"DisputeVoteCreate",
"DisputeCommentResponse",
"DisputeVoteResponse",
"DisputeResponse",
"AssignmentDetailResponse",
"ReturnedAssignmentResponse",
]

View File

@@ -24,6 +24,7 @@ class AssignmentResponse(BaseModel):
streak_at_completion: int | None = None
started_at: datetime
completed_at: datetime | None = None
drop_penalty: int = 0 # Calculated penalty if dropped
class Config:
from_attributes = True
@@ -48,3 +49,14 @@ class DropResult(BaseModel):
penalty: int
total_points: int
new_drop_count: int
class EventAssignmentResponse(BaseModel):
"""Response for event-specific assignment (Common Enemy)"""
assignment: AssignmentResponse | None
event_id: int | None
challenge_id: int | None
is_completed: bool
class Config:
from_attributes = True

View File

@@ -51,3 +51,45 @@ class ChallengeGenerated(BaseModel):
estimated_time: int | None = None
proof_type: str
proof_hint: str | None = None
class ChallengePreview(BaseModel):
"""Schema for challenge preview (with game info)"""
game_id: int
game_title: str
title: str
description: str
type: str
difficulty: str
points: int
estimated_time: int | None = None
proof_type: str
proof_hint: str | None = None
class ChallengesPreviewResponse(BaseModel):
"""Response with generated challenges for preview"""
challenges: list[ChallengePreview]
class ChallengeSaveItem(BaseModel):
"""Single challenge to save"""
game_id: int
title: str
description: str
type: str
difficulty: str
points: int
estimated_time: int | None = None
proof_type: str
proof_hint: str | None = None
class ChallengesSaveRequest(BaseModel):
"""Request to save previewed challenges"""
challenges: list[ChallengeSaveItem]
class ChallengesGenerateRequest(BaseModel):
"""Request to generate challenges for specific games"""
game_ids: list[int] | None = None # If None, generate for all approved games without challenges

View File

@@ -0,0 +1,91 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.user import UserPublic
from app.schemas.challenge import ChallengeResponse
class DisputeCreate(BaseModel):
"""Request to create a dispute"""
reason: str = Field(..., min_length=10, max_length=1000)
class DisputeCommentCreate(BaseModel):
"""Request to add a comment to a dispute"""
text: str = Field(..., min_length=1, max_length=500)
class DisputeVoteCreate(BaseModel):
"""Request to vote on a dispute"""
vote: bool # True = valid (proof is OK), False = invalid (proof is not OK)
class DisputeCommentResponse(BaseModel):
"""Comment in a dispute discussion"""
id: int
user: UserPublic
text: str
created_at: datetime
class Config:
from_attributes = True
class DisputeVoteResponse(BaseModel):
"""Vote in a dispute"""
user: UserPublic
vote: bool # True = valid, False = invalid
created_at: datetime
class Config:
from_attributes = True
class DisputeResponse(BaseModel):
"""Full dispute information"""
id: int
raised_by: UserPublic
reason: str
status: str # "open", "valid", "invalid"
comments: list[DisputeCommentResponse]
votes: list[DisputeVoteResponse]
votes_valid: int
votes_invalid: int
my_vote: bool | None # Current user's vote, None if not voted
expires_at: datetime
created_at: datetime
resolved_at: datetime | None
class Config:
from_attributes = True
class AssignmentDetailResponse(BaseModel):
"""Detailed assignment information with proofs and dispute"""
id: int
challenge: ChallengeResponse
participant: UserPublic
status: str
proof_url: str | None # External URL (YouTube, etc.)
proof_image_url: str | None # Uploaded file URL
proof_comment: str | None
points_earned: int
streak_at_completion: int | None
started_at: datetime
completed_at: datetime | None
can_dispute: bool # True if <24h since completion and not own assignment
dispute: DisputeResponse | None
class Config:
from_attributes = True
class ReturnedAssignmentResponse(BaseModel):
"""Returned assignment that needs to be redone"""
id: int
challenge: ChallengeResponse
original_completed_at: datetime
dispute_reason: str
class Config:
from_attributes = True

View File

@@ -0,0 +1,174 @@
from datetime import datetime
from pydantic import BaseModel, Field
from typing import Literal
from app.models.event import EventType
from app.schemas.user import UserPublic
# Event type literals for Pydantic
EventTypeLiteral = Literal[
"golden_hour",
"common_enemy",
"double_risk",
"jackpot",
"swap",
"game_choice",
]
class EventCreate(BaseModel):
type: EventTypeLiteral
duration_minutes: int | None = Field(
None,
description="Duration in minutes. If not provided, uses default for event type."
)
challenge_id: int | None = Field(
None,
description="For common_enemy event - the challenge everyone will get"
)
class EventEffects(BaseModel):
points_multiplier: float = 1.0
drop_free: bool = False
special_action: str | None = None # "swap", "game_choice"
description: str = ""
class EventResponse(BaseModel):
id: int
type: EventTypeLiteral
start_time: datetime
end_time: datetime | None
is_active: bool
created_by: UserPublic | None
data: dict | None
created_at: datetime
class Config:
from_attributes = True
class ActiveEventResponse(BaseModel):
event: EventResponse | None
effects: EventEffects
time_remaining_seconds: int | None = None
class SwapRequest(BaseModel):
target_participant_id: int
class CommonEnemyLeaderboard(BaseModel):
participant_id: int
user: UserPublic
completed_at: datetime | None
rank: int | None
bonus_points: int
# Event descriptions and default durations
EVENT_INFO = {
EventType.GOLDEN_HOUR: {
"name": "Золотой час",
"description": "Все очки x1.5!",
"default_duration": 45,
"points_multiplier": 1.5,
"drop_free": False,
},
EventType.COMMON_ENEMY: {
"name": "Общий враг",
"description": "Все получают одинаковый челлендж. Первые 3 получают бонус!",
"default_duration": None, # Until all complete
"points_multiplier": 1.0,
"drop_free": False,
},
EventType.DOUBLE_RISK: {
"name": "Безопасная игра",
"description": "Дропы бесплатны, но очки x0.5",
"default_duration": 120,
"points_multiplier": 0.5,
"drop_free": True,
},
EventType.JACKPOT: {
"name": "Джекпот",
"description": "Следующий спин — сложный челлендж с x3 очками!",
"default_duration": None, # 1 spin
"points_multiplier": 3.0,
"drop_free": False,
},
EventType.SWAP: {
"name": "Обмен",
"description": "Можно поменяться заданием с другим участником",
"default_duration": 60,
"points_multiplier": 1.0,
"drop_free": False,
"special_action": "swap",
},
EventType.GAME_CHOICE: {
"name": "Выбор игры",
"description": "Выбери игру и один из 3 челленджей. Можно заменить текущее задание без штрафа!",
"default_duration": 120,
"points_multiplier": 1.0,
"drop_free": True, # Free replacement of current assignment
"special_action": "game_choice",
},
}
# Bonus points for Common Enemy top 3
COMMON_ENEMY_BONUSES = {
1: 50,
2: 30,
3: 15,
}
class SwapCandidate(BaseModel):
"""Participant available for assignment swap"""
participant_id: int
user: UserPublic
challenge_title: str
challenge_description: str
challenge_points: int
challenge_difficulty: str
game_title: str
# Two-sided swap confirmation schemas
SwapRequestStatusLiteral = Literal["pending", "accepted", "declined", "cancelled"]
class SwapRequestCreate(BaseModel):
"""Request to swap assignment with another participant"""
target_participant_id: int
class SwapRequestChallengeInfo(BaseModel):
"""Challenge info for swap request display"""
title: str
description: str
points: int
difficulty: str
game_title: str
class SwapRequestResponse(BaseModel):
"""Response for a swap request"""
id: int
status: SwapRequestStatusLiteral
from_user: UserPublic
to_user: UserPublic
from_challenge: SwapRequestChallengeInfo
to_challenge: SwapRequestChallengeInfo
created_at: datetime
responded_at: datetime | None
class Config:
from_attributes = True
class MySwapRequests(BaseModel):
"""User's incoming and outgoing swap requests"""
incoming: list[SwapRequestResponse]
outgoing: list[SwapRequestResponse]

View File

@@ -32,7 +32,9 @@ class GameShort(BaseModel):
class GameResponse(GameBase):
id: int
cover_url: str | None = None
added_by: UserPublic | None = None
status: str = "pending"
proposed_by: UserPublic | None = None
approved_by: UserPublic | None = None
challenges_count: int = 0
created_at: datetime

View File

@@ -12,16 +12,22 @@ class MarathonBase(BaseModel):
class MarathonCreate(MarathonBase):
start_date: datetime
duration_days: int = Field(default=30, ge=1, le=365)
is_public: bool = False
game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$")
class MarathonUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
start_date: datetime | None = None
is_public: bool | None = None
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
auto_events_enabled: bool | None = None
class ParticipantInfo(BaseModel):
id: int
role: str = "participant"
total_points: int
current_streak: int
drop_count: int
@@ -37,9 +43,12 @@ class ParticipantWithUser(ParticipantInfo):
class MarathonResponse(MarathonBase):
id: int
organizer: UserPublic
creator: UserPublic
status: str
invite_code: str
is_public: bool
game_proposal_mode: str
auto_events_enabled: bool
start_date: datetime | None
end_date: datetime | None
participants_count: int
@@ -51,10 +60,15 @@ class MarathonResponse(MarathonBase):
from_attributes = True
class SetParticipantRole(BaseModel):
role: str = Field(..., pattern="^(participant|organizer)$")
class MarathonListItem(BaseModel):
id: int
title: str
status: str
is_public: bool
participants_count: int
start_date: datetime | None
end_date: datetime | None
@@ -67,6 +81,19 @@ class JoinMarathon(BaseModel):
invite_code: str
class MarathonPublicInfo(BaseModel):
"""Public info about marathon for invite page (no auth required)"""
id: int
title: str
description: str | None
status: str
participants_count: int
creator_nickname: str
class Config:
from_attributes = True
class LeaderboardEntry(BaseModel):
rank: int
user: UserPublic

View File

@@ -32,6 +32,12 @@ class UserPublic(UserBase):
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
created_at: datetime
class Config:
@@ -52,3 +58,28 @@ class TokenResponse(BaseModel):
class TelegramLink(BaseModel):
telegram_id: int
telegram_username: str | None = None
class PasswordChange(BaseModel):
current_password: str = Field(..., min_length=6)
new_password: str = Field(..., min_length=6, max_length=100)
class UserStats(BaseModel):
"""Статистика пользователя по марафонам"""
marathons_count: int
wins_count: int
completed_assignments: int
total_points_earned: int
class UserProfilePublic(BaseModel):
"""Публичный профиль пользователя со статистикой"""
id: int
nickname: str
avatar_url: str | None = None
created_at: datetime
stats: UserStats
class Config:
from_attributes = True

View File

@@ -0,0 +1,89 @@
"""
Dispute Scheduler for automatic dispute resolution after 24 hours.
"""
import asyncio
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
from app.services.disputes import dispute_service
# Configuration
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours
class DisputeScheduler:
"""Background scheduler for automatic dispute resolution."""
def __init__(self):
self._running = False
self._task: asyncio.Task | None = None
async def start(self, session_factory) -> None:
"""Start the scheduler background task."""
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._run_loop(session_factory))
print("[DisputeScheduler] Started")
async def stop(self) -> None:
"""Stop the scheduler."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
print("[DisputeScheduler] Stopped")
async def _run_loop(self, session_factory) -> None:
"""Main scheduler loop."""
while self._running:
try:
async with session_factory() as db:
await self._process_expired_disputes(db)
except Exception as e:
print(f"[DisputeScheduler] Error in loop: {e}")
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
async def _process_expired_disputes(self, db: AsyncSession) -> None:
"""Process and resolve expired disputes."""
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
# Find all open disputes that have expired
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
)
.where(
Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time,
)
)
expired_disputes = result.scalars().all()
for dispute in expired_disputes:
try:
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute(
db, dispute.id
)
print(
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: "
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})"
)
except Exception as e:
print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}")
# Global scheduler instance
dispute_scheduler = DisputeScheduler()

View File

@@ -0,0 +1,149 @@
"""
Dispute resolution service.
"""
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
Dispute, DisputeStatus, DisputeVote,
Assignment, AssignmentStatus, Participant, Marathon, Challenge, Game,
)
from app.services.telegram_notifier import telegram_notifier
class DisputeService:
"""Service for dispute resolution logic"""
async def resolve_dispute(self, db: AsyncSession, dispute_id: int) -> tuple[str, int, int]:
"""
Resolve a dispute based on votes.
Returns:
Tuple of (result_status, votes_valid, votes_invalid)
"""
# Get dispute with votes and assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise ValueError(f"Dispute {dispute_id} not found")
if dispute.status != DisputeStatus.OPEN.value:
raise ValueError(f"Dispute {dispute_id} is already resolved")
# Count votes
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
# Determine result: tie goes to the accused (valid)
if votes_invalid > votes_valid:
# Proof is invalid - mark assignment as RETURNED
result_status = DisputeStatus.RESOLVED_INVALID.value
await self._handle_invalid_proof(db, dispute)
else:
# Proof is valid (or tie)
result_status = DisputeStatus.RESOLVED_VALID.value
# Update dispute
dispute.status = result_status
dispute.resolved_at = datetime.utcnow()
await db.commit()
# Send Telegram notification about dispute resolution
await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value)
return result_status, votes_valid, votes_invalid
async def _notify_dispute_resolved(
self,
db: AsyncSession,
dispute: Dispute,
is_valid: bool
) -> None:
"""Send notification about dispute resolution to the assignment owner."""
try:
# Get assignment with challenge and marathon info
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(Assignment.id == dispute.assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
return
participant = assignment.participant
challenge = assignment.challenge
game = challenge.game if challenge else None
# Get marathon
result = await db.execute(
select(Marathon).where(Marathon.id == game.marathon_id if game else 0)
)
marathon = result.scalar_one_or_none()
if marathon and participant:
await telegram_notifier.notify_dispute_resolved(
db,
user_id=participant.user_id,
marathon_title=marathon.title,
challenge_title=challenge.title if challenge else "Unknown",
is_valid=is_valid
)
except Exception as e:
print(f"[DisputeService] Failed to send notification: {e}")
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
"""
Handle the case when proof is determined to be invalid.
- Mark assignment as RETURNED
- Subtract points from participant
- Reset streak if it was affected
"""
assignment = dispute.assignment
participant = assignment.participant
# Subtract points that were earned
points_to_subtract = assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Reset assignment
assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0
# Keep proof data so it can be reviewed
print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, "
f"subtracted {points_to_subtract} points from participant {participant.id}")
async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
"""Get all open disputes older than specified hours"""
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(Dispute)
.where(
Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time,
)
)
return list(result.scalars().all())
# Global service instance
dispute_service = DisputeService()

View File

@@ -0,0 +1,150 @@
"""
Event Scheduler for automatic event launching in marathons.
"""
import asyncio
import random
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Marathon, MarathonStatus, Event, EventType
from app.services.events import EventService
# Configuration
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
EVENT_PROBABILITY = 0.1 # 10% chance per check to start an event
MIN_EVENT_GAP_HOURS = 4 # Minimum hours between events
# Events that can be auto-triggered (excluding common_enemy which needs a challenge_id)
AUTO_EVENT_TYPES = [
EventType.GOLDEN_HOUR,
EventType.DOUBLE_RISK,
EventType.JACKPOT,
EventType.GAME_CHOICE,
]
class EventScheduler:
"""Background scheduler for automatic event management."""
def __init__(self):
self._running = False
self._task: asyncio.Task | None = None
async def start(self, session_factory) -> None:
"""Start the scheduler background task."""
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._run_loop(session_factory))
print("[EventScheduler] Started")
async def stop(self) -> None:
"""Stop the scheduler."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
print("[EventScheduler] Stopped")
async def _run_loop(self, session_factory) -> None:
"""Main scheduler loop."""
while self._running:
try:
async with session_factory() as db:
await self._process_events(db)
except Exception as e:
print(f"[EventScheduler] Error in loop: {e}")
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
async def _process_events(self, db: AsyncSession) -> None:
"""Process events - cleanup expired and potentially start new ones."""
# 1. Cleanup expired events
await self._cleanup_expired_events(db)
# 2. Maybe start new events for eligible marathons
await self._maybe_start_events(db)
async def _cleanup_expired_events(self, db: AsyncSession) -> None:
"""End any events that have expired."""
now = datetime.utcnow()
result = await db.execute(
select(Event).where(
Event.is_active == True,
Event.end_time < now,
)
)
expired_events = result.scalars().all()
for event in expired_events:
event.is_active = False
print(f"[EventScheduler] Ended expired event {event.id} ({event.type})")
if expired_events:
await db.commit()
async def _maybe_start_events(self, db: AsyncSession) -> None:
"""Potentially start new events for eligible marathons."""
# Get active marathons with auto_events enabled
result = await db.execute(
select(Marathon).where(
Marathon.status == MarathonStatus.ACTIVE.value,
Marathon.auto_events_enabled == True,
)
)
marathons = result.scalars().all()
event_service = EventService()
for marathon in marathons:
# Skip if random chance doesn't hit
if random.random() > EVENT_PROBABILITY:
continue
# Check if there's already an active event
active_event = await event_service.get_active_event(db, marathon.id)
if active_event:
continue
# Check if enough time has passed since last event
result = await db.execute(
select(Event)
.where(Event.marathon_id == marathon.id)
.order_by(Event.end_time.desc())
.limit(1)
)
last_event = result.scalar_one_or_none()
if last_event:
time_since_last = datetime.utcnow() - last_event.end_time
if time_since_last < timedelta(hours=MIN_EVENT_GAP_HOURS):
continue
# Start a random event
event_type = random.choice(AUTO_EVENT_TYPES)
try:
event = await event_service.start_event(
db=db,
marathon_id=marathon.id,
event_type=event_type.value,
created_by_id=None, # null = auto-started
)
print(
f"[EventScheduler] Auto-started {event_type.value} for marathon {marathon.id}"
)
except Exception as e:
print(
f"[EventScheduler] Failed to start event for marathon {marathon.id}: {e}"
)
# Global scheduler instance
event_scheduler = EventScheduler()

View File

@@ -0,0 +1,302 @@
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Event, EventType, Marathon, Challenge, Difficulty, Participant, Assignment, AssignmentStatus
from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES
from app.services.telegram_notifier import telegram_notifier
class EventService:
"""Service for managing marathon events"""
async def get_active_event(self, db: AsyncSession, marathon_id: int) -> Event | None:
"""Get currently active event for marathon"""
now = datetime.utcnow()
result = await db.execute(
select(Event)
.options(selectinload(Event.created_by))
.where(
Event.marathon_id == marathon_id,
Event.is_active == True,
Event.start_time <= now,
)
.order_by(Event.start_time.desc())
)
event = result.scalar_one_or_none()
# Check if event has expired
if event and event.end_time and event.end_time < now:
await self.end_event(db, event.id)
return None
return event
async def can_start_event(self, db: AsyncSession, marathon_id: int) -> bool:
"""Check if we can start a new event (no active event exists)"""
active = await self.get_active_event(db, marathon_id)
return active is None
async def start_event(
self,
db: AsyncSession,
marathon_id: int,
event_type: str,
created_by_id: int | None = None,
duration_minutes: int | None = None,
challenge_id: int | None = None,
) -> Event:
"""Start a new event"""
# Check no active event
if not await self.can_start_event(db, marathon_id):
raise ValueError("An event is already active")
# Get default duration if not provided
event_info = EVENT_INFO.get(EventType(event_type), {})
if duration_minutes is None:
duration_minutes = event_info.get("default_duration")
now = datetime.utcnow()
end_time = now + timedelta(minutes=duration_minutes) if duration_minutes else None
# Build event data
data = {}
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
data["challenge_id"] = challenge_id
data["completions"] = [] # Track who completed and when
event = Event(
marathon_id=marathon_id,
type=event_type,
start_time=now,
end_time=end_time,
is_active=True,
created_by_id=created_by_id,
data=data if data else None,
)
db.add(event)
await db.flush() # Get event.id before committing
# Auto-assign challenge to all participants for Common Enemy
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id)
await db.commit()
await db.refresh(event)
# Load created_by relationship
if created_by_id:
await db.refresh(event, ["created_by"])
# Send Telegram notifications
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_event_start(
db, marathon_id, event_type, marathon.title
)
return event
async def _assign_common_enemy_to_all(
self,
db: AsyncSession,
marathon_id: int,
event_id: int,
challenge_id: int,
) -> None:
"""Create event assignments for all participants in the marathon"""
# Get all participants
result = await db.execute(
select(Participant).where(Participant.marathon_id == marathon_id)
)
participants = result.scalars().all()
# Create event assignment for each participant
for participant in participants:
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge_id,
status=AssignmentStatus.ACTIVE.value,
event_type=EventType.COMMON_ENEMY.value,
is_event_assignment=True,
event_id=event_id,
)
db.add(assignment)
async def end_event(self, db: AsyncSession, event_id: int) -> None:
"""End an event and mark incomplete event assignments as expired"""
from sqlalchemy import update
result = await db.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if event:
event_type = event.type
marathon_id = event.marathon_id
event.is_active = False
if not event.end_time:
event.end_time = datetime.utcnow()
# Mark all incomplete event assignments for this event as dropped
if event.type == EventType.COMMON_ENEMY.value:
await db.execute(
update(Assignment)
.where(
Assignment.event_id == event_id,
Assignment.is_event_assignment == True,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
.values(
status=AssignmentStatus.DROPPED.value,
completed_at=datetime.utcnow(),
)
)
await db.commit()
# Send Telegram notifications about event end
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_event_end(
db, marathon_id, event_type, marathon.title
)
async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None:
"""Consume jackpot event after one spin"""
await self.end_event(db, event_id)
def get_event_effects(self, event: Event | None) -> EventEffects:
"""Get effects of an event"""
if not event:
return EventEffects(description="Нет активного события")
event_info = EVENT_INFO.get(EventType(event.type), {})
return EventEffects(
points_multiplier=event_info.get("points_multiplier", 1.0),
drop_free=event_info.get("drop_free", False),
special_action=event_info.get("special_action"),
description=event_info.get("description", ""),
)
async def get_random_hard_challenge(
self,
db: AsyncSession,
marathon_id: int
) -> Challenge | None:
"""Get a random hard challenge for jackpot event"""
result = await db.execute(
select(Challenge)
.join(Challenge.game)
.where(
Challenge.game.has(marathon_id=marathon_id),
Challenge.difficulty == Difficulty.HARD.value,
)
)
challenges = result.scalars().all()
if not challenges:
# Fallback to any challenge
result = await db.execute(
select(Challenge)
.join(Challenge.game)
.where(Challenge.game.has(marathon_id=marathon_id))
)
challenges = result.scalars().all()
if challenges:
import random
return random.choice(challenges)
return None
async def record_common_enemy_completion(
self,
db: AsyncSession,
event: Event,
participant_id: int,
user_id: int,
) -> tuple[int, bool, list[dict] | None]:
"""
Record completion for common enemy event.
Returns: (bonus_points, event_closed, winners_list)
- bonus_points: bonus for this completion (top 3 get bonuses)
- event_closed: True if event was auto-closed (3 completions reached)
- winners_list: list of winners if event closed, None otherwise
"""
if event.type != EventType.COMMON_ENEMY.value:
print(f"[COMMON_ENEMY] Event type mismatch: {event.type}")
return 0, False, None
data = event.data or {}
completions = data.get("completions", [])
print(f"[COMMON_ENEMY] Current completions count: {len(completions)}")
# Check if already completed
if any(c["participant_id"] == participant_id for c in completions):
print(f"[COMMON_ENEMY] Participant {participant_id} already completed")
return 0, False, None
# Add completion
rank = len(completions) + 1
completions.append({
"participant_id": participant_id,
"user_id": user_id,
"completed_at": datetime.utcnow().isoformat(),
"rank": rank,
})
print(f"[COMMON_ENEMY] Added completion for user {user_id}, rank={rank}")
# Update event data - need to flag_modified for SQLAlchemy to detect JSON changes
event.data = {**data, "completions": completions}
flag_modified(event, "data")
bonus = COMMON_ENEMY_BONUSES.get(rank, 0)
# Auto-close event when 3 players completed
event_closed = False
winners_list = None
if rank >= 3:
event.is_active = False
event.end_time = datetime.utcnow()
event_closed = True
winners_list = completions[:3] # Top 3
print(f"[COMMON_ENEMY] Event auto-closed! Winners: {winners_list}")
await db.commit()
return bonus, event_closed, winners_list
async def get_common_enemy_challenge(
self,
db: AsyncSession,
event: Event
) -> Challenge | None:
"""Get the challenge for common enemy event"""
if event.type != EventType.COMMON_ENEMY.value:
return None
data = event.data or {}
challenge_id = data.get("challenge_id")
if not challenge_id:
return None
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.where(Challenge.id == challenge_id)
)
return result.scalar_one_or_none()
def get_time_remaining(self, event: Event | None) -> int | None:
"""Get remaining time in seconds for an event"""
if not event or not event.end_time:
return None
remaining = (event.end_time - datetime.utcnow()).total_seconds()
return max(0, int(remaining))
event_service = EventService()

View File

@@ -13,76 +13,125 @@ class GPTService:
async def generate_challenges(
self,
game_title: str,
game_genre: str | None = None
) -> list[ChallengeGenerated]:
games: list[dict]
) -> dict[int, list[ChallengeGenerated]]:
"""
Generate challenges for a game using GPT.
Generate challenges for multiple games in one API call.
Args:
game_title: Name of the game
game_genre: Optional genre of the game
games: List of dicts with keys: id, title, genre
Returns:
List of generated challenges
Dict mapping game_id to list of generated challenges
"""
genre_text = f" (жанр: {game_genre})" if game_genre else ""
if not games:
return {}
prompt = f"""Для видеоигры "{game_title}"{genre_text} сгенерируй 6 челленджей для игрового марафона.
games_text = "\n".join([
f"- {g['title']}" + (f" (жанр: {g['genre']})" if g.get('genre') else "")
for g in games
])
Требования:
- 2 лёгких челленджа (15-30 минут игры)
- 2 средних челленджа (1-2 часа игры)
- 2 сложных челленджа (3+ часов или высокая сложность)
prompt = f"""Ты — эксперт по видеоиграм. Сгенерируй по 6 КОНКРЕТНЫХ челленджей для каждой из следующих игр:
Для каждого челленджа укажи:
- title: короткое название на русском (до 50 символов)
- description: что нужно сделать на русском (1-2 предложения)
- type: один из [completion, no_death, speedrun, collection, achievement, challenge_run]
- difficulty: easy/medium/hard
- points: очки (easy: 30-50, medium: 60-100, hard: 120-200)
- estimated_time: примерное время в минутах
- proof_type: screenshot/video/steam (что лучше подойдёт для проверки)
- proof_hint: что должно быть на скриншоте/видео для подтверждения на русском
{games_text}
Ответь ТОЛЬКО валидным JSON объектом с ключом "challenges" содержащим массив челленджей.
Пример формата:
{{"challenges": [{{"title": "...", "description": "...", "type": "...", "difficulty": "...", "points": 50, "estimated_time": 30, "proof_type": "...", "proof_hint": "..."}}]}}"""
ВАЖНО:
- ВСЕ ТЕКСТЫ (title, description, proof_hint) ОБЯЗАТЕЛЬНО ПИШИ НА РУССКОМ ЯЗЫКЕ!
- Используй интернет для поиска актуальной информации об играх
- Челленджи должны быть СПЕЦИФИЧНЫМИ для каждой игры!
- Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры
- Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре
- НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов"
Требования по сложности ДЛЯ КАЖДОЙ ИГРЫ:
- 2 лёгких (15-30 мин): простые задачи
- 2 средних (1-2 часа): требуют навыка
- 2 сложных (3-12 часов): серьёзный челлендж
Формат ответа — JSON с объектом где ключи это ТОЧНЫЕ названия игр, как они указаны в запросе:
{{
"Название игры 1": {{
"challenges": [
{{"title": "...", "description": "...", "type": "completion|no_death|speedrun|collection|achievement|challenge_run", "difficulty": "easy|medium|hard", "points": 50, "estimated_time": 30, "proof_type": "screenshot|video|steam", "proof_hint": "..."}}
]
}},
"Название игры 2": {{
"challenges": [...]
}}
}}
points: easy=20-40, medium=45-75, hard=90-150
Ответь ТОЛЬКО JSON. ОПИСАНИЕ И НАЗВАНИЕ ЧЕЛЛЕНДЖА ТОЛЬКО НА РУССКОМ ЯЗЫКЕ!"""
response = await self.client.chat.completions.create(
model="gpt-4o-mini",
model="gpt-5",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.7,
max_tokens=2000,
)
content = response.choices[0].message.content
data = json.loads(content)
# Map game titles to IDs (case-insensitive, strip whitespace)
title_to_id = {g['title'].lower().strip(): g['id'] for g in games}
# Also keep original titles for logging
id_to_title = {g['id']: g['title'] for g in games}
print(f"[GPT] Requested games: {[g['title'] for g in games]}")
print(f"[GPT] Response keys: {list(data.keys())}")
result = {}
for game_title, game_data in data.items():
# Try exact match first, then case-insensitive
game_id = title_to_id.get(game_title.lower().strip())
if not game_id:
# Try partial match if exact match fails
for stored_title, gid in title_to_id.items():
if stored_title in game_title.lower() or game_title.lower() in stored_title:
game_id = gid
break
if not game_id:
print(f"[GPT] Could not match game: '{game_title}'")
continue
challenges = []
for ch in data.get("challenges", []):
# Validate and normalize type
for ch in game_data.get("challenges", []):
challenges.append(self._parse_challenge(ch))
result[game_id] = challenges
print(f"[GPT] Generated {len(challenges)} challenges for '{id_to_title.get(game_id)}'")
return result
def _parse_challenge(self, ch: dict) -> ChallengeGenerated:
"""Parse and validate a single challenge from GPT response"""
ch_type = ch.get("type", "completion")
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
ch_type = "completion"
# Validate difficulty
difficulty = ch.get("difficulty", "medium")
if difficulty not in ["easy", "medium", "hard"]:
difficulty = "medium"
# Validate proof_type
proof_type = ch.get("proof_type", "screenshot")
if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot"
# Validate points
points = ch.get("points", 50)
points = ch.get("points", 30)
if not isinstance(points, int) or points < 1:
points = 50
points = 30
if difficulty == "easy":
points = max(20, min(40, points))
elif difficulty == "medium":
points = max(45, min(75, points))
elif difficulty == "hard":
points = max(90, min(150, points))
challenges.append(ChallengeGenerated(
return ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100],
description=ch.get("description", "Complete the challenge"),
type=ch_type,
@@ -91,6 +140,7 @@ class GPTService:
estimated_time=ch.get("estimated_time"),
proof_type=proof_type,
proof_hint=ch.get("proof_hint"),
))
)
return challenges
gpt_service = GPTService()

View File

@@ -1,3 +1,6 @@
from app.models import Event, EventType
class PointsService:
"""Service for calculating points and penalties"""
@@ -10,46 +13,87 @@ class PointsService:
}
MAX_STREAK_MULTIPLIER = 0.4
DROP_PENALTIES = {
0: 0, # First drop is free
1: 10,
2: 25,
# Drop penalty as percentage of challenge points
DROP_PENALTY_PERCENTAGES = {
0: 0.5, # 1st drop: 50%
1: 0.75, # 2nd drop: 75%
}
MAX_DROP_PENALTY_PERCENTAGE = 1.0 # 3rd+ drop: 100%
# Event point multipliers
EVENT_MULTIPLIERS = {
EventType.GOLDEN_HOUR.value: 1.5,
EventType.DOUBLE_RISK.value: 0.5,
EventType.JACKPOT.value: 3.0,
# GAME_CHOICE uses 1.0 multiplier (default)
}
MAX_DROP_PENALTY = 50
def calculate_completion_points(
self,
base_points: int,
current_streak: int
) -> tuple[int, int]:
current_streak: int,
event: Event | None = None,
) -> tuple[int, int, int]:
"""
Calculate points earned for completing a challenge.
Args:
base_points: Base points for the challenge
current_streak: Current streak before this completion
event: Active event (optional)
Returns:
Tuple of (total_points, streak_bonus)
Tuple of (total_points, streak_bonus, event_bonus)
"""
multiplier = self.STREAK_MULTIPLIERS.get(
# Apply event multiplier first
event_multiplier = 1.0
if event:
event_multiplier = self.EVENT_MULTIPLIERS.get(event.type, 1.0)
adjusted_base = int(base_points * event_multiplier)
event_bonus = adjusted_base - base_points
# Then apply streak bonus
streak_multiplier = self.STREAK_MULTIPLIERS.get(
current_streak,
self.MAX_STREAK_MULTIPLIER
)
bonus = int(base_points * multiplier)
return base_points + bonus, bonus
streak_bonus = int(adjusted_base * streak_multiplier)
def calculate_drop_penalty(self, consecutive_drops: int) -> int:
total_points = adjusted_base + streak_bonus
return total_points, streak_bonus, event_bonus
def calculate_drop_penalty(
self,
consecutive_drops: int,
challenge_points: int,
event: Event | None = None
) -> int:
"""
Calculate penalty for dropping a challenge.
Args:
consecutive_drops: Number of drops since last completion
challenge_points: Base points of the challenge being dropped
event: Active event (optional)
Returns:
Penalty points to subtract
"""
return self.DROP_PENALTIES.get(
# Double risk event = free drops
if event and event.type == EventType.DOUBLE_RISK.value:
return 0
penalty_percentage = self.DROP_PENALTY_PERCENTAGES.get(
consecutive_drops,
self.MAX_DROP_PENALTY
self.MAX_DROP_PENALTY_PERCENTAGE
)
return int(challenge_points * penalty_percentage)
def apply_event_multiplier(self, base_points: int, event: Event | None) -> int:
"""Apply event multiplier to points"""
if not event:
return base_points
multiplier = self.EVENT_MULTIPLIERS.get(event.type, 1.0)
return int(base_points * multiplier)

View File

@@ -0,0 +1,269 @@
"""
Storage service for file uploads.
Supports both local filesystem and S3-compatible storage (FirstVDS).
"""
import logging
import uuid
from pathlib import Path
from typing import Literal
import boto3
from botocore.exceptions import ClientError, BotoCoreError
from botocore.config import Config
from app.core.config import settings
logger = logging.getLogger(__name__)
StorageFolder = Literal["avatars", "covers", "proofs"]
class StorageService:
"""Unified storage service with S3 and local filesystem support."""
def __init__(self):
self._s3_client = None
@property
def s3_client(self):
"""Lazy initialization of S3 client."""
if self._s3_client is None and settings.S3_ENABLED:
logger.info(f"Initializing S3 client: endpoint={settings.S3_ENDPOINT_URL}, bucket={settings.S3_BUCKET_NAME}")
try:
# Use signature_version=s3v4 for S3-compatible storage
self._s3_client = boto3.client(
"s3",
endpoint_url=settings.S3_ENDPOINT_URL,
aws_access_key_id=settings.S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.S3_SECRET_ACCESS_KEY,
region_name=settings.S3_REGION or "us-east-1",
config=Config(signature_version="s3v4"),
)
logger.info("S3 client initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize S3 client: {e}")
self._s3_client = None
return self._s3_client
def generate_filename(self, prefix: str | int, original_filename: str | None) -> str:
"""Generate unique filename with prefix."""
ext = "jpg"
if original_filename and "." in original_filename:
ext = original_filename.rsplit(".", 1)[-1].lower()
return f"{prefix}_{uuid.uuid4().hex}.{ext}"
async def upload_file(
self,
content: bytes,
folder: StorageFolder,
filename: str,
content_type: str = "application/octet-stream",
) -> str:
"""
Upload file to storage.
Returns:
Path/key to the uploaded file (relative path for local, S3 key for S3)
"""
if settings.S3_ENABLED:
try:
return await self._upload_to_s3(content, folder, filename, content_type)
except Exception as e:
logger.error(f"S3 upload failed, falling back to local: {e}")
return await self._upload_to_local(content, folder, filename)
else:
return await self._upload_to_local(content, folder, filename)
async def _upload_to_s3(
self,
content: bytes,
folder: StorageFolder,
filename: str,
content_type: str,
) -> str:
"""Upload file to S3."""
key = f"{folder}/{filename}"
if not self.s3_client:
raise RuntimeError("S3 client not initialized")
try:
logger.info(f"Uploading to S3: bucket={settings.S3_BUCKET_NAME}, key={key}")
self.s3_client.put_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
Body=content,
ContentType=content_type,
)
logger.info(f"Successfully uploaded to S3: {key}")
return key
except (ClientError, BotoCoreError) as e:
logger.error(f"S3 upload error: {e}")
raise RuntimeError(f"Failed to upload to S3: {e}")
async def _upload_to_local(
self,
content: bytes,
folder: StorageFolder,
filename: str,
) -> str:
"""Upload file to local filesystem."""
filepath = Path(settings.UPLOAD_DIR) / folder / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f:
f.write(content)
return str(filepath)
def get_url(self, path: str | None, folder: StorageFolder) -> str | None:
"""
Get public URL for a file.
Args:
path: File path/key (can be full path or just filename)
folder: Storage folder (avatars, covers, proofs)
Returns:
Public URL or None if path is None
"""
if not path:
return None
# Extract filename from path
filename = path.split("/")[-1]
if settings.S3_ENABLED:
# S3 URL
return f"{settings.S3_PUBLIC_URL}/{folder}/{filename}"
else:
# Local URL
return f"/uploads/{folder}/{filename}"
async def delete_file(self, path: str | None) -> bool:
"""
Delete file from storage.
Args:
path: File path/key
Returns:
True if deleted, False otherwise
"""
if not path:
return False
if settings.S3_ENABLED:
return await self._delete_from_s3(path)
else:
return await self._delete_from_local(path)
async def _delete_from_s3(self, key: str) -> bool:
"""Delete file from S3."""
try:
self.s3_client.delete_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
)
return True
except ClientError:
return False
async def _delete_from_local(self, path: str) -> bool:
"""Delete file from local filesystem."""
try:
filepath = Path(path)
if filepath.exists():
filepath.unlink()
return True
return False
except Exception:
return False
async def get_file(
self,
path: str,
folder: StorageFolder,
) -> tuple[bytes, str] | None:
"""
Get file content from storage.
Args:
path: File path/key (can be full path or just filename)
folder: Storage folder
Returns:
Tuple of (content bytes, content_type) or None if not found
"""
if not path:
return None
# Extract filename from path
filename = path.split("/")[-1]
if settings.S3_ENABLED:
return await self._get_from_s3(folder, filename)
else:
return await self._get_from_local(folder, filename)
async def _get_from_s3(
self,
folder: StorageFolder,
filename: str,
) -> tuple[bytes, str] | None:
"""Get file from S3."""
key = f"{folder}/{filename}"
if not self.s3_client:
logger.error("S3 client not initialized")
return None
try:
response = self.s3_client.get_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
)
content = response["Body"].read()
content_type = response.get("ContentType", "application/octet-stream")
return content, content_type
except ClientError as e:
logger.error(f"S3 get error for {key}: {e}")
return None
async def _get_from_local(
self,
folder: StorageFolder,
filename: str,
) -> tuple[bytes, str] | None:
"""Get file from local filesystem."""
filepath = Path(settings.UPLOAD_DIR) / folder / filename
if not filepath.exists():
return None
try:
with open(filepath, "rb") as f:
content = f.read()
# Determine content type from extension
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
content_types = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"webp": "image/webp",
"mp4": "video/mp4",
"webm": "video/webm",
"mov": "video/quicktime",
}
content_type = content_types.get(ext, "application/octet-stream")
return content, content_type
except Exception as e:
logger.error(f"Local get error for {filepath}: {e}")
return None
# Singleton instance
storage_service = StorageService()

View File

@@ -0,0 +1,281 @@
import logging
from typing import List
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models import User, Participant, Marathon
logger = logging.getLogger(__name__)
class TelegramNotifier:
"""Service for sending Telegram notifications."""
def __init__(self):
self.bot_token = settings.TELEGRAM_BOT_TOKEN
self.api_url = f"https://api.telegram.org/bot{self.bot_token}"
async def send_message(
self,
chat_id: int,
text: str,
parse_mode: str = "HTML",
reply_markup: dict | None = None
) -> bool:
"""Send a message to a Telegram chat."""
if not self.bot_token:
logger.warning("Telegram bot token not configured")
return False
try:
async with httpx.AsyncClient() as client:
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": parse_mode
}
if reply_markup:
payload["reply_markup"] = reply_markup
response = await client.post(
f"{self.api_url}/sendMessage",
json=payload,
timeout=10.0
)
if response.status_code == 200:
return True
else:
logger.error(f"Failed to send message: {response.text}")
return False
except Exception as e:
logger.error(f"Error sending Telegram message: {e}")
return False
async def notify_user(
self,
db: AsyncSession,
user_id: int,
message: str,
reply_markup: dict | None = None
) -> bool:
"""Send notification to a user by user_id."""
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
logger.warning(f"[Notify] User {user_id} not found")
return False
if not user.telegram_id:
logger.warning(f"[Notify] User {user_id} ({user.nickname}) has no telegram_id")
return False
logger.info(f"[Notify] Sending to user {user.nickname} (telegram_id={user.telegram_id})")
return await self.send_message(user.telegram_id, message, reply_markup=reply_markup)
async def notify_marathon_participants(
self,
db: AsyncSession,
marathon_id: int,
message: str,
exclude_user_id: int | None = None
) -> int:
"""Send notification to all marathon participants with linked Telegram."""
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 = result.scalars().all()
sent_count = 0
for user in users:
if exclude_user_id and user.id == exclude_user_id:
continue
if await self.send_message(user.telegram_id, message):
sent_count += 1
return sent_count
# Notification templates
async def notify_event_start(
self,
db: AsyncSession,
marathon_id: int,
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event start."""
event_messages = {
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
"double_risk": f"⚡ <b>Double Risk</b> в «{marathon_title}»!\n\nПоловина очков, но дропы бесплатны!",
"common_enemy": f"👥 <b>Common Enemy</b> в «{marathon_title}»!\n\nВсе получают одинаковый челлендж. Первые 3 — бонус!",
"swap": f"🔄 <b>Swap</b> в «{marathon_title}»!\n\nМожно поменяться заданием с другим участником!",
"game_choice": f"🎲 <b>Выбор игры</b> в «{marathon_title}»!\n\nВыбери игру и один из 3 челленджей!"
}
message = event_messages.get(
event_type,
f"📌 Новое событие в «{marathon_title}»!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_event_end(
self,
db: AsyncSession,
marathon_id: int,
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event end."""
event_names = {
"golden_hour": "Golden Hour",
"jackpot": "Jackpot",
"double_risk": "Double Risk",
"common_enemy": "Common Enemy",
"swap": "Swap",
"game_choice": "Выбор игры"
}
event_name = event_names.get(event_type, "Событие")
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_marathon_start(
self,
db: AsyncSession,
marathon_id: int,
marathon_title: str
) -> int:
"""Notify participants about marathon start."""
message = (
f"🚀 <b>Марафон «{marathon_title}» начался!</b>\n\n"
f"Время крутить колесо и получить первое задание!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_marathon_finish(
self,
db: AsyncSession,
marathon_id: int,
marathon_title: str
) -> int:
"""Notify participants about marathon finish."""
message = (
f"🏆 <b>Марафон «{marathon_title}» завершён!</b>\n\n"
f"Зайди на сайт, чтобы увидеть итоговую таблицу!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_dispute_raised(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
challenge_title: str,
assignment_id: int
) -> bool:
"""Notify user about dispute raised on their assignment."""
logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}")
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
logger.info(f"[Dispute] URL: {dispute_url}")
# Telegram requires HTTPS for inline keyboard URLs
use_inline_button = dispute_url.startswith("https://")
if use_inline_button:
message = (
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}"
)
reply_markup = {
"inline_keyboard": [[
{"text": "Открыть спор", "url": dispute_url}
]]
}
else:
message = (
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"🔗 {dispute_url}"
)
reply_markup = None
result = await self.notify_user(db, user_id, message, reply_markup=reply_markup)
logger.info(f"[Dispute] Notification result: {result}")
return result
async def notify_dispute_resolved(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
challenge_title: str,
is_valid: bool
) -> bool:
"""Notify user about dispute resolution."""
if is_valid:
message = (
f"❌ <b>Спор признан обоснованным</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"Задание возвращено. Выполни его заново."
)
else:
message = (
f"✅ <b>Спор отклонён</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"Твоё выполнение засчитано!"
)
return await self.notify_user(db, user_id, message)
async def notify_game_approved(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was approved."""
message = (
f"✅ <b>Твоя игра одобрена!</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n\n"
f"Теперь она доступна для всех участников."
)
return await self.notify_user(db, user_id, message)
async def notify_game_rejected(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was rejected."""
message = (
f"❌ <b>Твоя игра отклонена</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n\n"
f"Ты можешь предложить другую игру."
)
return await self.notify_user(db, user_id, message)
# Global instance
telegram_notifier = TelegramNotifier()

View File

@@ -19,7 +19,7 @@ pydantic-settings==2.1.0
email-validator==2.1.0
# OpenAI
openai==1.12.0
openai==2.12.0
# Telegram notifications
httpx==0.26.0
@@ -28,5 +28,8 @@ httpx==0.26.0
aiofiles==23.2.1
python-magic==0.4.27
# S3 Storage
boto3==1.34.0
# Utils
python-dotenv==1.0.0

10
bot/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]

14
bot/config.py Normal file
View File

@@ -0,0 +1,14 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str
API_URL: str = "http://backend:8000"
BOT_USERNAME: str = "" # Will be set dynamically on startup
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()

1
bot/handlers/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Bot handlers

60
bot/handlers/link.py Normal file
View File

@@ -0,0 +1,60 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message
from keyboards.main_menu import get_main_menu
from services.api_client import api_client
router = Router()
@router.message(Command("unlink"))
async def cmd_unlink(message: Message):
"""Handle /unlink command to disconnect Telegram account."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Твой аккаунт не привязан к Game Marathon.\n"
"Привяжи его через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
result = await api_client.unlink_telegram(message.from_user.id)
if result.get("success"):
await message.answer(
"<b>Аккаунт отвязан</b>\n\n"
"Ты больше не будешь получать уведомления.\n"
"Чтобы привязать аккаунт снова, используй кнопку в настройках профиля на сайте.",
reply_markup=get_main_menu()
)
else:
await message.answer(
"Произошла ошибка при отвязке аккаунта.\n"
"Попробуй позже или обратись к администратору.",
reply_markup=get_main_menu()
)
@router.message(Command("status"))
async def cmd_status(message: Message):
"""Check account link status."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if user:
await message.answer(
f"<b>Статус аккаунта</b>\n\n"
f"✅ Аккаунт привязан\n"
f"👤 Никнейм: <b>{user.get('nickname', 'N/A')}</b>\n"
f"🆔 ID: {user.get('id', 'N/A')}",
reply_markup=get_main_menu()
)
else:
await message.answer(
"<b>Статус аккаунта</b>\n\n"
"❌ Аккаунт не привязан\n\n"
"Привяжи его через настройки профиля на сайте.",
reply_markup=get_main_menu()
)

211
bot/handlers/marathons.py Normal file
View File

@@ -0,0 +1,211 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from keyboards.main_menu import get_main_menu
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard
from services.api_client import api_client
router = Router()
@router.message(Command("marathons"))
@router.message(F.text == "📊 Мои марафоны")
async def cmd_marathons(message: Message):
"""Show user's marathons."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
marathons = await api_client.get_user_marathons(message.from_user.id)
if not marathons:
await message.answer(
"<b>Мои марафоны</b>\n\n"
"У тебя пока нет активных марафонов.\n"
"Присоединись к марафону на сайте!",
reply_markup=get_main_menu()
)
return
text = "<b>📊 Мои марафоны</b>\n\n"
for m in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(m.get("status"), "")
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
text += f" Очки: {m.get('total_points', 0)} | "
text += f"Место: #{m.get('position', '?')}\n\n"
await message.answer(
text,
reply_markup=get_marathons_keyboard(marathons)
)
@router.callback_query(F.data.startswith("marathon:"))
async def marathon_details(callback: CallbackQuery):
"""Show marathon details."""
marathon_id = int(callback.data.split(":")[1])
details = await api_client.get_marathon_details(
marathon_id=marathon_id,
telegram_id=callback.from_user.id
)
if not details:
await callback.answer("Не удалось загрузить данные марафона", show_alert=True)
return
marathon = details.get("marathon", {})
participant = details.get("participant", {})
active_events = details.get("active_events", [])
current_assignment = details.get("current_assignment")
status_text = {
"preparing": "⏳ Подготовка",
"active": "🎮 Активен",
"finished": "🏁 Завершён"
}.get(marathon.get("status"), "")
text = f"<b>{marathon.get('title')}</b>\n"
text += f"Статус: {status_text}\n\n"
text += f"<b>📈 Твоя статистика:</b>\n"
text += f"• Очки: <b>{participant.get('total_points', 0)}</b>\n"
text += f"• Место: <b>#{details.get('position', '?')}</b>\n"
text += f"• Стрик: <b>{participant.get('current_streak', 0)}</b> 🔥\n"
text += f"• Дропов: <b>{participant.get('drop_count', 0)}</b>\n\n"
if active_events:
text += "<b>⚡ Активные события:</b>\n"
for event in active_events:
event_emoji = {
"golden_hour": "🌟",
"jackpot": "🎰",
"double_risk": "",
"common_enemy": "👥",
"swap": "🔄",
"game_choice": "🎲"
}.get(event.get("type"), "📌")
text += f"{event_emoji} {event.get('type', '').replace('_', ' ').title()}\n"
text += "\n"
if current_assignment:
challenge = current_assignment.get("challenge", {})
game = challenge.get("game", {})
text += f"<b>🎯 Текущее задание:</b>\n"
text += f"Игра: {game.get('title', 'N/A')}\n"
text += f"Задание: {challenge.get('title', 'N/A')}\n"
text += f"Сложность: {challenge.get('difficulty', 'N/A')}\n"
text += f"Очки: {challenge.get('points', 0)}\n"
await callback.message.edit_text(
text,
reply_markup=get_marathon_details_keyboard(marathon_id)
)
await callback.answer()
@router.callback_query(F.data == "back_to_marathons")
async def back_to_marathons(callback: CallbackQuery):
"""Go back to marathons list."""
marathons = await api_client.get_user_marathons(callback.from_user.id)
if not marathons:
await callback.message.edit_text(
"<b>Мои марафоны</b>\n\n"
"У тебя пока нет активных марафонов."
)
await callback.answer()
return
text = "<b>📊 Мои марафоны</b>\n\n"
for m in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(m.get("status"), "")
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
text += f" Очки: {m.get('total_points', 0)} | "
text += f"Место: #{m.get('position', '?')}\n\n"
await callback.message.edit_text(
text,
reply_markup=get_marathons_keyboard(marathons)
)
await callback.answer()
@router.message(Command("stats"))
@router.message(F.text == "📈 Статистика")
async def cmd_stats(message: Message):
"""Show user's overall statistics."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
stats = await api_client.get_user_stats(message.from_user.id)
if not stats:
await message.answer(
"<b>📈 Статистика</b>\n\n"
"Пока нет данных для отображения.\n"
"Начни участвовать в марафонах!",
reply_markup=get_main_menu()
)
return
text = f"<b>📈 Общая статистика</b>\n\n"
text += f"👤 <b>{user.get('nickname', 'Игрок')}</b>\n\n"
text += f"🏆 Марафонов завершено: <b>{stats.get('marathons_completed', 0)}</b>\n"
text += f"🎮 Марафонов активно: <b>{stats.get('marathons_active', 0)}</b>\n"
text += f"✅ Заданий выполнено: <b>{stats.get('challenges_completed', 0)}</b>\n"
text += f"💰 Всего очков: <b>{stats.get('total_points', 0)}</b>\n"
text += f"🔥 Лучший стрик: <b>{stats.get('best_streak', 0)}</b>\n"
await message.answer(text, reply_markup=get_main_menu())
@router.message(Command("settings"))
@router.message(F.text == "⚙️ Настройки")
async def cmd_settings(message: Message):
"""Show notification settings."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
await message.answer(
"<b>⚙️ Настройки</b>\n\n"
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
"Сейчас ты получаешь все уведомления:\n"
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
"• 🚀 Старт/финиш марафонов\n"
"• ⚠️ Споры по заданиям\n\n"
"Команды:\n"
"/unlink - Отвязать аккаунт\n"
"/status - Проверить привязку",
reply_markup=get_main_menu()
)

146
bot/handlers/start.py Normal file
View File

@@ -0,0 +1,146 @@
import logging
from aiogram import Router, F, Bot
from aiogram.filters import CommandStart, Command, CommandObject
from aiogram.types import Message
from config import settings
from keyboards.main_menu import get_main_menu
from services.api_client import api_client
logger = logging.getLogger(__name__)
router = Router()
async def get_user_avatar_url(bot: Bot, user_id: int) -> str | None:
"""Get user's Telegram profile photo URL."""
try:
photos = await bot.get_user_profile_photos(user_id, limit=1)
if photos.total_count > 0 and photos.photos:
# Get the largest photo (last in the list)
photo = photos.photos[0][-1]
file = await bot.get_file(photo.file_id)
if file.file_path:
return f"https://api.telegram.org/file/bot{settings.TELEGRAM_BOT_TOKEN}/{file.file_path}"
except Exception as e:
logger.warning(f"[START] Could not get user avatar: {e}")
return None
@router.message(CommandStart())
async def cmd_start(message: Message, command: CommandObject):
"""Handle /start command with or without deep link."""
logger.info(f"[START] ==================== START COMMAND ====================")
logger.info(f"[START] Telegram user: id={message.from_user.id}, username=@{message.from_user.username}")
logger.info(f"[START] Full message text: '{message.text}'")
logger.info(f"[START] Deep link args (command.args): '{command.args}'")
# Check if there's a deep link token (for account linking)
token = command.args
if token:
logger.info(f"[START] -------- TOKEN RECEIVED --------")
logger.info(f"[START] Token: {token}")
logger.info(f"[START] Token length: {len(token)} chars")
# Get user's avatar
avatar_url = await get_user_avatar_url(message.bot, message.from_user.id)
logger.info(f"[START] User avatar URL: {avatar_url}")
logger.info(f"[START] -------- CALLING API --------")
logger.info(f"[START] Sending to /telegram/confirm-link:")
logger.info(f"[START] - token: {token}")
logger.info(f"[START] - telegram_id: {message.from_user.id}")
logger.info(f"[START] - telegram_username: {message.from_user.username}")
logger.info(f"[START] - telegram_first_name: {message.from_user.first_name}")
logger.info(f"[START] - telegram_last_name: {message.from_user.last_name}")
logger.info(f"[START] - telegram_avatar_url: {avatar_url}")
result = await api_client.confirm_telegram_link(
token=token,
telegram_id=message.from_user.id,
telegram_username=message.from_user.username,
telegram_first_name=message.from_user.first_name,
telegram_last_name=message.from_user.last_name,
telegram_avatar_url=avatar_url
)
logger.info(f"[START] -------- API RESPONSE --------")
logger.info(f"[START] Response: {result}")
logger.info(f"[START] Success: {result.get('success')}")
if result.get("success"):
user_nickname = result.get("nickname", "пользователь")
logger.info(f"[START] ✅ LINK SUCCESS! User '{user_nickname}' linked to telegram_id={message.from_user.id}")
await message.answer(
f"<b>Аккаунт успешно привязан!</b>\n\n"
f"Привет, <b>{user_nickname}</b>!\n\n"
f"Теперь ты будешь получать уведомления о:\n"
f"• Начале и окончании событий (Golden Hour, Jackpot и др.)\n"
f"• Старте и завершении марафонов\n"
f"• Спорах по твоим заданиям\n\n"
f"Используй меню ниже для навигации:",
reply_markup=get_main_menu()
)
return
else:
error = result.get("error", "Неизвестная ошибка")
logger.error(f"[START] ❌ LINK FAILED!")
logger.error(f"[START] Error: {error}")
logger.error(f"[START] Token was: {token}")
await message.answer(
f"<b>Ошибка привязки аккаунта</b>\n\n"
f"{error}\n\n"
f"Попробуй получить новую ссылку на сайте.",
reply_markup=get_main_menu()
)
return
# No token - regular start
logger.info(f"[START] No token, checking if user is already linked...")
user = await api_client.get_user_by_telegram_id(message.from_user.id)
logger.info(f"[START] API response: {user}")
if user:
await message.answer(
f"<b>С возвращением, {user.get('nickname', 'игрок')}!</b>\n\n"
f"Твой аккаунт привязан. Используй меню для навигации:",
reply_markup=get_main_menu()
)
else:
await message.answer(
"<b>Добро пожаловать в Game Marathon Bot!</b>\n\n"
"Этот бот поможет тебе следить за марафонами и "
"получать уведомления о важных событиях.\n\n"
"<b>Для начала работы:</b>\n"
"1. Зайди на сайт в настройки профиля\n"
"2. Нажми кнопку «Привязать Telegram»\n"
"3. Перейди по полученной ссылке\n\n"
"После привязки ты сможешь:\n"
"• Смотреть свои марафоны\n"
"• Получать уведомления о событиях\n"
"• Следить за статистикой",
reply_markup=get_main_menu()
)
@router.message(Command("help"))
@router.message(F.text == "❓ Помощь")
async def cmd_help(message: Message):
"""Handle /help command."""
await message.answer(
"<b>Справка по командам:</b>\n\n"
"/start - Начать работу с ботом\n"
"/marathons - Мои марафоны\n"
"/stats - Моя статистика\n"
"/settings - Настройки уведомлений\n"
"/help - Эта справка\n\n"
"<b>Уведомления:</b>\n"
"Бот присылает уведомления о:\n"
"• 🌟 Golden Hour - очки x1.5\n"
"• 🎰 Jackpot - очки x3\n"
"• ⚡ Double Risk - половина очков, дропы бесплатны\n"
"• 👥 Common Enemy - общий челлендж\n"
"• 🚀 Старт/финиш марафонов\n"
"• ⚠️ Споры по заданиям",
reply_markup=get_main_menu()
)

View File

@@ -0,0 +1 @@
# Bot keyboards

42
bot/keyboards/inline.py Normal file
View File

@@ -0,0 +1,42 @@
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
def get_marathons_keyboard(marathons: list) -> InlineKeyboardMarkup:
"""Create keyboard with marathon buttons."""
buttons = []
for marathon in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(marathon.get("status"), "")
buttons.append([
InlineKeyboardButton(
text=f"{status_emoji} {marathon.get('title', 'Marathon')}",
callback_data=f"marathon:{marathon.get('id')}"
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
"""Create keyboard for marathon details view."""
buttons = [
[
InlineKeyboardButton(
text="🔄 Обновить",
callback_data=f"marathon:{marathon_id}"
)
],
[
InlineKeyboardButton(
text="◀️ Назад к списку",
callback_data="back_to_marathons"
)
]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)

View File

@@ -0,0 +1,21 @@
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
def get_main_menu() -> ReplyKeyboardMarkup:
"""Create main menu keyboard."""
keyboard = [
[
KeyboardButton(text="📊 Мои марафоны"),
KeyboardButton(text="📈 Статистика")
],
[
KeyboardButton(text="⚙️ Настройки"),
KeyboardButton(text="❓ Помощь")
]
]
return ReplyKeyboardMarkup(
keyboard=keyboard,
resize_keyboard=True,
input_field_placeholder="Выбери действие..."
)

100
bot/main.py Normal file
View File

@@ -0,0 +1,100 @@
import asyncio
import logging
import sys
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiohttp import web
from config import settings
from handlers import start, marathons, link
from middlewares.logging import LoggingMiddleware
# Configure logging to stdout with DEBUG level
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
# Set aiogram logging level
logging.getLogger("aiogram").setLevel(logging.INFO)
# Health check state
bot_running = False
async def health_handler(request):
"""Health check endpoint"""
if bot_running:
return web.json_response({"status": "ok", "service": "telegram-bot"})
return web.json_response({"status": "starting"}, status=503)
async def start_health_server():
"""Start health check HTTP server"""
app = web.Application()
app.router.add_get("/health", health_handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", 8080)
await site.start()
logger.info("Health check server started on port 8080")
return runner
async def main():
global bot_running
logger.info("="*50)
logger.info("Starting Game Marathon Bot...")
logger.info(f"API_URL: {settings.API_URL}")
logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...")
logger.info("="*50)
# Start health check server
health_runner = await start_health_server()
bot = Bot(
token=settings.TELEGRAM_BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
# Get bot username for deep links
bot_info = await bot.get_me()
settings.BOT_USERNAME = bot_info.username
logger.info(f"Bot info: @{settings.BOT_USERNAME} (id={bot_info.id})")
dp = Dispatcher()
# Register middleware
dp.message.middleware(LoggingMiddleware())
logger.info("Logging middleware registered")
# Register routers
logger.info("Registering routers...")
dp.include_router(start.router)
dp.include_router(link.router)
dp.include_router(marathons.router)
logger.info("Routers registered: start, link, marathons")
# Mark bot as running
bot_running = True
# Start polling
logger.info("Deleting webhook and starting polling...")
await bot.delete_webhook(drop_pending_updates=True)
logger.info("Polling started! Waiting for messages...")
try:
await dp.start_polling(bot)
finally:
bot_running = False
await health_runner.cleanup()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1 @@
# Bot middlewares

View File

@@ -0,0 +1,28 @@
import logging
from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware
from aiogram.types import Message, Update
logger = logging.getLogger(__name__)
class LoggingMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
event: Message,
data: Dict[str, Any]
) -> Any:
logger.info("="*60)
logger.info(f"[MIDDLEWARE] Incoming message from user {event.from_user.id}")
logger.info(f"[MIDDLEWARE] Username: @{event.from_user.username}")
logger.info(f"[MIDDLEWARE] Text: {event.text}")
logger.info(f"[MIDDLEWARE] Message ID: {event.message_id}")
logger.info(f"[MIDDLEWARE] Chat ID: {event.chat.id}")
logger.info("="*60)
result = await handler(event, data)
logger.info(f"[MIDDLEWARE] Handler completed for message {event.message_id}")
return result

5
bot/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
aiogram==3.23.0
aiohttp==3.10.5
pydantic==2.9.2
pydantic-settings==2.5.2
python-dotenv==1.0.1

1
bot/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Bot services

129
bot/services/api_client.py Normal file
View File

@@ -0,0 +1,129 @@
import logging
from typing import Any
import aiohttp
from config import settings
logger = logging.getLogger(__name__)
class APIClient:
"""HTTP client for backend API communication."""
def __init__(self):
self.base_url = settings.API_URL
self._session: aiohttp.ClientSession | None = None
logger.info(f"[APIClient] Initialized with base_url: {self.base_url}")
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
logger.info("[APIClient] Creating new aiohttp session")
self._session = aiohttp.ClientSession()
return self._session
async def _request(
self,
method: str,
endpoint: str,
**kwargs
) -> dict[str, Any] | None:
"""Make HTTP request to backend API."""
session = await self._get_session()
url = f"{self.base_url}/api/v1{endpoint}"
logger.info(f"[APIClient] {method} {url}")
if 'json' in kwargs:
logger.info(f"[APIClient] Request body: {kwargs['json']}")
if 'params' in kwargs:
logger.info(f"[APIClient] Request params: {kwargs['params']}")
try:
async with session.request(method, url, **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]}")
if response.status == 200:
import json
return json.loads(response_text)
elif response.status == 404:
logger.warning(f"[APIClient] 404 Not Found")
return None
else:
logger.error(f"[APIClient] API error {response.status}: {response_text}")
return {"error": response_text}
except aiohttp.ClientError as e:
logger.error(f"[APIClient] Request failed: {e}")
return {"error": str(e)}
except Exception as e:
logger.error(f"[APIClient] Unexpected error: {e}")
return {"error": str(e)}
async def confirm_telegram_link(
self,
token: str,
telegram_id: int,
telegram_username: str | None,
telegram_first_name: str | None = None,
telegram_last_name: str | None = None,
telegram_avatar_url: str | None = None
) -> dict[str, Any]:
"""Confirm Telegram account linking."""
result = await self._request(
"POST",
"/telegram/confirm-link",
json={
"token": token,
"telegram_id": telegram_id,
"telegram_username": telegram_username,
"telegram_first_name": telegram_first_name,
"telegram_last_name": telegram_last_name,
"telegram_avatar_url": telegram_avatar_url
}
)
return result or {"error": "Не удалось связаться с сервером"}
async def get_user_by_telegram_id(self, telegram_id: int) -> dict[str, Any] | None:
"""Get user by Telegram ID."""
return await self._request("GET", f"/telegram/user/{telegram_id}")
async def unlink_telegram(self, telegram_id: int) -> dict[str, Any]:
"""Unlink Telegram account."""
result = await self._request(
"POST",
f"/telegram/unlink/{telegram_id}"
)
return result or {"error": "Не удалось связаться с сервером"}
async def get_user_marathons(self, telegram_id: int) -> list[dict[str, Any]]:
"""Get user's marathons."""
result = await self._request("GET", f"/telegram/marathons/{telegram_id}")
if isinstance(result, list):
return result
return result.get("marathons", []) if result else []
async def get_marathon_details(
self,
marathon_id: int,
telegram_id: int
) -> dict[str, Any] | None:
"""Get marathon details for user."""
return await self._request(
"GET",
f"/telegram/marathon/{marathon_id}",
params={"telegram_id": telegram_id}
)
async def get_user_stats(self, telegram_id: int) -> dict[str, Any] | None:
"""Get user's overall statistics."""
return await self._request("GET", f"/telegram/stats/{telegram_id}")
async def close(self):
"""Close the HTTP session."""
if self._session and not self._session.closed:
await self._session.close()
# Global API client instance
api_client = APIClient()

View File

@@ -27,7 +27,16 @@ 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}
DEBUG: ${DEBUG:-false}
# S3 Storage
S3_ENABLED: ${S3_ENABLED:-false}
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_PUBLIC_URL: ${S3_PUBLIC_URL:-}
volumes:
- ./backend/uploads:/app/uploads
- ./backend/app:/app/app
@@ -64,5 +73,35 @@ services:
- backend
restart: unless-stopped
bot:
build:
context: ./bot
dockerfile: Dockerfile
container_name: marathon-bot
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- API_URL=http://backend:8000
depends_on:
- backend
restart: unless-stopped
status:
build:
context: ./status-service
dockerfile: Dockerfile
container_name: marathon-status
environment:
BACKEND_URL: http://backend:8000
FRONTEND_URL: http://frontend:80
BOT_URL: http://bot:8080
CHECK_INTERVAL: "30"
ports:
- "8001:8001"
depends_on:
- backend
- frontend
- bot
restart: unless-stopped
volumes:
postgres_data:

View File

@@ -3,8 +3,8 @@ FROM node:20-alpine as build
WORKDIR /app
# Install dependencies
COPY package.json ./
RUN npm install
COPY package.json package-lock.json ./
RUN npm ci --network-timeout 300000
# Copy source
COPY . .

4253
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui'
// Layout
import { Layout } from '@/components/layout/Layout'
@@ -14,6 +15,13 @@ import { MarathonPage } from '@/pages/MarathonPage'
import { LobbyPage } from '@/pages/LobbyPage'
import { PlayPage } from '@/pages/PlayPage'
import { LeaderboardPage } from '@/pages/LeaderboardPage'
import { InvitePage } from '@/pages/InvitePage'
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage'
import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage'
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -39,10 +47,16 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
function App() {
return (
<>
<ToastContainer />
<ConfirmModal />
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
{/* Public invite page */}
<Route path="invite/:code" element={<InvitePage />} />
<Route
path="login"
element={
@@ -114,8 +128,42 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="assignments/:id"
element={
<ProtectedRoute>
<AssignmentDetailPage />
</ProtectedRoute>
}
/>
{/* Profile routes */}
<Route
path="profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
<Route path="users/:id" element={<UserProfilePage />} />
{/* Easter egg - 418 I'm a teapot */}
<Route path="418" element={<TeapotPage />} />
<Route path="teapot" element={<TeapotPage />} />
<Route path="tea" element={<TeapotPage />} />
{/* Server error page */}
<Route path="500" element={<ServerErrorPage />} />
<Route path="error" element={<ServerErrorPage />} />
{/* 404 - must be last */}
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</>
)
}

44
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,44 @@
import client from './client'
import type { AdminUser, AdminMarathon, UserRole, PlatformStats } from '@/types'
export const adminApi = {
// Users
listUsers: async (skip = 0, limit = 50, search?: string): Promise<AdminUser[]> => {
const params: Record<string, unknown> = { skip, limit }
if (search) params.search = search
const response = await client.get<AdminUser[]>('/admin/users', { params })
return response.data
},
getUser: async (id: number): Promise<AdminUser> => {
const response = await client.get<AdminUser>(`/admin/users/${id}`)
return response.data
},
setUserRole: async (id: number, role: UserRole): Promise<AdminUser> => {
const response = await client.patch<AdminUser>(`/admin/users/${id}/role`, { role })
return response.data
},
deleteUser: async (id: number): Promise<void> => {
await client.delete(`/admin/users/${id}`)
},
// Marathons
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
const params: Record<string, unknown> = { skip, limit }
if (search) params.search = search
const response = await client.get<AdminMarathon[]>('/admin/marathons', { params })
return response.data
},
deleteMarathon: async (id: number): Promise<void> => {
await client.delete(`/admin/marathons/${id}`)
},
// Stats
getStats: async (): Promise<PlatformStats> => {
const response = await client.get<PlatformStats>('/admin/stats')
return response.data
},
}

View File

@@ -0,0 +1,47 @@
import client from './client'
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment } from '@/types'
export const assignmentsApi = {
// Get detailed assignment info with proofs and dispute
getDetail: async (assignmentId: number): Promise<AssignmentDetail> => {
const response = await client.get<AssignmentDetail>(`/assignments/${assignmentId}`)
return response.data
},
// Create a dispute against an assignment
createDispute: async (assignmentId: number, reason: string): Promise<Dispute> => {
const response = await client.post<Dispute>(`/assignments/${assignmentId}/dispute`, { reason })
return response.data
},
// Add a comment to a dispute
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
return response.data
},
// Vote on a dispute (true = valid/proof is OK, false = invalid/proof is not OK)
vote: async (disputeId: number, vote: boolean): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/disputes/${disputeId}/vote`, { vote })
return response.data
},
// Get current user's returned assignments
getReturnedAssignments: async (marathonId: number): Promise<ReturnedAssignment[]> => {
const response = await client.get<ReturnedAssignment[]>(`/marathons/${marathonId}/returned-assignments`)
return response.data
},
// Get proof media as blob URL (supports both images and videos)
getProofMediaUrl: async (assignmentId: number): Promise<{ url: string; type: 'image' | 'video' }> => {
const response = await client.get(`/assignments/${assignmentId}/proof-media`, {
responseType: 'blob',
})
const contentType = response.headers['content-type'] || ''
const isVideo = contentType.startsWith('video/')
return {
url: URL.createObjectURL(response.data),
type: isVideo ? 'video' : 'image',
}
},
}

View File

@@ -0,0 +1,9 @@
import client from './client'
import type { Challenge } from '@/types'
export const challengesApi = {
list: async (marathonId: number): Promise<Challenge[]> => {
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/challenges`)
return response.data
},
}

View File

@@ -22,11 +22,28 @@ client.interceptors.request.use((config) => {
client.interceptors.response.use(
(response) => response,
(error: AxiosError<{ detail: string }>) => {
// Unauthorized - redirect to login
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
// Server error or network error - redirect to 500 page
if (
error.response?.status === 500 ||
error.response?.status === 502 ||
error.response?.status === 503 ||
error.response?.status === 504 ||
error.code === 'ERR_NETWORK' ||
error.code === 'ECONNABORTED'
) {
// Only redirect if not already on error page
if (!window.location.pathname.startsWith('/500') && !window.location.pathname.startsWith('/error')) {
window.location.href = '/500'
}
}
return Promise.reject(error)
}
)

View File

@@ -0,0 +1,96 @@
import client from './client'
import type { ActiveEvent, MarathonEvent, EventCreate, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult, GameChoiceChallenges } from '@/types'
export const eventsApi = {
getActive: async (marathonId: number): Promise<ActiveEvent> => {
const response = await client.get<ActiveEvent>(`/marathons/${marathonId}/event`)
return response.data
},
list: async (marathonId: number): Promise<MarathonEvent[]> => {
const response = await client.get<MarathonEvent[]>(`/marathons/${marathonId}/events`)
return response.data
},
start: async (marathonId: number, data: EventCreate): Promise<MarathonEvent> => {
const response = await client.post<MarathonEvent>(`/marathons/${marathonId}/events`, data)
return response.data
},
stop: async (marathonId: number): Promise<void> => {
await client.delete(`/marathons/${marathonId}/event`)
},
// Swap requests (two-sided confirmation)
createSwapRequest: async (marathonId: number, targetParticipantId: number): Promise<SwapRequestItem> => {
const response = await client.post<SwapRequestItem>(`/marathons/${marathonId}/swap-requests`, {
target_participant_id: targetParticipantId,
})
return response.data
},
getSwapRequests: async (marathonId: number): Promise<MySwapRequests> => {
const response = await client.get<MySwapRequests>(`/marathons/${marathonId}/swap-requests`)
return response.data
},
acceptSwapRequest: async (marathonId: number, requestId: number): Promise<void> => {
await client.post(`/marathons/${marathonId}/swap-requests/${requestId}/accept`)
},
declineSwapRequest: async (marathonId: number, requestId: number): Promise<void> => {
await client.post(`/marathons/${marathonId}/swap-requests/${requestId}/decline`)
},
cancelSwapRequest: async (marathonId: number, requestId: number): Promise<void> => {
await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`)
},
// Game Choice event
getGameChoiceChallenges: async (marathonId: number, gameId: number): Promise<GameChoiceChallenges> => {
const response = await client.get<GameChoiceChallenges>(`/marathons/${marathonId}/game-choice/challenges`, {
params: { game_id: gameId },
})
return response.data
},
selectGameChoiceChallenge: async (marathonId: number, challengeId: number): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/game-choice/select`, {
challenge_id: challengeId,
})
return response.data
},
getSwapCandidates: async (marathonId: number): Promise<SwapCandidate[]> => {
const response = await client.get<SwapCandidate[]>(`/marathons/${marathonId}/swap-candidates`)
return response.data
},
getCommonEnemyLeaderboard: async (marathonId: number): Promise<CommonEnemyLeaderboardEntry[]> => {
const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`)
return response.data
},
// Event Assignment (Common Enemy)
getEventAssignment: async (marathonId: number): Promise<EventAssignment> => {
const response = await client.get<EventAssignment>(`/marathons/${marathonId}/event-assignment`)
return response.data
},
completeEventAssignment: async (
assignmentId: number,
data: { proof_url?: string; comment?: string; proof_file?: File }
): Promise<CompleteResult> => {
const formData = new FormData()
if (data.proof_url) formData.append('proof_url', data.proof_url)
if (data.comment) formData.append('comment', data.comment)
if (data.proof_file) formData.append('proof_file', data.proof_file)
const response = await client.post<CompleteResult>(
`/event-assignments/${assignmentId}/complete`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
return response.data
},
}

View File

@@ -1,5 +1,5 @@
import client from './client'
import type { Game, Challenge } from '@/types'
import type { Game, GameStatus, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types'
export interface CreateGameData {
title: string
@@ -20,8 +20,14 @@ export interface CreateChallengeData {
}
export const gamesApi = {
list: async (marathonId: number): Promise<Game[]> => {
const response = await client.get<Game[]>(`/marathons/${marathonId}/games`)
list: async (marathonId: number, status?: GameStatus): Promise<Game[]> => {
const params = status ? { status } : {}
const response = await client.get<Game[]>(`/marathons/${marathonId}/games`, { params })
return response.data
},
listPending: async (marathonId: number): Promise<Game[]> => {
const response = await client.get<Game[]>(`/marathons/${marathonId}/games/pending`)
return response.data
},
@@ -39,6 +45,16 @@ export const gamesApi = {
await client.delete(`/games/${id}`)
},
approve: async (id: number): Promise<Game> => {
const response = await client.post<Game>(`/games/${id}/approve`)
return response.data
},
reject: async (id: number): Promise<Game> => {
const response = await client.post<Game>(`/games/${id}/reject`)
return response.data
},
uploadCover: async (id: number, file: File): Promise<Game> => {
const formData = new FormData()
formData.append('file', file)
@@ -63,8 +79,14 @@ export const gamesApi = {
await client.delete(`/challenges/${id}`)
},
generateChallenges: async (marathonId: number): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/generate-challenges`)
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)
return response.data
},
saveChallenges: async (marathonId: number, challenges: ChallengePreview[]): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
return response.data
},
}

View File

@@ -3,3 +3,9 @@ export { marathonsApi } from './marathons'
export { gamesApi } from './games'
export { wheelApi } from './wheel'
export { feedApi } from './feed'
export { adminApi } from './admin'
export { eventsApi } from './events'
export { challengesApi } from './challenges'
export { assignmentsApi } from './assignments'
export { usersApi } from './users'
export { telegramApi } from './telegram'

View File

@@ -1,15 +1,13 @@
import client from './client'
import type { Marathon, MarathonListItem, LeaderboardEntry, ParticipantInfo, User } from '@/types'
import type { Marathon, MarathonListItem, MarathonPublicInfo, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode } from '@/types'
export interface CreateMarathonData {
title: string
description?: string
start_date: string
duration_days?: number
}
export interface ParticipantWithUser extends ParticipantInfo {
user: User
is_public?: boolean
game_proposal_mode?: GameProposalMode
}
export const marathonsApi = {
@@ -23,6 +21,12 @@ export const marathonsApi = {
return response.data
},
getByCode: async (inviteCode: string): Promise<MarathonPublicInfo> => {
// Public endpoint - no auth required
const response = await client.get<MarathonPublicInfo>(`/marathons/by-code/${inviteCode}`)
return response.data
},
create: async (data: CreateMarathonData): Promise<Marathon> => {
const response = await client.post<Marathon>('/marathons', data)
return response.data
@@ -52,11 +56,24 @@ export const marathonsApi = {
return response.data
},
joinPublic: async (id: number): Promise<Marathon> => {
const response = await client.post<Marathon>(`/marathons/${id}/join`)
return response.data
},
getParticipants: async (id: number): Promise<ParticipantWithUser[]> => {
const response = await client.get<ParticipantWithUser[]>(`/marathons/${id}/participants`)
return response.data
},
setParticipantRole: async (marathonId: number, userId: number, role: ParticipantRole): Promise<ParticipantWithUser> => {
const response = await client.patch<ParticipantWithUser>(
`/marathons/${marathonId}/participants/${userId}/role`,
{ role }
)
return response.data
},
getLeaderboard: async (id: number): Promise<LeaderboardEntry[]> => {
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
return response.data

View File

@@ -0,0 +1,22 @@
import client from './client'
export interface TelegramLinkToken {
token: string
bot_url: string
}
export interface TelegramStatus {
telegram_id: number | null
telegram_username: string | null
}
export const telegramApi = {
generateLinkToken: async (): Promise<TelegramLinkToken> => {
const response = await client.post<TelegramLinkToken>('/telegram/generate-link-token')
return response.data
},
unlinkTelegram: async (): Promise<void> => {
await client.post('/users/me/telegram/unlink')
},
}

51
frontend/src/api/users.ts Normal file
View File

@@ -0,0 +1,51 @@
import client from './client'
import type { User, UserProfilePublic, UserStats, PasswordChangeData } from '@/types'
export interface UpdateNicknameData {
nickname: string
}
export const usersApi = {
// Получить публичный профиль пользователя со статистикой
getProfile: async (userId: number): Promise<UserProfilePublic> => {
const response = await client.get<UserProfilePublic>(`/users/${userId}/profile`)
return response.data
},
// Получить свою статистику
getMyStats: async (): Promise<UserStats> => {
const response = await client.get<UserStats>('/users/me/stats')
return response.data
},
// Обновить никнейм
updateNickname: async (data: UpdateNicknameData): Promise<User> => {
const response = await client.patch<User>('/users/me', data)
return response.data
},
// Загрузить аватар
uploadAvatar: async (file: File): Promise<User> => {
const formData = new FormData()
formData.append('file', file)
const response = await client.post<User>('/users/me/avatar', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
// Сменить пароль
changePassword: async (data: PasswordChangeData): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>('/users/me/password', data)
return response.data
},
// Получить аватар пользователя как blob URL
getAvatarUrl: async (userId: number, bustCache = false): Promise<string> => {
const cacheBuster = bustCache ? `?t=${Date.now()}` : ''
const response = await client.get(`/users/${userId}/avatar${cacheBuster}`, {
responseType: 'blob',
})
return URL.createObjectURL(response.data)
},
}

View File

@@ -0,0 +1,344 @@
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types'
import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
import { UserAvatar } from '@/components/ui'
import {
formatRelativeTime,
getActivityIcon,
getActivityColor,
isEventActivity,
formatActivityMessage,
} from '@/utils/activity'
interface ActivityFeedProps {
marathonId: number
className?: string
}
export interface ActivityFeedRef {
refresh: () => void
}
const ITEMS_PER_PAGE = 20
const POLL_INTERVAL = 10000 // 10 seconds
// Важные типы активности для отображения
const IMPORTANT_ACTIVITY_TYPES: ActivityType[] = [
'spin',
'complete',
'drop',
'start_marathon',
'finish_marathon',
'event_start',
'event_end',
'swap',
'rematch',
]
export const ActivityFeed = forwardRef<ActivityFeedRef, ActivityFeedProps>(
({ marathonId, className = '' }, ref) => {
const [activities, setActivities] = useState<Activity[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [total, setTotal] = useState(0)
const lastFetchRef = useRef<number>(0)
const loadActivities = useCallback(async (offset = 0, append = false) => {
try {
const response = await feedApi.get(marathonId, ITEMS_PER_PAGE * 2, offset)
// Фильтруем только важные события
const filtered = response.items.filter(item =>
IMPORTANT_ACTIVITY_TYPES.includes(item.type)
)
if (append) {
setActivities(prev => [...prev, ...filtered])
} else {
setActivities(filtered)
}
setHasMore(response.has_more)
setTotal(filtered.length)
lastFetchRef.current = Date.now()
} catch (error) {
console.error('Failed to load activity feed:', error)
}
}, [marathonId])
// Expose refresh method
useImperativeHandle(ref, () => ({
refresh: () => loadActivities()
}), [loadActivities])
// Initial load
useEffect(() => {
setIsLoading(true)
loadActivities().finally(() => setIsLoading(false))
}, [loadActivities])
// Polling for new activities
useEffect(() => {
const interval = setInterval(() => {
if (document.visibilityState === 'visible') {
loadActivities()
}
}, POLL_INTERVAL)
return () => clearInterval(interval)
}, [loadActivities])
const handleLoadMore = async () => {
setIsLoadingMore(true)
await loadActivities(activities.length, true)
setIsLoadingMore(false)
}
if (isLoading) {
return (
<div className={`glass rounded-2xl border border-dark-600 flex flex-col ${className}`}>
<div className="flex items-center gap-3 px-5 py-4 border-b border-dark-600">
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
<ActivityIcon className="w-4 h-4 text-neon-400" />
</div>
<h3 className="font-semibold text-white">Активность</h3>
</div>
<div className="flex-1 flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-neon-500" />
</div>
</div>
)
}
return (
<div className={`glass rounded-2xl border border-dark-600 flex flex-col overflow-hidden ${className}`}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-600 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
<Zap className="w-4 h-4 text-neon-400" />
</div>
<div>
<h3 className="font-semibold text-white">Активность</h3>
{total > 0 && (
<p className="text-xs text-gray-500">{total} событий</p>
)}
</div>
</div>
<div className="w-2 h-2 rounded-full bg-neon-500 animate-pulse" />
</div>
{/* Activity list */}
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
{activities.length === 0 ? (
<div className="px-5 py-12 text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
<Sparkles className="w-6 h-6 text-gray-600" />
</div>
<p className="text-gray-400 text-sm">Пока нет активности</p>
</div>
) : (
<div className="divide-y divide-dark-600/50">
{activities.map((activity, index) => (
<ActivityItem
key={activity.id}
activity={activity}
isNew={index === 0}
/>
))}
</div>
)}
{/* Load more button */}
{hasMore && (
<div className="p-4 border-t border-dark-600/50">
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className="w-full py-2.5 text-sm text-gray-400 hover:text-neon-400 transition-colors flex items-center justify-center gap-2 rounded-lg hover:bg-neon-500/5"
>
{isLoadingMore ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<ChevronDown className="w-4 h-4" />
Загрузить ещё
</>
)}
</button>
</div>
)}
</div>
</div>
)
}
)
ActivityFeed.displayName = 'ActivityFeed'
interface ActivityItemProps {
activity: Activity
isNew?: boolean
}
function ActivityItem({ activity, isNew }: ActivityItemProps) {
const navigate = useNavigate()
const Icon = getActivityIcon(activity.type)
const iconColor = getActivityColor(activity.type)
const isEvent = isEventActivity(activity.type)
const { title, details, extra } = formatActivityMessage(activity)
// Get assignment_id and dispute status for complete activities
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null
const assignmentId = activity.type === 'complete' && activityData?.assignment_id
? activityData.assignment_id
: null
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
? activityData.dispute_status
: null
// Determine accent color based on activity type
const getAccentConfig = () => {
switch (activity.type) {
case 'spin':
return { border: 'border-l-accent-500', bg: 'bg-accent-500/5' }
case 'complete':
return { border: 'border-l-green-500', bg: 'bg-green-500/5' }
case 'drop':
return { border: 'border-l-red-500', bg: 'bg-red-500/5' }
case 'start_marathon':
case 'event_start':
return { border: 'border-l-yellow-500', bg: 'bg-yellow-500/5' }
case 'finish_marathon':
case 'event_end':
return { border: 'border-l-gray-500', bg: 'bg-gray-500/5' }
case 'swap':
case 'rematch':
return { border: 'border-l-neon-500', bg: 'bg-neon-500/5' }
default:
return { border: 'border-l-dark-600', bg: '' }
}
}
const accent = getAccentConfig()
if (isEvent) {
return (
<div className={`
px-5 py-4 border-l-2 transition-colors
${accent.border} ${accent.bg}
hover:bg-dark-700/30
`}>
<div className="flex items-center gap-2 mb-1.5">
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${
activity.type === 'event_start' ? 'bg-yellow-500/20' : 'bg-gray-500/20'
}`}>
<Icon className={`w-3.5 h-3.5 ${iconColor}`} />
</div>
<span className={`text-sm font-semibold ${
activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'
}`}>
{title}
</span>
</div>
{details && (
<div className={`text-sm ml-8 ${
activity.type === 'event_start' ? 'text-yellow-200/80' : 'text-gray-500'
}`}>
{details}
</div>
)}
<div className="text-xs text-gray-600 mt-2 ml-8">
{formatRelativeTime(activity.created_at)}
</div>
</div>
)
}
return (
<div className={`
px-5 py-4 border-l-2 transition-all duration-200
${accent.border} ${isNew ? accent.bg : ''}
hover:bg-dark-700/30 group
`}>
<div className="flex items-start gap-3">
{/* Avatar */}
<Link to={`/users/${activity.user.id}`} className="flex-shrink-0 relative" onClick={(e) => e.stopPropagation()}>
<UserAvatar
userId={activity.user.id}
hasAvatar={!!activity.user.avatar_url}
nickname={activity.user.nickname}
size="sm"
/>
{/* Activity type badge */}
<div className={`
absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center
border-2 border-dark-800
${activity.type === 'complete' ? 'bg-green-500' :
activity.type === 'drop' ? 'bg-red-500' :
activity.type === 'spin' ? 'bg-accent-500' :
'bg-neon-500'}
`}>
<Icon className="w-2.5 h-2.5 text-white" />
</div>
</Link>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link
to={`/users/${activity.user.id}`}
className="text-sm font-semibold text-white hover:text-neon-400 transition-colors"
onClick={(e) => e.stopPropagation()}
>
{activity.user.nickname}
</Link>
<span className="text-xs text-gray-600">
{formatRelativeTime(activity.created_at)}
</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-sm text-gray-300">{title}</span>
</div>
{details && (
<div className="text-sm text-gray-500 mt-1">
{details}
</div>
)}
{extra && (
<div className="text-xs text-gray-600 mt-1">
{extra}
</div>
)}
{/* Details button and dispute indicator for complete activities */}
{assignmentId && (
<div className="flex items-center gap-3 mt-2">
<button
onClick={() => navigate(`/assignments/${assignmentId}`)}
className="text-xs text-neon-400 hover:text-neon-300 flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-neon-500/10 transition-colors"
>
<ExternalLink className="w-3 h-3" />
Детали
</button>
{disputeStatus === 'open' && (
<span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
<AlertTriangle className="w-3 h-3" />
Оспаривается
</span>
)}
{disputeStatus === 'valid' && (
<span className="text-xs text-red-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-red-500/10">
<AlertTriangle className="w-3 h-3" />
Отклонено
</span>
)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,163 @@
import { useState, useEffect } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock, Sparkles } from 'lucide-react'
import type { ActiveEvent, EventType } from '@/types'
import { EVENT_INFO } from '@/types'
interface EventBannerProps {
activeEvent: ActiveEvent
onRefresh?: () => void
}
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
golden_hour: <Zap className="w-5 h-5" />,
common_enemy: <Users className="w-5 h-5" />,
double_risk: <Shield className="w-5 h-5" />,
jackpot: <Gift className="w-5 h-5" />,
swap: <ArrowLeftRight className="w-5 h-5" />,
game_choice: <Gamepad2 className="w-5 h-5" />,
}
const EVENT_COLORS: Record<EventType, {
gradient: string
border: string
text: string
glow: string
iconBg: string
}> = {
golden_hour: {
gradient: 'from-yellow-500/20 via-yellow-500/10 to-transparent',
border: 'border-yellow-500/50',
text: 'text-yellow-400',
glow: 'shadow-[0_0_30px_rgba(234,179,8,0.3)]',
iconBg: 'bg-yellow-500/20',
},
common_enemy: {
gradient: 'from-red-500/20 via-red-500/10 to-transparent',
border: 'border-red-500/50',
text: 'text-red-400',
glow: 'shadow-[0_0_30px_rgba(239,68,68,0.3)]',
iconBg: 'bg-red-500/20',
},
double_risk: {
gradient: 'from-purple-500/20 via-purple-500/10 to-transparent',
border: 'border-purple-500/50',
text: 'text-purple-400',
glow: 'shadow-[0_0_20px_rgba(139,92,246,0.25)]',
iconBg: 'bg-purple-500/20',
},
jackpot: {
gradient: 'from-green-500/20 via-green-500/10 to-transparent',
border: 'border-green-500/50',
text: 'text-green-400',
glow: 'shadow-[0_0_30px_rgba(34,197,94,0.3)]',
iconBg: 'bg-green-500/20',
},
swap: {
gradient: 'from-neon-500/20 via-neon-500/10 to-transparent',
border: 'border-neon-500/50',
text: 'text-neon-400',
glow: 'shadow-[0_0_20px_rgba(34,211,238,0.25)]',
iconBg: 'bg-neon-500/20',
},
game_choice: {
gradient: 'from-orange-500/20 via-orange-500/10 to-transparent',
border: 'border-orange-500/50',
text: 'text-orange-400',
glow: 'shadow-[0_0_30px_rgba(249,115,22,0.3)]',
iconBg: 'bg-orange-500/20',
},
}
function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
export function EventBanner({ activeEvent, onRefresh }: EventBannerProps) {
const [timeRemaining, setTimeRemaining] = useState(activeEvent.time_remaining_seconds)
useEffect(() => {
setTimeRemaining(activeEvent.time_remaining_seconds)
}, [activeEvent.time_remaining_seconds])
useEffect(() => {
if (timeRemaining === null || timeRemaining <= 0) return
const timer = setInterval(() => {
setTimeRemaining((prev) => {
if (prev === null || prev <= 0) {
clearInterval(timer)
onRefresh?.()
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [timeRemaining, onRefresh])
if (!activeEvent.event) {
return null
}
const event = activeEvent.event
const info = EVENT_INFO[event.type]
const icon = EVENT_ICONS[event.type]
const colors = EVENT_COLORS[event.type]
return (
<div
className={`
relative overflow-hidden rounded-2xl border p-5
glass ${colors.border} ${colors.glow}
animate-pulse-slow
`}
>
{/* Background gradient */}
<div className={`absolute inset-0 bg-gradient-to-r ${colors.gradient}`} />
{/* Animated shimmer effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full animate-shimmer" />
{/* Grid pattern */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:20px_20px]" />
<div className="relative flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl ${colors.iconBg} ${colors.text}`}>
{icon}
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className={`font-bold text-lg ${colors.text}`}>{info.name}</h3>
<Sparkles className={`w-4 h-4 ${colors.text} animate-pulse`} />
</div>
<p className="text-sm text-gray-400">{info.description}</p>
</div>
</div>
<div className="flex items-center gap-4">
{activeEvent.effects.points_multiplier !== 1.0 && (
<div className={`px-4 py-2 rounded-xl ${colors.iconBg} font-bold ${colors.text} border ${colors.border}`}>
x{activeEvent.effects.points_multiplier}
</div>
)}
{timeRemaining !== null && timeRemaining > 0 && (
<div className={`flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700/50 border border-dark-600 font-mono font-bold ${colors.text}`}>
<Clock className="w-4 h-4" />
{formatTime(timeRemaining)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,248 @@
import { useState } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square, Sparkles } from 'lucide-react'
import { NeonButton } from '@/components/ui'
import { eventsApi } from '@/api'
import type { ActiveEvent, EventType, Challenge } from '@/types'
import { EVENT_INFO } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
interface EventControlProps {
marathonId: number
activeEvent: ActiveEvent
challenges?: Challenge[]
onEventChange: () => void
}
const EVENT_TYPES: EventType[] = [
'golden_hour',
'double_risk',
'jackpot',
'swap',
'game_choice',
'common_enemy',
]
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
golden_hour: <Zap className="w-5 h-5" />,
common_enemy: <Users className="w-5 h-5" />,
double_risk: <Shield className="w-5 h-5" />,
jackpot: <Gift className="w-5 h-5" />,
swap: <ArrowLeftRight className="w-5 h-5" />,
game_choice: <Gamepad2 className="w-5 h-5" />,
}
const EVENT_COLORS: Record<EventType, { selected: string; icon: string }> = {
golden_hour: { selected: 'border-yellow-500/50 bg-yellow-500/10', icon: 'text-yellow-400' },
common_enemy: { selected: 'border-red-500/50 bg-red-500/10', icon: 'text-red-400' },
double_risk: { selected: 'border-purple-500/50 bg-purple-500/10', icon: 'text-purple-400' },
jackpot: { selected: 'border-green-500/50 bg-green-500/10', icon: 'text-green-400' },
swap: { selected: 'border-neon-500/50 bg-neon-500/10', icon: 'text-neon-400' },
game_choice: { selected: 'border-orange-500/50 bg-orange-500/10', icon: 'text-orange-400' },
}
// Default durations for events (in minutes)
const DEFAULT_DURATIONS: Record<EventType, number | null> = {
golden_hour: 45,
common_enemy: null, // Until all complete
double_risk: 120,
jackpot: null, // 1 spin
swap: 60,
game_choice: 120,
}
export function EventControl({
marathonId,
activeEvent,
challenges,
onEventChange,
}: EventControlProps) {
const toast = useToast()
const confirm = useConfirm()
const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
const [durationMinutes, setDurationMinutes] = useState<number | ''>(45)
const [isStarting, setIsStarting] = useState(false)
const [isStopping, setIsStopping] = useState(false)
// Update duration when event type changes
const handleTypeChange = (type: EventType) => {
setSelectedType(type)
const defaultDuration = DEFAULT_DURATIONS[type]
setDurationMinutes(defaultDuration ?? '')
}
const handleStart = async () => {
if (selectedType === 'common_enemy' && !selectedChallengeId) {
toast.warning('Выберите челлендж для события "Общий враг"')
return
}
setIsStarting(true)
try {
await eventsApi.start(marathonId, {
type: selectedType,
duration_minutes: durationMinutes || undefined,
challenge_id: selectedType === 'common_enemy' ? selectedChallengeId ?? undefined : undefined,
})
onEventChange()
} catch (error) {
console.error('Failed to start event:', error)
toast.error('Не удалось запустить событие')
} finally {
setIsStarting(false)
}
}
const handleStop = async () => {
const confirmed = await confirm({
title: 'Остановить событие?',
message: 'Событие будет завершено досрочно.',
confirmText: 'Остановить',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsStopping(true)
try {
await eventsApi.stop(marathonId)
onEventChange()
} catch (error) {
console.error('Failed to stop event:', error)
} finally {
setIsStopping(false)
}
}
if (activeEvent.event) {
const colors = EVENT_COLORS[activeEvent.event.type]
return (
<div className={`glass rounded-xl p-4 border ${colors.selected}`}>
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg bg-white/10 ${colors.icon}`}>
{EVENT_ICONS[activeEvent.event.type]}
</div>
<div>
<span className="font-semibold text-white">
{EVENT_INFO[activeEvent.event.type].name}
</span>
<span className="text-gray-400 text-sm ml-2">активно</span>
</div>
</div>
<NeonButton
variant="outline"
size="sm"
onClick={handleStop}
isLoading={isStopping}
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
icon={<Square className="w-4 h-4" />}
>
Остановить
</NeonButton>
</div>
</div>
)
}
return (
<div className="glass rounded-xl p-5 space-y-5">
<div className="flex items-center gap-3">
<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-white">Запустить событие</h3>
<p className="text-sm text-gray-400">Выберите тип и настройте параметры</p>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{EVENT_TYPES.map((type) => {
const colors = EVENT_COLORS[type]
const isSelected = selectedType === type
return (
<button
key={type}
onClick={() => handleTypeChange(type)}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
${isSelected
? `${colors.selected} shadow-lg`
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`flex items-center gap-2 mb-2 ${isSelected ? colors.icon : 'text-gray-400'}`}>
{EVENT_ICONS[type]}
<span className={`font-semibold text-sm ${isSelected ? 'text-white' : 'text-gray-300'}`}>
{EVENT_INFO[type].name}
</span>
</div>
<p className="text-xs text-gray-500 line-clamp-2">
{EVENT_INFO[type].description}
</p>
{isSelected && (
<div className="absolute top-2 right-2">
<div className={`w-2 h-2 rounded-full ${colors.icon.replace('text-', 'bg-')} animate-pulse`} />
</div>
)}
</button>
)
})}
</div>
{/* Duration setting */}
{DEFAULT_DURATIONS[selectedType] !== null && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Длительность (минуты)
</label>
<input
type="number"
value={durationMinutes}
onChange={(e) => setDurationMinutes(e.target.value ? parseInt(e.target.value) : '')}
min={1}
max={480}
placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`}
className="input w-full"
/>
<p className="text-xs text-gray-500 mt-1.5">
Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин)
</p>
</div>
)}
{selectedType === 'common_enemy' && challenges && challenges.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Выберите челлендж для всех
</label>
<select
value={selectedChallengeId || ''}
onChange={(e) => setSelectedChallengeId(e.target.value ? Number(e.target.value) : null)}
className="input w-full"
>
<option value=""> Выберите челлендж </option>
{challenges.map((c) => (
<option key={c.id} value={c.id}>
{c.game.title}: {c.title} ({c.points} очков)
</option>
))}
</select>
</div>
)}
<NeonButton
onClick={handleStart}
isLoading={isStarting}
disabled={selectedType === 'common_enemy' && !selectedChallengeId}
className="w-full"
icon={<Play className="w-4 h-4" />}
>
Запустить {EVENT_INFO[selectedType].name}
</NeonButton>
</div>
)
}

View File

@@ -0,0 +1,334 @@
import { useState, useCallback, useMemo } from 'react'
import type { Game } from '@/types'
import { Gamepad2, Loader2 } from 'lucide-react'
interface SpinWheelProps {
games: Game[]
onSpin: () => Promise<Game | null>
onSpinComplete: (game: Game) => void
disabled?: boolean
}
const SPIN_DURATION = 5000 // ms
const EXTRA_ROTATIONS = 5
// Цветовая палитра секторов
const SECTOR_COLORS = [
{ bg: '#0d9488', border: '#14b8a6' }, // teal
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet
{ bg: '#0891b2', border: '#06b6d4' }, // cyan
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia
{ bg: '#059669', border: '#10b981' }, // emerald
{ bg: '#7c2d12', border: '#ea580c' }, // orange
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue
{ bg: '#be123c', border: '#e11d48' }, // rose
]
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
const [isSpinning, setIsSpinning] = useState(false)
const [rotation, setRotation] = useState(0)
// Размеры колеса
const wheelSize = 400
const centerX = wheelSize / 2
const centerY = wheelSize / 2
const radius = wheelSize / 2 - 10
// Рассчитываем углы секторов
const sectorAngle = games.length > 0 ? 360 / games.length : 360
// Создаём path для сектора
const createSectorPath = useCallback((index: number, total: number) => {
const angle = 360 / total
const startAngle = index * angle - 90 // Начинаем сверху
const endAngle = startAngle + angle
const startRad = (startAngle * Math.PI) / 180
const endRad = (endAngle * Math.PI) / 180
const x1 = centerX + radius * Math.cos(startRad)
const y1 = centerY + radius * Math.sin(startRad)
const x2 = centerX + radius * Math.cos(endRad)
const y2 = centerY + radius * Math.sin(endRad)
const largeArcFlag = angle > 180 ? 1 : 0
return `M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`
}, [centerX, centerY, radius])
// Позиция текста в секторе
const getTextPosition = useCallback((index: number, total: number) => {
const angle = 360 / total
const midAngle = index * angle + angle / 2 - 90
const midRad = (midAngle * Math.PI) / 180
const textRadius = radius * 0.65
return {
x: centerX + textRadius * Math.cos(midRad),
y: centerY + textRadius * Math.sin(midRad),
rotation: midAngle + 90, // Текст читается от центра к краю
}
}, [centerX, centerY, radius])
const handleSpin = useCallback(async () => {
if (isSpinning || disabled || games.length === 0) return
setIsSpinning(true)
// Получаем результат от API
const resultGame = await onSpin()
if (!resultGame) {
setIsSpinning(false)
return
}
// Находим индекс выигравшей игры
const targetIndex = games.findIndex(g => g.id === resultGame.id)
if (targetIndex === -1) {
setIsSpinning(false)
onSpinComplete(resultGame)
return
}
// Рассчитываем угол для остановки
// Указатель находится сверху (на 0°/360°)
// Нам нужно чтобы нужный сектор оказался под указателем
const targetSectorMidAngle = targetIndex * sectorAngle + sectorAngle / 2
// Полные обороты + угол до центра сектора
// Колесо крутится по часовой стрелке, указатель сверху
// Чтобы сектор оказался сверху, нужно повернуть на (360 - targetSectorMidAngle)
const baseRotation = rotation % 360
const fullRotations = EXTRA_ROTATIONS * 360
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
setRotation(rotation + finalAngle)
// Ждём окончания анимации
setTimeout(() => {
setIsSpinning(false)
onSpinComplete(resultGame)
}, SPIN_DURATION)
}, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete])
// Сокращаем название игры для отображения
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text
return text.slice(0, maxLength - 2) + '...'
}
// Мемоизируем секторы для производительности
const sectors = useMemo(() => {
return games.map((game, index) => {
const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
const path = createSectorPath(index, games.length)
const textPos = getTextPosition(index, games.length)
const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18
return { game, color, path, textPos, maxTextLength }
})
}, [games, createSectorPath, getTextPosition])
if (games.length === 0) {
return (
<div className="glass rounded-2xl p-12 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-gray-600" />
</div>
<p className="text-gray-400">Нет доступных игр для прокрутки</p>
</div>
)
}
return (
<div className="flex flex-col items-center gap-6">
{/* Контейнер колеса */}
<div className="relative">
{/* Внешнее свечение */}
<div className={`
absolute -inset-4 rounded-full transition-all duration-500
${isSpinning
? 'bg-neon-500/30 blur-2xl animate-pulse'
: 'bg-neon-500/10 blur-xl'
}
`} />
{/* Указатель (стрелка сверху) */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-2 z-20">
<div className={`
relative transition-all duration-300
${isSpinning ? 'scale-110' : ''}
`}>
{/* Свечение указателя */}
<div className={`
absolute inset-0 blur-sm transition-opacity duration-300
${isSpinning ? 'opacity-100' : 'opacity-50'}
`}>
<svg width="40" height="50" viewBox="0 0 40 50">
<path
d="M20 50 L5 15 L20 0 L35 15 Z"
fill="#22d3ee"
/>
</svg>
</div>
{/* Указатель */}
<svg width="40" height="50" viewBox="0 0 40 50" className="relative">
<defs>
<linearGradient id="pointerGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#0891b2" />
</linearGradient>
</defs>
<path
d="M20 50 L5 15 L20 0 L35 15 Z"
fill="url(#pointerGradient)"
stroke="#67e8f9"
strokeWidth="2"
/>
</svg>
</div>
</div>
{/* Колесо */}
<div
className="relative"
style={{ width: wheelSize, height: wheelSize }}
>
{/* Внешний ободок */}
<div className={`
absolute inset-0 rounded-full
border-4 transition-all duration-300
${isSpinning
? 'border-neon-400 shadow-[0_0_30px_rgba(34,211,238,0.5),inset_0_0_30px_rgba(34,211,238,0.1)]'
: 'border-neon-500/50 shadow-[0_0_15px_rgba(34,211,238,0.2)]'
}
`} />
{/* SVG колесо */}
<svg
width={wheelSize}
height={wheelSize}
className="relative z-10 transition-transform"
style={{
transform: `rotate(${rotation}deg)`,
transitionProperty: isSpinning ? 'transform' : 'none',
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
transitionTimingFunction: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)',
}}
>
<defs>
{/* Тени для секторов */}
<filter id="sectorShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="0" stdDeviation="2" floodColor="#000" floodOpacity="0.3" />
</filter>
</defs>
{/* Секторы */}
{sectors.map(({ game, color, path, textPos, maxTextLength }, index) => (
<g key={game.id}>
{/* Сектор */}
<path
d={path}
fill={color.bg}
stroke={color.border}
strokeWidth="2"
filter="url(#sectorShadow)"
/>
{/* Текст названия игры */}
<text
x={textPos.x}
y={textPos.y}
transform={`rotate(${textPos.rotation}, ${textPos.x}, ${textPos.y})`}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={games.length > 10 ? "10" : games.length > 6 ? "11" : "13"}
fontWeight="bold"
style={{
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
pointerEvents: 'none',
}}
>
{truncateText(game.title, maxTextLength)}
</text>
{/* Разделительная линия */}
<line
x1={centerX}
y1={centerY}
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
stroke="rgba(255,255,255,0.3)"
strokeWidth="1"
/>
</g>
))}
{/* Центральный круг */}
<circle
cx={centerX}
cy={centerY}
r="50"
fill="url(#centerGradient)"
stroke="#22d3ee"
strokeWidth="3"
/>
<defs>
<radialGradient id="centerGradient" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#1e293b" />
<stop offset="100%" stopColor="#0f172a" />
</radialGradient>
</defs>
</svg>
{/* Кнопка КРУТИТЬ в центре */}
<button
onClick={handleSpin}
disabled={isSpinning || disabled}
className={`
absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
w-24 h-24 rounded-full z-20
flex flex-col items-center justify-center gap-1
font-bold text-sm uppercase tracking-wider
transition-all duration-300
disabled:cursor-not-allowed
${isSpinning
? 'bg-dark-800 text-neon-400 shadow-[0_0_20px_rgba(34,211,238,0.4)]'
: 'bg-gradient-to-br from-neon-500 to-cyan-600 text-white hover:shadow-[0_0_30px_rgba(34,211,238,0.6)] hover:scale-105 active:scale-95'
}
${disabled && !isSpinning ? 'opacity-50' : ''}
`}
>
{isSpinning ? (
<Loader2 className="w-8 h-8 animate-spin" />
) : (
<>
<span className="text-xs">КРУТИТЬ</span>
</>
)}
</button>
</div>
{/* Декоративные элементы при вращении */}
{isSpinning && (
<>
<div className="absolute inset-0 rounded-full border-2 border-neon-400/30 animate-ping" />
<div
className="absolute inset-0 rounded-full border border-accent-400/20"
style={{ animation: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite 0.5s' }}
/>
</>
)}
</div>
{/* Подсказка */}
<p className={`
text-sm transition-all duration-300
${isSpinning ? 'text-neon-400 animate-pulse' : 'text-gray-500'}
`}>
{isSpinning ? 'Колесо вращается...' : 'Нажмите на колесо, чтобы крутить!'}
</p>
</div>
)
}

View File

@@ -0,0 +1,325 @@
import { useState, useEffect, useRef } from 'react'
import { MessageCircle, ExternalLink, X, Loader2, RefreshCw, CheckCircle, User, Link2, Link2Off } from 'lucide-react'
import { telegramApi } from '@/api/telegram'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/auth'
export function TelegramLink() {
const { user, updateUser } = useAuthStore()
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [botUrl, setBotUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [isPolling, setIsPolling] = useState(false)
const [linkSuccess, setLinkSuccess] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const isLinked = !!user?.telegram_id
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
}, [])
const startPolling = () => {
setIsPolling(true)
let attempts = 0
const maxAttempts = 60 // 5 minutes (5 sec intervals)
pollingRef.current = setInterval(async () => {
attempts++
try {
const userData = await authApi.me()
if (userData.telegram_id) {
// Success! User linked their account
updateUser({
telegram_id: userData.telegram_id,
telegram_username: userData.telegram_username,
telegram_first_name: userData.telegram_first_name,
telegram_last_name: userData.telegram_last_name,
telegram_avatar_url: userData.telegram_avatar_url
})
setLinkSuccess(true)
setIsPolling(false)
setBotUrl(null)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
} catch {
// Ignore errors, continue polling
}
if (attempts >= maxAttempts) {
setIsPolling(false)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
}, 5000)
}
const stopPolling = () => {
setIsPolling(false)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
const handleGenerateLink = async () => {
setLoading(true)
setError(null)
setLinkSuccess(false)
try {
const { bot_url } = await telegramApi.generateLinkToken()
setBotUrl(bot_url)
} catch {
setError('Не удалось сгенерировать ссылку')
} finally {
setLoading(false)
}
}
const handleUnlink = async () => {
setLoading(true)
setError(null)
try {
await telegramApi.unlinkTelegram()
updateUser({
telegram_id: null,
telegram_username: null,
telegram_first_name: null,
telegram_last_name: null,
telegram_avatar_url: null
})
setIsOpen(false)
} catch {
setError('Не удалось отвязать аккаунт')
} finally {
setLoading(false)
}
}
const handleOpenBot = () => {
if (botUrl) {
window.open(botUrl, '_blank')
startPolling()
}
}
const handleClose = () => {
setIsOpen(false)
setBotUrl(null)
setError(null)
setLinkSuccess(false)
stopPolling()
}
return (
<>
<button
onClick={() => setIsOpen(true)}
className={`p-2 rounded-lg transition-colors ${
isLinked
? 'text-blue-400 hover:text-blue-300 hover:bg-dark-700'
: 'text-gray-400 hover:text-white hover:bg-dark-700'
}`}
title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'}
>
<MessageCircle className="w-5 h-5" />
</button>
{isOpen && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-xl max-w-md w-full p-6 relative border border-dark-600">
<button
onClick={handleClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-blue-500/10 rounded-full flex items-center justify-center border border-blue-500/30">
<MessageCircle className="w-6 h-6 text-blue-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Telegram</h2>
<p className="text-sm text-gray-400">
{isLinked ? 'Аккаунт привязан' : 'Привяжи аккаунт'}
</p>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{isLinked || linkSuccess ? (
<div className="space-y-4">
{linkSuccess && (
<div className="p-4 bg-green-500/20 border border-green-500/50 rounded-lg flex items-center gap-3">
<CheckCircle className="w-6 h-6 text-green-400 flex-shrink-0" />
<p className="text-green-400 font-medium">Аккаунт успешно привязан!</p>
</div>
)}
{/* User Profile Card */}
<div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center gap-4">
{/* Avatar - Telegram avatar */}
<div className="relative">
{user?.telegram_avatar_url ? (
<img
src={user.telegram_avatar_url}
alt={user.nickname}
className="w-12 h-12 rounded-full object-cover border-2 border-blue-500/50"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-accent-500 flex items-center justify-center border-2 border-blue-500/50">
<User className="w-6 h-6 text-white" />
</div>
)}
{/* Link indicator */}
<div className="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center border-2 border-dark-800">
<Link2 className="w-2.5 h-2.5 text-white" />
</div>
</div>
{/* User Info */}
<div className="flex-1 min-w-0">
<p className="text-lg font-bold text-white truncate">
{[user?.telegram_first_name, user?.telegram_last_name].filter(Boolean).join(' ') || user?.nickname}
</p>
{user?.telegram_username && (
<p className="text-blue-400 font-medium truncate">@{user.telegram_username}</p>
)}
</div>
</div>
</div>
{/* Notifications Info */}
<div className="p-4 bg-dark-700/30 rounded-lg border border-dark-600/50">
<p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p>
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="text-yellow-400">🌟</span>
<span>События (Golden Hour, Jackpot)</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="text-green-400">🚀</span>
<span>Старт и финиш марафонов</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="text-red-400"></span>
<span>Споры по заданиям</span>
</div>
</div>
</div>
<button
onClick={handleUnlink}
disabled={loading}
className="w-full py-3 px-4 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2 border border-red-500/30"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<Link2Off className="w-4 h-4" />
Отвязать аккаунт
</>
)}
</button>
</div>
) : botUrl ? (
<div className="space-y-4">
{isPolling ? (
<>
<div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg">
<div className="flex items-center gap-3 mb-2">
<RefreshCw className="w-5 h-5 text-blue-400 animate-spin" />
<p className="text-blue-400 font-medium">Ожидание привязки...</p>
</div>
<p className="text-sm text-gray-400">
Открой бота в Telegram и нажми Start. Статус обновится автоматически.
</p>
</div>
<button
onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
>
<ExternalLink className="w-5 h-5" />
Открыть Telegram снова
</button>
</>
) : (
<>
<p className="text-gray-300">
Нажми кнопку ниже, чтобы открыть бота и завершить привязку:
</p>
<button
onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
>
<ExternalLink className="w-5 h-5" />
Открыть Telegram
</button>
<p className="text-sm text-gray-400 text-center">
Ссылка действительна 10 минут
</p>
</>
)}
</div>
) : (
<div className="space-y-4">
<p className="text-gray-300">
Привяжи Telegram, чтобы получать уведомления о важных событиях:
</p>
<ul className="text-sm text-gray-400 space-y-2">
<li className="flex items-center gap-2">
<span className="text-yellow-400">🌟</span>
Golden Hour - очки x1.5
</li>
<li className="flex items-center gap-2">
<span className="text-yellow-400">🎰</span>
Jackpot - очки x3
</li>
<li className="flex items-center gap-2">
<span className="text-yellow-400"></span>
Double Risk и другие события
</li>
</ul>
<button
onClick={handleGenerateLink}
disabled={loading}
className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<MessageCircle className="w-5 h-5" />
Привязать Telegram
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</>
)
}

View File

@@ -1,46 +1,98 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react'
import { Gamepad2, LogOut, Trophy, User, Menu, X } from 'lucide-react'
import { TelegramLink } from '@/components/TelegramLink'
import { clsx } from 'clsx'
export function Layout() {
const { user, isAuthenticated, logout } = useAuthStore()
const navigate = useNavigate()
const location = useLocation()
const [isScrolled, setIsScrolled] = useState(false)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Close mobile menu on route change
useEffect(() => {
setIsMobileMenuOpen(false)
}, [location])
const handleLogout = () => {
logout()
navigate('/login')
}
const isActiveLink = (path: string) => location.pathname === path
return (
<div className="min-h-screen flex flex-col">
{/* Header */}
<header className="bg-gray-800 border-b border-gray-700">
<header
className={clsx(
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
isScrolled
? 'bg-dark-900/80 backdrop-blur-lg border-b border-dark-600/50 shadow-lg'
: 'bg-transparent'
)}
>
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 text-xl font-bold text-white">
<Gamepad2 className="w-8 h-8 text-primary-500" />
<span>Игровой Марафон</span>
{/* Logo */}
<Link
to="/"
className="flex items-center gap-3 group"
>
<div className="relative">
<Gamepad2 className="w-8 h-8 text-neon-500 transition-all duration-300 group-hover:text-neon-400 group-hover:drop-shadow-[0_0_8px_rgba(34,211,238,0.6)]" />
</div>
<span className="text-xl font-bold text-white font-display tracking-wider glitch-hover">
МАРАФОН
</span>
</Link>
<nav className="flex items-center gap-4">
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-6">
{isAuthenticated ? (
<>
<Link
to="/marathons"
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors"
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
isActiveLink('/marathons')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<Trophy className="w-5 h-5" />
<span>Марафоны</span>
</Link>
<div className="flex items-center gap-3 ml-4 pl-4 border-l border-gray-700">
<div className="flex items-center gap-2 text-gray-300">
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
<Link
to="/profile"
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
isActiveLink('/profile')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<User className="w-5 h-5" />
<span>{user?.nickname}</span>
</div>
</Link>
<TelegramLink />
<button
onClick={handleLogout}
className="p-2 text-gray-400 hover:text-white transition-colors"
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all duration-200"
title="Выйти"
>
<LogOut className="w-5 h-5" />
@@ -49,27 +101,114 @@ export function Layout() {
</>
) : (
<>
<Link to="/login" className="text-gray-300 hover:text-white transition-colors">
<Link
to="/login"
className="text-gray-300 hover:text-white transition-colors px-4 py-2"
>
Войти
</Link>
<Link to="/register" className="btn btn-primary">
<Link
to="/register"
className="px-4 py-2 bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold rounded-lg transition-all duration-200 shadow-[0_0_10px_rgba(34,211,238,0.25)] hover:shadow-[0_0_16px_rgba(34,211,238,0.4)]"
>
Регистрация
</Link>
</>
)}
</nav>
{/* Mobile Menu Button */}
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="md:hidden p-2 text-gray-300 hover:text-white rounded-lg hover:bg-dark-700 transition-colors"
>
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden bg-dark-800/95 backdrop-blur-lg border-t border-dark-600 animate-slide-in-down">
<div className="container mx-auto px-4 py-4 space-y-2">
{isAuthenticated ? (
<>
<Link
to="/marathons"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
isActiveLink('/marathons')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<Trophy className="w-5 h-5" />
<span>Марафоны</span>
</Link>
<Link
to="/profile"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
isActiveLink('/profile')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<User className="w-5 h-5" />
<span>{user?.nickname}</span>
</Link>
<div className="pt-2 border-t border-dark-600">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 py-3 text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
>
<LogOut className="w-5 h-5" />
<span>Выйти</span>
</button>
</div>
</>
) : (
<>
<Link
to="/login"
className="block px-4 py-3 text-gray-300 hover:text-white hover:bg-dark-700 rounded-lg transition-all"
>
Войти
</Link>
<Link
to="/register"
className="block px-4 py-3 text-center bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold rounded-lg transition-all"
>
Регистрация
</Link>
</>
)}
</div>
</div>
)}
</header>
{/* Spacer for fixed header */}
<div className="h-[72px]" />
{/* Main content */}
<main className="flex-1 container mx-auto px-4 py-8">
<Outlet />
</main>
{/* Footer */}
<footer className="bg-gray-800 border-t border-gray-700 py-4">
<div className="container mx-auto px-4 text-center text-gray-500 text-sm">
<footer className="bg-dark-800/50 border-t border-dark-600/50 py-6">
<div className="container mx-auto px-4">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-gray-500">
<Gamepad2 className="w-5 h-5 text-neon-500/50" />
<span className="text-sm">
Игровой Марафон &copy; {new Date().getFullYear()}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="text-neon-500/50">v1.0</span>
</div>
</div>
</div>
</footer>
</div>

View File

@@ -15,13 +15,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center font-medium rounded-lg transition-colors',
'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
{
'bg-primary-600 hover:bg-primary-700 text-white': variant === 'primary',
'bg-gray-700 hover:bg-gray-600 text-white': variant === 'secondary',
'bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold shadow-[0_0_8px_rgba(34,211,238,0.25)] hover:shadow-[0_0_14px_rgba(34,211,238,0.4)]': variant === 'primary',
'bg-dark-600 hover:bg-dark-500 text-white border border-dark-500': variant === 'secondary',
'bg-red-600 hover:bg-red-700 text-white': variant === 'danger',
'bg-transparent hover:bg-gray-800 text-gray-300': variant === 'ghost',
'bg-transparent hover:bg-dark-700 text-gray-300 hover:text-white': variant === 'ghost',
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',

View File

@@ -4,11 +4,18 @@ import { clsx } from 'clsx'
interface CardProps {
children: ReactNode
className?: string
hover?: boolean
}
export function Card({ children, className }: CardProps) {
export function Card({ children, className, hover = false }: CardProps) {
return (
<div className={clsx('bg-gray-800 rounded-xl p-6 shadow-lg', className)}>
<div
className={clsx(
'bg-dark-800 rounded-xl p-6 border border-dark-600',
hover && 'transition-all duration-300 hover:-translate-y-1 hover:border-neon-500/30 hover:shadow-[0_10px_40px_rgba(34,211,238,0.08)]',
className
)}
>
{children}
</div>
)

View File

@@ -0,0 +1,122 @@
import { useEffect } from 'react'
import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
import { clsx } from 'clsx'
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
import { NeonButton } from './NeonButton'
const icons: Record<ConfirmVariant, React.ReactNode> = {
danger: <Trash2 className="w-6 h-6" />,
warning: <AlertTriangle className="w-6 h-6" />,
info: <Info className="w-6 h-6" />,
}
const iconStyles: Record<ConfirmVariant, string> = {
danger: 'bg-red-500/10 text-red-400 border border-red-500/30',
warning: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30',
info: 'bg-neon-500/10 text-neon-400 border border-neon-500/30',
}
const confirmButtonStyles: Record<ConfirmVariant, string> = {
danger: 'border-red-500/50 text-red-400 hover:bg-red-500/10 hover:border-red-500',
warning: 'border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500',
info: '', // Will use NeonButton default
}
export function ConfirmModal() {
const { isOpen, options, handleConfirm, handleCancel } = useConfirmStore()
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
handleCancel()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, handleCancel])
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
if (!isOpen || !options) return null
const variant = options.variant || 'warning'
const Icon = icons[variant]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200"
onClick={handleCancel}
/>
{/* Modal */}
<div className="relative glass rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-dark-600">
{/* Close button */}
<button
onClick={handleCancel}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="p-6">
{/* Icon */}
<div className={clsx('w-12 h-12 rounded-full flex items-center justify-center mb-4', iconStyles[variant])}>
{Icon}
</div>
{/* Title */}
<h3 className="text-xl font-bold text-white mb-2">
{options.title}
</h3>
{/* Message */}
<p className="text-gray-400 mb-6 whitespace-pre-line">
{options.message}
</p>
{/* Actions */}
<div className="flex gap-3">
<NeonButton
variant="secondary"
className="flex-1"
onClick={handleCancel}
>
{options.cancelText || 'Отмена'}
</NeonButton>
{variant === 'info' ? (
<NeonButton
className="flex-1"
onClick={handleConfirm}
>
{options.confirmText || 'Подтвердить'}
</NeonButton>
) : (
<button
className={clsx(
'flex-1 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border-2 bg-transparent',
confirmButtonStyles[variant]
)}
onClick={handleConfirm}
>
{options.confirmText || 'Подтвердить'}
</button>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,215 @@
import { type ReactNode, type HTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
variant?: 'default' | 'dark' | 'neon' | 'gradient'
hover?: boolean
glow?: boolean
className?: string
}
export function GlassCard({
children,
variant = 'default',
hover = false,
glow = false,
className,
...props
}: GlassCardProps) {
const variantClasses = {
default: 'glass',
dark: 'glass-dark',
neon: 'glass-neon',
gradient: 'gradient-border',
}
return (
<div
className={clsx(
'rounded-xl p-6',
variantClasses[variant],
hover && 'card-hover cursor-pointer',
glow && 'neon-glow-pulse',
className
)}
{...props}
>
{children}
</div>
)
}
// Stats card variant
interface StatsCardProps {
label: string
value: string | number
icon?: ReactNode
trend?: {
value: number
isPositive: boolean
}
color?: 'neon' | 'purple' | 'pink' | 'default'
className?: string
}
export function StatsCard({
label,
value,
icon,
trend,
color = 'default',
className,
}: StatsCardProps) {
const colorClasses = {
neon: 'border-neon-500/30 hover:border-neon-500/50',
purple: 'border-accent-500/30 hover:border-accent-500/50',
pink: 'border-pink-500/30 hover:border-pink-500/50',
default: 'border-dark-600 hover:border-dark-500',
}
const iconColorClasses = {
neon: 'text-neon-500 bg-neon-500/10',
purple: 'text-accent-500 bg-accent-500/10',
pink: 'text-pink-500 bg-pink-500/10',
default: 'text-gray-400 bg-dark-700',
}
const valueColorClasses = {
neon: 'text-neon-400',
purple: 'text-accent-400',
pink: 'text-pink-400',
default: 'text-white',
}
return (
<div
className={clsx(
'glass rounded-xl p-4 border transition-all duration-300',
colorClasses[color],
'hover:-translate-y-0.5',
className
)}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm text-gray-400 mb-1">{label}</p>
<p className={clsx(
'font-bold',
typeof value === 'number' ? 'text-2xl' : 'text-lg',
valueColorClasses[color]
)}>
{value}
</p>
{trend && (
<p
className={clsx(
'text-xs mt-1',
trend.isPositive ? 'text-green-400' : 'text-red-400'
)}
>
{trend.isPositive ? '+' : ''}{trend.value}%
</p>
)}
</div>
{icon && (
<div
className={clsx(
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
iconColorClasses[color]
)}
>
{icon}
</div>
)}
</div>
</div>
)
}
// Feature card variant
interface FeatureCardProps {
title: string
description: string
icon: ReactNode
color?: 'neon' | 'purple' | 'pink'
className?: string
}
export function FeatureCard({
title,
description,
icon,
color = 'neon',
className,
}: FeatureCardProps) {
const colorClasses = {
neon: {
icon: 'text-neon-500 bg-neon-500/10 group-hover:bg-neon-500/20',
border: 'group-hover:border-neon-500/50',
glow: 'group-hover:shadow-[0_0_20px_rgba(34,211,238,0.12)]',
},
purple: {
icon: 'text-accent-500 bg-accent-500/10 group-hover:bg-accent-500/20',
border: 'group-hover:border-accent-500/50',
glow: 'group-hover:shadow-[0_0_20px_rgba(139,92,246,0.12)]',
},
pink: {
icon: 'text-pink-500 bg-pink-500/10 group-hover:bg-pink-500/20',
border: 'group-hover:border-pink-500/50',
glow: 'group-hover:shadow-[0_0_20px_rgba(244,114,182,0.12)]',
},
}
const colors = colorClasses[color]
return (
<div
className={clsx(
'group glass rounded-xl p-6 border border-dark-600 transition-all duration-300',
'hover:-translate-y-1',
colors.border,
colors.glow,
className
)}
>
<div
className={clsx(
'w-14 h-14 rounded-xl flex items-center justify-center mb-4 transition-colors',
colors.icon
)}
>
{icon}
</div>
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-gray-400 text-sm">{description}</p>
</div>
)
}
// Interactive card with animated border
interface AnimatedBorderCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
className?: string
}
export function AnimatedBorderCard({
children,
className,
...props
}: AnimatedBorderCardProps) {
return (
<div className={clsx('relative group', className)} {...props}>
{/* Animated gradient border */}
<div
className="absolute -inset-0.5 bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500 rounded-xl opacity-30 group-hover:opacity-60 blur transition-opacity duration-300"
style={{
backgroundSize: '200% 200%',
animation: 'gradient-flow 3s linear infinite',
}}
/>
{/* Card content */}
<div className="relative glass-dark rounded-xl p-6">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,116 @@
import { type ReactNode, type HTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface GlitchTextProps extends HTMLAttributes<HTMLSpanElement> {
children: ReactNode
as?: 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'p'
intensity?: 'low' | 'medium' | 'high'
color?: 'neon' | 'purple' | 'pink' | 'white'
hover?: boolean
className?: string
}
export function GlitchText({
children,
as: Component = 'span',
intensity = 'medium',
color = 'neon',
hover = false,
className,
...props
}: GlitchTextProps) {
const text = typeof children === 'string' ? children : ''
const colorClasses = {
neon: 'text-neon-500',
purple: 'text-accent-500',
pink: 'text-pink-500',
white: 'text-white',
}
const glowClasses = {
neon: 'neon-text',
purple: 'neon-text-purple',
pink: '[text-shadow:0_0_8px_rgba(244,114,182,0.5),0_0_16px_rgba(244,114,182,0.25)]',
white: '[text-shadow:0_0_8px_rgba(255,255,255,0.4),0_0_16px_rgba(255,255,255,0.2)]',
}
const intensityClasses = {
low: 'animate-[glitch-skew_2s_infinite_linear_alternate-reverse]',
medium: '',
high: 'animate-glitch',
}
if (hover) {
return (
<Component
className={clsx(
colorClasses[color],
'relative inline-block cursor-pointer transition-all duration-300',
'hover:' + glowClasses[color],
'glitch-hover',
className
)}
{...props}
>
{children}
</Component>
)
}
return (
<Component
className={clsx(
'glitch relative inline-block',
colorClasses[color],
glowClasses[color],
intensityClasses[intensity],
className
)}
data-text={text}
{...props}
>
{children}
</Component>
)
}
// Simpler glitch effect for headings
interface GlitchHeadingProps {
children: ReactNode
level?: 1 | 2 | 3 | 4
className?: string
gradient?: boolean
}
export function GlitchHeading({
children,
level = 1,
className,
gradient = false,
}: GlitchHeadingProps) {
const text = typeof children === 'string' ? children : ''
const sizeClasses = {
1: 'text-4xl md:text-5xl lg:text-6xl font-bold',
2: 'text-3xl md:text-4xl font-bold',
3: 'text-2xl md:text-3xl font-semibold',
4: 'text-xl md:text-2xl font-semibold',
}
const Component = `h${level}` as keyof JSX.IntrinsicElements
return (
<Component
className={clsx(
'glitch relative inline-block',
sizeClasses[level],
gradient ? 'gradient-neon-text' : 'text-white neon-text',
className
)}
data-text={text}
>
{children}
</Component>
)
}

View File

@@ -11,7 +11,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
return (
<div className="w-full">
{label && (
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1">
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1.5">
{label}
</label>
)}
@@ -19,15 +19,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
ref={ref}
id={id}
className={clsx(
'w-full px-4 py-2 bg-gray-800 border rounded-lg text-white placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'transition-colors',
error ? 'border-red-500' : 'border-gray-700',
'w-full px-4 py-3 bg-dark-800 border rounded-lg text-white placeholder-gray-500',
'focus:outline-none focus:border-neon-500',
'focus:shadow-[0_0_0_3px_rgba(34,211,238,0.1),0_0_8px_rgba(34,211,238,0.15)]',
'transition-all duration-200',
error ? 'border-red-500' : 'border-dark-600',
className
)}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
{error && <p className="mt-1.5 text-sm text-red-400">{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,174 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
import { clsx } from 'clsx'
import { Loader2 } from 'lucide-react'
interface NeonButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
color?: 'neon' | 'purple' | 'pink'
isLoading?: boolean
icon?: ReactNode
iconPosition?: 'left' | 'right'
glow?: boolean
pulse?: boolean
}
export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
color = 'neon',
isLoading,
icon,
iconPosition = 'left',
glow = true,
pulse = false,
children,
disabled,
...props
},
ref
) => {
const colorMap = {
neon: {
primary: 'bg-neon-500 hover:bg-neon-400 text-dark-900',
secondary: 'bg-dark-600 hover:bg-dark-500 text-neon-400 border border-neon-500/30',
outline: 'bg-transparent border-2 border-neon-500 text-neon-500 hover:bg-neon-500 hover:text-dark-900',
ghost: 'bg-transparent hover:bg-neon-500/10 text-neon-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(34, 211, 238, 0.4)',
glowHover: '0 0 18px rgba(34, 211, 238, 0.55)',
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
},
purple: {
primary: 'bg-accent-500 hover:bg-accent-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-accent-400 border border-accent-500/30',
outline: 'bg-transparent border-2 border-accent-500 text-accent-500 hover:bg-accent-500 hover:text-white',
ghost: 'bg-transparent hover:bg-accent-500/10 text-accent-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(139, 92, 246, 0.4)',
glowHover: '0 0 18px rgba(139, 92, 246, 0.55)',
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
},
pink: {
primary: 'bg-pink-500 hover:bg-pink-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-pink-400 border border-pink-500/30',
outline: 'bg-transparent border-2 border-pink-500 text-pink-500 hover:bg-pink-500 hover:text-white',
ghost: 'bg-transparent hover:bg-pink-500/10 text-pink-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(244, 114, 182, 0.4)',
glowHover: '0 0 18px rgba(244, 114, 182, 0.55)',
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
},
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2.5 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
}
const iconSizes = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
}
const colors = colorMap[color]
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center font-semibold rounded-lg',
'transition-all duration-300 ease-out',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-900',
color === 'neon' && 'focus:ring-neon-500',
color === 'purple' && 'focus:ring-accent-500',
color === 'pink' && 'focus:ring-pink-500',
colors[variant],
sizeClasses[size],
pulse && 'neon-glow-pulse',
className
)}
style={{
boxShadow: glow && !disabled && variant !== 'ghost'
? (variant === 'danger' ? colors.glowDanger : colors.glow)
: undefined,
}}
onMouseEnter={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = variant === 'danger' ? colors.glowDangerHover : colors.glowHover
}
props.onMouseEnter?.(e)
}}
onMouseLeave={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = variant === 'danger' ? colors.glowDanger : colors.glow
}
props.onMouseLeave?.(e)
}}
{...props}
>
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
{!isLoading && icon && iconPosition === 'left' && (
<span className={iconSizes[size]}>{icon}</span>
)}
{children}
{!isLoading && icon && iconPosition === 'right' && (
<span className={iconSizes[size]}>{icon}</span>
)}
</button>
)
}
)
NeonButton.displayName = 'NeonButton'
// Gradient button variant
interface GradientButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
icon?: ReactNode
}
export const GradientButton = forwardRef<HTMLButtonElement, GradientButtonProps>(
({ className, size = 'md', isLoading, icon, children, disabled, ...props }, ref) => {
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2.5 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
}
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'relative inline-flex items-center justify-center font-semibold rounded-lg',
'bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500',
'text-white transition-all duration-300',
'hover:shadow-[0_0_20px_rgba(139,92,246,0.35)]',
'disabled:opacity-50 disabled:cursor-not-allowed',
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-dark-900',
sizeClasses[size],
className
)}
{...props}
>
{isLoading && <Loader2 className="w-5 h-5 animate-spin" />}
{!isLoading && icon && <span className="w-5 h-5">{icon}</span>}
{children}
</button>
)
}
)
GradientButton.displayName = 'GradientButton'

View File

@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react'
import { X, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
import { clsx } from 'clsx'
import { useToastStore, type Toast as ToastType } from '@/store/toast'
const icons = {
success: CheckCircle,
error: XCircle,
warning: AlertTriangle,
info: Info,
}
const styles = {
success: 'bg-green-500/20 border-green-500/50 text-green-400',
error: 'bg-red-500/20 border-red-500/50 text-red-400',
warning: 'bg-yellow-500/20 border-yellow-500/50 text-yellow-400',
info: 'bg-blue-500/20 border-blue-500/50 text-blue-400',
}
const iconStyles = {
success: 'text-green-500',
error: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500',
}
interface ToastItemProps {
toast: ToastType
onRemove: (id: string) => void
}
function ToastItem({ toast, onRemove }: ToastItemProps) {
const [isVisible, setIsVisible] = useState(false)
const [isLeaving, setIsLeaving] = useState(false)
const Icon = icons[toast.type]
useEffect(() => {
// Trigger enter animation
requestAnimationFrame(() => setIsVisible(true))
}, [])
const handleRemove = () => {
setIsLeaving(true)
setTimeout(() => onRemove(toast.id), 200)
}
return (
<div
className={clsx(
'flex items-start gap-3 p-4 rounded-lg border backdrop-blur-sm shadow-lg',
'transition-all duration-200 ease-out',
styles[toast.type],
isVisible && !isLeaving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
)}
>
<Icon className={clsx('w-5 h-5 flex-shrink-0 mt-0.5', iconStyles[toast.type])} />
<p className="flex-1 text-sm text-white">{toast.message}</p>
<button
onClick={handleRemove}
className="flex-shrink-0 text-gray-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)
}
export function ToastContainer() {
const toasts = useToastStore((state) => state.toasts)
const removeToast = useToastStore((state) => state.removeToast)
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem toast={toast} onRemove={removeToast} />
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,107 @@
import { useState, useEffect } from 'react'
import { usersApi } from '@/api'
// Глобальный кэш для blob URL аватарок
const avatarCache = new Map<number, string>()
// Пользователи, для которых нужно сбросить HTTP-кэш при следующем запросе
const needsCacheBust = new Set<number>()
interface UserAvatarProps {
userId: number
hasAvatar: boolean // Есть ли у пользователя avatar_url
nickname: string
size?: 'sm' | 'md' | 'lg'
className?: string
version?: number // Для принудительного обновления при смене аватара
}
const sizeClasses = {
sm: 'w-8 h-8 text-xs',
md: 'w-12 h-12 text-sm',
lg: 'w-24 h-24 text-xl',
}
export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '', version = 0 }: UserAvatarProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!hasAvatar) {
setBlobUrl(null)
return
}
// Если version > 0, значит аватар обновился - сбрасываем кэш
const shouldBustCache = version > 0 || needsCacheBust.has(userId)
// Проверяем кэш только если не нужен bust
if (!shouldBustCache) {
const cached = avatarCache.get(userId)
if (cached) {
setBlobUrl(cached)
return
}
}
// Очищаем старый кэш если bust
if (shouldBustCache) {
const cached = avatarCache.get(userId)
if (cached) {
URL.revokeObjectURL(cached)
avatarCache.delete(userId)
}
needsCacheBust.delete(userId)
}
// Загружаем аватарку
let cancelled = false
usersApi.getAvatarUrl(userId, shouldBustCache)
.then(url => {
if (!cancelled) {
avatarCache.set(userId, url)
setBlobUrl(url)
}
})
.catch(() => {
if (!cancelled) {
setFailed(true)
}
})
return () => {
cancelled = true
}
}, [userId, hasAvatar, version])
const sizeClass = sizeClasses[size]
if (blobUrl && !failed) {
return (
<img
src={blobUrl}
alt={nickname}
className={`rounded-full object-cover ${sizeClass} ${className}`}
/>
)
}
// Fallback - первая буква никнейма
return (
<div className={`rounded-full bg-gray-700 flex items-center justify-center ${sizeClass} ${className}`}>
<span className="text-gray-400 font-medium">
{nickname.charAt(0).toUpperCase()}
</span>
</div>
)
}
// Функция для очистки кэша конкретного пользователя (после загрузки нового аватара)
export function clearAvatarCache(userId: number) {
const cached = avatarCache.get(userId)
if (cached) {
URL.revokeObjectURL(cached)
avatarCache.delete(userId)
}
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
needsCacheBust.add(userId)
}

Some files were not shown because too many files have changed in this diff Show More