Compare commits

32 Commits

Author SHA1 Message Date
2874b64481 Bug fixes 2026-01-08 06:51:15 +07:00
4488a13808 Merge branch 'master' into marathon-v2 2026-01-08 05:37:27 +07:00
ca49e42f74 Fix common enemy 2026-01-08 05:29:55 +07:00
18fe95effc Fix events 2026-01-05 23:41:22 +07:00
6a7717a474 Add shop 2026-01-05 08:42:49 +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
125 changed files with 16913 additions and 1406 deletions

View File

@@ -32,3 +32,5 @@ PUBLIC_URL=https://your-domain.com
# Frontend (for build)
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
@@ -14,6 +14,7 @@ help:
@echo " make logs - Show logs (all services)"
@echo " make logs-b - Show backend logs"
@echo " make logs-f - Show frontend logs"
@echo " make logs-bot - Show Telegram bot logs"
@echo ""
@echo " Build:"
@echo " make build - Build all containers (with cache)"
@@ -63,6 +64,9 @@ logs-b:
logs-f:
$(DC) logs -f frontend
logs-bot:
$(DC) logs -f bot
# Build
build:
$(DC) build

View File

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

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
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
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:
# 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'))
# 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'))
# 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')
# Update existing participants: set role='organizer' for marathon creators
# This is idempotent - running multiple times is safe
op.execute("""
UPDATE participants p
SET role = 'organizer'
@@ -36,13 +55,17 @@ def upgrade() -> None:
""")
# 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'))
# 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')
# 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))
if not constraint_exists('games', 'fk_games_approved_by_id'):
op.create_foreign_key(
'fk_games_approved_by_id',
'games', 'users',
@@ -53,20 +76,27 @@ def upgrade() -> None:
def downgrade() -> None:
# 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')
if column_exists('games', 'approved_by_id'):
op.drop_column('games', 'approved_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')
# Remove status from games
if column_exists('games', 'status'):
op.drop_column('games', 'status')
# Rename creator_id back to organizer_id
if column_exists('marathons', 'creator_id'):
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
# Remove role from participants
if column_exists('participants', 'role'):
op.drop_column('participants', 'role')
# Remove role from users
if column_exists('users', 'role'):
op.drop_column('users', 'role')

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
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
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 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'))
# Add game_proposal_mode column to marathons table
# 'all_participants' - anyone can propose games (with moderation)
# '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'))
def downgrade() -> None:
if column_exists('marathons', 'game_proposal_mode'):
op.drop_column('marathons', 'game_proposal_mode')
if column_exists('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)
op.execute(f"""
INSERT INTO users (login, password_hash, nickname, role, created_at)
VALUES ('admin', '{password_hash}', 'Admin', 'admin', NOW())
INSERT INTO users (login, password_hash, nickname, role, is_banned, created_at)
VALUES ('admin', '{password_hash}', 'Admin', 'admin', false, NOW())
ON CONFLICT (login) DO UPDATE SET
password_hash = '{password_hash}',
role = 'admin'

View File

@@ -17,15 +17,17 @@ depends_on = None
def upgrade() -> None:
# 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'")
# Update event_type in assignments table
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
# Update activity data that references rematch event
# Cast JSON to JSONB, apply jsonb_set, then cast back to JSON
op.execute("""
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'
""")
@@ -36,6 +38,6 @@ def downgrade() -> None:
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
op.execute("""
UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"rematch"')
SET data = jsonb_set(data::jsonb, '{event_type}', '"rematch"')::json
WHERE data->>'event_type' = 'game_choice'
""")

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# 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
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists('users', 'telegram_first_name'):
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))
if not column_exists('users', 'telegram_avatar_url'):
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
def downgrade() -> None:
if column_exists('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')
if column_exists('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
import sqlalchemy as sa
from sqlalchemy import inspect
# 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
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists('challenges', 'proposed_by_id'):
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))
def downgrade() -> None:
if column_exists('challenges', 'status'):
op.drop_column('challenges', 'status')
if column_exists('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
import sqlalchemy as sa
from sqlalchemy import inspect
# 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
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists('users', 'is_banned'):
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))
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))
if not column_exists('users', 'ban_reason'):
op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True))
def downgrade() -> None:
if column_exists('users', 'ban_reason'):
op.drop_column('users', 'ban_reason')
if column_exists('users', 'banned_by_id'):
op.drop_column('users', 'banned_by_id')
if column_exists('users', 'banned_at'):
op.drop_column('users', 'banned_at')
if column_exists('users', 'is_banned'):
op.drop_column('users', 'is_banned')

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# 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
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:
# 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',
existing_type=sa.Integer(),
nullable=True)
@@ -27,6 +41,7 @@ def upgrade() -> None:
def downgrade() -> None:
# 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',
existing_type=sa.Integer(),
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

@@ -0,0 +1,230 @@
"""Add shop system with coins, items, inventory, certification
Revision ID: 023_add_shop_system
Revises: 022_add_notification_settings
Create Date: 2025-01-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '023_add_shop_system'
down_revision: Union[str, None] = '022_add_notification_settings'
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:
# === 1. Создаём таблицу shop_items ===
if not table_exists('shop_items'):
op.create_table(
'shop_items',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('item_type', sa.String(30), nullable=False, index=True),
sa.Column('code', sa.String(50), nullable=False, unique=True, index=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('price', sa.Integer(), nullable=False),
sa.Column('rarity', sa.String(20), nullable=False, server_default='common'),
sa.Column('asset_data', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('available_from', sa.DateTime(), nullable=True),
sa.Column('available_until', sa.DateTime(), nullable=True),
sa.Column('stock_limit', sa.Integer(), nullable=True),
sa.Column('stock_remaining', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# === 2. Создаём таблицу user_inventory ===
if not table_exists('user_inventory'):
op.create_table(
'user_inventory',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('quantity', sa.Integer(), nullable=False, server_default='1'),
sa.Column('equipped', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('purchased_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('expires_at', sa.DateTime(), nullable=True),
)
# === 3. Создаём таблицу coin_transactions ===
if not table_exists('coin_transactions'):
op.create_table(
'coin_transactions',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('amount', sa.Integer(), nullable=False),
sa.Column('transaction_type', sa.String(30), nullable=False),
sa.Column('reference_type', sa.String(30), nullable=True),
sa.Column('reference_id', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# === 4. Создаём таблицу consumable_usages ===
if not table_exists('consumable_usages'):
op.create_table(
'consumable_usages',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False),
sa.Column('marathon_id', sa.Integer(), sa.ForeignKey('marathons.id', ondelete='CASCADE'), nullable=True),
sa.Column('assignment_id', sa.Integer(), sa.ForeignKey('assignments.id', ondelete='CASCADE'), nullable=True),
sa.Column('used_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('effect_data', sa.JSON(), nullable=True),
)
# === 5. Добавляем поля в users ===
# coins_balance - баланс монет
if not column_exists('users', 'coins_balance'):
op.add_column('users', sa.Column('coins_balance', sa.Integer(), nullable=False, server_default='0'))
# equipped_frame_id - экипированная рамка
if not column_exists('users', 'equipped_frame_id'):
op.add_column('users', sa.Column('equipped_frame_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_title_id - экипированный титул
if not column_exists('users', 'equipped_title_id'):
op.add_column('users', sa.Column('equipped_title_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_name_color_id - экипированный цвет ника
if not column_exists('users', 'equipped_name_color_id'):
op.add_column('users', sa.Column('equipped_name_color_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_background_id - экипированный фон
if not column_exists('users', 'equipped_background_id'):
op.add_column('users', sa.Column('equipped_background_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# === 6. Добавляем поля сертификации в marathons ===
# certification_status - статус сертификации
if not column_exists('marathons', 'certification_status'):
op.add_column('marathons', sa.Column('certification_status', sa.String(20), nullable=False, server_default='none'))
# certification_requested_at - когда подана заявка
if not column_exists('marathons', 'certification_requested_at'):
op.add_column('marathons', sa.Column('certification_requested_at', sa.DateTime(), nullable=True))
# certified_at - когда сертифицирован
if not column_exists('marathons', 'certified_at'):
op.add_column('marathons', sa.Column('certified_at', sa.DateTime(), nullable=True))
# certified_by_id - кем сертифицирован
if not column_exists('marathons', 'certified_by_id'):
op.add_column('marathons', sa.Column('certified_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True))
# certification_rejection_reason - причина отказа
if not column_exists('marathons', 'certification_rejection_reason'):
op.add_column('marathons', sa.Column('certification_rejection_reason', sa.Text(), nullable=True))
# === 7. Добавляем настройки consumables в marathons ===
# allow_skips - разрешены ли скипы
if not column_exists('marathons', 'allow_skips'):
op.add_column('marathons', sa.Column('allow_skips', sa.Boolean(), nullable=False, server_default='true'))
# max_skips_per_participant - лимит скипов на участника
if not column_exists('marathons', 'max_skips_per_participant'):
op.add_column('marathons', sa.Column('max_skips_per_participant', sa.Integer(), nullable=True))
# allow_consumables - разрешены ли расходуемые
if not column_exists('marathons', 'allow_consumables'):
op.add_column('marathons', sa.Column('allow_consumables', sa.Boolean(), nullable=False, server_default='true'))
# === 8. Добавляем поля в participants ===
# coins_earned - заработано монет в марафоне
if not column_exists('participants', 'coins_earned'):
op.add_column('participants', sa.Column('coins_earned', sa.Integer(), nullable=False, server_default='0'))
# skips_used - использовано скипов
if not column_exists('participants', 'skips_used'):
op.add_column('participants', sa.Column('skips_used', sa.Integer(), nullable=False, server_default='0'))
# active_boost_multiplier - активный множитель буста
if not column_exists('participants', 'active_boost_multiplier'):
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
# active_boost_expires_at - когда истекает буст
if not column_exists('participants', 'active_boost_expires_at'):
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
# has_shield - есть ли активный щит
if not column_exists('participants', 'has_shield'):
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
def downgrade() -> None:
# === Удаляем поля из participants ===
if column_exists('participants', 'has_shield'):
op.drop_column('participants', 'has_shield')
if column_exists('participants', 'active_boost_expires_at'):
op.drop_column('participants', 'active_boost_expires_at')
if column_exists('participants', 'active_boost_multiplier'):
op.drop_column('participants', 'active_boost_multiplier')
if column_exists('participants', 'skips_used'):
op.drop_column('participants', 'skips_used')
if column_exists('participants', 'coins_earned'):
op.drop_column('participants', 'coins_earned')
# === Удаляем поля consumables из marathons ===
if column_exists('marathons', 'allow_consumables'):
op.drop_column('marathons', 'allow_consumables')
if column_exists('marathons', 'max_skips_per_participant'):
op.drop_column('marathons', 'max_skips_per_participant')
if column_exists('marathons', 'allow_skips'):
op.drop_column('marathons', 'allow_skips')
# === Удаляем поля сертификации из marathons ===
if column_exists('marathons', 'certification_rejection_reason'):
op.drop_column('marathons', 'certification_rejection_reason')
if column_exists('marathons', 'certified_by_id'):
op.drop_column('marathons', 'certified_by_id')
if column_exists('marathons', 'certified_at'):
op.drop_column('marathons', 'certified_at')
if column_exists('marathons', 'certification_requested_at'):
op.drop_column('marathons', 'certification_requested_at')
if column_exists('marathons', 'certification_status'):
op.drop_column('marathons', 'certification_status')
# === Удаляем поля из users ===
if column_exists('users', 'equipped_background_id'):
op.drop_column('users', 'equipped_background_id')
if column_exists('users', 'equipped_name_color_id'):
op.drop_column('users', 'equipped_name_color_id')
if column_exists('users', 'equipped_title_id'):
op.drop_column('users', 'equipped_title_id')
if column_exists('users', 'equipped_frame_id'):
op.drop_column('users', 'equipped_frame_id')
if column_exists('users', 'coins_balance'):
op.drop_column('users', 'coins_balance')
# === Удаляем таблицы ===
if table_exists('consumable_usages'):
op.drop_table('consumable_usages')
if table_exists('coin_transactions'):
op.drop_table('coin_transactions')
if table_exists('user_inventory'):
op.drop_table('user_inventory')
if table_exists('shop_items'):
op.drop_table('shop_items')

View File

@@ -0,0 +1,495 @@
"""Seed shop items (frames, titles, consumables)
Revision ID: 024_seed_shop_items
Revises: 023_add_shop_system
Create Date: 2025-01-05
"""
from typing import Sequence, Union
from datetime import datetime
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '024_seed_shop_items'
down_revision: Union[str, None] = '023_add_shop_system'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not table_exists('shop_items'):
return
now = datetime.utcnow()
# Таблица shop_items
shop_items = sa.table(
'shop_items',
sa.column('id', sa.Integer),
sa.column('item_type', sa.String),
sa.column('code', sa.String),
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('price', sa.Integer),
sa.column('rarity', sa.String),
sa.column('asset_data', sa.JSON),
sa.column('is_active', sa.Boolean),
sa.column('created_at', sa.DateTime),
)
# === Рамки аватара ===
frames = [
{
'item_type': 'frame',
'code': 'frame_bronze',
'name': 'Бронзовая рамка',
'description': 'Простая бронзовая рамка для начинающих',
'price': 50,
'rarity': 'common',
'asset_data': {
'border_color': '#CD7F32',
'border_width': 3,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_silver',
'name': 'Серебряная рамка',
'description': 'Элегантная серебряная рамка',
'price': 100,
'rarity': 'uncommon',
'asset_data': {
'border_color': '#C0C0C0',
'border_width': 3,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_gold',
'name': 'Золотая рамка',
'description': 'Престижная золотая рамка',
'price': 200,
'rarity': 'rare',
'asset_data': {
'border_color': '#FFD700',
'border_width': 4,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_diamond',
'name': 'Бриллиантовая рамка',
'description': 'Сверкающая бриллиантовая рамка для истинных ценителей',
'price': 500,
'rarity': 'epic',
'asset_data': {
'border_color': '#B9F2FF',
'border_width': 4,
'border_style': 'double',
'glow': True,
'glow_color': '#B9F2FF'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_fire',
'name': 'Огненная рамка',
'description': 'Анимированная рамка с эффектом пламени',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'border_style': 'gradient',
'gradient': ['#FF4500', '#FF8C00', '#FFD700'],
'animated': True,
'animation': 'fire-pulse'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_neon',
'name': 'Неоновая рамка',
'description': 'Яркая неоновая рамка с свечением',
'price': 800,
'rarity': 'epic',
'asset_data': {
'border_color': '#00FF00',
'border_width': 3,
'glow': True,
'glow_color': '#00FF00',
'glow_intensity': 10
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_rainbow',
'name': 'Радужная рамка',
'description': 'Переливающаяся радужная рамка',
'price': 1500,
'rarity': 'legendary',
'asset_data': {
'border_style': 'gradient',
'gradient': ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#9400D3'],
'animated': True,
'animation': 'rainbow-rotate'
},
'is_active': True,
},
]
# === Титулы ===
titles = [
{
'item_type': 'title',
'code': 'title_newcomer',
'name': 'Новичок',
'description': 'Первый шаг в мир марафонов',
'price': 30,
'rarity': 'common',
'asset_data': {
'text': 'Новичок',
'color': '#808080'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_runner',
'name': 'Марафонец',
'description': 'Опытный участник марафонов',
'price': 100,
'rarity': 'uncommon',
'asset_data': {
'text': 'Марафонец',
'color': '#4169E1'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_hunter',
'name': 'Охотник за челленджами',
'description': 'Мастер выполнения сложных заданий',
'price': 200,
'rarity': 'rare',
'asset_data': {
'text': 'Охотник за челленджами',
'color': '#228B22'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_veteran',
'name': 'Ветеран',
'description': 'Закаленный в боях участник',
'price': 300,
'rarity': 'rare',
'asset_data': {
'text': 'Ветеран',
'color': '#8B4513'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_champion',
'name': 'Чемпион',
'description': 'Победитель марафонов',
'price': 500,
'rarity': 'epic',
'asset_data': {
'text': 'Чемпион',
'color': '#FFD700',
'icon': 'trophy'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_legend',
'name': 'Легенда',
'description': 'Легендарный участник марафонов',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'text': 'Легенда',
'color': '#FF4500',
'glow': True,
'icon': 'star'
},
'is_active': True,
},
]
# === Цвета никнейма ===
name_colors = [
{
'item_type': 'name_color',
'code': 'color_red',
'name': 'Красный ник',
'description': 'Яркий красный цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#FF4444'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_blue',
'name': 'Синий ник',
'description': 'Глубокий синий цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#4444FF'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_green',
'name': 'Зеленый ник',
'description': 'Сочный зеленый цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#44FF44'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_purple',
'name': 'Фиолетовый ник',
'description': 'Королевский фиолетовый цвет',
'price': 200,
'rarity': 'rare',
'asset_data': {
'style': 'solid',
'color': '#9932CC'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gold',
'name': 'Золотой ник',
'description': 'Престижный золотой цвет',
'price': 300,
'rarity': 'rare',
'asset_data': {
'style': 'solid',
'color': '#FFD700'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gradient_sunset',
'name': 'Закат',
'description': 'Красивый градиент заката',
'price': 500,
'rarity': 'epic',
'asset_data': {
'style': 'gradient',
'gradient': ['#FF6B6B', '#FFE66D']
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gradient_ocean',
'name': 'Океан',
'description': 'Градиент морских глубин',
'price': 500,
'rarity': 'epic',
'asset_data': {
'style': 'gradient',
'gradient': ['#4ECDC4', '#44A3FF']
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_rainbow',
'name': 'Радужный ник',
'description': 'Анимированный радужный цвет',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'style': 'animated',
'animation': 'rainbow-shift'
},
'is_active': True,
},
]
# === Фоны профиля ===
backgrounds = [
{
'item_type': 'background',
'code': 'bg_dark',
'name': 'Тёмный фон',
'description': 'Элегантный тёмный фон',
'price': 100,
'rarity': 'common',
'asset_data': {
'type': 'solid',
'color': '#1a1a2e'
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_gradient_purple',
'name': 'Фиолетовый градиент',
'description': 'Красивый фиолетовый градиент',
'price': 200,
'rarity': 'uncommon',
'asset_data': {
'type': 'gradient',
'gradient': ['#1a1a2e', '#4a0080']
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_stars',
'name': 'Звёздное небо',
'description': 'Фон с мерцающими звёздами',
'price': 400,
'rarity': 'rare',
'asset_data': {
'type': 'pattern',
'pattern': 'stars',
'animated': True
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_gaming',
'name': 'Игровой фон',
'description': 'Фон с игровыми элементами',
'price': 500,
'rarity': 'epic',
'asset_data': {
'type': 'pattern',
'pattern': 'gaming-icons'
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_fire',
'name': 'Огненный фон',
'description': 'Анимированный огненный фон',
'price': 800,
'rarity': 'legendary',
'asset_data': {
'type': 'animated',
'animation': 'fire-particles'
},
'is_active': True,
},
]
# === Расходуемые предметы ===
consumables = [
{
'item_type': 'consumable',
'code': 'skip',
'name': 'Пропуск',
'description': 'Пропустить текущее задание без штрафа и потери streak',
'price': 100,
'rarity': 'common',
'asset_data': {
'effect': 'skip',
'icon': 'skip-forward'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'shield',
'name': 'Щит',
'description': 'Защита от штрафа при следующем дропе. Streak сохраняется.',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'effect': 'shield',
'icon': 'shield'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'boost',
'name': 'Буст x1.5',
'description': 'Множитель очков x1.5 на следующие 2 часа',
'price': 200,
'rarity': 'rare',
'asset_data': {
'effect': 'boost',
'multiplier': 1.5,
'duration_hours': 2,
'icon': 'zap'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'reroll',
'name': 'Перекрут',
'description': 'Перекрутить колесо и получить новое задание',
'price': 80,
'rarity': 'common',
'asset_data': {
'effect': 'reroll',
'icon': 'refresh-cw'
},
'is_active': True,
},
]
# Вставляем все товары
all_items = frames + titles + name_colors + backgrounds + consumables
# Добавляем created_at ко всем товарам
for item in all_items:
item['created_at'] = now
op.bulk_insert(shop_items, all_items)
def downgrade() -> None:
# Удаляем все seed-товары по коду
op.execute("DELETE FROM shop_items WHERE code LIKE 'frame_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'title_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'color_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'bg_%'")
op.execute("DELETE FROM shop_items WHERE code IN ('skip', 'shield', 'boost', 'reroll')")

View File

@@ -0,0 +1,52 @@
"""Simplify boost consumable - make it one-time instead of timed
Revision ID: 025_simplify_boost
Revises: 024_seed_shop_items
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '025_simplify_boost'
down_revision: Union[str, None] = '024_seed_shop_items'
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 = [c['name'] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# Add new boolean column for one-time boost
if not column_exists('participants', 'has_active_boost'):
op.add_column('participants', sa.Column('has_active_boost', sa.Boolean(), nullable=False, server_default='false'))
# Remove old timed boost columns
if column_exists('participants', 'active_boost_multiplier'):
op.drop_column('participants', 'active_boost_multiplier')
if column_exists('participants', 'active_boost_expires_at'):
op.drop_column('participants', 'active_boost_expires_at')
def downgrade() -> None:
# Restore old columns
if not column_exists('participants', 'active_boost_multiplier'):
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
if not column_exists('participants', 'active_boost_expires_at'):
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
# Remove new column
if column_exists('participants', 'has_active_boost'):
op.drop_column('participants', 'has_active_boost')

View File

@@ -4,6 +4,7 @@ from datetime import datetime
from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
@@ -35,7 +36,16 @@ async def get_current_user(
detail="Invalid token payload",
)
result = await db.execute(select(User).where(User.id == int(user_id)))
result = await db.execute(
select(User)
.where(User.id == int(user_id))
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none()
if user is None:

View File

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

View File

@@ -1,17 +1,22 @@
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.orm import selectinload
from pydantic import BaseModel, Field
from typing import Optional
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, CertificationStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
)
from app.schemas import (
UserPublic, MessageResponse,
AdminUserResponse, BanUserRequest, AdminLogResponse, AdminLogsListResponse,
AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
StaticContentCreate, DashboardStats
)
from app.core.security import get_password_hash
from app.services.telegram_notifier import telegram_notifier
from app.core.rate_limit import limiter
@@ -32,6 +37,8 @@ class AdminMarathonResponse(BaseModel):
start_date: str | None
end_date: str | None
created_at: str
certification_status: str = "none"
is_certified: bool = False
class Config:
from_attributes = True
@@ -60,6 +67,28 @@ async def log_admin_action(
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])
async def list_users(
current_user: CurrentUser,
@@ -93,21 +122,7 @@ async def list_users(
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
response.append(AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
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,
))
response.append(build_admin_user_response(user, marathons_count))
return response
@@ -126,21 +141,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)
)
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,
)
return build_admin_user_response(user, marathons_count)
@router.patch("/users/{user_id}/role", response_model=AdminUserResponse)
@@ -180,21 +181,7 @@ async def set_user_role(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)
return build_admin_user_response(user, marathons_count)
@router.delete("/users/{user_id}", response_model=MessageResponse)
@@ -226,7 +213,7 @@ async def list_marathons(
current_user: CurrentUser,
db: DbSession,
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,
):
"""List all marathons. Admin only."""
@@ -234,7 +221,12 @@ async def list_marathons(
query = (
select(Marathon)
.options(selectinload(Marathon.creator))
.options(
selectinload(Marathon.creator).selectinload(User.equipped_frame),
selectinload(Marathon.creator).selectinload(User.equipped_title),
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
selectinload(Marathon.creator).selectinload(User.equipped_background),
)
.order_by(Marathon.created_at.desc())
)
@@ -263,6 +255,8 @@ async def list_marathons(
start_date=marathon.start_date.isoformat() if marathon.start_date else None,
end_date=marathon.end_date.isoformat() if marathon.end_date else None,
created_at=marathon.created_at.isoformat(),
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
))
return response
@@ -359,21 +353,7 @@ async def ban_user(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)
return build_admin_user_response(user, marathons_count)
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
@@ -414,22 +394,54 @@ async def unban_user(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=None,
banned_until=None,
ban_reason=None,
return build_admin_user_response(user, marathons_count)
# ============ Reset Password ============
@router.post("/users/{user_id}/reset-password", response_model=AdminUserResponse)
async def reset_user_password(
request: Request,
user_id: int,
data: AdminResetPasswordRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Reset user password. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# 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 ============
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
@@ -440,6 +452,8 @@ async def force_finish_marathon(
db: DbSession,
):
"""Force finish a marathon. Admin only."""
from app.services.coins import coins_service
require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
@@ -453,6 +467,24 @@ async def force_finish_marathon(
old_status = marathon.status
marathon.status = MarathonStatus.FINISHED.value
marathon.end_date = datetime.utcnow()
# Award coins for top 3 places (only in certified marathons)
if marathon.is_certified:
top_result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
.limit(3)
)
top_participants = top_result.scalars().all()
for place, participant in enumerate(top_participants, start=1):
if participant.total_points > 0:
await coins_service.award_marathon_place(
db, participant.user, marathon, place
)
await db.commit()
# Log action
@@ -529,9 +561,10 @@ async def get_logs(
@limiter.limit("1/minute")
async def broadcast_to_all(
request: Request,
data: BroadcastRequest,
current_user: CurrentUser,
db: DbSession,
message: str = Form(""),
media: list[UploadFile] = File(default=[]),
):
"""Send broadcast message to all users with Telegram linked. Admin only."""
require_admin_with_2fa(current_user)
@@ -545,15 +578,40 @@ async def broadcast_to_all(
total_count = len(users)
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:
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
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
"broadcast", 0,
{"message": data.message[:100], "sent": sent_count, "total": total_count},
{"message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)},
request.client.host if request.client else None
)
@@ -565,9 +623,10 @@ async def broadcast_to_all(
async def broadcast_to_marathon(
request: Request,
marathon_id: int,
data: BroadcastRequest,
current_user: CurrentUser,
db: DbSession,
message: str = Form(""),
media: list[UploadFile] = File(default=[]),
):
"""Send broadcast message to marathon participants. Admin only."""
require_admin_with_2fa(current_user)
@@ -578,7 +637,7 @@ async def broadcast_to_marathon(
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
# Get participants count
# Get participants with telegram
total_result = await db.execute(
select(User)
.join(Participant, Participant.user_id == User.id)
@@ -590,15 +649,41 @@ async def broadcast_to_marathon(
users = total_result.scalars().all()
total_count = len(users)
sent_count = await telegram_notifier.notify_marathon_participants(
db, marathon_id, data.message
)
# 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
})
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
await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
"marathon", marathon_id,
{"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count},
{"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
)
@@ -697,6 +782,37 @@ async def create_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 ============
@router.get("/dashboard", response_model=DashboardStats)
async def get_dashboard(current_user: CurrentUser, db: DbSession):
@@ -745,3 +861,345 @@ async def get_dashboard(current_user: CurrentUser, db: DbSession):
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'}"
)
# ============ Marathon Certification ============
@router.post("/marathons/{marathon_id}/certify", response_model=MessageResponse)
async def certify_marathon(
request: Request,
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Certify (verify) a marathon. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.certification_status == CertificationStatus.CERTIFIED.value:
raise HTTPException(status_code=400, detail="Marathon is already certified")
marathon.certification_status = CertificationStatus.CERTIFIED.value
marathon.certified_at = datetime.utcnow()
marathon.certified_by_id = current_user.id
marathon.certification_rejection_reason = None
await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.MARATHON_CERTIFY.value,
"marathon", marathon_id,
{"title": marathon.title},
request.client.host if request.client else None
)
return MessageResponse(message="Marathon certified successfully")
@router.post("/marathons/{marathon_id}/revoke-certification", response_model=MessageResponse)
async def revoke_marathon_certification(
request: Request,
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Revoke certification from a marathon. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.certification_status != CertificationStatus.CERTIFIED.value:
raise HTTPException(status_code=400, detail="Marathon is not certified")
marathon.certification_status = CertificationStatus.NONE.value
marathon.certified_at = None
marathon.certified_by_id = None
await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.MARATHON_REVOKE_CERTIFICATION.value,
"marathon", marathon_id,
{"title": marathon.title},
request.client.host if request.client else None
)
return MessageResponse(message="Marathon certification revoked")

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import secrets
from fastapi import APIRouter, HTTPException, status, Request
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.core.security import verify_password, get_password_hash, create_access_token
@@ -48,7 +49,16 @@ async def register(request: Request, data: UserRegister, db: DbSession):
@limiter.limit("10/minute")
async def login(request: Request, data: UserLogin, db: DbSession):
# Find user
result = await db.execute(select(User).where(User.login == data.login.lower()))
result = await db.execute(
select(User)
.where(User.login == data.login.lower())
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.password_hash):
@@ -59,9 +69,15 @@ async def login(request: Request, data: UserLogin, db: DbSession):
# Check 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(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your account has been banned",
detail=ban_info,
)
# If admin with Telegram linked, require 2FA
@@ -141,7 +157,16 @@ async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession
await db.commit()
# Get user
result = await db.execute(select(User).where(User.id == session.user_id))
result = await db.execute(
select(User)
.where(User.id == session.user_id)
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none()
if not user:

View File

@@ -54,7 +54,7 @@ def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeRespo
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
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])
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
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
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:
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(
select(Challenge)
.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()
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)

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
Marathon, MarathonStatus, Participant, ParticipantRole,
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
)
from fastapi import UploadFile, File, Form
@@ -150,6 +150,46 @@ async def start_event(
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:
event = await event_service.start_event(
db=db,
@@ -157,7 +197,9 @@ async def start_event(
event_type=data.type,
created_by_id=current_user.id,
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:
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:
"""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
game = challenge.game
return AssignmentResponse(
@@ -937,6 +1014,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
@@ -968,7 +1046,8 @@ async def get_event_assignment(
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough assignments
)
.where(
Assignment.participant_id == participant.id,
@@ -999,10 +1078,19 @@ async def get_event_assignment(
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(
assignment=assignment_to_response(assignment) if assignment 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,
)
@@ -1026,6 +1114,7 @@ async def complete_event_assignment(
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough assignments
)
.where(Assignment.id == assignment_id)
)
@@ -1079,17 +1168,25 @@ async def complete_event_assignment(
assignment.proof_comment = comment
# Get marathon_id
marathon_id = assignment.challenge.game.marathon_id
# Get marathon_id and base points (handle playthrough vs regular challenge)
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
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
common_enemy_bonus = 0
common_enemy_closed = False
@@ -1113,12 +1210,13 @@ async def complete_event_assignment(
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"game": game_title,
"challenge": challenge_title,
"difficulty": difficulty,
"points": total_points,
"event_type": EventType.COMMON_ENEMY.value,
"is_event_assignment": True,
"is_playthrough": assignment.is_playthrough,
}
if common_enemy_bonus:
activity_data["common_enemy_bonus"] = common_enemy_bonus

View File

@@ -3,7 +3,7 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Activity, Participant, Dispute, ActivityType
from app.models import Activity, Participant, Dispute, ActivityType, User
from app.models.dispute import DisputeStatus
from app.schemas import FeedResponse, ActivityResponse, UserPublic
@@ -37,7 +37,12 @@ async def get_feed(
# Get activities
result = await db.execute(
select(Activity)
.options(selectinload(Activity.user))
.options(
selectinload(Activity.user).selectinload(User.equipped_frame),
selectinload(Activity.user).selectinload(User.equipped_title),
selectinload(Activity.user).selectinload(User.equipped_name_color),
selectinload(Activity.user).selectinload(User.equipped_background),
)
.where(Activity.marathon_id == marathon_id)
.order_by(Activity.created_at.desc())
.limit(limit)

View File

@@ -7,8 +7,12 @@ from app.api.deps import (
require_participant, require_organizer, get_participant,
)
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, User
)
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.telegram_notifier import telegram_notifier
@@ -19,8 +23,14 @@ async def get_game_or_404(db, game_id: int) -> Game:
result = await db.execute(
select(Game)
.options(
selectinload(Game.proposed_by),
selectinload(Game.approved_by),
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
)
.where(Game.id == game_id)
)
@@ -43,6 +53,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,
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,
)
@@ -63,8 +79,14 @@ async def list_games(
select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge)
.options(
selectinload(Game.proposed_by),
selectinload(Game.approved_by),
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
)
.where(Game.marathon_id == marathon_id)
.group_by(Game.id)
@@ -96,8 +118,14 @@ async def list_pending_games(marathon_id: int, current_user: CurrentUser, db: Db
select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge)
.options(
selectinload(Game.proposed_by),
selectinload(Game.approved_by),
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
)
.where(
Game.marathon_id == marathon_id,
@@ -145,6 +173,12 @@ async def add_game(
proposed_by_id=current_user.id,
status=game_status,
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)
@@ -171,6 +205,12 @@ async def add_game(
approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
challenges_count=0,
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 +267,18 @@ async def update_game(
if data.genre is not None:
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()
return await get_game(game_id, current_user, db)
@@ -398,3 +450,159 @@ async def upload_cover(
await db.commit()
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
import secrets
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 sqlalchemy import select, func
from sqlalchemy.orm import selectinload
@@ -11,13 +11,16 @@ from app.api.deps import (
require_participant, require_organizer, require_creator,
get_participant,
)
from app.core.config import settings
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 = HTTPBearer(auto_error=False)
from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
)
from app.schemas import (
MarathonCreate,
@@ -62,6 +65,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
title=marathon.title,
description=marathon.description,
status=marathon.status,
cover_url=marathon.cover_url,
participants_count=participants_count,
creator_nickname=marathon.creator.nickname,
)
@@ -76,7 +80,12 @@ def generate_invite_code() -> str:
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.creator))
.options(
selectinload(Marathon.creator).selectinload(User.equipped_frame),
selectinload(Marathon.creator).selectinload(User.equipped_title),
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
selectinload(Marathon.creator).selectinload(User.equipped_background),
)
.where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
@@ -128,6 +137,7 @@ async def list_marathons(current_user: CurrentUser, db: DbSession):
title=marathon.title,
status=marathon.status,
is_public=marathon.is_public,
cover_url=marathon.cover_url,
participants_count=row[1],
start_date=marathon.start_date,
end_date=marathon.end_date,
@@ -180,6 +190,7 @@ async def create_marathon(
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=1,
@@ -226,6 +237,7 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=participants_count,
@@ -301,9 +313,12 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
if len(approved_games) == 0:
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 = []
for game in approved_games:
if game.is_playthrough:
continue # Игры типа "Прохождение" не требуют челленджей
challenge_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
@@ -338,6 +353,8 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
from app.services.coins import coins_service
# Require organizer role
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
@@ -347,6 +364,24 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
marathon.status = MarathonStatus.FINISHED.value
# Award coins for top 3 places (only in certified marathons)
if marathon.is_certified:
# Get top 3 participants by total_points
top_result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
.limit(3)
)
top_participants = top_result.scalars().all()
for place, participant in enumerate(top_participants, start=1):
if participant.total_points > 0: # Only award if they have points
await coins_service.award_marathon_place(
db, participant.user, marathon, place
)
# Log activity
activity = Activity(
marathon_id=marathon_id,
@@ -455,7 +490,12 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.joined_at)
)
@@ -494,7 +534,12 @@ async def set_participant_role(
# Get participant
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
@@ -559,7 +604,12 @@ async def get_leaderboard(
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
)
@@ -591,3 +641,366 @@ async def get_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'}"
)

634
backend/app/api/v1/shop.py Normal file
View File

@@ -0,0 +1,634 @@
"""
Shop API endpoints - catalog, purchases, inventory, cosmetics, consumables
"""
from datetime import datetime
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import CurrentUser, DbSession, require_participant, require_admin_with_2fa
from app.models import (
User, Marathon, Participant, Assignment, AssignmentStatus,
ShopItem, UserInventory, CoinTransaction, ShopItemType,
CertificationStatus,
)
from app.schemas import (
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
InventoryItemResponse, PurchaseRequest, PurchaseResponse,
UseConsumableRequest, UseConsumableResponse,
EquipItemRequest, EquipItemResponse,
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
ConsumablesStatusResponse, MessageResponse,
)
from app.services.shop import shop_service
from app.services.coins import coins_service
from app.services.consumables import consumables_service
router = APIRouter(prefix="/shop", tags=["shop"])
# === Catalog ===
@router.get("/items", response_model=list[ShopItemResponse])
async def get_shop_items(
current_user: CurrentUser,
db: DbSession,
item_type: str | None = None,
include_unavailable: bool = False,
):
"""Get list of shop items"""
items = await shop_service.get_available_items(db, item_type, include_unavailable)
# Get user's inventory to mark owned/equipped items
user_inventory = await shop_service.get_user_inventory(db, current_user.id)
owned_ids = {inv.item_id for inv in user_inventory}
equipped_ids = {inv.item_id for inv in user_inventory if inv.equipped}
result = []
for item in items:
item_dict = ShopItemResponse.model_validate(item).model_dump()
item_dict["is_owned"] = item.id in owned_ids
item_dict["is_equipped"] = item.id in equipped_ids
result.append(ShopItemResponse(**item_dict))
return result
@router.get("/items/{item_id}", response_model=ShopItemResponse)
async def get_shop_item(
item_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get single shop item by ID"""
item = await shop_service.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
is_owned = await shop_service.check_user_owns_item(db, current_user.id, item_id)
# Check if equipped
is_equipped = False
if is_owned:
inventory = await shop_service.get_user_inventory(db, current_user.id, item.item_type)
is_equipped = any(inv.equipped and inv.item_id == item_id for inv in inventory)
response = ShopItemResponse.model_validate(item)
response.is_owned = is_owned
response.is_equipped = is_equipped
return response
# === Purchases ===
@router.post("/purchase", response_model=PurchaseResponse)
async def purchase_item(
data: PurchaseRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Purchase an item from the shop"""
inv_item, total_cost = await shop_service.purchase_item(
db, current_user, data.item_id, data.quantity
)
await db.commit()
await db.refresh(current_user)
item = await shop_service.get_item_by_id(db, data.item_id)
return PurchaseResponse(
success=True,
item=ShopItemResponse.model_validate(item),
quantity=data.quantity,
total_cost=total_cost,
new_balance=current_user.coins_balance,
message=f"Successfully purchased {item.name} x{data.quantity}",
)
# === Inventory ===
@router.get("/inventory", response_model=list[InventoryItemResponse])
async def get_my_inventory(
current_user: CurrentUser,
db: DbSession,
item_type: str | None = None,
):
"""Get current user's inventory"""
inventory = await shop_service.get_user_inventory(db, current_user.id, item_type)
return [InventoryItemResponse.model_validate(inv) for inv in inventory]
# === Equip/Unequip ===
@router.post("/equip", response_model=EquipItemResponse)
async def equip_item(
data: EquipItemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Equip a cosmetic item from inventory"""
item = await shop_service.equip_item(db, current_user, data.inventory_id)
await db.commit()
return EquipItemResponse(
success=True,
item_type=item.item_type,
equipped_item=ShopItemResponse.model_validate(item),
message=f"Equipped {item.name}",
)
@router.post("/unequip/{item_type}", response_model=EquipItemResponse)
async def unequip_item(
item_type: str,
current_user: CurrentUser,
db: DbSession,
):
"""Unequip item of specified type"""
valid_types = [ShopItemType.FRAME.value, ShopItemType.TITLE.value,
ShopItemType.NAME_COLOR.value, ShopItemType.BACKGROUND.value]
if item_type not in valid_types:
raise HTTPException(status_code=400, detail=f"Invalid item type: {item_type}")
await shop_service.unequip_item(db, current_user, item_type)
await db.commit()
return EquipItemResponse(
success=True,
item_type=item_type,
equipped_item=None,
message=f"Unequipped {item_type}",
)
# === Consumables ===
@router.post("/use", response_model=UseConsumableResponse)
async def use_consumable(
data: UseConsumableRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Use a consumable item"""
# Get marathon
result = await db.execute(select(Marathon).where(Marathon.id == data.marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
# Get participant
participant = await require_participant(db, current_user.id, data.marathon_id)
# For skip and reroll, we need the assignment
assignment = None
if data.item_code in ["skip", "reroll"]:
if not data.assignment_id:
raise HTTPException(status_code=400, detail="assignment_id is required for skip/reroll")
result = await db.execute(
select(Assignment).where(
Assignment.id == data.assignment_id,
Assignment.participant_id == participant.id,
)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Use the consumable
if data.item_code == "skip":
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
effect_description = "Assignment skipped without penalty"
elif data.item_code == "shield":
effect = await consumables_service.use_shield(db, current_user, participant, marathon)
effect_description = "Shield activated - next drop will be free"
elif data.item_code == "boost":
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
effect_description = f"Boost x{effect['multiplier']} activated for next complete"
elif data.item_code == "reroll":
effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment)
effect_description = "Assignment rerolled - you can spin again"
else:
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
await db.commit()
# Get remaining quantity
remaining = await consumables_service.get_consumable_count(db, current_user.id, data.item_code)
return UseConsumableResponse(
success=True,
item_code=data.item_code,
remaining_quantity=remaining,
effect_description=effect_description,
effect_data=effect,
)
@router.get("/consumables/{marathon_id}", response_model=ConsumablesStatusResponse)
async def get_consumables_status(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get consumables status for participant in marathon"""
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
participant = await require_participant(db, current_user.id, marathon_id)
# Get inventory counts for all consumables
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
shields_available = await consumables_service.get_consumable_count(db, current_user.id, "shield")
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
rerolls_available = await consumables_service.get_consumable_count(db, current_user.id, "reroll")
# Calculate remaining skips for this marathon
skips_remaining = None
if marathon.max_skips_per_participant is not None:
skips_remaining = max(0, marathon.max_skips_per_participant - participant.skips_used)
return ConsumablesStatusResponse(
skips_available=skips_available,
skips_used=participant.skips_used,
skips_remaining=skips_remaining,
shields_available=shields_available,
has_shield=participant.has_shield,
boosts_available=boosts_available,
has_active_boost=participant.has_active_boost,
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
rerolls_available=rerolls_available,
)
# === Coins ===
@router.get("/balance", response_model=CoinsBalanceResponse)
async def get_coins_balance(
current_user: CurrentUser,
db: DbSession,
):
"""Get current user's coins balance with recent transactions"""
result = await db.execute(
select(CoinTransaction)
.where(CoinTransaction.user_id == current_user.id)
.order_by(CoinTransaction.created_at.desc())
.limit(10)
)
transactions = result.scalars().all()
return CoinsBalanceResponse(
balance=current_user.coins_balance,
recent_transactions=[CoinTransactionResponse.model_validate(t) for t in transactions],
)
@router.get("/transactions", response_model=list[CoinTransactionResponse])
async def get_coin_transactions(
current_user: CurrentUser,
db: DbSession,
limit: int = 50,
offset: int = 0,
):
"""Get user's coin transaction history"""
result = await db.execute(
select(CoinTransaction)
.where(CoinTransaction.user_id == current_user.id)
.order_by(CoinTransaction.created_at.desc())
.offset(offset)
.limit(min(limit, 100))
)
transactions = result.scalars().all()
return [CoinTransactionResponse.model_validate(t) for t in transactions]
# === Certification (organizer endpoints) ===
@router.post("/certification/{marathon_id}/request", response_model=CertificationStatusResponse)
async def request_certification(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Request certification for a marathon (organizer only)"""
# Check user is organizer
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only the creator can request certification")
if marathon.certification_status != CertificationStatus.NONE.value:
raise HTTPException(
status_code=400,
detail=f"Marathon already has certification status: {marathon.certification_status}"
)
marathon.certification_status = CertificationStatus.PENDING.value
marathon.certification_requested_at = datetime.utcnow()
await db.commit()
await db.refresh(marathon)
return CertificationStatusResponse(
marathon_id=marathon.id,
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
certification_requested_at=marathon.certification_requested_at,
certified_at=marathon.certified_at,
certified_by_nickname=None,
rejection_reason=None,
)
@router.delete("/certification/{marathon_id}/request", response_model=MessageResponse)
async def cancel_certification_request(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Cancel certification request (organizer only)"""
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only the creator can cancel certification request")
if marathon.certification_status != CertificationStatus.PENDING.value:
raise HTTPException(status_code=400, detail="No pending certification request to cancel")
marathon.certification_status = CertificationStatus.NONE.value
marathon.certification_requested_at = None
await db.commit()
return MessageResponse(message="Certification request cancelled")
@router.get("/certification/{marathon_id}", response_model=CertificationStatusResponse)
async def get_certification_status(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get certification status of a marathon"""
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.certified_by))
.where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
return CertificationStatusResponse(
marathon_id=marathon.id,
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
certification_requested_at=marathon.certification_requested_at,
certified_at=marathon.certified_at,
certified_by_nickname=marathon.certified_by.nickname if marathon.certified_by else None,
rejection_reason=marathon.certification_rejection_reason,
)
# === Admin endpoints ===
@router.get("/admin/items", response_model=list[ShopItemResponse])
async def admin_get_all_items(
current_user: CurrentUser,
db: DbSession,
):
"""Get all shop items including inactive (admin only)"""
require_admin_with_2fa(current_user)
items = await shop_service.get_available_items(db, include_unavailable=True)
return [ShopItemResponse.model_validate(item) for item in items]
@router.post("/admin/items", response_model=ShopItemResponse)
async def admin_create_item(
data: ShopItemCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a new shop item (admin only)"""
require_admin_with_2fa(current_user)
# Check code uniqueness
existing = await shop_service.get_item_by_code(db, data.code)
if existing:
raise HTTPException(status_code=400, detail=f"Item with code '{data.code}' already exists")
item = ShopItem(
item_type=data.item_type,
code=data.code,
name=data.name,
description=data.description,
price=data.price,
rarity=data.rarity,
asset_data=data.asset_data,
is_active=data.is_active,
available_from=data.available_from,
available_until=data.available_until,
stock_limit=data.stock_limit,
stock_remaining=data.stock_limit, # Initialize remaining = limit
)
db.add(item)
await db.commit()
await db.refresh(item)
return ShopItemResponse.model_validate(item)
@router.put("/admin/items/{item_id}", response_model=ShopItemResponse)
async def admin_update_item(
item_id: int,
data: ShopItemUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update a shop item (admin only)"""
require_admin_with_2fa(current_user)
item = await shop_service.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
# Update fields
if data.name is not None:
item.name = data.name
if data.description is not None:
item.description = data.description
if data.price is not None:
item.price = data.price
if data.rarity is not None:
item.rarity = data.rarity
if data.asset_data is not None:
item.asset_data = data.asset_data
if data.is_active is not None:
item.is_active = data.is_active
if data.available_from is not None:
item.available_from = data.available_from
if data.available_until is not None:
item.available_until = data.available_until
if data.stock_limit is not None:
# If increasing limit, also increase remaining
if item.stock_limit is not None and data.stock_limit > item.stock_limit:
diff = data.stock_limit - item.stock_limit
item.stock_remaining = (item.stock_remaining or 0) + diff
item.stock_limit = data.stock_limit
await db.commit()
await db.refresh(item)
return ShopItemResponse.model_validate(item)
@router.delete("/admin/items/{item_id}", response_model=MessageResponse)
async def admin_delete_item(
item_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete a shop item (admin only)"""
require_admin_with_2fa(current_user)
item = await shop_service.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
await db.delete(item)
await db.commit()
return MessageResponse(message=f"Item '{item.name}' deleted")
@router.post("/admin/users/{user_id}/coins/grant", response_model=MessageResponse)
async def admin_grant_coins(
user_id: int,
data: AdminCoinsRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Grant coins to a user (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
await coins_service.admin_grant_coins(db, user, data.amount, data.reason, current_user.id)
await db.commit()
return MessageResponse(message=f"Granted {data.amount} coins to {user.nickname}")
@router.post("/admin/users/{user_id}/coins/deduct", response_model=MessageResponse)
async def admin_deduct_coins(
user_id: int,
data: AdminCoinsRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Deduct coins from a user (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
success = await coins_service.admin_deduct_coins(db, user, data.amount, data.reason, current_user.id)
if not success:
raise HTTPException(status_code=400, detail="User doesn't have enough coins")
await db.commit()
return MessageResponse(message=f"Deducted {data.amount} coins from {user.nickname}")
@router.get("/admin/certification/pending", response_model=list[dict])
async def admin_get_pending_certifications(
current_user: CurrentUser,
db: DbSession,
):
"""Get list of marathons pending certification (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.creator))
.where(Marathon.certification_status == CertificationStatus.PENDING.value)
.order_by(Marathon.certification_requested_at.asc())
)
marathons = result.scalars().all()
return [
{
"id": m.id,
"title": m.title,
"creator_nickname": m.creator.nickname,
"status": m.status,
"participants_count": len(m.participants) if m.participants else 0,
"certification_requested_at": m.certification_requested_at,
}
for m in marathons
]
@router.post("/admin/certification/{marathon_id}/review", response_model=CertificationStatusResponse)
async def admin_review_certification(
marathon_id: int,
data: CertificationReviewRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Approve or reject marathon certification (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.certification_status != CertificationStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Marathon is not pending certification")
if data.approve:
marathon.certification_status = CertificationStatus.CERTIFIED.value
marathon.certified_at = datetime.utcnow()
marathon.certified_by_id = current_user.id
marathon.certification_rejection_reason = None
else:
if not data.rejection_reason:
raise HTTPException(status_code=400, detail="Rejection reason is required")
marathon.certification_status = CertificationStatus.REJECTED.value
marathon.certification_rejection_reason = data.rejection_reason
await db.commit()
await db.refresh(marathon)
return CertificationStatusResponse(
marathon_id=marathon.id,
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
certification_requested_at=marathon.certification_requested_at,
certified_at=marathon.certified_at,
certified_by_nickname=current_user.nickname if data.approve else None,
rejection_reason=marathon.certification_rejection_reason,
)

View File

@@ -73,6 +73,21 @@ class TelegramStatsResponse(BaseModel):
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
@router.post("/generate-link-token", response_model=TelegramLinkToken)
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,
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

@@ -1,5 +1,6 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
@@ -9,7 +10,8 @@ from app.models.assignment import AssignmentStatus
from app.models.marathon import MarathonStatus
from app.schemas import (
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
PasswordChange, UserStats, UserProfilePublic,
PasswordChange, UserStats, UserProfilePublic, NotificationSettings,
NotificationSettingsUpdate,
)
from app.services.storage import storage_service
@@ -19,7 +21,16 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=UserPublic)
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
"""Get user profile. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id))
result = await db.execute(
select(User)
.where(User.id == user_id)
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none()
if not user:
@@ -189,6 +200,32 @@ async def change_password(
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)
async def get_my_stats(current_user: CurrentUser, db: DbSession):
"""Получить свою статистику"""
@@ -212,7 +249,16 @@ async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser)
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
"""Получить публичный профиль пользователя со статистикой. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id))
result = await db.execute(
select(User)
.where(User.id == user_id)
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none()
if not user:
@@ -227,8 +273,14 @@ async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUse
id=user.id,
nickname=user.nickname,
avatar_url=user.avatar_url,
telegram_avatar_url=user.telegram_avatar_url,
role=user.role,
created_at=user.created_at,
stats=stats,
equipped_frame=user.equipped_frame,
equipped_title=user.equipped_title,
equipped_name_color=user.equipped_name_color,
equipped_background=user.equipped_background,
)

View File

@@ -9,15 +9,20 @@ from app.core.config import settings
from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType,
EventType, Difficulty, User
EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType,
DisputeStatus,
)
from app.schemas import (
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.events import event_service
from app.services.storage import storage_service
from app.services.coins import coins_service
from app.services.consumables import consumables_service
from app.api.v1.games import get_available_games_for_participant
router = APIRouter(tags=["wheel"])
@@ -48,7 +53,9 @@ async def get_active_assignment(db, participant_id: int, is_event: bool = False)
result = await db.execute(
select(Assignment)
.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(
Assignment.participant_id == participant_id,
@@ -64,7 +71,9 @@ async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment
result = await db.execute(
select(Assignment)
.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(
Assignment.participant_id == participant_id,
@@ -94,7 +103,7 @@ async def activate_returned_assignment(db, returned_assignment: Assignment) -> N
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Spin the wheel to get a random game and challenge"""
"""Spin the wheel to get a random game and challenge (or playthrough)"""
# Check marathon is active
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
@@ -115,43 +124,115 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if active:
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
active_event = await event_service.get_active_event(db, marathon_id)
game = None
challenge = None
is_playthrough = False
# 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.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)
if challenge:
# Load game for challenge
# Check if this game is available for the participant
result = await db.execute(
select(Game).where(Game.id == challenge.game_id)
)
game = result.scalar_one_or_none()
if game and game.id in [g.id for g in available_games]:
# Consume jackpot (one-time use)
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
# 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(
select(Game)
.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:
raise HTTPException(status_code=400, detail="No games with challenges available")
# Filter out already completed challenges
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)
challenge = random.choice(game.challenges)
if not available_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(
participant_id=participant.id,
challenge_id=challenge.id,
@@ -181,10 +262,17 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
await db.commit()
await db.refresh(assignment)
# Calculate drop penalty (considers active event for double_risk)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
# Calculate drop penalty
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
if 'challenges' in game.__dict__:
challenges_count = len(game.challenges)
@@ -193,9 +281,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)
)
return SpinResult(
assignment_id=assignment.id,
game=GameResponse(
# Build response
game_response = GameResponse(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
@@ -204,7 +291,52 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
added_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,
)
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(
id=challenge.id,
title=challenge.title,
@@ -215,12 +347,14 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
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,
created_at=challenge.created_at,
),
is_playthrough=False,
can_drop=True,
drop_penalty=drop_penalty,
event_type=active_event.type if active_event else None,
)
@@ -230,9 +364,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)
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:
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
game = challenge.game
@@ -252,7 +463,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
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,
created_at=challenge.created_at,
),
@@ -264,6 +475,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
started_at=assignment.started_at,
completed_at=assignment.completed_at,
drop_penalty=drop_penalty,
event_type=assignment.event_type,
)
@@ -274,15 +486,19 @@ async def complete_assignment(
db: DbSession,
proof_url: 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)"""
# Get assignment
# Get assignment with all needed relationships
result = await db.execute(
select(Assignment)
.options(
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)
)
@@ -301,62 +517,197 @@ async def complete_assignment(
if assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
# Need either file or URL
if not proof_file and not proof_url:
# Combine legacy single file with new multiple files
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)")
# Handle file upload
if proof_file:
contents = await proof_file.read()
# Handle multiple file uploads
if all_files:
from app.models import AssignmentProof
for idx, file in enumerate(all_files):
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
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:
raise HTTPException(
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
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(
content=contents,
folder="proofs",
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
else:
# Set proof URL if provided
if proof_url:
assignment.proof_url = proof_url
assignment.proof_comment = comment
# Calculate points
participant = assignment.participant
challenge = assignment.challenge
# Get marathon_id for activity and event check
result = await db.execute(
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
# Handle playthrough completion
if assignment.is_playthrough:
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)
# Apply boost multiplier from consumable
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_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
# Get marathon and award coins if certified
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = marathon_result.scalar_one()
coins_earned = 0
if marathon.is_certified:
coins_earned = await coins_service.award_playthrough_coins(
db, current_user, participant, marathon, total_points, assignment.id
)
# 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 boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
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,
coins_earned=coins_earned,
)
# Regular challenge completion
challenge = assignment.challenge
marathon_id = challenge.game.marathon_id
# Check active event for point multipliers
active_event = await event_service.get_active_event(db, marathon_id)
# For jackpot: use the event_type stored in assignment (since event may be over)
# For other events: use the currently active event
effective_event = active_event
# Handle assignment-level event types (jackpot)
if assignment.event_type == EventType.JACKPOT.value:
# Create a mock event object for point calculation
class MockEvent:
def __init__(self, event_type):
self.type = event_type
@@ -377,6 +728,11 @@ async def complete_assignment(
total_points += common_enemy_bonus
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Apply boost multiplier from consumable
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier)
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points
@@ -386,18 +742,38 @@ async def complete_assignment(
# Update participant
participant.total_points += total_points
participant.current_streak += 1
participant.drop_count = 0 # Reset drop counter on success
participant.drop_count = 0
# Get marathon and award coins if certified
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = marathon_result.scalar_one()
coins_earned = 0
if marathon.is_certified:
coins_earned = await coins_service.award_challenge_coins(
db, current_user, participant, marathon, challenge.difficulty, assignment.id
)
# 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": full_challenge.game.title,
"game": challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": total_points,
"streak": participant.current_streak,
}
# Log event info (use assignment's event_type for jackpot, active_event for others)
if is_redo:
activity_data["is_redo"] = True
if boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if assignment.event_type == EventType.JACKPOT.value:
activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus
@@ -418,7 +794,6 @@ async def complete_assignment(
# If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners:
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
# Load winner nicknames
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids))
@@ -438,7 +813,7 @@ async def complete_assignment(
event_end_activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id, # Last completer triggers the close
user_id=current_user.id,
type=ActivityType.EVENT_END.value,
data={
"event_type": EventType.COMMON_ENEMY.value,
@@ -451,7 +826,7 @@ async def complete_assignment(
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)
if returned_assignment:
await activate_returned_assignment(db, returned_assignment)
@@ -463,18 +838,21 @@ async def complete_assignment(
streak_bonus=streak_bonus,
total_points=participant.total_points,
new_streak=participant.current_streak,
coins_earned=coins_earned,
)
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
"""Drop current assignment"""
# Get assignment
# Get assignment with all needed relationships
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
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)
)
@@ -490,6 +868,83 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
raise HTTPException(status_code=400, detail="Assignment is not active")
participant = assignment.participant
# 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
)
# Check for shield - if active, no penalty
shield_used = False
if consumables_service.consume_shield(participant):
penalty = 0
shield_used = True
# 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 shield_used:
activity_data["shield_used"] = True
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,
shield_used=shield_used,
)
# Regular challenge drop
marathon_id = assignment.challenge.game.marathon_id
# Check active event for free drops (double_risk)
@@ -498,6 +953,12 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
# Calculate penalty (0 if double_risk event is active)
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
# Check for shield - if active, no penalty
shield_used = False
if consumables_service.consume_shield(participant):
penalty = 0
shield_used = True
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
@@ -514,6 +975,8 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
"difficulty": assignment.challenge.difficulty,
"penalty": penalty,
}
if shield_used:
activity_data["shield_used"] = True
if active_event:
activity_data["event_type"] = active_event.type
if active_event.type == EventType.DOUBLE_RISK.value:
@@ -533,6 +996,7 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
shield_used=shield_used,
)
@@ -550,7 +1014,9 @@ async def get_my_history(
result = await db.execute(
select(Assignment)
.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)
.order_by(Assignment.started_at.desc())
@@ -559,8 +1025,61 @@ async def get_my_history(
)
assignments = result.scalars().all()
return [
AssignmentResponse(
responses = []
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,
challenge=ChallengeResponse(
id=a.challenge.id,
@@ -575,7 +1094,9 @@ async def get_my_history(
game=GameShort(
id=a.challenge.game.id,
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,
created_at=a.challenge.created_at,
@@ -587,6 +1108,6 @@ async def get_my_history(
streak_at_completion=a.streak_at_completion,
started_at=a.started_at,
completed_at=a.completed_at,
)
for a in assignments
]
))
return responses

View File

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

View File

@@ -1,5 +1,10 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
from app.core.config import settings
# 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.marathon import Marathon, MarathonStatus, GameProposalMode
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode, CertificationStatus
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.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.event import Event, EventType
from app.models.swap_request import SwapRequest, SwapRequestStatus
@@ -11,6 +13,10 @@ from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVo
from app.models.admin_log import AdminLog, AdminActionType
from app.models.admin_2fa import Admin2FASession
from app.models.static_content import StaticContent
from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType
from app.models.inventory import UserInventory
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
from app.models.consumable_usage import ConsumableUsage
__all__ = [
"User",
@@ -18,16 +24,22 @@ __all__ = [
"Marathon",
"MarathonStatus",
"GameProposalMode",
"CertificationStatus",
"Participant",
"ParticipantRole",
"Game",
"GameStatus",
"GameType",
"Challenge",
"ChallengeType",
"Difficulty",
"ProofType",
"Assignment",
"AssignmentStatus",
"BonusAssignment",
"BonusAssignmentStatus",
"AssignmentProof",
"BonusAssignmentProof",
"Activity",
"ActivityType",
"Event",
@@ -42,4 +54,12 @@ __all__ = [
"AdminActionType",
"Admin2FASession",
"StaticContent",
"ShopItem",
"ShopItemType",
"ItemRarity",
"ConsumableType",
"UserInventory",
"CoinTransaction",
"CoinTransactionType",
"ConsumableUsage",
]

View File

@@ -12,10 +12,13 @@ class AdminActionType(str, Enum):
USER_UNBAN = "user_unban"
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
USER_ROLE_CHANGE = "user_role_change"
USER_PASSWORD_RESET = "user_password_reset"
# Marathon actions
MARATHON_FORCE_FINISH = "marathon_force_finish"
MARATHON_DELETE = "marathon_delete"
MARATHON_CERTIFY = "marathon_certify"
MARATHON_REVOKE_CERTIFICATION = "marathon_revoke_certification"
# Content actions
CONTENT_UPDATE = "content_update"
@@ -29,6 +32,10 @@ class AdminActionType(str, Enum):
ADMIN_2FA_SUCCESS = "admin_2fa_success"
ADMIN_2FA_FAIL = "admin_2fa_fail"
# Dispute actions
DISPUTE_RESOLVE_VALID = "dispute_resolve_valid"
DISPUTE_RESOLVE_INVALID = "dispute_resolve_invalid"
class AdminLog(Base):
__tablename__ = "admin_logs"

View File

@@ -18,8 +18,12 @@ class Assignment(Base):
id: Mapped[int] = mapped_column(primary_key=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)
# Для прохождений (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
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
@@ -33,6 +37,9 @@ class Assignment(Base):
# Relationships
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")
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

@@ -0,0 +1,41 @@
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 typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class CoinTransactionType(str, Enum):
CHALLENGE_COMPLETE = "challenge_complete"
PLAYTHROUGH_COMPLETE = "playthrough_complete"
MARATHON_WIN = "marathon_win"
MARATHON_PLACE = "marathon_place"
COMMON_ENEMY_BONUS = "common_enemy_bonus"
PURCHASE = "purchase"
REFUND = "refund"
ADMIN_GRANT = "admin_grant"
ADMIN_DEDUCT = "admin_deduct"
class CoinTransaction(Base):
__tablename__ = "coin_transactions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
amount: Mapped[int] = mapped_column(Integer, nullable=False)
transaction_type: Mapped[str] = mapped_column(String(30), nullable=False)
reference_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="coin_transactions"
)

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.shop import ShopItem
from app.models.marathon import Marathon
from app.models.assignment import Assignment
class ConsumableUsage(Base):
__tablename__ = "consumable_usages"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False)
marathon_id: Mapped[int | None] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), nullable=True)
assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True)
used_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
effect_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Relationships
user: Mapped["User"] = relationship("User")
item: Mapped["ShopItem"] = relationship("ShopItem")
marathon: Mapped["Marathon | None"] = relationship("Marathon")
assignment: Mapped["Assignment | None"] = relationship("Assignment")

View File

@@ -8,16 +8,19 @@ from app.core.database import Base
class DisputeStatus(str, Enum):
OPEN = "open"
PENDING_ADMIN = "pending_admin" # Voting ended, waiting for admin decision
RESOLVED_VALID = "valid"
RESOLVED_INVALID = "invalid"
class Dispute(Base):
"""Dispute against a completed assignment's proof"""
"""Dispute against a completed assignment's or bonus assignment's proof"""
__tablename__ = "disputes"
id: Mapped[int] = mapped_column(primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True)
# 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"))
reason: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
@@ -26,6 +29,7 @@ class Dispute(Base):
# Relationships
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])
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")

View File

@@ -1,6 +1,6 @@
from datetime import datetime
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 app.core.database import Base
@@ -12,6 +12,11 @@ class GameStatus(str, Enum):
REJECTED = "rejected" # Отклонена
class GameType(str, Enum):
PLAYTHROUGH = "playthrough" # Прохождение игры
CHALLENGES = "challenges" # Челленджи
class Game(Base):
__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)
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
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
proposed_by: Mapped["User"] = relationship(
@@ -43,6 +57,12 @@ class Game(Base):
back_populates="game",
cascade="all, delete-orphan"
)
# Assignments для прохождений (playthrough)
playthrough_assignments: Mapped[list["Assignment"]] = relationship(
"Assignment",
back_populates="game",
foreign_keys="Assignment.game_id"
)
@property
def is_approved(self) -> bool:
@@ -51,3 +71,11 @@ class Game(Base):
@property
def is_pending(self) -> bool:
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

@@ -0,0 +1,39 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.shop import ShopItem
class UserInventory(Base):
__tablename__ = "user_inventory"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False, index=True)
quantity: Mapped[int] = mapped_column(Integer, default=1)
equipped: Mapped[bool] = mapped_column(Boolean, default=False)
purchased_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="inventory"
)
item: Mapped["ShopItem"] = relationship(
"ShopItem",
back_populates="inventory_items"
)
@property
def is_expired(self) -> bool:
"""Check if item has expired"""
if self.expires_at is None:
return False
return datetime.utcnow() > self.expires_at

View File

@@ -17,6 +17,13 @@ class GameProposalMode(str, Enum):
ORGANIZER_ONLY = "organizer_only"
class CertificationStatus(str, Enum):
NONE = "none"
PENDING = "pending"
CERTIFIED = "certified"
REJECTED = "rejected"
class Marathon(Base):
__tablename__ = "marathons"
@@ -31,14 +38,32 @@ class Marathon(Base):
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
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)
# Certification fields
certification_status: Mapped[str] = mapped_column(String(20), default=CertificationStatus.NONE.value)
certification_requested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
certified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
certified_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
certification_rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
# Shop/Consumables settings
allow_skips: Mapped[bool] = mapped_column(Boolean, default=True)
max_skips_per_participant: Mapped[int | None] = mapped_column(Integer, nullable=True)
allow_consumables: Mapped[bool] = mapped_column(Boolean, default=True)
# Relationships
creator: Mapped["User"] = relationship(
"User",
back_populates="created_marathons",
foreign_keys=[creator_id]
)
certified_by: Mapped["User | None"] = relationship(
"User",
foreign_keys=[certified_by_id]
)
participants: Mapped[list["Participant"]] = relationship(
"Participant",
back_populates="marathon",
@@ -59,3 +84,7 @@ class Marathon(Base):
back_populates="marathon",
cascade="all, delete-orphan"
)
@property
def is_certified(self) -> bool:
return self.certification_status == CertificationStatus.CERTIFIED.value

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -26,6 +26,14 @@ class Participant(Base):
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Shop: coins earned in this marathon
coins_earned: Mapped[int] = mapped_column(Integer, default=0)
# Shop: consumables state
skips_used: Mapped[int] = mapped_column(Integer, default=0)
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="participations")
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")

View File

@@ -0,0 +1,81 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, Integer, Boolean, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.inventory import UserInventory
class ShopItemType(str, Enum):
FRAME = "frame"
TITLE = "title"
NAME_COLOR = "name_color"
BACKGROUND = "background"
CONSUMABLE = "consumable"
class ItemRarity(str, Enum):
COMMON = "common"
UNCOMMON = "uncommon"
RARE = "rare"
EPIC = "epic"
LEGENDARY = "legendary"
class ConsumableType(str, Enum):
SKIP = "skip"
SHIELD = "shield"
BOOST = "boost"
REROLL = "reroll"
class ShopItem(Base):
__tablename__ = "shop_items"
id: Mapped[int] = mapped_column(primary_key=True)
item_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
price: Mapped[int] = mapped_column(Integer, nullable=False)
rarity: Mapped[str] = mapped_column(String(20), default=ItemRarity.COMMON.value)
asset_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
available_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
available_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
stock_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
stock_remaining: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
inventory_items: Mapped[list["UserInventory"]] = relationship(
"UserInventory",
back_populates="item"
)
@property
def is_available(self) -> bool:
"""Check if item is currently available for purchase"""
if not self.is_active:
return False
now = datetime.utcnow()
if self.available_from and self.available_from > now:
return False
if self.available_until and self.available_until < now:
return False
if self.stock_remaining is not None and self.stock_remaining <= 0:
return False
return True
@property
def is_consumable(self) -> bool:
return self.item_type == ShopItemType.CONSUMABLE.value

View File

@@ -2,9 +2,15 @@ from datetime import datetime
from enum import Enum
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.shop import ShopItem
from app.models.inventory import UserInventory
from app.models.coin_transaction import CoinTransaction
class UserRole(str, Enum):
USER = "user"
@@ -34,6 +40,20 @@ class User(Base):
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)
# 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)
# Shop: coins balance
coins_balance: Mapped[int] = mapped_column(Integer, default=0)
# Shop: equipped cosmetics
equipped_frame_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
equipped_title_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
equipped_name_color_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
equipped_background_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
# Relationships
created_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon",
@@ -60,6 +80,32 @@ class User(Base):
foreign_keys=[banned_by_id]
)
# Shop relationships
inventory: Mapped[list["UserInventory"]] = relationship(
"UserInventory",
back_populates="user"
)
coin_transactions: Mapped[list["CoinTransaction"]] = relationship(
"CoinTransaction",
back_populates="user"
)
equipped_frame: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_frame_id]
)
equipped_title: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_title_id]
)
equipped_name_color: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_name_color_id]
)
equipped_background: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_background_id]
)
@property
def is_admin(self) -> bool:
return self.role == UserRole.ADMIN.value

View File

@@ -9,6 +9,8 @@ from app.schemas.user import (
PasswordChange,
UserStats,
UserProfilePublic,
NotificationSettings,
NotificationSettingsUpdate,
)
from app.schemas.marathon import (
MarathonCreate,
@@ -46,6 +48,10 @@ from app.schemas.assignment import (
CompleteResult,
DropResult,
EventAssignmentResponse,
BonusAssignmentResponse,
CompleteBonusAssignment,
BonusCompleteResult,
AvailableGamesCount,
)
from app.schemas.activity import (
ActivityResponse,
@@ -83,6 +89,7 @@ from app.schemas.dispute import (
)
from app.schemas.admin import (
BanUserRequest,
AdminResetPasswordRequest,
AdminUserResponse,
AdminLogResponse,
AdminLogsListResponse,
@@ -97,6 +104,27 @@ from app.schemas.admin import (
LoginResponse,
DashboardStats,
)
from app.schemas.shop import (
ShopItemCreate,
ShopItemUpdate,
ShopItemResponse,
InventoryItemResponse,
PurchaseRequest,
PurchaseResponse,
UseConsumableRequest,
UseConsumableResponse,
EquipItemRequest,
EquipItemResponse,
CoinTransactionResponse,
CoinsBalanceResponse,
AdminCoinsRequest,
UserCosmeticsResponse,
CertificationRequestSchema,
CertificationReviewRequest,
CertificationStatusResponse,
ConsumablesStatusResponse,
)
from app.schemas.user import ShopItemPublic
__all__ = [
# User
@@ -110,6 +138,8 @@ __all__ = [
"PasswordChange",
"UserStats",
"UserProfilePublic",
"NotificationSettings",
"NotificationSettingsUpdate",
# Marathon
"MarathonCreate",
"MarathonUpdate",
@@ -143,6 +173,10 @@ __all__ = [
"CompleteResult",
"DropResult",
"EventAssignmentResponse",
"BonusAssignmentResponse",
"CompleteBonusAssignment",
"BonusCompleteResult",
"AvailableGamesCount",
# Activity
"ActivityResponse",
"FeedResponse",
@@ -175,6 +209,7 @@ __all__ = [
"ReturnedAssignmentResponse",
# Admin
"BanUserRequest",
"AdminResetPasswordRequest",
"AdminUserResponse",
"AdminLogResponse",
"AdminLogsListResponse",
@@ -188,4 +223,24 @@ __all__ = [
"TwoFactorVerifyRequest",
"LoginResponse",
"DashboardStats",
# Shop
"ShopItemCreate",
"ShopItemUpdate",
"ShopItemResponse",
"ShopItemPublic",
"InventoryItemResponse",
"PurchaseRequest",
"PurchaseResponse",
"UseConsumableRequest",
"UseConsumableResponse",
"EquipItemRequest",
"EquipItemResponse",
"CoinTransactionResponse",
"CoinsBalanceResponse",
"AdminCoinsRequest",
"UserCosmeticsResponse",
"CertificationRequestSchema",
"CertificationReviewRequest",
"CertificationStatusResponse",
"ConsumablesStatusResponse",
]

View File

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

View File

@@ -1,10 +1,21 @@
from datetime import datetime
from pydantic import BaseModel
from app.schemas.game import GameResponse
from app.schemas.game import GameResponse, GameShort, PlaythroughInfo
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):
pass
@@ -14,9 +25,28 @@ class CompleteAssignment(BaseModel):
comment: str | None = None
class AssignmentResponse(BaseModel):
class BonusAssignmentResponse(BaseModel):
"""Ответ с информацией о бонусном челлендже"""
id: int
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
proof_url: str | None = None
proof_comment: str | None = None
@@ -25,6 +55,8 @@ class AssignmentResponse(BaseModel):
started_at: datetime
completed_at: datetime | None = None
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:
from_attributes = True
@@ -33,9 +65,13 @@ class AssignmentResponse(BaseModel):
class SpinResult(BaseModel):
assignment_id: int
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
drop_penalty: int
event_type: str | None = None # Event type if active during spin
class CompleteResult(BaseModel):
@@ -43,12 +79,14 @@ class CompleteResult(BaseModel):
streak_bonus: int
total_points: int
new_streak: int
coins_earned: int = 0 # Coins earned (only in certified marathons)
class DropResult(BaseModel):
penalty: int
total_points: int
new_drop_count: int
shield_used: bool = False # Whether shield consumable was used to prevent penalty
class EventAssignmentResponse(BaseModel):
@@ -60,3 +98,22 @@ class EventAssignmentResponse(BaseModel):
class Config:
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)
type: ChallengeType
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
proof_type: ProofType
proof_hint: str | None = None
@@ -34,7 +34,7 @@ class ChallengeUpdate(BaseModel):
description: str | None = None
type: ChallengeType | 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
proof_type: ProofType | None = None
proof_hint: str | None = None

View File

@@ -1,8 +1,14 @@
from datetime import datetime
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
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):
@@ -63,11 +69,15 @@ class DisputeResponse(BaseModel):
class AssignmentDetailResponse(BaseModel):
"""Detailed assignment information with proofs and dispute"""
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
status: str
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
points_earned: int
streak_at_completion: int | None
@@ -75,6 +85,7 @@ class AssignmentDetailResponse(BaseModel):
completed_at: datetime | None
can_dispute: bool # True if <24h since completion and not own assignment
dispute: DisputeResponse | None
bonus_challenges: list[dict] | None = None # For playthrough
class Config:
from_attributes = True
@@ -83,7 +94,11 @@ class AssignmentDetailResponse(BaseModel):
class ReturnedAssignmentResponse(BaseModel):
"""Returned assignment that needs to be redone"""
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
dispute_reason: str

View File

@@ -1,6 +1,9 @@
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
@@ -13,17 +16,48 @@ class GameBase(BaseModel):
class GameCreate(GameBase):
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):
title: str | None = Field(None, min_length=1, max_length=100)
download_url: 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):
id: int
title: str
cover_url: str | None = None
download_url: str
game_type: str = "challenges"
class Config:
from_attributes = True
@@ -38,5 +72,22 @@ class GameResponse(GameBase):
challenges_count: int = 0
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:
from_attributes = True
class PlaythroughInfo(BaseModel):
"""Информация о прохождении для игр типа playthrough"""
description: str
points: int
proof_type: str
proof_hint: str | None = None

View File

@@ -14,6 +14,10 @@ class MarathonCreate(MarathonBase):
duration_days: int = Field(default=30, ge=1, le=365)
is_public: bool = False
game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$")
# Shop/Consumables settings
allow_skips: bool = True
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
allow_consumables: bool = True
class MarathonUpdate(BaseModel):
@@ -23,6 +27,10 @@ class MarathonUpdate(BaseModel):
is_public: bool | None = None
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
auto_events_enabled: bool | None = None
# Shop/Consumables settings
allow_skips: bool | None = None
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
allow_consumables: bool | None = None
class ParticipantInfo(BaseModel):
@@ -32,6 +40,13 @@ class ParticipantInfo(BaseModel):
current_streak: int
drop_count: int
joined_at: datetime
# Shop: coins and consumables status
coins_earned: int = 0
skips_used: int = 0
has_shield: bool = False
has_active_boost: bool = False
boost_multiplier: float | None = None
boost_expires_at: datetime | None = None
class Config:
from_attributes = True
@@ -49,12 +64,20 @@ class MarathonResponse(MarathonBase):
is_public: bool
game_proposal_mode: str
auto_events_enabled: bool
cover_url: str | None
start_date: datetime | None
end_date: datetime | None
participants_count: int
games_count: int
created_at: datetime
my_participation: ParticipantInfo | None = None
# Certification
certification_status: str = "none"
is_certified: bool = False
# Shop/Consumables settings
allow_skips: bool = True
max_skips_per_participant: int | None = None
allow_consumables: bool = True
class Config:
from_attributes = True
@@ -69,9 +92,12 @@ class MarathonListItem(BaseModel):
title: str
status: str
is_public: bool
cover_url: str | None
participants_count: int
start_date: datetime | None
end_date: datetime | None
# Certification badge
is_certified: bool = False
class Config:
from_attributes = True
@@ -87,6 +113,7 @@ class MarathonPublicInfo(BaseModel):
title: str
description: str | None
status: str
cover_url: str | None
participants_count: int
creator_nickname: str

200
backend/app/schemas/shop.py Normal file
View File

@@ -0,0 +1,200 @@
"""
Pydantic schemas for Shop system
"""
from datetime import datetime
from pydantic import BaseModel, Field
from typing import Any
# === Shop Items ===
class ShopItemBase(BaseModel):
"""Base schema for shop items"""
item_type: str
code: str
name: str
description: str | None = None
price: int
rarity: str = "common"
asset_data: dict | None = None
class ShopItemCreate(ShopItemBase):
"""Schema for creating a shop item (admin)"""
is_active: bool = True
available_from: datetime | None = None
available_until: datetime | None = None
stock_limit: int | None = None
class ShopItemUpdate(BaseModel):
"""Schema for updating a shop item (admin)"""
name: str | None = None
description: str | None = None
price: int | None = Field(None, ge=1)
rarity: str | None = None
asset_data: dict | None = None
is_active: bool | None = None
available_from: datetime | None = None
available_until: datetime | None = None
stock_limit: int | None = None
class ShopItemResponse(ShopItemBase):
"""Schema for shop item response"""
id: int
is_active: bool
available_from: datetime | None
available_until: datetime | None
stock_limit: int | None
stock_remaining: int | None
created_at: datetime
is_available: bool # Computed property
is_owned: bool = False # Set by API based on user
is_equipped: bool = False # Set by API based on user
class Config:
from_attributes = True
# === Inventory ===
class InventoryItemResponse(BaseModel):
"""Schema for user inventory item"""
id: int
item: ShopItemResponse
quantity: int
equipped: bool
purchased_at: datetime
expires_at: datetime | None
class Config:
from_attributes = True
# === Purchases ===
class PurchaseRequest(BaseModel):
"""Schema for purchase request"""
item_id: int
quantity: int = Field(default=1, ge=1, le=10)
class PurchaseResponse(BaseModel):
"""Schema for purchase response"""
success: bool
item: ShopItemResponse
quantity: int
total_cost: int
new_balance: int
message: str
# === Consumables ===
class UseConsumableRequest(BaseModel):
"""Schema for using a consumable"""
item_code: str # 'skip', 'shield', 'boost', 'reroll'
marathon_id: int
assignment_id: int | None = None # Required for skip and reroll
class UseConsumableResponse(BaseModel):
"""Schema for consumable use response"""
success: bool
item_code: str
remaining_quantity: int
effect_description: str
effect_data: dict | None = None
# === Equipment ===
class EquipItemRequest(BaseModel):
"""Schema for equipping an item"""
inventory_id: int
class EquipItemResponse(BaseModel):
"""Schema for equip response"""
success: bool
item_type: str
equipped_item: ShopItemResponse | None
message: str
# === Coins ===
class CoinTransactionResponse(BaseModel):
"""Schema for coin transaction"""
id: int
amount: int
transaction_type: str
description: str | None
reference_type: str | None
reference_id: int | None
created_at: datetime
class Config:
from_attributes = True
class CoinsBalanceResponse(BaseModel):
"""Schema for coins balance with recent transactions"""
balance: int
recent_transactions: list[CoinTransactionResponse]
class AdminCoinsRequest(BaseModel):
"""Schema for admin coin operations"""
amount: int = Field(..., ge=1)
reason: str = Field(..., min_length=1, max_length=500)
# === User Cosmetics ===
class UserCosmeticsResponse(BaseModel):
"""Schema for user's equipped cosmetics"""
frame: ShopItemResponse | None = None
title: ShopItemResponse | None = None
name_color: ShopItemResponse | None = None
background: ShopItemResponse | None = None
# === Certification ===
class CertificationRequestSchema(BaseModel):
"""Schema for requesting marathon certification"""
pass # No fields needed for now
class CertificationReviewRequest(BaseModel):
"""Schema for admin reviewing certification"""
approve: bool
rejection_reason: str | None = Field(None, max_length=1000)
class CertificationStatusResponse(BaseModel):
"""Schema for certification status"""
marathon_id: int
certification_status: str
is_certified: bool
certification_requested_at: datetime | None
certified_at: datetime | None
certified_by_nickname: str | None = None
rejection_reason: str | None = None
# === Consumables Status ===
class ConsumablesStatusResponse(BaseModel):
"""Schema for participant's consumables status in a marathon"""
skips_available: int # From inventory
skips_used: int # In this marathon
skips_remaining: int | None # Based on marathon limit
shields_available: int # From inventory
has_shield: bool # Currently activated
boosts_available: int # From inventory
has_active_boost: bool # Currently activated (one-time for next complete)
boost_multiplier: float | None # 1.5 if boost active
rerolls_available: int # From inventory

View File

@@ -28,6 +28,19 @@ class UserUpdate(BaseModel):
nickname: str | None = Field(None, min_length=2, max_length=50)
class ShopItemPublic(BaseModel):
"""Minimal shop item info for public display"""
id: int
code: str
name: str
item_type: str
rarity: str
asset_data: dict | None = None
class Config:
from_attributes = True
class UserPublic(UserBase):
"""Public user info visible to other users - minimal data"""
id: int
@@ -35,6 +48,11 @@ class UserPublic(UserBase):
role: str = "user"
telegram_avatar_url: str | None = None # Only TG avatar is public
created_at: datetime
# Shop: equipped cosmetics (visible to others)
equipped_frame: ShopItemPublic | None = None
equipped_title: ShopItemPublic | None = None
equipped_name_color: ShopItemPublic | None = None
equipped_background: ShopItemPublic | None = None
class Config:
from_attributes = True
@@ -47,6 +65,12 @@ class UserPrivate(UserPublic):
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
# Notification settings
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
# Shop: coins balance (only visible to self)
coins_balance: int = 0
class TokenResponse(BaseModel):
@@ -78,8 +102,32 @@ class UserProfilePublic(BaseModel):
id: int
nickname: str
avatar_url: str | None = None
telegram_avatar_url: str | None = None
role: str = "user"
created_at: datetime
stats: UserStats
# Equipped cosmetics
equipped_frame: ShopItemPublic | None = None
equipped_title: ShopItemPublic | None = None
equipped_name_color: ShopItemPublic | None = None
equipped_background: ShopItemPublic | None = None
class Config:
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

@@ -0,0 +1,288 @@
"""
Coins Service - handles all coin-related operations
Coins are earned only in certified marathons and can be spent in the shop.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User, Participant, Marathon, CoinTransaction, CoinTransactionType
from app.models.challenge import Difficulty
class CoinsService:
"""Service for managing coin transactions and balances"""
# Coins awarded per challenge difficulty (only in certified marathons)
CHALLENGE_COINS = {
Difficulty.EASY.value: 5,
Difficulty.MEDIUM.value: 12,
Difficulty.HARD.value: 25,
}
# Coins for playthrough = points * this ratio
PLAYTHROUGH_COIN_RATIO = 0.05 # 5% of points
# Coins awarded for marathon placements
MARATHON_PLACE_COINS = {
1: 100, # 1st place
2: 50, # 2nd place
3: 30, # 3rd place
}
# Bonus coins for Common Enemy event winners
COMMON_ENEMY_BONUS_COINS = {
1: 15, # First to complete
2: 10, # Second
3: 5, # Third
}
async def award_challenge_coins(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
difficulty: str,
assignment_id: int,
) -> int:
"""
Award coins for completing a challenge.
Only awards coins if marathon is certified.
Returns: number of coins awarded (0 if marathon not certified)
"""
if not marathon.is_certified:
return 0
coins = self.CHALLENGE_COINS.get(difficulty, 0)
if coins <= 0:
return 0
# Create transaction
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.CHALLENGE_COMPLETE.value,
reference_type="assignment",
reference_id=assignment_id,
description=f"Challenge completion ({difficulty})",
)
db.add(transaction)
# Update balances
user.coins_balance += coins
participant.coins_earned += coins
return coins
async def award_playthrough_coins(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
points: int,
assignment_id: int,
) -> int:
"""
Award coins for completing a playthrough.
Coins = points * PLAYTHROUGH_COIN_RATIO
Returns: number of coins awarded (0 if marathon not certified)
"""
if not marathon.is_certified:
return 0
coins = int(points * self.PLAYTHROUGH_COIN_RATIO)
if coins <= 0:
return 0
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.PLAYTHROUGH_COMPLETE.value,
reference_type="assignment",
reference_id=assignment_id,
description=f"Playthrough completion ({points} points)",
)
db.add(transaction)
user.coins_balance += coins
participant.coins_earned += coins
return coins
async def award_marathon_place(
self,
db: AsyncSession,
user: User,
marathon: Marathon,
place: int,
) -> int:
"""
Award coins for placing in a marathon (1st, 2nd, 3rd).
Returns: number of coins awarded (0 if not top 3 or not certified)
"""
if not marathon.is_certified:
return 0
coins = self.MARATHON_PLACE_COINS.get(place, 0)
if coins <= 0:
return 0
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.MARATHON_PLACE.value,
reference_type="marathon",
reference_id=marathon.id,
description=f"Marathon #{place} place: {marathon.title}",
)
db.add(transaction)
user.coins_balance += coins
return coins
async def award_common_enemy_bonus(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
rank: int,
event_id: int,
) -> int:
"""
Award bonus coins for Common Enemy event completion.
Returns: number of bonus coins awarded
"""
if not marathon.is_certified:
return 0
coins = self.COMMON_ENEMY_BONUS_COINS.get(rank, 0)
if coins <= 0:
return 0
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.COMMON_ENEMY_BONUS.value,
reference_type="event",
reference_id=event_id,
description=f"Common Enemy #{rank} place",
)
db.add(transaction)
user.coins_balance += coins
participant.coins_earned += coins
return coins
async def spend_coins(
self,
db: AsyncSession,
user: User,
amount: int,
description: str,
reference_type: str | None = None,
reference_id: int | None = None,
) -> bool:
"""
Spend coins (for purchases).
Returns: True if successful, False if insufficient balance
"""
if user.coins_balance < amount:
return False
transaction = CoinTransaction(
user_id=user.id,
amount=-amount, # Negative for spending
transaction_type=CoinTransactionType.PURCHASE.value,
reference_type=reference_type,
reference_id=reference_id,
description=description,
)
db.add(transaction)
user.coins_balance -= amount
return True
async def refund_coins(
self,
db: AsyncSession,
user: User,
amount: int,
description: str,
reference_type: str | None = None,
reference_id: int | None = None,
) -> None:
"""Refund coins to user (for failed purchases, etc.)"""
transaction = CoinTransaction(
user_id=user.id,
amount=amount,
transaction_type=CoinTransactionType.REFUND.value,
reference_type=reference_type,
reference_id=reference_id,
description=description,
)
db.add(transaction)
user.coins_balance += amount
async def admin_grant_coins(
self,
db: AsyncSession,
user: User,
amount: int,
reason: str,
admin_id: int,
) -> None:
"""Admin grants coins to user"""
transaction = CoinTransaction(
user_id=user.id,
amount=amount,
transaction_type=CoinTransactionType.ADMIN_GRANT.value,
reference_type="admin",
reference_id=admin_id,
description=f"Admin grant: {reason}",
)
db.add(transaction)
user.coins_balance += amount
async def admin_deduct_coins(
self,
db: AsyncSession,
user: User,
amount: int,
reason: str,
admin_id: int,
) -> bool:
"""
Admin deducts coins from user.
Returns: True if successful, False if insufficient balance
"""
if user.coins_balance < amount:
return False
transaction = CoinTransaction(
user_id=user.id,
amount=-amount,
transaction_type=CoinTransactionType.ADMIN_DEDUCT.value,
reference_type="admin",
reference_id=admin_id,
description=f"Admin deduction: {reason}",
)
db.add(transaction)
user.coins_balance -= amount
return True
# Singleton instance
coins_service = CoinsService()

View File

@@ -0,0 +1,320 @@
"""
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
"""
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
User, Participant, Marathon, Assignment, AssignmentStatus,
ShopItem, UserInventory, ConsumableUsage, ConsumableType
)
class ConsumablesService:
"""Service for consumable items"""
# Boost settings
BOOST_MULTIPLIER = 1.5
async def use_skip(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
) -> dict:
"""
Use a Skip to bypass current assignment without penalty.
- No streak loss
- No drop penalty
- Assignment marked as dropped but without negative effects
Returns: dict with result info
Raises:
HTTPException: If skips not allowed or limit reached
"""
# Check marathon settings
if not marathon.allow_skips:
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
if marathon.max_skips_per_participant is not None:
if participant.skips_used >= marathon.max_skips_per_participant:
raise HTTPException(
status_code=400,
detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)"
)
# Check assignment is active
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only skip active assignments")
# Consume skip from inventory
item = await self._consume_item(db, user, ConsumableType.SKIP.value)
# Mark assignment as dropped (but without penalty)
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Note: We do NOT increase drop_count or reset streak
# Track skip usage
participant.skips_used += 1
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "skip",
"skipped_without_penalty": True,
},
)
db.add(usage)
return {
"success": True,
"skipped": True,
"penalty": 0,
"streak_preserved": True,
}
async def use_shield(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Activate a Shield - protects from next drop penalty.
- Next drop will not cause point penalty
- Streak is preserved on next drop
Returns: dict with result info
Raises:
HTTPException: If consumables not allowed or shield already active
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_shield:
raise HTTPException(status_code=400, detail="Shield is already active")
# Consume shield from inventory
item = await self._consume_item(db, user, ConsumableType.SHIELD.value)
# Activate shield
participant.has_shield = True
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "shield",
"activated": True,
},
)
db.add(usage)
return {
"success": True,
"shield_activated": True,
}
async def use_boost(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Activate a Boost - multiplies points for NEXT complete only.
- Points for next completed challenge are multiplied by BOOST_MULTIPLIER
- One-time use (consumed on next complete)
Returns: dict with result info
Raises:
HTTPException: If consumables not allowed or boost already active
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_active_boost:
raise HTTPException(status_code=400, detail="Boost is already activated")
# Consume boost from inventory
item = await self._consume_item(db, user, ConsumableType.BOOST.value)
# Activate boost (one-time use)
participant.has_active_boost = True
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "boost",
"multiplier": self.BOOST_MULTIPLIER,
"one_time": True,
},
)
db.add(usage)
return {
"success": True,
"boost_activated": True,
"multiplier": self.BOOST_MULTIPLIER,
}
async def use_reroll(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
) -> dict:
"""
Use a Reroll - discard current assignment and spin again.
- Current assignment is cancelled (not dropped)
- User can spin the wheel again
- No penalty
Returns: dict with result info
Raises:
HTTPException: If consumables not allowed or assignment not active
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only reroll active assignments")
# Consume reroll from inventory
item = await self._consume_item(db, user, ConsumableType.REROLL.value)
# Cancel current assignment
old_challenge_id = assignment.challenge_id
old_game_id = assignment.game_id
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Note: We do NOT increase drop_count (this is a reroll, not a real drop)
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "reroll",
"rerolled_from_challenge_id": old_challenge_id,
"rerolled_from_game_id": old_game_id,
},
)
db.add(usage)
return {
"success": True,
"rerolled": True,
"can_spin_again": True,
}
async def _consume_item(
self,
db: AsyncSession,
user: User,
item_code: str,
) -> ShopItem:
"""
Consume 1 unit of a consumable from user's inventory.
Returns: The consumed ShopItem
Raises:
HTTPException: If user doesn't have the item
"""
result = await db.execute(
select(UserInventory)
.options(selectinload(UserInventory.item))
.join(ShopItem)
.where(
UserInventory.user_id == user.id,
ShopItem.code == item_code,
UserInventory.quantity > 0,
)
)
inv_item = result.scalar_one_or_none()
if not inv_item:
raise HTTPException(
status_code=400,
detail=f"You don't have any {item_code} in your inventory"
)
# Decrease quantity
inv_item.quantity -= 1
return inv_item.item
async def get_consumable_count(
self,
db: AsyncSession,
user_id: int,
item_code: str,
) -> int:
"""Get how many of a consumable user has"""
result = await db.execute(
select(UserInventory.quantity)
.join(ShopItem)
.where(
UserInventory.user_id == user_id,
ShopItem.code == item_code,
)
)
quantity = result.scalar_one_or_none()
return quantity or 0
def consume_shield(self, participant: Participant) -> bool:
"""
Consume shield when dropping (called from wheel.py).
Returns: True if shield was consumed, False otherwise
"""
if participant.has_shield:
participant.has_shield = False
return True
return False
def consume_boost_on_complete(self, participant: Participant) -> float:
"""
Consume boost when completing assignment (called from wheel.py).
One-time use - boost is consumed after single complete.
Returns: Multiplier value (BOOST_MULTIPLIER if boost was active, 1.0 otherwise)
"""
if participant.has_active_boost:
participant.has_active_boost = False
return self.BOOST_MULTIPLIER
return 1.0
# Singleton instance
consumables_service = ConsumablesService()

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
from datetime import datetime, timedelta
@@ -8,16 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
from app.services.disputes import dispute_service
from app.services.telegram_notifier import telegram_notifier
# Configuration
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:
"""Background scheduler for automatic dispute resolution."""
"""Background scheduler that marks expired disputes for admin review."""
def __init__(self):
self._running = False
@@ -55,7 +55,7 @@ class DisputeScheduler:
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
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)
# Find all open disputes that have expired
@@ -63,7 +63,6 @@ class DisputeScheduler:
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
)
.where(
Dispute.status == DisputeStatus.OPEN.value,
@@ -74,15 +73,25 @@ class DisputeScheduler:
for dispute in expired_disputes:
try:
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute(
db, dispute.id
)
# Count votes for logging
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(
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: "
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})"
f"[DisputeScheduler] Dispute {dispute.id} marked as pending admin "
f"(recommendation: {'invalid' if votes_invalid > votes_valid else 'valid'}, "
f"votes: {votes_valid} valid, {votes_invalid} invalid)"
)
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

View File

@@ -23,12 +23,15 @@ class DisputeService:
Returns:
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(
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
)
.where(Dispute.id == dispute_id)
)
@@ -46,8 +49,11 @@ class DisputeService:
# Determine result: tie goes to the accused (valid)
if votes_invalid > votes_valid:
# Proof is invalid - mark assignment as RETURNED
# Proof is invalid
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)
else:
# Proof is valid (or tie)
@@ -60,7 +66,11 @@ class DisputeService:
await db.commit()
# 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
@@ -72,12 +82,13 @@ class DisputeService:
) -> None:
"""Send notification about dispute resolution to the assignment owner."""
try:
# Get assignment with challenge and marathon info
# Get assignment with challenge/game and marathon info
result = await db.execute(
select(Assignment)
.options(
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)
)
@@ -86,12 +97,19 @@ class DisputeService:
return
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
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
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()
@@ -100,12 +118,86 @@ class DisputeService:
db,
user_id=participant.user_id,
marathon_title=marathon.title,
challenge_title=challenge.title if challenge else "Unknown",
challenge_title=title,
is_valid=is_valid
)
except Exception as 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:
"""
Handle the case when proof is determined to be invalid.
@@ -113,7 +205,10 @@ class DisputeService:
- Mark assignment as RETURNED
- Subtract points from participant
- Reset streak if it was affected
- For playthrough: also reset bonus assignments
"""
from app.models import BonusAssignment, BonusAssignmentStatus
assignment = dispute.assignment
participant = assignment.participant
@@ -121,22 +216,45 @@ class DisputeService:
points_to_subtract = assignment.points_earned
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
assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0
# 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, "
f"subtracted {points_to_subtract} points from participant {participant.id}")
async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
"""Get all open disputes older than specified hours"""
"""Get all open disputes (both regular and bonus) older than specified hours"""
from datetime import timedelta
from app.models import BonusAssignment
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment),
selectinload(Dispute.bonus_assignment),
)
.where(
Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time,

View File

@@ -47,6 +47,8 @@ class EventService:
created_by_id: int | None = None,
duration_minutes: int | None = None,
challenge_id: int | None = None,
game_id: int | None = None,
is_playthrough: bool = False,
) -> Event:
"""Start a new event"""
# Check no active event
@@ -63,7 +65,11 @@ class EventService:
# Build event 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["completions"] = [] # Track who completed and when
@@ -79,9 +85,11 @@ class EventService:
db.add(event)
await db.flush() # Get event.id before committing
# Auto-assign challenge to all participants for Common Enemy
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id)
# Auto-assign challenge/playthrough to all participants for Common Enemy
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, game_id, is_playthrough
)
await db.commit()
await db.refresh(event)
@@ -105,7 +113,9 @@ class EventService:
db: AsyncSession,
marathon_id: int,
event_id: int,
challenge_id: int,
challenge_id: int | None,
game_id: int | None = None,
is_playthrough: bool = False,
) -> None:
"""Create event assignments for all participants in the marathon"""
# Get all participants
@@ -118,7 +128,9 @@ class EventService:
for participant in participants:
assignment = Assignment(
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,
event_type=EventType.COMMON_ENEMY.value,
is_event_assignment=True,
@@ -290,6 +302,30 @@ class EventService:
)
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:
"""Get remaining time in seconds for an event"""
if not event or not event.end_time:

View File

@@ -0,0 +1,297 @@
"""
Shop Service - handles shop items, purchases, and inventory management
"""
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import User, ShopItem, UserInventory, ShopItemType
from app.services.coins import coins_service
class ShopService:
"""Service for shop operations"""
async def get_available_items(
self,
db: AsyncSession,
item_type: str | None = None,
include_unavailable: bool = False,
) -> list[ShopItem]:
"""
Get list of shop items.
Args:
item_type: Filter by item type (frame, title, etc.)
include_unavailable: Include inactive/out of stock items
"""
query = select(ShopItem)
if item_type:
query = query.where(ShopItem.item_type == item_type)
if not include_unavailable:
now = datetime.utcnow()
query = query.where(
ShopItem.is_active == True,
(ShopItem.available_from.is_(None)) | (ShopItem.available_from <= now),
(ShopItem.available_until.is_(None)) | (ShopItem.available_until >= now),
(ShopItem.stock_remaining.is_(None)) | (ShopItem.stock_remaining > 0),
)
query = query.order_by(ShopItem.price.asc())
result = await db.execute(query)
return list(result.scalars().all())
async def get_item_by_id(self, db: AsyncSession, item_id: int) -> ShopItem | None:
"""Get shop item by ID"""
result = await db.execute(select(ShopItem).where(ShopItem.id == item_id))
return result.scalar_one_or_none()
async def get_item_by_code(self, db: AsyncSession, code: str) -> ShopItem | None:
"""Get shop item by code"""
result = await db.execute(select(ShopItem).where(ShopItem.code == code))
return result.scalar_one_or_none()
async def purchase_item(
self,
db: AsyncSession,
user: User,
item_id: int,
quantity: int = 1,
) -> tuple[UserInventory, int]:
"""
Purchase an item from the shop.
Args:
user: The purchasing user
item_id: ID of item to purchase
quantity: Number to purchase (only for consumables)
Returns:
Tuple of (inventory item, total cost)
Raises:
HTTPException: If item not found, not available, or insufficient funds
"""
# Get item
item = await self.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
# Check availability
if not item.is_available:
raise HTTPException(status_code=400, detail="Item is not available")
# For non-consumables, quantity is always 1
if item.item_type != ShopItemType.CONSUMABLE.value:
quantity = 1
# Check if already owned
existing = await db.execute(
select(UserInventory).where(
UserInventory.user_id == user.id,
UserInventory.item_id == item.id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="You already own this item")
# Check stock
if item.stock_remaining is not None and item.stock_remaining < quantity:
raise HTTPException(status_code=400, detail="Not enough stock available")
# Calculate total cost
total_cost = item.price * quantity
# Check balance
if user.coins_balance < total_cost:
raise HTTPException(status_code=400, detail="Not enough coins")
# Deduct coins
success = await coins_service.spend_coins(
db, user, total_cost,
f"Purchase: {item.name} x{quantity}",
"shop_item", item.id,
)
if not success:
raise HTTPException(status_code=400, detail="Payment failed")
# Add to inventory
if item.item_type == ShopItemType.CONSUMABLE.value:
# For consumables, increase quantity if already exists
existing_result = await db.execute(
select(UserInventory).where(
UserInventory.user_id == user.id,
UserInventory.item_id == item.id,
)
)
inv_item = existing_result.scalar_one_or_none()
if inv_item:
inv_item.quantity += quantity
else:
inv_item = UserInventory(
user_id=user.id,
item_id=item.id,
quantity=quantity,
)
db.add(inv_item)
else:
# For cosmetics, create new inventory entry
inv_item = UserInventory(
user_id=user.id,
item_id=item.id,
quantity=1,
)
db.add(inv_item)
# Decrease stock if limited
if item.stock_remaining is not None:
item.stock_remaining -= quantity
await db.flush()
return inv_item, total_cost
async def get_user_inventory(
self,
db: AsyncSession,
user_id: int,
item_type: str | None = None,
) -> list[UserInventory]:
"""Get user's inventory"""
query = (
select(UserInventory)
.options(selectinload(UserInventory.item))
.where(UserInventory.user_id == user_id)
)
if item_type:
query = query.join(ShopItem).where(ShopItem.item_type == item_type)
# Exclude empty consumables
query = query.where(UserInventory.quantity > 0)
result = await db.execute(query)
return list(result.scalars().all())
async def get_inventory_item(
self,
db: AsyncSession,
user_id: int,
inventory_id: int,
) -> UserInventory | None:
"""Get specific inventory item"""
result = await db.execute(
select(UserInventory)
.options(selectinload(UserInventory.item))
.where(
UserInventory.id == inventory_id,
UserInventory.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def equip_item(
self,
db: AsyncSession,
user: User,
inventory_id: int,
) -> ShopItem:
"""
Equip a cosmetic item from inventory.
Returns: The equipped item
Raises:
HTTPException: If item not found or is a consumable
"""
# Get inventory item
inv_item = await self.get_inventory_item(db, user.id, inventory_id)
if not inv_item:
raise HTTPException(status_code=404, detail="Item not found in inventory")
item = inv_item.item
if item.item_type == ShopItemType.CONSUMABLE.value:
raise HTTPException(status_code=400, detail="Cannot equip consumables")
# Unequip current item of same type
await db.execute(
update(UserInventory)
.where(
UserInventory.user_id == user.id,
UserInventory.equipped == True,
UserInventory.item_id.in_(
select(ShopItem.id).where(ShopItem.item_type == item.item_type)
),
)
.values(equipped=False)
)
# Equip new item
inv_item.equipped = True
# Update user's equipped_*_id
if item.item_type == ShopItemType.FRAME.value:
user.equipped_frame_id = item.id
elif item.item_type == ShopItemType.TITLE.value:
user.equipped_title_id = item.id
elif item.item_type == ShopItemType.NAME_COLOR.value:
user.equipped_name_color_id = item.id
elif item.item_type == ShopItemType.BACKGROUND.value:
user.equipped_background_id = item.id
return item
async def unequip_item(
self,
db: AsyncSession,
user: User,
item_type: str,
) -> None:
"""Unequip item of specified type"""
# Unequip from inventory
await db.execute(
update(UserInventory)
.where(
UserInventory.user_id == user.id,
UserInventory.equipped == True,
UserInventory.item_id.in_(
select(ShopItem.id).where(ShopItem.item_type == item_type)
),
)
.values(equipped=False)
)
# Clear user's equipped_*_id
if item_type == ShopItemType.FRAME.value:
user.equipped_frame_id = None
elif item_type == ShopItemType.TITLE.value:
user.equipped_title_id = None
elif item_type == ShopItemType.NAME_COLOR.value:
user.equipped_name_color_id = None
elif item_type == ShopItemType.BACKGROUND.value:
user.equipped_background_id = None
async def check_user_owns_item(
self,
db: AsyncSession,
user_id: int,
item_id: int,
) -> bool:
"""Check if user owns an item"""
result = await db.execute(
select(UserInventory).where(
UserInventory.user_id == user_id,
UserInventory.item_id == item_id,
UserInventory.quantity > 0,
)
)
return result.scalar_one_or_none() is not None
# Singleton instance
shop_service = ShopService()

View File

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

View File

@@ -54,6 +54,209 @@ class TelegramNotifier:
logger.error(f"Error sending Telegram message: {e}")
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(
self,
db: AsyncSession,
@@ -83,9 +286,15 @@ class TelegramNotifier:
db: AsyncSession,
marathon_id: int,
message: str,
exclude_user_id: int | None = None
exclude_user_id: int | None = None,
check_setting: str | None = None
) -> 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(
select(User)
.join(Participant, Participant.user_id == User.id)
@@ -100,6 +309,10 @@ class TelegramNotifier:
for user in users:
if exclude_user_id and user.id == exclude_user_id:
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):
sent_count += 1
@@ -113,7 +326,7 @@ class TelegramNotifier:
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event start."""
"""Notify participants about event start (respects notify_events setting)."""
event_messages = {
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
@@ -128,7 +341,9 @@ class TelegramNotifier:
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(
self,
@@ -137,7 +352,7 @@ class TelegramNotifier:
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event end."""
"""Notify participants about event end (respects notify_events setting)."""
event_names = {
"golden_hour": "Golden Hour",
"jackpot": "Jackpot",
@@ -150,7 +365,9 @@ class TelegramNotifier:
event_name = event_names.get(event_type, "Событие")
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
return await self.notify_marathon_participants(db, marathon_id, message)
return await self.notify_marathon_participants(
db, marathon_id, message, check_setting='notify_events'
)
async def notify_marathon_start(
self,
@@ -186,7 +403,14 @@ class TelegramNotifier:
challenge_title: str,
assignment_id: int
) -> 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}")
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
@@ -227,7 +451,14 @@ class TelegramNotifier:
challenge_title: str,
is_valid: 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:
message = (
f"❌ <b>Спор признан обоснованным</b>\n\n"
@@ -251,7 +482,14 @@ class TelegramNotifier:
marathon_title: str,
game_title: str
) -> 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 = (
f"✅ <b>Твоя игра одобрена!</b>\n\n"
f"Марафон: {marathon_title}\n"
@@ -267,7 +505,14 @@ class TelegramNotifier:
marathon_title: str,
game_title: str
) -> 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 = (
f"❌ <b>Твоя игра отклонена</b>\n\n"
f"Марафон: {marathon_title}\n"
@@ -284,7 +529,14 @@ class TelegramNotifier:
game_title: str,
challenge_title: str
) -> 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 = (
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
f"Марафон: {marathon_title}\n"
@@ -302,7 +554,14 @@ class TelegramNotifier:
game_title: str,
challenge_title: str
) -> 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 = (
f"❌ <b>Твой челлендж отклонён</b>\n\n"
f"Марафон: {marathon_title}\n"
@@ -312,6 +571,43 @@ class TelegramNotifier:
)
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
telegram_notifier = TelegramNotifier()

View File

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

View File

@@ -4,7 +4,8 @@ Restore PostgreSQL database from S3 backup.
Usage:
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 os
@@ -62,7 +63,48 @@ def list_backups(s3_client) -> list[tuple[str, float, str]]:
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."""
key = f"{config.S3_BACKUP_PREFIX}{filename}"
@@ -79,6 +121,10 @@ def restore_backup(s3_client, filename: str) -> None:
print("Decompressing...")
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}...")
# Build psql command
@@ -124,20 +170,32 @@ def main() -> int:
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
backups = list_backups(s3_client)
if backups:
print(f"\nTo restore, run: python restore.py <filename>")
print("Add --no-clean to skip database cleanup before restore")
else:
print("No backups found.")
return 0
filename = sys.argv[1]
filename = args[0]
# Confirm restore
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()
confirm = input("Type 'yes' to continue: ")
@@ -147,7 +205,7 @@ def main() -> int:
return 0
try:
restore_backup(s3_client, filename)
restore_backup(s3_client, filename, clean_first=clean_first)
return 0
except Exception as e:
print(f"Restore failed: {e}")

View File

@@ -3,7 +3,7 @@ from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from keyboards.main_menu import get_main_menu
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard, get_settings_keyboard
from services.api_client import api_client
router = Router()
@@ -197,15 +197,66 @@ async def cmd_settings(message: Message):
)
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(
"<b>⚙️ Настройки</b>\n\n"
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
"Сейчас ты получаешь все уведомления:\n"
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
"• 🚀 Старт/финиш марафонов\n"
"• ⚠️ Споры по заданиям\n\n"
"Команды:\n"
"/unlink - Отвязать аккаунт\n"
"/status - Проверить привязку",
"<b>⚙️ Настройки уведомлений</b>\n\n"
"Нажми на категорию, чтобы включить/выключить:\n\n"
"✅ — уведомления включены\n"
"❌ — уведомления выключены\n\n"
"<i>Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.</i>",
reply_markup=get_settings_keyboard(settings)
)
@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()
)
await callback.answer()

View File

@@ -40,3 +40,45 @@ def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
]
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."""
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):
"""Close the HTTP session."""
if self._session and not self._session.closed:

View File

@@ -9,7 +9,7 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U marathon"]
interval: 5s
@@ -30,6 +30,7 @@ services:
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
BOT_API_SECRET: ${BOT_API_SECRET:-}
DEBUG: ${DEBUG:-false}
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
# S3 Storage
S3_ENABLED: ${S3_ENABLED:-false}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
@@ -42,7 +43,7 @@ services:
- ./backend/uploads:/app/uploads
- ./backend/app:/app/app
ports:
- "8000:8000"
- "8002:8000"
depends_on:
db:
condition: service_healthy
@@ -56,7 +57,7 @@ services:
VITE_API_URL: ${VITE_API_URL:-/api/v1}
container_name: marathon-frontend
ports:
- "3000:80"
- "3002:80"
depends_on:
- backend
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 { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui'
@@ -20,9 +21,12 @@ import { InvitePage } from '@/pages/InvitePage'
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage'
import { StaticContentPage } from '@/pages/StaticContentPage'
import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage'
import { ShopPage } from '@/pages/ShopPage'
import { InventoryPage } from '@/pages/InventoryPage'
// Admin Pages
import {
@@ -59,10 +63,15 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
function App() {
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
if (isAuthenticated && banInfo) {
// Sync user data with server on app load
useEffect(() => {
syncUser()
}, [syncUser])
// Show banned screen if user is banned (either authenticated or during login attempt)
if (banInfo) {
return (
<>
<ToastContainer />
@@ -82,6 +91,11 @@ function App() {
{/* Public invite page */}
<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
path="login"
element={
@@ -175,6 +189,25 @@ function App() {
<Route path="users/:id" element={<UserProfilePage />} />
{/* Shop routes */}
<Route
path="shop"
element={
<ProtectedRoute>
<ShopPage />
</ProtectedRoute>
}
/>
<Route
path="inventory"
element={
<ProtectedRoute>
<InventoryPage />
</ProtectedRoute>
}
/>
{/* Easter egg - 418 I'm a teapot */}
<Route path="418" element={<TeapotPage />} />
<Route path="teapot" element={<TeapotPage />} />

View File

@@ -7,7 +7,8 @@ import type {
AdminLogsResponse,
BroadcastResponse,
StaticContent,
DashboardStats
DashboardStats,
AdminDispute
} from '@/types'
export const adminApi = {
@@ -52,6 +53,13 @@ export const adminApi = {
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
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
const params: Record<string, unknown> = { skip, limit }
@@ -68,6 +76,14 @@ export const adminApi = {
await client.post(`/admin/marathons/${id}/force-finish`)
},
certifyMarathon: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/certify`)
},
revokeCertification: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/revoke-certification`)
},
// Stats
getStats: async (): Promise<PlatformStats> => {
const response = await client.get<PlatformStats>('/admin/stats')
@@ -84,13 +100,31 @@ export const adminApi = {
},
// Broadcast
broadcastToAll: async (message: string): Promise<BroadcastResponse> => {
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message })
broadcastToAll: async (message: string, media?: File[]): Promise<BroadcastResponse> => {
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
},
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => {
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message })
broadcastToMarathon: async (marathonId: number, message: string, media?: File[]): Promise<BroadcastResponse> => {
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
},
@@ -114,6 +148,23 @@ export const adminApi = {
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
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)

View File

@@ -1,5 +1,11 @@
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 = {
// Get detailed assignment info with proofs and dispute
@@ -14,6 +20,12 @@ export const assignmentsApi = {
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
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
@@ -44,4 +56,95 @@ export const assignmentsApi = {
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(
(response) => response,
(error: AxiosError<{ detail: string | BanInfo }>) => {
// Unauthorized - redirect to login
// Unauthorized - redirect to login (but not for auth endpoints)
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('user')
window.location.href = '/login'
}
}
// Forbidden - check if user is banned
if (error.response?.status === 403) {

View File

@@ -1,11 +1,28 @@
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 {
title: string
download_url: string
genre?: 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 {
@@ -45,6 +62,21 @@ export const gamesApi = {
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> => {
const response = await client.post<Game>(`/games/${id}/approve`)
return response.data

View File

@@ -9,3 +9,4 @@ export { challengesApi } from './challenges'
export { assignmentsApi } from './assignments'
export { usersApi } from './users'
export { telegramApi } from './telegram'
export { shopApi } from './shop'

View File

@@ -1,5 +1,5 @@
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 {
title: string
@@ -10,6 +10,8 @@ export interface CreateMarathonData {
game_proposal_mode?: GameProposalMode
}
export type { MarathonUpdate }
export const marathonsApi = {
list: async (): Promise<MarathonListItem[]> => {
const response = await client.get<MarathonListItem[]>('/marathons')
@@ -32,7 +34,7 @@ export const marathonsApi = {
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)
return response.data
},
@@ -78,4 +80,36 @@ export const marathonsApi = {
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
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
},
}

102
frontend/src/api/shop.ts Normal file
View File

@@ -0,0 +1,102 @@
import client from './client'
import type {
ShopItem,
ShopItemType,
InventoryItem,
PurchaseResponse,
UseConsumableRequest,
UseConsumableResponse,
CoinsBalance,
CoinTransaction,
ConsumablesStatus,
UserCosmetics,
} from '@/types'
export const shopApi = {
// === Каталог товаров ===
// Получить список товаров
getItems: async (itemType?: ShopItemType): Promise<ShopItem[]> => {
const params = itemType ? { item_type: itemType } : {}
const response = await client.get<ShopItem[]>('/shop/items', { params })
return response.data
},
// Получить товар по ID
getItem: async (itemId: number): Promise<ShopItem> => {
const response = await client.get<ShopItem>(`/shop/items/${itemId}`)
return response.data
},
// === Покупки ===
// Купить товар
purchase: async (itemId: number, quantity: number = 1): Promise<PurchaseResponse> => {
const response = await client.post<PurchaseResponse>('/shop/purchase', {
item_id: itemId,
quantity,
})
return response.data
},
// === Инвентарь ===
// Получить инвентарь пользователя
getInventory: async (itemType?: ShopItemType): Promise<InventoryItem[]> => {
const params = itemType ? { item_type: itemType } : {}
const response = await client.get<InventoryItem[]>('/shop/inventory', { params })
return response.data
},
// === Экипировка ===
// Экипировать предмет
equip: async (inventoryId: number): Promise<{ success: boolean; message: string }> => {
const response = await client.post<{ success: boolean; message: string }>('/shop/equip', {
inventory_id: inventoryId,
})
return response.data
},
// Снять предмет
unequip: async (itemType: ShopItemType): Promise<{ success: boolean; message: string }> => {
const response = await client.post<{ success: boolean; message: string }>(`/shop/unequip/${itemType}`)
return response.data
},
// Получить экипированную косметику
getCosmetics: async (): Promise<UserCosmetics> => {
const response = await client.get<UserCosmetics>('/shop/cosmetics')
return response.data
},
// === Расходуемые ===
// Использовать расходуемый предмет
useConsumable: async (data: UseConsumableRequest): Promise<UseConsumableResponse> => {
const response = await client.post<UseConsumableResponse>('/shop/use', data)
return response.data
},
// Получить статус расходуемых в марафоне
getConsumablesStatus: async (marathonId: number): Promise<ConsumablesStatus> => {
const response = await client.get<ConsumablesStatus>(`/shop/consumables/${marathonId}`)
return response.data
},
// === Монеты ===
// Получить баланс и последние транзакции
getBalance: async (): Promise<CoinsBalance> => {
const response = await client.get<CoinsBalance>('/shop/balance')
return response.data
},
// Получить историю транзакций
getTransactions: async (limit: number = 50, offset: number = 0): Promise<CoinTransaction[]> => {
const response = await client.get<CoinTransaction[]>('/shop/transactions', {
params: { limit, offset },
})
return response.data
},
}

View File

@@ -1,5 +1,5 @@
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 {
nickname: string
@@ -48,4 +48,16 @@ export const usersApi = {
})
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 (
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> => {
const formData = new FormData()
if (data.proof_url) formData.append('proof_url', data.proof_url)
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_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, {
headers: { 'Content-Type': 'multipart/form-data' },

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types'
import type { Activity, ActivityType, ShopItemPublic, User } from '@/types'
import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
import { UserAvatar } from '@/components/ui'
import {
@@ -12,6 +12,77 @@ import {
formatActivityMessage,
} from '@/utils/activity'
// Helper to get name color styles and animation class
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
if (!nameColor?.asset_data) return { styles: {}, className: '' }
const data = nameColor.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string }
if (data.style === 'gradient' && data.gradient) {
return {
styles: {
background: `linear-gradient(90deg, ${data.gradient.join(', ')})`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
},
className: '',
}
}
if (data.style === 'animated') {
return {
styles: {
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
backgroundSize: '400% 100%',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
},
className: 'animate-rainbow-rotate',
}
}
if (data.style === 'solid' && data.color) {
return { styles: { color: data.color }, className: '' }
}
return { styles: {}, className: '' }
}
// Helper to get title data
function getTitleData(title: ShopItemPublic | null | undefined): { text: string; color: string } | null {
if (!title?.asset_data) return null
const data = title.asset_data as { text?: string; color?: string }
if (!data.text) return null
return {
text: data.text,
color: data.color || '#ffffff',
}
}
// Styled nickname component for activity feed
function StyledNickname({ user }: { user: User }) {
const nameColorData = getNameColorData(user.equipped_name_color)
const titleData = getTitleData(user.equipped_title)
return (
<>
<span className={nameColorData.className} style={nameColorData.styles}>{user.nickname}</span>
{titleData && (
<span
className="ml-1.5 px-1 py-0.5 text-[10px] font-medium rounded bg-dark-700/50"
style={{ color: titleData.color }}
>
{titleData.text}
</span>
)}
</>
)
}
interface ActivityFeedProps {
marathonId: number
className?: string
@@ -191,14 +262,15 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
const isEvent = isEventActivity(activity.type)
const { title, details, extra } = formatActivityMessage(activity)
// Get assignment_id and dispute status for complete activities
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null
// Get assignment_id, dispute status, and is_redo for complete activities
const activityData = activity.data as { assignment_id?: number; dispute_status?: string; is_redo?: boolean } | null
const assignmentId = activity.type === 'complete' && activityData?.assignment_id
? activityData.assignment_id
: null
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
? activityData.dispute_status
: null
const isRedo = activity.type === 'complete' && activityData?.is_redo === true
// Determine accent color based on activity type
const getAccentConfig = () => {
@@ -272,6 +344,8 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
hasAvatar={!!activity.user.avatar_url}
nickname={activity.user.nickname}
size="sm"
frame={activity.user.equipped_frame}
telegramAvatarUrl={activity.user.telegram_avatar_url}
/>
{/* Activity type badge */}
<div className={`
@@ -291,10 +365,10 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
<div className="flex items-center gap-2 flex-wrap">
<Link
to={`/users/${activity.user.id}`}
className="text-sm font-semibold text-white hover:text-neon-400 transition-colors"
className="text-sm font-semibold hover:text-neon-400 transition-colors"
onClick={(e) => e.stopPropagation()}
>
{activity.user.nickname}
<StyledNickname user={activity.user} />
</Link>
<span className="text-xs text-gray-600">
{formatRelativeTime(activity.created_at)}
@@ -323,6 +397,12 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
<ExternalLink className="w-3 h-3" />
Детали
</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' && (
<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" />

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 {
banInfo: BanInfo
onLogout?: () => void
}
function formatDate(dateStr: string | null) {
@@ -24,8 +25,9 @@ function formatDate(dateStr: string | null) {
}) + ' (МСК)'
}
export function BannedScreen({ banInfo }: BannedScreenProps) {
const logout = useAuthStore((state) => state.logout)
export function BannedScreen({ banInfo, onLogout }: BannedScreenProps) {
const storeLogout = useAuthStore((state) => state.logout)
const handleLogout = onLogout || storeLogout
const bannedAtFormatted = formatDate(banInfo.banned_at)
const bannedUntilFormatted = formatDate(banInfo.banned_until)
@@ -112,7 +114,7 @@ export function BannedScreen({ banInfo }: BannedScreenProps) {
<NeonButton
variant="secondary"
size="lg"
onClick={logout}
onClick={handleLogout}
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 { Gamepad2, Loader2 } from 'lucide-react'
@@ -9,27 +9,43 @@ interface SpinWheelProps {
disabled?: boolean
}
const SPIN_DURATION = 5000 // ms
const EXTRA_ROTATIONS = 5
const SPIN_DURATION = 6000 // ms - увеличено для более плавного замедления
const EXTRA_ROTATIONS = 7 // больше оборотов для эффекта инерции
// Цветовая палитра секторов
// Пороги для адаптивного отображения
const TEXT_THRESHOLD = 16 // До 16 игр - показываем текст
const LINES_THRESHOLD = 40 // До 40 игр - показываем разделители
// Цветовая палитра секторов (расширенная для большего количества)
const SECTOR_COLORS = [
{ bg: '#0d9488', border: '#14b8a6' }, // teal
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet
{ bg: '#0891b2', border: '#06b6d4' }, // cyan
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia
{ bg: '#059669', border: '#10b981' }, // emerald
{ bg: '#7c2d12', border: '#ea580c' }, // orange
{ bg: '#ea580c', border: '#f97316' }, // orange
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue
{ 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) {
const [isSpinning, setIsSpinning] = useState(false)
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 centerY = wheelSize / 2
const radius = wheelSize / 2 - 10
@@ -102,11 +118,16 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
const fullRotations = EXTRA_ROTATIONS * 360
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
setRotation(rotation + finalAngle)
const newRotation = rotation + finalAngle
setStartRotation(rotation)
setTargetRotation(newRotation)
setSpinStartTime(Date.now())
setRotation(newRotation)
// Ждём окончания анимации
setTimeout(() => {
setIsSpinning(false)
setSpinStartTime(null)
onSpinComplete(resultGame)
}, SPIN_DURATION)
}, [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) + '...'
}
// Функция для вычисления игры под указателем по углу
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(() => {
return games.map((game, index) => {
const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
const path = createSectorPath(index, games.length)
const textPos = getTextPosition(index, games.length)
const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18
const maxTextLength = games.length > 12 ? 8 : games.length > 8 ? 10 : games.length > 5 ? 14 : 18
return { game, color, path, textPos, maxTextLength }
})
@@ -213,7 +288,8 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
transform: `rotate(${rotation}deg)`,
transitionProperty: isSpinning ? 'transform' : 'none',
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
transitionTimingFunction: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)',
// Инерционное вращение: быстрый старт, долгое плавное замедление
transitionTimingFunction: 'cubic-bezier(0.12, 0.9, 0.15, 1)',
}}
>
<defs>
@@ -230,12 +306,13 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
<path
d={path}
fill={color.bg}
stroke={color.border}
strokeWidth="2"
stroke={showLines ? color.border : 'transparent'}
strokeWidth={showLines ? "1" : "0"}
filter="url(#sectorShadow)"
/>
{/* Текст названия игры */}
{/* Текст названия игры - только для небольшого количества */}
{showText && (
<text
x={textPos.x}
y={textPos.y}
@@ -243,7 +320,7 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
textAnchor="middle"
dominantBaseline="middle"
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"
style={{
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)}
</text>
)}
{/* Разделительная линия */}
{/* Разделительная линия - только для среднего количества */}
{showLines && (
<line
x1={centerX}
y1={centerY}
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
stroke="rgba(255,255,255,0.3)"
stroke="rgba(255,255,255,0.2)"
strokeWidth="1"
/>
)}
</g>
))}
@@ -322,6 +402,21 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
)}
</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={`
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

@@ -1,12 +1,13 @@
import { useState, useEffect } from 'react'
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield } from 'lucide-react'
import { TelegramLink } from '@/components/TelegramLink'
import { useShopStore } from '@/store/shop'
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield, ShoppingBag, Coins, Backpack } from 'lucide-react'
import { clsx } from 'clsx'
export function Layout() {
const { user, isAuthenticated, logout } = useAuthStore()
const { balance, loadBalance } = useShopStore()
const navigate = useNavigate()
const location = useLocation()
const [isScrolled, setIsScrolled] = useState(false)
@@ -20,6 +21,13 @@ export function Layout() {
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Load balance when authenticated
useEffect(() => {
if (isAuthenticated) {
loadBalance()
}
}, [isAuthenticated, loadBalance])
// Close mobile menu on route change
useEffect(() => {
setIsMobileMenuOpen(false)
@@ -74,6 +82,19 @@ export function Layout() {
<span>Марафоны</span>
</Link>
<Link
to="/shop"
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
isActiveLink('/shop')
? 'text-yellow-400 bg-yellow-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<ShoppingBag className="w-5 h-5" />
<span>Магазин</span>
</Link>
{user?.role === 'admin' && (
<Link
to="/admin"
@@ -89,7 +110,7 @@ export function Layout() {
</Link>
)}
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
<div className="flex items-center gap-2 ml-2 pl-4 border-l border-dark-600">
<Link
to="/profile"
className={clsx(
@@ -101,9 +122,24 @@ export function Layout() {
>
<User className="w-5 h-5" />
<span>{user?.nickname}</span>
<span className="flex items-center gap-1 text-yellow-400 ml-1">
<Coins className="w-4 h-4" />
<span className="font-medium">{balance}</span>
</span>
</Link>
<TelegramLink />
<Link
to="/inventory"
className={clsx(
'p-2 rounded-lg transition-all duration-200',
isActiveLink('/inventory')
? 'text-yellow-400 bg-yellow-500/10'
: 'text-gray-400 hover:text-yellow-400 hover:bg-yellow-500/10'
)}
title="Инвентарь"
>
<Backpack className="w-5 h-5" />
</Link>
<button
onClick={handleLogout}
@@ -159,6 +195,18 @@ export function Layout() {
<Trophy className="w-5 h-5" />
<span>Марафоны</span>
</Link>
<Link
to="/shop"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
isActiveLink('/shop')
? 'text-yellow-400 bg-yellow-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<ShoppingBag className="w-5 h-5" />
<span>Магазин</span>
</Link>
{user?.role === 'admin' && (
<Link
to="/admin"
@@ -184,6 +232,22 @@ export function Layout() {
>
<User className="w-5 h-5" />
<span>{user?.nickname}</span>
<span className="flex items-center gap-1 text-yellow-400 ml-auto">
<Coins className="w-4 h-4" />
<span className="font-medium">{balance}</span>
</span>
</Link>
<Link
to="/inventory"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
isActiveLink('/inventory')
? 'text-yellow-400 bg-yellow-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<Backpack className="w-5 h-5" />
<span>Инвентарь</span>
</Link>
<div className="pt-2 border-t border-dark-600">
<button
@@ -234,7 +298,13 @@ export function Layout() {
Игровой Марафон &copy; {new Date().getFullYear()}
</span>
</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>
</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 = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
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]
return (
@@ -118,13 +128,9 @@ export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
{...props}
>
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
{!isLoading && icon && iconPosition === 'left' && (
<span className={iconSizes[size]}>{icon}</span>
)}
{!isLoading && icon && iconPosition === 'left' && icon}
{children}
{!isLoading && icon && iconPosition === 'right' && (
<span className={iconSizes[size]}>{icon}</span>
)}
{!isLoading && icon && iconPosition === 'right' && icon}
</button>
)
}

View File

@@ -1,5 +1,8 @@
import { useState, useEffect } from 'react'
import { usersApi } from '@/api'
import { User } from 'lucide-react'
import clsx from 'clsx'
import type { ShopItemPublic } from '@/types'
// Глобальный кэш для blob URL аватарок
const avatarCache = new Map<number, string>()
@@ -10,18 +13,77 @@ interface UserAvatarProps {
userId: number
hasAvatar: boolean // Есть ли у пользователя avatar_url
nickname: string
size?: 'sm' | 'md' | 'lg'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
className?: string
version?: number // Для принудительного обновления при смене аватара
frame?: ShopItemPublic | null // Equipped frame cosmetic
telegramAvatarUrl?: string | null // Fallback to telegram avatar
}
const sizeClasses = {
xs: 'w-6 h-6 text-[8px]',
sm: 'w-8 h-8 text-xs',
md: 'w-12 h-12 text-sm',
lg: 'w-24 h-24 text-xl',
lg: 'w-16 h-16 text-base',
xl: 'w-24 h-24 text-xl',
}
export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '', version = 0 }: UserAvatarProps) {
const framePadding = {
xs: 2,
sm: 2,
md: 3,
lg: 4,
xl: 5,
}
// Get frame styles from asset_data
function getFrameStyles(frame: ShopItemPublic | null | undefined): React.CSSProperties {
if (!frame?.asset_data) return {}
const data = frame.asset_data as {
border_color?: string
gradient?: string[]
glow_color?: string
}
const styles: React.CSSProperties = {}
if (data.gradient && data.gradient.length > 0) {
styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
styles.backgroundSize = '400% 400%'
} else if (data.border_color) {
styles.background = data.border_color
}
if (data.glow_color) {
styles.boxShadow = `0 0 12px ${data.glow_color}, 0 0 24px ${data.glow_color}40`
}
return styles
}
// Get frame animation class
function getFrameAnimation(frame: ShopItemPublic | null | undefined): string {
if (!frame?.asset_data) return ''
const data = frame.asset_data as { animation?: string }
if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
return ''
}
export function UserAvatar({
userId,
hasAvatar,
nickname,
size = 'md',
className = '',
version = 0,
frame,
telegramAvatarUrl
}: UserAvatarProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
@@ -74,25 +136,54 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
}, [userId, hasAvatar, version])
const sizeClass = sizeClasses[size]
const displayUrl = (blobUrl && !failed) ? blobUrl : telegramAvatarUrl
if (blobUrl && !failed) {
return (
// Avatar content
const avatarContent = displayUrl ? (
<img
src={blobUrl}
src={displayUrl}
alt={nickname}
className={`rounded-full object-cover ${sizeClass} ${className}`}
className="w-full h-full rounded-full object-cover"
/>
)
}
// Fallback - первая буква никнейма
return (
<div className={`rounded-full bg-gray-700 flex items-center justify-center ${sizeClass} ${className}`}>
) : (
<div className="w-full h-full rounded-full bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center">
<span className="text-gray-400 font-medium">
{nickname.charAt(0).toUpperCase()}
</span>
</div>
)
// If no frame, return simple avatar
if (!frame) {
return (
<div className={clsx('rounded-full overflow-hidden bg-dark-700', sizeClass, className)}>
{avatarContent}
</div>
)
}
// With frame - wrap avatar in frame container
const padding = framePadding[size]
return (
<div
className={clsx(
'rounded-full flex items-center justify-center',
getFrameAnimation(frame),
className
)}
style={{
...getFrameStyles(frame),
padding: `${padding}px`,
width: 'fit-content',
height: 'fit-content',
}}
>
<div className={clsx('rounded-full overflow-hidden bg-dark-700', sizeClass)}>
{avatarContent}
</div>
</div>
)
}
// Функция для очистки кэша конкретного пользователя (после загрузки нового аватара)
@@ -105,3 +196,55 @@ export function clearAvatarCache(userId: number) {
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
needsCacheBust.add(userId)
}
// FramePreview component for shop - shows frame without avatar
interface FramePreviewProps {
frame: ShopItemPublic | null
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
className?: string
}
const previewSizes = {
xs: 'w-8 h-8',
sm: 'w-10 h-10',
md: 'w-14 h-14',
lg: 'w-20 h-20',
xl: 'w-28 h-28',
}
export function FramePreview({ frame, size = 'md', className }: FramePreviewProps) {
if (!frame?.asset_data) {
return (
<div className={clsx(
previewSizes[size],
'rounded-lg border-4 border-gray-600 flex items-center justify-center bg-dark-800',
className
)}>
<User className="w-1/2 h-1/2 text-gray-500" />
</div>
)
}
const padding = framePadding[size]
return (
<div
className={clsx(
'rounded-lg flex items-center justify-center',
getFrameAnimation(frame),
className
)}
style={{
...getFrameStyles(frame),
padding: `${padding}px`,
}}
>
<div className={clsx(
previewSizes[size],
'rounded-md bg-dark-800/90 flex items-center justify-center'
)}>
<User className="w-1/2 h-1/2 text-gray-400" />
</div>
</div>
)
}

View File

@@ -3,7 +3,7 @@ export { Input } from './Input'
export { Card, CardHeader, CardTitle, CardContent } from './Card'
export { ToastContainer } from './Toast'
export { ConfirmModal } from './ConfirmModal'
export { UserAvatar, clearAvatarCache } from './UserAvatar'
export { UserAvatar, clearAvatarCache, FramePreview } from './UserAvatar'
// New design system components
export { GlitchText, GlitchHeading } from './GlitchText'

View File

@@ -571,6 +571,125 @@ input:-webkit-autofill:active {
@apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900;
}
/* ========================================
Frame Animations (Shop cosmetics)
======================================== */
/* Fire pulse animation */
@keyframes fire-pulse {
0%, 100% {
background-size: 200% 200%;
background-position: 0% 50%;
filter: brightness(1);
}
50% {
background-size: 220% 220%;
background-position: 100% 50%;
filter: brightness(1.2);
}
}
.animate-fire-pulse {
animation: fire-pulse 2s ease-in-out infinite;
}
/* Rainbow rotate animation */
@keyframes rainbow-rotate {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animate-rainbow-rotate {
animation: rainbow-rotate 3s linear infinite;
background-size: 400% 400%;
}
/* Rainbow text color shift */
@keyframes rainbow-shift {
0% { color: #FF0000; }
16% { color: #FF7F00; }
33% { color: #FFFF00; }
50% { color: #00FF00; }
66% { color: #0000FF; }
83% { color: #9400D3; }
100% { color: #FF0000; }
}
.animate-rainbow-shift {
animation: rainbow-shift 4s linear infinite;
}
/* Fire particles background animation */
@keyframes fire-particles {
0%, 100% {
background-position: 0% 100%;
}
50% {
background-position: 100% 0%;
}
}
.animate-fire-particles {
animation: fire-particles 3s ease-in-out infinite;
}
/* Star twinkle animation */
@keyframes twinkle {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.animate-twinkle {
animation: twinkle 2s ease-in-out infinite;
}
/* Stars background with multiple twinkling layers */
.bg-stars-animated {
position: relative;
background: linear-gradient(135deg, #0d1b2a 0%, #1b263b 50%, #0d1b2a 100%);
}
.bg-stars-animated::before,
.bg-stars-animated::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
}
.bg-stars-animated::before {
background:
radial-gradient(2px 2px at 20px 30px, #fff, transparent),
radial-gradient(2px 2px at 80px 60px, rgba(255,255,255,0.9), transparent),
radial-gradient(1px 1px at 130px 40px, #fff, transparent),
radial-gradient(2px 2px at 180px 90px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 50px 100px, #fff, transparent),
radial-gradient(1.5px 1.5px at 220px 20px, rgba(255,255,255,0.9), transparent);
background-size: 250px 150px;
animation: twinkle 3s ease-in-out infinite;
}
.bg-stars-animated::after {
background:
radial-gradient(1px 1px at 40px 20px, rgba(255,255,255,0.7), transparent),
radial-gradient(2px 2px at 100px 80px, #fff, transparent),
radial-gradient(1.5px 1.5px at 160px 30px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 200px 70px, #fff, transparent),
radial-gradient(2px 2px at 70px 110px, rgba(255,255,255,0.9), transparent);
background-size: 220px 140px;
animation: twinkle 4s ease-in-out infinite 1s;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
*,

View File

@@ -8,7 +8,7 @@ import { useToast } from '@/store/toast'
import {
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
Send, Flag, Gamepad2, Zap, Trophy
Send, Flag, Gamepad2, Zap, Trophy, Download, ChevronLeft, ChevronRight, X
} from 'lucide-react'
export function AssignmentDetailPage() {
@@ -23,11 +23,30 @@ export function AssignmentDetailPage() {
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | 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
const [showDisputeForm, setShowDisputeForm] = useState(false)
const [disputeReason, setDisputeReason] = useState('')
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
const [commentText, setCommentText] = useState('')
const [isAddingComment, setIsAddingComment] = useState(false)
@@ -38,10 +57,24 @@ export function AssignmentDetailPage() {
useEffect(() => {
loadAssignment()
return () => {
// Cleanup blob URL on unmount
// Cleanup blob URLs on unmount
if (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])
@@ -53,8 +86,20 @@ export function AssignmentDetailPage() {
const data = await assignmentsApi.getDetail(parseInt(id))
setAssignment(data)
// Load proof media if exists
if (data.proof_image_url) {
// Load proof files if exists (new multi-file support)
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 {
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
setProofMediaBlobUrl(url)
@@ -63,6 +108,39 @@ export function AssignmentDetailPage() {
// 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) {
const error = err as { response?: { data?: { detail?: string } } }
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) => {
if (!assignment?.dispute) return
@@ -142,6 +251,24 @@ export function AssignmentDetailPage() {
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) => {
switch (status) {
case 'completed':
@@ -215,31 +342,54 @@ export function AssignmentDetailPage() {
</div>
</div>
{/* Challenge info */}
{/* Challenge/Playthrough info */}
<GlassCard variant="neon">
<div className="flex items-start justify-between mb-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">
<Gamepad2 className="w-7 h-7 text-neon-400" />
<div className={`w-14 h-14 rounded-xl border flex items-center justify-center ${
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>
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
<p className="text-gray-400 text-sm">
{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 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}`}>
{status.icon}
{status.text}
</span>
</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">
<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" />
+{assignment.challenge.points} очков
+{assignment.is_playthrough
? assignment.playthrough_info?.points
: assignment.challenge?.points} очков
</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">
{assignment.challenge.difficulty}
</span>
@@ -249,6 +399,20 @@ export function AssignmentDetailPage() {
~{assignment.challenge.estimated_time} мин
</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 className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1">
@@ -271,6 +435,224 @@ export function AssignmentDetailPage() {
</div>
</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 */}
<GlassCard>
<div className="flex items-center gap-3 mb-4">
@@ -283,8 +665,47 @@ export function AssignmentDetailPage() {
</div>
</div>
{/* Proof media (image or video) */}
{assignment.proof_image_url && (
{/* Proof files gallery (multiple proofs) */}
{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">
{proofMediaBlobUrl ? (
proofMediaType === 'video' ? (
@@ -295,11 +716,16 @@ export function AssignmentDetailPage() {
preload="metadata"
/>
) : (
<button
onClick={() => openLightbox([{ url: proofMediaBlobUrl, type: 'image' }], 0)}
className="w-full"
>
<img
src={proofMediaBlobUrl}
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">
@@ -332,7 +758,7 @@ export function AssignmentDetailPage() {
</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="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" />
@@ -548,6 +974,69 @@ export function AssignmentDetailPage() {
</div>
</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>
)
}

View File

@@ -1,12 +1,13 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { marathonsApi } from '@/api'
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 { useToast } from '@/store/toast'
const createSchema = z.object({
title: z.string().min(1, 'Название обязательно').max(100),
@@ -21,8 +22,12 @@ type CreateForm = z.infer<typeof createSchema>
export function CreateMarathonPage() {
const navigate = useNavigate()
const toast = useToast()
const [isLoading, setIsLoading] = useState(false)
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 {
register,
@@ -42,6 +47,38 @@ export function CreateMarathonPage() {
const isPublic = watch('is_public')
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) => {
setIsLoading(true)
setError(null)
@@ -54,6 +91,16 @@ export function CreateMarathonPage() {
is_public: data.is_public,
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`)
} catch (err: unknown) {
const apiError = err as { response?: { data?: { detail?: string } } }
@@ -94,6 +141,57 @@ export function CreateMarathonPage() {
</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 */}
<div className="space-y-4">
<Input

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