Compare commits

29 Commits

Author SHA1 Message Date
ca49e42f74 Fix common enemy 2026-01-08 05:29:55 +07:00
18fe95effc Fix events 2026-01-05 23:41:22 +07:00
65b2512d8c Add upload images 2026-01-04 04:58:41 +07:00
81d992abe6 remake send push systems 2026-01-04 04:16:54 +07:00
9014d5d79d Add notification status to users table in AP 2026-01-04 03:42:11 +07:00
18ffff5473 Fix games list 2026-01-04 03:17:17 +07:00
475e2cf4cd Add notification settings 2026-01-04 02:47:38 +07:00
7a3576aec0 a 2026-01-03 00:43:26 +07:00
d295ff2aff Исключить игры типа Прохождение из проверки челленджей
При старте марафона теперь проверяются только игры с типом challenges.
Игры с типом playthrough не требуют наличия челленджей.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-30 23:02:24 +03:00
1e751f7af3 Увеличен лимит очков до 1000 и добавлена документация
- Максимум очков за челлендж/прохождение: 500 → 1000
- Добавлена документация по системе типов игр (docs/game-types.md)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-30 19:49:12 +03:00
89dbe2c018 Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования
  - Можно повторно оспаривать после разрешённых споров
  - Исправлены бонусные очки при перепрохождении после оспаривания
  - Сброс серии при невалидном пруфе
  - Колесо показывает только доступные игры
  - Rate limiting только через backend (RATE_LIMIT_ENABLED)
2025-12-29 22:23:34 +03:00
1cedfeb3ee Fix migration 2025-12-21 04:39:08 +07:00
1e723e7bcd Fix migration 2025-12-21 04:22:59 +07:00
a513dc2207 Filters 2025-12-21 04:13:20 +07:00
6bc35fc0bb http checking 2025-12-21 03:46:37 +07:00
d3adf07c3f Add covers 2025-12-21 03:05:57 +07:00
921917a319 Add covers 2025-12-21 02:52:48 +07:00
9d2dba87b8 Fix wheel 2025-12-21 00:33:25 +07:00
95e2a77335 Fix ban screen 2025-12-20 23:59:13 +07:00
6c824712c9 Fix status--service 2025-12-20 22:41:26 +07:00
5c073705d8 change backup service 2025-12-20 22:30:18 +07:00
243abe55b5 Fix service status 2025-12-20 02:28:41 +07:00
c645171671 Add static pages and styles 2025-12-20 02:01:51 +07:00
07745ea4ed Add TG banner 2025-12-20 01:07:24 +07:00
22385e8742 Fix auth refresh 2025-12-20 00:43:36 +07:00
a77a757317 Add reset password to admin panel 2025-12-20 00:34:22 +07:00
2d281d1c8c Add search and fetch user account 2025-12-20 00:17:58 +07:00
13f484e726 Fix migrations 2 2025-12-19 02:28:02 +07:00
ebaf6d39ea Fix migrations 2025-12-19 02:23:50 +07:00
98 changed files with 10862 additions and 1228 deletions

View File

@@ -32,3 +32,5 @@ PUBLIC_URL=https://your-domain.com
# Frontend (for build) # Frontend (for build)
VITE_API_URL=/api/v1 VITE_API_URL=/api/v1
RATE_LIMIT_ENABLED=false

View File

@@ -1,4 +1,4 @@
.PHONY: help dev up down build build-no-cache logs restart clean migrate shell db-shell frontend-shell backend-shell lint test .PHONY: help dev up down build build-no-cache logs logs-bot restart clean migrate shell db-shell frontend-shell backend-shell lint test
DC = sudo docker-compose DC = sudo docker-compose
@@ -14,6 +14,7 @@ help:
@echo " make logs - Show logs (all services)" @echo " make logs - Show logs (all services)"
@echo " make logs-b - Show backend logs" @echo " make logs-b - Show backend logs"
@echo " make logs-f - Show frontend logs" @echo " make logs-f - Show frontend logs"
@echo " make logs-bot - Show Telegram bot logs"
@echo "" @echo ""
@echo " Build:" @echo " Build:"
@echo " make build - Build all containers (with cache)" @echo " make build - Build all containers (with cache)"
@@ -63,6 +64,9 @@ logs-b:
logs-f: logs-f:
$(DC) logs -f frontend $(DC) logs -f frontend
logs-bot:
$(DC) logs -f bot
# Build # Build
build: build:
$(DC) build $(DC) build

View File

@@ -22,353 +22,7 @@ Success: #22c55e
Error: #ef4444 Error: #ef4444
Text: #e2e8f0 Text: #e2e8f0
Text Muted: #64748b 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
---
## Референсы для вдохновления ## Референсы для вдохновления

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '001_add_roles' revision: str = '001_add_roles'
@@ -17,17 +18,35 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
fks = inspector.get_foreign_keys(table_name)
return any(fk['name'] == constraint_name for fk in fks)
def upgrade() -> None: def upgrade() -> None:
# Add role column to users table # Add role column to users table
if not column_exists('users', 'role'):
op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user')) op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user'))
# Add role column to participants table # Add role column to participants table
if not column_exists('participants', 'role'):
op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant')) op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant'))
# Rename organizer_id to creator_id in marathons table # Rename organizer_id to creator_id in marathons table
if column_exists('marathons', 'organizer_id') and not column_exists('marathons', 'creator_id'):
op.alter_column('marathons', 'organizer_id', new_column_name='creator_id') op.alter_column('marathons', 'organizer_id', new_column_name='creator_id')
# Update existing participants: set role='organizer' for marathon creators # Update existing participants: set role='organizer' for marathon creators
# This is idempotent - running multiple times is safe
op.execute(""" op.execute("""
UPDATE participants p UPDATE participants p
SET role = 'organizer' SET role = 'organizer'
@@ -36,13 +55,17 @@ def upgrade() -> None:
""") """)
# Add status column to games table # Add status column to games table
if not column_exists('games', 'status'):
op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved')) 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 # Rename added_by_id to proposed_by_id in games table
if column_exists('games', 'added_by_id') and not column_exists('games', 'proposed_by_id'):
op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id') op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id')
# Add approved_by_id column to games table # Add approved_by_id column to games table
if not column_exists('games', 'approved_by_id'):
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True)) op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
if not constraint_exists('games', 'fk_games_approved_by_id'):
op.create_foreign_key( op.create_foreign_key(
'fk_games_approved_by_id', 'fk_games_approved_by_id',
'games', 'users', 'games', 'users',
@@ -53,20 +76,27 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
# Remove approved_by_id from games # Remove approved_by_id from games
if constraint_exists('games', 'fk_games_approved_by_id'):
op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey') op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey')
if column_exists('games', 'approved_by_id'):
op.drop_column('games', 'approved_by_id') op.drop_column('games', 'approved_by_id')
# Rename proposed_by_id back to added_by_id # Rename proposed_by_id back to added_by_id
if column_exists('games', 'proposed_by_id'):
op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id') op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id')
# Remove status from games # Remove status from games
if column_exists('games', 'status'):
op.drop_column('games', 'status') op.drop_column('games', 'status')
# Rename creator_id back to organizer_id # Rename creator_id back to organizer_id
if column_exists('marathons', 'creator_id'):
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id') op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
# Remove role from participants # Remove role from participants
if column_exists('participants', 'role'):
op.drop_column('participants', 'role') op.drop_column('participants', 'role')
# Remove role from users # Remove role from users
if column_exists('users', 'role'):
op.drop_column('users', 'role') op.drop_column('users', 'role')

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '002_marathon_settings' revision: str = '002_marathon_settings'
@@ -17,16 +18,27 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None: def upgrade() -> None:
# Add is_public column to marathons table (default False = private) # Add is_public column to marathons table (default False = private)
if not column_exists('marathons', 'is_public'):
op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false')) op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
# Add game_proposal_mode column to marathons table # Add game_proposal_mode column to marathons table
# 'all_participants' - anyone can propose games (with moderation) # 'all_participants' - anyone can propose games (with moderation)
# 'organizer_only' - only organizers can add games # 'organizer_only' - only organizers can add games
if not column_exists('marathons', 'game_proposal_mode'):
op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants')) op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants'))
def downgrade() -> None: def downgrade() -> None:
if column_exists('marathons', 'game_proposal_mode'):
op.drop_column('marathons', 'game_proposal_mode') op.drop_column('marathons', 'game_proposal_mode')
if column_exists('marathons', 'is_public'):
op.drop_column('marathons', 'is_public') op.drop_column('marathons', 'is_public')

View File

@@ -26,8 +26,8 @@ def upgrade() -> None:
# Insert admin user (ignore if already exists) # Insert admin user (ignore if already exists)
op.execute(f""" op.execute(f"""
INSERT INTO users (login, password_hash, nickname, role, created_at) INSERT INTO users (login, password_hash, nickname, role, is_banned, created_at)
VALUES ('admin', '{password_hash}', 'Admin', 'admin', NOW()) VALUES ('admin', '{password_hash}', 'Admin', 'admin', false, NOW())
ON CONFLICT (login) DO UPDATE SET ON CONFLICT (login) DO UPDATE SET
password_hash = '{password_hash}', password_hash = '{password_hash}',
role = 'admin' role = 'admin'

View File

@@ -17,15 +17,17 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# Update event type from 'rematch' to 'game_choice' in events table # Update event type from 'rematch' to 'game_choice' in events table
# These UPDATE statements are idempotent - safe to run multiple times
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'") op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
# Update event_type in assignments table # Update event_type in assignments table
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'") op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
# Update activity data that references rematch event # Update activity data that references rematch event
# Cast JSON to JSONB, apply jsonb_set, then cast back to JSON
op.execute(""" op.execute("""
UPDATE activities UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"game_choice"') SET data = jsonb_set(data::jsonb, '{event_type}', '"game_choice"')::json
WHERE data->>'event_type' = 'rematch' WHERE data->>'event_type' = 'rematch'
""") """)
@@ -36,6 +38,6 @@ def downgrade() -> None:
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'") op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
op.execute(""" op.execute("""
UPDATE activities UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"rematch"') SET data = jsonb_set(data::jsonb, '{event_type}', '"rematch"')::json
WHERE data->>'event_type' = 'game_choice' WHERE data->>'event_type' = 'game_choice'
""") """)

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
@@ -18,13 +19,26 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None: def upgrade() -> None:
if not column_exists('users', 'telegram_first_name'):
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True)) op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
if not column_exists('users', 'telegram_last_name'):
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True)) op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
if not column_exists('users', 'telegram_avatar_url'):
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True)) op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
def downgrade() -> None: def downgrade() -> None:
if column_exists('users', 'telegram_avatar_url'):
op.drop_column('users', 'telegram_avatar_url') op.drop_column('users', 'telegram_avatar_url')
if column_exists('users', 'telegram_last_name'):
op.drop_column('users', 'telegram_last_name') op.drop_column('users', 'telegram_last_name')
if column_exists('users', 'telegram_first_name'):
op.drop_column('users', 'telegram_first_name') op.drop_column('users', 'telegram_first_name')

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
@@ -18,11 +19,22 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None: def upgrade() -> None:
if not column_exists('challenges', 'proposed_by_id'):
op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True)) op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
if not column_exists('challenges', 'status'):
op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False)) op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False))
def downgrade() -> None: def downgrade() -> None:
if column_exists('challenges', 'status'):
op.drop_column('challenges', 'status') op.drop_column('challenges', 'status')
if column_exists('challenges', 'proposed_by_id'):
op.drop_column('challenges', 'proposed_by_id') op.drop_column('challenges', 'proposed_by_id')

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
@@ -18,15 +19,30 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None: def upgrade() -> None:
if not column_exists('users', 'is_banned'):
op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False)) op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False))
if not column_exists('users', 'banned_at'):
op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True)) op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True))
if not column_exists('users', 'banned_by_id'):
op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True)) op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
if not column_exists('users', 'ban_reason'):
op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True)) op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True))
def downgrade() -> None: def downgrade() -> None:
if column_exists('users', 'ban_reason'):
op.drop_column('users', 'ban_reason') op.drop_column('users', 'ban_reason')
if column_exists('users', 'banned_by_id'):
op.drop_column('users', 'banned_by_id') op.drop_column('users', 'banned_by_id')
if column_exists('users', 'banned_at'):
op.drop_column('users', 'banned_at') op.drop_column('users', 'banned_at')
if column_exists('users', 'is_banned'):
op.drop_column('users', 'is_banned') op.drop_column('users', 'is_banned')

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
@@ -18,8 +19,21 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def is_column_nullable(table_name: str, column_name: str) -> bool:
"""Check if a column is nullable."""
bind = op.get_bind()
inspector = inspect(bind)
columns = inspector.get_columns(table_name)
for col in columns:
if col['name'] == column_name:
return col.get('nullable', True)
return True
def upgrade() -> None: def upgrade() -> None:
# Make admin_id nullable for system actions (like auto-unban) # Make admin_id nullable for system actions (like auto-unban)
# Only alter if currently not nullable
if not is_column_nullable('admin_logs', 'admin_id'):
op.alter_column('admin_logs', 'admin_id', op.alter_column('admin_logs', 'admin_id',
existing_type=sa.Integer(), existing_type=sa.Integer(),
nullable=True) nullable=True)
@@ -27,6 +41,7 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
# Revert to not nullable (will fail if there are NULL values) # Revert to not nullable (will fail if there are NULL values)
if is_column_nullable('admin_logs', 'admin_id'):
op.alter_column('admin_logs', 'admin_id', op.alter_column('admin_logs', 'admin_id',
existing_type=sa.Integer(), existing_type=sa.Integer(),
nullable=False) nullable=False)

View File

@@ -0,0 +1,346 @@
"""Seed static content
Revision ID: 018_seed_static_content
Revises: 017_admin_logs_nullable_admin_id
Create Date: 2024-12-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '018_seed_static_content'
down_revision: Union[str, None] = '017_admin_logs_nullable_admin_id'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
STATIC_CONTENT_DATA = [
{
'key': 'terms_of_service',
'title': 'Пользовательское соглашение',
'content': '''<p class="text-gray-400 mb-6">Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между администрацией интернет-сервиса «Игровой Марафон» (далее — «Сервис», «Платформа», «Мы») и физическим лицом, использующим Сервис (далее — «Пользователь», «Вы»).</p>
<p class="text-gray-400 mb-6"><strong class="text-white">Дата вступления в силу:</strong> с момента регистрации на Платформе.<br/>
Используя Сервис, Вы подтверждаете, что полностью ознакомились с условиями настоящего Соглашения и принимаете их в полном объёме.</p>
<hr class="my-8 border-dark-600" />
<h2>1. Общие положения</h2>
<p>1.1. Сервис «Игровой Марафон» представляет собой онлайн-платформу для организации и проведения игровых марафонов — соревнований, в рамках которых участники выполняют игровые задания (челленджи) и получают очки за их успешное выполнение.</p>
<p>1.2. Сервис предоставляет Пользователям следующие возможности:</p>
<ul>
<li>Создание и участие в игровых марафонах</li>
<li>Получение случайных игровых заданий различной сложности</li>
<li>Отслеживание прогресса и статистики участников</li>
<li>Участие в специальных игровых событиях</li>
<li>Получение уведомлений через интеграцию с Telegram</li>
</ul>
<p>1.3. Сервис предоставляется на условиях «как есть» (as is). Администрация не гарантирует, что Сервис будет соответствовать ожиданиям Пользователя или работать бесперебойно.</p>
<hr class="my-8 border-dark-600" />
<h2>2. Регистрация и учётная запись</h2>
<p>2.1. Для доступа к функционалу Сервиса необходима регистрация учётной записи. При регистрации Пользователь обязуется предоставить достоверные данные.</p>
<p>2.2. Пользователь несёт полную ответственность за:</p>
<ul>
<li>Сохранность своих учётных данных (логина и пароля)</li>
<li>Все действия, совершённые с использованием его учётной записи</li>
<li>Своевременное уведомление Администрации о несанкционированном доступе к аккаунту</li>
</ul>
<p>2.3. Каждый Пользователь имеет право на одну учётную запись. Создание дополнительных аккаунтов (мультиаккаунтинг) запрещено и влечёт блокировку всех связанных учётных записей.</p>
<p>2.4. Пользователь вправе в любой момент удалить свою учётную запись, обратившись к Администрации. При удалении аккаунта все связанные данные будут безвозвратно удалены.</p>
<hr class="my-8 border-dark-600" />
<h2>3. Правила использования Сервиса</h2>
<p>3.1. <strong class="text-white">При использовании Сервиса запрещается:</strong></p>
<ul>
<li>Использовать читы, эксплойты, модификации и любое стороннее программное обеспечение, дающее нечестное преимущество при выполнении игровых заданий</li>
<li>Предоставлять ложные доказательства выполнения заданий (поддельные скриншоты, видео, достижения)</li>
<li>Передавать доступ к учётной записи третьим лицам</li>
<li>Оскорблять, унижать или преследовать других участников</li>
<li>Распространять спам, рекламу или вредоносный контент</li>
<li>Нарушать работу Сервиса техническими средствами</li>
<li>Использовать Сервис для деятельности, нарушающей законодательство</li>
</ul>
<p>3.2. <strong class="text-white">Правила проведения марафонов:</strong></p>
<ul>
<li>Участники обязаны честно выполнять полученные задания</li>
<li>Доказательства выполнения должны быть подлинными и соответствовать требованиям задания</li>
<li>Отказ от задания (дроп) влечёт штрафные санкции согласно правилам конкретного марафона</li>
<li>Споры по заданиям разрешаются через систему диспутов с участием других участников марафона</li>
</ul>
<p>3.3. Организаторы марафонов несут ответственность за соблюдение правил в рамках своих мероприятий и имеют право устанавливать дополнительные правила, не противоречащие настоящему Соглашению.</p>
<hr class="my-8 border-dark-600" />
<h2>4. Система очков и рейтинг</h2>
<p>4.1. За выполнение заданий Пользователи получают очки, количество которых зависит от сложности задания и активных игровых событий.</p>
<p>4.2. Очки используются исключительно для формирования рейтинга участников в рамках марафонов и не имеют денежного эквивалента.</p>
<p>4.3. Администрация оставляет за собой право корректировать начисленные очки в случае выявления нарушений или технических ошибок.</p>
<hr class="my-8 border-dark-600" />
<h2>5. Ответственность сторон</h2>
<p>5.1. <strong class="text-white">Администрация не несёт ответственности за:</strong></p>
<ul>
<li>Временную недоступность Сервиса по техническим причинам</li>
<li>Потерю данных вследствие технических сбоев</li>
<li>Действия третьих лиц, получивших доступ к учётной записи Пользователя</li>
<li>Контент, размещаемый Пользователями</li>
<li>Качество интернет-соединения Пользователя</li>
</ul>
<p>5.2. Пользователь несёт ответственность за соблюдение условий настоящего Соглашения и применимого законодательства.</p>
<hr class="my-8 border-dark-600" />
<h2>6. Санкции за нарушения</h2>
<p>6.1. За нарушение условий настоящего Соглашения Администрация вправе применить следующие санкции:</p>
<ul>
<li><strong class="text-yellow-400">Предупреждение</strong> — за незначительные нарушения</li>
<li><strong class="text-orange-400">Временная блокировка</strong> — ограничение доступа к Сервису на определённый срок</li>
<li><strong class="text-red-400">Постоянная блокировка</strong> — бессрочное ограничение доступа за грубые или повторные нарушения</li>
</ul>
<p>6.2. Решение о применении санкций принимается Администрацией единолично и является окончательным. Администрация не обязана объяснять причины принятого решения.</p>
<p>6.3. Обход блокировки путём создания новых учётных записей влечёт блокировку всех выявленных аккаунтов.</p>
<hr class="my-8 border-dark-600" />
<h2>7. Интеллектуальная собственность</h2>
<p>7.1. Все элементы Сервиса (дизайн, код, тексты, логотипы) являются объектами интеллектуальной собственности Администрации и защищены применимым законодательством.</p>
<p>7.2. Использование материалов Сервиса без письменного разрешения Администрации запрещено.</p>
<hr class="my-8 border-dark-600" />
<h2>8. Изменение условий Соглашения</h2>
<p>8.1. Администрация вправе в одностороннем порядке изменять условия настоящего Соглашения.</p>
<p>8.2. Актуальная редакция Соглашения размещается на данной странице с указанием даты последнего обновления.</p>
<p>8.3. Продолжение использования Сервиса после внесения изменений означает согласие Пользователя с новой редакцией Соглашения.</p>
<hr class="my-8 border-dark-600" />
<h2>9. Заключительные положения</h2>
<p>9.1. Настоящее Соглашение регулируется законодательством Российской Федерации.</p>
<p>9.2. Все споры, возникающие в связи с использованием Сервиса, подлежат разрешению путём переговоров. При недостижении согласия споры разрешаются в судебном порядке по месту нахождения Администрации.</p>
<p>9.3. Признание судом недействительности какого-либо положения настоящего Соглашения не влечёт недействительности остальных положений.</p>
<p>9.4. По всем вопросам, связанным с использованием Сервиса, Вы можете обратиться к Администрации через Telegram-бота или иные доступные каналы связи.</p>'''
},
{
'key': 'privacy_policy',
'title': 'Политика конфиденциальности',
'content': '''<p class="text-gray-400 mb-6">Настоящая Политика конфиденциальности (далее — «Политика») описывает, как интернет-сервис «Игровой Марафон» (далее — «Сервис», «Мы») собирает, использует, хранит и защищает персональные данные пользователей (далее — «Пользователь», «Вы»).</p>
<p class="text-gray-400 mb-6">Используя Сервис, Вы даёте согласие на обработку Ваших персональных данных в соответствии с условиями настоящей Политики.</p>
<hr class="my-8 border-dark-600" />
<h2>1. Собираемые данные</h2>
<p>1.1. <strong class="text-white">Данные, предоставляемые Пользователем:</strong></p>
<ul>
<li><strong>Регистрационные данные:</strong> логин, пароль (в зашифрованном виде), никнейм</li>
<li><strong>Данные профиля:</strong> аватар (при загрузке)</li>
<li><strong>Данные интеграции Telegram:</strong> Telegram ID, имя пользователя, имя и фамилия (при привязке бота)</li>
</ul>
<p>1.2. <strong class="text-white">Данные, собираемые автоматически:</strong></p>
<ul>
<li><strong>Данные об активности:</strong> участие в марафонах, выполненные задания, заработанные очки, статистика</li>
<li><strong>Технические данные:</strong> IP-адрес, тип браузера, время доступа (для обеспечения безопасности)</li>
<li><strong>Данные сессии:</strong> информация для поддержания авторизации</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>2. Цели обработки данных</h2>
<p>2.1. Мы обрабатываем Ваши персональные данные для следующих целей:</p>
<p><strong class="text-neon-400">Предоставление услуг:</strong></p>
<ul>
<li>Идентификация и аутентификация Пользователя</li>
<li>Обеспечение участия в марафонах и игровых событиях</li>
<li>Ведение статистики и формирование рейтингов</li>
<li>Отображение профиля Пользователя другим участникам</li>
</ul>
<p><strong class="text-neon-400">Коммуникация:</strong></p>
<ul>
<li>Отправка уведомлений о событиях марафонов через Telegram-бота</li>
<li>Информирование о новых заданиях и результатах</li>
<li>Ответы на обращения Пользователей</li>
</ul>
<p><strong class="text-neon-400">Безопасность:</strong></p>
<ul>
<li>Защита от несанкционированного доступа</li>
<li>Выявление и предотвращение нарушений</li>
<li>Ведение журнала административных действий</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>3. Правовые основания обработки</h2>
<p>3.1. Обработка персональных данных осуществляется на следующих основаниях:</p>
<ul>
<li><strong>Согласие Пользователя</strong> — при регистрации и использовании Сервиса</li>
<li><strong>Исполнение договора</strong> — Пользовательского соглашения</li>
<li><strong>Законный интерес</strong> — обеспечение безопасности Сервиса</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>4. Хранение и защита данных</h2>
<p>4.1. <strong class="text-white">Меры безопасности:</strong></p>
<ul>
<li>Пароли хранятся в зашифрованном виде с использованием алгоритма bcrypt</li>
<li>Передача данных осуществляется по защищённому протоколу HTTPS</li>
<li>Доступ к базе данных ограничен и контролируется</li>
<li>Административные действия логируются и требуют двухфакторной аутентификации</li>
</ul>
<p>4.2. <strong class="text-white">Срок хранения:</strong></p>
<ul>
<li>Данные учётной записи хранятся до момента её удаления Пользователем</li>
<li>Данные об активности в марафонах хранятся бессрочно для ведения статистики</li>
<li>Технические логи хранятся в течение 12 месяцев</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>5. Передача данных третьим лицам</h2>
<p>5.1. Мы не продаём, не сдаём в аренду и не передаём Ваши персональные данные третьим лицам в коммерческих целях.</p>
<p>5.2. <strong class="text-white">Данные могут быть переданы:</strong></p>
<ul>
<li>Telegram — для обеспечения работы уведомлений (только Telegram ID)</li>
<li>Правоохранительным органам — по законному запросу в соответствии с применимым законодательством</li>
</ul>
<p>5.3. <strong class="text-white">Публично доступная информация:</strong></p>
<p>Следующие данные видны другим Пользователям Сервиса:</p>
<ul>
<li>Никнейм</li>
<li>Аватар</li>
<li>Статистика участия в марафонах</li>
<li>Позиция в рейтингах</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>6. Права Пользователя</h2>
<p>6.1. Вы имеете право:</p>
<ul>
<li><strong>Получить доступ</strong> к своим персональным данным</li>
<li><strong>Исправить</strong> неточные или неполные данные в настройках профиля</li>
<li><strong>Удалить</strong> свою учётную запись и связанные данные</li>
<li><strong>Отозвать согласие</strong> на обработку данных (путём удаления аккаунта)</li>
<li><strong>Отключить</strong> интеграцию с Telegram в любой момент</li>
</ul>
<p>6.2. Для реализации своих прав обратитесь к Администрации через доступные каналы связи.</p>
<hr class="my-8 border-dark-600" />
<h2>7. Файлы cookie и локальное хранилище</h2>
<p>7.1. Сервис использует локальное хранилище браузера (localStorage, sessionStorage) для:</p>
<ul>
<li>Хранения токена авторизации</li>
<li>Сохранения пользовательских настроек интерфейса</li>
<li>Запоминания закрытых информационных баннеров</li>
</ul>
<p>7.2. Вы можете очистить локальное хранилище в настройках браузера, однако это приведёт к выходу из учётной записи.</p>
<hr class="my-8 border-dark-600" />
<h2>8. Обработка данных несовершеннолетних</h2>
<p>8.1. Сервис не предназначен для лиц младше 14 лет. Мы сознательно не собираем персональные данные детей.</p>
<p>8.2. Если Вам стало известно, что ребёнок предоставил нам персональные данные, пожалуйста, свяжитесь с Администрацией для их удаления.</p>
<hr class="my-8 border-dark-600" />
<h2>9. Изменение Политики</h2>
<p>9.1. Мы оставляем за собой право изменять настоящую Политику. Актуальная редакция всегда доступна на данной странице.</p>
<p>9.2. О существенных изменениях мы уведомим Пользователей через Telegram-бота или баннер на сайте.</p>
<p>9.3. Продолжение использования Сервиса после внесения изменений означает согласие с обновлённой Политикой.</p>
<hr class="my-8 border-dark-600" />
<h2>10. Контактная информация</h2>
<p>10.1. По вопросам, связанным с обработкой персональных данных, Вы можете обратиться к Администрации через:</p>
<ul>
<li>Telegram-бота Сервиса</li>
<li>Форму обратной связи (при наличии)</li>
</ul>
<p>10.2. Мы обязуемся рассмотреть Ваше обращение в разумные сроки и предоставить ответ.</p>'''
},
{
'key': 'telegram_bot_info',
'title': 'Привяжите Telegram-бота',
'content': 'Получайте уведомления о событиях марафона, новых заданиях и результатах прямо в Telegram'
},
{
'key': 'announcement',
'title': 'Добро пожаловать!',
'content': 'Мы рады приветствовать вас в «Игровом Марафоне»! Создайте свой первый марафон или присоединитесь к существующему по коду приглашения.'
}
]
def upgrade() -> None:
for item in STATIC_CONTENT_DATA:
# Use ON CONFLICT to avoid duplicates
op.execute(f"""
INSERT INTO static_content (key, title, content, created_at, updated_at)
VALUES ('{item['key']}', '{item['title'].replace("'", "''")}', '{item['content'].replace("'", "''")}', NOW(), NOW())
ON CONFLICT (key) DO NOTHING
""")
def downgrade() -> None:
keys = [f"'{item['key']}'" for item in STATIC_CONTENT_DATA]
op.execute(f"DELETE FROM static_content WHERE key IN ({', '.join(keys)})")

View File

@@ -0,0 +1,43 @@
"""Add marathon cover fields (cover_path and cover_url)
Revision ID: 019_add_marathon_cover
Revises: 018_seed_static_content
Create Date: 2024-12-21
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '019_add_marathon_cover'
down_revision: Union[str, None] = '018_seed_static_content'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# cover_path - путь к файлу в S3 хранилище
if not column_exists('marathons', 'cover_path'):
op.add_column('marathons', sa.Column('cover_path', sa.String(500), nullable=True))
# cover_url - API URL для доступа к обложке
if not column_exists('marathons', 'cover_url'):
op.add_column('marathons', sa.Column('cover_url', sa.String(500), nullable=True))
def downgrade() -> None:
if column_exists('marathons', 'cover_url'):
op.drop_column('marathons', 'cover_url')
if column_exists('marathons', 'cover_path'):
op.drop_column('marathons', 'cover_path')

View File

@@ -0,0 +1,156 @@
"""Add game types (playthrough/challenges) and bonus assignments
Revision ID: 020_add_game_types
Revises: 019_add_marathon_cover
Create Date: 2024-12-26
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '020_add_game_types'
down_revision: Union[str, None] = '019_add_marathon_cover'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
# === Games table: добавляем поля для типа игры ===
# game_type - тип игры (playthrough/challenges)
if not column_exists('games', 'game_type'):
op.add_column('games', sa.Column(
'game_type',
sa.String(20),
nullable=False,
server_default='challenges'
))
# playthrough_points - очки за прохождение
if not column_exists('games', 'playthrough_points'):
op.add_column('games', sa.Column(
'playthrough_points',
sa.Integer(),
nullable=True
))
# playthrough_description - описание прохождения
if not column_exists('games', 'playthrough_description'):
op.add_column('games', sa.Column(
'playthrough_description',
sa.Text(),
nullable=True
))
# playthrough_proof_type - тип пруфа для прохождения
if not column_exists('games', 'playthrough_proof_type'):
op.add_column('games', sa.Column(
'playthrough_proof_type',
sa.String(20),
nullable=True
))
# playthrough_proof_hint - подсказка для пруфа
if not column_exists('games', 'playthrough_proof_hint'):
op.add_column('games', sa.Column(
'playthrough_proof_hint',
sa.Text(),
nullable=True
))
# === Assignments table: добавляем поля для прохождений ===
# game_id - ссылка на игру (для playthrough)
if not column_exists('assignments', 'game_id'):
op.add_column('assignments', sa.Column(
'game_id',
sa.Integer(),
sa.ForeignKey('games.id', ondelete='CASCADE'),
nullable=True
))
op.create_index('ix_assignments_game_id', 'assignments', ['game_id'])
# is_playthrough - флаг прохождения
if not column_exists('assignments', 'is_playthrough'):
op.add_column('assignments', sa.Column(
'is_playthrough',
sa.Boolean(),
nullable=False,
server_default='false'
))
# Делаем challenge_id nullable (для playthrough заданий)
# SQLite не поддерживает ALTER COLUMN, поэтому проверяем dialect
bind = op.get_bind()
if bind.dialect.name != 'sqlite':
op.alter_column('assignments', 'challenge_id', nullable=True)
# === Создаём таблицу bonus_assignments ===
if not table_exists('bonus_assignments'):
op.create_table(
'bonus_assignments',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('main_assignment_id', sa.Integer(),
sa.ForeignKey('assignments.id', ondelete='CASCADE'),
nullable=False, index=True),
sa.Column('challenge_id', sa.Integer(),
sa.ForeignKey('challenges.id', ondelete='CASCADE'),
nullable=False, index=True),
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
sa.Column('proof_path', sa.String(500), nullable=True),
sa.Column('proof_url', sa.Text(), nullable=True),
sa.Column('proof_comment', sa.Text(), nullable=True),
sa.Column('points_earned', sa.Integer(), nullable=False, server_default='0'),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False,
server_default=sa.func.now()),
)
def downgrade() -> None:
# Удаляем таблицу bonus_assignments
if table_exists('bonus_assignments'):
op.drop_table('bonus_assignments')
# Удаляем поля из assignments
if column_exists('assignments', 'is_playthrough'):
op.drop_column('assignments', 'is_playthrough')
if column_exists('assignments', 'game_id'):
op.drop_index('ix_assignments_game_id', 'assignments')
op.drop_column('assignments', 'game_id')
# Удаляем поля из games
if column_exists('games', 'playthrough_proof_hint'):
op.drop_column('games', 'playthrough_proof_hint')
if column_exists('games', 'playthrough_proof_type'):
op.drop_column('games', 'playthrough_proof_type')
if column_exists('games', 'playthrough_description'):
op.drop_column('games', 'playthrough_description')
if column_exists('games', 'playthrough_points'):
op.drop_column('games', 'playthrough_points')
if column_exists('games', 'game_type'):
op.drop_column('games', 'game_type')

View File

@@ -0,0 +1,100 @@
"""Add bonus assignment disputes support
Revision ID: 021_add_bonus_disputes
Revises: 020_add_game_types
Create Date: 2024-12-29
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '021_add_bonus_disputes'
down_revision: Union[str, None] = '020_add_game_types'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
constraints = inspector.get_unique_constraints(table_name)
return any(c['name'] == constraint_name for c in constraints)
def upgrade() -> None:
bind = op.get_bind()
# Add bonus_assignment_id column to disputes
if not column_exists('disputes', 'bonus_assignment_id'):
op.add_column('disputes', sa.Column(
'bonus_assignment_id',
sa.Integer(),
nullable=True
))
op.create_foreign_key(
'fk_disputes_bonus_assignment_id',
'disputes',
'bonus_assignments',
['bonus_assignment_id'],
['id'],
ondelete='CASCADE'
)
op.create_index('ix_disputes_bonus_assignment_id', 'disputes', ['bonus_assignment_id'])
# Drop the unique index on assignment_id first (required before making nullable)
if bind.dialect.name != 'sqlite':
try:
op.drop_index('ix_disputes_assignment_id', 'disputes')
except Exception:
pass # Index might not exist
# Make assignment_id nullable (PostgreSQL only, SQLite doesn't support ALTER COLUMN)
if bind.dialect.name != 'sqlite':
op.alter_column('disputes', 'assignment_id', nullable=True)
# Create a non-unique index on assignment_id
try:
op.create_index('ix_disputes_assignment_id_non_unique', 'disputes', ['assignment_id'])
except Exception:
pass # Index might already exist
def downgrade() -> None:
bind = op.get_bind()
# Remove non-unique index
try:
op.drop_index('ix_disputes_assignment_id_non_unique', table_name='disputes')
except Exception:
pass
# Make assignment_id not nullable again
if bind.dialect.name != 'sqlite':
op.alter_column('disputes', 'assignment_id', nullable=False)
# Recreate unique index
try:
op.create_index('ix_disputes_assignment_id', 'disputes', ['assignment_id'], unique=True)
except Exception:
pass
# Remove foreign key, index and column
if column_exists('disputes', 'bonus_assignment_id'):
try:
op.drop_constraint('fk_disputes_bonus_assignment_id', 'disputes', type_='foreignkey')
except Exception:
pass
op.drop_index('ix_disputes_bonus_assignment_id', table_name='disputes')
op.drop_column('disputes', 'bonus_assignment_id')

View File

@@ -0,0 +1,45 @@
"""Add notification settings to users
Revision ID: 022_add_notification_settings
Revises: 021_add_bonus_disputes
Create Date: 2025-01-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '022_add_notification_settings'
down_revision: Union[str, None] = '021_add_bonus_disputes'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# Add notification settings (all enabled by default)
if not column_exists('users', 'notify_events'):
op.add_column('users', sa.Column('notify_events', sa.Boolean(), server_default='true', nullable=False))
if not column_exists('users', 'notify_disputes'):
op.add_column('users', sa.Column('notify_disputes', sa.Boolean(), server_default='true', nullable=False))
if not column_exists('users', 'notify_moderation'):
op.add_column('users', sa.Column('notify_moderation', sa.Boolean(), server_default='true', nullable=False))
def downgrade() -> None:
if column_exists('users', 'notify_moderation'):
op.drop_column('users', 'notify_moderation')
if column_exists('users', 'notify_disputes'):
op.drop_column('users', 'notify_disputes')
if column_exists('users', 'notify_events'):
op.drop_column('users', 'notify_events')

View File

@@ -1,17 +1,22 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Form
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent from app.models import (
User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
)
from app.schemas import ( from app.schemas import (
UserPublic, MessageResponse, UserPublic, MessageResponse,
AdminUserResponse, BanUserRequest, AdminLogResponse, AdminLogsListResponse, AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate, BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
StaticContentCreate, DashboardStats StaticContentCreate, DashboardStats
) )
from app.core.security import get_password_hash
from app.services.telegram_notifier import telegram_notifier from app.services.telegram_notifier import telegram_notifier
from app.core.rate_limit import limiter from app.core.rate_limit import limiter
@@ -60,6 +65,28 @@ async def log_admin_action(
await db.commit() await db.commit()
def build_admin_user_response(user: User, marathons_count: int) -> AdminUserResponse:
"""Build AdminUserResponse from User model."""
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
notify_events=user.notify_events,
notify_disputes=user.notify_disputes,
notify_moderation=user.notify_moderation,
)
@router.get("/users", response_model=list[AdminUserResponse]) @router.get("/users", response_model=list[AdminUserResponse])
async def list_users( async def list_users(
current_user: CurrentUser, current_user: CurrentUser,
@@ -93,21 +120,7 @@ async def list_users(
marathons_count = await db.scalar( marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id) select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
) )
response.append(AdminUserResponse( response.append(build_admin_user_response(user, marathons_count))
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
))
return response return response
@@ -126,21 +139,7 @@ async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
select(func.count()).select_from(Participant).where(Participant.user_id == user.id) select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
) )
return AdminUserResponse( return build_admin_user_response(user, marathons_count)
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)
@router.patch("/users/{user_id}/role", response_model=AdminUserResponse) @router.patch("/users/{user_id}/role", response_model=AdminUserResponse)
@@ -180,21 +179,7 @@ async def set_user_role(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id) select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
) )
return AdminUserResponse( return build_admin_user_response(user, marathons_count)
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)
@router.delete("/users/{user_id}", response_model=MessageResponse) @router.delete("/users/{user_id}", response_model=MessageResponse)
@@ -226,7 +211,7 @@ async def list_marathons(
current_user: CurrentUser, current_user: CurrentUser,
db: DbSession, db: DbSession,
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=200),
search: str | None = None, search: str | None = None,
): ):
"""List all marathons. Admin only.""" """List all marathons. Admin only."""
@@ -359,21 +344,7 @@ async def ban_user(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id) select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
) )
return AdminUserResponse( return build_admin_user_response(user, marathons_count)
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse) @router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
@@ -414,22 +385,54 @@ async def unban_user(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id) select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
) )
return AdminUserResponse( return build_admin_user_response(user, marathons_count)
id=user.id,
login=user.login,
nickname=user.nickname, # ============ Reset Password ============
role=user.role, @router.post("/users/{user_id}/reset-password", response_model=AdminUserResponse)
avatar_url=user.avatar_url, async def reset_user_password(
telegram_id=user.telegram_id, request: Request,
telegram_username=user.telegram_username, user_id: int,
marathons_count=marathons_count, data: AdminResetPasswordRequest,
created_at=user.created_at.isoformat(), current_user: CurrentUser,
is_banned=user.is_banned, db: DbSession,
banned_at=None, ):
banned_until=None, """Reset user password. Admin only."""
ban_reason=None, require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Hash and save new password
user.password_hash = get_password_hash(data.new_password)
await db.commit()
await db.refresh(user)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.USER_PASSWORD_RESET.value,
"user", user_id,
{"nickname": user.nickname},
request.client.host if request.client else None
) )
# Notify user via Telegram if linked
if user.telegram_id:
await telegram_notifier.send_message(
user.telegram_id,
"🔐 <b>Ваш пароль был сброшен</b>\n\n"
"Администратор установил вам новый пароль. "
"Если это были не вы, свяжитесь с поддержкой."
)
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return build_admin_user_response(user, marathons_count)
# ============ Force Finish Marathon ============ # ============ Force Finish Marathon ============
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse) @router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
@@ -529,9 +532,10 @@ async def get_logs(
@limiter.limit("1/minute") @limiter.limit("1/minute")
async def broadcast_to_all( async def broadcast_to_all(
request: Request, request: Request,
data: BroadcastRequest,
current_user: CurrentUser, current_user: CurrentUser,
db: DbSession, db: DbSession,
message: str = Form(""),
media: list[UploadFile] = File(default=[]),
): ):
"""Send broadcast message to all users with Telegram linked. Admin only.""" """Send broadcast message to all users with Telegram linked. Admin only."""
require_admin_with_2fa(current_user) require_admin_with_2fa(current_user)
@@ -545,15 +549,40 @@ async def broadcast_to_all(
total_count = len(users) total_count = len(users)
sent_count = 0 sent_count = 0
# Read media files if provided (up to 10 files, Telegram limit)
media_items = []
for file in media[:10]:
if file and file.filename:
file_data = await file.read()
content_type = file.content_type or ""
if content_type.startswith("image/"):
media_items.append({
"type": "photo",
"data": file_data,
"filename": file.filename,
"content_type": content_type
})
elif content_type.startswith("video/"):
media_items.append({
"type": "video",
"data": file_data,
"filename": file.filename,
"content_type": content_type
})
for user in users: for user in users:
if await telegram_notifier.send_message(user.telegram_id, data.message): if await telegram_notifier.send_media_message(
user.telegram_id,
text=message if message.strip() else None,
media_items=media_items if media_items else None
):
sent_count += 1 sent_count += 1
# Log action # Log action
await log_admin_action( await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_ALL.value, db, current_user.id, AdminActionType.BROADCAST_ALL.value,
"broadcast", 0, "broadcast", 0,
{"message": data.message[:100], "sent": sent_count, "total": total_count}, {"message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)},
request.client.host if request.client else None request.client.host if request.client else None
) )
@@ -565,9 +594,10 @@ async def broadcast_to_all(
async def broadcast_to_marathon( async def broadcast_to_marathon(
request: Request, request: Request,
marathon_id: int, marathon_id: int,
data: BroadcastRequest,
current_user: CurrentUser, current_user: CurrentUser,
db: DbSession, db: DbSession,
message: str = Form(""),
media: list[UploadFile] = File(default=[]),
): ):
"""Send broadcast message to marathon participants. Admin only.""" """Send broadcast message to marathon participants. Admin only."""
require_admin_with_2fa(current_user) require_admin_with_2fa(current_user)
@@ -578,7 +608,7 @@ async def broadcast_to_marathon(
if not marathon: if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found") raise HTTPException(status_code=404, detail="Marathon not found")
# Get participants count # Get participants with telegram
total_result = await db.execute( total_result = await db.execute(
select(User) select(User)
.join(Participant, Participant.user_id == User.id) .join(Participant, Participant.user_id == User.id)
@@ -590,15 +620,41 @@ async def broadcast_to_marathon(
users = total_result.scalars().all() users = total_result.scalars().all()
total_count = len(users) total_count = len(users)
sent_count = await telegram_notifier.notify_marathon_participants( # Read media files if provided (up to 10 files, Telegram limit)
db, marathon_id, data.message media_items = []
) for file in media[:10]:
if file and file.filename:
file_data = await file.read()
content_type = file.content_type or ""
if content_type.startswith("image/"):
media_items.append({
"type": "photo",
"data": file_data,
"filename": file.filename,
"content_type": content_type
})
elif content_type.startswith("video/"):
media_items.append({
"type": "video",
"data": file_data,
"filename": file.filename,
"content_type": content_type
})
sent_count = 0
for user in users:
if await telegram_notifier.send_media_message(
user.telegram_id,
text=message if message.strip() else None,
media_items=media_items if media_items else None
):
sent_count += 1
# Log action # Log action
await log_admin_action( await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value, db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
"marathon", marathon_id, "marathon", marathon_id,
{"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count}, {"title": marathon.title, "message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)},
request.client.host if request.client else None request.client.host if request.client else None
) )
@@ -697,6 +753,37 @@ async def create_content(
return content return content
@router.delete("/content/{key}", response_model=MessageResponse)
async def delete_content(
key: str,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Delete static content. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(
select(StaticContent).where(StaticContent.key == key)
)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Content not found")
await db.delete(content)
await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
"static_content", content.id,
{"action": "delete", "key": key},
request.client.host if request.client else None
)
return {"message": f"Content '{key}' deleted successfully"}
# ============ Dashboard ============ # ============ Dashboard ============
@router.get("/dashboard", response_model=DashboardStats) @router.get("/dashboard", response_model=DashboardStats)
async def get_dashboard(current_user: CurrentUser, db: DbSession): async def get_dashboard(current_user: CurrentUser, db: DbSession):
@@ -745,3 +832,273 @@ async def get_dashboard(current_user: CurrentUser, db: DbSession):
for log in recent_logs for log in recent_logs
], ],
) )
# ============ Disputes Management ============
class AdminDisputeResponse(BaseModel):
id: int
assignment_id: int | None
bonus_assignment_id: int | None
marathon_id: int
marathon_title: str
challenge_title: str
participant_nickname: str
raised_by_nickname: str
reason: str
status: str
votes_valid: int
votes_invalid: int
created_at: str
expires_at: str
class Config:
from_attributes = True
class ResolveDisputeRequest(BaseModel):
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
@router.get("/disputes", response_model=list[AdminDisputeResponse])
async def list_disputes(
current_user: CurrentUser,
db: DbSession,
status: str = Query("pending", pattern="^(open|pending|all)$"),
):
"""List all disputes. Admin only.
Status filter:
- pending: disputes waiting for admin decision (default)
- open: disputes still in voting phase
- all: all disputes
"""
require_admin_with_2fa(current_user)
from datetime import timedelta
DISPUTE_WINDOW_HOURS = 24
query = (
select(Dispute)
.options(
selectinload(Dispute.raised_by),
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Dispute.assignment).selectinload(Assignment.game), # For playthrough
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
)
.order_by(Dispute.created_at.desc())
)
if status == "pending":
# Disputes waiting for admin decision
query = query.where(Dispute.status == DisputeStatus.PENDING_ADMIN.value)
elif status == "open":
# Disputes still in voting phase
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
result = await db.execute(query)
disputes = result.scalars().all()
response = []
for dispute in disputes:
# Get info based on dispute type
if dispute.bonus_assignment_id:
bonus = dispute.bonus_assignment
main_assignment = bonus.main_assignment
participant = main_assignment.participant
challenge_title = f"Бонус: {bonus.challenge.title}"
marathon_id = main_assignment.game.marathon_id
# Get marathon title
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = marathon_result.scalar_one_or_none()
marathon_title = marathon.title if marathon else "Unknown"
else:
assignment = dispute.assignment
participant = assignment.participant
if assignment.is_playthrough:
challenge_title = f"Прохождение: {assignment.game.title}"
marathon_id = assignment.game.marathon_id
else:
challenge_title = assignment.challenge.title
marathon_id = assignment.challenge.game.marathon_id
# Get marathon title
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = marathon_result.scalar_one_or_none()
marathon_title = marathon.title if marathon else "Unknown"
# Count votes
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
# Calculate expiry
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
response.append(AdminDisputeResponse(
id=dispute.id,
assignment_id=dispute.assignment_id,
bonus_assignment_id=dispute.bonus_assignment_id,
marathon_id=marathon_id,
marathon_title=marathon_title,
challenge_title=challenge_title,
participant_nickname=participant.user.nickname,
raised_by_nickname=dispute.raised_by.nickname,
reason=dispute.reason,
status=dispute.status,
votes_valid=votes_valid,
votes_invalid=votes_invalid,
created_at=dispute.created_at.isoformat(),
expires_at=expires_at.isoformat(),
))
return response
@router.post("/disputes/{dispute_id}/resolve", response_model=MessageResponse)
async def resolve_dispute(
request: Request,
dispute_id: int,
data: ResolveDisputeRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Manually resolve a dispute. Admin only."""
require_admin_with_2fa(current_user)
# Get dispute
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.participant),
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Dispute.assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
# Allow resolving disputes that are either open or pending admin decision
if dispute.status not in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Determine result
if data.is_valid:
result_status = DisputeStatus.RESOLVED_VALID.value
action_type = AdminActionType.DISPUTE_RESOLVE_VALID.value
else:
result_status = DisputeStatus.RESOLVED_INVALID.value
action_type = AdminActionType.DISPUTE_RESOLVE_INVALID.value
# Handle invalid proof
if dispute.bonus_assignment_id:
# Reset bonus assignment
bonus = dispute.bonus_assignment
main_assignment = bonus.main_assignment
participant = main_assignment.participant
# Only subtract points if main playthrough was already completed
# (bonus points are added only when main playthrough is completed)
if main_assignment.status == AssignmentStatus.COMPLETED.value:
points_to_subtract = bonus.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Also reduce the points_earned on the main assignment
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
bonus.status = BonusAssignmentStatus.PENDING.value
bonus.proof_path = None
bonus.proof_url = None
bonus.proof_comment = None
bonus.points_earned = 0
bonus.completed_at = None
else:
# Reset main assignment
assignment = dispute.assignment
participant = assignment.participant
# Subtract points
points_to_subtract = assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Reset streak - the completion was invalid
participant.current_streak = 0
# Reset assignment
assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0
# For playthrough: reset all bonus assignments
if assignment.is_playthrough:
bonus_result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
)
for ba in bonus_result.scalars().all():
ba.status = BonusAssignmentStatus.PENDING.value
ba.proof_path = None
ba.proof_url = None
ba.proof_comment = None
ba.points_earned = 0
ba.completed_at = None
# Update dispute
dispute.status = result_status
dispute.resolved_at = datetime.utcnow()
await db.commit()
# Get details for logging
if dispute.bonus_assignment_id:
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
marathon_id = dispute.bonus_assignment.main_assignment.game.marathon_id
elif dispute.assignment.is_playthrough:
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
marathon_id = dispute.assignment.game.marathon_id
else:
challenge_title = dispute.assignment.challenge.title
marathon_id = dispute.assignment.challenge.game.marathon_id
# Log action
await log_admin_action(
db, current_user.id, action_type,
"dispute", dispute_id,
{
"challenge_title": challenge_title,
"marathon_id": marathon_id,
"is_valid": data.is_valid,
},
request.client.host if request.client else None
)
# Send notification
from app.services.telegram_notifier import telegram_notifier
if dispute.bonus_assignment_id:
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
else:
participant_user_id = dispute.assignment.participant.user_id
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = marathon_result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_dispute_resolved(
db,
user_id=participant_user_id,
marathon_title=marathon.title,
challenge_title=challenge_title,
is_valid=data.is_valid
)
return MessageResponse(
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -59,9 +59,15 @@ async def login(request: Request, data: UserLogin, db: DbSession):
# Check if user is banned # Check if user is banned
if user.is_banned: if user.is_banned:
# Return full ban info like in deps.py
ban_info = {
"banned_at": user.banned_at.isoformat() if user.banned_at else None,
"banned_until": user.banned_until.isoformat() if user.banned_until else None,
"reason": user.ban_reason,
}
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Your account has been banned", detail=ban_info,
) )
# If admin with Telegram linked, require 2FA # If admin with Telegram linked, require 2FA

View File

@@ -54,7 +54,7 @@ def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeRespo
estimated_time=challenge.estimated_time, estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type, proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint, proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None), game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
status=challenge.status, status=challenge.status,
@@ -99,7 +99,10 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse]) @router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): 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.""" """List all challenges for a marathon (from all approved games). Participants only.
Also includes virtual challenges for playthrough-type games."""
from app.models.game import GameType
# Check marathon exists # Check marathon exists
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()
@@ -111,7 +114,7 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
if not current_user.is_admin and not participant: if not current_user.is_admin and not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon") raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Get all approved challenges from approved games in this marathon # Get all approved challenges from approved games (challenges type) in this marathon
result = await db.execute( result = await db.execute(
select(Challenge) select(Challenge)
.join(Game, Challenge.game_id == Game.id) .join(Game, Challenge.game_id == Game.id)
@@ -125,7 +128,47 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
) )
challenges = result.scalars().all() challenges = result.scalars().all()
return [build_challenge_response(c, c.game) for c in challenges] responses = [build_challenge_response(c, c.game) for c in challenges]
# Also get playthrough-type games and create virtual challenges for them
result = await db.execute(
select(Game)
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
Game.game_type == GameType.PLAYTHROUGH.value,
)
.order_by(Game.title)
)
playthrough_games = result.scalars().all()
for game in playthrough_games:
# Create virtual challenge response for playthrough game
virtual_challenge = ChallengeResponse(
id=-game.id, # Negative ID to distinguish from real challenges
title=f"Прохождение: {game.title}",
description=game.playthrough_description or "Пройдите игру",
type="completion",
difficulty="medium",
points=game.playthrough_points or 0,
estimated_time=None,
proof_type=game.playthrough_proof_type or "screenshot",
proof_hint=game.playthrough_proof_hint,
game=GameShort(
id=game.id,
title=game.title,
cover_url=None,
download_url=game.download_url,
game_type=game.game_type
),
is_generated=False,
created_at=game.created_at,
status="approved",
proposed_by=None,
)
responses.append(virtual_challenge)
return responses
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse) @router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.models import ( from app.models import (
Marathon, MarathonStatus, Participant, ParticipantRole, Marathon, MarathonStatus, Participant, ParticipantRole,
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
SwapRequest as SwapRequestModel, SwapRequestStatus, User, SwapRequest as SwapRequestModel, SwapRequestStatus, User,
) )
from fastapi import UploadFile, File, Form from fastapi import UploadFile, File, Form
@@ -150,6 +150,46 @@ async def start_event(
detail="Common enemy event requires challenge_id" detail="Common enemy event requires challenge_id"
) )
# Handle playthrough games (negative challenge_id = -game_id)
challenge_id = data.challenge_id
game_id = None
is_playthrough = False
if data.type == EventType.COMMON_ENEMY.value and challenge_id and challenge_id < 0:
# This is a playthrough game, not a real challenge
game_id = -challenge_id # Convert negative to positive game_id
challenge_id = None
is_playthrough = True
# Verify game exists and is a playthrough game
from app.models.game import GameType
result = await db.execute(
select(Game).where(
Game.id == game_id,
Game.marathon_id == marathon_id,
Game.game_type == GameType.PLAYTHROUGH.value,
)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(
status_code=400,
detail="Playthrough game not found"
)
elif data.type == EventType.COMMON_ENEMY.value and challenge_id and challenge_id > 0:
# Verify regular challenge exists
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.where(Challenge.id == challenge_id)
)
challenge = result.scalar_one_or_none()
if not challenge or challenge.game.marathon_id != marathon_id:
raise HTTPException(
status_code=400,
detail="Challenge not found in this marathon"
)
try: try:
event = await event_service.start_event( event = await event_service.start_event(
db=db, db=db,
@@ -157,7 +197,9 @@ async def start_event(
event_type=data.type, event_type=data.type,
created_by_id=current_user.id, created_by_id=current_user.id,
duration_minutes=data.duration_minutes, duration_minutes=data.duration_minutes,
challenge_id=data.challenge_id, challenge_id=challenge_id,
game_id=game_id,
is_playthrough=is_playthrough,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -919,6 +961,41 @@ async def get_common_enemy_leaderboard(
def assignment_to_response(assignment: Assignment) -> AssignmentResponse: def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
"""Convert Assignment model to AssignmentResponse""" """Convert Assignment model to AssignmentResponse"""
# Handle playthrough assignments (no challenge, only game)
if assignment.is_playthrough and assignment.game:
game = assignment.game
return AssignmentResponse(
id=assignment.id,
challenge=ChallengeResponse(
id=-game.id, # Negative ID for playthrough
title=f"Прохождение: {game.title}",
description=game.playthrough_description or "Пройдите игру",
type="completion",
difficulty="medium",
points=game.playthrough_points or 0,
estimated_time=None,
proof_type=game.playthrough_proof_type or "screenshot",
proof_hint=game.playthrough_proof_hint,
game=GameShort(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
game_type=game.game_type,
),
is_generated=False,
created_at=game.created_at,
),
status=assignment.status,
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,
)
# Regular challenge assignment
challenge = assignment.challenge challenge = assignment.challenge
game = challenge.game game = challenge.game
return AssignmentResponse( return AssignmentResponse(
@@ -937,6 +1014,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"), cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
), ),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
@@ -968,7 +1046,8 @@ async def get_event_assignment(
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough assignments
) )
.where( .where(
Assignment.participant_id == participant.id, Assignment.participant_id == participant.id,
@@ -999,10 +1078,19 @@ async def get_event_assignment(
is_completed=False, is_completed=False,
) )
# Determine challenge_id for response (negative for playthrough)
challenge_id_response = None
if event and event.data:
if event.data.get("is_playthrough"):
game_id = event.data.get("game_id")
challenge_id_response = -game_id if game_id else None
else:
challenge_id_response = event.data.get("challenge_id")
return EventAssignmentResponse( return EventAssignmentResponse(
assignment=assignment_to_response(assignment) if assignment else None, assignment=assignment_to_response(assignment) if assignment else None,
event_id=event.id if event else None, event_id=event.id if event else None,
challenge_id=event.data.get("challenge_id") if event and event.data else None, challenge_id=challenge_id_response,
is_completed=is_completed, is_completed=is_completed,
) )
@@ -1026,6 +1114,7 @@ async def complete_event_assignment(
.options( .options(
selectinload(Assignment.participant), selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough assignments
) )
.where(Assignment.id == assignment_id) .where(Assignment.id == assignment_id)
) )
@@ -1079,17 +1168,25 @@ async def complete_event_assignment(
assignment.proof_comment = comment assignment.proof_comment = comment
# Get marathon_id # Get marathon_id and base points (handle playthrough vs regular challenge)
marathon_id = assignment.challenge.game.marathon_id participant = assignment.participant
if assignment.is_playthrough and assignment.game:
marathon_id = assignment.game.marathon_id
base_points = assignment.game.playthrough_points or 0
challenge_title = f"Прохождение: {assignment.game.title}"
game_title = assignment.game.title
difficulty = "medium"
else:
challenge = assignment.challenge
marathon_id = challenge.game.marathon_id
base_points = challenge.points
challenge_title = challenge.title
game_title = challenge.game.title
difficulty = challenge.difficulty
# Get active event for bonus calculation # Get active event for bonus calculation
active_event = await event_service.get_active_event(db, marathon_id) active_event = await event_service.get_active_event(db, marathon_id)
# Calculate base points (no streak bonus for event assignments)
participant = assignment.participant
challenge = assignment.challenge
base_points = challenge.points
# Handle common enemy bonus # Handle common enemy bonus
common_enemy_bonus = 0 common_enemy_bonus = 0
common_enemy_closed = False common_enemy_closed = False
@@ -1113,12 +1210,13 @@ async def complete_event_assignment(
# Log activity # Log activity
activity_data = { activity_data = {
"assignment_id": assignment.id, "assignment_id": assignment.id,
"game": challenge.game.title, "game": game_title,
"challenge": challenge.title, "challenge": challenge_title,
"difficulty": challenge.difficulty, "difficulty": difficulty,
"points": total_points, "points": total_points,
"event_type": EventType.COMMON_ENEMY.value, "event_type": EventType.COMMON_ENEMY.value,
"is_event_assignment": True, "is_event_assignment": True,
"is_playthrough": assignment.is_playthrough,
} }
if common_enemy_bonus: if common_enemy_bonus:
activity_data["common_enemy_bonus"] = common_enemy_bonus activity_data["common_enemy_bonus"] = common_enemy_bonus

View File

@@ -7,8 +7,12 @@ from app.api.deps import (
require_participant, require_organizer, get_participant, require_participant, require_organizer, get_participant,
) )
from app.core.config import settings from app.core.config import settings
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType from app.models import (
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant
)
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
from app.schemas.assignment import AvailableGamesCount
from app.services.storage import storage_service from app.services.storage import storage_service
from app.services.telegram_notifier import telegram_notifier from app.services.telegram_notifier import telegram_notifier
@@ -43,6 +47,12 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None, approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None,
challenges_count=challenges_count, challenges_count=challenges_count,
created_at=game.created_at, created_at=game.created_at,
# Поля для типа игры
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
) )
@@ -145,6 +155,12 @@ async def add_game(
proposed_by_id=current_user.id, proposed_by_id=current_user.id,
status=game_status, status=game_status,
approved_by_id=current_user.id if is_organizer else None, approved_by_id=current_user.id if is_organizer else None,
# Поля для типа игры
game_type=data.game_type.value,
playthrough_points=data.playthrough_points,
playthrough_description=data.playthrough_description,
playthrough_proof_type=data.playthrough_proof_type.value if data.playthrough_proof_type else None,
playthrough_proof_hint=data.playthrough_proof_hint,
) )
db.add(game) db.add(game)
@@ -171,6 +187,12 @@ async def add_game(
approved_by=UserPublic.model_validate(current_user) if is_organizer else None, approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
challenges_count=0, challenges_count=0,
created_at=game.created_at, created_at=game.created_at,
# Поля для типа игры
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
) )
@@ -227,6 +249,18 @@ async def update_game(
if data.genre is not None: if data.genre is not None:
game.genre = data.genre game.genre = data.genre
# Поля для типа игры
if data.game_type is not None:
game.game_type = data.game_type.value
if data.playthrough_points is not None:
game.playthrough_points = data.playthrough_points
if data.playthrough_description is not None:
game.playthrough_description = data.playthrough_description
if data.playthrough_proof_type is not None:
game.playthrough_proof_type = data.playthrough_proof_type.value
if data.playthrough_proof_hint is not None:
game.playthrough_proof_hint = data.playthrough_proof_hint
await db.commit() await db.commit()
return await get_game(game_id, current_user, db) return await get_game(game_id, current_user, db)
@@ -398,3 +432,159 @@ async def upload_cover(
await db.commit() await db.commit()
return await get_game(game_id, current_user, db) return await get_game(game_id, current_user, db)
async def get_available_games_for_participant(
db, participant: Participant, marathon_id: int
) -> tuple[list[Game], int]:
"""
Получить список игр, доступных для спина участника.
Возвращает кортеж (доступные игры, всего игр).
Логика исключения:
- playthrough: игра исключается если участник завершил ИЛИ дропнул прохождение
- challenges: игра исключается если участник выполнил ВСЕ челленджи
"""
from sqlalchemy.orm import selectinload
# Получаем все одобренные игры с челленджами
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value
)
)
all_games = list(result.scalars().all())
# Фильтруем игры с челленджами (для типа challenges)
# или игры с заполненными playthrough полями (для типа playthrough)
games_with_content = []
for game in all_games:
if game.game_type == GameType.PLAYTHROUGH.value:
# Для playthrough не нужны челленджи
if game.playthrough_points and game.playthrough_description:
games_with_content.append(game)
else:
# Для challenges нужны челленджи
if game.challenges:
games_with_content.append(game)
total_games = len(games_with_content)
if total_games == 0:
return [], 0
# Получаем завершённые/дропнутые assignments участника
finished_statuses = [AssignmentStatus.COMPLETED.value, AssignmentStatus.DROPPED.value]
# Для playthrough: получаем game_id завершённых/дропнутых прохождений
playthrough_result = await db.execute(
select(Assignment.game_id)
.where(
Assignment.participant_id == participant.id,
Assignment.is_playthrough == True,
Assignment.status.in_(finished_statuses)
)
)
finished_playthrough_game_ids = set(playthrough_result.scalars().all())
# Для challenges: получаем challenge_id завершённых заданий
challenges_result = await db.execute(
select(Assignment.challenge_id)
.where(
Assignment.participant_id == participant.id,
Assignment.is_playthrough == False,
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
completed_challenge_ids = set(challenges_result.scalars().all())
# Фильтруем доступные игры
available_games = []
for game in games_with_content:
if game.game_type == GameType.PLAYTHROUGH.value:
# Исключаем если игра уже завершена/дропнута
if game.id not in finished_playthrough_game_ids:
available_games.append(game)
else:
# Для challenges: исключаем если все челленджи выполнены
game_challenge_ids = {c.id for c in game.challenges}
if not game_challenge_ids.issubset(completed_challenge_ids):
available_games.append(game)
return available_games, total_games
@router.get("/marathons/{marathon_id}/available-games-count", response_model=AvailableGamesCount)
async def get_available_games_count(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""
Получить количество игр, доступных для спина.
Возвращает { available: X, total: Y } где:
- available: количество игр, которые могут выпасть
- total: общее количество игр в марафоне
"""
participant = await get_participant(db, current_user.id, marathon_id)
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
available_games, total_games = await get_available_games_for_participant(
db, participant, marathon_id
)
return AvailableGamesCount(
available=len(available_games),
total=total_games
)
@router.get("/marathons/{marathon_id}/available-games", response_model=list[GameResponse])
async def get_available_games(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""
Получить список игр, доступных для спина.
Возвращает только те игры, которые могут выпасть участнику:
- Для playthrough: исключаются игры которые уже завершены/дропнуты
- Для challenges: исключаются игры где все челленджи выполнены
"""
participant = await get_participant(db, current_user.id, marathon_id)
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
available_games, _ = await get_available_games_for_participant(
db, participant, marathon_id
)
# Convert to response with challenges count
result = []
for game in available_games:
challenges_count = len(game.challenges) if game.challenges else 0
result.append(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=None,
approved_by=None,
challenges_count=challenges_count,
created_at=game.created_at,
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
))
return result

View File

@@ -1,7 +1,7 @@
from datetime import timedelta from datetime import timedelta
import secrets import secrets
import string import string
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends, UploadFile, File, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -11,13 +11,16 @@ from app.api.deps import (
require_participant, require_organizer, require_creator, require_participant, require_organizer, require_creator,
get_participant, get_participant,
) )
from app.core.config import settings
from app.core.security import decode_access_token from app.core.security import decode_access_token
from app.services.storage import storage_service
# Optional auth for endpoints that need it conditionally # Optional auth for endpoints that need it conditionally
optional_auth = HTTPBearer(auto_error=False) optional_auth = HTTPBearer(auto_error=False)
from app.models import ( from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge, Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus,
) )
from app.schemas import ( from app.schemas import (
MarathonCreate, MarathonCreate,
@@ -62,6 +65,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
title=marathon.title, title=marathon.title,
description=marathon.description, description=marathon.description,
status=marathon.status, status=marathon.status,
cover_url=marathon.cover_url,
participants_count=participants_count, participants_count=participants_count,
creator_nickname=marathon.creator.nickname, creator_nickname=marathon.creator.nickname,
) )
@@ -128,6 +132,7 @@ async def list_marathons(current_user: CurrentUser, db: DbSession):
title=marathon.title, title=marathon.title,
status=marathon.status, status=marathon.status,
is_public=marathon.is_public, is_public=marathon.is_public,
cover_url=marathon.cover_url,
participants_count=row[1], participants_count=row[1],
start_date=marathon.start_date, start_date=marathon.start_date,
end_date=marathon.end_date, end_date=marathon.end_date,
@@ -180,6 +185,7 @@ async def create_marathon(
is_public=marathon.is_public, is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode, game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled, auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date, start_date=marathon.start_date,
end_date=marathon.end_date, end_date=marathon.end_date,
participants_count=1, participants_count=1,
@@ -226,6 +232,7 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
is_public=marathon.is_public, is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode, game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled, auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date, start_date=marathon.start_date,
end_date=marathon.end_date, end_date=marathon.end_date,
participants_count=participants_count, participants_count=participants_count,
@@ -301,9 +308,12 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
if len(approved_games) == 0: if len(approved_games) == 0:
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру") raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
# Check that all approved games have at least one challenge # Check that all approved challenge-based games have at least one challenge
# Playthrough games don't need challenges
games_without_challenges = [] games_without_challenges = []
for game in approved_games: for game in approved_games:
if game.is_playthrough:
continue # Игры типа "Прохождение" не требуют челленджей
challenge_count = await db.scalar( challenge_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id) select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
) )
@@ -591,3 +601,366 @@ async def get_leaderboard(
)) ))
return leaderboard return leaderboard
@router.get("/{marathon_id}/cover")
async def get_marathon_cover(marathon_id: int, db: DbSession):
"""Get marathon cover image"""
marathon = await get_marathon_or_404(db, marathon_id)
if not marathon.cover_path:
raise HTTPException(status_code=404, detail="Marathon has no cover")
file_data = await storage_service.get_file(marathon.cover_path, "covers")
if not file_data:
raise HTTPException(status_code=404, detail="Cover 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.post("/{marathon_id}/cover", response_model=MarathonResponse)
async def upload_marathon_cover(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
file: UploadFile = File(...),
):
"""Upload marathon cover image (organizers only, preparing status)"""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
# Validate file
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image",
)
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
# Get file extension
ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
)
# Delete old cover if exists
if marathon.cover_path:
await storage_service.delete_file(marathon.cover_path)
# Upload file
filename = storage_service.generate_filename(marathon_id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="covers",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
# Update marathon with cover path and URL
marathon.cover_path = file_path
marathon.cover_url = f"/api/v1/marathons/{marathon_id}/cover"
await db.commit()
return await get_marathon(marathon_id, current_user, db)
@router.delete("/{marathon_id}/cover", response_model=MarathonResponse)
async def delete_marathon_cover(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete marathon cover image (organizers only, preparing status)"""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
if not marathon.cover_path:
raise HTTPException(status_code=400, detail="Marathon has no cover")
# Delete file from storage
await storage_service.delete_file(marathon.cover_path)
marathon.cover_path = None
marathon.cover_url = None
await db.commit()
return await get_marathon(marathon_id, current_user, db)
# ============ Marathon Disputes (for organizers) ============
from pydantic import BaseModel, Field
from datetime import datetime
class MarathonDisputeResponse(BaseModel):
id: int
assignment_id: int | None
bonus_assignment_id: int | None
challenge_title: str
participant_nickname: str
raised_by_nickname: str
reason: str
status: str
votes_valid: int
votes_invalid: int
created_at: str
expires_at: str
class Config:
from_attributes = True
class ResolveDisputeRequest(BaseModel):
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
@router.get("/{marathon_id}/disputes", response_model=list[MarathonDisputeResponse])
async def list_marathon_disputes(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
status_filter: str = "open",
):
"""List disputes in a marathon. Organizers only."""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
from datetime import timedelta
DISPUTE_WINDOW_HOURS = 24
# Get all assignments in this marathon (through games)
games_result = await db.execute(
select(Game.id).where(Game.marathon_id == marathon_id)
)
game_ids = [g[0] for g in games_result.all()]
if not game_ids:
return []
# Get disputes for assignments in these games
# Using selectinload for eager loading - no explicit joins needed
query = (
select(Dispute)
.options(
selectinload(Dispute.raised_by),
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Dispute.assignment).selectinload(Assignment.challenge),
selectinload(Dispute.assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
)
.order_by(Dispute.created_at.desc())
)
if status_filter == "open":
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
result = await db.execute(query)
all_disputes = result.scalars().unique().all()
# Filter disputes that belong to this marathon's games
response = []
for dispute in all_disputes:
# Check if dispute belongs to this marathon
if dispute.bonus_assignment_id:
bonus = dispute.bonus_assignment
if not bonus or not bonus.main_assignment:
continue
if bonus.main_assignment.game_id not in game_ids:
continue
participant = bonus.main_assignment.participant
challenge_title = f"Бонус: {bonus.challenge.title}"
else:
assignment = dispute.assignment
if not assignment:
continue
if assignment.is_playthrough:
if assignment.game_id not in game_ids:
continue
challenge_title = f"Прохождение: {assignment.game.title}"
else:
if not assignment.challenge or assignment.challenge.game_id not in game_ids:
continue
challenge_title = assignment.challenge.title
participant = assignment.participant
# Count votes
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
# Calculate expiry
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
response.append(MarathonDisputeResponse(
id=dispute.id,
assignment_id=dispute.assignment_id,
bonus_assignment_id=dispute.bonus_assignment_id,
challenge_title=challenge_title,
participant_nickname=participant.user.nickname,
raised_by_nickname=dispute.raised_by.nickname,
reason=dispute.reason,
status=dispute.status,
votes_valid=votes_valid,
votes_invalid=votes_invalid,
created_at=dispute.created_at.isoformat(),
expires_at=expires_at.isoformat(),
))
return response
@router.post("/{marathon_id}/disputes/{dispute_id}/resolve", response_model=MessageResponse)
async def resolve_marathon_dispute(
marathon_id: int,
dispute_id: int,
data: ResolveDisputeRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Manually resolve a dispute in a marathon. Organizers only."""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
# Get dispute
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.participant),
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Dispute.assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
# Verify dispute belongs to this marathon
if dispute.bonus_assignment_id:
bonus = dispute.bonus_assignment
if bonus.main_assignment.game.marathon_id != marathon_id:
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
else:
assignment = dispute.assignment
if assignment.is_playthrough:
if assignment.game.marathon_id != marathon_id:
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
else:
if assignment.challenge.game.marathon_id != marathon_id:
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
if dispute.status != DisputeStatus.OPEN.value:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Determine result
if data.is_valid:
result_status = DisputeStatus.RESOLVED_VALID.value
else:
result_status = DisputeStatus.RESOLVED_INVALID.value
# Handle invalid proof
if dispute.bonus_assignment_id:
# Reset bonus assignment
bonus = dispute.bonus_assignment
main_assignment = bonus.main_assignment
participant = main_assignment.participant
# Only subtract points if main playthrough was already completed
# (bonus points are added only when main playthrough is completed)
if main_assignment.status == AssignmentStatus.COMPLETED.value:
points_to_subtract = bonus.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Also reduce the points_earned on the main assignment
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
bonus.status = BonusAssignmentStatus.PENDING.value
bonus.proof_path = None
bonus.proof_url = None
bonus.proof_comment = None
bonus.points_earned = 0
bonus.completed_at = None
else:
# Reset main assignment
assignment = dispute.assignment
participant = assignment.participant
# Subtract points
points_to_subtract = assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Reset streak - the completion was invalid
participant.current_streak = 0
# Reset assignment
assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0
# For playthrough: reset all bonus assignments
if assignment.is_playthrough:
bonus_result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
)
for ba in bonus_result.scalars().all():
ba.status = BonusAssignmentStatus.PENDING.value
ba.proof_path = None
ba.proof_url = None
ba.proof_comment = None
ba.points_earned = 0
ba.completed_at = None
# Update dispute
dispute.status = result_status
dispute.resolved_at = datetime.utcnow()
await db.commit()
# Send notification
if dispute.bonus_assignment_id:
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
elif dispute.assignment.is_playthrough:
participant_user_id = dispute.assignment.participant.user_id
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
else:
participant_user_id = dispute.assignment.participant.user_id
challenge_title = dispute.assignment.challenge.title
await telegram_notifier.notify_dispute_resolved(
db,
user_id=participant_user_id,
marathon_title=marathon.title,
challenge_title=challenge_title,
is_valid=data.is_valid
)
return MessageResponse(
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
)

View File

@@ -73,6 +73,21 @@ class TelegramStatsResponse(BaseModel):
best_streak: int best_streak: int
class TelegramNotificationSettings(BaseModel):
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
class Config:
from_attributes = True
class TelegramNotificationSettingsUpdate(BaseModel):
notify_events: bool | None = None
notify_disputes: bool | None = None
notify_moderation: bool | None = None
# Endpoints # Endpoints
@router.post("/generate-link-token", response_model=TelegramLinkToken) @router.post("/generate-link-token", response_model=TelegramLinkToken)
async def generate_link_token(current_user: CurrentUser): async def generate_link_token(current_user: CurrentUser):
@@ -391,3 +406,46 @@ async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
total_points=total_points, total_points=total_points,
best_streak=best_streak best_streak=best_streak
) )
@router.get("/notifications/{telegram_id}", response_model=TelegramNotificationSettings | None)
async def get_notification_settings(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user's notification settings by Telegram ID."""
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
return TelegramNotificationSettings.model_validate(user)
@router.patch("/notifications/{telegram_id}", response_model=TelegramNotificationSettings | None)
async def update_notification_settings(
telegram_id: int,
data: TelegramNotificationSettingsUpdate,
db: DbSession,
_: BotSecretDep
):
"""Update user's notification settings by Telegram ID."""
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
if data.notify_events is not None:
user.notify_events = data.notify_events
if data.notify_disputes is not None:
user.notify_disputes = data.notify_disputes
if data.notify_moderation is not None:
user.notify_moderation = data.notify_moderation
await db.commit()
await db.refresh(user)
return TelegramNotificationSettings.model_validate(user)

View File

@@ -9,7 +9,8 @@ from app.models.assignment import AssignmentStatus
from app.models.marathon import MarathonStatus from app.models.marathon import MarathonStatus
from app.schemas import ( from app.schemas import (
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse, UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
PasswordChange, UserStats, UserProfilePublic, PasswordChange, UserStats, UserProfilePublic, NotificationSettings,
NotificationSettingsUpdate,
) )
from app.services.storage import storage_service from app.services.storage import storage_service
@@ -189,6 +190,32 @@ async def change_password(
return MessageResponse(message="Пароль успешно изменен") return MessageResponse(message="Пароль успешно изменен")
@router.get("/me/notifications", response_model=NotificationSettings)
async def get_notification_settings(current_user: CurrentUser):
"""Get current user's notification settings"""
return NotificationSettings.model_validate(current_user)
@router.patch("/me/notifications", response_model=NotificationSettings)
async def update_notification_settings(
data: NotificationSettingsUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update current user's notification settings"""
if data.notify_events is not None:
current_user.notify_events = data.notify_events
if data.notify_disputes is not None:
current_user.notify_disputes = data.notify_disputes
if data.notify_moderation is not None:
current_user.notify_moderation = data.notify_moderation
await db.commit()
await db.refresh(current_user)
return NotificationSettings.model_validate(current_user)
@router.get("/me/stats", response_model=UserStats) @router.get("/me/stats", response_model=UserStats)
async def get_my_stats(current_user: CurrentUser, db: DbSession): async def get_my_stats(current_user: CurrentUser, db: DbSession):
"""Получить свою статистику""" """Получить свою статистику"""

View File

@@ -9,15 +9,18 @@ from app.core.config import settings
from app.models import ( from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant, Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType, Assignment, AssignmentStatus, Activity, ActivityType,
EventType, Difficulty, User EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType,
DisputeStatus,
) )
from app.schemas import ( from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult, SpinResult, AssignmentResponse, CompleteResult, DropResult,
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
) )
from app.schemas.game import PlaythroughInfo
from app.services.points import PointsService from app.services.points import PointsService
from app.services.events import event_service from app.services.events import event_service
from app.services.storage import storage_service from app.services.storage import storage_service
from app.api.v1.games import get_available_games_for_participant
router = APIRouter(tags=["wheel"]) router = APIRouter(tags=["wheel"])
@@ -48,7 +51,9 @@ async def get_active_assignment(db, participant_id: int, is_event: bool = False)
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
) )
.where( .where(
Assignment.participant_id == participant_id, Assignment.participant_id == participant_id,
@@ -64,7 +69,9 @@ async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
) )
.where( .where(
Assignment.participant_id == participant_id, Assignment.participant_id == participant_id,
@@ -94,7 +101,7 @@ async def activate_returned_assignment(db, returned_assignment: Assignment) -> N
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult) @router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession): async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Spin the wheel to get a random game and challenge""" """Spin the wheel to get a random game and challenge (or playthrough)"""
# Check marathon is active # Check marathon is active
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()
@@ -115,43 +122,115 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if active: if active:
raise HTTPException(status_code=400, detail="You already have an active assignment") raise HTTPException(status_code=400, detail="You already have an active assignment")
# Get available games (filtered by completion status)
available_games, _ = await get_available_games_for_participant(db, participant, marathon_id)
if not available_games:
raise HTTPException(status_code=400, detail="No games available for spin")
# Check active event # Check active event
active_event = await event_service.get_active_event(db, marathon_id) active_event = await event_service.get_active_event(db, marathon_id)
game = None game = None
challenge = None challenge = None
is_playthrough = False
# Handle special event cases (excluding Common Enemy - it has separate flow) # Handle special event cases (excluding Common Enemy - it has separate flow)
# Events only apply to challenges-type games, not playthrough
if active_event: if active_event:
if active_event.type == EventType.JACKPOT.value: if active_event.type == EventType.JACKPOT.value:
# Jackpot: Get hard challenge only # Jackpot: Get hard challenge only (from challenges-type games)
challenge = await event_service.get_random_hard_challenge(db, marathon_id) challenge = await event_service.get_random_hard_challenge(db, marathon_id)
if challenge: if challenge:
# Load game for challenge # Check if this game is available for the participant
result = await db.execute( result = await db.execute(
select(Game).where(Game.id == challenge.game_id) select(Game).where(Game.id == challenge.game_id)
) )
game = result.scalar_one_or_none() game = result.scalar_one_or_none()
if game and game.id in [g.id for g in available_games]:
# Consume jackpot (one-time use) # Consume jackpot (one-time use)
await event_service.consume_jackpot(db, active_event.id) await event_service.consume_jackpot(db, active_event.id)
else:
# Game not available, fall back to normal selection
game = None
challenge = None
# Note: Common Enemy is handled separately via event-assignment endpoints # Note: Common Enemy is handled separately via event-assignment endpoints
# Normal random selection if no special event handling # Normal random selection if no special event handling
if not game or not challenge: if not game:
game = random.choice(available_games)
if game.game_type == GameType.PLAYTHROUGH.value:
# Playthrough game - no challenge selection
# Events that apply to playthrough: GOLDEN_HOUR, DOUBLE_RISK, COMMON_ENEMY
# Events that DON'T apply: JACKPOT (hard challenges only)
is_playthrough = True
challenge = None
if active_event and active_event.type == EventType.JACKPOT.value:
active_event = None # Jackpot doesn't apply to playthrough
else:
# Challenges game - select random challenge
if not game.challenges:
# Reload challenges if not loaded
result = await db.execute( result = await db.execute(
select(Game) select(Game)
.options(selectinload(Game.challenges)) .options(selectinload(Game.challenges))
.where(Game.marathon_id == marathon_id) .where(Game.id == game.id)
) )
games = [g for g in result.scalars().all() if g.challenges] game = result.scalar_one()
if not games: # Filter out already completed challenges
raise HTTPException(status_code=400, detail="No games with challenges available") completed_result = await db.execute(
select(Assignment.challenge_id)
.where(
Assignment.participant_id == participant.id,
Assignment.challenge_id.in_([c.id for c in game.challenges]),
Assignment.status == AssignmentStatus.COMPLETED.value,
)
)
completed_ids = set(completed_result.scalars().all())
available_challenges = [c for c in game.challenges if c.id not in completed_ids]
game = random.choice(games) if not available_challenges:
challenge = random.choice(game.challenges) raise HTTPException(status_code=400, detail="No challenges available for this game")
# Create assignment (store event_type for jackpot multiplier on completion) challenge = random.choice(available_challenges)
# Create assignment
if is_playthrough:
# Playthrough assignment - link to game, not challenge
assignment = Assignment(
participant_id=participant.id,
game_id=game.id,
is_playthrough=True,
status=AssignmentStatus.ACTIVE.value,
event_type=active_event.type if active_event else None,
)
db.add(assignment)
await db.flush() # Get assignment.id for bonus assignments
# Create bonus assignments for all challenges
bonus_challenges = []
if game.challenges:
for ch in game.challenges:
bonus = BonusAssignment(
main_assignment_id=assignment.id,
challenge_id=ch.id,
)
db.add(bonus)
bonus_challenges.append(ch)
# Log activity
activity_data = {
"game": game.title,
"is_playthrough": True,
"points": game.playthrough_points,
"bonus_challenges_count": len(bonus_challenges),
}
if active_event:
activity_data["event_type"] = active_event.type
else:
# Regular challenge assignment
assignment = Assignment( assignment = Assignment(
participant_id=participant.id, participant_id=participant.id,
challenge_id=challenge.id, challenge_id=challenge.id,
@@ -181,10 +260,17 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
await db.commit() await db.commit()
await db.refresh(assignment) await db.refresh(assignment)
# Calculate drop penalty (considers active event for double_risk) # Calculate drop penalty
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event) if is_playthrough:
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, None # No events for playthrough
)
else:
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, challenge.points, active_event
)
# Get challenges count (avoid lazy loading in async context) # Get challenges count
challenges_count = 0 challenges_count = 0
if 'challenges' in game.__dict__: if 'challenges' in game.__dict__:
challenges_count = len(game.challenges) challenges_count = len(game.challenges)
@@ -193,9 +279,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id) select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
) )
return SpinResult( # Build response
assignment_id=assignment.id, game_response = GameResponse(
game=GameResponse(
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"), cover_url=storage_service.get_url(game.cover_path, "covers"),
@@ -204,7 +289,52 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
added_by=None, added_by=None,
challenges_count=challenges_count, challenges_count=challenges_count,
created_at=game.created_at, created_at=game.created_at,
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
)
if is_playthrough:
# Return playthrough result
return SpinResult(
assignment_id=assignment.id,
game=game_response,
challenge=None,
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
), ),
bonus_challenges=[
ChallengeResponse(
id=ch.id,
title=ch.title,
description=ch.description,
type=ch.type,
difficulty=ch.difficulty,
points=ch.points,
estimated_time=ch.estimated_time,
proof_type=ch.proof_type,
proof_hint=ch.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=ch.is_generated,
created_at=ch.created_at,
)
for ch in bonus_challenges
],
can_drop=True,
drop_penalty=drop_penalty,
event_type=active_event.type if active_event else None,
)
else:
# Return challenge result
return SpinResult(
assignment_id=assignment.id,
game=game_response,
challenge=ChallengeResponse( challenge=ChallengeResponse(
id=challenge.id, id=challenge.id,
title=challenge.title, title=challenge.title,
@@ -215,12 +345,14 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
estimated_time=challenge.estimated_time, estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type, proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint, proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None), game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
), ),
is_playthrough=False,
can_drop=True, can_drop=True,
drop_penalty=drop_penalty, drop_penalty=drop_penalty,
event_type=active_event.type if active_event else None,
) )
@@ -230,9 +362,86 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
participant = await get_participant_or_403(db, current_user.id, marathon_id) participant = await get_participant_or_403(db, current_user.id, marathon_id)
assignment = await get_active_assignment(db, participant.id, is_event=False) assignment = await get_active_assignment(db, participant.id, is_event=False)
# If no active assignment, check for returned assignments
if not assignment:
returned = await get_oldest_returned_assignment(db, participant.id)
if returned:
# Activate the returned assignment
await activate_returned_assignment(db, returned)
await db.commit()
# Reload with all relationships
assignment = await get_active_assignment(db, participant.id, is_event=False)
if not assignment: if not assignment:
return None return None
# Handle playthrough assignments
if assignment.is_playthrough:
game = assignment.game
# Use stored event_type for playthrough
# All events except JACKPOT apply (DOUBLE_RISK = free drop, others affect points)
playthrough_event = None
if assignment.event_type and assignment.event_type != EventType.JACKPOT.value:
class MockEvent:
def __init__(self, event_type):
self.type = event_type
playthrough_event = MockEvent(assignment.event_type)
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, playthrough_event
)
# Build bonus challenges response
from app.schemas.assignment import BonusAssignmentResponse
bonus_responses = []
for ba in assignment.bonus_assignments:
bonus_responses.append(BonusAssignmentResponse(
id=ba.id,
challenge=ChallengeResponse(
id=ba.challenge.id,
title=ba.challenge.title,
description=ba.challenge.description,
type=ba.challenge.type,
difficulty=ba.challenge.difficulty,
points=ba.challenge.points,
estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at,
),
status=ba.status,
proof_url=ba.proof_url,
proof_comment=ba.proof_comment,
points_earned=ba.points_earned,
completed_at=ba.completed_at,
))
return AssignmentResponse(
id=assignment.id,
challenge=None,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
),
status=assignment.status,
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,
bonus_challenges=bonus_responses,
event_type=assignment.event_type,
)
# Regular challenge assignment
challenge = assignment.challenge challenge = assignment.challenge
game = challenge.game game = challenge.game
@@ -252,7 +461,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
estimated_time=challenge.estimated_time, estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type, proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint, proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None), game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
), ),
@@ -264,6 +473,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
started_at=assignment.started_at, started_at=assignment.started_at,
completed_at=assignment.completed_at, completed_at=assignment.completed_at,
drop_penalty=drop_penalty, drop_penalty=drop_penalty,
event_type=assignment.event_type,
) )
@@ -274,15 +484,19 @@ async def complete_assignment(
db: DbSession, db: DbSession,
proof_url: str | None = Form(None), proof_url: str | None = Form(None),
comment: str | None = Form(None), comment: str | None = Form(None),
proof_file: UploadFile | None = File(None), proof_file: UploadFile | None = File(None), # Legacy single file support
proof_files: list[UploadFile] = File([]), # Multiple files support
): ):
"""Complete a regular assignment with proof (not event assignments)""" """Complete a regular assignment with proof (not event assignments)"""
# Get assignment # Get assignment with all needed relationships
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.participant), selectinload(Assignment.participant),
selectinload(Assignment.challenge), selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For bonus points
selectinload(Assignment.dispute), # To check if it was previously disputed
) )
.where(Assignment.id == assignment_id) .where(Assignment.id == assignment_id)
) )
@@ -301,62 +515,178 @@ async def complete_assignment(
if assignment.is_event_assignment: if assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments") raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
# Need either file or URL # Combine legacy single file with new multiple files
if not proof_file and not proof_url: all_files = []
if proof_file:
all_files.append(proof_file)
if proof_files:
all_files.extend(proof_files)
# For playthrough: need either file(s) or URL or comment (proof is flexible)
# For challenges: need either file(s) or URL
if assignment.is_playthrough:
if not all_files and not proof_url and not comment:
raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)")
else:
if not all_files and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)") raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
# Handle file upload # Handle multiple file uploads
if proof_file: if all_files:
contents = await proof_file.read() from app.models import AssignmentProof
for idx, file in enumerate(all_files):
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE: if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", detail=f"File {file.filename} too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
) )
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg" ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS: if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", detail=f"Invalid file type for {file.filename}. Allowed: {settings.ALLOWED_EXTENSIONS}",
) )
# Determine file type (image or video)
file_type = "video" if ext in ["mp4", "webm", "mov", "avi"] else "image"
# Upload file to storage # Upload file to storage
filename = storage_service.generate_filename(assignment_id, proof_file.filename) filename = storage_service.generate_filename(f"{assignment_id}_{idx}", file.filename)
file_path = await storage_service.upload_file( file_path = await storage_service.upload_file(
content=contents, content=contents,
folder="proofs", folder="proofs",
filename=filename, filename=filename,
content_type=proof_file.content_type or "application/octet-stream", content_type=file.content_type or "application/octet-stream",
) )
# Create AssignmentProof record
proof_record = AssignmentProof(
assignment_id=assignment_id,
file_path=file_path,
file_type=file_type,
order_index=idx
)
db.add(proof_record)
# Legacy: set proof_path on first file for backward compatibility
if idx == 0:
assignment.proof_path = file_path assignment.proof_path = file_path
else:
# Set proof URL if provided
if proof_url:
assignment.proof_url = proof_url assignment.proof_url = proof_url
assignment.proof_comment = comment assignment.proof_comment = comment
# Calculate points
participant = assignment.participant participant = assignment.participant
challenge = assignment.challenge
# Get marathon_id for activity and event check # Handle playthrough completion
result = await db.execute( if assignment.is_playthrough:
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id) game = assignment.game
marathon_id = game.marathon_id
base_playthrough_points = game.playthrough_points
# Calculate BASE bonus points from completed bonus assignments (before multiplier)
base_bonus_points = sum(
ba.challenge.points for ba in assignment.bonus_assignments
if ba.status == BonusAssignmentStatus.COMPLETED.value
) )
full_challenge = result.scalar_one()
marathon_id = full_challenge.game.marathon_id # Total base = playthrough + all bonuses
total_base_points = base_playthrough_points + base_bonus_points
# Get event for playthrough (use stored event_type from assignment)
# All events except JACKPOT apply to playthrough
playthrough_event = None
if assignment.event_type and assignment.event_type != EventType.JACKPOT.value:
class MockEvent:
def __init__(self, event_type):
self.type = event_type
playthrough_event = MockEvent(assignment.event_type)
# Apply multiplier to the TOTAL (base + bonuses), then add streak bonus
total_points, streak_bonus, event_bonus = points_service.calculate_completion_points(
total_base_points, participant.current_streak, playthrough_event
)
# Update bonus assignments to reflect multiplied points for display
if playthrough_event:
multiplier = points_service.EVENT_MULTIPLIERS.get(playthrough_event.type, 1.0)
for ba in assignment.bonus_assignments:
if ba.status == BonusAssignmentStatus.COMPLETED.value:
ba.points_earned = int(ba.challenge.points * multiplier)
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points
assignment.streak_at_completion = participant.current_streak + 1
assignment.completed_at = datetime.utcnow()
# Update participant
participant.total_points += total_points
participant.current_streak += 1
participant.drop_count = 0
# Check if this is a redo of a previously disputed assignment
is_redo = (
assignment.dispute is not None and
assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value
)
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": game.title,
"is_playthrough": True,
"points": total_points,
"base_points": base_playthrough_points,
"bonus_points": base_bonus_points,
"streak": participant.current_streak,
}
if is_redo:
activity_data["is_redo"] = True
if playthrough_event:
activity_data["event_type"] = playthrough_event.type
activity_data["event_bonus"] = event_bonus
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data=activity_data,
)
db.add(activity)
await db.commit()
# Check for returned assignments
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment:
await activate_returned_assignment(db, returned_assignment)
await db.commit()
return CompleteResult(
points_earned=total_points,
streak_bonus=streak_bonus,
total_points=participant.total_points,
new_streak=participant.current_streak,
)
# Regular challenge completion
challenge = assignment.challenge
marathon_id = challenge.game.marathon_id
# Check active event for point multipliers # Check active event for point multipliers
active_event = await event_service.get_active_event(db, marathon_id) 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 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 effective_event = active_event
# Handle assignment-level event types (jackpot) # Handle assignment-level event types (jackpot)
if assignment.event_type == EventType.JACKPOT.value: if assignment.event_type == EventType.JACKPOT.value:
# Create a mock event object for point calculation
class MockEvent: class MockEvent:
def __init__(self, event_type): def __init__(self, event_type):
self.type = event_type self.type = event_type
@@ -386,18 +716,25 @@ async def complete_assignment(
# Update participant # Update participant
participant.total_points += total_points participant.total_points += total_points
participant.current_streak += 1 participant.current_streak += 1
participant.drop_count = 0 # Reset drop counter on success participant.drop_count = 0
# Check if this is a redo of a previously disputed assignment
is_redo = (
assignment.dispute is not None and
assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value
)
# Log activity # Log activity
activity_data = { activity_data = {
"assignment_id": assignment.id, "assignment_id": assignment.id,
"game": full_challenge.game.title, "game": challenge.game.title,
"challenge": challenge.title, "challenge": challenge.title,
"difficulty": challenge.difficulty, "difficulty": challenge.difficulty,
"points": total_points, "points": total_points,
"streak": participant.current_streak, "streak": participant.current_streak,
} }
# Log event info (use assignment's event_type for jackpot, active_event for others) if is_redo:
activity_data["is_redo"] = True
if assignment.event_type == EventType.JACKPOT.value: if assignment.event_type == EventType.JACKPOT.value:
activity_data["event_type"] = assignment.event_type activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus activity_data["event_bonus"] = event_bonus
@@ -418,7 +755,6 @@ async def complete_assignment(
# If common enemy event auto-closed, log the event end with winners # If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners: if common_enemy_closed and common_enemy_winners:
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES 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] winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute( users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids)) select(User).where(User.id.in_(winner_user_ids))
@@ -438,7 +774,7 @@ async def complete_assignment(
event_end_activity = Activity( event_end_activity = Activity(
marathon_id=marathon_id, marathon_id=marathon_id,
user_id=current_user.id, # Last completer triggers the close user_id=current_user.id,
type=ActivityType.EVENT_END.value, type=ActivityType.EVENT_END.value,
data={ data={
"event_type": EventType.COMMON_ENEMY.value, "event_type": EventType.COMMON_ENEMY.value,
@@ -451,7 +787,7 @@ async def complete_assignment(
await db.commit() await db.commit()
# Check for returned assignments and activate the oldest one # Check for returned assignments
returned_assignment = await get_oldest_returned_assignment(db, participant.id) returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment: if returned_assignment:
await activate_returned_assignment(db, returned_assignment) await activate_returned_assignment(db, returned_assignment)
@@ -469,12 +805,14 @@ async def complete_assignment(
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult) @router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession): async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
"""Drop current assignment""" """Drop current assignment"""
# Get assignment # Get assignment with all needed relationships
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.participant), selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments), # For resetting bonuses on drop
) )
.where(Assignment.id == assignment_id) .where(Assignment.id == assignment_id)
) )
@@ -490,6 +828,74 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
raise HTTPException(status_code=400, detail="Assignment is not active") raise HTTPException(status_code=400, detail="Assignment is not active")
participant = assignment.participant participant = assignment.participant
# Handle playthrough drop
if assignment.is_playthrough:
game = assignment.game
marathon_id = game.marathon_id
# Use stored event_type for drop penalty calculation
# DOUBLE_RISK = free drop (0 penalty)
playthrough_event = None
if assignment.event_type and assignment.event_type != EventType.JACKPOT.value:
class MockEvent:
def __init__(self, event_type):
self.type = event_type
playthrough_event = MockEvent(assignment.event_type)
penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, playthrough_event
)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Reset all bonus assignments (lose any completed bonuses)
completed_bonuses_count = 0
for ba in assignment.bonus_assignments:
if ba.status == BonusAssignmentStatus.COMPLETED.value:
completed_bonuses_count += 1
ba.status = BonusAssignmentStatus.PENDING.value
ba.proof_path = None
ba.proof_url = None
ba.proof_comment = None
ba.points_earned = 0
ba.completed_at = None
# Update participant
participant.total_points = max(0, participant.total_points - penalty)
participant.current_streak = 0
participant.drop_count += 1
# Log activity
activity_data = {
"game": game.title,
"is_playthrough": True,
"penalty": penalty,
"lost_bonuses": completed_bonuses_count,
}
if playthrough_event:
activity_data["event_type"] = playthrough_event.type
activity_data["free_drop"] = True
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.DROP.value,
data=activity_data,
)
db.add(activity)
await db.commit()
return DropResult(
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
)
# Regular challenge drop
marathon_id = assignment.challenge.game.marathon_id marathon_id = assignment.challenge.game.marathon_id
# Check active event for free drops (double_risk) # Check active event for free drops (double_risk)
@@ -550,7 +956,9 @@ async def get_my_history(
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
) )
.where(Assignment.participant_id == participant.id) .where(Assignment.participant_id == participant.id)
.order_by(Assignment.started_at.desc()) .order_by(Assignment.started_at.desc())
@@ -559,8 +967,61 @@ async def get_my_history(
) )
assignments = result.scalars().all() assignments = result.scalars().all()
return [ responses = []
AssignmentResponse( for a in assignments:
if a.is_playthrough:
# Playthrough assignment
game = a.game
from app.schemas.assignment import BonusAssignmentResponse
bonus_responses = [
BonusAssignmentResponse(
id=ba.id,
challenge=ChallengeResponse(
id=ba.challenge.id,
title=ba.challenge.title,
description=ba.challenge.description,
type=ba.challenge.type,
difficulty=ba.challenge.difficulty,
points=ba.challenge.points,
estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at,
),
status=ba.status,
proof_url=ba.proof_url,
proof_comment=ba.proof_comment,
points_earned=ba.points_earned,
completed_at=ba.completed_at,
)
for ba in a.bonus_assignments
]
responses.append(AssignmentResponse(
id=a.id,
challenge=None,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
),
status=a.status,
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,
started_at=a.started_at,
completed_at=a.completed_at,
bonus_challenges=bonus_responses,
))
else:
# Regular challenge assignment
responses.append(AssignmentResponse(
id=a.id, id=a.id,
challenge=ChallengeResponse( challenge=ChallengeResponse(
id=a.challenge.id, id=a.challenge.id,
@@ -575,7 +1036,9 @@ async def get_my_history(
game=GameShort( game=GameShort(
id=a.challenge.game.id, id=a.challenge.game.id,
title=a.challenge.game.title, title=a.challenge.game.title,
cover_url=None cover_url=None,
download_url=a.challenge.game.download_url,
game_type=a.challenge.game.game_type,
), ),
is_generated=a.challenge.is_generated, is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at, created_at=a.challenge.created_at,
@@ -587,6 +1050,6 @@ async def get_my_history(
streak_at_completion=a.streak_at_completion, streak_at_completion=a.streak_at_completion,
started_at=a.started_at, started_at=a.started_at,
completed_at=a.completed_at, completed_at=a.completed_at,
) ))
for a in assignments
] return responses

View File

@@ -6,6 +6,7 @@ class Settings(BaseSettings):
# App # App
APP_NAME: str = "Game Marathon" APP_NAME: str = "Game Marathon"
DEBUG: bool = False DEBUG: bool = False
RATE_LIMIT_ENABLED: bool = True # Set to False to disable rate limiting
# Database # Database
DATABASE_URL: str = "postgresql+asyncpg://marathon:marathon@localhost:5432/marathon" DATABASE_URL: str = "postgresql+asyncpg://marathon:marathon@localhost:5432/marathon"

View File

@@ -1,5 +1,10 @@
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from app.core.config import settings
# Rate limiter using client IP address as key # Rate limiter using client IP address as key
limiter = Limiter(key_func=get_remote_address) # Can be disabled via RATE_LIMIT_ENABLED=false in .env
limiter = Limiter(
key_func=get_remote_address,
enabled=settings.RATE_LIMIT_ENABLED
)

View File

@@ -1,9 +1,11 @@
from app.models.user import User, UserRole from app.models.user import User, UserRole
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
from app.models.participant import Participant, ParticipantRole from app.models.participant import Participant, ParticipantRole
from app.models.game import Game, GameStatus from app.models.game import Game, GameStatus, GameType
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
from app.models.assignment import Assignment, AssignmentStatus from app.models.assignment import Assignment, AssignmentStatus
from app.models.bonus_assignment import BonusAssignment, BonusAssignmentStatus
from app.models.assignment_proof import AssignmentProof, BonusAssignmentProof
from app.models.activity import Activity, ActivityType from app.models.activity import Activity, ActivityType
from app.models.event import Event, EventType from app.models.event import Event, EventType
from app.models.swap_request import SwapRequest, SwapRequestStatus from app.models.swap_request import SwapRequest, SwapRequestStatus
@@ -22,12 +24,17 @@ __all__ = [
"ParticipantRole", "ParticipantRole",
"Game", "Game",
"GameStatus", "GameStatus",
"GameType",
"Challenge", "Challenge",
"ChallengeType", "ChallengeType",
"Difficulty", "Difficulty",
"ProofType", "ProofType",
"Assignment", "Assignment",
"AssignmentStatus", "AssignmentStatus",
"BonusAssignment",
"BonusAssignmentStatus",
"AssignmentProof",
"BonusAssignmentProof",
"Activity", "Activity",
"ActivityType", "ActivityType",
"Event", "Event",

View File

@@ -12,6 +12,7 @@ class AdminActionType(str, Enum):
USER_UNBAN = "user_unban" USER_UNBAN = "user_unban"
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
USER_ROLE_CHANGE = "user_role_change" USER_ROLE_CHANGE = "user_role_change"
USER_PASSWORD_RESET = "user_password_reset"
# Marathon actions # Marathon actions
MARATHON_FORCE_FINISH = "marathon_force_finish" MARATHON_FORCE_FINISH = "marathon_force_finish"
@@ -29,6 +30,10 @@ class AdminActionType(str, Enum):
ADMIN_2FA_SUCCESS = "admin_2fa_success" ADMIN_2FA_SUCCESS = "admin_2fa_success"
ADMIN_2FA_FAIL = "admin_2fa_fail" ADMIN_2FA_FAIL = "admin_2fa_fail"
# Dispute actions
DISPUTE_RESOLVE_VALID = "dispute_resolve_valid"
DISPUTE_RESOLVE_INVALID = "dispute_resolve_invalid"
class AdminLog(Base): class AdminLog(Base):
__tablename__ = "admin_logs" __tablename__ = "admin_logs"

View File

@@ -18,8 +18,12 @@ class Assignment(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True) participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE")) challenge_id: Mapped[int | None] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"), nullable=True) # None для playthrough
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value) status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
# Для прохождений (playthrough)
game_id: Mapped[int | None] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), nullable=True, index=True)
is_playthrough: Mapped[bool] = mapped_column(Boolean, default=False)
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created 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 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 event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
@@ -33,6 +37,9 @@ class Assignment(Base):
# Relationships # Relationships
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments") participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments") challenge: Mapped["Challenge | None"] = relationship("Challenge", back_populates="assignments")
game: Mapped["Game | None"] = relationship("Game", back_populates="playthrough_assignments", foreign_keys=[game_id])
event: Mapped["Event | None"] = relationship("Event", 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) dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True)
bonus_assignments: Mapped[list["BonusAssignment"]] = relationship("BonusAssignment", back_populates="main_assignment", cascade="all, delete-orphan")
proof_files: Mapped[list["AssignmentProof"]] = relationship("AssignmentProof", back_populates="assignment", cascade="all, delete-orphan", order_by="AssignmentProof.order_index")

View File

@@ -0,0 +1,47 @@
from datetime import datetime
from sqlalchemy import String, ForeignKey, Integer, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class AssignmentProof(Base):
"""Файлы-доказательства для заданий (множественные пруфы)"""
__tablename__ = "assignment_proofs"
id: Mapped[int] = mapped_column(primary_key=True)
assignment_id: Mapped[int] = mapped_column(
ForeignKey("assignments.id", ondelete="CASCADE"),
index=True
)
file_path: Mapped[str] = mapped_column(String(500)) # Путь к файлу в хранилище
file_type: Mapped[str] = mapped_column(String(20)) # image или video
order_index: Mapped[int] = mapped_column(Integer, default=0) # Порядок отображения
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
assignment: Mapped["Assignment"] = relationship(
"Assignment",
back_populates="proof_files"
)
class BonusAssignmentProof(Base):
"""Файлы-доказательства для бонусных заданий (множественные пруфы)"""
__tablename__ = "bonus_assignment_proofs"
id: Mapped[int] = mapped_column(primary_key=True)
bonus_assignment_id: Mapped[int] = mapped_column(
ForeignKey("bonus_assignments.id", ondelete="CASCADE"),
index=True
)
file_path: Mapped[str] = mapped_column(String(500)) # Путь к файлу в хранилище
file_type: Mapped[str] = mapped_column(String(20)) # image или video
order_index: Mapped[int] = mapped_column(Integer, default=0) # Порядок отображения
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
bonus_assignment: Mapped["BonusAssignment"] = relationship(
"BonusAssignment",
back_populates="proof_files"
)

View File

@@ -0,0 +1,54 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class BonusAssignmentStatus(str, Enum):
PENDING = "pending"
COMPLETED = "completed"
class BonusAssignment(Base):
"""Бонусные челленджи для игр типа 'playthrough'"""
__tablename__ = "bonus_assignments"
id: Mapped[int] = mapped_column(primary_key=True)
main_assignment_id: Mapped[int] = mapped_column(
ForeignKey("assignments.id", ondelete="CASCADE"),
index=True
)
challenge_id: Mapped[int] = mapped_column(
ForeignKey("challenges.id", ondelete="CASCADE"),
index=True
)
status: Mapped[str] = mapped_column(
String(20),
default=BonusAssignmentStatus.PENDING.value
)
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)
points_earned: Mapped[int] = mapped_column(Integer, default=0)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
main_assignment: Mapped["Assignment"] = relationship(
"Assignment",
back_populates="bonus_assignments"
)
challenge: Mapped["Challenge"] = relationship("Challenge")
dispute: Mapped["Dispute"] = relationship(
"Dispute",
back_populates="bonus_assignment",
uselist=False,
)
proof_files: Mapped[list["BonusAssignmentProof"]] = relationship(
"BonusAssignmentProof",
back_populates="bonus_assignment",
cascade="all, delete-orphan",
order_by="BonusAssignmentProof.order_index"
)

View File

@@ -8,16 +8,19 @@ from app.core.database import Base
class DisputeStatus(str, Enum): class DisputeStatus(str, Enum):
OPEN = "open" OPEN = "open"
PENDING_ADMIN = "pending_admin" # Voting ended, waiting for admin decision
RESOLVED_VALID = "valid" RESOLVED_VALID = "valid"
RESOLVED_INVALID = "invalid" RESOLVED_INVALID = "invalid"
class Dispute(Base): class Dispute(Base):
"""Dispute against a completed assignment's proof""" """Dispute against a completed assignment's or bonus assignment's proof"""
__tablename__ = "disputes" __tablename__ = "disputes"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True) # Either assignment_id OR bonus_assignment_id should be set (not both)
assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True, index=True)
bonus_assignment_id: Mapped[int | None] = mapped_column(ForeignKey("bonus_assignments.id", ondelete="CASCADE"), nullable=True, index=True)
raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
reason: Mapped[str] = mapped_column(Text, nullable=False) reason: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value) status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
@@ -26,6 +29,7 @@ class Dispute(Base):
# Relationships # Relationships
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute") assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute")
bonus_assignment: Mapped["BonusAssignment"] = relationship("BonusAssignment", back_populates="dispute")
raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id]) raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id])
comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan") 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") votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan")

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from sqlalchemy import String, DateTime, ForeignKey, Text from sqlalchemy import String, DateTime, ForeignKey, Text, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -12,6 +12,11 @@ class GameStatus(str, Enum):
REJECTED = "rejected" # Отклонена REJECTED = "rejected" # Отклонена
class GameType(str, Enum):
PLAYTHROUGH = "playthrough" # Прохождение игры
CHALLENGES = "challenges" # Челленджи
class Game(Base): class Game(Base):
__tablename__ = "games" __tablename__ = "games"
@@ -26,6 +31,15 @@ class Game(Base):
approved_by_id: Mapped[int | None] = 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) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Тип игры
game_type: Mapped[str] = mapped_column(String(20), default=GameType.CHALLENGES.value, nullable=False)
# Поля для типа "Прохождение" (заполняются только для playthrough)
playthrough_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
playthrough_description: Mapped[str | None] = mapped_column(Text, nullable=True)
playthrough_proof_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # screenshot, video, steam
playthrough_proof_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relationships # Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games") marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
proposed_by: Mapped["User"] = relationship( proposed_by: Mapped["User"] = relationship(
@@ -43,6 +57,12 @@ class Game(Base):
back_populates="game", back_populates="game",
cascade="all, delete-orphan" cascade="all, delete-orphan"
) )
# Assignments для прохождений (playthrough)
playthrough_assignments: Mapped[list["Assignment"]] = relationship(
"Assignment",
back_populates="game",
foreign_keys="Assignment.game_id"
)
@property @property
def is_approved(self) -> bool: def is_approved(self) -> bool:
@@ -51,3 +71,11 @@ class Game(Base):
@property @property
def is_pending(self) -> bool: def is_pending(self) -> bool:
return self.status == GameStatus.PENDING.value return self.status == GameStatus.PENDING.value
@property
def is_playthrough(self) -> bool:
return self.game_type == GameType.PLAYTHROUGH.value
@property
def is_challenges(self) -> bool:
return self.game_type == GameType.CHALLENGES.value

View File

@@ -31,6 +31,8 @@ class Marathon(Base):
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
end_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) auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships # Relationships

View File

@@ -34,6 +34,11 @@ class User(Base):
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True) banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Notification settings (all enabled by default)
notify_events: Mapped[bool] = mapped_column(Boolean, default=True)
notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True)
notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True)
# Relationships # Relationships
created_marathons: Mapped[list["Marathon"]] = relationship( created_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon", "Marathon",

View File

@@ -9,6 +9,8 @@ from app.schemas.user import (
PasswordChange, PasswordChange,
UserStats, UserStats,
UserProfilePublic, UserProfilePublic,
NotificationSettings,
NotificationSettingsUpdate,
) )
from app.schemas.marathon import ( from app.schemas.marathon import (
MarathonCreate, MarathonCreate,
@@ -46,6 +48,10 @@ from app.schemas.assignment import (
CompleteResult, CompleteResult,
DropResult, DropResult,
EventAssignmentResponse, EventAssignmentResponse,
BonusAssignmentResponse,
CompleteBonusAssignment,
BonusCompleteResult,
AvailableGamesCount,
) )
from app.schemas.activity import ( from app.schemas.activity import (
ActivityResponse, ActivityResponse,
@@ -83,6 +89,7 @@ from app.schemas.dispute import (
) )
from app.schemas.admin import ( from app.schemas.admin import (
BanUserRequest, BanUserRequest,
AdminResetPasswordRequest,
AdminUserResponse, AdminUserResponse,
AdminLogResponse, AdminLogResponse,
AdminLogsListResponse, AdminLogsListResponse,
@@ -110,6 +117,8 @@ __all__ = [
"PasswordChange", "PasswordChange",
"UserStats", "UserStats",
"UserProfilePublic", "UserProfilePublic",
"NotificationSettings",
"NotificationSettingsUpdate",
# Marathon # Marathon
"MarathonCreate", "MarathonCreate",
"MarathonUpdate", "MarathonUpdate",
@@ -143,6 +152,10 @@ __all__ = [
"CompleteResult", "CompleteResult",
"DropResult", "DropResult",
"EventAssignmentResponse", "EventAssignmentResponse",
"BonusAssignmentResponse",
"CompleteBonusAssignment",
"BonusCompleteResult",
"AvailableGamesCount",
# Activity # Activity
"ActivityResponse", "ActivityResponse",
"FeedResponse", "FeedResponse",
@@ -175,6 +188,7 @@ __all__ = [
"ReturnedAssignmentResponse", "ReturnedAssignmentResponse",
# Admin # Admin
"BanUserRequest", "BanUserRequest",
"AdminResetPasswordRequest",
"AdminUserResponse", "AdminUserResponse",
"AdminLogResponse", "AdminLogResponse",
"AdminLogsListResponse", "AdminLogsListResponse",

View File

@@ -9,6 +9,10 @@ class BanUserRequest(BaseModel):
banned_until: datetime | None = None # None = permanent ban banned_until: datetime | None = None # None = permanent ban
class AdminResetPasswordRequest(BaseModel):
new_password: str = Field(..., min_length=6, max_length=100)
class AdminUserResponse(BaseModel): class AdminUserResponse(BaseModel):
id: int id: int
login: str login: str
@@ -23,6 +27,10 @@ class AdminUserResponse(BaseModel):
banned_at: str | None = None banned_at: str | None = None
banned_until: str | None = None # None = permanent banned_until: str | None = None # None = permanent
ban_reason: str | None = None ban_reason: str | None = None
# Notification settings
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -1,10 +1,21 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
from app.schemas.game import GameResponse from app.schemas.game import GameResponse, GameShort, PlaythroughInfo
from app.schemas.challenge import ChallengeResponse from app.schemas.challenge import ChallengeResponse
class ProofFileResponse(BaseModel):
"""Информация о файле-доказательстве"""
id: int
file_type: str # image или video
order_index: int
created_at: datetime
class Config:
from_attributes = True
class AssignmentBase(BaseModel): class AssignmentBase(BaseModel):
pass pass
@@ -14,9 +25,28 @@ class CompleteAssignment(BaseModel):
comment: str | None = None comment: str | None = None
class AssignmentResponse(BaseModel): class BonusAssignmentResponse(BaseModel):
"""Ответ с информацией о бонусном челлендже"""
id: int id: int
challenge: ChallengeResponse challenge: ChallengeResponse
status: str # pending, completed
proof_url: str | None = None
proof_image_url: str | None = None # Legacy, for backward compatibility
proof_files: list[ProofFileResponse] = [] # Multiple uploaded files
proof_comment: str | None = None
points_earned: int = 0
completed_at: datetime | None = None
class Config:
from_attributes = True
class AssignmentResponse(BaseModel):
id: int
challenge: ChallengeResponse | None # None для playthrough
game: GameShort | None = None # Заполняется для playthrough
is_playthrough: bool = False
playthrough_info: PlaythroughInfo | None = None # Заполняется для playthrough
status: str status: str
proof_url: str | None = None proof_url: str | None = None
proof_comment: str | None = None proof_comment: str | None = None
@@ -25,6 +55,8 @@ class AssignmentResponse(BaseModel):
started_at: datetime started_at: datetime
completed_at: datetime | None = None completed_at: datetime | None = None
drop_penalty: int = 0 # Calculated penalty if dropped drop_penalty: int = 0 # Calculated penalty if dropped
bonus_challenges: list[BonusAssignmentResponse] = [] # Для playthrough
event_type: str | None = None # Event type if assignment was created during event
class Config: class Config:
from_attributes = True from_attributes = True
@@ -33,9 +65,13 @@ class AssignmentResponse(BaseModel):
class SpinResult(BaseModel): class SpinResult(BaseModel):
assignment_id: int assignment_id: int
game: GameResponse game: GameResponse
challenge: ChallengeResponse challenge: ChallengeResponse | None # None для playthrough
is_playthrough: bool = False
playthrough_info: PlaythroughInfo | None = None # Заполняется для playthrough
bonus_challenges: list[ChallengeResponse] = [] # Для playthrough - список доступных бонусных челленджей
can_drop: bool can_drop: bool
drop_penalty: int drop_penalty: int
event_type: str | None = None # Event type if active during spin
class CompleteResult(BaseModel): class CompleteResult(BaseModel):
@@ -60,3 +96,22 @@ class EventAssignmentResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class CompleteBonusAssignment(BaseModel):
"""Запрос на завершение бонусного челленджа"""
proof_url: str | None = None
comment: str | None = None
class BonusCompleteResult(BaseModel):
"""Результат завершения бонусного челленджа"""
bonus_assignment_id: int
points_earned: int
total_bonus_points: int # Сумма очков за все бонусные челленджи
class AvailableGamesCount(BaseModel):
"""Количество доступных игр для спина"""
available: int
total: int

View File

@@ -19,7 +19,7 @@ class ChallengeBase(BaseModel):
description: str = Field(..., min_length=1) description: str = Field(..., min_length=1)
type: ChallengeType type: ChallengeType
difficulty: Difficulty difficulty: Difficulty
points: int = Field(..., ge=1, le=500) points: int = Field(..., ge=1, le=1000)
estimated_time: int | None = Field(None, ge=1) # minutes estimated_time: int | None = Field(None, ge=1) # minutes
proof_type: ProofType proof_type: ProofType
proof_hint: str | None = None proof_hint: str | None = None
@@ -34,7 +34,7 @@ class ChallengeUpdate(BaseModel):
description: str | None = None description: str | None = None
type: ChallengeType | None = None type: ChallengeType | None = None
difficulty: Difficulty | None = None difficulty: Difficulty | None = None
points: int | None = Field(None, ge=1, le=500) points: int | None = Field(None, ge=1, le=1000)
estimated_time: int | None = None estimated_time: int | None = None
proof_type: ProofType | None = None proof_type: ProofType | None = None
proof_hint: str | None = None proof_hint: str | None = None

View File

@@ -1,8 +1,14 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
from app.schemas.challenge import ChallengeResponse from app.schemas.challenge import ChallengeResponse, GameShort
from app.schemas.assignment import ProofFileResponse
if TYPE_CHECKING:
from app.schemas.game import PlaythroughInfo
from app.schemas.assignment import BonusAssignmentResponse
class DisputeCreate(BaseModel): class DisputeCreate(BaseModel):
@@ -63,11 +69,15 @@ class DisputeResponse(BaseModel):
class AssignmentDetailResponse(BaseModel): class AssignmentDetailResponse(BaseModel):
"""Detailed assignment information with proofs and dispute""" """Detailed assignment information with proofs and dispute"""
id: int id: int
challenge: ChallengeResponse challenge: ChallengeResponse | None # None for playthrough
game: GameShort | None = None # For playthrough
is_playthrough: bool = False
playthrough_info: dict | None = None # For playthrough (description, points, proof_type, proof_hint)
participant: UserPublic participant: UserPublic
status: str status: str
proof_url: str | None # External URL (YouTube, etc.) proof_url: str | None # External URL (YouTube, etc.)
proof_image_url: str | None # Uploaded file URL proof_image_url: str | None # Uploaded file URL (legacy, for backward compatibility)
proof_files: list[ProofFileResponse] = [] # Multiple uploaded files
proof_comment: str | None proof_comment: str | None
points_earned: int points_earned: int
streak_at_completion: int | None streak_at_completion: int | None
@@ -75,6 +85,7 @@ class AssignmentDetailResponse(BaseModel):
completed_at: datetime | None completed_at: datetime | None
can_dispute: bool # True if <24h since completion and not own assignment can_dispute: bool # True if <24h since completion and not own assignment
dispute: DisputeResponse | None dispute: DisputeResponse | None
bonus_challenges: list[dict] | None = None # For playthrough
class Config: class Config:
from_attributes = True from_attributes = True
@@ -83,7 +94,11 @@ class AssignmentDetailResponse(BaseModel):
class ReturnedAssignmentResponse(BaseModel): class ReturnedAssignmentResponse(BaseModel):
"""Returned assignment that needs to be redone""" """Returned assignment that needs to be redone"""
id: int id: int
challenge: ChallengeResponse challenge: ChallengeResponse | None = None # For challenge assignments
is_playthrough: bool = False
game_id: int | None = None # For playthrough assignments
game_title: str | None = None
game_cover_url: str | None = None
original_completed_at: datetime original_completed_at: datetime
dispute_reason: str dispute_reason: str

View File

@@ -1,6 +1,9 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field, HttpUrl from typing import Self
from pydantic import BaseModel, Field, model_validator
from app.models.game import GameType
from app.models.challenge import ProofType
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
@@ -13,17 +16,48 @@ class GameBase(BaseModel):
class GameCreate(GameBase): class GameCreate(GameBase):
cover_url: str | None = None cover_url: str | None = None
# Тип игры
game_type: GameType = GameType.CHALLENGES
# Поля для типа "Прохождение"
playthrough_points: int | None = Field(None, ge=1, le=1000)
playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None
@model_validator(mode='after')
def validate_playthrough_fields(self) -> Self:
if self.game_type == GameType.PLAYTHROUGH:
if self.playthrough_points is None:
raise ValueError('playthrough_points обязателен для типа "Прохождение"')
if self.playthrough_description is None:
raise ValueError('playthrough_description обязателен для типа "Прохождение"')
if self.playthrough_proof_type is None:
raise ValueError('playthrough_proof_type обязателен для типа "Прохождение"')
return self
class GameUpdate(BaseModel): class GameUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=100) title: str | None = Field(None, min_length=1, max_length=100)
download_url: str | None = None download_url: str | None = None
genre: str | None = None genre: str | None = None
# Тип игры
game_type: GameType | None = None
# Поля для типа "Прохождение"
playthrough_points: int | None = Field(None, ge=1, le=1000)
playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None
class GameShort(BaseModel): class GameShort(BaseModel):
id: int id: int
title: str title: str
cover_url: str | None = None cover_url: str | None = None
download_url: str
game_type: str = "challenges"
class Config: class Config:
from_attributes = True from_attributes = True
@@ -38,5 +72,22 @@ class GameResponse(GameBase):
challenges_count: int = 0 challenges_count: int = 0
created_at: datetime created_at: datetime
# Тип игры
game_type: str = "challenges"
# Поля для типа "Прохождение"
playthrough_points: int | None = None
playthrough_description: str | None = None
playthrough_proof_type: str | None = None
playthrough_proof_hint: str | None = None
class Config: class Config:
from_attributes = True from_attributes = True
class PlaythroughInfo(BaseModel):
"""Информация о прохождении для игр типа playthrough"""
description: str
points: int
proof_type: str
proof_hint: str | None = None

View File

@@ -49,6 +49,7 @@ class MarathonResponse(MarathonBase):
is_public: bool is_public: bool
game_proposal_mode: str game_proposal_mode: str
auto_events_enabled: bool auto_events_enabled: bool
cover_url: str | None
start_date: datetime | None start_date: datetime | None
end_date: datetime | None end_date: datetime | None
participants_count: int participants_count: int
@@ -69,6 +70,7 @@ class MarathonListItem(BaseModel):
title: str title: str
status: str status: str
is_public: bool is_public: bool
cover_url: str | None
participants_count: int participants_count: int
start_date: datetime | None start_date: datetime | None
end_date: datetime | None end_date: datetime | None
@@ -87,6 +89,7 @@ class MarathonPublicInfo(BaseModel):
title: str title: str
description: str | None description: str | None
status: str status: str
cover_url: str | None
participants_count: int participants_count: int
creator_nickname: str creator_nickname: str

View File

@@ -47,6 +47,10 @@ class UserPrivate(UserPublic):
telegram_username: str | None = None telegram_username: str | None = None
telegram_first_name: str | None = None telegram_first_name: str | None = None
telegram_last_name: str | None = None telegram_last_name: str | None = None
# Notification settings
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
@@ -83,3 +87,20 @@ class UserProfilePublic(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class NotificationSettings(BaseModel):
"""Notification settings for Telegram bot"""
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
class Config:
from_attributes = True
class NotificationSettingsUpdate(BaseModel):
"""Update notification settings"""
notify_events: bool | None = None
notify_disputes: bool | None = None
notify_moderation: bool | None = None

View File

@@ -1,5 +1,5 @@
""" """
Dispute Scheduler for automatic dispute resolution after 24 hours. Dispute Scheduler - marks disputes as pending admin review after 24 hours.
""" """
import asyncio import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -8,16 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
from app.services.disputes import dispute_service from app.services.telegram_notifier import telegram_notifier
# Configuration # Configuration
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours DISPUTE_WINDOW_HOURS = 24 # Disputes need admin decision after 24 hours
class DisputeScheduler: class DisputeScheduler:
"""Background scheduler for automatic dispute resolution.""" """Background scheduler that marks expired disputes for admin review."""
def __init__(self): def __init__(self):
self._running = False self._running = False
@@ -55,7 +55,7 @@ class DisputeScheduler:
await asyncio.sleep(CHECK_INTERVAL_SECONDS) await asyncio.sleep(CHECK_INTERVAL_SECONDS)
async def _process_expired_disputes(self, db: AsyncSession) -> None: async def _process_expired_disputes(self, db: AsyncSession) -> None:
"""Process and resolve expired disputes.""" """Mark expired disputes as pending admin review."""
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS) cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
# Find all open disputes that have expired # Find all open disputes that have expired
@@ -63,7 +63,6 @@ class DisputeScheduler:
select(Dispute) select(Dispute)
.options( .options(
selectinload(Dispute.votes), selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
) )
.where( .where(
Dispute.status == DisputeStatus.OPEN.value, Dispute.status == DisputeStatus.OPEN.value,
@@ -74,15 +73,25 @@ class DisputeScheduler:
for dispute in expired_disputes: for dispute in expired_disputes:
try: try:
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute( # Count votes for logging
db, dispute.id 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)
# Mark as pending admin decision
dispute.status = DisputeStatus.PENDING_ADMIN.value
print( print(
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: " f"[DisputeScheduler] Dispute {dispute.id} marked as pending admin "
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})" f"(recommendation: {'invalid' if votes_invalid > votes_valid else 'valid'}, "
f"votes: {votes_valid} valid, {votes_invalid} invalid)"
) )
except Exception as e: except Exception as e:
print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}") print(f"[DisputeScheduler] Failed to process dispute {dispute.id}: {e}")
if expired_disputes:
await db.commit()
# Notify admins about pending disputes
await telegram_notifier.notify_admin_disputes_pending(db, len(expired_disputes))
# Global scheduler instance # Global scheduler instance

View File

@@ -23,12 +23,15 @@ class DisputeService:
Returns: Returns:
Tuple of (result_status, votes_valid, votes_invalid) Tuple of (result_status, votes_valid, votes_invalid)
""" """
# Get dispute with votes and assignment from app.models import BonusAssignment, BonusAssignmentStatus
# Get dispute with votes, assignment and bonus_assignment
result = await db.execute( result = await db.execute(
select(Dispute) select(Dispute)
.options( .options(
selectinload(Dispute.votes), selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant), selectinload(Dispute.assignment).selectinload(Assignment.participant),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
) )
.where(Dispute.id == dispute_id) .where(Dispute.id == dispute_id)
) )
@@ -46,8 +49,11 @@ class DisputeService:
# Determine result: tie goes to the accused (valid) # Determine result: tie goes to the accused (valid)
if votes_invalid > votes_valid: if votes_invalid > votes_valid:
# Proof is invalid - mark assignment as RETURNED # Proof is invalid
result_status = DisputeStatus.RESOLVED_INVALID.value result_status = DisputeStatus.RESOLVED_INVALID.value
if dispute.bonus_assignment_id:
await self._handle_invalid_bonus_proof(db, dispute)
else:
await self._handle_invalid_proof(db, dispute) await self._handle_invalid_proof(db, dispute)
else: else:
# Proof is valid (or tie) # Proof is valid (or tie)
@@ -60,7 +66,11 @@ class DisputeService:
await db.commit() await db.commit()
# Send Telegram notification about dispute resolution # Send Telegram notification about dispute resolution
await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value) is_invalid = result_status == DisputeStatus.RESOLVED_INVALID.value
if dispute.bonus_assignment_id:
await self._notify_bonus_dispute_resolved(db, dispute, is_invalid)
else:
await self._notify_dispute_resolved(db, dispute, is_invalid)
return result_status, votes_valid, votes_invalid return result_status, votes_valid, votes_invalid
@@ -72,12 +82,13 @@ class DisputeService:
) -> None: ) -> None:
"""Send notification about dispute resolution to the assignment owner.""" """Send notification about dispute resolution to the assignment owner."""
try: try:
# Get assignment with challenge and marathon info # Get assignment with challenge/game and marathon info
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.participant), selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
) )
.where(Assignment.id == dispute.assignment_id) .where(Assignment.id == dispute.assignment_id)
) )
@@ -86,12 +97,19 @@ class DisputeService:
return return
participant = assignment.participant participant = assignment.participant
# Get title and marathon_id based on assignment type
if assignment.is_playthrough:
title = f"Прохождение: {assignment.game.title}"
marathon_id = assignment.game.marathon_id
else:
challenge = assignment.challenge challenge = assignment.challenge
game = challenge.game if challenge else None title = challenge.title if challenge else "Unknown"
marathon_id = challenge.game.marathon_id if challenge and challenge.game else 0
# Get marathon # Get marathon
result = await db.execute( result = await db.execute(
select(Marathon).where(Marathon.id == game.marathon_id if game else 0) select(Marathon).where(Marathon.id == marathon_id)
) )
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()
@@ -100,12 +118,86 @@ class DisputeService:
db, db,
user_id=participant.user_id, user_id=participant.user_id,
marathon_title=marathon.title, marathon_title=marathon.title,
challenge_title=challenge.title if challenge else "Unknown", challenge_title=title,
is_valid=is_valid is_valid=is_valid
) )
except Exception as e: except Exception as e:
print(f"[DisputeService] Failed to send notification: {e}") print(f"[DisputeService] Failed to send notification: {e}")
async def _notify_bonus_dispute_resolved(
self,
db: AsyncSession,
dispute: Dispute,
is_invalid: bool
) -> None:
"""Send notification about bonus dispute resolution to the assignment owner."""
try:
bonus_assignment = dispute.bonus_assignment
main_assignment = bonus_assignment.main_assignment
participant = main_assignment.participant
# Get marathon info
result = await db.execute(
select(Game).where(Game.id == main_assignment.game_id)
)
game = result.scalar_one_or_none()
if not game:
return
result = await db.execute(
select(Marathon).where(Marathon.id == game.marathon_id)
)
marathon = result.scalar_one_or_none()
# Get challenge title
result = await db.execute(
select(Challenge).where(Challenge.id == bonus_assignment.challenge_id)
)
challenge = result.scalar_one_or_none()
title = f"Бонус: {challenge.title}" if challenge else "Бонусный челлендж"
if marathon and participant:
await telegram_notifier.notify_dispute_resolved(
db,
user_id=participant.user_id,
marathon_title=marathon.title,
challenge_title=title,
is_valid=not is_invalid
)
except Exception as e:
print(f"[DisputeService] Failed to send bonus dispute notification: {e}")
async def _handle_invalid_bonus_proof(self, db: AsyncSession, dispute: Dispute) -> None:
"""
Handle the case when bonus proof is determined to be invalid.
- Reset bonus assignment to PENDING
- If main playthrough was already completed, subtract bonus points from participant
"""
from app.models import BonusAssignment, BonusAssignmentStatus, AssignmentStatus
bonus_assignment = dispute.bonus_assignment
main_assignment = bonus_assignment.main_assignment
participant = main_assignment.participant
# If main playthrough was already completed, we need to subtract the bonus points
if main_assignment.status == AssignmentStatus.COMPLETED.value:
points_to_subtract = bonus_assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Also reduce the points_earned on the main assignment
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
print(f"[DisputeService] Subtracted {points_to_subtract} points from participant {participant.id}")
# Reset bonus assignment
bonus_assignment.status = BonusAssignmentStatus.PENDING.value
bonus_assignment.proof_path = None
bonus_assignment.proof_url = None
bonus_assignment.proof_comment = None
bonus_assignment.points_earned = 0
bonus_assignment.completed_at = None
print(f"[DisputeService] Bonus assignment {bonus_assignment.id} reset to PENDING due to invalid dispute")
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None: async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
""" """
Handle the case when proof is determined to be invalid. Handle the case when proof is determined to be invalid.
@@ -113,7 +205,10 @@ class DisputeService:
- Mark assignment as RETURNED - Mark assignment as RETURNED
- Subtract points from participant - Subtract points from participant
- Reset streak if it was affected - Reset streak if it was affected
- For playthrough: also reset bonus assignments
""" """
from app.models import BonusAssignment, BonusAssignmentStatus
assignment = dispute.assignment assignment = dispute.assignment
participant = assignment.participant participant = assignment.participant
@@ -121,22 +216,45 @@ class DisputeService:
points_to_subtract = assignment.points_earned points_to_subtract = assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract) participant.total_points = max(0, participant.total_points - points_to_subtract)
# Reset streak - the completion was invalid so streak should be broken
participant.current_streak = 0
# Reset assignment # Reset assignment
assignment.status = AssignmentStatus.RETURNED.value assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0 assignment.points_earned = 0
# Keep proof data so it can be reviewed # Keep proof data so it can be reviewed
# For playthrough: reset all bonus assignments
if assignment.is_playthrough:
result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
)
bonus_assignments = result.scalars().all()
for ba in bonus_assignments:
ba.status = BonusAssignmentStatus.PENDING.value
ba.proof_path = None
ba.proof_url = None
ba.proof_comment = None
ba.points_earned = 0
ba.completed_at = None
print(f"[DisputeService] Reset {len(bonus_assignments)} bonus assignments for playthrough {assignment.id}")
print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, " print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, "
f"subtracted {points_to_subtract} points from participant {participant.id}") 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]: async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
"""Get all open disputes older than specified hours""" """Get all open disputes (both regular and bonus) older than specified hours"""
from datetime import timedelta from datetime import timedelta
from app.models import BonusAssignment
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours) cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute( result = await db.execute(
select(Dispute) select(Dispute)
.options(
selectinload(Dispute.assignment),
selectinload(Dispute.bonus_assignment),
)
.where( .where(
Dispute.status == DisputeStatus.OPEN.value, Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time, Dispute.created_at < cutoff_time,

View File

@@ -47,6 +47,8 @@ class EventService:
created_by_id: int | None = None, created_by_id: int | None = None,
duration_minutes: int | None = None, duration_minutes: int | None = None,
challenge_id: int | None = None, challenge_id: int | None = None,
game_id: int | None = None,
is_playthrough: bool = False,
) -> Event: ) -> Event:
"""Start a new event""" """Start a new event"""
# Check no active event # Check no active event
@@ -63,7 +65,11 @@ class EventService:
# Build event data # Build event data
data = {} data = {}
if event_type == EventType.COMMON_ENEMY.value and challenge_id: if event_type == EventType.COMMON_ENEMY.value and (challenge_id or game_id):
if is_playthrough and game_id:
data["game_id"] = game_id
data["is_playthrough"] = True
else:
data["challenge_id"] = challenge_id data["challenge_id"] = challenge_id
data["completions"] = [] # Track who completed and when data["completions"] = [] # Track who completed and when
@@ -79,9 +85,11 @@ class EventService:
db.add(event) db.add(event)
await db.flush() # Get event.id before committing await db.flush() # Get event.id before committing
# Auto-assign challenge to all participants for Common Enemy # Auto-assign challenge/playthrough to all participants for Common Enemy
if event_type == EventType.COMMON_ENEMY.value and challenge_id: if event_type == EventType.COMMON_ENEMY.value and (challenge_id or game_id):
await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id) await self._assign_common_enemy_to_all(
db, marathon_id, event.id, challenge_id, game_id, is_playthrough
)
await db.commit() await db.commit()
await db.refresh(event) await db.refresh(event)
@@ -105,7 +113,9 @@ class EventService:
db: AsyncSession, db: AsyncSession,
marathon_id: int, marathon_id: int,
event_id: int, event_id: int,
challenge_id: int, challenge_id: int | None,
game_id: int | None = None,
is_playthrough: bool = False,
) -> None: ) -> None:
"""Create event assignments for all participants in the marathon""" """Create event assignments for all participants in the marathon"""
# Get all participants # Get all participants
@@ -118,7 +128,9 @@ class EventService:
for participant in participants: for participant in participants:
assignment = Assignment( assignment = Assignment(
participant_id=participant.id, participant_id=participant.id,
challenge_id=challenge_id, challenge_id=challenge_id if not is_playthrough else None,
game_id=game_id if is_playthrough else None,
is_playthrough=is_playthrough,
status=AssignmentStatus.ACTIVE.value, status=AssignmentStatus.ACTIVE.value,
event_type=EventType.COMMON_ENEMY.value, event_type=EventType.COMMON_ENEMY.value,
is_event_assignment=True, is_event_assignment=True,
@@ -290,6 +302,30 @@ class EventService:
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_common_enemy_game(
self,
db: AsyncSession,
event: Event
):
"""Get the playthrough game for common enemy event (if it's a playthrough)"""
from app.models import Game
if event.type != EventType.COMMON_ENEMY.value:
return None
data = event.data or {}
if not data.get("is_playthrough"):
return None
game_id = data.get("game_id")
if not game_id:
return None
result = await db.execute(
select(Game).where(Game.id == game_id)
)
return result.scalar_one_or_none()
def get_time_remaining(self, event: Event | None) -> int | None: def get_time_remaining(self, event: Event | None) -> int | None:
"""Get remaining time in seconds for an event""" """Get remaining time in seconds for an event"""
if not event or not event.end_time: if not event or not event.end_time:

View File

@@ -15,7 +15,7 @@ from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
StorageFolder = Literal["avatars", "covers", "proofs"] StorageFolder = Literal["avatars", "covers", "proofs", "bonus_proofs"]
class StorageService: class StorageService:

View File

@@ -54,6 +54,209 @@ class TelegramNotifier:
logger.error(f"Error sending Telegram message: {e}") logger.error(f"Error sending Telegram message: {e}")
return False return False
async def send_photo(
self,
chat_id: int,
photo: bytes,
caption: str | None = None,
parse_mode: str = "HTML",
filename: str = "photo.jpg",
content_type: str = "image/jpeg"
) -> bool:
"""Send a photo to a Telegram chat."""
if not self.bot_token:
logger.warning("Telegram bot token not configured")
return False
try:
timeout = httpx.Timeout(connect=30.0, read=60.0, write=120.0, pool=30.0)
async with httpx.AsyncClient(timeout=timeout) as client:
data = {"chat_id": str(chat_id)}
if caption:
data["caption"] = caption
data["parse_mode"] = parse_mode
files = {"photo": (filename, photo, content_type)}
response = await client.post(
f"{self.api_url}/sendPhoto",
data=data,
files=files,
)
if response.status_code == 200:
return True
else:
logger.error(f"Failed to send photo to {chat_id}: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Error sending Telegram photo to {chat_id}: {type(e).__name__}: {e}")
return False
async def send_video(
self,
chat_id: int,
video: bytes,
caption: str | None = None,
parse_mode: str = "HTML",
filename: str = "video.mp4",
content_type: str = "video/mp4"
) -> bool:
"""Send a video to a Telegram chat."""
if not self.bot_token:
logger.warning("Telegram bot token not configured")
return False
try:
timeout = httpx.Timeout(connect=30.0, read=120.0, write=300.0, pool=30.0)
async with httpx.AsyncClient(timeout=timeout) as client:
data = {"chat_id": str(chat_id)}
if caption:
data["caption"] = caption
data["parse_mode"] = parse_mode
files = {"video": (filename, video, content_type)}
response = await client.post(
f"{self.api_url}/sendVideo",
data=data,
files=files,
)
if response.status_code == 200:
return True
else:
logger.error(f"Failed to send video to {chat_id}: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Error sending Telegram video to {chat_id}: {type(e).__name__}: {e}")
return False
async def send_media_group(
self,
chat_id: int,
media_items: list[dict],
caption: str | None = None,
parse_mode: str = "HTML"
) -> bool:
"""
Send a media group (multiple photos/videos) to a Telegram chat.
media_items: list of dicts with keys:
- type: "photo" or "video"
- data: bytes
- filename: str
- content_type: str
"""
if not self.bot_token:
logger.warning("Telegram bot token not configured")
return False
if not media_items:
return False
try:
import json
# Use longer timeouts for file uploads
timeout = httpx.Timeout(
connect=30.0,
read=120.0,
write=300.0, # 5 minutes for uploading files
pool=30.0
)
async with httpx.AsyncClient(timeout=timeout) as client:
# Build media array and files dict
media_array = []
files_dict = {}
for i, item in enumerate(media_items):
attach_name = f"media{i}"
media_obj = {
"type": item["type"],
"media": f"attach://{attach_name}"
}
# Only first item gets the caption
if i == 0 and caption:
media_obj["caption"] = caption
media_obj["parse_mode"] = parse_mode
media_array.append(media_obj)
files_dict[attach_name] = (
item.get("filename", f"file{i}"),
item["data"],
item.get("content_type", "application/octet-stream")
)
data = {
"chat_id": str(chat_id),
"media": json.dumps(media_array)
}
logger.info(f"Sending media group to {chat_id}: {len(media_items)} files")
response = await client.post(
f"{self.api_url}/sendMediaGroup",
data=data,
files=files_dict,
)
if response.status_code == 200:
logger.info(f"Successfully sent media group to {chat_id}")
return True
else:
logger.error(f"Failed to send media group to {chat_id}: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Error sending Telegram media group to {chat_id}: {type(e).__name__}: {e}")
return False
async def send_media_message(
self,
chat_id: int,
text: str | None = None,
media_type: str | None = None,
media_data: bytes | None = None,
media_items: list[dict] | None = None,
parse_mode: str = "HTML"
) -> bool:
"""
Send a message with optional media.
For single media: use media_type and media_data
For multiple media: use media_items list with dicts containing:
- type: "photo" or "video"
- data: bytes
- filename: str (optional)
- content_type: str (optional)
"""
# Multiple media - use media group
if media_items and len(media_items) > 1:
return await self.send_media_group(chat_id, media_items, text, parse_mode)
# Single media from media_items
if media_items and len(media_items) == 1:
item = media_items[0]
if item["type"] == "photo":
return await self.send_photo(
chat_id, item["data"], text, parse_mode,
item.get("filename", "photo.jpg"),
item.get("content_type", "image/jpeg")
)
elif item["type"] == "video":
return await self.send_video(
chat_id, item["data"], text, parse_mode,
item.get("filename", "video.mp4"),
item.get("content_type", "video/mp4")
)
# Legacy single media support
if media_data and media_type:
if media_type == "photo":
return await self.send_photo(chat_id, media_data, text, parse_mode)
elif media_type == "video":
return await self.send_video(chat_id, media_data, text, parse_mode)
if text:
return await self.send_message(chat_id, text, parse_mode)
return False
async def notify_user( async def notify_user(
self, self,
db: AsyncSession, db: AsyncSession,
@@ -83,9 +286,15 @@ class TelegramNotifier:
db: AsyncSession, db: AsyncSession,
marathon_id: int, marathon_id: int,
message: str, message: str,
exclude_user_id: int | None = None exclude_user_id: int | None = None,
check_setting: str | None = None
) -> int: ) -> int:
"""Send notification to all marathon participants with linked Telegram.""" """Send notification to all marathon participants with linked Telegram.
Args:
check_setting: If provided, only send to users with this setting enabled.
Options: 'notify_events', 'notify_disputes', 'notify_moderation'
"""
result = await db.execute( result = await db.execute(
select(User) select(User)
.join(Participant, Participant.user_id == User.id) .join(Participant, Participant.user_id == User.id)
@@ -100,6 +309,10 @@ class TelegramNotifier:
for user in users: for user in users:
if exclude_user_id and user.id == exclude_user_id: if exclude_user_id and user.id == exclude_user_id:
continue continue
# Check notification setting if specified
if check_setting and not getattr(user, check_setting, True):
logger.info(f"[Notify] Skipping user {user.nickname} - {check_setting} is disabled")
continue
if await self.send_message(user.telegram_id, message): if await self.send_message(user.telegram_id, message):
sent_count += 1 sent_count += 1
@@ -113,7 +326,7 @@ class TelegramNotifier:
event_type: str, event_type: str,
marathon_title: str marathon_title: str
) -> int: ) -> int:
"""Notify participants about event start.""" """Notify participants about event start (respects notify_events setting)."""
event_messages = { event_messages = {
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!", "golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!", "jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
@@ -128,7 +341,9 @@ class TelegramNotifier:
f"📌 Новое событие в «{marathon_title}»!" f"📌 Новое событие в «{marathon_title}»!"
) )
return await self.notify_marathon_participants(db, marathon_id, message) return await self.notify_marathon_participants(
db, marathon_id, message, check_setting='notify_events'
)
async def notify_event_end( async def notify_event_end(
self, self,
@@ -137,7 +352,7 @@ class TelegramNotifier:
event_type: str, event_type: str,
marathon_title: str marathon_title: str
) -> int: ) -> int:
"""Notify participants about event end.""" """Notify participants about event end (respects notify_events setting)."""
event_names = { event_names = {
"golden_hour": "Golden Hour", "golden_hour": "Golden Hour",
"jackpot": "Jackpot", "jackpot": "Jackpot",
@@ -150,7 +365,9 @@ class TelegramNotifier:
event_name = event_names.get(event_type, "Событие") event_name = event_names.get(event_type, "Событие")
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён" message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
return await self.notify_marathon_participants(db, marathon_id, message) return await self.notify_marathon_participants(
db, marathon_id, message, check_setting='notify_events'
)
async def notify_marathon_start( async def notify_marathon_start(
self, self,
@@ -186,7 +403,14 @@ class TelegramNotifier:
challenge_title: str, challenge_title: str,
assignment_id: int assignment_id: int
) -> bool: ) -> bool:
"""Notify user about dispute raised on their assignment.""" """Notify user about dispute raised on their assignment (respects notify_disputes setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_disputes:
logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled")
return False
logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}") 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}" dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
@@ -227,7 +451,14 @@ class TelegramNotifier:
challenge_title: str, challenge_title: str,
is_valid: bool is_valid: bool
) -> bool: ) -> bool:
"""Notify user about dispute resolution.""" """Notify user about dispute resolution (respects notify_disputes setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_disputes:
logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled")
return False
if is_valid: if is_valid:
message = ( message = (
f"❌ <b>Спор признан обоснованным</b>\n\n" f"❌ <b>Спор признан обоснованным</b>\n\n"
@@ -251,7 +482,14 @@ class TelegramNotifier:
marathon_title: str, marathon_title: str,
game_title: str game_title: str
) -> bool: ) -> bool:
"""Notify user that their proposed game was approved.""" """Notify user that their proposed game was approved (respects notify_moderation setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_moderation:
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
return False
message = ( message = (
f"✅ <b>Твоя игра одобрена!</b>\n\n" f"✅ <b>Твоя игра одобрена!</b>\n\n"
f"Марафон: {marathon_title}\n" f"Марафон: {marathon_title}\n"
@@ -267,7 +505,14 @@ class TelegramNotifier:
marathon_title: str, marathon_title: str,
game_title: str game_title: str
) -> bool: ) -> bool:
"""Notify user that their proposed game was rejected.""" """Notify user that their proposed game was rejected (respects notify_moderation setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_moderation:
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
return False
message = ( message = (
f"❌ <b>Твоя игра отклонена</b>\n\n" f"❌ <b>Твоя игра отклонена</b>\n\n"
f"Марафон: {marathon_title}\n" f"Марафон: {marathon_title}\n"
@@ -284,7 +529,14 @@ class TelegramNotifier:
game_title: str, game_title: str,
challenge_title: str challenge_title: str
) -> bool: ) -> bool:
"""Notify user that their proposed challenge was approved.""" """Notify user that their proposed challenge was approved (respects notify_moderation setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_moderation:
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
return False
message = ( message = (
f"✅ <b>Твой челлендж одобрен!</b>\n\n" f"✅ <b>Твой челлендж одобрен!</b>\n\n"
f"Марафон: {marathon_title}\n" f"Марафон: {marathon_title}\n"
@@ -302,7 +554,14 @@ class TelegramNotifier:
game_title: str, game_title: str,
challenge_title: str challenge_title: str
) -> bool: ) -> bool:
"""Notify user that their proposed challenge was rejected.""" """Notify user that their proposed challenge was rejected (respects notify_moderation setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_moderation:
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
return False
message = ( message = (
f"❌ <b>Твой челлендж отклонён</b>\n\n" f"❌ <b>Твой челлендж отклонён</b>\n\n"
f"Марафон: {marathon_title}\n" f"Марафон: {marathon_title}\n"
@@ -312,6 +571,43 @@ class TelegramNotifier:
) )
return await self.notify_user(db, user_id, message) return await self.notify_user(db, user_id, message)
async def notify_admin_disputes_pending(
self,
db: AsyncSession,
count: int
) -> bool:
"""Notify admin about disputes waiting for decision."""
if not settings.TELEGRAM_ADMIN_ID:
logger.warning("[Notify] No TELEGRAM_ADMIN_ID configured")
return False
admin_url = f"{settings.FRONTEND_URL}/admin/disputes"
use_inline_button = admin_url.startswith("https://")
if use_inline_button:
message = (
f"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\n\n"
f"Голосование завершено, требуется ваше решение."
)
reply_markup = {
"inline_keyboard": [[
{"text": "Открыть оспаривания", "url": admin_url}
]]
}
else:
message = (
f"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\n\n"
f"Голосование завершено, требуется ваше решение.\n\n"
f"🔗 {admin_url}"
)
reply_markup = None
return await self.send_message(
int(settings.TELEGRAM_ADMIN_ID),
message,
reply_markup=reply_markup
)
# Global instance # Global instance
telegram_notifier = TelegramNotifier() telegram_notifier = TelegramNotifier()

View File

@@ -79,6 +79,8 @@ def create_backup() -> tuple[str, bytes]:
config.DB_NAME, config.DB_NAME,
"--no-owner", "--no-owner",
"--no-acl", "--no-acl",
"--clean", # Add DROP commands before CREATE
"--if-exists", # Use IF EXISTS with DROP commands
"-F", "-F",
"p", # plain SQL format "p", # plain SQL format
] ]

View File

@@ -4,7 +4,8 @@ Restore PostgreSQL database from S3 backup.
Usage: Usage:
python restore.py - List available backups python restore.py - List available backups
python restore.py <filename> - Restore from specific backup python restore.py <filename> - Restore from backup (cleans DB first)
python restore.py <filename> --no-clean - Restore without cleaning DB first
""" """
import gzip import gzip
import os import os
@@ -62,7 +63,48 @@ def list_backups(s3_client) -> list[tuple[str, float, str]]:
return [] return []
def restore_backup(s3_client, filename: str) -> None: def clean_database() -> None:
"""Drop and recreate public schema to clean the database."""
print("Cleaning database (dropping and recreating public schema)...")
env = os.environ.copy()
env["PGPASSWORD"] = config.DB_PASSWORD
# Drop and recreate public schema
clean_sql = b"""
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO public;
"""
cmd = [
"psql",
"-h",
config.DB_HOST,
"-p",
config.DB_PORT,
"-U",
config.DB_USER,
"-d",
config.DB_NAME,
]
result = subprocess.run(
cmd,
env=env,
input=clean_sql,
capture_output=True,
)
if result.returncode != 0:
stderr = result.stderr.decode()
if "ERROR" in stderr:
raise Exception(f"Database cleanup failed: {stderr}")
print("Database cleaned successfully!")
def restore_backup(s3_client, filename: str, clean_first: bool = True) -> None:
"""Download and restore backup.""" """Download and restore backup."""
key = f"{config.S3_BACKUP_PREFIX}{filename}" key = f"{config.S3_BACKUP_PREFIX}{filename}"
@@ -79,6 +121,10 @@ def restore_backup(s3_client, filename: str) -> None:
print("Decompressing...") print("Decompressing...")
sql_data = gzip.decompress(compressed_data) sql_data = gzip.decompress(compressed_data)
# Clean database before restore if requested
if clean_first:
clean_database()
print(f"Restoring to database {config.DB_NAME}...") print(f"Restoring to database {config.DB_NAME}...")
# Build psql command # Build psql command
@@ -124,20 +170,32 @@ def main() -> int:
s3_client = create_s3_client() s3_client = create_s3_client()
if len(sys.argv) < 2: # Parse arguments
args = sys.argv[1:]
clean_first = True
if "--no-clean" in args:
clean_first = False
args.remove("--no-clean")
if len(args) < 1:
# List available backups # List available backups
backups = list_backups(s3_client) backups = list_backups(s3_client)
if backups: if backups:
print(f"\nTo restore, run: python restore.py <filename>") print(f"\nTo restore, run: python restore.py <filename>")
print("Add --no-clean to skip database cleanup before restore")
else: else:
print("No backups found.") print("No backups found.")
return 0 return 0
filename = sys.argv[1] filename = args[0]
# Confirm restore # Confirm restore
print(f"WARNING: This will restore database from {filename}") print(f"WARNING: This will restore database from {filename}")
print("This may overwrite existing data!") if clean_first:
print("Database will be CLEANED (all existing data will be DELETED)!")
else:
print("Database will NOT be cleaned (may cause conflicts with existing data)")
print() print()
confirm = input("Type 'yes' to continue: ") confirm = input("Type 'yes' to continue: ")
@@ -147,7 +205,7 @@ def main() -> int:
return 0 return 0
try: try:
restore_backup(s3_client, filename) restore_backup(s3_client, filename, clean_first=clean_first)
return 0 return 0
except Exception as e: except Exception as e:
print(f"Restore failed: {e}") print(f"Restore failed: {e}")

View File

@@ -3,7 +3,7 @@ from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from keyboards.main_menu import get_main_menu from keyboards.main_menu import get_main_menu
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard, get_settings_keyboard
from services.api_client import api_client from services.api_client import api_client
router = Router() router = Router()
@@ -197,15 +197,66 @@ async def cmd_settings(message: Message):
) )
return return
# Get current notification settings
settings = await api_client.get_notification_settings(message.from_user.id)
if not settings:
settings = {"notify_events": True, "notify_disputes": True, "notify_moderation": True}
await message.answer( await message.answer(
"<b>⚙️ Настройки</b>\n\n" "<b>⚙️ Настройки уведомлений</b>\n\n"
"Управление уведомлениями будет доступно в следующем обновлении.\n\n" "Нажми на категорию, чтобы включить/выключить:\n\n"
"Сейчас ты получаешь все уведомления:\n" "✅ — уведомления включены\n"
"• 🌟 События (Golden Hour, Jackpot и др.)\n" "❌ — уведомления выключены\n\n"
"• 🚀 Старт/финиш марафонов\n" "<i>Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.</i>",
"• ⚠️ Споры по заданиям\n\n" reply_markup=get_settings_keyboard(settings)
"Команды:\n" )
"/unlink - Отвязать аккаунт\n"
"/status - Проверить привязку",
@router.callback_query(F.data.startswith("toggle:"))
async def toggle_notification(callback: CallbackQuery):
"""Toggle notification setting."""
setting_name = callback.data.split(":")[1]
# Get current settings
current_settings = await api_client.get_notification_settings(callback.from_user.id)
if not current_settings:
await callback.answer("Не удалось загрузить настройки", show_alert=True)
return
# Toggle the setting
current_value = current_settings.get(setting_name, True)
new_value = not current_value
# Update on backend
result = await api_client.update_notification_settings(
callback.from_user.id,
{setting_name: new_value}
)
if not result or result.get("error"):
await callback.answer("Не удалось сохранить настройки", show_alert=True)
return
# Update keyboard with new values
await callback.message.edit_reply_markup(
reply_markup=get_settings_keyboard(result)
)
status = "включены" if new_value else "выключены"
setting_names = {
"notify_events": "События",
"notify_disputes": "Споры",
"notify_moderation": "Модерация"
}
await callback.answer(f"{setting_names.get(setting_name, setting_name)}: {status}")
@router.callback_query(F.data == "back_to_menu")
async def back_to_menu(callback: CallbackQuery):
"""Go back to main menu from settings."""
await callback.message.delete()
await callback.message.answer(
"Главное меню",
reply_markup=get_main_menu() reply_markup=get_main_menu()
) )
await callback.answer()

View File

@@ -40,3 +40,45 @@ def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
] ]
return InlineKeyboardMarkup(inline_keyboard=buttons) return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_settings_keyboard(settings: dict) -> InlineKeyboardMarkup:
"""Create keyboard for notification settings."""
# Get current values with defaults
notify_events = settings.get("notify_events", True)
notify_disputes = settings.get("notify_disputes", True)
notify_moderation = settings.get("notify_moderation", True)
# Status indicators
events_status = "" if notify_events else ""
disputes_status = "" if notify_disputes else ""
moderation_status = "" if notify_moderation else ""
buttons = [
[
InlineKeyboardButton(
text=f"{events_status} События (Golden Hour, Jackpot...)",
callback_data="toggle:notify_events"
)
],
[
InlineKeyboardButton(
text=f"{disputes_status} Споры",
callback_data="toggle:notify_disputes"
)
],
[
InlineKeyboardButton(
text=f"{moderation_status} Модерация (игры/челленджи)",
callback_data="toggle:notify_moderation"
)
],
[
InlineKeyboardButton(
text="◀️ Назад",
callback_data="back_to_menu"
)
]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)

View File

@@ -124,6 +124,22 @@ class APIClient:
"""Get user's overall statistics.""" """Get user's overall statistics."""
return await self._request("GET", f"/telegram/stats/{telegram_id}") return await self._request("GET", f"/telegram/stats/{telegram_id}")
async def get_notification_settings(self, telegram_id: int) -> dict[str, Any] | None:
"""Get user's notification settings."""
return await self._request("GET", f"/telegram/notifications/{telegram_id}")
async def update_notification_settings(
self,
telegram_id: int,
settings: dict[str, bool]
) -> dict[str, Any] | None:
"""Update user's notification settings."""
return await self._request(
"PATCH",
f"/telegram/notifications/{telegram_id}",
json=settings
)
async def close(self): async def close(self):
"""Close the HTTP session.""" """Close the HTTP session."""
if self._session and not self._session.closed: if self._session and not self._session.closed:

View File

@@ -9,7 +9,7 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "5433:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U marathon"] test: ["CMD-SHELL", "pg_isready -U marathon"]
interval: 5s interval: 5s
@@ -30,6 +30,7 @@ services:
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
BOT_API_SECRET: ${BOT_API_SECRET:-} BOT_API_SECRET: ${BOT_API_SECRET:-}
DEBUG: ${DEBUG:-false} DEBUG: ${DEBUG:-false}
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
# S3 Storage # S3 Storage
S3_ENABLED: ${S3_ENABLED:-false} S3_ENABLED: ${S3_ENABLED:-false}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-} S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
@@ -42,7 +43,7 @@ services:
- ./backend/uploads:/app/uploads - ./backend/uploads:/app/uploads
- ./backend/app:/app/app - ./backend/app:/app/app
ports: ports:
- "8000:8000" - "8002:8000"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -56,7 +57,7 @@ services:
VITE_API_URL: ${VITE_API_URL:-/api/v1} VITE_API_URL: ${VITE_API_URL:-/api/v1}
container_name: marathon-frontend container_name: marathon-frontend
ports: ports:
- "3000:80" - "3002:80"
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped

381
docs/disputes.md Normal file
View File

@@ -0,0 +1,381 @@
# Система оспаривания (Disputes)
Система оспаривания позволяет участникам марафона проверять доказательства (пруфы) выполненных заданий друг друга и голосовать за их валидность.
## Общий принцип работы
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ЖИЗНЕННЫЙ ЦИКЛ ДИСПУТА │
└──────────────────────────────────────────────────────────────────────────┘
Участник A Участник B Все участники
выполняет задание замечает проблему голосуют
│ │ │
▼ ▼ ▼
┌───────────┐ 24 часа ┌───────────┐ 24 часа ┌───────────┐
│ Завершено │ ─────────────────▶ │ Оспорено │ ─────────────▶ │ Решено │
│ │ окно оспаривания │ (OPEN) │ голосование │ │
└───────────┘ └───────────┘ └───────────┘
│ │ │
│ │ ├──▶ VALID (пруф OK)
│ │ │ Задание остаётся
│ │ │
│ │ └──▶ INVALID (пруф не OK)
│ │ Задание возвращается
│ │
└──────────────────────────────────┘
Если не оспорено — задание засчитано
```
## Кто может оспаривать
| Условие | Можно оспорить? |
|---------|-----------------|
| Своё задание | ❌ Нельзя |
| Чужое задание (статус COMPLETED) | ✅ Можно (в течение 24 часов) |
| Чужое задание (статус ACTIVE/DROPPED) | ❌ Нельзя |
| Прошло более 24 часов с момента выполнения | ❌ Нельзя |
| Уже есть активный диспут на это задание | ❌ Нельзя |
## Типы оспариваемых заданий
### 1. Обычные челленджи
Можно оспорить выполнение любого челленджа. При признании пруфа невалидным:
- Задание переходит в статус `RETURNED`
- Очки снимаются с участника
- Участник должен переделать задание
### 2. Прохождения игр (Playthrough)
Основное задание прохождения можно оспорить. При признании невалидным:
- Основное задание переходит в статус `RETURNED`
- Очки снимаются
- **Все бонусные челленджи сбрасываются** в статус `PENDING`
### 3. Бонусные челленджи
Каждый бонусный челлендж можно оспорить **отдельно**. При признании невалидным:
- Только этот бонусный челлендж сбрасывается в `PENDING`
- Участник может переделать его
- Основное задание и другие бонусы не затрагиваются
**Важно:** Очки за бонусные челленджи начисляются только при завершении основного задания. Поэтому при оспаривании бонуса очки не снимаются — просто сбрасывается статус.
## Процесс голосования
### Создание диспута
1. Участник нажимает "Оспорить" на странице деталей задания
2. Вводит причину оспаривания (минимум 10 символов)
3. Создаётся диспут со статусом `OPEN`
4. Владельцу задания отправляется уведомление в Telegram
### Голосование
- **Любой участник марафона** может голосовать
- Два варианта: "Валидно" (пруф OK) или "Невалидно" (пруф не OK)
- Можно **изменить** свой голос до завершения голосования
- Голосование длится **24 часа** с момента создания диспута
### Комментарии
- Участники могут оставлять комментарии для обсуждения
- Комментарии помогают другим участникам принять решение
- Комментарии доступны только пока диспут открыт
## Разрешение диспута
### Автоматическое (по таймеру)
Через 24 часа диспут автоматически разрешается:
- Система подсчитывает голоса
- При равенстве голосов — **в пользу обвиняемого** (пруф валиден)
- Результат: `RESOLVED_VALID` или `RESOLVED_INVALID`
**Технически:** Фоновый планировщик (`DisputeScheduler`) проверяет истёкшие диспуты каждые 5 минут.
### Результаты
| Результат | Условие | Последствия |
|-----------|---------|-------------|
| `RESOLVED_VALID` | Голосов "валидно" ≥ голосов "невалидно" | Задание остаётся выполненным |
| `RESOLVED_INVALID` | Голосов "невалидно" > голосов "валидно" | Задание возвращается |
### Что происходит при INVALID
**Для обычного задания:**
1. Статус → `RETURNED`
2. Очки (`points_earned`) вычитаются из общего счёта участника
3. Пруфы сохраняются для истории
**Для прохождения:**
1. Основное задание → `RETURNED`
2. Очки вычитаются
3. Все бонусные челленджи сбрасываются:
- Статус → `PENDING`
- Пруфы удаляются
- Очки обнуляются
**Для бонусного челленджа:**
1. Только этот бонус → `PENDING`
2. Пруфы удаляются
3. Можно переделать
## API эндпоинты
### Создание диспута
```
POST /api/v1/assignments/{assignment_id}/dispute
POST /api/v1/bonus-assignments/{bonus_id}/dispute
Body: { "reason": "Описание проблемы с пруфом..." }
```
### Голосование
```
POST /api/v1/disputes/{dispute_id}/vote
Body: { "vote": true } // true = валидно, false = невалидно
```
### Комментарии
```
POST /api/v1/disputes/{dispute_id}/comments
Body: { "text": "Текст комментария" }
```
### Получение информации
```
GET /api/v1/assignments/{assignment_id}
// В ответе включено поле dispute с полной информацией:
{
"dispute": {
"id": 1,
"status": "open",
"reason": "...",
"votes_valid": 3,
"votes_invalid": 2,
"my_vote": true,
"expires_at": "2024-12-30T12:00:00Z",
"comments": [...],
"votes": [...]
}
}
```
## Структура базы данных
### Таблица `disputes`
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | INT | PK |
| `assignment_id` | INT | FK → assignments (nullable для бонусов) |
| `bonus_assignment_id` | INT | FK → bonus_assignments (nullable для основных) |
| `raised_by_id` | INT | FK → users |
| `reason` | TEXT | Причина оспаривания |
| `status` | VARCHAR(20) | open / valid / invalid |
| `created_at` | DATETIME | Время создания |
| `resolved_at` | DATETIME | Время разрешения |
**Ограничение:** Либо `assignment_id`, либо `bonus_assignment_id` должен быть заполнен (не оба).
### Таблица `dispute_votes`
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | INT | PK |
| `dispute_id` | INT | FK → disputes |
| `user_id` | INT | FK → users |
| `vote` | BOOLEAN | true = валидно, false = невалидно |
| `created_at` | DATETIME | Время голоса |
**Ограничение:** Один голос на участника (`UNIQUE dispute_id + user_id`).
### Таблица `dispute_comments`
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | INT | PK |
| `dispute_id` | INT | FK → disputes |
| `user_id` | INT | FK → users |
| `text` | TEXT | Текст комментария |
| `created_at` | DATETIME | Время комментария |
## UI компоненты
### Кнопка "Оспорить"
Появляется на странице деталей задания (`/assignments/{id}`) если:
- Статус задания: `COMPLETED`
- Это не своё задание
- Прошло меньше 24 часов с момента выполнения
- Нет активного диспута
### Секция диспута
Показывается если есть активный или завершённый диспут:
- Статус (открыт / валиден / невалиден)
- Таймер до окончания (для открытых)
- Причина оспаривания
- Кнопки голосования с счётчиками
- Секция комментариев
### Для бонусных челленджей
На каждом бонусном челлендже:
- Маленькая кнопка "Оспорить" (если можно)
- Бейдж статуса диспута
- Компактное голосование прямо в карточке бонуса
## Уведомления
### Telegram уведомления
| Событие | Получатель | Сообщение |
|---------|------------|-----------|
| Создание диспута | Владелец задания | "Ваше задание X оспорено в марафоне Y" |
| Результат: валидно | Владелец задания | "Диспут по заданию X решён в вашу пользу" |
| Результат: невалидно | Владелец задания | "Диспут по заданию X решён не в вашу пользу, задание возвращено" |
## Конфигурация
```python
# backend/app/api/v1/assignments.py
DISPUTE_WINDOW_HOURS = 24 # Окно для создания диспута
# backend/app/services/dispute_scheduler.py
CHECK_INTERVAL_SECONDS = 300 # Проверка каждые 5 минут
DISPUTE_WINDOW_HOURS = 24 # Время голосования
```
## Пример сценария
### Сценарий 1: Успешное оспаривание
1. **Иван** выполняет челлендж "Пройти уровень без смертей"
2. **Иван** прикладывает скриншот финального экрана
3. **Петр** открывает детали задания и видит, что на скриншоте есть смерти
4. **Петр** нажимает "Оспорить" и пишет: "На скриншоте видно 3 смерти"
5. Участники марафона голосуют: 5 за "невалидно", 2 за "валидно"
6. Через 24 часа диспут закрывается как `RESOLVED_INVALID`
7. Задание Ивана возвращается, очки снимаются
8. Иван получает уведомление и должен переделать задание
### Сценарий 2: Оспаривание бонуса
1. **Анна** проходит игру и выполняет бонусный челлендж
2. **Сергей** замечает проблему с пруфом бонуса
3. **Сергей** оспаривает только бонусный челлендж
4. Голосование: 4 за "невалидно", 1 за "валидно"
5. Результат: бонус сбрасывается в `PENDING`
6. Основное задание Анны **не затронуто**
7. Анна может переделать бонус (пока основное задание активно)
## Ручное разрешение диспутов
Администраторы системы и организаторы марафонов могут вручную разрешать диспуты, не дожидаясь окончания 24-часового окна голосования.
### Кто может разрешать
| Роль | Доступ |
|------|--------|
| **Системный админ** | Все диспуты во всех марафонах (`/admin/disputes`) |
| **Организатор марафона** | Только диспуты в своём марафоне (секция "Оспаривания" на странице марафона) |
### Интерфейс для системных админов
**Путь:** `/admin/disputes`
- Отдельная страница в админ-панели
- Фильтры: "Открытые" / "Все"
- Показывает диспуты из всех марафонов
- Информация: марафон, задание, участник, кто оспорил, причина
- Счётчик голосов и время до истечения
- Кнопки "Валидно" / "Невалидно" для мгновенного решения
### Интерфейс для организаторов
**Путь:** На странице марафона (`/marathons/{id}`) → секция "Оспаривания"
- Доступна только организаторам активного марафона
- Показывает только диспуты текущего марафона
- Компактный вид с возможностью раскрытия
- Ссылка на страницу задания для детального просмотра
### API для ручного разрешения
**Системные админы:**
```
GET /api/v1/admin/disputes?status_filter=open|all
POST /api/v1/admin/disputes/{dispute_id}/resolve
Body: { "is_valid": true|false }
```
**Организаторы марафона:**
```
GET /api/v1/marathons/{marathon_id}/disputes?status_filter=open|all
POST /api/v1/marathons/{marathon_id}/disputes/{dispute_id}/resolve
Body: { "is_valid": true|false }
```
### Что происходит при ручном разрешении
Логика идентична автоматическому разрешению:
**При `is_valid: true`:**
- Диспут закрывается как `RESOLVED_VALID`
- Задание остаётся выполненным
- Участник получает уведомление
**При `is_valid: false`:**
- Диспут закрывается как `RESOLVED_INVALID`
- Задание возвращается, очки снимаются
- Участник получает уведомление
### Важно: логика снятия очков за бонусы
При отклонении бонусного диспута система проверяет статус основного прохождения:
```
┌─────────────────────────────────────────────────────────────────┐
│ БОНУС ПРИЗНАН НЕВАЛИДНЫМ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Основное прохождение Основное прохождение │
НЕ завершено? УЖЕ завершено? │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────┐ ┌───────────┐ │
│ │ Просто │ │ Вычитаем │ │
│ │ сбросить │ │ очки из │ │
│ │ бонус │ │ участника │ │
│ └───────────┘ └───────────┘ │
│ (очки ещё не (очки уже были │
│ были начислены) начислены при │
│ завершении прохождения) │
└─────────────────────────────────────────────────────────────────┘
```
**Почему так?** Очки за бонусные челленджи начисляются только в момент завершения основного прохождения (чтобы нельзя было получить очки за бонусы и потом дропнуть основное задание).
## Логирование действий
Ручное разрешение диспутов логируется в системе:
| Действие | Тип лога |
|----------|----------|
| Админ подтвердил пруф | `DISPUTE_RESOLVE_VALID` |
| Админ отклонил пруф | `DISPUTE_RESOLVE_INVALID` |
Логи доступны в `/admin/logs` для аудита действий администраторов.

242
docs/game-types.md Normal file
View File

@@ -0,0 +1,242 @@
# Система типов игр
## Обзор
В системе существует два типа игр, определяющих логику выдачи заданий:
| Тип | Значение | Описание |
|-----|----------|----------|
| **Челленджи** | `challenges` | При спине выдаётся один случайный челлендж из списка |
| **Прохождение** | `playthrough` | Нужно пройти игру целиком, челленджи становятся бонусными |
---
## Модели данных
### Game
```
game_type: str # "challenges" | "playthrough"
playthrough_points: int? # Очки за прохождение (только для playthrough)
playthrough_description: str? # Описание задания
playthrough_proof_type: str? # Тип пруфа: screenshot/video/steam
playthrough_proof_hint: str? # Подсказка для пруфа
```
### Assignment
```
challenge_id: int? # ID челленджа (для challenges)
game_id: int? # ID игры (для playthrough)
is_playthrough: bool # True если это прохождение
```
### BonusAssignment
```
main_assignment_id: int # Ссылка на основное задание (playthrough)
challenge_id: int # ID бонусного челленджа
status: str # "pending" | "completed"
proof_path: str? # Путь к файлу пруфа
proof_url: str? # URL пруфа
proof_comment: str? # Комментарий со ссылкой
points_earned: int # Заработанные очки
```
---
## Логика спина
### Тип "Челленджи" (challenges)
```
1. Выбрать случайную игру из доступных
2. Отфильтровать уже выполненные челленджи этой игры
3. Выбрать случайный невыполненный челлендж
4. Создать Assignment с challenge_id
```
**Игра исключается из спина**, если все её челленджи выполнены.
### Тип "Прохождение" (playthrough)
```
1. Выбрать случайную игру из доступных
2. Создать Assignment с game_id и is_playthrough=True
3. Создать BonusAssignment для каждого челленджа игры
4. События (Jackpot, Golden Hour и т.д.) ИГНОРИРУЮТСЯ
```
**Игра исключается из спина**, если есть Assignment со статусом COMPLETED или DROPPED.
---
## Завершение заданий
### Челлендж (challenges)
```
POST /marathons/{id}/complete-assignment
```
1. Загрузить пруф (файл или комментарий)
2. Начисляются очки челленджа × модификатор события
3. Увеличивается серия участника
4. Статус → COMPLETED
### Прохождение (playthrough)
```
POST /marathons/{id}/complete-assignment
```
1. Загрузить пруф прохождения
2. Начисляются очки за прохождение (`playthrough_points`)
3. Бонусные очки добавляются из completed BonusAssignments
4. Увеличивается серия участника
5. Все pending BonusAssignments удаляются (больше нельзя выполнить)
6. Статус → COMPLETED
### Бонусный челлендж
```
POST /marathons/{id}/assignments/{assignment_id}/bonus/{challenge_id}/complete
```
1. Доступно только пока основное задание ACTIVE
2. Загрузить пруф бонусного челленджа
3. BonusAssignment.status → COMPLETED
4. Очки накапливаются в BonusAssignment.points_earned
5. **Очки НЕ добавляются сразу** — добавятся при завершении основного задания
**Исключение:** Если main assignment уже COMPLETED (перепрохождение после диспута), очки добавляются сразу.
---
## Фильтрация игр для спина
### Функция `get_available_games_for_participant`
```python
for game in approved_games:
if game.game_type == "playthrough":
# Исключить если есть COMPLETED или DROPPED assignment
if has_finished_playthrough(participant, game):
continue
else: # challenges
# Исключить если ВСЕ челленджи выполнены
if all_challenges_completed(participant, game):
continue
available.append(game)
```
---
## Система очков
### Челлендж
```
base_points = challenge.points
modifier = event_modifier (если есть активное событие)
total = base_points × modifier
```
### Прохождение
```
base_points = game.playthrough_points
bonus_points = sum(bonus.points_earned for bonus in completed_bonuses)
total = base_points + bonus_points
```
**События НЕ влияют на очки за прохождение.**
---
## Дроп задания
### Челлендж
- Штраф в очках (зависит от настроек марафона)
- Серия обнуляется
- Игра остаётся доступной (можно получить другой челлендж)
### Прохождение
- Штраф в очках
- Серия обнуляется
- **Игра исключается из спина навсегда**
- Все BonusAssignments удаляются
---
## Диспуты
### Оспаривание прохождения
Если диспут признан недействительным:
1. Assignment → RETURNED
2. Вычитаются все очки (прохождение + бонусы)
3. Серия обнуляется
4. Все BonusAssignments сбрасываются в PENDING
### Оспаривание бонуса
Если диспут признан недействительным:
1. BonusAssignment → PENDING
2. Вычитаются очки бонуса
3. Proof данные очищаются
4. Можно попробовать выполнить заново
---
## API эндпоинты
| Метод | Путь | Описание |
|-------|------|----------|
| POST | `/marathons/{id}/spin` | Крутить колесо |
| POST | `/marathons/{id}/complete-assignment` | Завершить основное задание |
| POST | `/marathons/{id}/assignments/{id}/bonus/{challenge_id}/complete` | Завершить бонус |
| GET | `/marathons/{id}/available-games` | Список доступных игр |
| GET | `/marathons/{id}/available-games-count` | Количество доступных игр |
---
## Схема работы
```
┌─────────────────────────────────────────────────────────────────┐
│ СПИН │
└─────────────────────────────────────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ PLAYTHROUGH │ │ CHALLENGES │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Assignment │ │ Assignment │
│ game_id = X │ │ challenge_id │
│ is_playthrough │ │ = X │
└─────────────────┘ └─────────────────┘
│ │
▼ │
┌─────────────────┐ │
│ BonusAssignment │ │
× N (по числу │ │
│ челленджей) │ │
└─────────────────┘ │
│ │
├───────────────────────────────┤
▼ ▼
┌─────────────────────────────────────────────────┐
│ COMPLETE │
│ • Загрузка пруфа │
│ • Начисление очков │
│ • Увеличение серии │
└─────────────────────────────────────────────────┘
```

906
docs/tz-game-types.md Normal file
View File

@@ -0,0 +1,906 @@
# ТЗ: Типы игр "Прохождение" и "Челленджи"
## Описание задачи
Добавить систему типов для игр, которая определяет логику выпадения заданий при спине колеса.
### Два типа игр:
| Тип | Название | Поведение при выпадении |
|-----|----------|------------------------|
| `playthrough` | Прохождение | Основное задание — пройти игру. Челленджи становятся **дополнительными** заданиями |
| `challenges` | Челленджи | Выдаётся **случайный челлендж** из списка челленджей игры (текущее поведение) |
---
## Детальное описание логики
### Тип "Прохождение" (`playthrough`)
**При создании игры** с типом "Прохождение" указываются дополнительные поля:
- **Очки за прохождение** (`playthrough_points`) — количество очков за прохождение игры
- **Описание прохождения** (`playthrough_description`) — описание задания (например: "Пройти основной сюжет игры")
- **Тип пруфа** (`playthrough_proof_type`) — screenshot / video / steam
- **Подсказка для пруфа** (`playthrough_proof_hint`) — опционально (например: "Скриншот финальных титров")
**При выпадении игры** с типом "Прохождение":
1. **Основное задание**: Пройти игру (очки и описание берутся из полей игры)
2. **Дополнительные задания**: Все челленджи игры становятся **опциональными** бонусными заданиями
3. **Пруфы**:
- Требуется **отдельный пруф на прохождение** игры (тип из `playthrough_proof_type`)
- Для каждого бонусного челленджа **тоже требуется пруф** (по типу челленджа)
- **Прикрепление файла не обязательно** — можно отправить только комментарий со ссылкой на видео
4. **Система очков**:
- За основное прохождение — `playthrough_points` (указанные при создании)
- За каждый выполненный доп. челлендж — очки челленджа
5. **Завершение**: Задание считается выполненным после прохождения основной игры. Доп. челленджи **не обязательны** — можно выполнять параллельно или игнорировать
### Тип "Челленджи" (`challenges`)
При выпадении игры с типом "Челленджи":
1. Выбирается **один случайный челлендж** из списка челленджей игры
2. Участник выполняет только этот челлендж
3. Логика остаётся **без изменений** (текущее поведение системы)
---
### Фильтрация игр при спине
При выборе игры для спина необходимо исключать уже пройденные/дропнутые игры:
| Тип игры | Условие исключения из спина |
|----------|----------------------------|
| `playthrough` | Игра **исключается**, если участник **завершил ИЛИ дропнул** прохождение этой игры |
| `challenges` | Игра **исключается**, только если участник выполнил **все** челленджи этой игры |
**Логика:**
```
Для каждой игры в марафоне:
ЕСЛИ game_type == "playthrough":
Проверить: есть ли Assignment с is_playthrough=True для этой игры
со статусом COMPLETED или DROPPED?
Если да → исключить игру
ЕСЛИ game_type == "challenges":
Получить все челленджи игры
Получить все завершённые Assignment участника для этих челленджей
Если количество завершённых == количество челленджей → исключить игру
```
**Важно:** Если все игры исключены (всё пройдено), спин должен вернуть ошибку или специальный статус "Все игры пройдены!"
### Бонусные челленджи
Бонусные челленджи доступны **только пока основное задание активно**:
- После **завершения** прохождения — бонусные челленджи недоступны
- После **дропа** прохождения — бонусные челленджи недоступны
- Нельзя вернуться к бонусным челленджам позже
### Взаимодействие с событиями
**Все события игнорируются** при выпадении игры с типом `playthrough`:
| Событие | Поведение для `playthrough` |
|---------|----------------------------|
| **JACKPOT** (x3 за hard) | Игнорируется |
| **GAME_CHOICE** (выбор из 3) | Игнорируется |
| **GOLDEN_HOUR** (x1.5) | Игнорируется |
| **DOUBLE_RISK** (x0.5, бесплатный дроп) | Игнорируется |
| **COMMON_ENEMY** | Игнорируется |
| **SWAP** | Игнорируется |
Игрок получает стандартные очки `playthrough_points` без модификаторов.
---
## Изменения в Backend
### 1. Модель Game (`backend/app/models/game.py`)
Добавить поля для типа игры и прохождения:
```python
class GameType(str, Enum):
PLAYTHROUGH = "playthrough" # Прохождение
CHALLENGES = "challenges" # Челленджи
class Game(Base):
# ... существующие поля ...
# Тип игры
game_type: Mapped[str] = mapped_column(
String(20),
default=GameType.CHALLENGES.value,
nullable=False
)
# Поля для типа "Прохождение" (nullable, заполняются только для playthrough)
playthrough_points: Mapped[int | None] = mapped_column(
Integer,
nullable=True
)
playthrough_description: Mapped[str | None] = mapped_column(
Text,
nullable=True
)
playthrough_proof_type: Mapped[str | None] = mapped_column(
String(20), # screenshot, video, steam
nullable=True
)
playthrough_proof_hint: Mapped[str | None] = mapped_column(
Text,
nullable=True
)
```
### 2. Схемы Pydantic (`backend/app/schemas/`)
Обновить схемы для Game:
```python
# schemas/game.py
class GameType(str, Enum):
PLAYTHROUGH = "playthrough"
CHALLENGES = "challenges"
class GameCreate(BaseModel):
# ... существующие поля ...
game_type: GameType = GameType.CHALLENGES
# Поля для типа "Прохождение"
playthrough_points: int | None = None
playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None
@model_validator(mode='after')
def validate_playthrough_fields(self) -> Self:
if self.game_type == GameType.PLAYTHROUGH:
if self.playthrough_points is None:
raise ValueError('playthrough_points обязателен для типа "Прохождение"')
if self.playthrough_description is None:
raise ValueError('playthrough_description обязателен для типа "Прохождение"')
if self.playthrough_proof_type is None:
raise ValueError('playthrough_proof_type обязателен для типа "Прохождение"')
if self.playthrough_points < 1 or self.playthrough_points > 500:
raise ValueError('playthrough_points должен быть от 1 до 500')
return self
class GameResponse(BaseModel):
# ... существующие поля ...
game_type: GameType
playthrough_points: int | None
playthrough_description: str | None
playthrough_proof_type: ProofType | None
playthrough_proof_hint: str | None
class GameUpdate(BaseModel):
"""Схема для редактирования игры"""
title: str | None = None
download_url: str | None = None
genre: str | None = None
game_type: GameType | None = None
playthrough_points: int | None = None
playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None
@model_validator(mode='after')
def validate_playthrough_fields(self) -> Self:
# Валидация только если меняем на playthrough
if self.game_type == GameType.PLAYTHROUGH:
if self.playthrough_points is not None:
if self.playthrough_points < 1 or self.playthrough_points > 500:
raise ValueError('playthrough_points должен быть от 1 до 500')
return self
```
### 3. Миграция Alembic
```python
# Новая миграция
def upgrade():
# Тип игры
op.add_column('games', sa.Column(
'game_type',
sa.String(20),
nullable=False,
server_default='challenges'
))
# Поля для прохождения
op.add_column('games', sa.Column(
'playthrough_points',
sa.Integer(),
nullable=True
))
op.add_column('games', sa.Column(
'playthrough_description',
sa.Text(),
nullable=True
))
op.add_column('games', sa.Column(
'playthrough_proof_type',
sa.String(20),
nullable=True
))
op.add_column('games', sa.Column(
'playthrough_proof_hint',
sa.Text(),
nullable=True
))
def downgrade():
op.drop_column('games', 'playthrough_proof_hint')
op.drop_column('games', 'playthrough_proof_type')
op.drop_column('games', 'playthrough_description')
op.drop_column('games', 'playthrough_points')
op.drop_column('games', 'game_type')
```
### 4. Логика спина (`backend/app/api/v1/wheel.py`)
Изменить функцию `spin_wheel`:
```python
async def get_available_games(
participant: Participant,
marathon_games: list[Game],
db: AsyncSession
) -> list[Game]:
"""Получить список игр, доступных для спина"""
available = []
for game in marathon_games:
if game.game_type == GameType.PLAYTHROUGH.value:
# Проверяем, прошёл ли участник эту игру
# Исключаем если COMPLETED или DROPPED
finished = await db.scalar(
select(Assignment)
.where(
Assignment.participant_id == participant.id,
Assignment.game_id == game.id,
Assignment.is_playthrough == True,
Assignment.status.in_([
AssignmentStatus.COMPLETED.value,
AssignmentStatus.DROPPED.value
])
)
)
if not finished:
available.append(game)
else: # GameType.CHALLENGES
# Проверяем, остались ли невыполненные челленджи
completed_challenge_ids = await db.scalars(
select(Assignment.challenge_id)
.where(
Assignment.participant_id == participant.id,
Assignment.challenge_id.in_([c.id for c in game.challenges]),
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
completed_ids = set(completed_challenge_ids.all())
all_challenge_ids = {c.id for c in game.challenges}
if completed_ids != all_challenge_ids:
available.append(game)
return available
async def spin_wheel(...):
# Получаем доступные игры (исключаем пройденные)
available_games = await get_available_games(participant, marathon_games, db)
if not available_games:
raise HTTPException(
status_code=400,
detail="Все игры пройдены! Поздравляем!"
)
game = random.choice(available_games)
if game.game_type == GameType.PLAYTHROUGH.value:
# Для playthrough НЕ выбираем челлендж — основное задание это прохождение
# Данные берутся из полей игры: playthrough_points, playthrough_description
challenge = None # Или создаём виртуальный объект
# Все челленджи игры становятся дополнительными
bonus_challenges = list(game.challenges)
# Создаём Assignment с флагом is_playthrough=True
assignment = Assignment(
participant_id=participant.id,
challenge_id=None, # Нет привязки к челленджу
game_id=game.id, # Новое поле — привязка к игре
is_playthrough=True,
status=AssignmentStatus.ACTIVE,
# ...
)
else: # GameType.CHALLENGES
# Выбираем случайный НЕВЫПОЛНЕННЫЙ челлендж
completed_challenge_ids = await db.scalars(
select(Assignment.challenge_id)
.where(
Assignment.participant_id == participant.id,
Assignment.challenge_id.in_([c.id for c in game.challenges]),
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
completed_ids = set(completed_challenge_ids.all())
available_challenges = [c for c in game.challenges if c.id not in completed_ids]
challenge = random.choice(available_challenges)
bonus_challenges = []
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge.id,
is_playthrough=False,
status=AssignmentStatus.ACTIVE,
# ...
)
# ... сохранение Assignment ...
```
### 5. Модель Assignment (`backend/app/models/assignment.py`)
Обновить модель для поддержки прохождений:
```python
class Assignment(Base):
# ... существующие поля ...
# Для прохождений: привязка к игре вместо челленджа
game_id: Mapped[int | None] = mapped_column(
ForeignKey("games.id"),
nullable=True
)
is_playthrough: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
game: Mapped["Game"] = relationship(back_populates="playthrough_assignments")
# Отдельная таблица для бонусных челленджей
class BonusAssignment(Base):
__tablename__ = "bonus_assignments"
id: Mapped[int] = mapped_column(primary_key=True)
main_assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id"))
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id"))
status: Mapped[str] = mapped_column(String(20), default="pending") # pending, completed
proof_path: Mapped[str | None] = mapped_column(Text, nullable=True)
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
points_earned: Mapped[int] = mapped_column(Integer, default=0)
# Relationships
main_assignment: Mapped["Assignment"] = relationship(back_populates="bonus_assignments")
challenge: Mapped["Challenge"] = relationship()
```
### 6. API эндпоинты
Добавить/обновить эндпоинты:
```python
# Обновить ответ спина
class PlaythroughInfo(BaseModel):
"""Информация о прохождении (для playthrough игр)"""
description: str
points: int
class SpinResult(BaseModel):
assignment_id: int
game: GameResponse
challenge: ChallengeResponse | None # None для playthrough
is_playthrough: bool
playthrough_info: PlaythroughInfo | None # Заполняется для playthrough
bonus_challenges: list[ChallengeResponse] = [] # Для playthrough
can_drop: bool
drop_penalty: int
# Завершение бонусного челленджа
@router.post("/assignments/{assignment_id}/bonus/{challenge_id}/complete")
async def complete_bonus_challenge(
assignment_id: int,
challenge_id: int,
proof: ProofData,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> BonusAssignmentResponse:
"""Завершить дополнительный челлендж для игры-прохождения"""
...
# Получение бонусных челленджей
@router.get("/assignments/{assignment_id}/bonus")
async def get_bonus_assignments(
assignment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> list[BonusAssignmentResponse]:
"""Получить список бонусных челленджей и их статус"""
...
# Получение количества доступных игр для спина
@router.get("/marathons/{marathon_id}/available-games-count")
async def get_available_games_count(
marathon_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> dict:
"""
Получить количество игр, доступных для спина.
Возвращает: { "available": 5, "total": 10 }
"""
participant = await get_participant(...)
marathon_games = await get_marathon_games(...)
available = await get_available_games(participant, marathon_games, db)
return {
"available": len(available),
"total": len(marathon_games)
}
# Редактирование игры
@router.patch("/marathons/{marathon_id}/games/{game_id}")
async def update_game(
marathon_id: int,
game_id: int,
game_data: GameUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> GameResponse:
"""
Редактировать игру.
Доступно только организатору марафона.
При смене типа на 'playthrough' необходимо указать playthrough_points и playthrough_description.
"""
# Проверка прав (организатор)
# Валидация: если меняем тип на playthrough, проверить что поля заполнены
# Обновление полей
...
```
---
## Изменения в Frontend
### 1. Типы (`frontend/src/types/index.ts`)
```typescript
export type GameType = 'playthrough' | 'challenges'
export interface Game {
// ... существующие поля ...
game_type: GameType
playthrough_points: number | null
playthrough_description: string | null
}
export interface PlaythroughInfo {
description: string
points: number
}
export interface SpinResult {
assignment_id: number
game: Game
challenge: Challenge | null // null для playthrough
is_playthrough: boolean
playthrough_info: PlaythroughInfo | null
bonus_challenges: Challenge[]
can_drop: boolean
drop_penalty: number
}
export interface BonusAssignment {
id: number
challenge: Challenge
status: 'pending' | 'completed'
proof_url: string | null
completed_at: string | null
points_earned: number
}
export interface GameUpdate {
title?: string
download_url?: string
genre?: string
game_type?: GameType
playthrough_points?: number
playthrough_description?: string
}
```
### 2. Форма добавления игры
Добавить выбор типа игры и условные поля:
```tsx
// components/AddGameForm.tsx
const [gameType, setGameType] = useState<GameType>('challenges')
const [playthroughPoints, setPlaythroughPoints] = useState<number>(100)
const [playthroughDescription, setPlaythroughDescription] = useState<string>('')
return (
<form>
{/* ... существующие поля ... */}
<Select
label="Тип игры"
value={gameType}
onChange={setGameType}
options={[
{ value: 'challenges', label: 'Челленджи' },
{ value: 'playthrough', label: 'Прохождение' }
]}
/>
{/* Поля только для типа "Прохождение" */}
{gameType === 'playthrough' && (
<>
<Input
type="number"
label="Очки за прохождение"
value={playthroughPoints}
onChange={setPlaythroughPoints}
min={1}
max={500}
required
/>
<Textarea
label="Описание прохождения"
value={playthroughDescription}
onChange={setPlaythroughDescription}
placeholder="Например: Пройти основной сюжет игры"
required
/>
</>
)}
</form>
)
```
### 3. Отображение результата спина
Для типа "Прохождение" показывать:
- Основное задание с описанием из `playthrough_info`
- Очки за прохождение
- Список дополнительных челленджей (опциональные)
```tsx
// components/SpinResult.tsx
{result.is_playthrough ? (
<PlaythroughCard
game={result.game}
info={result.playthrough_info}
bonusChallenges={result.bonus_challenges}
/>
) : (
<ChallengeCard challenge={result.challenge} />
)}
```
### 4. Карточка текущего задания
Для playthrough показывать прогресс по доп. челленджам:
```tsx
// components/CurrentAssignment.tsx
{assignment.is_playthrough && (
<div className="mt-4">
<h4>Дополнительные задания (опционально)</h4>
<BonusChallengesList
assignmentId={assignment.id}
challenges={assignment.bonus_challenges}
onComplete={handleBonusComplete}
/>
<p className="text-sm text-gray-500">
Выполнено: {completedCount} / {totalCount} (+{bonusPoints} очков)
</p>
</div>
)}
```
### 5. Форма завершения бонусного челленджа
```tsx
// components/BonusChallengeCompleteModal.tsx
<Modal>
<h3>Завершить челлендж: {challenge.title}</h3>
<p>{challenge.description}</p>
<p>Очки: +{challenge.points}</p>
<ProofUpload
proofType={challenge.proof_type}
onUpload={handleProofUpload}
/>
<Button onClick={handleComplete}>
Завершить (+{challenge.points} очков)
</Button>
</Modal>
```
### 6. Редактирование игры
Добавить модалку/страницу редактирования игры:
```tsx
// components/EditGameModal.tsx
interface EditGameModalProps {
game: Game
onSave: (data: GameUpdate) => void
onClose: () => void
}
const EditGameModal = ({ game, onSave, onClose }: EditGameModalProps) => {
const [title, setTitle] = useState(game.title)
const [downloadUrl, setDownloadUrl] = useState(game.download_url)
const [genre, setGenre] = useState(game.genre)
const [gameType, setGameType] = useState<GameType>(game.game_type)
const [playthroughPoints, setPlaythroughPoints] = useState(game.playthrough_points ?? 100)
const [playthroughDescription, setPlaythroughDescription] = useState(game.playthrough_description ?? '')
const handleSubmit = () => {
const data: GameUpdate = {
title,
download_url: downloadUrl,
genre,
game_type: gameType,
...(gameType === 'playthrough' && {
playthrough_points: playthroughPoints,
playthrough_description: playthroughDescription,
}),
}
onSave(data)
}
return (
<Modal onClose={onClose}>
<h2>Редактирование игры</h2>
<Input label="Название" value={title} onChange={setTitle} />
<Input label="Ссылка на скачивание" value={downloadUrl} onChange={setDownloadUrl} />
<Input label="Жанр" value={genre} onChange={setGenre} />
<Select
label="Тип игры"
value={gameType}
onChange={setGameType}
options={[
{ value: 'challenges', label: 'Челленджи' },
{ value: 'playthrough', label: 'Прохождение' }
]}
/>
{gameType === 'playthrough' && (
<>
<Input
type="number"
label="Очки за прохождение"
value={playthroughPoints}
onChange={setPlaythroughPoints}
min={1}
max={500}
/>
<Textarea
label="Описание прохождения"
value={playthroughDescription}
onChange={setPlaythroughDescription}
/>
</>
)}
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button onClick={handleSubmit}>Сохранить</Button>
</div>
</Modal>
)
}
```
### 7. Кнопка редактирования в списке игр
```tsx
// components/GameCard.tsx (или GamesList)
{isOrganizer && (
<Button
variant="ghost"
size="sm"
onClick={() => setEditingGame(game)}
>
Редактировать
</Button>
)}
```
### 8. Счётчик доступных игр
Отображать количество игр, которые ещё могут выпасть при спине:
```tsx
// components/AvailableGamesCounter.tsx
interface AvailableGamesCounterProps {
available: number
total: number
}
const AvailableGamesCounter = ({ available, total }: AvailableGamesCounterProps) => {
const allCompleted = available === 0
return (
<div className="text-sm text-gray-500">
{allCompleted ? (
<span className="text-green-600 font-medium">
Все игры пройдены!
</span>
) : (
<span>
Доступно игр: <strong>{available}</strong> из {total}
</span>
)}
</div>
)
}
// Использование на странице марафона / рядом с колесом
<AvailableGamesCounter available={gamesCount.available} total={gamesCount.total} />
```
---
## Уточнённые требования
| Вопрос | Решение |
|--------|---------|
| Очки за прохождение | Устанавливаются при создании игры (поле `playthrough_points`) |
| Обязательность доп. челленджей | **Не обязательны** — можно завершить задание без них |
| Пруф на прохождение | Тип указывается при создании (`playthrough_proof_type`) |
| Пруфы на бонусные челленджи | **Требуются** — по типу челленджа (screenshot/video/steam) |
| Прикрепление файла | **Не обязательно** — можно отправить комментарий со ссылкой |
| Миграция существующих игр | Тип по умолчанию: `challenges` |
| Дроп игры (playthrough) | Дропнутая игра **не выпадает** повторно |
| Бонусные челленджи после завершения | **Недоступны** — только пока задание активно |
| Счётчик игр | Показывать "Доступно игр: X из Y" |
| События для playthrough | **Все игнорируются** — стандартные очки без модификаторов |
---
## План реализации
### Этап 1: Backend (модели и миграции) ✅
- [x] Добавить enum `GameType` в `backend/app/models/game.py`
- [x] Добавить поля `game_type`, `playthrough_points`, `playthrough_description`, `playthrough_proof_type`, `playthrough_proof_hint` в модель Game
- [x] Создать модель `BonusAssignment` в `backend/app/models/bonus_assignment.py`
- [x] Обновить модель `Assignment` — добавить `game_id`, `is_playthrough`
- [x] Создать миграцию Alembic (`020_add_game_types.py`)
### Этап 2: Backend (схемы и API) ✅
- [x] Обновить Pydantic схемы для Game (`GameCreate`, `GameResponse`)
- [x] Добавить схему `GameUpdate` с валидацией
- [x] Обновить API создания игры
- [x] Добавить API редактирования игры (`PATCH /games/{id}`)
- [x] Добавить API счётчика игр (`GET /available-games-count`)
- [x] Добавить схемы для `BonusAssignment`, `PlaythroughInfo`
- [x] Добавить эндпоинты для бонусных челленджей
### Этап 3: Backend (логика спина) ✅
- [x] Добавить функцию `get_available_games()` для фильтрации пройденных игр
- [x] Обновить логику `spin_wheel` для обработки типов
- [x] Для типа `challenges` — выбирать только невыполненные челленджи
- [x] Обработать случай "Все игры пройдены"
- [x] Обновить ответ SpinResult
- [x] Обновить логику завершения задания для playthrough
- [x] Добавить логику завершения бонусных челленджей
- [x] Игнорирование событий для playthrough
### Этап 4: Frontend (типы и формы) ✅
- [x] Обновить типы TypeScript (`Game`, `SpinResult`, `BonusAssignment`, `GameUpdate`, `AvailableGamesCount`)
- [x] Добавить выбор типа в форму создания игры
- [x] Добавить условные поля "Очки", "Описание", "Тип пруфа", "Подсказка" для типа "Прохождение"
- [x] Добавить API метод `gamesApi.update()` и `gamesApi.getAvailableGamesCount()`
- [x] Добавить API методы для бонусных челленджей
### Этап 5: Frontend (UI) ✅
- [x] Обновить отображение результата спина для playthrough
- [x] Обновить карточку текущего задания (PlayPage)
- [x] Показ бонусных челленджей со статусами
- [x] Бейдж "Прохождение" на карточках игр в лобби
- [x] Поддержка пруфа через комментарий для playthrough
### Этап 6: Тестирование
- [ ] Тестирование миграции на существующих данных
- [ ] Проверка создания игр обоих типов
- [ ] Проверка редактирования игр (смена типа, обновление полей)
- [ ] Проверка спина для playthrough и challenges
- [ ] Проверка фильтрации пройденных игр (playthrough не выпадает повторно)
- [ ] Проверка фильтрации челленджей (выпадают только невыполненные)
- [ ] Проверка состояния "Все игры пройдены"
- [ ] Проверка завершения основного и бонусных заданий
---
## Схема работы
### Создание игры
```
┌─────────────────────────────────────────────────────────────────┐
│ СОЗДАНИЕ ИГРЫ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ Выбор типа │
└─────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ "Прохождение" │ │ "Челленджи" │
│ │ │ │
│ Доп. поля: │ │ Стандартные │
│ • Очки │ │ поля │
│ • Описание │ │ │
└─────────────────┘ └─────────────────┘
```
### Спин колеса
```
┌─────────────────────────────────────────────────────────────────┐
│ СПИН КОЛЕСА │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ Выбор игры │
│ (random) │
└─────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ game_type = │ │ game_type = │
│ "playthrough" │ │ "challenges" │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Основное: │ │ Случайный │
│ playthrough_ │ │ челлендж │
│ description │ │ │
│ │ │ (текущая │
│ Очки: │ │ логика) │
│ playthrough_ │ │ │
│ points │ │ │
│ │ │ │
│ Доп. задания: │ │ │
Все челленджи │ │ │
│ (опционально) │ │ │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Пруф: │ │ Пруф: │
На прохождение │ │ По типу │
│ игры │ │ челленджа │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Очки: │ │ Очки: │
│ + За прохождение│ │ + За челлендж │
│ + Бонус за доп. │ │ │
│ челленджи │ │ │
└─────────────────┘ └─────────────────┘
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui' import { ToastContainer, ConfirmModal } from '@/components/ui'
@@ -20,6 +21,7 @@ import { InvitePage } from '@/pages/InvitePage'
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage' import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage' import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage' import { UserProfilePage } from '@/pages/UserProfilePage'
import { StaticContentPage } from '@/pages/StaticContentPage'
import { NotFoundPage } from '@/pages/NotFoundPage' import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage' import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage' import { ServerErrorPage } from '@/pages/ServerErrorPage'
@@ -59,10 +61,15 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
function App() { function App() {
const banInfo = useAuthStore((state) => state.banInfo) const banInfo = useAuthStore((state) => state.banInfo)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated) const syncUser = useAuthStore((state) => state.syncUser)
// Show banned screen if user is authenticated and banned // Sync user data with server on app load
if (isAuthenticated && banInfo) { useEffect(() => {
syncUser()
}, [syncUser])
// Show banned screen if user is banned (either authenticated or during login attempt)
if (banInfo) {
return ( return (
<> <>
<ToastContainer /> <ToastContainer />
@@ -82,6 +89,11 @@ function App() {
{/* Public invite page */} {/* Public invite page */}
<Route path="invite/:code" element={<InvitePage />} /> <Route path="invite/:code" element={<InvitePage />} />
{/* Public static content pages */}
<Route path="terms" element={<StaticContentPage />} />
<Route path="privacy" element={<StaticContentPage />} />
<Route path="page/:key" element={<StaticContentPage />} />
<Route <Route
path="login" path="login"
element={ element={

View File

@@ -7,7 +7,8 @@ import type {
AdminLogsResponse, AdminLogsResponse,
BroadcastResponse, BroadcastResponse,
StaticContent, StaticContent,
DashboardStats DashboardStats,
AdminDispute
} from '@/types' } from '@/types'
export const adminApi = { export const adminApi = {
@@ -52,6 +53,13 @@ export const adminApi = {
return response.data return response.data
}, },
resetUserPassword: async (id: number, newPassword: string): Promise<AdminUser> => {
const response = await client.post<AdminUser>(`/admin/users/${id}/reset-password`, {
new_password: newPassword,
})
return response.data
},
// Marathons // Marathons
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => { listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
const params: Record<string, unknown> = { skip, limit } const params: Record<string, unknown> = { skip, limit }
@@ -84,13 +92,31 @@ export const adminApi = {
}, },
// Broadcast // Broadcast
broadcastToAll: async (message: string): Promise<BroadcastResponse> => { broadcastToAll: async (message: string, media?: File[]): Promise<BroadcastResponse> => {
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message }) const formData = new FormData()
formData.append('message', message)
if (media && media.length > 0) {
media.forEach(file => {
formData.append('media', file)
})
}
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return response.data return response.data
}, },
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => { broadcastToMarathon: async (marathonId: number, message: string, media?: File[]): Promise<BroadcastResponse> => {
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message }) const formData = new FormData()
formData.append('message', message)
if (media && media.length > 0) {
media.forEach(file => {
formData.append('media', file)
})
}
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return response.data return response.data
}, },
@@ -114,6 +140,23 @@ export const adminApi = {
const response = await client.post<StaticContent>('/admin/content', { key, title, content }) const response = await client.post<StaticContent>('/admin/content', { key, title, content })
return response.data return response.data
}, },
deleteContent: async (key: string): Promise<void> => {
await client.delete(`/admin/content/${key}`)
},
// Disputes
listDisputes: async (status: 'pending' | 'open' | 'all' = 'pending'): Promise<AdminDispute[]> => {
const response = await client.get<AdminDispute[]>('/admin/disputes', { params: { status } })
return response.data
},
resolveDispute: async (disputeId: number, isValid: boolean): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/admin/disputes/${disputeId}/resolve`, {
is_valid: isValid,
})
return response.data
},
} }
// Public content API (no auth required) // Public content API (no auth required)

View File

@@ -1,5 +1,11 @@
import client from './client' import client from './client'
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment } from '@/types' import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment, BonusAssignment } from '@/types'
export interface BonusCompleteResult {
bonus_assignment_id: number
points_earned: number
total_bonus_points: number
}
export const assignmentsApi = { export const assignmentsApi = {
// Get detailed assignment info with proofs and dispute // Get detailed assignment info with proofs and dispute
@@ -14,6 +20,12 @@ export const assignmentsApi = {
return response.data return response.data
}, },
// Create a dispute against a bonus assignment
createBonusDispute: async (bonusId: number, reason: string): Promise<Dispute> => {
const response = await client.post<Dispute>(`/bonus-assignments/${bonusId}/dispute`, { reason })
return response.data
},
// Add a comment to a dispute // Add a comment to a dispute
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => { addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text }) const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
@@ -44,4 +56,95 @@ export const assignmentsApi = {
type: isVideo ? 'video' : 'image', type: isVideo ? 'video' : 'image',
} }
}, },
// Get bonus assignments for a playthrough assignment
getBonusAssignments: async (assignmentId: number): Promise<BonusAssignment[]> => {
const response = await client.get<BonusAssignment[]>(`/assignments/${assignmentId}/bonus`)
return response.data
},
// Complete a bonus challenge
completeBonusAssignment: async (
assignmentId: number,
bonusId: number,
data: { proof_file?: File; proof_files?: File[]; proof_url?: string; comment?: string }
): Promise<BonusCompleteResult> => {
const formData = new FormData()
// Support both single file (legacy) and multiple files
if (data.proof_file) {
formData.append('proof_file', data.proof_file)
}
if (data.proof_files && data.proof_files.length > 0) {
data.proof_files.forEach(file => {
formData.append('proof_files', file)
})
}
if (data.proof_url) {
formData.append('proof_url', data.proof_url)
}
if (data.comment) {
formData.append('comment', data.comment)
}
const response = await client.post<BonusCompleteResult>(
`/assignments/${assignmentId}/bonus/${bonusId}/complete`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
return response.data
},
// Get bonus proof media as blob URL (supports both images and videos)
getBonusProofMediaUrl: async (
assignmentId: number,
bonusId: number
): Promise<{ url: string; type: 'image' | 'video' }> => {
const response = await client.get(
`/assignments/${assignmentId}/bonus/${bonusId}/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',
}
},
// Get individual proof file media as blob URL (for multiple proofs support)
getProofFileMediaUrl: async (
assignmentId: number,
proofFileId: number
): Promise<{ url: string; type: 'image' | 'video' }> => {
const response = await client.get(
`/assignments/${assignmentId}/proof-files/${proofFileId}/media`,
{ responseType: 'blob' }
)
const contentType = response.headers['content-type'] || ''
const isVideo = contentType.startsWith('video/')
return {
url: URL.createObjectURL(response.data),
type: isVideo ? 'video' : 'image',
}
},
// Get individual bonus proof file media as blob URL (for multiple proofs support)
getBonusProofFileMediaUrl: async (
assignmentId: number,
bonusId: number,
proofFileId: number
): Promise<{ url: string; type: 'image' | 'video' }> => {
const response = await client.get(
`/assignments/${assignmentId}/bonus/${bonusId}/proof-files/${proofFileId}/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

@@ -33,12 +33,17 @@ function isBanInfo(detail: unknown): detail is BanInfo {
client.interceptors.response.use( client.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError<{ detail: string | BanInfo }>) => { (error: AxiosError<{ detail: string | BanInfo }>) => {
// Unauthorized - redirect to login // Unauthorized - redirect to login (but not for auth endpoints)
if (error.response?.status === 401) { if (error.response?.status === 401) {
const url = error.config?.url || ''
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/2fa')
if (!isAuthEndpoint) {
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('user') localStorage.removeItem('user')
window.location.href = '/login' window.location.href = '/login'
} }
}
// Forbidden - check if user is banned // Forbidden - check if user is banned
if (error.response?.status === 403) { if (error.response?.status === 403) {

View File

@@ -1,11 +1,28 @@
import client from './client' import client from './client'
import type { Game, GameStatus, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types' import type { Game, GameStatus, GameType, ProofType, Challenge, ChallengePreview, ChallengesPreviewResponse, AvailableGamesCount } from '@/types'
export interface CreateGameData { export interface CreateGameData {
title: string title: string
download_url: string download_url: string
genre?: string genre?: string
cover_url?: string cover_url?: string
// Game type fields
game_type?: GameType
playthrough_points?: number
playthrough_description?: string
playthrough_proof_type?: ProofType
playthrough_proof_hint?: string
}
export interface UpdateGameData {
title?: string
download_url?: string
genre?: string
game_type?: GameType
playthrough_points?: number
playthrough_description?: string
playthrough_proof_type?: ProofType
playthrough_proof_hint?: string
} }
export interface CreateChallengeData { export interface CreateChallengeData {
@@ -45,6 +62,21 @@ export const gamesApi = {
await client.delete(`/games/${id}`) await client.delete(`/games/${id}`)
}, },
update: async (id: number, data: UpdateGameData): Promise<Game> => {
const response = await client.patch<Game>(`/games/${id}`, data)
return response.data
},
getAvailableGamesCount: async (marathonId: number): Promise<AvailableGamesCount> => {
const response = await client.get<AvailableGamesCount>(`/marathons/${marathonId}/available-games-count`)
return response.data
},
getAvailableGames: async (marathonId: number): Promise<Game[]> => {
const response = await client.get<Game[]>(`/marathons/${marathonId}/available-games`)
return response.data
},
approve: async (id: number): Promise<Game> => { approve: async (id: number): Promise<Game> => {
const response = await client.post<Game>(`/games/${id}/approve`) const response = await client.post<Game>(`/games/${id}/approve`)
return response.data return response.data

View File

@@ -1,5 +1,5 @@
import client from './client' import client from './client'
import type { Marathon, MarathonListItem, MarathonPublicInfo, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode } from '@/types' import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute } from '@/types'
export interface CreateMarathonData { export interface CreateMarathonData {
title: string title: string
@@ -10,6 +10,8 @@ export interface CreateMarathonData {
game_proposal_mode?: GameProposalMode game_proposal_mode?: GameProposalMode
} }
export type { MarathonUpdate }
export const marathonsApi = { export const marathonsApi = {
list: async (): Promise<MarathonListItem[]> => { list: async (): Promise<MarathonListItem[]> => {
const response = await client.get<MarathonListItem[]>('/marathons') const response = await client.get<MarathonListItem[]>('/marathons')
@@ -32,7 +34,7 @@ export const marathonsApi = {
return response.data return response.data
}, },
update: async (id: number, data: Partial<CreateMarathonData>): Promise<Marathon> => { update: async (id: number, data: MarathonUpdate): Promise<Marathon> => {
const response = await client.patch<Marathon>(`/marathons/${id}`, data) const response = await client.patch<Marathon>(`/marathons/${id}`, data)
return response.data return response.data
}, },
@@ -78,4 +80,36 @@ export const marathonsApi = {
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`) const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
return response.data return response.data
}, },
uploadCover: async (id: number, file: File): Promise<Marathon> => {
const formData = new FormData()
formData.append('file', file)
const response = await client.post<Marathon>(`/marathons/${id}/cover`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
},
deleteCover: async (id: number): Promise<Marathon> => {
const response = await client.delete<Marathon>(`/marathons/${id}/cover`)
return response.data
},
// Disputes management for organizers
listDisputes: async (id: number, status: 'open' | 'all' = 'open'): Promise<MarathonDispute[]> => {
const response = await client.get<MarathonDispute[]>(`/marathons/${id}/disputes`, {
params: { status_filter: status }
})
return response.data
},
resolveDispute: async (marathonId: number, disputeId: number, isValid: boolean): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(
`/marathons/${marathonId}/disputes/${disputeId}/resolve`,
{ is_valid: isValid }
)
return response.data
},
} }

View File

@@ -1,5 +1,5 @@
import client from './client' import client from './client'
import type { User, UserProfilePublic, UserStats, PasswordChangeData } from '@/types' import type { User, UserProfilePublic, UserStats, PasswordChangeData, NotificationSettings, NotificationSettingsUpdate } from '@/types'
export interface UpdateNicknameData { export interface UpdateNicknameData {
nickname: string nickname: string
@@ -48,4 +48,16 @@ export const usersApi = {
}) })
return URL.createObjectURL(response.data) return URL.createObjectURL(response.data)
}, },
// Получить настройки уведомлений
getNotificationSettings: async (): Promise<NotificationSettings> => {
const response = await client.get<NotificationSettings>('/users/me/notifications')
return response.data
},
// Обновить настройки уведомлений
updateNotificationSettings: async (data: NotificationSettingsUpdate): Promise<NotificationSettings> => {
const response = await client.patch<NotificationSettings>('/users/me/notifications', data)
return response.data
},
} }

View File

@@ -14,12 +14,19 @@ export const wheelApi = {
complete: async ( complete: async (
assignmentId: number, assignmentId: number,
data: { proof_url?: string; comment?: string; proof_file?: File } data: { proof_url?: string; comment?: string; proof_file?: File; proof_files?: File[] }
): Promise<CompleteResult> => { ): Promise<CompleteResult> => {
const formData = new FormData() const formData = new FormData()
if (data.proof_url) formData.append('proof_url', data.proof_url) if (data.proof_url) formData.append('proof_url', data.proof_url)
if (data.comment) formData.append('comment', data.comment) if (data.comment) formData.append('comment', data.comment)
// Support both single file (legacy) and multiple files
if (data.proof_file) formData.append('proof_file', data.proof_file) if (data.proof_file) formData.append('proof_file', data.proof_file)
if (data.proof_files && data.proof_files.length > 0) {
data.proof_files.forEach(file => {
formData.append('proof_files', file)
})
}
const response = await client.post<CompleteResult>(`/assignments/${assignmentId}/complete`, formData, { const response = await client.post<CompleteResult>(`/assignments/${assignmentId}/complete`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },

View File

@@ -191,14 +191,15 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
const isEvent = isEventActivity(activity.type) const isEvent = isEventActivity(activity.type)
const { title, details, extra } = formatActivityMessage(activity) const { title, details, extra } = formatActivityMessage(activity)
// Get assignment_id and dispute status for complete activities // Get assignment_id, dispute status, and is_redo for complete activities
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null const activityData = activity.data as { assignment_id?: number; dispute_status?: string; is_redo?: boolean } | null
const assignmentId = activity.type === 'complete' && activityData?.assignment_id const assignmentId = activity.type === 'complete' && activityData?.assignment_id
? activityData.assignment_id ? activityData.assignment_id
: null : null
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
? activityData.dispute_status ? activityData.dispute_status
: null : null
const isRedo = activity.type === 'complete' && activityData?.is_redo === true
// Determine accent color based on activity type // Determine accent color based on activity type
const getAccentConfig = () => { const getAccentConfig = () => {
@@ -323,6 +324,12 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3" />
Детали Детали
</button> </button>
{isRedo && (
<span className="text-xs text-purple-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-purple-500/10">
<Zap className="w-3 h-3" />
Перепрохождение
</span>
)}
{disputeStatus === 'open' && ( {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"> <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" /> <AlertTriangle className="w-3 h-3" />

View File

@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react'
import { contentApi } from '@/api/admin'
import { Megaphone, X } from 'lucide-react'
const STORAGE_KEY = 'announcement_dismissed'
export function AnnouncementBanner() {
const [content, setContent] = useState<string | null>(null)
const [title, setTitle] = useState<string | null>(null)
const [updatedAt, setUpdatedAt] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const loadAnnouncement = async () => {
try {
const data = await contentApi.getPublicContent('announcement')
// Check if this announcement was already dismissed (by updated_at)
const dismissedAt = localStorage.getItem(STORAGE_KEY)
if (dismissedAt === data.updated_at) {
setContent(null)
} else {
setContent(data.content)
setTitle(data.title)
setUpdatedAt(data.updated_at)
}
} catch {
// No announcement or error - don't show
setContent(null)
} finally {
setIsLoading(false)
}
}
loadAnnouncement()
}, [])
const handleDismiss = () => {
if (updatedAt) {
// Store the updated_at to know which announcement was dismissed
// When admin updates announcement, updated_at changes and banner shows again
localStorage.setItem(STORAGE_KEY, updatedAt)
setContent(null)
}
}
if (isLoading || !content) {
return null
}
return (
<div className="relative rounded-xl overflow-hidden bg-gradient-to-r from-accent-500/20 via-purple-500/20 to-pink-500/20 border border-accent-500/30">
{/* Close button */}
<button
onClick={handleDismiss}
className="absolute top-3 right-3 p-1.5 text-white bg-dark-700/70 hover:bg-dark-600 rounded-lg transition-colors z-10"
title="Скрыть"
>
<X className="w-4 h-4" />
</button>
{/* Content */}
<div className="p-4 pr-12 flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 border border-accent-500/30 flex items-center justify-center flex-shrink-0">
<Megaphone className="w-5 h-5 text-accent-400" />
</div>
<div>
{title && (
<h3 className="font-semibold text-white mb-1">{title}</h3>
)}
<div
className="text-sm text-gray-300"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</div>
</div>
)
}

View File

@@ -10,6 +10,7 @@ interface BanInfo {
interface BannedScreenProps { interface BannedScreenProps {
banInfo: BanInfo banInfo: BanInfo
onLogout?: () => void
} }
function formatDate(dateStr: string | null) { function formatDate(dateStr: string | null) {
@@ -24,8 +25,9 @@ function formatDate(dateStr: string | null) {
}) + ' (МСК)' }) + ' (МСК)'
} }
export function BannedScreen({ banInfo }: BannedScreenProps) { export function BannedScreen({ banInfo, onLogout }: BannedScreenProps) {
const logout = useAuthStore((state) => state.logout) const storeLogout = useAuthStore((state) => state.logout)
const handleLogout = onLogout || storeLogout
const bannedAtFormatted = formatDate(banInfo.banned_at) const bannedAtFormatted = formatDate(banInfo.banned_at)
const bannedUntilFormatted = formatDate(banInfo.banned_until) const bannedUntilFormatted = formatDate(banInfo.banned_until)
@@ -112,7 +114,7 @@ export function BannedScreen({ banInfo }: BannedScreenProps) {
<NeonButton <NeonButton
variant="secondary" variant="secondary"
size="lg" size="lg"
onClick={logout} onClick={handleLogout}
icon={<LogOut className="w-5 h-5" />} icon={<LogOut className="w-5 h-5" />}
> >
Выйти из аккаунта Выйти из аккаунта

View File

@@ -0,0 +1,501 @@
import { useState, useRef, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { marathonsApi } from '@/api'
import type { Marathon, GameProposalMode } from '@/types'
import { NeonButton, Input } from '@/components/ui'
import { useToast } from '@/store/toast'
import {
X, Camera, Trash2, Loader2, Save, Globe, Lock, Users, UserCog, Sparkles, Zap
} from 'lucide-react'
const settingsSchema = z.object({
title: z.string().min(1, 'Название обязательно').max(100, 'Максимум 100 символов'),
description: z.string().optional(),
start_date: z.string().min(1, 'Дата начала обязательна'),
is_public: z.boolean(),
game_proposal_mode: z.enum(['all_participants', 'organizer_only']),
auto_events_enabled: z.boolean(),
})
type SettingsForm = z.infer<typeof settingsSchema>
interface MarathonSettingsModalProps {
marathon: Marathon
isOpen: boolean
onClose: () => void
onUpdate: (marathon: Marathon) => void
}
export function MarathonSettingsModal({
marathon,
isOpen,
onClose,
onUpdate,
}: MarathonSettingsModalProps) {
const toast = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting, isDirty },
} = useForm<SettingsForm>({
resolver: zodResolver(settingsSchema),
defaultValues: {
title: marathon.title,
description: marathon.description || '',
start_date: marathon.start_date
? new Date(marathon.start_date).toISOString().slice(0, 16)
: '',
is_public: marathon.is_public,
game_proposal_mode: marathon.game_proposal_mode as GameProposalMode,
auto_events_enabled: marathon.auto_events_enabled,
},
})
const isPublic = watch('is_public')
const gameProposalMode = watch('game_proposal_mode')
const autoEventsEnabled = watch('auto_events_enabled')
// Reset form when marathon changes
useEffect(() => {
reset({
title: marathon.title,
description: marathon.description || '',
start_date: marathon.start_date
? new Date(marathon.start_date).toISOString().slice(0, 16)
: '',
is_public: marathon.is_public,
game_proposal_mode: marathon.game_proposal_mode as GameProposalMode,
auto_events_enabled: marathon.auto_events_enabled,
})
setCoverPreview(null)
}, [marathon, reset])
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose])
// 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])
const onSubmit = async (data: SettingsForm) => {
try {
const updated = await marathonsApi.update(marathon.id, {
title: data.title,
description: data.description || undefined,
start_date: new Date(data.start_date).toISOString(),
is_public: data.is_public,
game_proposal_mode: data.game_proposal_mode,
auto_events_enabled: data.auto_events_enabled,
})
onUpdate(updated)
toast.success('Настройки сохранены')
onClose()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось сохранить настройки')
}
}
const handleCoverClick = () => {
fileInputRef.current?.click()
}
const handleCoverChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast.error('Файл должен быть изображением')
return
}
if (file.size > 5 * 1024 * 1024) {
toast.error('Максимальный размер файла 5 МБ')
return
}
// Show preview immediately
const previewUrl = URL.createObjectURL(file)
setCoverPreview(previewUrl)
setIsUploading(true)
try {
const updated = await marathonsApi.uploadCover(marathon.id, file)
onUpdate(updated)
toast.success('Обложка загружена')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось загрузить обложку')
setCoverPreview(null)
} finally {
setIsUploading(false)
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
const handleDeleteCover = async () => {
setIsDeleting(true)
try {
const updated = await marathonsApi.deleteCover(marathon.id)
onUpdate(updated)
setCoverPreview(null)
toast.success('Обложка удалена')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось удалить обложку')
} finally {
setIsDeleting(false)
}
}
if (!isOpen) return null
const displayCover = coverPreview || marathon.cover_url
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={onClose}
/>
{/* Modal */}
<div className="relative glass rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto animate-in zoom-in-95 fade-in duration-200 border border-dark-600 custom-scrollbar">
{/* Header */}
<div className="sticky top-0 z-10 bg-dark-800/95 backdrop-blur-sm border-b border-dark-600 px-6 py-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">Настройки марафона</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors p-1"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-6">
{/* Cover Image */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Обложка марафона
</label>
<div className="relative group">
<button
type="button"
onClick={handleCoverClick}
disabled={isUploading || isDeleting}
className="relative w-full h-48 rounded-xl overflow-hidden bg-dark-700 border-2 border-dashed border-dark-500 hover:border-neon-500/50 transition-all"
>
{displayCover ? (
<img
src={displayCover}
alt="Обложка марафона"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-gray-500">
<Camera className="w-10 h-10 mb-2" />
<span className="text-sm">Нажмите для загрузки</span>
<span className="text-xs text-gray-600 mt-1">JPG, PNG до 5 МБ</span>
</div>
)}
{(isUploading || isDeleting) && (
<div className="absolute inset-0 bg-dark-900/80 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
</div>
)}
{displayCover && !isUploading && !isDeleting && (
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="w-8 h-8 text-neon-500" />
<span className="ml-2 text-white">Изменить</span>
</div>
)}
</button>
{displayCover && !isUploading && !isDeleting && (
<button
type="button"
onClick={handleDeleteCover}
className="absolute top-2 right-2 p-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleCoverChange}
className="hidden"
/>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Title */}
<Input
label="Название"
placeholder="Введите название марафона"
error={errors.title?.message}
{...register('title')}
/>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Описание (необязательно)
</label>
<textarea
className="input min-h-[100px] resize-none w-full"
placeholder="Расскажите о вашем марафоне..."
{...register('description')}
/>
</div>
{/* Start date */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Дата начала
</label>
<input
type="datetime-local"
className="input w-full"
{...register('start_date')}
/>
{errors.start_date && (
<p className="text-red-400 text-xs mt-1">{errors.start_date.message}</p>
)}
</div>
{/* Marathon type */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Тип марафона
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setValue('is_public', false, { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
${!isPublic
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${!isPublic ? 'bg-neon-500/20' : 'bg-dark-600'}
`}>
<Lock className={`w-5 h-5 ${!isPublic ? 'text-neon-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
Закрытый
</div>
<div className="text-xs text-gray-500">
Вход только по коду приглашения
</div>
{!isPublic && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-neon-400" />
</div>
)}
</button>
<button
type="button"
onClick={() => setValue('is_public', true, { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
${isPublic
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${isPublic ? 'bg-accent-500/20' : 'bg-dark-600'}
`}>
<Globe className={`w-5 h-5 ${isPublic ? 'text-accent-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${isPublic ? 'text-white' : 'text-gray-300'}`}>
Открытый
</div>
<div className="text-xs text-gray-500">
Виден всем пользователям
</div>
{isPublic && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-accent-400" />
</div>
)}
</button>
</div>
</div>
{/* Game proposal mode */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Кто может предлагать игры
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'all_participants', { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
${gameProposalMode === 'all_participants'
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${gameProposalMode === 'all_participants' ? 'bg-neon-500/20' : 'bg-dark-600'}
`}>
<Users className={`w-5 h-5 ${gameProposalMode === 'all_participants' ? 'text-neon-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
Все участники
</div>
<div className="text-xs text-gray-500">
С модерацией организатором
</div>
{gameProposalMode === 'all_participants' && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-neon-400" />
</div>
)}
</button>
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'organizer_only', { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
${gameProposalMode === 'organizer_only'
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${gameProposalMode === 'organizer_only' ? 'bg-accent-500/20' : 'bg-dark-600'}
`}>
<UserCog className={`w-5 h-5 ${gameProposalMode === 'organizer_only' ? 'text-accent-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
Только организатор
</div>
<div className="text-xs text-gray-500">
Без модерации
</div>
{gameProposalMode === 'organizer_only' && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-accent-400" />
</div>
)}
</button>
</div>
</div>
{/* Auto events toggle */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Автоматические события
</label>
<button
type="button"
onClick={() => setValue('auto_events_enabled', !autoEventsEnabled, { shouldDirty: true })}
className={`
w-full p-4 rounded-xl border-2 transition-all duration-300 text-left flex items-center gap-4
${autoEventsEnabled
? 'border-yellow-500/50 bg-yellow-500/10'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl flex items-center justify-center transition-colors flex-shrink-0
${autoEventsEnabled ? 'bg-yellow-500/20' : 'bg-dark-600'}
`}>
<Zap className={`w-5 h-5 ${autoEventsEnabled ? 'text-yellow-400' : 'text-gray-400'}`} />
</div>
<div className="flex-1">
<div className={`font-semibold ${autoEventsEnabled ? 'text-white' : 'text-gray-300'}`}>
{autoEventsEnabled ? 'Включены' : 'Выключены'}
</div>
<div className="text-xs text-gray-500">
Случайные бонусные события во время марафона
</div>
</div>
<div className={`
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
${autoEventsEnabled ? 'bg-yellow-500' : 'bg-dark-600'}
`}>
<div className={`
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
${autoEventsEnabled ? 'left-6' : 'left-1'}
`} />
</div>
</button>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-dark-600">
<NeonButton
type="button"
variant="outline"
className="flex-1"
onClick={onClose}
>
Отмена
</NeonButton>
<NeonButton
type="submit"
className="flex-1"
isLoading={isSubmitting}
disabled={!isDirty}
icon={<Save className="w-4 h-4" />}
>
Сохранить
</NeonButton>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo } from 'react' import { useState, useCallback, useMemo, useEffect } from 'react'
import type { Game } from '@/types' import type { Game } from '@/types'
import { Gamepad2, Loader2 } from 'lucide-react' import { Gamepad2, Loader2 } from 'lucide-react'
@@ -9,27 +9,43 @@ interface SpinWheelProps {
disabled?: boolean disabled?: boolean
} }
const SPIN_DURATION = 5000 // ms const SPIN_DURATION = 6000 // ms - увеличено для более плавного замедления
const EXTRA_ROTATIONS = 5 const EXTRA_ROTATIONS = 7 // больше оборотов для эффекта инерции
// Цветовая палитра секторов // Пороги для адаптивного отображения
const TEXT_THRESHOLD = 16 // До 16 игр - показываем текст
const LINES_THRESHOLD = 40 // До 40 игр - показываем разделители
// Цветовая палитра секторов (расширенная для большего количества)
const SECTOR_COLORS = [ const SECTOR_COLORS = [
{ bg: '#0d9488', border: '#14b8a6' }, // teal { bg: '#0d9488', border: '#14b8a6' }, // teal
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet { bg: '#7c3aed', border: '#8b5cf6' }, // violet
{ bg: '#0891b2', border: '#06b6d4' }, // cyan { bg: '#0891b2', border: '#06b6d4' }, // cyan
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia { bg: '#c026d3', border: '#d946ef' }, // fuchsia
{ bg: '#059669', border: '#10b981' }, // emerald { bg: '#059669', border: '#10b981' }, // emerald
{ bg: '#7c2d12', border: '#ea580c' }, // orange { bg: '#ea580c', border: '#f97316' }, // orange
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue { bg: '#1d4ed8', border: '#3b82f6' }, // blue
{ bg: '#be123c', border: '#e11d48' }, // rose { bg: '#be123c', border: '#e11d48' }, // rose
{ bg: '#4f46e5', border: '#6366f1' }, // indigo
{ bg: '#0284c7', border: '#0ea5e9' }, // sky
{ bg: '#9333ea', border: '#a855f7' }, // purple
{ bg: '#16a34a', border: '#22c55e' }, // green
] ]
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) { export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
const [isSpinning, setIsSpinning] = useState(false) const [isSpinning, setIsSpinning] = useState(false)
const [rotation, setRotation] = useState(0) const [rotation, setRotation] = useState(0)
const [displayedGame, setDisplayedGame] = useState<Game | null>(null)
const [spinStartTime, setSpinStartTime] = useState<number | null>(null)
const [startRotation, setStartRotation] = useState(0)
const [targetRotation, setTargetRotation] = useState(0)
// Размеры колеса // Определяем режим отображения
const wheelSize = 400 const showText = games.length <= TEXT_THRESHOLD
const showLines = games.length <= LINES_THRESHOLD
// Размеры колеса - увеличиваем для большого количества игр
const wheelSize = games.length > 50 ? 450 : games.length > 30 ? 420 : 400
const centerX = wheelSize / 2 const centerX = wheelSize / 2
const centerY = wheelSize / 2 const centerY = wheelSize / 2
const radius = wheelSize / 2 - 10 const radius = wheelSize / 2 - 10
@@ -102,11 +118,16 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
const fullRotations = EXTRA_ROTATIONS * 360 const fullRotations = EXTRA_ROTATIONS * 360
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
setRotation(rotation + finalAngle) const newRotation = rotation + finalAngle
setStartRotation(rotation)
setTargetRotation(newRotation)
setSpinStartTime(Date.now())
setRotation(newRotation)
// Ждём окончания анимации // Ждём окончания анимации
setTimeout(() => { setTimeout(() => {
setIsSpinning(false) setIsSpinning(false)
setSpinStartTime(null)
onSpinComplete(resultGame) onSpinComplete(resultGame)
}, SPIN_DURATION) }, SPIN_DURATION)
}, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete]) }, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete])
@@ -117,13 +138,67 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
return text.slice(0, maxLength - 2) + '...' return text.slice(0, maxLength - 2) + '...'
} }
// Функция для вычисления игры под указателем по углу
const getGameAtAngle = useCallback((currentRotation: number) => {
if (games.length === 0) return null
const normalizedRotation = ((currentRotation % 360) + 360) % 360
const angleUnderPointer = (360 - normalizedRotation + 360) % 360
const sectorIndex = Math.floor(angleUnderPointer / sectorAngle) % games.length
return games[sectorIndex] || null
}, [games, sectorAngle])
// Вычисляем игру под указателем (статическое состояние)
const currentGameUnderPointer = useMemo(() => {
return getGameAtAngle(rotation)
}, [rotation, getGameAtAngle])
// Easing функция для имитации инерции - быстрый старт, долгое замедление
// Аппроксимирует CSS cubic-bezier(0.12, 0.9, 0.15, 1)
const easeOutExpo = useCallback((t: number): number => {
// Экспоненциальное замедление - очень быстро в начале, очень медленно в конце
return t === 1 ? 1 : 1 - Math.pow(2, -12 * t)
}, [])
// Отслеживаем позицию во время вращения
useEffect(() => {
if (!isSpinning || spinStartTime === null) {
// Когда не крутится - показываем текущую игру под указателем
if (currentGameUnderPointer) {
setDisplayedGame(currentGameUnderPointer)
}
return
}
const totalDelta = targetRotation - startRotation
const updateDisplayedGame = () => {
const elapsed = Date.now() - spinStartTime
const progress = Math.min(elapsed / SPIN_DURATION, 1)
const easedProgress = easeOutExpo(progress)
// Вычисляем текущий угол на основе прогресса анимации
const currentAngle = startRotation + (totalDelta * easedProgress)
const game = getGameAtAngle(currentAngle)
if (game) {
setDisplayedGame(game)
}
}
// Обновляем каждые 30мс для плавности
const interval = setInterval(updateDisplayedGame, 30)
updateDisplayedGame() // Сразу обновляем
return () => clearInterval(interval)
}, [isSpinning, spinStartTime, startRotation, targetRotation, getGameAtAngle, currentGameUnderPointer, easeOutExpo])
// Мемоизируем секторы для производительности // Мемоизируем секторы для производительности
const sectors = useMemo(() => { const sectors = useMemo(() => {
return games.map((game, index) => { return games.map((game, index) => {
const color = SECTOR_COLORS[index % SECTOR_COLORS.length] const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
const path = createSectorPath(index, games.length) const path = createSectorPath(index, games.length)
const textPos = getTextPosition(index, games.length) const textPos = getTextPosition(index, games.length)
const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18 const maxTextLength = games.length > 12 ? 8 : games.length > 8 ? 10 : games.length > 5 ? 14 : 18
return { game, color, path, textPos, maxTextLength } return { game, color, path, textPos, maxTextLength }
}) })
@@ -213,7 +288,8 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
transform: `rotate(${rotation}deg)`, transform: `rotate(${rotation}deg)`,
transitionProperty: isSpinning ? 'transform' : 'none', transitionProperty: isSpinning ? 'transform' : 'none',
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms', transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
transitionTimingFunction: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)', // Инерционное вращение: быстрый старт, долгое плавное замедление
transitionTimingFunction: 'cubic-bezier(0.12, 0.9, 0.15, 1)',
}} }}
> >
<defs> <defs>
@@ -230,12 +306,13 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
<path <path
d={path} d={path}
fill={color.bg} fill={color.bg}
stroke={color.border} stroke={showLines ? color.border : 'transparent'}
strokeWidth="2" strokeWidth={showLines ? "1" : "0"}
filter="url(#sectorShadow)" filter="url(#sectorShadow)"
/> />
{/* Текст названия игры */} {/* Текст названия игры - только для небольшого количества */}
{showText && (
<text <text
x={textPos.x} x={textPos.x}
y={textPos.y} y={textPos.y}
@@ -243,7 +320,7 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
fill="white" fill="white"
fontSize={games.length > 10 ? "10" : games.length > 6 ? "11" : "13"} fontSize={games.length > 12 ? "9" : games.length > 8 ? "10" : games.length > 6 ? "11" : "13"}
fontWeight="bold" fontWeight="bold"
style={{ style={{
textShadow: '0 1px 3px rgba(0,0,0,0.8)', textShadow: '0 1px 3px rgba(0,0,0,0.8)',
@@ -252,16 +329,19 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
> >
{truncateText(game.title, maxTextLength)} {truncateText(game.title, maxTextLength)}
</text> </text>
)}
{/* Разделительная линия */} {/* Разделительная линия - только для среднего количества */}
{showLines && (
<line <line
x1={centerX} x1={centerX}
y1={centerY} y1={centerY}
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)} x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)} y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
stroke="rgba(255,255,255,0.3)" stroke="rgba(255,255,255,0.2)"
strokeWidth="1" strokeWidth="1"
/> />
)}
</g> </g>
))} ))}
@@ -322,6 +402,21 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
)} )}
</div> </div>
{/* Название текущей игры (для большого количества) */}
{!showText && (
<div className="glass rounded-xl px-6 py-3 min-w-[280px] text-center">
<p className="text-xs text-gray-500 mb-1">
{games.length} игр в колесе
</p>
<p className={`
font-semibold transition-all duration-100 truncate max-w-[280px]
${isSpinning ? 'text-neon-400 animate-pulse' : 'text-white'}
`}>
{displayedGame?.title || 'Крутите колесо!'}
</p>
</div>
)}
{/* Подсказка */} {/* Подсказка */}
<p className={` <p className={`
text-sm transition-all duration-300 text-sm transition-all duration-300

View File

@@ -0,0 +1,100 @@
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { contentApi } from '@/api/admin'
import { NeonButton } from '@/components/ui'
import { Bot, Bell, X } from 'lucide-react'
import { useState, useEffect } from 'react'
const STORAGE_KEY = 'telegram_banner_dismissed'
// Default content if not configured in admin
const DEFAULT_TITLE = 'Привяжите Telegram-бота'
const DEFAULT_DESCRIPTION = 'Получайте уведомления о событиях марафона, новых заданиях и результатах прямо в Telegram'
export function TelegramBotBanner() {
const user = useAuthStore((state) => state.user)
const [dismissed, setDismissed] = useState(() => {
return sessionStorage.getItem(STORAGE_KEY) === 'true'
})
const [title, setTitle] = useState(DEFAULT_TITLE)
const [description, setDescription] = useState(DEFAULT_DESCRIPTION)
useEffect(() => {
const loadContent = async () => {
try {
const data = await contentApi.getPublicContent('telegram_bot_info')
if (data.title) setTitle(data.title)
if (data.content) setDescription(data.content)
} catch {
// Use defaults if content not found
}
}
loadContent()
}, [])
const handleDismiss = () => {
sessionStorage.setItem(STORAGE_KEY, 'true')
setDismissed(true)
}
// Don't show if user already has Telegram linked or dismissed
if (user?.telegram_id || dismissed) {
return null
}
return (
<div className="relative rounded-2xl overflow-hidden">
{/* Background image */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: 'url(/telegram_bot_banner.png)' }}
/>
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-dark-900/95 via-dark-900/80 to-dark-900/60" />
{/* Close button */}
<button
onClick={handleDismiss}
className="absolute top-3 right-3 p-1.5 text-white bg-dark-700/70 hover:bg-dark-600 rounded-lg transition-colors z-10"
title="Скрыть"
>
<X className="w-4 h-4" />
</button>
{/* Content */}
<div className="relative p-6 pr-12 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-[#2AABEE]/20 border border-[#2AABEE]/30 flex items-center justify-center flex-shrink-0">
<Bot className="w-6 h-6 text-[#2AABEE]" />
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-1">
{title}
</h3>
<p className="text-gray-400 text-sm max-w-md">
{description}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Bell className="w-3 h-3" />
Мгновенные уведомления
</span>
<span className="flex items-center gap-1">
<Bot className="w-3 h-3" />
Удобное управление
</span>
</div>
</div>
</div>
<div className="ml-16 sm:ml-0">
<Link to="/profile">
<NeonButton color="neon" size="sm">
Привязать
</NeonButton>
</Link>
</div>
</div>
</div>
)
}

View File

@@ -234,7 +234,13 @@ export function Layout() {
Игровой Марафон &copy; {new Date().getFullYear()} Игровой Марафон &copy; {new Date().getFullYear()}
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-sm text-gray-500"> <div className="flex items-center gap-6 text-sm">
<Link to="/terms" className="text-gray-500 hover:text-gray-300 transition-colors">
Правила
</Link>
<Link to="/privacy" className="text-gray-500 hover:text-gray-300 transition-colors">
Конфиденциальность
</Link>
<span className="text-neon-500/50">v1.0</span> <span className="text-neon-500/50">v1.0</span>
</div> </div>
</div> </div>

View File

@@ -67,18 +67,28 @@ export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
}, },
} }
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 = { const iconSizes = {
sm: 'w-4 h-4', sm: 'w-4 h-4',
md: 'w-5 h-5', md: 'w-5 h-5',
lg: 'w-6 h-6', lg: 'w-6 h-6',
} }
const isIconOnly = icon && !children
const sizeClassesWithText = {
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 sizeClassesIconOnly = {
sm: 'p-2 text-sm',
md: 'p-2.5 text-base',
lg: 'p-3 text-lg',
}
const sizeClasses = isIconOnly ? sizeClassesIconOnly : sizeClassesWithText
const colors = colorMap[color] const colors = colorMap[color]
return ( return (
@@ -118,13 +128,9 @@ export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
{...props} {...props}
> >
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />} {isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
{!isLoading && icon && iconPosition === 'left' && ( {!isLoading && icon && iconPosition === 'left' && icon}
<span className={iconSizes[size]}>{icon}</span>
)}
{children} {children}
{!isLoading && icon && iconPosition === 'right' && ( {!isLoading && icon && iconPosition === 'right' && icon}
<span className={iconSizes[size]}>{icon}</span>
)}
</button> </button>
) )
} }

View File

@@ -8,7 +8,7 @@ import { useToast } from '@/store/toast'
import { import {
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare, ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
Send, Flag, Gamepad2, Zap, Trophy Send, Flag, Gamepad2, Zap, Trophy, Download, ChevronLeft, ChevronRight, X
} from 'lucide-react' } from 'lucide-react'
export function AssignmentDetailPage() { export function AssignmentDetailPage() {
@@ -23,11 +23,30 @@ export function AssignmentDetailPage() {
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null) const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null) const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
// Multiple proof files
const [proofFiles, setProofFiles] = useState<Array<{ id: number; url: string; type: 'image' | 'video' }>>([])
// Bonus proof media
const [bonusProofMedia, setBonusProofMedia] = useState<Record<number, { url: string; type: 'image' | 'video' }>>({})
// Bonus proof files (multiple)
const [bonusProofFiles, setBonusProofFiles] = useState<Record<number, Array<{ id: number; url: string; type: 'image' | 'video' }>>>({})
// Lightbox state
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
const [lightboxItems, setLightboxItems] = useState<Array<{ url: string; type: 'image' | 'video' }>>([])
// Dispute creation // Dispute creation
const [showDisputeForm, setShowDisputeForm] = useState(false) const [showDisputeForm, setShowDisputeForm] = useState(false)
const [disputeReason, setDisputeReason] = useState('') const [disputeReason, setDisputeReason] = useState('')
const [isCreatingDispute, setIsCreatingDispute] = useState(false) const [isCreatingDispute, setIsCreatingDispute] = useState(false)
// Bonus dispute creation
const [activeBonusDisputeId, setActiveBonusDisputeId] = useState<number | null>(null)
const [bonusDisputeReason, setBonusDisputeReason] = useState('')
const [isCreatingBonusDispute, setIsCreatingBonusDispute] = useState(false)
// Comment // Comment
const [commentText, setCommentText] = useState('') const [commentText, setCommentText] = useState('')
const [isAddingComment, setIsAddingComment] = useState(false) const [isAddingComment, setIsAddingComment] = useState(false)
@@ -38,10 +57,24 @@ export function AssignmentDetailPage() {
useEffect(() => { useEffect(() => {
loadAssignment() loadAssignment()
return () => { return () => {
// Cleanup blob URL on unmount // Cleanup blob URLs on unmount
if (proofMediaBlobUrl) { if (proofMediaBlobUrl) {
URL.revokeObjectURL(proofMediaBlobUrl) URL.revokeObjectURL(proofMediaBlobUrl)
} }
proofFiles.forEach(file => {
URL.revokeObjectURL(file.url)
})
Object.values(bonusProofMedia).forEach(media => {
URL.revokeObjectURL(media.url)
})
Object.values(bonusProofFiles).forEach(files => {
files.forEach(file => {
URL.revokeObjectURL(file.url)
})
})
lightboxItems.forEach(item => {
URL.revokeObjectURL(item.url)
})
} }
}, [id]) }, [id])
@@ -53,8 +86,20 @@ export function AssignmentDetailPage() {
const data = await assignmentsApi.getDetail(parseInt(id)) const data = await assignmentsApi.getDetail(parseInt(id))
setAssignment(data) setAssignment(data)
// Load proof media if exists // Load proof files if exists (new multi-file support)
if (data.proof_image_url) { if (data.proof_files && data.proof_files.length > 0) {
const files: Array<{ id: number; url: string; type: 'image' | 'video' }> = []
for (const proofFile of data.proof_files) {
try {
const { url, type } = await assignmentsApi.getProofFileMediaUrl(parseInt(id), proofFile.id)
files.push({ id: proofFile.id, url, type })
} catch {
// Ignore error, file just won't show
}
}
setProofFiles(files)
} else if (data.proof_image_url) {
// Legacy: Load single proof media if exists
try { try {
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id)) const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
setProofMediaBlobUrl(url) setProofMediaBlobUrl(url)
@@ -63,6 +108,39 @@ export function AssignmentDetailPage() {
// Ignore error, media just won't show // Ignore error, media just won't show
} }
} }
// Load bonus proof files for playthrough
if (data.is_playthrough && data.bonus_challenges) {
const bonusMedia: Record<number, { url: string; type: 'image' | 'video' }> = {}
const bonusFiles: Record<number, Array<{ id: number; url: string; type: 'image' | 'video' }>> = {}
for (const bonus of data.bonus_challenges) {
// New multi-file support
if (bonus.proof_files && bonus.proof_files.length > 0) {
const files: Array<{ id: number; url: string; type: 'image' | 'video' }> = []
for (const proofFile of bonus.proof_files) {
try {
const { url, type } = await assignmentsApi.getBonusProofFileMediaUrl(parseInt(id), bonus.id, proofFile.id)
files.push({ id: proofFile.id, url, type })
} catch {
// Ignore error, file just won't show
}
}
bonusFiles[bonus.id] = files
} else if (bonus.proof_image_url) {
// Legacy: single file
try {
const { url, type } = await assignmentsApi.getBonusProofMediaUrl(parseInt(id), bonus.id)
bonusMedia[bonus.id] = { url, type }
} catch {
// Ignore error, media just won't show
}
}
}
setBonusProofMedia(bonusMedia)
setBonusProofFiles(bonusFiles)
}
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || 'Не удалось загрузить данные') setError(error.response?.data?.detail || 'Не удалось загрузить данные')
@@ -88,6 +166,37 @@ export function AssignmentDetailPage() {
} }
} }
const handleCreateBonusDispute = async (bonusId: number) => {
if (!bonusDisputeReason.trim()) return
setIsCreatingBonusDispute(true)
try {
await assignmentsApi.createBonusDispute(bonusId, bonusDisputeReason)
setBonusDisputeReason('')
setActiveBonusDisputeId(null)
await loadAssignment()
toast.success('Оспаривание бонуса создано')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание')
} finally {
setIsCreatingBonusDispute(false)
}
}
const handleBonusVote = async (disputeId: number, vote: boolean) => {
setIsVoting(true)
try {
await assignmentsApi.vote(disputeId, vote)
await loadAssignment()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось проголосовать')
} finally {
setIsVoting(false)
}
}
const handleVote = async (vote: boolean) => { const handleVote = async (vote: boolean) => {
if (!assignment?.dispute) return if (!assignment?.dispute) return
@@ -142,6 +251,24 @@ export function AssignmentDetailPage() {
return `${hours}ч ${minutes}м` return `${hours}ч ${minutes}м`
} }
const openLightbox = (items: Array<{ url: string; type: 'image' | 'video' }>, index: number) => {
setLightboxItems(items)
setLightboxIndex(index)
setLightboxOpen(true)
}
const closeLightbox = () => {
setLightboxOpen(false)
}
const nextLightboxItem = () => {
setLightboxIndex((prev) => (prev + 1) % lightboxItems.length)
}
const prevLightboxItem = () => {
setLightboxIndex((prev) => (prev - 1 + lightboxItems.length) % lightboxItems.length)
}
const getStatusConfig = (status: string) => { const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'completed': case 'completed':
@@ -215,31 +342,54 @@ export function AssignmentDetailPage() {
</div> </div>
</div> </div>
{/* Challenge info */} {/* Challenge/Playthrough info */}
<GlassCard variant="neon"> <GlassCard variant="neon">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center"> <div className={`w-14 h-14 rounded-xl border flex items-center justify-center ${
<Gamepad2 className="w-7 h-7 text-neon-400" /> assignment.is_playthrough
? 'bg-gradient-to-br from-accent-500/20 to-purple-500/20 border-accent-500/20'
: 'bg-gradient-to-br from-neon-500/20 to-accent-500/20 border-neon-500/20'
}`}>
<Gamepad2 className={`w-7 h-7 ${assignment.is_playthrough ? 'text-accent-400' : 'text-neon-400'}`} />
</div> </div>
<div> <div>
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p> <p className="text-gray-400 text-sm">
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2> {assignment.is_playthrough ? assignment.game?.title : assignment.challenge?.game.title}
</p>
<h2 className="text-xl font-bold text-white">
{assignment.is_playthrough ? 'Прохождение игры' : assignment.challenge?.title}
</h2>
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-2">
{assignment.is_playthrough && (
<span className="px-3 py-1 bg-accent-500/20 text-accent-400 rounded-full text-xs font-medium border border-accent-500/30">
Прохождение
</span>
)}
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}> <span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
{status.icon} {status.icon}
{status.text} {status.text}
</span> </span>
</div> </div>
</div>
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p> <p className="text-gray-300 mb-4">
{assignment.is_playthrough
? assignment.playthrough_info?.description
: assignment.challenge?.description}
</p>
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-2 mb-4">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5"> <span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5">
<Trophy className="w-4 h-4" /> <Trophy className="w-4 h-4" />
+{assignment.challenge.points} очков +{assignment.is_playthrough
? assignment.playthrough_info?.points
: assignment.challenge?.points} очков
</span> </span>
{!assignment.is_playthrough && assignment.challenge && (
<>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600"> <span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{assignment.challenge.difficulty} {assignment.challenge.difficulty}
</span> </span>
@@ -249,6 +399,20 @@ export function AssignmentDetailPage() {
~{assignment.challenge.estimated_time} мин ~{assignment.challenge.estimated_time} мин
</span> </span>
)} )}
</>
)}
{/* Download link */}
{(assignment.game?.download_url || assignment.challenge?.game.download_url) && (
<a
href={assignment.is_playthrough ? assignment.game?.download_url : assignment.challenge?.game.download_url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5 hover:bg-neon-500/30 transition-colors"
>
<Download className="w-4 h-4" />
Скачать игру
</a>
)}
</div> </div>
<div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1"> <div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1">
@@ -271,6 +435,224 @@ export function AssignmentDetailPage() {
</div> </div>
</GlassCard> </GlassCard>
{/* Bonus challenges for playthrough */}
{assignment.is_playthrough && assignment.bonus_challenges && assignment.bonus_challenges.length > 0 && (
<GlassCard>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Trophy className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-white">Бонусные челленджи</h3>
<p className="text-sm text-gray-400">
Выполнено: {assignment.bonus_challenges.filter((b: { status: string }) => b.status === 'completed').length} из {assignment.bonus_challenges.length}
</p>
</div>
</div>
<div className="space-y-3">
{assignment.bonus_challenges.map((bonus) => (
<div
key={bonus.id}
className={`p-4 rounded-xl border ${
bonus.dispute ? 'bg-yellow-500/10 border-yellow-500/30' :
bonus.status === 'completed'
? 'bg-green-500/10 border-green-500/30'
: 'bg-dark-700/50 border-dark-600'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{bonus.dispute ? (
<AlertTriangle className="w-4 h-4 text-yellow-400" />
) : bonus.status === 'completed' ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : null}
<span className="text-white font-medium">{bonus.challenge.title}</span>
{bonus.dispute && (
<span className={`text-xs px-2 py-0.5 rounded ${
bonus.dispute.status === 'open' ? 'bg-yellow-500/20 text-yellow-400' :
bonus.dispute.status === 'valid' ? 'bg-green-500/20 text-green-400' :
'bg-red-500/20 text-red-400'
}`}>
{bonus.dispute.status === 'open' ? 'Оспаривается' :
bonus.dispute.status === 'valid' ? 'Валидно' : 'Невалидно'}
</span>
)}
</div>
<p className="text-gray-400 text-sm">{bonus.challenge.description}</p>
{bonus.status === 'completed' && (bonus.proof_url || bonus.proof_image_url || bonus.proof_comment || bonusProofFiles[bonus.id]) && (
<div className="mt-2 text-xs space-y-2">
{/* Multiple proof files */}
{bonusProofFiles[bonus.id] && bonusProofFiles[bonus.id].length > 0 && (
<div className="flex gap-2 flex-wrap">
{bonusProofFiles[bonus.id].map((file, index) => (
<div
key={file.id}
className="relative rounded-lg overflow-hidden border border-dark-600 cursor-pointer hover:border-neon-500/50 transition-all w-24 h-24"
onClick={() => openLightbox(bonusProofFiles[bonus.id], index)}
>
{file.type === 'video' ? (
<div className="relative w-full h-full">
<video
src={file.url}
className="w-full h-full object-cover bg-dark-900"
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="w-6 h-6 rounded-full bg-neon-500/80 flex items-center justify-center">
<div className="w-0 h-0 border-l-4 border-l-white border-y-3 border-y-transparent ml-0.5"></div>
</div>
</div>
</div>
) : (
<img
src={file.url}
alt={`Proof ${index + 1}`}
className="w-full h-full object-cover bg-dark-900"
/>
)}
</div>
))}
</div>
)}
{/* Legacy: single proof media */}
{(!bonusProofFiles[bonus.id] || bonusProofFiles[bonus.id].length === 0) && bonusProofMedia[bonus.id] && (
<div className="rounded-lg overflow-hidden border border-dark-600 max-w-xs">
{bonusProofMedia[bonus.id].type === 'video' ? (
<video
src={bonusProofMedia[bonus.id].url}
controls
className="w-full max-h-32 bg-dark-900"
preload="metadata"
/>
) : (
<button
onClick={() => openLightbox([bonusProofMedia[bonus.id]], 0)}
className="w-full"
>
<img
src={bonusProofMedia[bonus.id].url}
alt="Proof"
className="w-full h-auto max-h-32 object-cover hover:opacity-80 transition-opacity"
/>
</button>
)}
</div>
)}
{bonus.proof_url && (
<a
href={bonus.proof_url}
target="_blank"
rel="noopener noreferrer"
className="text-neon-400 hover:underline flex items-center gap-1 break-all"
>
<ExternalLink className="w-3 h-3 shrink-0" />
{bonus.proof_url}
</a>
)}
{bonus.proof_comment && (
<p className="text-gray-400">"{bonus.proof_comment}"</p>
)}
</div>
)}
{/* Bonus dispute form */}
{activeBonusDisputeId === bonus.id && (
<div className="mt-3 p-3 bg-red-500/10 rounded-lg border border-red-500/30">
<textarea
className="input w-full min-h-[80px] resize-none mb-2 text-sm"
placeholder="Причина оспаривания (минимум 10 символов)..."
value={bonusDisputeReason}
onChange={(e) => setBonusDisputeReason(e.target.value)}
/>
<div className="flex gap-2">
<button
className="px-3 py-1.5 text-sm bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 disabled:opacity-50"
onClick={() => handleCreateBonusDispute(bonus.id)}
disabled={bonusDisputeReason.trim().length < 10 || isCreatingBonusDispute}
>
{isCreatingBonusDispute ? 'Создание...' : 'Оспорить'}
</button>
<button
className="px-3 py-1.5 text-sm bg-dark-600 text-gray-300 rounded-lg hover:bg-dark-500"
onClick={() => {
setActiveBonusDisputeId(null)
setBonusDisputeReason('')
}}
>
Отмена
</button>
</div>
</div>
)}
{/* Bonus dispute info */}
{bonus.dispute && (
<div className="mt-3 p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
<p className="text-xs text-gray-400 mb-1">
Оспорил: <span className="text-white">{bonus.dispute.raised_by.nickname}</span>
</p>
<p className="text-sm text-white mb-2">{bonus.dispute.reason}</p>
{bonus.dispute.status === 'open' && (
<div className="flex items-center gap-4 mt-2">
<div className="flex items-center gap-1">
<ThumbsUp className="w-3 h-3 text-green-400" />
<span className="text-green-400 text-sm font-medium">{bonus.dispute.votes_valid}</span>
</div>
<div className="flex items-center gap-1">
<ThumbsDown className="w-3 h-3 text-red-400" />
<span className="text-red-400 text-sm font-medium">{bonus.dispute.votes_invalid}</span>
</div>
<div className="flex gap-1 ml-auto">
<button
className={`p-1.5 rounded ${bonus.dispute.my_vote === true ? 'bg-green-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
onClick={() => handleBonusVote(bonus.dispute!.id, true)}
disabled={isVoting}
>
<ThumbsUp className="w-3 h-3 text-green-400" />
</button>
<button
className={`p-1.5 rounded ${bonus.dispute.my_vote === false ? 'bg-red-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
onClick={() => handleBonusVote(bonus.dispute!.id, false)}
disabled={isVoting}
>
<ThumbsDown className="w-3 h-3 text-red-400" />
</button>
</div>
</div>
)}
</div>
)}
</div>
<div className="text-right shrink-0 ml-3 flex flex-col items-end gap-2">
{bonus.status === 'completed' ? (
<span className="text-green-400 font-semibold">+{bonus.points_earned}</span>
) : (
<span className="text-gray-500">+{bonus.challenge.points}</span>
)}
{/* Dispute button for bonus */}
{bonus.can_dispute && !bonus.dispute && activeBonusDisputeId !== bonus.id && (
<button
className="text-xs px-2 py-1 text-red-400 hover:bg-red-500/10 rounded flex items-center gap-1"
onClick={() => setActiveBonusDisputeId(bonus.id)}
>
<Flag className="w-3 h-3" />
Оспорить
</button>
)}
</div>
</div>
</div>
))}
</div>
</GlassCard>
)}
{/* Proof section */} {/* Proof section */}
<GlassCard> <GlassCard>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
@@ -283,8 +665,47 @@ export function AssignmentDetailPage() {
</div> </div>
</div> </div>
{/* Proof media (image or video) */} {/* Proof files gallery (multiple proofs) */}
{assignment.proof_image_url && ( {proofFiles.length > 0 && (
<div className="mb-4">
<div className="grid grid-cols-2 gap-3">
{proofFiles.map((file, index) => (
<div
key={file.id}
className="relative rounded-xl overflow-hidden border border-dark-600 cursor-pointer hover:border-neon-500/50 transition-all group"
onClick={() => openLightbox(proofFiles, index)}
>
{file.type === 'video' ? (
<div className="relative">
<video
src={file.url}
className="w-full h-48 object-cover bg-dark-900"
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 group-hover:bg-black/30 transition-all">
<div className="w-12 h-12 rounded-full bg-neon-500/80 flex items-center justify-center">
<div className="w-0 h-0 border-l-8 border-l-white border-y-6 border-y-transparent ml-1"></div>
</div>
</div>
</div>
) : (
<img
src={file.url}
alt={`Proof ${index + 1}`}
className="w-full h-48 object-cover bg-dark-900 group-hover:opacity-90 transition-opacity"
/>
)}
<div className="absolute top-2 right-2 px-2 py-1 bg-dark-900/80 rounded text-xs text-gray-300">
{index + 1}/{proofFiles.length}
</div>
</div>
))}
</div>
</div>
)}
{/* Legacy: Single proof media (for backwards compatibility) */}
{proofFiles.length === 0 && assignment.proof_image_url && (
<div className="mb-4 rounded-xl overflow-hidden border border-dark-600"> <div className="mb-4 rounded-xl overflow-hidden border border-dark-600">
{proofMediaBlobUrl ? ( {proofMediaBlobUrl ? (
proofMediaType === 'video' ? ( proofMediaType === 'video' ? (
@@ -295,11 +716,16 @@ export function AssignmentDetailPage() {
preload="metadata" preload="metadata"
/> />
) : ( ) : (
<button
onClick={() => openLightbox([{ url: proofMediaBlobUrl, type: 'image' }], 0)}
className="w-full"
>
<img <img
src={proofMediaBlobUrl} src={proofMediaBlobUrl}
alt="Proof" alt="Proof"
className="w-full max-h-96 object-contain bg-dark-900" className="w-full max-h-96 object-contain bg-dark-900 hover:opacity-90 transition-opacity"
/> />
</button>
) )
) : ( ) : (
<div className="w-full h-48 bg-dark-900 flex items-center justify-center"> <div className="w-full h-48 bg-dark-900 flex items-center justify-center">
@@ -332,7 +758,7 @@ export function AssignmentDetailPage() {
</div> </div>
)} )}
{!assignment.proof_image_url && !assignment.proof_url && ( {proofFiles.length === 0 && !assignment.proof_image_url && !assignment.proof_url && (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center"> <div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
<Image className="w-6 h-6 text-gray-600" /> <Image className="w-6 h-6 text-gray-600" />
@@ -548,6 +974,69 @@ export function AssignmentDetailPage() {
</div> </div>
</GlassCard> </GlassCard>
)} )}
{/* Lightbox modal */}
{lightboxOpen && lightboxItems.length > 0 && (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
onClick={closeLightbox}
>
<button
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
onClick={closeLightbox}
>
<X className="w-6 h-6" />
</button>
{lightboxItems.length > 1 && (
<>
<button
className="absolute left-4 w-12 h-12 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
onClick={(e) => {
e.stopPropagation()
prevLightboxItem()
}}
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
className="absolute right-4 w-12 h-12 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
onClick={(e) => {
e.stopPropagation()
nextLightboxItem()
}}
>
<ChevronRight className="w-8 h-8" />
</button>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 bg-dark-700/80 rounded-full text-white text-sm z-10">
{lightboxIndex + 1} / {lightboxItems.length}
</div>
</>
)}
<div
className="max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center p-4"
onClick={(e) => e.stopPropagation()}
>
{lightboxItems[lightboxIndex].type === 'video' ? (
<video
src={lightboxItems[lightboxIndex].url}
controls
autoPlay
className="max-w-full max-h-full"
/>
) : (
<img
src={lightboxItems[lightboxIndex].url}
alt="Proof"
className="max-w-full max-h-full object-contain"
/>
)}
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,12 +1,13 @@
import { useState } from 'react' import { useState, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import { NeonButton, Input, GlassCard } from '@/components/ui' import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock } from 'lucide-react' import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock, Camera, Trash2 } from 'lucide-react'
import type { GameProposalMode } from '@/types' import type { GameProposalMode } from '@/types'
import { useToast } from '@/store/toast'
const createSchema = z.object({ const createSchema = z.object({
title: z.string().min(1, 'Название обязательно').max(100), title: z.string().min(1, 'Название обязательно').max(100),
@@ -21,8 +22,12 @@ type CreateForm = z.infer<typeof createSchema>
export function CreateMarathonPage() { export function CreateMarathonPage() {
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [coverFile, setCoverFile] = useState<File | null>(null)
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const { const {
register, register,
@@ -42,6 +47,38 @@ export function CreateMarathonPage() {
const isPublic = watch('is_public') const isPublic = watch('is_public')
const gameProposalMode = watch('game_proposal_mode') const gameProposalMode = watch('game_proposal_mode')
const handleCoverClick = () => {
fileInputRef.current?.click()
}
const handleCoverChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast.error('Файл должен быть изображением')
return
}
if (file.size > 5 * 1024 * 1024) {
toast.error('Максимальный размер файла 5 МБ')
return
}
setCoverFile(file)
setCoverPreview(URL.createObjectURL(file))
}
const handleRemoveCover = () => {
setCoverFile(null)
if (coverPreview) {
URL.revokeObjectURL(coverPreview)
}
setCoverPreview(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const onSubmit = async (data: CreateForm) => { const onSubmit = async (data: CreateForm) => {
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
@@ -54,6 +91,16 @@ export function CreateMarathonPage() {
is_public: data.is_public, is_public: data.is_public,
game_proposal_mode: data.game_proposal_mode as GameProposalMode, game_proposal_mode: data.game_proposal_mode as GameProposalMode,
}) })
// Upload cover if selected
if (coverFile) {
try {
await marathonsApi.uploadCover(marathon.id, coverFile)
} catch {
toast.warning('Марафон создан, но не удалось загрузить обложку')
}
}
navigate(`/marathons/${marathon.id}/lobby`) navigate(`/marathons/${marathon.id}/lobby`)
} catch (err: unknown) { } catch (err: unknown) {
const apiError = err as { response?: { data?: { detail?: string } } } const apiError = err as { response?: { data?: { detail?: string } } }
@@ -94,6 +141,57 @@ export function CreateMarathonPage() {
</div> </div>
)} )}
{/* Cover Image */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Обложка (необязательно)
</label>
<div className="relative group">
<button
type="button"
onClick={handleCoverClick}
disabled={isLoading}
className="relative w-full h-40 rounded-xl overflow-hidden bg-dark-700 border-2 border-dashed border-dark-500 hover:border-neon-500/50 transition-all"
>
{coverPreview ? (
<img
src={coverPreview}
alt="Обложка марафона"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-gray-500">
<Camera className="w-8 h-8 mb-2" />
<span className="text-sm">Нажмите для загрузки</span>
<span className="text-xs text-gray-600 mt-1">JPG, PNG до 5 МБ</span>
</div>
)}
{coverPreview && (
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="w-6 h-6 text-neon-500" />
<span className="ml-2 text-white text-sm">Изменить</span>
</div>
)}
</button>
{coverPreview && (
<button
type="button"
onClick={handleRemoveCover}
className="absolute top-2 right-2 p-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleCoverChange}
className="hidden"
/>
</div>
{/* Basic info */} {/* Basic info */}
<div className="space-y-4"> <div className="space-y-4">
<Input <Input

View File

@@ -1,15 +1,17 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, gamesApi } from '@/api' import { marathonsApi, gamesApi } from '@/api'
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types' import type { Marathon, Game, Challenge, ChallengePreview, GameType, ProofType } from '@/types'
import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { fuzzyFilter } from '@/utils/fuzzySearch'
import { import {
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye, Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap, Search, Settings
} from 'lucide-react' } from 'lucide-react'
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
export function LobbyPage() { export function LobbyPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -27,9 +29,60 @@ export function LobbyPage() {
const [showAddGame, setShowAddGame] = useState(false) const [showAddGame, setShowAddGame] = useState(false)
const [gameTitle, setGameTitle] = useState('') const [gameTitle, setGameTitle] = useState('')
const [gameUrl, setGameUrl] = useState('') const [gameUrl, setGameUrl] = useState('')
const [gameUrlError, setGameUrlError] = useState<string | null>(null)
const [gameGenre, setGameGenre] = useState('') const [gameGenre, setGameGenre] = useState('')
const [gameType, setGameType] = useState<GameType>('challenges')
const [playthroughPoints, setPlaythroughPoints] = useState(50)
const [playthroughDescription, setPlaythroughDescription] = useState('')
const [playthroughProofType, setPlaythroughProofType] = useState<ProofType>('screenshot')
const [playthroughProofHint, setPlaythroughProofHint] = useState('')
const [isAddingGame, setIsAddingGame] = useState(false) const [isAddingGame, setIsAddingGame] = useState(false)
// Edit game modal
const [editingGame, setEditingGame] = useState<Game | null>(null)
const [editTitle, setEditTitle] = useState('')
const [editUrl, setEditUrl] = useState('')
const [editGenre, setEditGenre] = useState('')
const [editGameType, setEditGameType] = useState<GameType>('challenges')
const [editPlaythroughPoints, setEditPlaythroughPoints] = useState(50)
const [editPlaythroughDescription, setEditPlaythroughDescription] = useState('')
const [editPlaythroughProofType, setEditPlaythroughProofType] = useState<ProofType>('screenshot')
const [editPlaythroughProofHint, setEditPlaythroughProofHint] = useState('')
const [isEditingGame, setIsEditingGame] = useState(false)
const validateUrl = (url: string): boolean => {
if (!url.trim()) return true // Empty is ok, will be caught by required check
try {
const parsed = new URL(url.trim())
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false
}
// Check that hostname has at least one dot (domain.tld)
const hostname = parsed.hostname
if (!hostname || !hostname.includes('.')) {
return false
}
// Check that TLD is valid (2-6 letters only, like com, ru, org, online)
const parts = hostname.split('.')
const tld = parts[parts.length - 1].toLowerCase()
if (tld.length < 2 || tld.length > 6 || !/^[a-z]+$/.test(tld)) {
return false
}
return true
} catch {
return false
}
}
const handleGameUrlChange = (value: string) => {
setGameUrl(value)
if (value.trim() && !validateUrl(value)) {
setGameUrlError('Введите корректную ссылку (например: https://store.steampowered.com/...)')
} else {
setGameUrlError(null)
}
}
// Moderation // Moderation
const [moderatingGameId, setModeratingGameId] = useState<number | null>(null) const [moderatingGameId, setModeratingGameId] = useState<number | null>(null)
@@ -85,6 +138,21 @@ export function LobbyPage() {
// Start marathon // Start marathon
const [isStarting, setIsStarting] = useState(false) const [isStarting, setIsStarting] = useState(false)
// Search
const [searchQuery, setSearchQuery] = useState('')
const [generateSearchQuery, setGenerateSearchQuery] = useState('')
// Games list filters
const [filterProposer, setFilterProposer] = useState<number | 'all'>('all')
const [filterChallenges, setFilterChallenges] = useState<'all' | 'with' | 'without'>('all')
// Generation filters
const [generateFilterProposer, setGenerateFilterProposer] = useState<number | 'all'>('all')
const [generateFilterChallenges, setGenerateFilterChallenges] = useState<'all' | 'with' | 'without'>('all')
// Settings modal
const [showSettings, setShowSettings] = useState(false)
useEffect(() => { useEffect(() => {
loadData() loadData()
}, [id]) }, [id])
@@ -132,7 +200,15 @@ export function LobbyPage() {
} }
const handleAddGame = async () => { const handleAddGame = async () => {
if (!id || !gameTitle.trim() || !gameUrl.trim()) return if (!id || !gameTitle.trim() || !gameUrl.trim() || !validateUrl(gameUrl)) return
// Validate playthrough fields
if (gameType === 'playthrough') {
if (!playthroughDescription.trim()) {
toast.warning('Заполните описание прохождения')
return
}
}
setIsAddingGame(true) setIsAddingGame(true)
try { try {
@@ -140,10 +216,24 @@ export function LobbyPage() {
title: gameTitle.trim(), title: gameTitle.trim(),
download_url: gameUrl.trim(), download_url: gameUrl.trim(),
genre: gameGenre.trim() || undefined, genre: gameGenre.trim() || undefined,
game_type: gameType,
...(gameType === 'playthrough' && {
playthrough_points: playthroughPoints,
playthrough_description: playthroughDescription.trim(),
playthrough_proof_type: playthroughProofType,
playthrough_proof_hint: playthroughProofHint.trim() || undefined,
}),
}) })
// Reset form
setGameTitle('') setGameTitle('')
setGameUrl('') setGameUrl('')
setGameUrlError(null)
setGameGenre('') setGameGenre('')
setGameType('challenges')
setPlaythroughPoints(50)
setPlaythroughDescription('')
setPlaythroughProofType('screenshot')
setPlaythroughProofHint('')
setShowAddGame(false) setShowAddGame(false)
await loadData() await loadData()
} catch (error) { } catch (error) {
@@ -153,6 +243,56 @@ export function LobbyPage() {
} }
} }
const openEditModal = (game: Game) => {
setEditingGame(game)
setEditTitle(game.title)
setEditUrl(game.download_url)
setEditGenre(game.genre || '')
setEditGameType(game.game_type || 'challenges')
setEditPlaythroughPoints(game.playthrough_points || 50)
setEditPlaythroughDescription(game.playthrough_description || '')
setEditPlaythroughProofType((game.playthrough_proof_type as ProofType) || 'screenshot')
setEditPlaythroughProofHint(game.playthrough_proof_hint || '')
}
const closeEditModal = () => {
setEditingGame(null)
}
const handleEditGame = async () => {
if (!editingGame) return
// Validate playthrough fields
if (editGameType === 'playthrough' && !editPlaythroughDescription.trim()) {
toast.warning('Заполните описание прохождения')
return
}
setIsEditingGame(true)
try {
await gamesApi.update(editingGame.id, {
title: editTitle.trim(),
download_url: editUrl.trim(),
genre: editGenre.trim() || undefined,
game_type: editGameType,
...(editGameType === 'playthrough' && {
playthrough_points: editPlaythroughPoints,
playthrough_description: editPlaythroughDescription.trim(),
playthrough_proof_type: editPlaythroughProofType,
playthrough_proof_hint: editPlaythroughProofHint.trim() || undefined,
}),
})
toast.success('Игра обновлена')
closeEditModal()
await loadData()
} catch (error) {
console.error('Failed to update game:', error)
toast.error('Не удалось обновить игру')
} finally {
setIsEditingGame(false)
}
}
const handleDeleteGame = async (gameId: number) => { const handleDeleteGame = async (gameId: number) => {
const confirmed = await confirm({ const confirmed = await confirm({
title: 'Удалить игру?', title: 'Удалить игру?',
@@ -501,6 +641,7 @@ export function LobbyPage() {
} else { } else {
setPreviewChallenges(result.challenges) setPreviewChallenges(result.challenges)
setShowGenerateSelection(false) setShowGenerateSelection(false)
setGenerateSearchQuery('')
} }
} catch (error) { } catch (error) {
console.error('Failed to generate challenges:', error) console.error('Failed to generate challenges:', error)
@@ -518,10 +659,6 @@ export function LobbyPage() {
) )
} }
const selectAllGamesForGeneration = () => {
setSelectedGamesForGeneration(approvedGames.map(g => g.id))
}
const clearGameSelection = () => { const clearGameSelection = () => {
setSelectedGamesForGeneration([]) setSelectedGamesForGeneration([])
} }
@@ -599,6 +736,22 @@ export function LobbyPage() {
const approvedGames = games.filter(g => g.status === 'approved') const approvedGames = games.filter(g => g.status === 'approved')
const totalChallenges = approvedGames.reduce((sum, g) => sum + g.challenges_count, 0) const totalChallenges = approvedGames.reduce((sum, g) => sum + g.challenges_count, 0)
// Get unique proposers for generation filter (from approved games)
const uniqueProposers = approvedGames.reduce((acc, game) => {
if (game.proposed_by && !acc.some(u => u.id === game.proposed_by?.id)) {
acc.push(game.proposed_by)
}
return acc
}, [] as { id: number; nickname: string }[])
// Get unique proposers for games list filter (from all games)
const allGamesProposers = games.reduce((acc, game) => {
if (game.proposed_by && !acc.some(u => u.id === game.proposed_by?.id)) {
acc.push(game.proposed_by)
}
return acc
}, [] as { id: number; nickname: string }[])
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
case 'approved': case 'approved':
@@ -652,6 +805,11 @@ export function LobbyPage() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h4 className="font-semibold text-white">{game.title}</h4> <h4 className="font-semibold text-white">{game.title}</h4>
{game.game_type === 'playthrough' && (
<span className="text-xs px-2 py-0.5 rounded-lg bg-accent-500/20 text-accent-400 border border-accent-500/30">
Прохождение
</span>
)}
{getStatusBadge(game.status)} {getStatusBadge(game.status)}
</div> </div>
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap"> <div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
@@ -694,6 +852,15 @@ export function LobbyPage() {
</button> </button>
</> </>
)} )}
{isOrganizer && (
<button
onClick={() => openEditModal(game)}
className="p-2 rounded-lg text-neon-400 hover:bg-neon-500/10 transition-colors"
title="Редактировать"
>
<Edit2 className="w-4 h-4" />
</button>
)}
{(isOrganizer || game.proposed_by?.id === user?.id) && ( {(isOrganizer || game.proposed_by?.id === user?.id) && (
<button <button
onClick={() => handleDeleteGame(game.id)} onClick={() => handleDeleteGame(game.id)}
@@ -782,7 +949,7 @@ export function LobbyPage() {
value={editChallenge.points} value={editChallenge.points}
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))} onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
min={1} min={1}
max={500} max={1000}
/> />
</div> </div>
<div> <div>
@@ -798,6 +965,14 @@ export function LobbyPage() {
</select> </select>
</div> </div>
</div> </div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Подсказка для пруфа</label>
<Input
placeholder="Что именно должно быть на скриншоте/видео"
value={editChallenge.proof_hint}
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
/>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<NeonButton <NeonButton
size="sm" size="sm"
@@ -846,6 +1021,9 @@ export function LobbyPage() {
</div> </div>
<h5 className="text-sm font-medium text-white">{challenge.title}</h5> <h5 className="text-sm font-medium text-white">{challenge.title}</h5>
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p> <p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
{challenge.proof_hint && (
<p className="text-xs text-gray-500 mt-1">Пруф: {challenge.proof_hint}</p>
)}
</div> </div>
{isOrganizer && ( {isOrganizer && (
<div className="flex gap-1 shrink-0"> <div className="flex gap-1 shrink-0">
@@ -931,7 +1109,7 @@ export function LobbyPage() {
value={newChallenge.points} value={newChallenge.points}
onChange={(e) => setNewChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))} onChange={(e) => setNewChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
min={1} min={1}
max={500} max={1000}
/> />
</div> </div>
<div> <div>
@@ -1045,6 +1223,13 @@ export function LobbyPage() {
</div> </div>
{isOrganizer && ( {isOrganizer && (
<div className="flex gap-2">
<NeonButton
variant="ghost"
onClick={() => setShowSettings(true)}
className="!text-gray-400 hover:!bg-dark-600"
icon={<Settings className="w-4 h-4" />}
/>
<NeonButton <NeonButton
onClick={handleStartMarathon} onClick={handleStartMarathon}
isLoading={isStarting} isLoading={isStarting}
@@ -1053,6 +1238,7 @@ export function LobbyPage() {
> >
Запустить марафон Запустить марафон
</NeonButton> </NeonButton>
</div>
)} )}
</div> </div>
@@ -1165,7 +1351,7 @@ export function LobbyPage() {
value={editChallenge.points} value={editChallenge.points}
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))} onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
min={1} min={1}
max={500} max={1000}
/> />
</div> </div>
<div> <div>
@@ -1181,6 +1367,14 @@ export function LobbyPage() {
</select> </select>
</div> </div>
</div> </div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Подсказка для пруфа</label>
<Input
placeholder="Что именно должно быть на скриншоте/видео"
value={editChallenge.proof_hint}
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
/>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<NeonButton <NeonButton
size="sm" size="sm"
@@ -1226,6 +1420,9 @@ export function LobbyPage() {
</div> </div>
<h4 className="font-medium text-white mb-1">{challenge.title}</h4> <h4 className="font-medium text-white mb-1">{challenge.title}</h4>
<p className="text-sm text-gray-400 mb-2">{challenge.description}</p> <p className="text-sm text-gray-400 mb-2">{challenge.description}</p>
{challenge.proof_hint && (
<p className="text-xs text-gray-500 mb-2">Пруф: {challenge.proof_hint}</p>
)}
{challenge.proposed_by && ( {challenge.proposed_by && (
<p className="text-xs text-gray-500 flex items-center gap-1"> <p className="text-xs text-gray-500 flex items-center gap-1">
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname} <User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
@@ -1304,6 +1501,9 @@ export function LobbyPage() {
</div> </div>
<h4 className="font-medium text-white mb-1">{challenge.title}</h4> <h4 className="font-medium text-white mb-1">{challenge.title}</h4>
<p className="text-sm text-gray-400">{challenge.description}</p> <p className="text-sm text-gray-400">{challenge.description}</p>
{challenge.proof_hint && (
<p className="text-xs text-gray-500 mt-1">Пруф: {challenge.proof_hint}</p>
)}
</div> </div>
{challenge.status === 'pending' && ( {challenge.status === 'pending' && (
<button <button
@@ -1343,6 +1543,9 @@ export function LobbyPage() {
onClick={() => { onClick={() => {
setShowGenerateSelection(false) setShowGenerateSelection(false)
clearGameSelection() clearGameSelection()
setGenerateSearchQuery('')
setGenerateFilterProposer('all')
setGenerateFilterChallenges('all')
}} }}
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -1376,12 +1579,82 @@ export function LobbyPage() {
{/* Game selection */} {/* Game selection */}
{showGenerateSelection && ( {showGenerateSelection && (
<div className="space-y-3"> <div className="space-y-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Поиск игры..."
value={generateSearchQuery}
onChange={(e) => setGenerateSearchQuery(e.target.value)}
className="input w-full pl-10 pr-10 py-2 text-sm"
/>
{generateSearchQuery && (
<button
onClick={() => setGenerateSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Filters */}
<div className="flex gap-2">
<select
value={generateFilterProposer === 'all' ? 'all' : generateFilterProposer}
onChange={(e) => setGenerateFilterProposer(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
className="input py-2 text-sm flex-1"
>
<option value="all">Все участники</option>
{uniqueProposers.map(u => (
<option key={u.id} value={u.id}>{u.nickname}</option>
))}
</select>
<select
value={generateFilterChallenges}
onChange={(e) => setGenerateFilterChallenges(e.target.value as 'all' | 'with' | 'without')}
className="input py-2 text-sm flex-1"
>
<option value="all">Все игры</option>
<option value="with">С заданиями</option>
<option value="without">Без заданий</option>
</select>
</div>
{(() => {
// Compute filtered games
let filteredGames = approvedGames
// Apply proposer filter
if (generateFilterProposer !== 'all') {
filteredGames = filteredGames.filter(g => g.proposed_by?.id === generateFilterProposer)
}
// Apply challenges filter
if (generateFilterChallenges === 'with') {
filteredGames = filteredGames.filter(g => {
const count = gameChallenges[g.id]?.length ?? g.challenges_count
return count > 0
})
} else if (generateFilterChallenges === 'without') {
filteredGames = filteredGames.filter(g => {
const count = gameChallenges[g.id]?.length ?? g.challenges_count
return count === 0
})
}
// Apply search filter
if (generateSearchQuery) {
filteredGames = fuzzyFilter(filteredGames, generateSearchQuery, (g) => g.title + ' ' + (g.genre || ''))
}
return (
<>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<button <button
onClick={selectAllGamesForGeneration} onClick={() => setSelectedGamesForGeneration(filteredGames.map(g => g.id))}
className="text-neon-400 hover:text-neon-300 transition-colors" className="text-neon-400 hover:text-neon-300 transition-colors"
> >
Выбрать все Выбрать все ({filteredGames.length})
</button> </button>
<button <button
onClick={clearGameSelection} onClick={clearGameSelection}
@@ -1390,8 +1663,13 @@ export function LobbyPage() {
Снять выбор Снять выбор
</button> </button>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2 max-h-64 overflow-y-auto custom-scrollbar">
{approvedGames.map((game) => { {filteredGames.length === 0 ? (
<p className="text-center text-gray-500 py-4 text-sm">
Ничего не найдено
</p>
) : (
filteredGames.map((game) => {
const isSelected = selectedGamesForGeneration.includes(game.id) const isSelected = selectedGamesForGeneration.includes(game.id)
const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
return ( return (
@@ -1412,15 +1690,24 @@ export function LobbyPage() {
{isSelected && <Check className="w-3 h-3 text-white" />} {isSelected && <Check className="w-3 h-3 text-white" />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-white font-medium truncate">{game.title}</p> <p className="text-white font-medium truncate">{game.title}</p>
{game.proposed_by && (
<span className="text-xs text-gray-500 shrink-0">от {game.proposed_by.nickname}</span>
)}
</div>
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
{challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'} {challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
</p> </p>
</div> </div>
</button> </button>
) )
})} })
)}
</div> </div>
</>
)
})()}
</div> </div>
)} )}
@@ -1574,7 +1861,7 @@ export function LobbyPage() {
{/* Games list */} {/* Games list */}
<GlassCard> <GlassCard>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Gamepad2 className="w-5 h-5 text-neon-400" /> <Gamepad2 className="w-5 h-5 text-neon-400" />
@@ -1592,6 +1879,49 @@ export function LobbyPage() {
)} )}
</div> </div>
{/* Search and filters */}
<div className="space-y-3 mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Поиск игры..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input w-full pl-10 pr-10"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="flex gap-2">
<select
value={filterProposer === 'all' ? 'all' : filterProposer}
onChange={(e) => setFilterProposer(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
className="input py-2 text-sm flex-1"
>
<option value="all">Все участники</option>
{allGamesProposers.map(u => (
<option key={u.id} value={u.id}>{u.nickname}</option>
))}
</select>
<select
value={filterChallenges}
onChange={(e) => setFilterChallenges(e.target.value as 'all' | 'with' | 'without')}
className="input py-2 text-sm flex-1"
>
<option value="all">Все игры</option>
<option value="with">С заданиями</option>
<option value="without">Без заданий</option>
</select>
</div>
</div>
{/* Add game form */} {/* Add game form */}
{showAddGame && ( {showAddGame && (
<div className="mb-6 p-4 glass rounded-xl space-y-3 border border-neon-500/20"> <div className="mb-6 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
@@ -1601,24 +1931,85 @@ export function LobbyPage() {
onChange={(e) => setGameTitle(e.target.value)} onChange={(e) => setGameTitle(e.target.value)}
/> />
<Input <Input
placeholder="Ссылка для скачивания" placeholder="Ссылка для скачивания (https://...)"
value={gameUrl} value={gameUrl}
onChange={(e) => setGameUrl(e.target.value)} onChange={(e) => handleGameUrlChange(e.target.value)}
error={gameUrlError || undefined}
/> />
<Input <Input
placeholder="Жанр (необязательно)" placeholder="Жанр (необязательно)"
value={gameGenre} value={gameGenre}
onChange={(e) => setGameGenre(e.target.value)} onChange={(e) => setGameGenre(e.target.value)}
/> />
{/* Game type selector */}
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип игры</label>
<select
value={gameType}
onChange={(e) => setGameType(e.target.value as GameType)}
className="input w-full"
>
<option value="challenges">Челленджи случайный челлендж при спине</option>
<option value="playthrough">Прохождение основная задача + бонусные челленджи</option>
</select>
</div>
{/* Playthrough fields */}
{gameType === 'playthrough' && (
<div className="space-y-3 p-3 bg-accent-500/10 rounded-lg border border-accent-500/20">
<p className="text-xs text-accent-400 font-medium">Настройки прохождения</p>
<textarea
placeholder="Что нужно сделать для прохождения (например: пройти игру до финальных титров)"
value={playthroughDescription}
onChange={(e) => setPlaythroughDescription(e.target.value)}
className="input w-full resize-none"
rows={2}
/>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Очки за прохождение</label>
<Input
type="number"
value={playthroughPoints}
onChange={(e) => setPlaythroughPoints(parseInt(e.target.value) || 50)}
min={1}
max={1000}
/>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
<select
value={playthroughProofType}
onChange={(e) => setPlaythroughProofType(e.target.value as ProofType)}
className="input w-full"
>
<option value="screenshot">Скриншот</option>
<option value="video">Видео</option>
<option value="steam">Steam</option>
</select>
</div>
</div>
<Input
placeholder="Подсказка для пруфа (необязательно)"
value={playthroughProofHint}
onChange={(e) => setPlaythroughProofHint(e.target.value)}
/>
<p className="text-xs text-gray-500">
Все челленджи этой игры станут бонусными (опциональными)
</p>
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<NeonButton <NeonButton
onClick={handleAddGame} onClick={handleAddGame}
isLoading={isAddingGame} isLoading={isAddingGame}
disabled={!gameTitle || !gameUrl} disabled={!gameTitle || !gameUrl || !!gameUrlError || (gameType === 'playthrough' && !playthroughDescription)}
> >
{isOrganizer ? 'Добавить' : 'Предложить'} {isOrganizer ? 'Добавить' : 'Предложить'}
</NeonButton> </NeonButton>
<NeonButton variant="outline" onClick={() => setShowAddGame(false)}> <NeonButton variant="outline" onClick={() => { setShowAddGame(false); setGameUrlError(null); setGameType('challenges') }}>
Отмена Отмена
</NeonButton> </NeonButton>
</div> </div>
@@ -1632,26 +2023,177 @@ export function LobbyPage() {
{/* Games */} {/* Games */}
{(() => { {(() => {
const visibleGames = isOrganizer let filteredGames = isOrganizer
? games.filter(g => g.status !== 'pending') ? games.filter(g => g.status !== 'pending')
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id)) : games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
return visibleGames.length === 0 ? ( // Apply proposer filter
if (filterProposer !== 'all') {
filteredGames = filteredGames.filter(g => g.proposed_by?.id === filterProposer)
}
// Apply challenges filter
if (filterChallenges === 'with') {
filteredGames = filteredGames.filter(g => {
const count = gameChallenges[g.id]?.length ?? g.challenges_count
return count > 0
})
} else if (filterChallenges === 'without') {
filteredGames = filteredGames.filter(g => {
const count = gameChallenges[g.id]?.length ?? g.challenges_count
return count === 0
})
}
// Apply search filter
if (searchQuery) {
filteredGames = fuzzyFilter(filteredGames, searchQuery, (g) => g.title + ' ' + (g.genre || ''))
}
const hasFilters = searchQuery || filterProposer !== 'all' || filterChallenges !== 'all'
return filteredGames.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
{hasFilters ? (
<Search className="w-8 h-8 text-gray-600" />
) : (
<Gamepad2 className="w-8 h-8 text-gray-600" /> <Gamepad2 className="w-8 h-8 text-gray-600" />
)}
</div> </div>
<p className="text-gray-400"> <p className="text-gray-400">
{isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'} {hasFilters
? 'Ничего не найдено по заданным фильтрам'
: isOrganizer
? 'Пока нет игр. Добавьте игры, чтобы начать!'
: 'Пока нет одобренных игр. Предложите свою!'}
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{visibleGames.map((game) => renderGameCard(game, false))} {filteredGames.map((game) => renderGameCard(game, false))}
</div> </div>
) )
})()} })()}
</GlassCard> </GlassCard>
{/* Settings Modal */}
{marathon && (
<MarathonSettingsModal
marathon={marathon}
isOpen={showSettings}
onClose={() => setShowSettings(false)}
onUpdate={setMarathon}
/>
)}
{/* Edit Game Modal */}
{editingGame && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="glass rounded-2xl border border-neon-500/20 w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-white">Редактировать игру</h3>
<button
onClick={closeEditModal}
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<Input
placeholder="Название игры"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
/>
<Input
placeholder="Ссылка для скачивания"
value={editUrl}
onChange={(e) => setEditUrl(e.target.value)}
/>
<Input
placeholder="Жанр (необязательно)"
value={editGenre}
onChange={(e) => setEditGenre(e.target.value)}
/>
{/* Game type selector */}
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип игры</label>
<select
value={editGameType}
onChange={(e) => setEditGameType(e.target.value as GameType)}
className="input w-full"
>
<option value="challenges">Челленджи</option>
<option value="playthrough">Прохождение</option>
</select>
</div>
{/* Playthrough fields */}
{editGameType === 'playthrough' && (
<div className="space-y-3 p-3 bg-accent-500/10 rounded-lg border border-accent-500/20">
<p className="text-xs text-accent-400 font-medium">Настройки прохождения</p>
<textarea
placeholder="Описание прохождения"
value={editPlaythroughDescription}
onChange={(e) => setEditPlaythroughDescription(e.target.value)}
className="input w-full resize-none"
rows={2}
/>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
<Input
type="number"
value={editPlaythroughPoints}
onChange={(e) => setEditPlaythroughPoints(parseInt(e.target.value) || 50)}
min={1}
max={1000}
/>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
<select
value={editPlaythroughProofType}
onChange={(e) => setEditPlaythroughProofType(e.target.value as ProofType)}
className="input w-full"
>
<option value="screenshot">Скриншот</option>
<option value="video">Видео</option>
<option value="steam">Steam</option>
</select>
</div>
</div>
<Input
placeholder="Подсказка для пруфа (необязательно)"
value={editPlaythroughProofHint}
onChange={(e) => setEditPlaythroughProofHint(e.target.value)}
/>
</div>
)}
<div className="flex gap-3 pt-2">
<NeonButton
className="flex-1"
onClick={handleEditGame}
isLoading={isEditingGame}
disabled={!editTitle.trim() || !editUrl.trim() || (editGameType === 'playthrough' && !editPlaythroughDescription.trim())}
icon={<Save className="w-4 h-4" />}
>
Сохранить
</NeonButton>
<NeonButton variant="outline" onClick={closeEditModal}>
Отмена
</NeonButton>
</div>
</div>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -54,7 +54,8 @@ export function LoginPage() {
navigate('/marathons') navigate('/marathons')
} catch { } catch {
setSubmitError(error || 'Ошибка входа') // Error is already set in store by login function
// Ban case is handled separately via banInfo state
} }
} }

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, eventsApi, challengesApi } from '@/api' import { marathonsApi, eventsApi, challengesApi } from '@/api'
import type { Marathon, ActiveEvent, Challenge } from '@/types' import type { Marathon, ActiveEvent, Challenge, MarathonDispute } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
@@ -9,13 +9,16 @@ import { useConfirm } from '@/store/confirm'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl' import { EventControl } from '@/components/EventControl'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed' import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
import { import {
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles,
AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, ExternalLink, User
} from 'lucide-react' } from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { ru } from 'date-fns/locale' import { ru } from 'date-fns/locale'
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
export function MarathonPage() { export function MarathonPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -34,12 +37,26 @@ export function MarathonPage() {
const [showEventControl, setShowEventControl] = useState(false) const [showEventControl, setShowEventControl] = useState(false)
const [showChallenges, setShowChallenges] = useState(false) const [showChallenges, setShowChallenges] = useState(false)
const [expandedGameId, setExpandedGameId] = useState<number | null>(null) const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
const [showSettings, setShowSettings] = useState(false)
const activityFeedRef = useRef<ActivityFeedRef>(null) const activityFeedRef = useRef<ActivityFeedRef>(null)
// Disputes for organizers
const [showDisputes, setShowDisputes] = useState(false)
const [disputes, setDisputes] = useState<MarathonDispute[]>([])
const [loadingDisputes, setLoadingDisputes] = useState(false)
const [disputeFilter, setDisputeFilter] = useState<'open' | 'all'>('open')
const [resolvingDisputeId, setResolvingDisputeId] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
loadMarathon() loadMarathon()
}, [id]) }, [id])
useEffect(() => {
if (showDisputes) {
loadDisputes()
}
}, [showDisputes, disputeFilter])
const loadMarathon = async () => { const loadMarathon = async () => {
if (!id) return if (!id) return
try { try {
@@ -77,6 +94,57 @@ export function MarathonPage() {
} }
} }
const loadDisputes = async () => {
if (!id) return
setLoadingDisputes(true)
try {
const data = await marathonsApi.listDisputes(parseInt(id), disputeFilter)
setDisputes(data)
} catch (error) {
console.error('Failed to load disputes:', error)
toast.error('Не удалось загрузить оспаривания')
} finally {
setLoadingDisputes(false)
}
}
const handleResolveDispute = async (disputeId: number, isValid: boolean) => {
if (!id) return
setResolvingDisputeId(disputeId)
try {
await marathonsApi.resolveDispute(parseInt(id), disputeId, isValid)
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
await loadDisputes()
} catch (error) {
console.error('Failed to resolve dispute:', error)
toast.error('Не удалось разрешить диспут')
} finally {
setResolvingDisputeId(null)
}
}
const formatDisputeDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ru-RU', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})
}
const getDisputeTimeRemaining = (expiresAt: string) => {
const now = new Date()
const expires = new Date(expiresAt)
const diff = expires.getTime() - now.getTime()
if (diff <= 0) return 'Истекло'
const hours = Math.floor(diff / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
return `${hours}ч ${minutes}м`
}
const getInviteLink = () => { const getInviteLink = () => {
if (!marathon) return '' if (!marathon) return ''
return `${window.location.origin}/invite/${marathon.invite_code}` return `${window.location.origin}/invite/${marathon.invite_code}`
@@ -189,8 +257,22 @@ export function MarathonPage() {
{/* Hero Banner */} {/* Hero Banner */}
<div className="relative rounded-2xl overflow-hidden mb-8"> <div className="relative rounded-2xl overflow-hidden mb-8">
{/* Background */} {/* Background */}
{marathon.cover_url ? (
<>
<img
src={marathon.cover_url}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-dark-900/95 via-dark-900/80 to-dark-900/60" />
<div className="absolute inset-0 bg-gradient-to-t from-dark-900/90 to-transparent" />
</>
) : (
<>
<div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" /> <div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(34,211,238,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.02)_1px,transparent_1px)] bg-[size:50px_50px]" /> <div className="absolute inset-0 bg-[linear-gradient(rgba(34,211,238,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.02)_1px,transparent_1px)] bg-[size:50px_50px]" />
</>
)}
<div className="relative p-8"> <div className="relative p-8">
<div className="flex flex-col md:flex-row justify-between items-start gap-6"> <div className="flex flex-col md:flex-row justify-between items-start gap-6">
@@ -226,8 +308,8 @@ export function MarathonPage() {
{marathon.status === 'preparing' && isOrganizer && ( {marathon.status === 'preparing' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}> <Link to={`/marathons/${id}/lobby`}>
<NeonButton variant="secondary" icon={<Settings className="w-4 h-4" />}> <NeonButton variant="secondary" icon={<Gamepad2 className="w-4 h-4" />}>
Настройка Игры
</NeonButton> </NeonButton>
</Link> </Link>
)} )}
@@ -265,6 +347,15 @@ export function MarathonPage() {
</button> </button>
)} )}
{marathon.status === 'preparing' && isOrganizer && (
<NeonButton
variant="ghost"
onClick={() => setShowSettings(true)}
className="!text-gray-400 hover:!bg-dark-600"
icon={<Settings className="w-4 h-4" />}
/>
)}
{canDelete && ( {canDelete && (
<NeonButton <NeonButton
variant="ghost" variant="ghost"
@@ -316,6 +407,9 @@ export function MarathonPage() {
/> />
</div> </div>
{/* Telegram Bot Banner */}
<TelegramBotBanner />
{/* Active event banner */} {/* Active event banner */}
{marathon.status === 'active' && activeEvent?.event && ( {marathon.status === 'active' && activeEvent?.event && (
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} /> <EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
@@ -356,6 +450,196 @@ export function MarathonPage() {
</GlassCard> </GlassCard>
)} )}
{/* Disputes management for organizers */}
{marathon.status === 'active' && isOrganizer && (
<GlassCard>
<button
onClick={() => setShowDisputes(!showDisputes)}
className="w-full flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-orange-400" />
</div>
<div className="text-left">
<h3 className="font-semibold text-white">Оспаривания</h3>
<p className="text-sm text-gray-400">Проверьте спорные выполнения</p>
</div>
</div>
<div className="flex items-center gap-3">
{disputes.filter(d => d.status === 'open').length > 0 && (
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium">
{disputes.filter(d => d.status === 'open').length} открыто
</span>
)}
{showDisputes ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</div>
</button>
{showDisputes && (
<div className="mt-6 pt-6 border-t border-dark-600">
{/* Filters */}
<div className="flex gap-2 mb-4">
<button
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
disputeFilter === 'open'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
}`}
onClick={() => setDisputeFilter('open')}
>
Открытые
</button>
<button
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
disputeFilter === 'all'
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
}`}
onClick={() => setDisputeFilter('all')}
>
Все
</button>
</div>
{/* Loading */}
{loadingDisputes ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-accent-500" />
</div>
) : disputes.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-green-400" />
</div>
<p className="text-gray-400 text-sm">
{disputeFilter === 'open' ? 'Нет открытых оспариваний' : 'Нет оспариваний'}
</p>
</div>
) : (
<div className="space-y-3">
{disputes.map((dispute) => (
<div
key={dispute.id}
className={`p-4 bg-dark-700/50 rounded-xl border ${
dispute.status === 'open' ? 'border-orange-500/30' : 'border-dark-600'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{/* Challenge title */}
<h4 className="text-white font-medium truncate mb-1">
{dispute.challenge_title}
</h4>
{/* Participants */}
<div className="flex flex-wrap gap-3 text-xs text-gray-400 mb-2">
<span className="flex items-center gap-1">
<User className="w-3 h-3" />
Автор: <span className="text-white">{dispute.participant_nickname}</span>
</span>
<span className="flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Оспорил: <span className="text-white">{dispute.raised_by_nickname}</span>
</span>
</div>
{/* Reason */}
<p className="text-sm text-gray-300 mb-2 line-clamp-2">
{dispute.reason}
</p>
{/* Votes & Time */}
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-0.5 text-green-400">
<ThumbsUp className="w-3 h-3" />
<span>{dispute.votes_valid}</span>
</div>
<span className="text-gray-600">/</span>
<div className="flex items-center gap-0.5 text-red-400">
<ThumbsDown className="w-3 h-3" />
<span>{dispute.votes_invalid}</span>
</div>
</div>
<span className="text-gray-500">{formatDisputeDate(dispute.created_at)}</span>
{dispute.status === 'open' && (
<span className="text-orange-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
{getDisputeTimeRemaining(dispute.expires_at)}
</span>
)}
</div>
</div>
{/* Right side - Status & Actions */}
<div className="flex flex-col items-end gap-2 shrink-0">
{dispute.status === 'open' ? (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 rounded text-xs font-medium flex items-center gap-1">
<Clock className="w-3 h-3" />
Открыт
</span>
) : dispute.status === 'valid' ? (
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Валидно
</span>
) : (
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
<XCircle className="w-3 h-3" />
Невалидно
</span>
)}
{/* Link to assignment */}
{dispute.assignment_id && (
<Link
to={`/assignments/${dispute.assignment_id}`}
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
Открыть
</Link>
)}
{/* Resolution buttons */}
{dispute.status === 'open' && (
<div className="flex gap-1.5 mt-1">
<NeonButton
size="sm"
variant="outline"
className="border-green-500/50 text-green-400 hover:bg-green-500/10 !px-2 !py-1 text-xs"
onClick={() => handleResolveDispute(dispute.id, true)}
isLoading={resolvingDisputeId === dispute.id}
disabled={resolvingDisputeId !== null}
icon={<CheckCircle className="w-3 h-3" />}
>
Валидно
</NeonButton>
<NeonButton
size="sm"
variant="outline"
className="border-red-500/50 text-red-400 hover:bg-red-500/10 !px-2 !py-1 text-xs"
onClick={() => handleResolveDispute(dispute.id, false)}
isLoading={resolvingDisputeId === dispute.id}
disabled={resolvingDisputeId !== null}
icon={<XCircle className="w-3 h-3" />}
>
Невалидно
</NeonButton>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</GlassCard>
)}
{/* Invite link */} {/* Invite link */}
{marathon.status !== 'finished' && ( {marathon.status !== 'finished' && (
<GlassCard> <GlassCard>
@@ -529,6 +813,14 @@ export function MarathonPage() {
</div> </div>
)} )}
</div> </div>
{/* Settings Modal */}
<MarathonSettingsModal
marathon={marathon}
isOpen={showSettings}
onClose={() => setShowSettings(false)}
onUpdate={setMarathon}
/>
</div> </div>
) )
} }

View File

@@ -4,6 +4,8 @@ import { marathonsApi } from '@/api'
import type { MarathonListItem } from '@/types' import type { MarathonListItem } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react' import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react'
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
import { AnnouncementBanner } from '@/components/AnnouncementBanner'
import { format } from 'date-fns' import { format } from 'date-fns'
import { ru } from 'date-fns/locale' import { ru } from 'date-fns/locale'
@@ -145,6 +147,16 @@ export function MarathonsPage() {
</div> </div>
)} )}
{/* Announcement Banner */}
<div className="mb-4">
<AnnouncementBanner />
</div>
{/* Telegram Bot Banner */}
<div className="mb-8">
<TelegramBotBanner />
</div>
{/* Join marathon */} {/* Join marathon */}
{showJoinSection && ( {showJoinSection && (
<GlassCard className="mb-8 animate-slide-in-down" variant="neon"> <GlassCard className="mb-8 animate-slide-in-down" variant="neon">
@@ -221,10 +233,20 @@ export function MarathonsPage() {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Icon */} {/* Cover or Icon */}
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors"> {marathon.cover_url ? (
<div className="w-14 h-14 rounded-xl overflow-hidden border border-dark-500 group-hover:border-neon-500/40 transition-colors flex-shrink-0">
<img
src={marathon.cover_url}
alt={marathon.title}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors flex-shrink-0">
<Gamepad2 className="w-7 h-7 text-neon-400" /> <Gamepad2 className="w-7 h-7 text-neon-400" />
</div> </div>
)}
{/* Info */} {/* Info */}
<div> <div>

View File

@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel' import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target } from 'lucide-react' import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download } from 'lucide-react'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
@@ -25,7 +25,7 @@ export function PlayPage() {
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null) const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [proofFile, setProofFile] = useState<File | null>(null) const [proofFiles, setProofFiles] = useState<File[]>([])
const [proofUrl, setProofUrl] = useState('') const [proofUrl, setProofUrl] = useState('')
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
const [isCompleting, setIsCompleting] = useState(false) const [isCompleting, setIsCompleting] = useState(false)
@@ -55,6 +55,15 @@ export function PlayPage() {
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([]) const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
// Bonus challenge completion
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
const [bonusProofUrl, setBonusProofUrl] = useState('')
const [bonusComment, setBonusComment] = useState('')
const [isCompletingBonus, setIsCompletingBonus] = useState(false)
const bonusFileInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const eventFileInputRef = useRef<HTMLInputElement>(null) const eventFileInputRef = useRef<HTMLInputElement>(null)
@@ -168,17 +177,17 @@ export function PlayPage() {
const loadData = async () => { const loadData = async () => {
if (!id) return if (!id) return
try { try {
const [marathonData, assignment, gamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([ const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
marathonsApi.get(parseInt(id)), marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)), wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.list(parseInt(id), 'approved'), gamesApi.getAvailableGames(parseInt(id)),
eventsApi.getActive(parseInt(id)), eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)), eventsApi.getEventAssignment(parseInt(id)),
assignmentsApi.getReturnedAssignments(parseInt(id)), assignmentsApi.getReturnedAssignments(parseInt(id)),
]) ])
setMarathon(marathonData) setMarathon(marathonData)
setCurrentAssignment(assignment) setCurrentAssignment(assignment)
setGames(gamesData) setGames(availableGamesData)
setActiveEvent(eventData) setActiveEvent(eventData)
setEventAssignment(eventAssignmentData) setEventAssignment(eventAssignmentData)
setReturnedAssignments(returnedData) setReturnedAssignments(returnedData)
@@ -219,20 +228,30 @@ export function PlayPage() {
const handleComplete = async () => { const handleComplete = async () => {
if (!currentAssignment) return if (!currentAssignment) return
if (!proofFile && !proofUrl) {
// For playthrough: allow file, URL, or comment
// For challenges: require file or URL
if (currentAssignment.is_playthrough) {
if (proofFiles.length === 0 && !proofUrl && !comment) {
toast.warning('Пожалуйста, предоставьте доказательство (файл, ссылку или комментарий)')
return
}
} else {
if (proofFiles.length === 0 && !proofUrl) {
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)') toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return return
} }
}
setIsCompleting(true) setIsCompleting(true)
try { try {
const result = await wheelApi.complete(currentAssignment.id, { const result = await wheelApi.complete(currentAssignment.id, {
proof_file: proofFile || undefined, proof_files: proofFiles.length > 0 ? proofFiles : undefined,
proof_url: proofUrl || undefined, proof_url: proofUrl || undefined,
comment: comment || undefined, comment: comment || undefined,
}) })
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`) toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
setProofFile(null) setProofFiles([])
setProofUrl('') setProofUrl('')
setComment('') setComment('')
await loadData() await loadData()
@@ -270,6 +289,39 @@ export function PlayPage() {
} }
} }
const handleBonusComplete = async (bonusId: number) => {
if (!currentAssignment) return
if (bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment) {
toast.warning('Прикрепите файл, ссылку или комментарий')
return
}
setIsCompletingBonus(true)
try {
const result = await assignmentsApi.completeBonusAssignment(
currentAssignment.id,
bonusId,
{
proof_files: bonusProofFiles.length > 0 ? bonusProofFiles : undefined,
proof_url: bonusProofUrl || undefined,
comment: bonusComment || undefined,
}
)
toast.success(`Бонус отмечен! +${result.points_earned} очков начислится при завершении игры`)
setBonusProofFiles([])
setBonusProofUrl('')
setBonusComment('')
setExpandedBonusId(null)
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось выполнить бонус')
} finally {
setIsCompletingBonus(false)
}
}
const handleEventComplete = async () => { const handleEventComplete = async () => {
if (!eventAssignment?.assignment) return if (!eventAssignment?.assignment) return
if (!eventProofFile && !eventProofUrl) { if (!eventProofFile && !eventProofUrl) {
@@ -529,12 +581,23 @@ export function PlayPage() {
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
{ra.is_playthrough ? (
<>
<p className="text-white font-medium">Прохождение: {ra.game_title}</p>
<p className="text-gray-400 text-sm">Прохождение игры</p>
</>
) : ra.challenge ? (
<>
<p className="text-white font-medium">{ra.challenge.title}</p> <p className="text-white font-medium">{ra.challenge.title}</p>
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p> <p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
</>
) : null}
</div> </div>
{!ra.is_playthrough && ra.challenge && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-lg border border-green-500/30"> <span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-lg border border-green-500/30">
+{ra.challenge.points} +{ra.challenge.points}
</span> </span>
)}
</div> </div>
<p className="text-orange-300 text-xs mt-2"> <p className="text-orange-300 text-xs mt-2">
Причина: {ra.dispute_reason} Причина: {ra.dispute_reason}
@@ -640,28 +703,28 @@ export function PlayPage() {
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Игра</p> <p className="text-gray-400 text-sm mb-1">Игра</p>
<p className="text-xl font-bold text-white"> <p className="text-xl font-bold text-white">
{eventAssignment.assignment.challenge.game.title} {eventAssignment.assignment.challenge?.game.title}
</p> </p>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задание</p> <p className="text-gray-400 text-sm mb-1">Задание</p>
<p className="text-xl font-bold text-neon-400 mb-2"> <p className="text-xl font-bold text-neon-400 mb-2">
{eventAssignment.assignment.challenge.title} {eventAssignment.assignment.challenge?.title}
</p> </p>
<p className="text-gray-300"> <p className="text-gray-300">
{eventAssignment.assignment.challenge.description} {eventAssignment.assignment.challenge?.description}
</p> </p>
</div> </div>
<div className="flex items-center gap-3 mb-6 flex-wrap"> <div className="flex items-center gap-3 mb-6 flex-wrap">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30"> <span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
+{eventAssignment.assignment.challenge.points} очков +{eventAssignment.assignment.challenge?.points} очков
</span> </span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600"> <span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{eventAssignment.assignment.challenge.difficulty} {eventAssignment.assignment.challenge?.difficulty}
</span> </span>
{eventAssignment.assignment.challenge.estimated_time && ( {eventAssignment.assignment.challenge?.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1"> <span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
~{eventAssignment.assignment.challenge.estimated_time} мин ~{eventAssignment.assignment.challenge.estimated_time} мин
@@ -669,7 +732,7 @@ export function PlayPage() {
)} )}
</div> </div>
{eventAssignment.assignment.challenge.proof_hint && ( {eventAssignment.assignment.challenge?.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600"> <div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint} <strong className="text-white">Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
@@ -680,7 +743,7 @@ export function PlayPage() {
<div className="space-y-4 mb-6"> <div className="space-y-4 mb-6">
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
Загрузить доказательство ({eventAssignment.assignment.challenge.proof_type}) Загрузить доказательство ({eventAssignment.assignment.challenge?.proof_type})
</label> </label>
<input <input
@@ -891,36 +954,258 @@ export function PlayPage() {
<> <>
<GlassCard variant="neon"> <GlassCard variant="neon">
<div className="text-center mb-6"> <div className="text-center mb-6">
<span className="px-4 py-1.5 bg-neon-500/20 text-neon-400 rounded-full text-sm font-medium border border-neon-500/30"> <span className={`px-4 py-1.5 rounded-full text-sm font-medium border ${
Активное задание currentAssignment.is_playthrough
? 'bg-accent-500/20 text-accent-400 border-accent-500/30'
: 'bg-neon-500/20 text-neon-400 border-neon-500/30'
}`}>
{currentAssignment.is_playthrough ? 'Прохождение игры' : 'Активное задание'}
</span> </span>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Игра</p> <p className="text-gray-400 text-sm mb-1">Игра</p>
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="text-xl font-bold text-white"> <p className="text-xl font-bold text-white">
{currentAssignment.challenge.game.title} {currentAssignment.is_playthrough
? currentAssignment.game?.title
: currentAssignment.challenge?.game.title}
</p> </p>
{(currentAssignment.is_playthrough
? currentAssignment.game?.download_url
: currentAssignment.challenge?.game.download_url) && (
<a
href={currentAssignment.is_playthrough
? currentAssignment.game?.download_url
: currentAssignment.challenge?.game.download_url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5 hover:bg-neon-500/30 transition-colors"
>
<Download className="w-4 h-4" />
Скачать игру
</a>
)}
</div>
</div> </div>
{currentAssignment.is_playthrough ? (
// Playthrough task
<>
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задание</p> <p className="text-gray-400 text-sm mb-1">Задача</p>
<p className="text-xl font-bold text-neon-400 mb-2"> <p className="text-xl font-bold text-accent-400 mb-2">
{currentAssignment.challenge.title} Пройти игру
</p> </p>
<p className="text-gray-300"> <p className="text-gray-300">
{currentAssignment.challenge.description} {currentAssignment.playthrough_info?.description}
</p> </p>
</div> </div>
<div className="flex items-center gap-3 mb-6 flex-wrap"> <div className="flex items-center gap-3 mb-6 flex-wrap">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30"> <span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
+{currentAssignment.challenge.points} очков +{currentAssignment.playthrough_info?.points} очков
</span>
</div>
{currentAssignment.playthrough_info?.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.playthrough_info.proof_hint}
</p>
</div>
)}
{/* Bonus challenges */}
{currentAssignment.bonus_challenges && currentAssignment.bonus_challenges.length > 0 && (
<div className="mb-6 p-4 bg-accent-500/10 rounded-xl border border-accent-500/20">
<p className="text-accent-400 font-medium mb-3">
Бонусные челленджи (опционально) {currentAssignment.bonus_challenges.filter(b => b.status === 'completed').length}/{currentAssignment.bonus_challenges.length}
</p>
<div className="space-y-2">
{currentAssignment.bonus_challenges.map((bonus) => (
<div
key={bonus.id}
className={`rounded-lg border overflow-hidden ${
bonus.status === 'completed'
? 'bg-green-500/10 border-green-500/30'
: 'bg-dark-700/50 border-dark-600'
}`}
>
{/* Bonus header */}
<div
className={`p-3 flex items-center justify-between ${
bonus.status === 'pending' ? 'cursor-pointer hover:bg-dark-600/50' : ''
}`}
onClick={() => {
if (bonus.status === 'pending') {
setExpandedBonusId(expandedBonusId === bonus.id ? null : bonus.id)
setBonusProofFiles([])
setBonusProofUrl('')
setBonusComment('')
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
}
}}
>
<div className="flex-1">
<div className="flex items-center gap-2">
{bonus.status === 'completed' && (
<Check className="w-4 h-4 text-green-400" />
)}
<p className="text-white font-medium text-sm">{bonus.challenge.title}</p>
</div>
<p className="text-gray-400 text-xs mt-0.5">{bonus.challenge.description}</p>
</div>
<div className="text-right shrink-0 ml-2">
{bonus.status === 'completed' ? (
<span className="text-green-400 text-sm font-medium">+{bonus.points_earned}</span>
) : (
<span className="text-accent-400 text-sm">+{bonus.challenge.points}</span>
)}
</div>
</div>
{/* Expanded form for completing */}
{expandedBonusId === bonus.id && bonus.status === 'pending' && (
<div className="p-3 border-t border-dark-600 bg-dark-800/50 space-y-3">
{bonus.challenge.proof_hint && (
<p className="text-xs text-gray-400">
<strong className="text-white">Пруф:</strong> {bonus.challenge.proof_hint}
</p>
)}
{/* File upload */}
<input
ref={bonusFileInputRef}
type="file"
accept="image/*,video/*"
multiple
className="hidden"
onChange={(e) => {
e.stopPropagation()
const files = Array.from(e.target.files || [])
setBonusProofFiles(prev => [...prev, ...files])
e.target.value = ''
}}
/>
{bonusProofFiles.length > 0 ? (
<div className="space-y-2">
{bonusProofFiles.map((file, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-dark-700/50 rounded-lg border border-dark-600">
<span className="text-white text-sm flex-1 truncate">{file.name}</span>
<button
onClick={(e) => {
e.stopPropagation()
setBonusProofFiles(prev => prev.filter((_, i) => i !== index))
}}
className="p-1 rounded text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<button
onClick={(e) => {
e.stopPropagation()
bonusFileInputRef.current?.click()
}}
className="w-full p-2 border border-dashed border-neon-500/30 rounded-lg text-neon-400 hover:border-neon-500/50 hover:bg-neon-500/5 transition-all text-sm flex items-center justify-center gap-2"
>
<Upload className="w-4 h-4" />
Добавить еще файл
</button>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
bonusFileInputRef.current?.click()
}}
className="w-full p-2 border border-dashed border-dark-500 rounded-lg text-gray-400 text-sm hover:border-accent-400 hover:text-accent-400 transition-colors flex items-center justify-center gap-2"
>
<Upload className="w-4 h-4" />
Загрузить файл
</button>
)}
<div className="text-center text-gray-500 text-xs">или</div>
<input
type="text"
className="input text-sm"
placeholder="Ссылка на пруф (YouTube, Steam и т.д.)"
value={bonusProofUrl}
onChange={(e) => setBonusProofUrl(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
<textarea
className="input text-sm resize-none"
placeholder="Комментарий (необязательно)"
rows={2}
value={bonusComment}
onChange={(e) => setBonusComment(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex gap-2">
<NeonButton
size="sm"
onClick={(e) => {
e.stopPropagation()
handleBonusComplete(bonus.id)
}}
isLoading={isCompletingBonus}
disabled={bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment}
icon={<Check className="w-3 h-3" />}
>
Выполнено
</NeonButton>
<NeonButton
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
setBonusProofFiles([])
setBonusProofUrl('')
setBonusComment('')
setExpandedBonusId(null)
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
}}
>
Отмена
</NeonButton>
</div>
</div>
)}
</div>
))}
</div>
<p className="text-xs text-gray-500 mt-2">
Нажмите на бонус, чтобы отметить. Очки начислятся при завершении игры.
</p>
</div>
)}
</>
) : (
// Regular challenge
<>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задание</p>
<p className="text-xl font-bold text-neon-400 mb-2">
{currentAssignment.challenge?.title}
</p>
<p className="text-gray-300">
{currentAssignment.challenge?.description}
</p>
</div>
<div className="flex items-center gap-3 mb-6 flex-wrap">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
+{currentAssignment.challenge?.points} очков
</span> </span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600"> <span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{currentAssignment.challenge.difficulty} {currentAssignment.challenge?.difficulty}
</span> </span>
{currentAssignment.challenge.estimated_time && ( {currentAssignment.challenge?.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1"> <span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
~{currentAssignment.challenge.estimated_time} мин ~{currentAssignment.challenge.estimated_time} мин
@@ -928,38 +1213,60 @@ export function PlayPage() {
)} )}
</div> </div>
{currentAssignment.challenge.proof_hint && ( {currentAssignment.challenge?.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600"> <div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint} <strong className="text-white">Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint}
</p> </p>
</div> </div>
)} )}
</>
)}
<div className="space-y-4 mb-6"> <div className="space-y-4 mb-6">
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
Загрузить доказательство ({currentAssignment.challenge.proof_type}) Загрузить доказательство ({currentAssignment.is_playthrough
? currentAssignment.playthrough_info?.proof_type
: currentAssignment.challenge?.proof_type})
</label> </label>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*,video/*" accept="image/*,video/*"
multiple
className="hidden" className="hidden"
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setProofFile, fileInputRef)} onChange={(e) => {
const files = Array.from(e.target.files || [])
setProofFiles(prev => [...prev, ...files])
// Reset input to allow selecting same files again
e.target.value = ''
}}
/> />
{proofFile ? ( {proofFiles.length > 0 ? (
<div className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600"> <div className="space-y-2">
<span className="text-white flex-1 truncate">{proofFile.name}</span> {proofFiles.map((file, index) => (
<div key={index} className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<span className="text-white flex-1 truncate">{file.name}</span>
<button <button
onClick={() => setProofFile(null)} onClick={() => setProofFiles(proofFiles.filter((_, i) => i !== index))}
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors" className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
))}
<NeonButton
variant="outline"
className="w-full"
onClick={() => fileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />}
>
Добавить ещё файлы
</NeonButton>
</div>
) : ( ) : (
<div> <div>
<NeonButton <NeonButton
@@ -968,10 +1275,10 @@ export function PlayPage() {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />} icon={<Upload className="w-4 h-4" />}
> >
Выбрать файл Выбрать файлы
</NeonButton> </NeonButton>
<p className="text-xs text-gray-500 mt-2 text-center"> <p className="text-xs text-gray-500 mt-2 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео Можно выбрать несколько файлов. Макс. 15 МБ для изображений, 30 МБ для видео
</p> </p>
</div> </div>
)} )}
@@ -1000,7 +1307,10 @@ export function PlayPage() {
className="flex-1" className="flex-1"
onClick={handleComplete} onClick={handleComplete}
isLoading={isCompleting} isLoading={isCompleting}
disabled={!proofFile && !proofUrl} disabled={currentAssignment.is_playthrough
? (proofFiles.length === 0 && !proofUrl && !comment)
: (proofFiles.length === 0 && !proofUrl)
}
icon={<Check className="w-4 h-4" />} icon={<Check className="w-4 h-4" />}
> >
Выполнено Выполнено

View File

@@ -12,7 +12,8 @@ import {
import { import {
User, Camera, Trophy, Target, CheckCircle, Flame, User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink, Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound, Shield Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
AlertTriangle, FileCheck
} from 'lucide-react' } from 'lucide-react'
// Schemas // Schemas
@@ -51,6 +52,12 @@ export function ProfilePage() {
const [isPolling, setIsPolling] = useState(false) const [isPolling, setIsPolling] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null) const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Notification settings state
const [notifyEvents, setNotifyEvents] = useState(user?.notify_events ?? true)
const [notifyDisputes, setNotifyDisputes] = useState(user?.notify_disputes ?? true)
const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true)
const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
// Forms // Forms
@@ -265,6 +272,29 @@ export function ProfilePage() {
} }
} }
// Update notification setting
const handleNotificationToggle = async (
setting: 'notify_events' | 'notify_disputes' | 'notify_moderation',
currentValue: boolean,
setValue: (value: boolean) => void
) => {
setNotificationUpdating(setting)
const newValue = !currentValue
setValue(newValue)
try {
await usersApi.updateNotificationSettings({ [setting]: newValue })
updateUser({ [setting]: newValue })
toast.success('Настройки сохранены')
} catch {
// Revert on error
setValue(currentValue)
toast.error('Не удалось сохранить настройки')
} finally {
setNotificationUpdating(null)
}
}
const isLinked = !!user?.telegram_id const isLinked = !!user?.telegram_id
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
@@ -544,6 +574,109 @@ export function ProfilePage() {
</form> </form>
)} )}
</GlassCard> </GlassCard>
{/* Notifications */}
{isLinked && (
<GlassCard>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Bell className="w-6 h-6 text-neon-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Уведомления</h2>
<p className="text-sm text-gray-400">Настройте типы уведомлений в Telegram</p>
</div>
</div>
<div className="space-y-4">
{/* Events toggle */}
<button
onClick={() => handleNotificationToggle('notify_events', notifyEvents, setNotifyEvents)}
disabled={notificationUpdating !== null}
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-yellow-400" />
</div>
<div className="text-left">
<p className="text-white font-medium">События</p>
<p className="text-sm text-gray-400">Golden Hour, Jackpot, Double Risk и др.</p>
</div>
</div>
<div className={`
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
${notifyEvents ? 'bg-neon-500' : 'bg-dark-600'}
${notificationUpdating === 'notify_events' ? 'opacity-50' : ''}
`}>
<div className={`
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
${notifyEvents ? 'left-6' : 'left-1'}
`} />
</div>
</button>
{/* Disputes toggle */}
<button
onClick={() => handleNotificationToggle('notify_disputes', notifyDisputes, setNotifyDisputes)}
disabled={notificationUpdating !== null}
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-orange-400" />
</div>
<div className="text-left">
<p className="text-white font-medium">Споры</p>
<p className="text-sm text-gray-400">Оспаривания заданий и их решения</p>
</div>
</div>
<div className={`
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
${notifyDisputes ? 'bg-neon-500' : 'bg-dark-600'}
${notificationUpdating === 'notify_disputes' ? 'opacity-50' : ''}
`}>
<div className={`
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
${notifyDisputes ? 'left-6' : 'left-1'}
`} />
</div>
</button>
{/* Moderation toggle */}
<button
onClick={() => handleNotificationToggle('notify_moderation', notifyModeration, setNotifyModeration)}
disabled={notificationUpdating !== null}
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<FileCheck className="w-5 h-5 text-green-400" />
</div>
<div className="text-left">
<p className="text-white font-medium">Модерация</p>
<p className="text-sm text-gray-400">Одобрение/отклонение игр и челленджей</p>
</div>
</div>
<div className={`
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
${notifyModeration ? 'bg-neon-500' : 'bg-dark-600'}
${notificationUpdating === 'notify_moderation' ? 'opacity-50' : ''}
`}>
<div className={`
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
${notifyModeration ? 'left-6' : 'left-1'}
`} />
</div>
</button>
{/* Info about mandatory notifications */}
<p className="text-xs text-gray-500 mt-4">
Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.
</p>
</div>
</GlassCard>
)}
</div> </div>
) )
} }

View File

@@ -0,0 +1,107 @@
import { useState, useEffect } from 'react'
import { useParams, useLocation, Link } from 'react-router-dom'
import { contentApi } from '@/api/admin'
import type { StaticContent } from '@/types'
import { GlassCard } from '@/components/ui'
import { ArrowLeft, Loader2, FileText } from 'lucide-react'
// Map routes to content keys
const ROUTE_KEY_MAP: Record<string, string> = {
'/terms': 'terms_of_service',
'/privacy': 'privacy_policy',
}
export function StaticContentPage() {
const { key: paramKey } = useParams<{ key: string }>()
const location = useLocation()
const [content, setContent] = useState<StaticContent | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Determine content key from route or param
const contentKey = ROUTE_KEY_MAP[location.pathname] || paramKey
useEffect(() => {
if (!contentKey) return
const loadContent = async () => {
setIsLoading(true)
setError(null)
try {
const data = await contentApi.getPublicContent(contentKey)
setContent(data)
} catch {
setError('Контент не найден')
} finally {
setIsLoading(false)
}
}
loadContent()
}, [contentKey])
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка...</p>
</div>
)
}
if (error || !content) {
return (
<div className="max-w-3xl mx-auto">
<GlassCard className="text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-dark-700 flex items-center justify-center">
<FileText className="w-10 h-10 text-gray-600" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">Страница не найдена</h3>
<p className="text-gray-400 mb-6">Запрашиваемый контент не существует</p>
<Link
to="/"
className="inline-flex items-center gap-2 text-neon-400 hover:text-neon-300 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
На главную
</Link>
</GlassCard>
</div>
)
}
return (
<div className="max-w-3xl mx-auto">
<Link
to="/"
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
На главную
</Link>
<GlassCard>
<h1 className="text-2xl md:text-3xl font-bold text-white mb-6">{content.title}</h1>
<div
className="prose prose-invert prose-gray max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-gray-300 prose-p:leading-relaxed
prose-a:text-neon-400 prose-a:no-underline hover:prose-a:text-neon-300
prose-strong:text-white
prose-ul:text-gray-300 prose-ol:text-gray-300
prose-li:marker:text-gray-500
prose-hr:border-dark-600 prose-hr:my-6
prose-img:rounded-xl prose-img:shadow-lg"
dangerouslySetInnerHTML={{ __html: content.content }}
/>
<div className="mt-8 pt-6 border-t border-dark-600 text-sm text-gray-500">
Последнее обновление: {new Date(content.updated_at).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</div>
</GlassCard>
</div>
)
}

View File

@@ -1,9 +1,40 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { adminApi } from '@/api' import { adminApi } from '@/api'
import type { AdminMarathon } from '@/types' import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { NeonButton } from '@/components/ui' import { NeonButton } from '@/components/ui'
import { Send, Users, Trophy, AlertTriangle } from 'lucide-react' import { Send, Users, Trophy, AlertTriangle, Search, Eye, MessageSquare, ChevronDown, X, Image, Film, Trash2, Plus } from 'lucide-react'
interface MediaItem {
file: File
preview: string
type: 'photo' | 'video'
}
// Telegram supported tags for reference
const TELEGRAM_TAGS = [
{ tag: '<b>', description: 'Жирный текст', example: '<b>жирный</b>' },
{ tag: '<i>', description: 'Курсив', example: '<i>курсив</i>' },
{ tag: '<u>', description: 'Подчёркнутый', example: '<u>подчёркнутый</u>' },
{ tag: '<s>', description: 'Зачёркнутый', example: '<s>зачёркнутый</s>' },
{ tag: '<code>', description: 'Моноширинный', example: '<code>код</code>' },
{ tag: '<pre>', description: 'Блок кода', example: '<pre>блок кода</pre>' },
{ tag: '<a>', description: 'Ссылка', example: '<a href="url">текст</a>' },
{ tag: '<tg-spoiler>', description: 'Спойлер', example: '<tg-spoiler>скрытый текст</tg-spoiler>' },
{ tag: '<blockquote>', description: 'Цитата', example: '<blockquote>цитата</blockquote>' },
]
// Convert Telegram HTML to web-safe HTML for preview
function telegramToHtml(text: string): string {
let html = text
// Convert tg-spoiler to span with blur effect
.replace(/<tg-spoiler>/g, '<span class="tg-spoiler">')
.replace(/<\/tg-spoiler>/g, '</span>')
// Convert newlines to <br> tags (but not inside <pre> blocks)
.replace(/\n/g, '<br>')
return html
}
export function AdminBroadcastPage() { export function AdminBroadcastPage() {
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
@@ -12,11 +43,28 @@ export function AdminBroadcastPage() {
const [marathons, setMarathons] = useState<AdminMarathon[]>([]) const [marathons, setMarathons] = useState<AdminMarathon[]>([])
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [loadingMarathons, setLoadingMarathons] = useState(false) const [loadingMarathons, setLoadingMarathons] = useState(false)
const [marathonSearch, setMarathonSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'preparing' | 'finished'>('all')
const [showTagsHelp, setShowTagsHelp] = useState(false)
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// Undo/Redo history
const [history, setHistory] = useState<string[]>([''])
const [historyIndex, setHistoryIndex] = useState(0)
const isUndoRedo = useRef(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const toast = useToast() const toast = useToast()
// Load marathons on mount and when switching to marathon target
useEffect(() => { useEffect(() => {
if (targetType === 'marathon') { // Always load marathons on mount so they're ready when user switches
loadMarathons()
}, [])
useEffect(() => {
if (targetType === 'marathon' && marathons.length === 0) {
loadMarathons() loadMarathons()
} }
}, [targetType]) }, [targetType])
@@ -24,18 +72,155 @@ export function AdminBroadcastPage() {
const loadMarathons = async () => { const loadMarathons = async () => {
setLoadingMarathons(true) setLoadingMarathons(true)
try { try {
const data = await adminApi.listMarathons(0, 100) const data = await adminApi.listMarathons(0, 200)
setMarathons(data.filter(m => m.status === 'active')) console.log('Loaded marathons:', data)
setMarathons(data)
} catch (err) { } catch (err) {
console.error('Failed to load marathons:', err) console.error('Failed to load marathons:', err)
toast.error('Ошибка загрузки марафонов')
} finally { } finally {
setLoadingMarathons(false) setLoadingMarathons(false)
} }
} }
// Handle message change with history
const handleMessageChange = useCallback((newValue: string) => {
if (isUndoRedo.current) {
isUndoRedo.current = false
return
}
setMessage(newValue)
// Add to history (debounced - only if different from last entry)
setHistory(prev => {
const newHistory = prev.slice(0, historyIndex + 1)
if (newHistory[newHistory.length - 1] !== newValue) {
return [...newHistory, newValue]
}
return newHistory
})
setHistoryIndex(prev => prev + 1)
}, [historyIndex])
// Undo function
const undo = useCallback(() => {
if (historyIndex > 0) {
isUndoRedo.current = true
const newIndex = historyIndex - 1
setHistoryIndex(newIndex)
setMessage(history[newIndex])
}
}, [history, historyIndex])
// Redo function
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
isUndoRedo.current = true
const newIndex = historyIndex + 1
setHistoryIndex(newIndex)
setMessage(history[newIndex])
}
}, [history, historyIndex])
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z') {
e.preventDefault()
if (e.shiftKey) {
redo()
} else {
undo()
}
} else if (e.key === 'y') {
e.preventDefault()
redo()
}
}
}, [undo, redo])
// Handle media file selection (multiple)
const handleMediaSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
// Check max limit (10 files for Telegram media group)
if (mediaItems.length + files.length > 10) {
toast.error('Максимум 10 файлов')
return
}
const newItems: MediaItem[] = []
for (const file of Array.from(files)) {
// Validate file type
const isImage = file.type.startsWith('image/')
const isVideo = file.type.startsWith('video/')
if (!isImage && !isVideo) {
toast.error(`${file.name}: поддерживаются только изображения и видео`)
continue
}
// Validate file size (20MB for images, 50MB for videos)
const maxSize = isImage ? 20 * 1024 * 1024 : 50 * 1024 * 1024
if (file.size > maxSize) {
toast.error(`${file.name}: файл слишком большой. Максимум: ${isImage ? '20MB' : '50MB'}`)
continue
}
newItems.push({
file,
preview: URL.createObjectURL(file),
type: isImage ? 'photo' : 'video'
})
}
if (newItems.length > 0) {
setMediaItems(prev => [...prev, ...newItems])
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
// Remove single media item
const removeMediaItem = (index: number) => {
setMediaItems(prev => {
const item = prev[index]
if (item) {
URL.revokeObjectURL(item.preview)
}
return prev.filter((_, i) => i !== index)
})
}
// Clear all media
const clearAllMedia = () => {
mediaItems.forEach(item => URL.revokeObjectURL(item.preview))
setMediaItems([])
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
// Filter marathons based on search and status
const filteredMarathons = useMemo(() => {
return marathons.filter(m => {
const matchesSearch = !marathonSearch ||
m.title.toLowerCase().includes(marathonSearch.toLowerCase())
const matchesStatus = statusFilter === 'all' || m.status === statusFilter
return matchesSearch && matchesStatus
})
}, [marathons, marathonSearch, statusFilter])
const selectedMarathon = marathons.find(m => m.id === marathonId)
const handleSend = async () => { const handleSend = async () => {
if (!message.trim()) { if (!message.trim() && mediaItems.length === 0) {
toast.error('Введите сообщение') toast.error('Введите сообщение или прикрепите медиа')
return return
} }
@@ -46,15 +231,17 @@ export function AdminBroadcastPage() {
setSending(true) setSending(true)
try { try {
const files = mediaItems.map(item => item.file)
let result let result
if (targetType === 'all') { if (targetType === 'all') {
result = await adminApi.broadcastToAll(message) result = await adminApi.broadcastToAll(message, files.length > 0 ? files : undefined)
} else { } else {
result = await adminApi.broadcastToMarathon(marathonId!, message) result = await adminApi.broadcastToMarathon(marathonId!, message, files.length > 0 ? files : undefined)
} }
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`) toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
setMessage('') setMessage('')
clearAllMedia()
} catch (err) { } catch (err) {
console.error('Failed to send broadcast:', err) console.error('Failed to send broadcast:', err)
toast.error('Ошибка отправки') toast.error('Ошибка отправки')
@@ -63,6 +250,39 @@ export function AdminBroadcastPage() {
} }
} }
const getStatusBadge = (status: string) => {
const config = {
active: { label: 'Активен', class: 'bg-green-500/20 text-green-400 border-green-500/30' },
preparing: { label: 'Подготовка', class: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
finished: { label: 'Завершён', class: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
}
const c = config[status as keyof typeof config] || config.finished
return (
<span className={`text-xs px-2 py-0.5 rounded border ${c.class}`}>
{c.label}
</span>
)
}
const insertTag = (openTag: string, closeTag: string) => {
const textarea = textareaRef.current
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selectedText = message.substring(start, end)
const newText = message.substring(0, start) + openTag + selectedText + closeTag + message.substring(end)
handleMessageChange(newText)
// Restore cursor position
setTimeout(() => {
textarea.focus()
const newCursorPos = start + openTag.length + selectedText.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
}, 0)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -73,7 +293,9 @@ export function AdminBroadcastPage() {
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1> <h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
</div> </div>
<div className="max-w-2xl space-y-6"> <div className="flex gap-6">
{/* Left Column - Editor */}
<div className="flex-1 space-y-6">
{/* Target Selection */} {/* Target Selection */}
<div className="space-y-3"> <div className="space-y-3">
<label className="block text-sm font-medium text-gray-300"> <label className="block text-sm font-medium text-gray-300">
@@ -110,52 +332,303 @@ export function AdminBroadcastPage() {
{/* Marathon Selection */} {/* Marathon Selection */}
{targetType === 'marathon' && ( {targetType === 'marathon' && (
<div className="space-y-2"> <div className="space-y-3">
<label className="block text-sm font-medium text-gray-300"> <label className="block text-sm font-medium text-gray-300">
Выберите марафон Выберите марафон
</label> </label>
{loadingMarathons ? (
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" /> {/* Search and Filter */}
) : ( <div className="flex gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Поиск по названию..."
value={marathonSearch}
onChange={(e) => setMarathonSearch(e.target.value)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
/>
</div>
<select <select
value={marathonId || ''} value={statusFilter}
onChange={(e) => setMarathonId(Number(e.target.value) || null)} onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-accent-500/50 transition-colors" className="bg-dark-700/50 border border-dark-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-accent-500/50 transition-colors"
> >
<option value="">Выберите марафон...</option> <option value="all">Все статусы</option>
{marathons.map((m) => ( <option value="active">Активные</option>
<option key={m.id} value={m.id}> <option value="preparing">Подготовка</option>
{m.title} ({m.participants_count} участников) <option value="finished">Завершённые</option>
</option>
))}
</select> </select>
</div>
{/* Marathon List */}
{loadingMarathons ? (
<div className="animate-pulse bg-dark-700 h-32 rounded-xl" />
) : (
<div className="max-h-48 overflow-y-auto rounded-xl border border-dark-600 bg-dark-800/50">
{filteredMarathons.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
{marathons.length === 0 ? (
<div className="space-y-2">
<p>Не удалось загрузить марафоны</p>
<button
onClick={loadMarathons}
className="text-accent-400 hover:underline"
>
Повторить загрузку
</button>
</div>
) : (
'Марафоны не найдены по фильтру'
)} )}
{marathons.length === 0 && !loadingMarathons && ( </div>
<p className="text-sm text-gray-500">Нет активных марафонов</p> ) : (
filteredMarathons.map((m) => (
<button
key={m.id}
onClick={() => setMarathonId(marathonId === m.id ? null : m.id)}
className={`w-full flex items-center justify-between p-3 text-left border-b border-dark-600 last:border-b-0 transition-colors ${
marathonId === m.id
? 'bg-accent-500/20 text-white'
: 'hover:bg-dark-700/50 text-gray-300'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<span className="truncate font-medium">{m.title}</span>
{getStatusBadge(m.status)}
</div>
<span className="text-xs text-gray-500 flex-shrink-0 ml-2">
{m.participants_count} уч.
</span>
</button>
))
)} )}
</div> </div>
)} )}
{/* Message */} {/* Selected Marathon Info */}
{selectedMarathon && (
<div className="p-3 bg-accent-500/10 border border-accent-500/30 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-accent-400">
<Trophy className="w-4 h-4" />
<span className="font-medium">{selectedMarathon.title}</span>
{getStatusBadge(selectedMarathon.status)}
</div>
<button
onClick={() => setMarathonId(null)}
className="p-1 text-gray-400 hover:text-white rounded hover:bg-dark-600/50 transition-colors"
title="Отменить выбор"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-gray-400 mt-1">
{selectedMarathon.participants_count} участников получат сообщение
</p>
</div>
)}
</div>
)}
{/* Message with Tag Toolbar */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-300"> <label className="block text-sm font-medium text-gray-300">
Сообщение Сообщение
</label> </label>
<button
onClick={() => setShowTagsHelp(!showTagsHelp)}
className="text-xs text-gray-500 hover:text-accent-400 flex items-center gap-1 transition-colors"
>
<ChevronDown className={`w-3 h-3 transition-transform ${showTagsHelp ? 'rotate-180' : ''}`} />
Справка по тегам
</button>
</div>
{/* Quick Tag Buttons */}
<div className="flex flex-wrap gap-1">
<button
onClick={() => insertTag('<b>', '</b>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-bold transition-colors"
title="Жирный"
>
B
</button>
<button
onClick={() => insertTag('<i>', '</i>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded italic transition-colors"
title="Курсив"
>
I
</button>
<button
onClick={() => insertTag('<u>', '</u>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded underline transition-colors"
title="Подчёркнутый"
>
U
</button>
<button
onClick={() => insertTag('<s>', '</s>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded line-through transition-colors"
title="Зачёркнутый"
>
S
</button>
<button
onClick={() => insertTag('<code>', '</code>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-mono transition-colors"
title="Код"
>
{'</>'}
</button>
<button
onClick={() => insertTag('<pre>', '</pre>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-mono transition-colors"
title="Блок кода"
>
PRE
</button>
<button
onClick={() => insertTag('<a href="URL">', '</a>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
title="Ссылка"
>
🔗
</button>
<button
onClick={() => insertTag('<tg-spoiler>', '</tg-spoiler>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
title="Спойлер"
>
👁
</button>
<button
onClick={() => insertTag('<blockquote>', '</blockquote>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
title="Цитата"
>
</button>
</div>
{/* Tags Help */}
{showTagsHelp && (
<div className="p-3 bg-dark-800 border border-dark-600 rounded-lg text-xs space-y-2">
<p className="text-gray-400 font-medium mb-2">Поддерживаемые теги Telegram:</p>
<div className="grid grid-cols-2 gap-2">
{TELEGRAM_TAGS.map((t) => (
<div key={t.tag} className="flex items-center gap-2">
<code className="text-neon-400 bg-dark-700 px-1.5 py-0.5 rounded">{t.tag}</code>
<span className="text-gray-500"> {t.description}</span>
</div>
))}
</div>
</div>
)}
<textarea <textarea
ref={textareaRef}
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => handleMessageChange(e.target.value)}
rows={6} onKeyDown={handleKeyDown}
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)" rows={8}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none" placeholder="Введите текст сообщения..."
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none font-mono text-sm"
/> />
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between">
<p className="text-gray-500"> <p className="text-xs text-gray-600">
Поддерживается HTML: &lt;b&gt;, &lt;i&gt;, &lt;code&gt;, &lt;a href&gt; Ctrl+Z отмена Ctrl+Shift+Z повтор
</p> </p>
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}> <p className={`text-xs ${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
{message.length} / 2000 {message.length} / 2000
</p> </p>
</div> </div>
{/* Media Attachment */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleMediaSelect}
accept="image/*,video/*"
multiple
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={mediaItems.length >= 10}
className="flex items-center gap-2 px-3 py-2 text-sm bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Image className="w-4 h-4" />
<Film className="w-4 h-4" />
<span>Прикрепить медиа</span>
</button>
{mediaItems.length > 0 && (
<>
<span className="text-sm text-gray-500">
{mediaItems.length}/10 файлов
</span>
<button
onClick={clearAllMedia}
className="flex items-center gap-1 px-2 py-1 text-xs text-red-400 hover:text-red-300 transition-colors"
>
<Trash2 className="w-3 h-3" />
Удалить все
</button>
</>
)}
</div>
{/* Media Grid Preview */}
{mediaItems.length > 0 && (
<div className="grid grid-cols-5 gap-2">
{mediaItems.map((item, index) => (
<div
key={index}
className="relative aspect-square rounded-lg overflow-hidden border border-dark-600 bg-dark-800 group"
>
{item.type === 'photo' ? (
<img
src={item.preview}
alt={`Preview ${index + 1}`}
className="w-full h-full object-cover"
/>
) : (
<div className="relative w-full h-full">
<video
src={item.preview}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<Film className="w-6 h-6 text-white" />
</div>
</div>
)}
<button
onClick={() => removeMediaItem(index)}
className="absolute top-1 right-1 p-1 bg-red-500/80 hover:bg-red-500 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
<div className="absolute bottom-1 left-1 px-1.5 py-0.5 bg-black/60 rounded text-xs text-white">
{item.type === 'photo' ? 'IMG' : 'VID'}
</div>
</div>
))}
{mediaItems.length < 10 && (
<button
onClick={() => fileInputRef.current?.click()}
className="aspect-square rounded-lg border-2 border-dashed border-dark-500 hover:border-accent-500/50 flex items-center justify-center text-gray-500 hover:text-accent-400 transition-colors"
>
<Plus className="w-6 h-6" />
</button>
)}
</div>
)}
</div>
</div> </div>
{/* Send Button */} {/* Send Button */}
@@ -163,7 +636,7 @@ export function AdminBroadcastPage() {
size="lg" size="lg"
color="purple" color="purple"
onClick={handleSend} onClick={handleSend}
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)} disabled={sending || (!message.trim() && mediaItems.length === 0) || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
isLoading={sending} isLoading={sending}
icon={<Send className="w-5 h-5" />} icon={<Send className="w-5 h-5" />}
className="w-full" className="w-full"
@@ -185,6 +658,156 @@ export function AdminBroadcastPage() {
</div> </div>
</div> </div>
</div> </div>
{/* Right Column - Preview */}
<div className="w-96 flex-shrink-0">
<div className="sticky top-6 space-y-3">
<div className="flex items-center gap-2 text-gray-400">
<Eye className="w-4 h-4" />
<span className="text-sm font-medium">Предпросмотр</span>
</div>
{/* Telegram-style Preview */}
<div className="bg-[#0e1621] rounded-2xl p-4 border border-dark-600 shadow-xl">
{/* Chat Header */}
<div className="flex items-center gap-3 pb-3 border-b border-dark-600 mb-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-accent-500 to-pink-500 flex items-center justify-center text-white font-bold">
M
</div>
<div>
<p className="text-white font-medium text-sm">Marathon Bot</p>
<p className="text-gray-500 text-xs">бот</p>
</div>
</div>
{/* Message Bubble */}
<div className="bg-[#182533] rounded-2xl rounded-tl-md overflow-hidden max-w-full">
{/* Media Preview in Telegram style */}
{mediaItems.length > 0 && (
<div className={`${mediaItems.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''}`}>
{mediaItems.slice(0, 4).map((item, index) => (
<div key={index} className="relative">
{item.type === 'photo' ? (
<img
src={item.preview}
alt={`Preview ${index + 1}`}
className={`w-full object-cover ${mediaItems.length === 1 ? 'max-h-64' : 'h-24'}`}
/>
) : (
<div className="relative bg-black">
<video
src={item.preview}
className={`w-full object-cover ${mediaItems.length === 1 ? 'max-h-64' : 'h-24'}`}
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-white/30 backdrop-blur flex items-center justify-center">
<Film className="w-4 h-4 text-white" />
</div>
</div>
</div>
)}
{/* Show +N indicator on last visible item if there are more */}
{index === 3 && mediaItems.length > 4 && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<span className="text-white text-lg font-bold">+{mediaItems.length - 4}</span>
</div>
)}
</div>
))}
</div>
)}
<div className="p-3">
{message.trim() ? (
<div
className="text-white text-sm break-words telegram-preview"
dangerouslySetInnerHTML={{ __html: telegramToHtml(message) }}
/>
) : mediaItems.length === 0 ? (
<p className="text-gray-500 text-sm italic">Введите сообщение...</p>
) : null}
<p className="text-gray-500 text-xs text-right mt-2">
{new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
{/* Info */}
<div className="mt-4 pt-3 border-t border-dark-600">
<div className="flex items-center gap-2 text-gray-500 text-xs">
<MessageSquare className="w-3 h-3" />
<span>
Так будет выглядеть сообщение в Telegram
</span>
</div>
</div>
</div>
{/* Preview Notes */}
<div className="text-xs text-gray-500 space-y-1">
<p> Спойлеры (<code className="text-gray-400">&lt;tg-spoiler&gt;</code>) показаны размытыми</p>
<p> Цитаты (<code className="text-gray-400">&lt;blockquote&gt;</code>) с вертикальной линией</p>
<p> Ссылки будут кликабельны в Telegram</p>
</div>
</div>
</div>
</div>
{/* Custom styles for Telegram preview */}
<style>{`
.telegram-preview b, .telegram-preview strong {
font-weight: 600;
}
.telegram-preview i, .telegram-preview em {
font-style: italic;
}
.telegram-preview u {
text-decoration: underline;
}
.telegram-preview s, .telegram-preview strike, .telegram-preview del {
text-decoration: line-through;
}
.telegram-preview code {
font-family: monospace;
background: rgba(255,255,255,0.1);
padding: 1px 4px;
border-radius: 4px;
font-size: 0.9em;
}
.telegram-preview pre {
font-family: monospace;
background: rgba(0,0,0,0.3);
padding: 8px 12px;
border-radius: 8px;
overflow-x: auto;
margin: 4px 0;
font-size: 0.85em;
}
.telegram-preview a {
color: #6ab2f2;
text-decoration: none;
}
.telegram-preview a:hover {
text-decoration: underline;
}
.telegram-preview .tg-spoiler {
background: #333;
color: transparent;
border-radius: 4px;
padding: 0 4px;
cursor: pointer;
transition: all 0.2s;
}
.telegram-preview .tg-spoiler:hover {
color: white;
background: transparent;
}
.telegram-preview blockquote {
border-left: 3px solid #6ab2f2;
padding-left: 12px;
margin: 8px 0;
color: #aaa;
}
`}</style>
</div> </div>
) )
} }

View File

@@ -3,7 +3,8 @@ import { adminApi } from '@/api'
import type { StaticContent } from '@/types' import type { StaticContent } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { NeonButton } from '@/components/ui' import { NeonButton } from '@/components/ui'
import { FileText, Plus, Pencil, X, Save, Code } from 'lucide-react' import { FileText, Plus, Pencil, X, Save, Code, Trash2 } from 'lucide-react'
import { useConfirm } from '@/store/confirm'
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('ru-RU', { return new Date(dateStr).toLocaleString('ru-RU', {
@@ -28,6 +29,7 @@ export function AdminContentPage() {
const [formContent, setFormContent] = useState('') const [formContent, setFormContent] = useState('')
const toast = useToast() const toast = useToast()
const confirm = useConfirm()
useEffect(() => { useEffect(() => {
loadContents() loadContents()
@@ -101,6 +103,30 @@ export function AdminContentPage() {
} }
} }
const handleDelete = async (content: StaticContent) => {
const confirmed = await confirm({
title: 'Удалить контент?',
message: `Вы уверены, что хотите удалить "${content.title}"? Это действие нельзя отменить.`,
confirmText: 'Удалить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
try {
await adminApi.deleteContent(content.key)
setContents(contents.filter(c => c.id !== content.id))
if (editing?.id === content.id) {
handleCancel()
}
toast.success('Контент удалён')
} catch (err) {
console.error('Failed to delete content:', err)
toast.error('Ошибка удаления')
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@@ -155,15 +181,28 @@ export function AdminContentPage() {
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}... {content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
</p> </p>
</div> </div>
<div className="flex items-center gap-1 ml-3">
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleEdit(content) handleEdit(content)
}} }}
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors ml-3" className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors"
title="Редактировать"
> >
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</button> </button>
<button
onClick={(e) => {
e.stopPropagation()
handleDelete(content)
}}
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div> </div>
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600"> <p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
Обновлено: {formatDate(content.updated_at)} Обновлено: {formatDate(content.updated_at)}

View File

@@ -4,7 +4,7 @@ import type { AdminUser, UserRole } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui' import { NeonButton } from '@/components/ui'
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X } from 'lucide-react' import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound, Bell, BellOff } from 'lucide-react'
export function AdminUsersPage() { export function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
@@ -17,6 +17,9 @@ export function AdminUsersPage() {
const [banDuration, setBanDuration] = useState<string>('permanent') const [banDuration, setBanDuration] = useState<string>('permanent')
const [banCustomDate, setBanCustomDate] = useState('') const [banCustomDate, setBanCustomDate] = useState('')
const [banning, setBanning] = useState(false) const [banning, setBanning] = useState(false)
const [resetPasswordUser, setResetPasswordUser] = useState<AdminUser | null>(null)
const [newPassword, setNewPassword] = useState('')
const [resettingPassword, setResettingPassword] = useState(false)
const toast = useToast() const toast = useToast()
const confirm = useConfirm() const confirm = useConfirm()
@@ -120,6 +123,24 @@ export function AdminUsersPage() {
} }
} }
const handleResetPassword = async () => {
if (!resetPasswordUser || !newPassword.trim() || newPassword.length < 6) return
setResettingPassword(true)
try {
const updated = await adminApi.resetUserPassword(resetPasswordUser.id, newPassword)
setUsers(users.map(u => u.id === updated.id ? updated : u))
toast.success(`Пароль ${updated.nickname} сброшен`)
setResetPasswordUser(null)
setNewPassword('')
} catch (err) {
console.error('Failed to reset password:', err)
toast.error('Ошибка сброса пароля')
} finally {
setResettingPassword(false)
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -174,6 +195,7 @@ export function AdminUsersPage() {
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Роль</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Роль</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Telegram</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Telegram</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Марафоны</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Марафоны</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Уведомления</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
</tr> </tr>
@@ -181,13 +203,13 @@ export function AdminUsersPage() {
<tbody className="divide-y divide-dark-600"> <tbody className="divide-y divide-dark-600">
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={8} className="px-4 py-8 text-center"> <td colSpan={9} className="px-4 py-8 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" /> <div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
</td> </td>
</tr> </tr>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<tr> <tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400"> <td colSpan={9} className="px-4 py-8 text-center text-gray-400">
Пользователи не найдены Пользователи не найдены
</td> </td>
</tr> </tr>
@@ -215,6 +237,30 @@ export function AdminUsersPage() {
)} )}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-400">{user.marathons_count}</td> <td className="px-4 py-3 text-sm text-gray-400">{user.marathons_count}</td>
<td className="px-4 py-3">
{user.telegram_id ? (
<div className="flex items-center gap-1" title={`События: ${user.notify_events ? 'вкл' : 'выкл'}, Споры: ${user.notify_disputes ? 'вкл' : 'выкл'}, Модерация: ${user.notify_moderation ? 'вкл' : 'выкл'}`}>
{user.notify_events && user.notify_disputes && user.notify_moderation ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
<Bell className="w-3 h-3" />
Все
</span>
) : !user.notify_events && !user.notify_disputes && !user.notify_moderation ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
<BellOff className="w-3 h-3" />
Откл
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
<Bell className="w-3 h-3" />
Частично
</span>
)}
</div>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{user.is_banned ? ( {user.is_banned ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
@@ -265,6 +311,14 @@ export function AdminUsersPage() {
<Shield className="w-4 h-4" /> <Shield className="w-4 h-4" />
</button> </button>
)} )}
<button
onClick={() => setResetPasswordUser(user)}
className="p-2 text-yellow-400 hover:bg-yellow-500/20 rounded-lg transition-colors"
title="Сбросить пароль"
>
<KeyRound className="w-4 h-4" />
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -393,6 +447,71 @@ export function AdminUsersPage() {
</div> </div>
</div> </div>
)} )}
{/* Reset Password Modal */}
{resetPasswordUser && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<KeyRound className="w-5 h-5 text-yellow-400" />
Сбросить пароль {resetPasswordUser.nickname}
</h3>
<button
onClick={() => {
setResetPasswordUser(null)
setNewPassword('')
}}
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-300 mb-2">
Новый пароль
</label>
<input
type="text"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Минимум 6 символов"
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
/>
{newPassword && newPassword.length < 6 && (
<p className="mt-2 text-sm text-red-400">Пароль должен быть минимум 6 символов</p>
)}
{resetPasswordUser.telegram_id && (
<p className="mt-2 text-sm text-gray-400">
Пользователь получит уведомление в Telegram о смене пароля
</p>
)}
</div>
<div className="flex gap-3 justify-end">
<NeonButton
variant="ghost"
onClick={() => {
setResetPasswordUser(null)
setNewPassword('')
}}
>
Отмена
</NeonButton>
<NeonButton
color="neon"
onClick={handleResetPassword}
disabled={!newPassword.trim() || newPassword.length < 6 || resettingPassword}
isLoading={resettingPassword}
icon={<KeyRound className="w-4 h-4" />}
>
Сбросить
</NeonButton>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -3,6 +3,8 @@ import { persist } from 'zustand/middleware'
import type { User } from '@/types' import type { User } from '@/types'
import { authApi, type RegisterData, type LoginData } from '@/api/auth' import { authApi, type RegisterData, type LoginData } from '@/api/auth'
let syncPromise: Promise<void> | null = null
interface Pending2FA { interface Pending2FA {
sessionId: number sessionId: number
} }
@@ -41,6 +43,7 @@ interface AuthState {
bumpAvatarVersion: () => void bumpAvatarVersion: () => void
setBanned: (banInfo: BanInfo) => void setBanned: (banInfo: BanInfo) => void
clearBanned: () => void clearBanned: () => void
syncUser: () => Promise<void>
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
@@ -57,7 +60,7 @@ export const useAuthStore = create<AuthState>()(
banInfo: null, banInfo: null,
login: async (data) => { login: async (data) => {
set({ isLoading: true, error: null, pending2FA: null }) set({ isLoading: true, error: null, pending2FA: null, banInfo: null })
try { try {
const response = await authApi.login(data) const response = await authApi.login(data)
@@ -82,9 +85,34 @@ export const useAuthStore = create<AuthState>()(
} }
return { requires2FA: false } return { requires2FA: false }
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { status?: number; data?: { detail?: string | BanInfo } } }
// Check if user is banned (403 with ban info)
if (error.response?.status === 403) {
const detail = error.response?.data?.detail
if (typeof detail === 'object' && detail !== null && 'banned_at' in detail) {
set({ set({
error: error.response?.data?.detail || 'Login failed', banInfo: detail as BanInfo,
isLoading: false,
error: null,
})
throw err
}
}
// Regular error - translate common messages
let errorMessage = 'Ошибка входа'
const detail = error.response?.data?.detail
if (typeof detail === 'string') {
if (detail === 'Incorrect login or password') {
errorMessage = 'Неверный логин или пароль'
} else {
errorMessage = detail
}
}
set({
error: errorMessage,
isLoading: false, isLoading: false,
}) })
throw err throw err
@@ -145,6 +173,7 @@ export const useAuthStore = create<AuthState>()(
logout: () => { logout: () => {
localStorage.removeItem('token') localStorage.removeItem('token')
sessionStorage.removeItem('telegram_banner_dismissed')
set({ set({
user: null, user: null,
token: null, token: null,
@@ -181,6 +210,27 @@ export const useAuthStore = create<AuthState>()(
clearBanned: () => { clearBanned: () => {
set({ banInfo: null }) set({ banInfo: null })
}, },
syncUser: async () => {
if (!get().isAuthenticated || !get().token) return
// Prevent duplicate sync calls
if (syncPromise) return syncPromise
syncPromise = (async () => {
try {
const userData = await authApi.me()
set({ user: userData })
} catch {
// Token invalid - logout
get().logout()
} finally {
syncPromise = null
}
})()
return syncPromise
},
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',

View File

@@ -18,6 +18,23 @@ export interface User extends UserPublic {
telegram_username?: string | null // Only visible to self telegram_username?: string | null // Only visible to self
telegram_first_name?: string | null // Only visible to self telegram_first_name?: string | null // Only visible to self
telegram_last_name?: string | null // Only visible to self telegram_last_name?: string | null // Only visible to self
// Notification settings
notify_events?: boolean
notify_disputes?: boolean
notify_moderation?: boolean
}
// Notification settings
export interface NotificationSettings {
notify_events: boolean
notify_disputes: boolean
notify_moderation: boolean
}
export interface NotificationSettingsUpdate {
notify_events?: boolean
notify_disputes?: boolean
notify_moderation?: boolean
} }
export interface TokenResponse { export interface TokenResponse {
@@ -63,6 +80,7 @@ export interface Marathon {
is_public: boolean is_public: boolean
game_proposal_mode: GameProposalMode game_proposal_mode: GameProposalMode
auto_events_enabled: boolean auto_events_enabled: boolean
cover_url: string | null
start_date: string | null start_date: string | null
end_date: string | null end_date: string | null
participants_count: number participants_count: number
@@ -76,6 +94,7 @@ export interface MarathonListItem {
title: string title: string
status: MarathonStatus status: MarathonStatus
is_public: boolean is_public: boolean
cover_url: string | null
participants_count: number participants_count: number
start_date: string | null start_date: string | null
end_date: string | null end_date: string | null
@@ -90,11 +109,21 @@ export interface MarathonCreate {
game_proposal_mode: GameProposalMode game_proposal_mode: GameProposalMode
} }
export interface MarathonUpdate {
title?: string
description?: string
start_date?: string
is_public?: boolean
game_proposal_mode?: GameProposalMode
auto_events_enabled?: boolean
}
export interface MarathonPublicInfo { export interface MarathonPublicInfo {
id: number id: number
title: string title: string
description: string | null description: string | null
status: MarathonStatus status: MarathonStatus
cover_url: string | null
participants_count: number participants_count: number
creator_nickname: string creator_nickname: string
} }
@@ -110,6 +139,14 @@ export interface LeaderboardEntry {
// Game types // Game types
export type GameStatus = 'pending' | 'approved' | 'rejected' export type GameStatus = 'pending' | 'approved' | 'rejected'
export type GameType = 'challenges' | 'playthrough'
export interface PlaythroughInfo {
description: string
points: number
proof_type: ProofType
proof_hint: string | null
}
export interface Game { export interface Game {
id: number id: number
@@ -122,12 +159,25 @@ export interface Game {
approved_by: User | null approved_by: User | null
challenges_count: number challenges_count: number
created_at: string created_at: string
// Game type fields
game_type: GameType
playthrough_points: number | null
playthrough_description: string | null
playthrough_proof_type: ProofType | null
playthrough_proof_hint: string | null
} }
export interface GameShort { export interface GameShort {
id: number id: number
title: string title: string
cover_url: string | null cover_url: string | null
download_url?: string
game_type?: GameType
}
export interface AvailableGamesCount {
available: number
total: number
} }
// Challenge types // Challenge types
@@ -187,10 +237,28 @@ export interface ChallengesPreviewResponse {
// Assignment types // Assignment types
export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned' export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned'
export type BonusAssignmentStatus = 'pending' | 'completed'
export interface BonusAssignment {
id: number
challenge: Challenge
status: BonusAssignmentStatus
proof_url: string | null
proof_image_url: string | null // Legacy, for backward compatibility
proof_files?: ProofFile[] // Multiple uploaded files
proof_comment: string | null
points_earned: number
completed_at: string | null
can_dispute?: boolean
dispute?: Dispute | null
}
export interface Assignment { export interface Assignment {
id: number id: number
challenge: Challenge challenge: Challenge | null // null for playthrough
game?: GameShort // For playthrough
is_playthrough?: boolean
playthrough_info?: PlaythroughInfo // For playthrough
status: AssignmentStatus status: AssignmentStatus
proof_url: string | null proof_url: string | null
proof_comment: string | null proof_comment: string | null
@@ -199,14 +267,20 @@ export interface Assignment {
started_at: string started_at: string
completed_at: string | null completed_at: string | null
drop_penalty: number drop_penalty: number
bonus_challenges?: BonusAssignment[] // For playthrough
event_type?: EventType // Event active when assignment was created
} }
export interface SpinResult { export interface SpinResult {
assignment_id: number assignment_id: number
game: Game game: Game
challenge: Challenge challenge: Challenge | null // null for playthrough
is_playthrough?: boolean
playthrough_info?: PlaythroughInfo // For playthrough
bonus_challenges?: Challenge[] // Available bonus challenges for playthrough
can_drop: boolean can_drop: boolean
drop_penalty: number drop_penalty: number
event_type?: EventType // Event active during spin
} }
export interface CompleteResult { export interface CompleteResult {
@@ -417,6 +491,10 @@ export interface AdminUser {
banned_at: string | null banned_at: string | null
banned_until: string | null // null = permanent ban banned_until: string | null // null = permanent ban
ban_reason: string | null ban_reason: string | null
// Notification settings
notify_events: boolean
notify_disputes: boolean
notify_moderation: boolean
} }
export interface AdminMarathon { export interface AdminMarathon {
@@ -496,8 +574,42 @@ export interface DashboardStats {
recent_logs: AdminLog[] recent_logs: AdminLog[]
} }
// Admin dispute
export interface AdminDispute {
id: number
assignment_id: number | null
bonus_assignment_id: number | null
marathon_id: number
marathon_title: string
challenge_title: string
participant_nickname: string
raised_by_nickname: string
reason: string
status: DisputeStatus
votes_valid: number
votes_invalid: number
created_at: string
expires_at: string
}
// Marathon organizer dispute
export interface MarathonDispute {
id: number
assignment_id: number | null
bonus_assignment_id: number | null
challenge_title: string
participant_nickname: string
raised_by_nickname: string
reason: string
status: DisputeStatus
votes_valid: number
votes_invalid: number
created_at: string
expires_at: string
}
// Dispute types // Dispute types
export type DisputeStatus = 'open' | 'valid' | 'invalid' export type DisputeStatus = 'open' | 'pending_admin' | 'valid' | 'invalid'
export interface DisputeComment { export interface DisputeComment {
id: number id: number
@@ -527,13 +639,24 @@ export interface Dispute {
resolved_at: string | null resolved_at: string | null
} }
export interface ProofFile {
id: number
file_type: 'image' | 'video'
order_index: number
created_at: string
}
export interface AssignmentDetail { export interface AssignmentDetail {
id: number id: number
challenge: Challenge challenge: Challenge | null // null for playthrough
game?: GameShort // for playthrough
is_playthrough: boolean
playthrough_info?: PlaythroughInfo // for playthrough
participant: User participant: User
status: AssignmentStatus status: AssignmentStatus
proof_url: string | null proof_url: string | null
proof_image_url: string | null proof_image_url: string | null // Legacy, for backward compatibility
proof_files: ProofFile[] // Multiple uploaded files
proof_comment: string | null proof_comment: string | null
points_earned: number points_earned: number
streak_at_completion: number | null streak_at_completion: number | null
@@ -541,11 +664,16 @@ export interface AssignmentDetail {
completed_at: string | null completed_at: string | null
can_dispute: boolean can_dispute: boolean
dispute: Dispute | null dispute: Dispute | null
bonus_challenges?: BonusAssignment[] // for playthrough
} }
export interface ReturnedAssignment { export interface ReturnedAssignment {
id: number id: number
challenge: Challenge challenge: Challenge | null // For challenge assignments
is_playthrough: boolean
game_id: number | null // For playthrough assignments
game_title: string | null
game_cover_url: string | null
original_completed_at: string original_completed_at: string
dispute_reason: string dispute_reason: string
} }

View File

@@ -152,11 +152,12 @@ export function formatActivityMessage(activity: Activity): { title: string; deta
const points = data.points ? `+${data.points}` : '' const points = data.points ? `+${data.points}` : ''
const streak = data.streak && (data.streak as number) > 1 ? `серия ${data.streak}` : '' const streak = data.streak && (data.streak as number) > 1 ? `серия ${data.streak}` : ''
const bonus = data.common_enemy_bonus ? `+${data.common_enemy_bonus} бонус` : '' const bonus = data.common_enemy_bonus ? `+${data.common_enemy_bonus} бонус` : ''
const eventBonus = data.event_bonus ? `(+${data.event_bonus} ${EVENT_NAMES[data.event_type as EventType] || 'бонус'})` : ''
return { return {
title: `завершил ${points}`, title: `завершил ${points}`,
details: challenge || undefined, details: challenge || undefined,
extra: [game, streak, bonus].filter(Boolean).join(' • ') || undefined, extra: [game, streak, bonus, eventBonus].filter(Boolean).join(' • ') || undefined,
} }
} }

View File

@@ -0,0 +1,123 @@
// Keyboard layout mapping (RU -> EN and EN -> RU)
const ruToEn: Record<string, string> = {
'й': 'q', 'ц': 'w', 'у': 'e', 'к': 'r', 'е': 't', 'н': 'y', 'г': 'u', 'ш': 'i', 'щ': 'o', 'з': 'p',
'ф': 'a', 'ы': 's', 'в': 'd', 'а': 'f', 'п': 'g', 'р': 'h', 'о': 'j', 'л': 'k', 'д': 'l',
'я': 'z', 'ч': 'x', 'с': 'c', 'м': 'v', 'и': 'b', 'т': 'n', 'ь': 'm',
'х': '[', 'ъ': ']', 'ж': ';', 'э': "'", 'б': ',', 'ю': '.',
}
const enToRu: Record<string, string> = Object.fromEntries(
Object.entries(ruToEn).map(([ru, en]) => [en, ru])
)
function convertLayout(text: string): string {
return text
.split('')
.map(char => {
const lower = char.toLowerCase()
const converted = ruToEn[lower] || enToRu[lower] || char
return char === lower ? converted : converted.toUpperCase()
})
.join('')
}
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = []
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1]
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
)
}
}
}
return matrix[b.length][a.length]
}
export interface FuzzyMatch<T> {
item: T
score: number
}
export function fuzzySearch<T>(
items: T[],
query: string,
getSearchField: (item: T) => string
): FuzzyMatch<T>[] {
if (!query.trim()) {
return items.map(item => ({ item, score: 1 }))
}
const normalizedQuery = query.toLowerCase().trim()
const convertedQuery = convertLayout(normalizedQuery)
const results: FuzzyMatch<T>[] = []
for (const item of items) {
const field = getSearchField(item).toLowerCase()
// Exact substring match - highest score
if (field.includes(normalizedQuery)) {
results.push({ item, score: 1 })
continue
}
// Converted layout match
if (field.includes(convertedQuery)) {
results.push({ item, score: 0.95 })
continue
}
// Check if query matches start of words
const words = field.split(/\s+/)
const queryWords = normalizedQuery.split(/\s+/)
const startsWithMatch = queryWords.every(qw =>
words.some(w => w.startsWith(qw))
)
if (startsWithMatch) {
results.push({ item, score: 0.9 })
continue
}
// Levenshtein distance for typo tolerance
const distance = levenshteinDistance(normalizedQuery, field)
const maxLen = Math.max(normalizedQuery.length, field.length)
const similarity = 1 - distance / maxLen
// Also check against converted query
const convertedDistance = levenshteinDistance(convertedQuery, field)
const convertedSimilarity = 1 - convertedDistance / maxLen
const bestSimilarity = Math.max(similarity, convertedSimilarity)
// Only include if similarity is reasonable (> 40%)
if (bestSimilarity > 0.4) {
results.push({ item, score: bestSimilarity * 0.8 })
}
}
// Sort by score descending
return results.sort((a, b) => b.score - a.score)
}
export function fuzzyFilter<T>(
items: T[],
query: string,
getSearchField: (item: T) => string
): T[] {
return fuzzySearch(items, query, getSearchField).map(r => r.item)
}

View File

@@ -17,10 +17,6 @@ http {
# File upload limit (15 MB) # File upload limit (15 MB)
client_max_body_size 15M; client_max_body_size 15M;
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api_general:10m rate=60r/m;
upstream backend { upstream backend {
server backend:8000; server backend:8000;
} }
@@ -41,22 +37,8 @@ http {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
# Auth API - strict rate limit (10 req/min with burst of 5) # Backend API (rate limiting handled by backend via RATE_LIMIT_ENABLED env)
location /api/v1/auth {
limit_req zone=api_auth burst=5 nodelay;
limit_req_status 429;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Backend API - general rate limit (60 req/min with burst of 20)
location /api { location /api {
limit_req zone=api_general burst=20 nodelay;
limit_req_status 429;
proxy_pass http://backend; proxy_pass http://backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

View File

@@ -90,13 +90,14 @@ def get_latency_history(service_name: str, hours: int = 24) -> list[dict]:
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours) since = datetime.utcnow() - timedelta(hours=hours)
# Use strftime format to match SQLite CURRENT_TIMESTAMP format (no 'T')
cursor.execute(""" cursor.execute("""
SELECT latency_ms, status, checked_at SELECT latency_ms, status, checked_at
FROM metrics FROM metrics
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
ORDER BY checked_at ASC ORDER BY checked_at ASC
""", (service_name, since.isoformat())) """, (service_name, since.strftime("%Y-%m-%d %H:%M:%S")))
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
@@ -116,14 +117,14 @@ def get_uptime_stats(service_name: str, hours: int = 24) -> dict:
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours) since = datetime.utcnow() - timedelta(hours=hours)
cursor.execute(""" cursor.execute("""
SELECT COUNT(*) as total, SELECT COUNT(*) as total,
SUM(CASE WHEN status = 'operational' THEN 1 ELSE 0 END) as successful SUM(CASE WHEN status = 'operational' THEN 1 ELSE 0 END) as successful
FROM metrics FROM metrics
WHERE service_name = ? AND checked_at > ? WHERE service_name = ? AND checked_at > ?
""", (service_name, since.isoformat())) """, (service_name, since.strftime("%Y-%m-%d %H:%M:%S")))
row = cursor.fetchone() row = cursor.fetchone()
conn.close() conn.close()
@@ -143,12 +144,12 @@ def get_avg_latency(service_name: str, hours: int = 24) -> Optional[float]:
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours) since = datetime.utcnow() - timedelta(hours=hours)
cursor.execute(""" cursor.execute("""
SELECT AVG(latency_ms) as avg_latency SELECT AVG(latency_ms) as avg_latency
FROM metrics FROM metrics
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
""", (service_name, since.isoformat())) """, (service_name, since.strftime("%Y-%m-%d %H:%M:%S")))
row = cursor.fetchone() row = cursor.fetchone()
conn.close() conn.close()
@@ -231,7 +232,7 @@ def save_ssl_info(domain: str, issuer: str, expires_at: datetime, days_until_exp
INSERT OR REPLACE INTO ssl_certificates INSERT OR REPLACE INTO ssl_certificates
(domain, issuer, expires_at, days_until_expiry, checked_at) (domain, issuer, expires_at, days_until_expiry, checked_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (domain, issuer, expires_at.isoformat(), days_until_expiry)) """, (domain, issuer, expires_at.strftime("%Y-%m-%d %H:%M:%S"), days_until_expiry))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -249,12 +250,12 @@ def get_ssl_info(domain: str) -> Optional[dict]:
return None return None
def cleanup_old_metrics(days: int = 1): def cleanup_old_metrics(hours: int = 24):
"""Delete metrics older than specified days (default: 24 hours).""" """Delete metrics older than specified hours (default: 24 hours)."""
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cutoff = datetime.now() - timedelta(days=days) cutoff = datetime.utcnow() - timedelta(hours=hours)
cursor.execute("DELETE FROM metrics WHERE checked_at < ?", (cutoff.isoformat(),)) cursor.execute("DELETE FROM metrics WHERE checked_at < ?", (cutoff.strftime("%Y-%m-%d %H:%M:%S"),))
deleted = cursor.rowcount deleted = cursor.rowcount
conn.commit() conn.commit()
conn.close() conn.close()

View File

@@ -9,7 +9,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from monitors import ServiceMonitor from monitors import ServiceMonitor, Status
from database import init_db, get_recent_incidents, get_latency_history, cleanup_old_metrics from database import init_db, get_recent_incidents, get_latency_history, cleanup_old_metrics
@@ -19,52 +19,91 @@ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://frontend:80")
BOT_URL = os.getenv("BOT_URL", "http://bot:8080") BOT_URL = os.getenv("BOT_URL", "http://bot:8080")
EXTERNAL_URL = os.getenv("EXTERNAL_URL", "") # Public URL for external checks EXTERNAL_URL = os.getenv("EXTERNAL_URL", "") # Public URL for external checks
PUBLIC_URL = os.getenv("PUBLIC_URL", "") # Public HTTPS URL for SSL checks PUBLIC_URL = os.getenv("PUBLIC_URL", "") # Public HTTPS URL for SSL checks
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "600")) # 10 minutes CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "60")) # Normal interval (1 minute)
FAST_CHECK_INTERVAL = int(os.getenv("FAST_CHECK_INTERVAL", "5")) # Fast interval when issues detected
STARTUP_GRACE_PERIOD = int(os.getenv("STARTUP_GRACE_PERIOD", "60")) # Wait before alerting after startup
# Initialize monitor # Initialize monitor
monitor = ServiceMonitor() monitor = ServiceMonitor()
startup_time: Optional[datetime] = None # Track when service started
# Background task reference # Background task reference
background_task: Optional[asyncio.Task] = None background_task: Optional[asyncio.Task] = None
cleanup_task: Optional[asyncio.Task] = None cleanup_task: Optional[asyncio.Task] = None
def has_issues() -> bool:
"""Check if any monitored service has issues."""
for name, svc in monitor.services.items():
# Skip external if not configured
if name == "external" and svc.status == Status.UNKNOWN:
continue
if svc.status in (Status.DOWN, Status.DEGRADED):
return True
return False
async def periodic_health_check(): async def periodic_health_check():
"""Background task to check services periodically.""" """Background task to check services periodically with adaptive polling."""
while True: while True:
try: try:
# Suppress alerts during startup grace period
suppress_alerts = is_in_grace_period()
if suppress_alerts:
remaining = STARTUP_GRACE_PERIOD - (datetime.now() - startup_time).total_seconds()
print(f"Grace period: {remaining:.0f}s remaining (alerts suppressed)")
await monitor.check_all_services( await monitor.check_all_services(
backend_url=BACKEND_URL, backend_url=BACKEND_URL,
frontend_url=FRONTEND_URL, frontend_url=FRONTEND_URL,
bot_url=BOT_URL, bot_url=BOT_URL,
external_url=EXTERNAL_URL, external_url=EXTERNAL_URL,
public_url=PUBLIC_URL public_url=PUBLIC_URL,
suppress_alerts=suppress_alerts
) )
except Exception as e: except Exception as e:
print(f"Health check error: {e}") print(f"Health check error: {e}")
# Adaptive polling: check more frequently when issues detected
if has_issues():
await asyncio.sleep(FAST_CHECK_INTERVAL)
else:
await asyncio.sleep(CHECK_INTERVAL) await asyncio.sleep(CHECK_INTERVAL)
async def periodic_cleanup(): async def periodic_cleanup():
"""Background task to cleanup old metrics (hourly).""" """Background task to cleanup old metrics (runs immediately, then hourly)."""
while True: while True:
await asyncio.sleep(3600) # 1 hour
try: try:
deleted = cleanup_old_metrics(days=1) # Keep only last 24 hours deleted = cleanup_old_metrics(hours=24) # Keep only last 24 hours
if deleted > 0:
print(f"Cleaned up {deleted} old metrics") print(f"Cleaned up {deleted} old metrics")
except Exception as e: except Exception as e:
print(f"Cleanup error: {e}") print(f"Cleanup error: {e}")
await asyncio.sleep(3600) # Wait 1 hour before next cleanup
def is_in_grace_period() -> bool:
"""Check if we're still in startup grace period."""
if startup_time is None:
return True
elapsed = (datetime.now() - startup_time).total_seconds()
return elapsed < STARTUP_GRACE_PERIOD
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Startup and shutdown events.""" """Startup and shutdown events."""
global background_task, cleanup_task global background_task, cleanup_task, startup_time
# Initialize database # Initialize database
init_db() init_db()
print("Database initialized") print("Database initialized")
# Mark startup time
startup_time = datetime.now()
print(f"Startup grace period: {STARTUP_GRACE_PERIOD}s (no alerts until services stabilize)")
# Start background health checks # Start background health checks
background_task = asyncio.create_task(periodic_health_check()) background_task = asyncio.create_task(periodic_health_check())
cleanup_task = asyncio.create_task(periodic_cleanup()) cleanup_task = asyncio.create_task(periodic_cleanup())
@@ -91,12 +130,20 @@ templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def status_page(request: Request): async def status_page(request: Request, period: int = 24):
"""Main status page.""" """Main status page."""
services = monitor.get_all_statuses() # Validate period (1, 12, or 24 hours)
if period not in (1, 12, 24):
period = 24
services = monitor.get_all_statuses(period_hours=period)
overall_status = monitor.get_overall_status() overall_status = monitor.get_overall_status()
ssl_status = monitor.get_ssl_status() ssl_status = monitor.get_ssl_status()
incidents = get_recent_incidents(limit=5) incidents = get_recent_incidents(limit=5)
fast_mode = has_issues()
current_interval = FAST_CHECK_INTERVAL if fast_mode else CHECK_INTERVAL
grace_period_active = is_in_grace_period()
grace_period_remaining = max(0, STARTUP_GRACE_PERIOD - (datetime.now() - startup_time).total_seconds()) if startup_time else 0
return templates.TemplateResponse( return templates.TemplateResponse(
"index.html", "index.html",
@@ -107,7 +154,11 @@ async def status_page(request: Request):
"ssl_status": ssl_status, "ssl_status": ssl_status,
"incidents": incidents, "incidents": incidents,
"last_check": monitor.last_check, "last_check": monitor.last_check,
"check_interval": CHECK_INTERVAL "check_interval": current_interval,
"fast_mode": fast_mode,
"grace_period_active": grace_period_active,
"grace_period_remaining": int(grace_period_remaining),
"period": period
} }
) )
@@ -118,13 +169,15 @@ async def api_status():
services = monitor.get_all_statuses() services = monitor.get_all_statuses()
overall_status = monitor.get_overall_status() overall_status = monitor.get_overall_status()
ssl_status = monitor.get_ssl_status() ssl_status = monitor.get_ssl_status()
current_interval = FAST_CHECK_INTERVAL if has_issues() else CHECK_INTERVAL
return { return {
"overall_status": overall_status.value, "overall_status": overall_status.value,
"services": {name: status.to_dict() for name, status in services.items()}, "services": {name: status.to_dict() for name, status in services.items()},
"ssl": ssl_status, "ssl": ssl_status,
"last_check": monitor.last_check.isoformat() if monitor.last_check else None, "last_check": monitor.last_check.isoformat() if monitor.last_check else None,
"check_interval_seconds": CHECK_INTERVAL "check_interval_seconds": current_interval,
"fast_mode": has_issues()
} }

View File

@@ -184,7 +184,8 @@ class ServiceMonitor:
self, self,
service_name: str, service_name: str,
result: tuple, result: tuple,
now: datetime now: datetime,
suppress_alerts: bool = False
): ):
"""Process check result with DB persistence and alerting.""" """Process check result with DB persistence and alerting."""
if isinstance(result, Exception): if isinstance(result, Exception):
@@ -221,11 +222,12 @@ class ServiceMonitor:
if stats["total_checks"] > 0: if stats["total_checks"] > 0:
svc.uptime_percent = stats["uptime_percent"] svc.uptime_percent = stats["uptime_percent"]
# Handle incident tracking and alerting # Handle incident tracking and alerting (skip alerts during grace period)
if is_down and not was_down: if is_down and not was_down:
# Service just went down # Service just went down
svc.last_incident = now svc.last_incident = now
incident_id = create_incident(service_name, status.value, message) incident_id = create_incident(service_name, status.value, message)
if not suppress_alerts:
await alert_service_down(service_name, svc.display_name, message) await alert_service_down(service_name, svc.display_name, message)
mark_incident_notified(incident_id) mark_incident_notified(incident_id)
@@ -236,6 +238,7 @@ class ServiceMonitor:
started_at = datetime.fromisoformat(open_incident["started_at"]) started_at = datetime.fromisoformat(open_incident["started_at"])
downtime_minutes = int((now - started_at).total_seconds() / 60) downtime_minutes = int((now - started_at).total_seconds() / 60)
resolve_incident(service_name) resolve_incident(service_name)
if not suppress_alerts:
await alert_service_recovered(service_name, svc.display_name, downtime_minutes) await alert_service_recovered(service_name, svc.display_name, downtime_minutes)
async def check_all_services( async def check_all_services(
@@ -244,7 +247,8 @@ class ServiceMonitor:
frontend_url: str, frontend_url: str,
bot_url: str, bot_url: str,
external_url: str = "", external_url: str = "",
public_url: str = "" public_url: str = "",
suppress_alerts: bool = False
): ):
"""Check all services concurrently.""" """Check all services concurrently."""
now = datetime.now() now = datetime.now()
@@ -262,7 +266,7 @@ class ServiceMonitor:
# Process results # Process results
service_names = ["backend", "database", "frontend", "bot", "external"] service_names = ["backend", "database", "frontend", "bot", "external"]
for i, service_name in enumerate(service_names): for i, service_name in enumerate(service_names):
await self._process_check_result(service_name, results[i], now) await self._process_check_result(service_name, results[i], now, suppress_alerts)
# Check SSL certificate (if public URL is HTTPS) # Check SSL certificate (if public URL is HTTPS)
if public_url and public_url.startswith("https://"): if public_url and public_url.startswith("https://"):
@@ -270,7 +274,15 @@ class ServiceMonitor:
self.last_check = now self.last_check = now
def get_all_statuses(self) -> dict[str, ServiceStatus]: def get_all_statuses(self, period_hours: int = 24) -> dict[str, ServiceStatus]:
"""Get all service statuses with data for specified period."""
# Update historical data for requested period
for name, svc in self.services.items():
svc.latency_history = get_latency_history(name, hours=period_hours)
svc.avg_latency_24h = get_avg_latency(name, hours=period_hours)
stats = get_uptime_stats(name, hours=period_hours)
if stats["total_checks"] > 0:
svc.uptime_percent = stats["uptime_percent"]
return self.services return self.services
def get_overall_status(self) -> Status: def get_overall_status(self) -> Status:

View File

@@ -107,6 +107,32 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.fast-mode-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
background: rgba(250, 204, 21, 0.15);
border: 1px solid rgba(250, 204, 21, 0.3);
border-radius: 12px;
color: #facc15;
font-size: 0.8rem;
font-weight: 500;
animation: pulse 2s infinite;
}
.grace-period-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 12px;
color: #3b82f6;
font-size: 0.8rem;
font-weight: 500;
animation: pulse 2s infinite;
}
.services-grid { .services-grid {
display: grid; display: grid;
gap: 16px; gap: 16px;
@@ -347,6 +373,37 @@
color: #64748b; color: #64748b;
} }
.period-selector {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 24px;
}
.period-btn {
padding: 8px 16px;
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(100, 116, 139, 0.3);
border-radius: 8px;
color: #94a3b8;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.period-btn:hover {
border-color: rgba(0, 212, 255, 0.3);
color: #e2e8f0;
}
.period-btn.active {
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(168, 85, 247, 0.2));
border-color: rgba(0, 212, 255, 0.5);
color: #00d4ff;
}
.refresh-btn { .refresh-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -424,9 +481,21 @@
Checking services... Checking services...
{% endif %} {% endif %}
&bull; Auto-refresh every {{ check_interval }}s &bull; Auto-refresh every {{ check_interval }}s
{% if grace_period_active %}
<span class="grace-period-badge">🚀 Startup ({{ grace_period_remaining }}s)</span>
{% elif fast_mode %}
<span class="fast-mode-badge">⚡ Fast Mode</span>
{% endif %}
</p> </p>
</header> </header>
<!-- Period Selector -->
<div class="period-selector">
<a href="?period=1" class="period-btn {% if period == 1 %}active{% endif %}">1 час</a>
<a href="?period=12" class="period-btn {% if period == 12 %}active{% endif %}">12 часов</a>
<a href="?period=24" class="period-btn {% if period == 24 %}active{% endif %}">24 часа</a>
</div>
{% if ssl_status %} {% if ssl_status %}
<div class="ssl-card {% if ssl_status.days_until_expiry <= 0 %}danger{% elif ssl_status.days_until_expiry <= 14 %}warning{% endif %}"> <div class="ssl-card {% if ssl_status.days_until_expiry <= 0 %}danger{% elif ssl_status.days_until_expiry <= 14 %}warning{% endif %}">
<div class="ssl-header"> <div class="ssl-header">
@@ -491,7 +560,7 @@
</div> </div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="metric-label">Avg 24h</div> <div class="metric-label">Avg {{ period }}h</div>
<div class="metric-value {% if service.avg_latency_24h and service.avg_latency_24h < 200 %}good{% elif service.avg_latency_24h and service.avg_latency_24h < 500 %}warning{% elif service.avg_latency_24h %}bad{% endif %}"> <div class="metric-value {% if service.avg_latency_24h and service.avg_latency_24h < 200 %}good{% elif service.avg_latency_24h and service.avg_latency_24h < 500 %}warning{% elif service.avg_latency_24h %}bad{% endif %}">
{% if service.avg_latency_24h %} {% if service.avg_latency_24h %}
{{ "%.0f"|format(service.avg_latency_24h) }} ms {{ "%.0f"|format(service.avg_latency_24h) }} ms
@@ -501,7 +570,7 @@
</div> </div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="metric-label">Uptime 24h</div> <div class="metric-label">Uptime {{ period }}h</div>
<div class="metric-value {% if service.uptime_percent >= 99 %}good{% elif service.uptime_percent >= 95 %}warning{% else %}bad{% endif %}"> <div class="metric-value {% if service.uptime_percent >= 99 %}good{% elif service.uptime_percent >= 95 %}warning{% else %}bad{% endif %}">
{{ "%.1f"|format(service.uptime_percent) }}% {{ "%.1f"|format(service.uptime_percent) }}%
</div> </div>
@@ -620,13 +689,29 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
// Save scroll position before unload
window.addEventListener('beforeunload', () => {
sessionStorage.setItem('scrollPos', window.scrollY.toString());
});
// Restore scroll position on load
window.addEventListener('load', () => {
const scrollPos = sessionStorage.getItem('scrollPos');
if (scrollPos) {
window.scrollTo(0, parseInt(scrollPos));
}
});
async function refreshStatus(btn) { async function refreshStatus(btn) {
btn.classList.add('loading'); btn.classList.add('loading');
btn.disabled = true; btn.disabled = true;
try { try {
await fetch('/api/refresh', { method: 'POST' }); await fetch('/api/refresh', { method: 'POST' });
window.location.reload(); // Preserve period parameter on reload
const url = new URL(window.location);
url.searchParams.set('period', '{{ period }}');
window.location.href = url.toString();
} catch (e) { } catch (e) {
console.error('Refresh failed:', e); console.error('Refresh failed:', e);
btn.classList.remove('loading'); btn.classList.remove('loading');
@@ -634,9 +719,11 @@
} }
} }
// Auto-refresh page // Auto-refresh page (preserve period parameter)
setTimeout(() => { setTimeout(() => {
window.location.reload(); const url = new URL(window.location);
url.searchParams.set('period', '{{ period }}');
window.location.href = url.toString();
}, {{ check_interval }} * 1000); }, {{ check_interval }} * 1000);
</script> </script>
</body> </body>