Compare commits
36 Commits
feature/Se
...
e63d6c8489
| Author | SHA1 | Date | |
|---|---|---|---|
| e63d6c8489 | |||
| 1751c4dd4c | |||
| 2874b64481 | |||
| 4488a13808 | |||
| ca49e42f74 | |||
| 18fe95effc | |||
| 6a7717a474 | |||
| 65b2512d8c | |||
| 81d992abe6 | |||
| 9014d5d79d | |||
| 18ffff5473 | |||
| 475e2cf4cd | |||
| 7a3576aec0 | |||
| d295ff2aff | |||
| 1e751f7af3 | |||
| 89dbe2c018 | |||
| 1cedfeb3ee | |||
| 1e723e7bcd | |||
| a513dc2207 | |||
| 6bc35fc0bb | |||
| d3adf07c3f | |||
| 921917a319 | |||
| 9d2dba87b8 | |||
| 95e2a77335 | |||
| 6c824712c9 | |||
| 5c073705d8 | |||
| 243abe55b5 | |||
| c645171671 | |||
| 07745ea4ed | |||
| 22385e8742 | |||
| a77a757317 | |||
| 2d281d1c8c | |||
| 13f484e726 | |||
| ebaf6d39ea | |||
| 481bdabaa8 | |||
| 8e634994bd |
@@ -32,3 +32,5 @@ PUBLIC_URL=https://your-domain.com
|
||||
|
||||
# Frontend (for build)
|
||||
VITE_API_URL=/api/v1
|
||||
|
||||
RATE_LIMIT_ENABLED=false
|
||||
6
Makefile
6
Makefile
@@ -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
|
||||
|
||||
348
REDESIGN_PLAN.md
348
REDESIGN_PLAN.md
@@ -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
|
||||
|
||||
---
|
||||
```А ты
|
||||
|
||||
## Референсы для вдохновления
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
""")
|
||||
|
||||
@@ -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')
|
||||
|
||||
40
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
40
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Add challenge proposals support
|
||||
|
||||
Revision ID: 011_add_challenge_proposals
|
||||
Revises: 010_add_telegram_profile
|
||||
Create Date: 2024-12-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '011_add_challenge_proposals'
|
||||
down_revision: Union[str, None] = '010_add_telegram_profile'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def 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')
|
||||
48
backend/alembic/versions/012_add_user_banned.py
Normal file
48
backend/alembic/versions/012_add_user_banned.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Add user banned fields
|
||||
|
||||
Revision ID: 012_add_user_banned
|
||||
Revises: 011_add_challenge_proposals
|
||||
Create Date: 2024-12-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '012_add_user_banned'
|
||||
down_revision: Union[str, None] = '011_add_challenge_proposals'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def 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')
|
||||
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Add admin_logs table
|
||||
|
||||
Revision ID: 013_add_admin_logs
|
||||
Revises: 012_add_user_banned
|
||||
Create Date: 2024-12-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '013_add_admin_logs'
|
||||
down_revision: Union[str, None] = '012_add_user_banned'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx['name'] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not table_exists('admin_logs'):
|
||||
op.create_table(
|
||||
'admin_logs',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('admin_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('action', sa.String(50), nullable=False),
|
||||
sa.Column('target_type', sa.String(50), nullable=False),
|
||||
sa.Column('target_id', sa.Integer(), nullable=False),
|
||||
sa.Column('details', sa.JSON(), nullable=True),
|
||||
sa.Column('ip_address', sa.String(50), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
if not index_exists('admin_logs', 'ix_admin_logs_admin_id'):
|
||||
op.create_index('ix_admin_logs_admin_id', 'admin_logs', ['admin_id'])
|
||||
if not index_exists('admin_logs', 'ix_admin_logs_action'):
|
||||
op.create_index('ix_admin_logs_action', 'admin_logs', ['action'])
|
||||
if not index_exists('admin_logs', 'ix_admin_logs_created_at'):
|
||||
op.create_index('ix_admin_logs_created_at', 'admin_logs', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_admin_logs_created_at', 'admin_logs')
|
||||
op.drop_index('ix_admin_logs_action', 'admin_logs')
|
||||
op.drop_index('ix_admin_logs_admin_id', 'admin_logs')
|
||||
op.drop_table('admin_logs')
|
||||
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Add admin_2fa_sessions table
|
||||
|
||||
Revision ID: 014_add_admin_2fa
|
||||
Revises: 013_add_admin_logs
|
||||
Create Date: 2024-12-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '014_add_admin_2fa'
|
||||
down_revision: Union[str, None] = '013_add_admin_logs'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx['name'] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not table_exists('admin_2fa_sessions'):
|
||||
op.create_table(
|
||||
'admin_2fa_sessions',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('code', sa.String(6), nullable=False),
|
||||
sa.Column('telegram_sent', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('is_verified', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_user_id'):
|
||||
op.create_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions', ['user_id'])
|
||||
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_expires_at'):
|
||||
op.create_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions', ['expires_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions')
|
||||
op.drop_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions')
|
||||
op.drop_table('admin_2fa_sessions')
|
||||
54
backend/alembic/versions/015_add_static_content.py
Normal file
54
backend/alembic/versions/015_add_static_content.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Add static_content table
|
||||
|
||||
Revision ID: 015_add_static_content
|
||||
Revises: 014_add_admin_2fa
|
||||
Create Date: 2024-12-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '015_add_static_content'
|
||||
down_revision: Union[str, None] = '014_add_admin_2fa'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx['name'] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not table_exists('static_content'):
|
||||
op.create_table(
|
||||
'static_content',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('key', sa.String(100), unique=True, nullable=False),
|
||||
sa.Column('title', sa.String(200), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('updated_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
if not index_exists('static_content', 'ix_static_content_key'):
|
||||
op.create_index('ix_static_content_key', 'static_content', ['key'], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_static_content_key', 'static_content')
|
||||
op.drop_table('static_content')
|
||||
36
backend/alembic/versions/016_add_banned_until.py
Normal file
36
backend/alembic/versions/016_add_banned_until.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Add banned_until field
|
||||
|
||||
Revision ID: 016_add_banned_until
|
||||
Revises: 015_add_static_content
|
||||
Create Date: 2024-12-19
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '016_add_banned_until'
|
||||
down_revision: Union[str, None] = '015_add_static_content'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists('users', 'banned_until'):
|
||||
op.add_column('users', sa.Column('banned_until', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists('users', 'banned_until'):
|
||||
op.drop_column('users', 'banned_until')
|
||||
47
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
47
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Make admin_id nullable in admin_logs for system actions
|
||||
|
||||
Revision ID: 017_admin_logs_nullable_admin_id
|
||||
Revises: 016_add_banned_until
|
||||
Create Date: 2024-12-19
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '017_admin_logs_nullable_admin_id'
|
||||
down_revision: Union[str, None] = '016_add_banned_until'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def 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)
|
||||
|
||||
|
||||
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)
|
||||
346
backend/alembic/versions/018_seed_static_content.py
Normal file
346
backend/alembic/versions/018_seed_static_content.py
Normal 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)})")
|
||||
43
backend/alembic/versions/019_add_marathon_cover.py
Normal file
43
backend/alembic/versions/019_add_marathon_cover.py
Normal 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')
|
||||
156
backend/alembic/versions/020_add_game_types.py
Normal file
156
backend/alembic/versions/020_add_game_types.py
Normal 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')
|
||||
100
backend/alembic/versions/021_add_bonus_disputes.py
Normal file
100
backend/alembic/versions/021_add_bonus_disputes.py
Normal 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')
|
||||
45
backend/alembic/versions/022_add_notification_settings.py
Normal file
45
backend/alembic/versions/022_add_notification_settings.py
Normal 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')
|
||||
230
backend/alembic/versions/023_add_shop_system.py
Normal file
230
backend/alembic/versions/023_add_shop_system.py
Normal 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')
|
||||
495
backend/alembic/versions/024_seed_shop_items.py
Normal file
495
backend/alembic/versions/024_seed_shop_items.py
Normal 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 на текущее задание',
|
||||
'price': 200,
|
||||
'rarity': 'rare',
|
||||
'asset_data': {
|
||||
'effect': 'boost',
|
||||
'multiplier': 1.5,
|
||||
'one_time': True,
|
||||
'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')")
|
||||
52
backend/alembic/versions/025_simplify_boost_consumable.py
Normal file
52
backend/alembic/versions/025_simplify_boost_consumable.py
Normal 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')
|
||||
46
backend/alembic/versions/026_update_boost_description.py
Normal file
46
backend/alembic/versions/026_update_boost_description.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Update boost description to one-time usage
|
||||
|
||||
Revision ID: 026_update_boost_desc
|
||||
Revises: 025_simplify_boost
|
||||
Create Date: 2026-01-08
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '026_update_boost_desc'
|
||||
down_revision: Union[str, None] = '025_simplify_boost'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Update boost description in shop_items table
|
||||
op.execute("""
|
||||
UPDATE shop_items
|
||||
SET description = 'Множитель очков x1.5 на текущее задание',
|
||||
asset_data = jsonb_set(
|
||||
asset_data::jsonb - 'duration_hours',
|
||||
'{one_time}',
|
||||
'true'
|
||||
)
|
||||
WHERE code = 'boost' AND item_type = 'consumable'
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert boost description
|
||||
op.execute("""
|
||||
UPDATE shop_items
|
||||
SET description = 'Множитель очков x1.5 на следующие 2 часа',
|
||||
asset_data = jsonb_set(
|
||||
asset_data::jsonb - 'one_time',
|
||||
'{duration_hours}',
|
||||
'2'
|
||||
)
|
||||
WHERE code = 'boost' AND item_type = 'consumable'
|
||||
""")
|
||||
83
backend/alembic/versions/027_consumables_redesign.py
Normal file
83
backend/alembic/versions/027_consumables_redesign.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Consumables redesign: remove shield/reroll, add wild_card/lucky_dice/copycat/undo
|
||||
|
||||
Revision ID: 027_consumables_redesign
|
||||
Revises: 026_update_boost_desc
|
||||
Create Date: 2026-01-08
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from datetime import datetime
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '027_consumables_redesign'
|
||||
down_revision: Union[str, None] = '026_update_boost_desc'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Remove has_shield column from participants
|
||||
op.drop_column('participants', 'has_shield')
|
||||
|
||||
# 2. Add new columns for lucky_dice and undo
|
||||
op.add_column('participants', sa.Column('has_lucky_dice', sa.Boolean(), nullable=False, server_default='false'))
|
||||
op.add_column('participants', sa.Column('lucky_dice_multiplier', sa.Float(), nullable=True))
|
||||
op.add_column('participants', sa.Column('last_drop_points', sa.Integer(), nullable=True))
|
||||
op.add_column('participants', sa.Column('last_drop_streak_before', sa.Integer(), nullable=True))
|
||||
op.add_column('participants', sa.Column('can_undo', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
# 3. Remove old consumables from shop
|
||||
op.execute("DELETE FROM shop_items WHERE code IN ('reroll', 'shield')")
|
||||
|
||||
# 4. Update boost price from 200 to 150
|
||||
op.execute("UPDATE shop_items SET price = 150 WHERE code = 'boost'")
|
||||
|
||||
# 5. Add new consumables to shop
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
op.execute(f"""
|
||||
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
|
||||
VALUES
|
||||
('consumable', 'wild_card', 'Дикая карта', 'Выбери игру и получи случайное задание из неё', 150, 'uncommon',
|
||||
'{{"effect": "wild_card", "icon": "shuffle"}}', true, '{now}'),
|
||||
('consumable', 'lucky_dice', 'Счастливые кости', 'Случайный множитель очков (1.5x - 4.0x)', 250, 'rare',
|
||||
'{{"effect": "lucky_dice", "multipliers": [1.5, 2.0, 2.5, 3.0, 3.5, 4.0], "icon": "dice"}}', true, '{now}'),
|
||||
('consumable', 'copycat', 'Копикэт', 'Скопируй задание любого участника марафона', 300, 'epic',
|
||||
'{{"effect": "copycat", "icon": "copy"}}', true, '{now}'),
|
||||
('consumable', 'undo', 'Отмена', 'Отмени последний дроп и верни очки со стриком', 300, 'epic',
|
||||
'{{"effect": "undo", "icon": "undo"}}', true, '{now}')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. Remove new columns
|
||||
op.drop_column('participants', 'can_undo')
|
||||
op.drop_column('participants', 'last_drop_streak_before')
|
||||
op.drop_column('participants', 'last_drop_points')
|
||||
op.drop_column('participants', 'lucky_dice_multiplier')
|
||||
op.drop_column('participants', 'has_lucky_dice')
|
||||
|
||||
# 2. Add back has_shield
|
||||
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
# 3. Remove new consumables
|
||||
op.execute("DELETE FROM shop_items WHERE code IN ('wild_card', 'lucky_dice', 'copycat', 'undo')")
|
||||
|
||||
# 4. Restore boost price back to 200
|
||||
op.execute("UPDATE shop_items SET price = 200 WHERE code = 'boost'")
|
||||
|
||||
# 5. Add back old consumables
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
op.execute(f"""
|
||||
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
|
||||
VALUES
|
||||
('consumable', 'shield', 'Щит', 'Защита от штрафа при следующем дропе. Streak сохраняется.', 150, 'uncommon',
|
||||
'{{"effect": "shield", "icon": "shield"}}', true, '{now}'),
|
||||
('consumable', 'reroll', 'Перекрут', 'Перекрутить колесо и получить новое задание', 80, 'common',
|
||||
'{{"effect": "reroll", "icon": "refresh-cw"}}', true, '{now}')
|
||||
""")
|
||||
58
backend/alembic/versions/028_add_promo_codes.py
Normal file
58
backend/alembic/versions/028_add_promo_codes.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Add promo codes system
|
||||
|
||||
Revision ID: 028_add_promo_codes
|
||||
Revises: 027_consumables_redesign
|
||||
Create Date: 2026-01-08
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '028_add_promo_codes'
|
||||
down_revision: Union[str, None] = '027_consumables_redesign'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create promo_codes table
|
||||
op.create_table(
|
||||
'promo_codes',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('code', sa.String(50), nullable=False),
|
||||
sa.Column('coins_amount', sa.Integer(), nullable=False),
|
||||
sa.Column('max_uses', sa.Integer(), nullable=True),
|
||||
sa.Column('uses_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=False),
|
||||
sa.Column('valid_from', sa.DateTime(), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index('ix_promo_codes_code', 'promo_codes', ['code'], unique=True)
|
||||
|
||||
# Create promo_code_redemptions table
|
||||
op.create_table(
|
||||
'promo_code_redemptions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('promo_code_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('coins_awarded', sa.Integer(), nullable=False),
|
||||
sa.Column('redeemed_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['promo_code_id'], ['promo_codes.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
|
||||
)
|
||||
op.create_index('ix_promo_code_redemptions_user_id', 'promo_code_redemptions', ['user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('promo_code_redemptions')
|
||||
op.drop_table('promo_codes')
|
||||
@@ -1,14 +1,16 @@
|
||||
from typing import Annotated
|
||||
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
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_access_token
|
||||
from app.models import User, Participant, Marathon, UserRole, ParticipantRole
|
||||
from app.models import User, Participant, Marathon, UserRole, ParticipantRole, AdminLog, AdminActionType
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
@@ -34,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:
|
||||
@@ -43,6 +54,50 @@ async def get_current_user(
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Check if user is banned
|
||||
if user.is_banned:
|
||||
# Auto-unban if ban expired
|
||||
if user.banned_until and datetime.utcnow() > user.banned_until:
|
||||
# Save ban info for logging before clearing
|
||||
old_ban_reason = user.ban_reason
|
||||
old_banned_until = user.banned_until.isoformat() if user.banned_until else None
|
||||
|
||||
user.is_banned = False
|
||||
user.banned_at = None
|
||||
user.banned_until = None
|
||||
user.banned_by_id = None
|
||||
user.ban_reason = None
|
||||
|
||||
# Log system auto-unban action
|
||||
log = AdminLog(
|
||||
admin_id=None, # System action, no admin
|
||||
action=AdminActionType.USER_AUTO_UNBAN.value,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
details={
|
||||
"nickname": user.nickname,
|
||||
"reason": old_ban_reason,
|
||||
"banned_until": old_banned_until,
|
||||
"system": True,
|
||||
},
|
||||
ip_address=None,
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
else:
|
||||
# Still banned - return ban info in error
|
||||
ban_info = {
|
||||
"banned_at": user.banned_at.isoformat() if user.banned_at else None,
|
||||
"banned_until": user.banned_until.isoformat() if user.banned_until else None,
|
||||
"reason": user.ban_reason,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ban_info,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -56,6 +111,21 @@ def require_admin(user: User) -> User:
|
||||
return user
|
||||
|
||||
|
||||
def require_admin_with_2fa(user: User) -> User:
|
||||
"""Check if user is admin with Telegram linked (2FA enabled)"""
|
||||
if not user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
if not user.telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Для доступа к админ-панели необходимо привязать Telegram в профиле",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def get_participant(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -15,3 +15,6 @@ router.include_router(admin.router)
|
||||
router.include_router(events.router)
|
||||
router.include_router(assignments.router)
|
||||
router.include_router(telegram.router)
|
||||
router.include_router(content.router)
|
||||
router.include_router(shop.router)
|
||||
router.include_router(promo.router)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,16 @@
|
||||
from datetime import datetime, timedelta
|
||||
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
|
||||
from app.core.rate_limit import limiter
|
||||
from app.models import User
|
||||
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate
|
||||
from app.models import User, UserRole, Admin2FASession
|
||||
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -40,11 +45,20 @@ async def register(request: Request, data: UserRegister, db: DbSession):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def login(request: Request, data: UserLogin, db: DbSession):
|
||||
# Find user
|
||||
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):
|
||||
@@ -53,6 +67,114 @@ async def login(request: Request, data: UserLogin, db: DbSession):
|
||||
detail="Incorrect login or password",
|
||||
)
|
||||
|
||||
# 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=ban_info,
|
||||
)
|
||||
|
||||
# If admin with Telegram linked, require 2FA
|
||||
if user.role == UserRole.ADMIN.value and user.telegram_id:
|
||||
# Generate 6-digit code
|
||||
code = "".join([str(secrets.randbelow(10)) for _ in range(6)])
|
||||
|
||||
# Create 2FA session (expires in 5 minutes)
|
||||
session = Admin2FASession(
|
||||
user_id=user.id,
|
||||
code=code,
|
||||
expires_at=datetime.utcnow() + timedelta(minutes=5),
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
# Send code to Telegram
|
||||
message = f"🔐 <b>Код подтверждения для входа в админку</b>\n\nВаш код: <code>{code}</code>\n\nКод действителен 5 минут."
|
||||
sent = await telegram_notifier.send_message(user.telegram_id, message)
|
||||
|
||||
if sent:
|
||||
session.telegram_sent = True
|
||||
await db.commit()
|
||||
|
||||
return LoginResponse(
|
||||
requires_2fa=True,
|
||||
two_factor_session_id=session.id,
|
||||
)
|
||||
|
||||
# Regular user or admin without Telegram - generate token immediately
|
||||
# Admin without Telegram can login but admin panel will check for Telegram
|
||||
access_token = create_access_token(subject=user.id)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
user=UserPrivate.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/2fa/verify", response_model=TokenResponse)
|
||||
@limiter.limit("5/minute")
|
||||
async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession):
|
||||
"""Verify 2FA code and return JWT token."""
|
||||
# Find session
|
||||
result = await db.execute(
|
||||
select(Admin2FASession).where(Admin2FASession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid session",
|
||||
)
|
||||
|
||||
if session.is_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Session already verified",
|
||||
)
|
||||
|
||||
if datetime.utcnow() > session.expires_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Code expired",
|
||||
)
|
||||
|
||||
if session.code != code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid code",
|
||||
)
|
||||
|
||||
# Mark as verified
|
||||
session.is_verified = True
|
||||
await db.commit()
|
||||
|
||||
# Get user
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == session.user_id)
|
||||
.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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Generate token
|
||||
access_token = create_access_token(subject=user.id)
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
|
||||
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge
|
||||
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge, User
|
||||
from app.models.challenge import ChallengeStatus
|
||||
from app.schemas import (
|
||||
ChallengeCreate,
|
||||
ChallengeUpdate,
|
||||
@@ -15,7 +16,9 @@ from app.schemas import (
|
||||
ChallengesSaveRequest,
|
||||
ChallengesGenerateRequest,
|
||||
)
|
||||
from app.schemas.challenge import ChallengePropose, ProposedByUser
|
||||
from app.services.gpt import gpt_service
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
router = APIRouter(tags=["challenges"])
|
||||
|
||||
@@ -23,7 +26,7 @@ router = APIRouter(tags=["challenges"])
|
||||
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.options(selectinload(Challenge.game))
|
||||
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||
.where(Challenge.id == challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one_or_none()
|
||||
@@ -32,9 +35,36 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
||||
return challenge
|
||||
|
||||
|
||||
def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeResponse:
|
||||
"""Helper to build ChallengeResponse with proposed_by"""
|
||||
proposed_by = None
|
||||
if challenge.proposed_by:
|
||||
proposed_by = ProposedByUser(
|
||||
id=challenge.proposed_by.id,
|
||||
nickname=challenge.proposed_by.nickname
|
||||
)
|
||||
|
||||
return ChallengeResponse(
|
||||
id=challenge.id,
|
||||
title=challenge.title,
|
||||
description=challenge.description,
|
||||
type=challenge.type,
|
||||
difficulty=challenge.difficulty,
|
||||
points=challenge.points,
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
status=challenge.status,
|
||||
proposed_by=proposed_by,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
|
||||
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""List challenges for a game. Participants can view challenges for approved games only."""
|
||||
"""List challenges for a game. Participants can view approved and pending challenges."""
|
||||
# Get game and check access
|
||||
result = await db.execute(
|
||||
select(Game).where(Game.id == game_id)
|
||||
@@ -54,35 +84,25 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
|
||||
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Game not accessible")
|
||||
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.where(Challenge.game_id == game_id)
|
||||
.order_by(Challenge.difficulty, Challenge.created_at)
|
||||
)
|
||||
# Get challenges with proposed_by
|
||||
query = select(Challenge).options(selectinload(Challenge.proposed_by)).where(Challenge.game_id == game_id)
|
||||
|
||||
# Regular participants see approved and pending challenges (but not rejected)
|
||||
if not current_user.is_admin and participant and not participant.is_organizer:
|
||||
query = query.where(Challenge.status.in_([ChallengeStatus.APPROVED.value, ChallengeStatus.PENDING.value]))
|
||||
|
||||
result = await db.execute(query.order_by(Challenge.status.desc(), Challenge.difficulty, Challenge.created_at))
|
||||
challenges = result.scalars().all()
|
||||
|
||||
return [
|
||||
ChallengeResponse(
|
||||
id=c.id,
|
||||
title=c.title,
|
||||
description=c.description,
|
||||
type=c.type,
|
||||
difficulty=c.difficulty,
|
||||
points=c.points,
|
||||
estimated_time=c.estimated_time,
|
||||
proof_type=c.proof_type,
|
||||
proof_hint=c.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||
is_generated=c.is_generated,
|
||||
created_at=c.created_at,
|
||||
)
|
||||
for c in challenges
|
||||
]
|
||||
return [build_challenge_response(c, game) for c in challenges]
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
||||
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()
|
||||
@@ -94,36 +114,61 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
|
||||
if not current_user.is_admin and not participant:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
# Get all challenges from approved games in this marathon
|
||||
# Get all approved challenges from approved games (challenges type) in this marathon
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.join(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Challenge.game))
|
||||
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.status == GameStatus.APPROVED.value,
|
||||
Challenge.status == ChallengeStatus.APPROVED.value,
|
||||
)
|
||||
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
|
||||
)
|
||||
challenges = result.scalars().all()
|
||||
|
||||
return [
|
||||
ChallengeResponse(
|
||||
id=c.id,
|
||||
title=c.title,
|
||||
description=c.description,
|
||||
type=c.type,
|
||||
difficulty=c.difficulty,
|
||||
points=c.points,
|
||||
estimated_time=c.estimated_time,
|
||||
proof_type=c.proof_type,
|
||||
proof_hint=c.proof_hint,
|
||||
game=GameShort(id=c.game.id, title=c.game.title, cover_url=None),
|
||||
is_generated=c.is_generated,
|
||||
created_at=c.created_at,
|
||||
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,
|
||||
)
|
||||
for c in challenges
|
||||
]
|
||||
.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)
|
||||
@@ -166,25 +211,13 @@ async def create_challenge(
|
||||
proof_type=data.proof_type.value,
|
||||
proof_hint=data.proof_hint,
|
||||
is_generated=False,
|
||||
status=ChallengeStatus.APPROVED.value, # Organizer-created challenges are auto-approved
|
||||
)
|
||||
db.add(challenge)
|
||||
await db.commit()
|
||||
await db.refresh(challenge)
|
||||
|
||||
return ChallengeResponse(
|
||||
id=challenge.id,
|
||||
title=challenge.title,
|
||||
description=challenge.description,
|
||||
type=challenge.type,
|
||||
difficulty=challenge.difficulty,
|
||||
points=challenge.points,
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
)
|
||||
return build_challenge_response(challenge, game)
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
||||
@@ -386,26 +419,12 @@ async def update_challenge(
|
||||
await db.commit()
|
||||
await db.refresh(challenge)
|
||||
|
||||
game = challenge.game
|
||||
return ChallengeResponse(
|
||||
id=challenge.id,
|
||||
title=challenge.title,
|
||||
description=challenge.description,
|
||||
type=challenge.type,
|
||||
difficulty=challenge.difficulty,
|
||||
points=challenge.points,
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
)
|
||||
return build_challenge_response(challenge, challenge.game)
|
||||
|
||||
|
||||
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
||||
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""Delete a challenge. Organizers only."""
|
||||
"""Delete a challenge. Organizers can delete any, participants can delete their own pending."""
|
||||
challenge = await get_challenge_or_404(db, challenge_id)
|
||||
|
||||
# Check marathon is in preparing state
|
||||
@@ -414,10 +433,206 @@ async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbS
|
||||
if marathon.status != MarathonStatus.PREPARING.value:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
||||
|
||||
# Only organizers can delete challenges
|
||||
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||
participant = await get_participant(db, current_user.id, challenge.game.marathon_id)
|
||||
|
||||
# Check permissions
|
||||
if current_user.is_admin or (participant and participant.is_organizer):
|
||||
# Organizers can delete any challenge
|
||||
pass
|
||||
elif challenge.proposed_by_id == current_user.id and challenge.status == ChallengeStatus.PENDING.value:
|
||||
# Participants can delete their own pending challenges
|
||||
pass
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="You can only delete your own pending challenges")
|
||||
|
||||
await db.delete(challenge)
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Challenge deleted")
|
||||
|
||||
|
||||
# ============ Proposed challenges endpoints ============
|
||||
|
||||
@router.post("/games/{game_id}/propose-challenge", response_model=ChallengeResponse)
|
||||
async def propose_challenge(
|
||||
game_id: int,
|
||||
data: ChallengePropose,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Propose a challenge for a game. Participants only, during PREPARING phase."""
|
||||
# Get game
|
||||
result = await db.execute(select(Game).where(Game.id == game_id))
|
||||
game = result.scalar_one_or_none()
|
||||
if not game:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
|
||||
# Check marathon is in preparing state
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
||||
marathon = result.scalar_one()
|
||||
if marathon.status != MarathonStatus.PREPARING.value:
|
||||
raise HTTPException(status_code=400, detail="Cannot propose challenges to active or finished marathon")
|
||||
|
||||
# Check user is participant
|
||||
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||
if not participant and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
# Can only propose challenges to approved games
|
||||
if game.status != GameStatus.APPROVED.value:
|
||||
raise HTTPException(status_code=400, detail="Can only propose challenges to approved games")
|
||||
|
||||
challenge = Challenge(
|
||||
game_id=game_id,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
type=data.type.value,
|
||||
difficulty=data.difficulty.value,
|
||||
points=data.points,
|
||||
estimated_time=data.estimated_time,
|
||||
proof_type=data.proof_type.value,
|
||||
proof_hint=data.proof_hint,
|
||||
is_generated=False,
|
||||
proposed_by_id=current_user.id,
|
||||
status=ChallengeStatus.PENDING.value,
|
||||
)
|
||||
db.add(challenge)
|
||||
await db.commit()
|
||||
await db.refresh(challenge)
|
||||
|
||||
# Load proposed_by relationship
|
||||
challenge.proposed_by = current_user
|
||||
|
||||
return build_challenge_response(challenge, game)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/proposed-challenges", response_model=list[ChallengeResponse])
|
||||
async def list_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""List all pending proposed challenges for a marathon. Organizers only."""
|
||||
# Check marathon exists
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
# Only organizers can see all proposed challenges
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
|
||||
# Get all pending challenges from approved games
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.join(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.status == GameStatus.APPROVED.value,
|
||||
Challenge.status == ChallengeStatus.PENDING.value,
|
||||
)
|
||||
.order_by(Challenge.created_at.desc())
|
||||
)
|
||||
challenges = result.scalars().all()
|
||||
|
||||
return [build_challenge_response(c, c.game) for c in challenges]
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/my-proposed-challenges", response_model=list[ChallengeResponse])
|
||||
async def list_my_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""List current user's proposed challenges for a marathon."""
|
||||
# Check marathon exists
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
# Check user is participant
|
||||
participant = await get_participant(db, current_user.id, marathon_id)
|
||||
if not participant and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
# Get user's proposed challenges
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.join(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Challenge.proposed_by_id == current_user.id,
|
||||
)
|
||||
.order_by(Challenge.created_at.desc())
|
||||
)
|
||||
challenges = result.scalars().all()
|
||||
|
||||
return [build_challenge_response(c, c.game) for c in challenges]
|
||||
|
||||
|
||||
@router.patch("/challenges/{challenge_id}/approve", response_model=ChallengeResponse)
|
||||
async def approve_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""Approve a proposed challenge. Organizers only."""
|
||||
challenge = await get_challenge_or_404(db, challenge_id)
|
||||
|
||||
# Check marathon is in preparing state
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||
marathon = result.scalar_one()
|
||||
if marathon.status != MarathonStatus.PREPARING.value:
|
||||
raise HTTPException(status_code=400, detail="Cannot approve challenges in active or finished marathon")
|
||||
|
||||
# Only organizers can approve
|
||||
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||
|
||||
if challenge.status != ChallengeStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||
|
||||
challenge.status = ChallengeStatus.APPROVED.value
|
||||
await db.commit()
|
||||
await db.refresh(challenge)
|
||||
|
||||
# Send Telegram notification to proposer
|
||||
if challenge.proposed_by_id:
|
||||
await telegram_notifier.notify_challenge_approved(
|
||||
db,
|
||||
challenge.proposed_by_id,
|
||||
marathon.title,
|
||||
challenge.game.title,
|
||||
challenge.title
|
||||
)
|
||||
|
||||
return build_challenge_response(challenge, challenge.game)
|
||||
|
||||
|
||||
@router.patch("/challenges/{challenge_id}/reject", response_model=ChallengeResponse)
|
||||
async def reject_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""Reject a proposed challenge. Organizers only."""
|
||||
challenge = await get_challenge_or_404(db, challenge_id)
|
||||
|
||||
# Check marathon is in preparing state
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||
marathon = result.scalar_one()
|
||||
if marathon.status != MarathonStatus.PREPARING.value:
|
||||
raise HTTPException(status_code=400, detail="Cannot reject challenges in active or finished marathon")
|
||||
|
||||
# Only organizers can reject
|
||||
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||
|
||||
if challenge.status != ChallengeStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||
|
||||
# Save info for notification before changing status
|
||||
proposer_id = challenge.proposed_by_id
|
||||
game_title = challenge.game.title
|
||||
challenge_title = challenge.title
|
||||
|
||||
challenge.status = ChallengeStatus.REJECTED.value
|
||||
await db.commit()
|
||||
await db.refresh(challenge)
|
||||
|
||||
# Send Telegram notification to proposer
|
||||
if proposer_id:
|
||||
await telegram_notifier.notify_challenge_rejected(
|
||||
db,
|
||||
proposer_id,
|
||||
marathon.title,
|
||||
game_title,
|
||||
challenge_title
|
||||
)
|
||||
|
||||
return build_challenge_response(challenge, challenge.game)
|
||||
|
||||
20
backend/app/api/v1/content.py
Normal file
20
backend/app/api/v1/content.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.models import StaticContent
|
||||
from app.schemas import StaticContentResponse
|
||||
|
||||
router = APIRouter(prefix="/content", tags=["content"])
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=StaticContentResponse)
|
||||
async def get_public_content(key: str, db: DbSession):
|
||||
"""Get public static content by key. No authentication required."""
|
||||
result = await db.execute(
|
||||
select(StaticContent).where(StaticContent.key == key)
|
||||
)
|
||||
content = result.scalar_one_or_none()
|
||||
if not content:
|
||||
raise HTTPException(status_code=404, detail="Content not found")
|
||||
return content
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}"
|
||||
)
|
||||
|
||||
299
backend/app/api/v1/promo.py
Normal file
299
backend/app/api/v1/promo.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Promo Code API endpoints - user redemption and admin management
|
||||
"""
|
||||
import secrets
|
||||
import string
|
||||
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_admin_with_2fa
|
||||
from app.models import User, CoinTransaction, CoinTransactionType
|
||||
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
||||
from app.schemas.promo_code import (
|
||||
PromoCodeCreate,
|
||||
PromoCodeUpdate,
|
||||
PromoCodeResponse,
|
||||
PromoCodeRedeemRequest,
|
||||
PromoCodeRedeemResponse,
|
||||
PromoCodeRedemptionResponse,
|
||||
PromoCodeRedemptionUser,
|
||||
)
|
||||
from app.schemas.common import MessageResponse
|
||||
|
||||
router = APIRouter(prefix="/promo", tags=["promo"])
|
||||
|
||||
|
||||
def generate_promo_code(length: int = 8) -> str:
|
||||
"""Generate a random promo code"""
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
return ''.join(secrets.choice(chars) for _ in range(length))
|
||||
|
||||
|
||||
# === User endpoints ===
|
||||
|
||||
@router.post("/redeem", response_model=PromoCodeRedeemResponse)
|
||||
async def redeem_promo_code(
|
||||
data: PromoCodeRedeemRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Redeem a promo code to receive coins"""
|
||||
# Find promo code
|
||||
result = await db.execute(
|
||||
select(PromoCode).where(PromoCode.code == data.code.upper().strip())
|
||||
)
|
||||
promo = result.scalar_one_or_none()
|
||||
|
||||
if not promo:
|
||||
raise HTTPException(status_code=404, detail="Промокод не найден")
|
||||
|
||||
# Check if valid
|
||||
if not promo.is_active:
|
||||
raise HTTPException(status_code=400, detail="Промокод деактивирован")
|
||||
|
||||
now = datetime.utcnow()
|
||||
if promo.valid_from and now < promo.valid_from:
|
||||
raise HTTPException(status_code=400, detail="Промокод ещё не активен")
|
||||
|
||||
if promo.valid_until and now > promo.valid_until:
|
||||
raise HTTPException(status_code=400, detail="Промокод истёк")
|
||||
|
||||
if promo.max_uses is not None and promo.uses_count >= promo.max_uses:
|
||||
raise HTTPException(status_code=400, detail="Лимит использований исчерпан")
|
||||
|
||||
# Check if user already redeemed
|
||||
result = await db.execute(
|
||||
select(PromoCodeRedemption).where(
|
||||
PromoCodeRedemption.promo_code_id == promo.id,
|
||||
PromoCodeRedemption.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Вы уже использовали этот промокод")
|
||||
|
||||
# Create redemption record
|
||||
redemption = PromoCodeRedemption(
|
||||
promo_code_id=promo.id,
|
||||
user_id=current_user.id,
|
||||
coins_awarded=promo.coins_amount,
|
||||
)
|
||||
db.add(redemption)
|
||||
|
||||
# Update uses count
|
||||
promo.uses_count += 1
|
||||
|
||||
# Award coins
|
||||
transaction = CoinTransaction(
|
||||
user_id=current_user.id,
|
||||
amount=promo.coins_amount,
|
||||
transaction_type=CoinTransactionType.PROMO_CODE.value,
|
||||
reference_type="promo_code",
|
||||
reference_id=promo.id,
|
||||
description=f"Промокод: {promo.code}",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
current_user.coins_balance += promo.coins_amount
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return PromoCodeRedeemResponse(
|
||||
success=True,
|
||||
coins_awarded=promo.coins_amount,
|
||||
new_balance=current_user.coins_balance,
|
||||
message=f"Вы получили {promo.coins_amount} монет!",
|
||||
)
|
||||
|
||||
|
||||
# === Admin endpoints ===
|
||||
|
||||
@router.get("/admin/list", response_model=list[PromoCodeResponse])
|
||||
async def admin_list_promo_codes(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""Get all promo codes (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
query = select(PromoCode).options(selectinload(PromoCode.created_by))
|
||||
if not include_inactive:
|
||||
query = query.where(PromoCode.is_active == True)
|
||||
|
||||
query = query.order_by(PromoCode.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
promos = result.scalars().all()
|
||||
|
||||
return [
|
||||
PromoCodeResponse(
|
||||
id=p.id,
|
||||
code=p.code,
|
||||
coins_amount=p.coins_amount,
|
||||
max_uses=p.max_uses,
|
||||
uses_count=p.uses_count,
|
||||
is_active=p.is_active,
|
||||
valid_from=p.valid_from,
|
||||
valid_until=p.valid_until,
|
||||
created_at=p.created_at,
|
||||
created_by_nickname=p.created_by.nickname if p.created_by else None,
|
||||
)
|
||||
for p in promos
|
||||
]
|
||||
|
||||
|
||||
@router.post("/admin/create", response_model=PromoCodeResponse)
|
||||
async def admin_create_promo_code(
|
||||
data: PromoCodeCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Create a new promo code (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Generate or use provided code
|
||||
code = data.code.upper().strip() if data.code else generate_promo_code()
|
||||
|
||||
# Check uniqueness
|
||||
result = await db.execute(
|
||||
select(PromoCode).where(PromoCode.code == code)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail=f"Промокод '{code}' уже существует")
|
||||
|
||||
promo = PromoCode(
|
||||
code=code,
|
||||
coins_amount=data.coins_amount,
|
||||
max_uses=data.max_uses,
|
||||
valid_from=data.valid_from,
|
||||
valid_until=data.valid_until,
|
||||
created_by_id=current_user.id,
|
||||
)
|
||||
db.add(promo)
|
||||
await db.commit()
|
||||
await db.refresh(promo)
|
||||
|
||||
return PromoCodeResponse(
|
||||
id=promo.id,
|
||||
code=promo.code,
|
||||
coins_amount=promo.coins_amount,
|
||||
max_uses=promo.max_uses,
|
||||
uses_count=promo.uses_count,
|
||||
is_active=promo.is_active,
|
||||
valid_from=promo.valid_from,
|
||||
valid_until=promo.valid_until,
|
||||
created_at=promo.created_at,
|
||||
created_by_nickname=current_user.nickname,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/admin/{promo_id}", response_model=PromoCodeResponse)
|
||||
async def admin_update_promo_code(
|
||||
promo_id: int,
|
||||
data: PromoCodeUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Update a promo code (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(selectinload(PromoCode.created_by))
|
||||
.where(PromoCode.id == promo_id)
|
||||
)
|
||||
promo = result.scalar_one_or_none()
|
||||
|
||||
if not promo:
|
||||
raise HTTPException(status_code=404, detail="Промокод не найден")
|
||||
|
||||
if data.is_active is not None:
|
||||
promo.is_active = data.is_active
|
||||
if data.max_uses is not None:
|
||||
promo.max_uses = data.max_uses
|
||||
if data.valid_until is not None:
|
||||
promo.valid_until = data.valid_until
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(promo)
|
||||
|
||||
return PromoCodeResponse(
|
||||
id=promo.id,
|
||||
code=promo.code,
|
||||
coins_amount=promo.coins_amount,
|
||||
max_uses=promo.max_uses,
|
||||
uses_count=promo.uses_count,
|
||||
is_active=promo.is_active,
|
||||
valid_from=promo.valid_from,
|
||||
valid_until=promo.valid_until,
|
||||
created_at=promo.created_at,
|
||||
created_by_nickname=promo.created_by.nickname if promo.created_by else None,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/admin/{promo_id}", response_model=MessageResponse)
|
||||
async def admin_delete_promo_code(
|
||||
promo_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Delete a promo code (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoCode).where(PromoCode.id == promo_id)
|
||||
)
|
||||
promo = result.scalar_one_or_none()
|
||||
|
||||
if not promo:
|
||||
raise HTTPException(status_code=404, detail="Промокод не найден")
|
||||
|
||||
await db.delete(promo)
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message=f"Промокод '{promo.code}' удалён")
|
||||
|
||||
|
||||
@router.get("/admin/{promo_id}/redemptions", response_model=list[PromoCodeRedemptionResponse])
|
||||
async def admin_get_promo_redemptions(
|
||||
promo_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get list of users who redeemed a promo code (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Check promo exists
|
||||
result = await db.execute(
|
||||
select(PromoCode).where(PromoCode.id == promo_id)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Промокод не найден")
|
||||
|
||||
# Get redemptions
|
||||
result = await db.execute(
|
||||
select(PromoCodeRedemption)
|
||||
.options(selectinload(PromoCodeRedemption.user))
|
||||
.where(PromoCodeRedemption.promo_code_id == promo_id)
|
||||
.order_by(PromoCodeRedemption.redeemed_at.desc())
|
||||
)
|
||||
redemptions = result.scalars().all()
|
||||
|
||||
return [
|
||||
PromoCodeRedemptionResponse(
|
||||
id=r.id,
|
||||
user=PromoCodeRedemptionUser(
|
||||
id=r.user.id,
|
||||
nickname=r.user.nickname,
|
||||
),
|
||||
coins_awarded=r.coins_awarded,
|
||||
redeemed_at=r.redeemed_at,
|
||||
)
|
||||
for r in redemptions
|
||||
]
|
||||
751
backend/app/api/v1/shop.py
Normal file
751
backend/app/api/v1/shop.py
Normal file
@@ -0,0 +1,751 @@
|
||||
"""
|
||||
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, Challenge, Game,
|
||||
)
|
||||
from app.schemas import (
|
||||
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
|
||||
InventoryItemResponse, PurchaseRequest, PurchaseResponse,
|
||||
UseConsumableRequest, UseConsumableResponse,
|
||||
EquipItemRequest, EquipItemResponse,
|
||||
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
|
||||
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
|
||||
ConsumablesStatusResponse, MessageResponse, SwapCandidate,
|
||||
)
|
||||
from app.schemas.user import UserPublic
|
||||
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 some consumables, we need the assignment
|
||||
assignment = None
|
||||
if data.item_code in ["skip", "wild_card", "copycat"]:
|
||||
if not data.assignment_id:
|
||||
raise HTTPException(status_code=400, detail=f"assignment_id is required for {data.item_code}")
|
||||
|
||||
# For copycat, we need bonus_assignments to properly handle playthrough
|
||||
if data.item_code == "copycat":
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(selectinload(Assignment.bonus_assignments))
|
||||
.where(
|
||||
Assignment.id == data.assignment_id,
|
||||
Assignment.participant_id == participant.id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
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 == "boost":
|
||||
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
|
||||
effect_description = f"Boost x{effect['multiplier']} activated for current assignment"
|
||||
elif data.item_code == "wild_card":
|
||||
if data.game_id is None:
|
||||
raise HTTPException(status_code=400, detail="game_id is required for wild_card")
|
||||
effect = await consumables_service.use_wild_card(
|
||||
db, current_user, participant, marathon, assignment, data.game_id
|
||||
)
|
||||
effect_description = f"New challenge from {effect['game_name']}: {effect['challenge_title']}"
|
||||
elif data.item_code == "lucky_dice":
|
||||
effect = await consumables_service.use_lucky_dice(db, current_user, participant, marathon)
|
||||
effect_description = f"Lucky Dice rolled: x{effect['multiplier']} multiplier"
|
||||
elif data.item_code == "copycat":
|
||||
if data.target_participant_id is None:
|
||||
raise HTTPException(status_code=400, detail="target_participant_id is required for copycat")
|
||||
effect = await consumables_service.use_copycat(
|
||||
db, current_user, participant, marathon, assignment, data.target_participant_id
|
||||
)
|
||||
effect_description = f"Copied challenge: {effect['challenge_title']}"
|
||||
elif data.item_code == "undo":
|
||||
effect = await consumables_service.use_undo(db, current_user, participant, marathon)
|
||||
effect_description = f"Restored {effect['points_restored']} points and streak {effect['streak_restored']}"
|
||||
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")
|
||||
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
|
||||
wild_cards_available = await consumables_service.get_consumable_count(db, current_user.id, "wild_card")
|
||||
lucky_dice_available = await consumables_service.get_consumable_count(db, current_user.id, "lucky_dice")
|
||||
copycats_available = await consumables_service.get_consumable_count(db, current_user.id, "copycat")
|
||||
undos_available = await consumables_service.get_consumable_count(db, current_user.id, "undo")
|
||||
|
||||
# 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,
|
||||
boosts_available=boosts_available,
|
||||
has_active_boost=participant.has_active_boost,
|
||||
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
|
||||
wild_cards_available=wild_cards_available,
|
||||
lucky_dice_available=lucky_dice_available,
|
||||
has_lucky_dice=participant.has_lucky_dice,
|
||||
lucky_dice_multiplier=participant.lucky_dice_multiplier,
|
||||
copycats_available=copycats_available,
|
||||
undos_available=undos_available,
|
||||
can_undo=participant.can_undo,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/copycat-candidates/{marathon_id}", response_model=list[SwapCandidate])
|
||||
async def get_copycat_candidates(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get participants with active assignments available for copycat (no event required)"""
|
||||
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 all participants except current user with active assignments
|
||||
# Support both challenge assignments and playthrough assignments
|
||||
result = await db.execute(
|
||||
select(Participant, Assignment, Challenge, Game)
|
||||
.join(Assignment, Assignment.participant_id == Participant.id)
|
||||
.outerjoin(Challenge, Assignment.challenge_id == Challenge.id)
|
||||
.outerjoin(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(
|
||||
Participant.marathon_id == marathon_id,
|
||||
Participant.id != participant.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
candidates = []
|
||||
for p, assignment, challenge, game in rows:
|
||||
# For playthrough assignments, challenge is None
|
||||
if assignment.is_playthrough:
|
||||
# Need to get game info for playthrough
|
||||
game_result = await db.execute(
|
||||
select(Game).where(Game.id == assignment.game_id)
|
||||
)
|
||||
playthrough_game = game_result.scalar_one_or_none()
|
||||
if playthrough_game:
|
||||
candidates.append(SwapCandidate(
|
||||
participant_id=p.id,
|
||||
user=UserPublic(
|
||||
id=p.user.id,
|
||||
nickname=p.user.nickname,
|
||||
avatar_url=p.user.avatar_url,
|
||||
role=p.user.role,
|
||||
telegram_avatar_url=p.user.telegram_avatar_url,
|
||||
created_at=p.user.created_at,
|
||||
equipped_frame=None,
|
||||
equipped_title=None,
|
||||
equipped_name_color=None,
|
||||
equipped_background=None,
|
||||
),
|
||||
challenge_title=f"Прохождение: {playthrough_game.title}",
|
||||
challenge_description=playthrough_game.playthrough_description or "Прохождение игры",
|
||||
challenge_points=playthrough_game.playthrough_points or 0,
|
||||
challenge_difficulty="medium",
|
||||
game_title=playthrough_game.title,
|
||||
))
|
||||
elif challenge and game:
|
||||
candidates.append(SwapCandidate(
|
||||
participant_id=p.id,
|
||||
user=UserPublic(
|
||||
id=p.user.id,
|
||||
nickname=p.user.nickname,
|
||||
avatar_url=p.user.avatar_url,
|
||||
role=p.user.role,
|
||||
telegram_avatar_url=p.user.telegram_avatar_url,
|
||||
created_at=p.user.created_at,
|
||||
equipped_frame=None,
|
||||
equipped_title=None,
|
||||
equipped_name_color=None,
|
||||
equipped_background=None,
|
||||
),
|
||||
challenge_title=challenge.title,
|
||||
challenge_description=challenge.description,
|
||||
challenge_points=challenge.points,
|
||||
challenge_difficulty=challenge.difficulty,
|
||||
game_title=game.title,
|
||||
))
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
# === 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,
|
||||
)
|
||||
@@ -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):
|
||||
@@ -86,7 +101,7 @@ async def generate_link_token(current_user: CurrentUser):
|
||||
)
|
||||
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
|
||||
|
||||
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot"
|
||||
bot_username = settings.TELEGRAM_BOT_USERNAME or "BCMarathonbot"
|
||||
bot_url = f"https://t.me/{bot_username}?start={token}"
|
||||
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,201 @@ 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 and lucky dice multipliers from consumables
|
||||
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
|
||||
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
|
||||
combined_multiplier = boost_multiplier * lucky_dice_multiplier
|
||||
if combined_multiplier != 1.0:
|
||||
total_points = int(total_points * combined_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 lucky_dice_multiplier != 1.0:
|
||||
activity_data["lucky_dice_multiplier"] = lucky_dice_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 +732,13 @@ 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 and lucky dice multipliers from consumables
|
||||
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
|
||||
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
|
||||
combined_multiplier = boost_multiplier * lucky_dice_multiplier
|
||||
if combined_multiplier != 1.0:
|
||||
total_points = int(total_points * combined_multiplier)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.COMPLETED.value
|
||||
assignment.points_earned = total_points
|
||||
@@ -386,18 +748,40 @@ 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 lucky_dice_multiplier != 1.0:
|
||||
activity_data["lucky_dice_multiplier"] = lucky_dice_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 +802,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 +821,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 +834,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 +846,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 +876,79 @@ 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
|
||||
)
|
||||
|
||||
# Save drop data for potential undo
|
||||
consumables_service.save_drop_for_undo(
|
||||
participant, penalty, participant.current_streak
|
||||
)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
|
||||
# Reset all bonus assignments (lose any completed bonuses)
|
||||
completed_bonuses_count = 0
|
||||
for ba in assignment.bonus_assignments:
|
||||
if ba.status == BonusAssignmentStatus.COMPLETED.value:
|
||||
completed_bonuses_count += 1
|
||||
ba.status = BonusAssignmentStatus.PENDING.value
|
||||
ba.proof_path = None
|
||||
ba.proof_url = None
|
||||
ba.proof_comment = None
|
||||
ba.points_earned = 0
|
||||
ba.completed_at = None
|
||||
|
||||
# Update participant
|
||||
participant.total_points = max(0, participant.total_points - penalty)
|
||||
participant.current_streak = 0
|
||||
participant.drop_count += 1
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"game": game.title,
|
||||
"is_playthrough": True,
|
||||
"penalty": penalty,
|
||||
"lost_bonuses": completed_bonuses_count,
|
||||
}
|
||||
if playthrough_event:
|
||||
activity_data["event_type"] = playthrough_event.type
|
||||
activity_data["free_drop"] = True
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.DROP.value,
|
||||
data=activity_data,
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return DropResult(
|
||||
penalty=penalty,
|
||||
total_points=participant.total_points,
|
||||
new_drop_count=participant.drop_count,
|
||||
)
|
||||
|
||||
# Regular challenge drop
|
||||
marathon_id = assignment.challenge.game.marathon_id
|
||||
|
||||
# Check active event for free drops (double_risk)
|
||||
@@ -498,6 +957,11 @@ 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)
|
||||
|
||||
# Save drop data for potential undo
|
||||
consumables_service.save_drop_for_undo(
|
||||
participant, penalty, participant.current_streak
|
||||
)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
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
|
||||
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
|
||||
from app.models.admin_log import AdminLog, AdminActionType
|
||||
from app.models.admin_2fa import Admin2FASession
|
||||
from app.models.static_content import StaticContent
|
||||
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
|
||||
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -15,16 +25,22 @@ __all__ = [
|
||||
"Marathon",
|
||||
"MarathonStatus",
|
||||
"GameProposalMode",
|
||||
"CertificationStatus",
|
||||
"Participant",
|
||||
"ParticipantRole",
|
||||
"Game",
|
||||
"GameStatus",
|
||||
"GameType",
|
||||
"Challenge",
|
||||
"ChallengeType",
|
||||
"Difficulty",
|
||||
"ProofType",
|
||||
"Assignment",
|
||||
"AssignmentStatus",
|
||||
"BonusAssignment",
|
||||
"BonusAssignmentStatus",
|
||||
"AssignmentProof",
|
||||
"BonusAssignmentProof",
|
||||
"Activity",
|
||||
"ActivityType",
|
||||
"Event",
|
||||
@@ -35,4 +51,18 @@ __all__ = [
|
||||
"DisputeStatus",
|
||||
"DisputeComment",
|
||||
"DisputeVote",
|
||||
"AdminLog",
|
||||
"AdminActionType",
|
||||
"Admin2FASession",
|
||||
"StaticContent",
|
||||
"ShopItem",
|
||||
"ShopItemType",
|
||||
"ItemRarity",
|
||||
"ConsumableType",
|
||||
"UserInventory",
|
||||
"CoinTransaction",
|
||||
"CoinTransactionType",
|
||||
"ConsumableUsage",
|
||||
"PromoCode",
|
||||
"PromoCodeRedemption",
|
||||
]
|
||||
|
||||
20
backend/app/models/admin_2fa.py
Normal file
20
backend/app/models/admin_2fa.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Integer, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Admin2FASession(Base):
|
||||
__tablename__ = "admin_2fa_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
code: Mapped[str] = mapped_column(String(6), nullable=False)
|
||||
telegram_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
||||
53
backend/app/models/admin_log.py
Normal file
53
backend/app/models/admin_log.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, DateTime, Integer, ForeignKey, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AdminActionType(str, Enum):
|
||||
# User actions
|
||||
USER_BAN = "user_ban"
|
||||
USER_UNBAN = "user_unban"
|
||||
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
|
||||
USER_ROLE_CHANGE = "user_role_change"
|
||||
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"
|
||||
|
||||
# Broadcast actions
|
||||
BROADCAST_ALL = "broadcast_all"
|
||||
BROADCAST_MARATHON = "broadcast_marathon"
|
||||
|
||||
# Auth actions
|
||||
ADMIN_LOGIN = "admin_login"
|
||||
ADMIN_2FA_SUCCESS = "admin_2fa_success"
|
||||
ADMIN_2FA_FAIL = "admin_2fa_fail"
|
||||
|
||||
# Dispute actions
|
||||
DISPUTE_RESOLVE_VALID = "dispute_resolve_valid"
|
||||
DISPUTE_RESOLVE_INVALID = "dispute_resolve_invalid"
|
||||
|
||||
|
||||
class AdminLog(Base):
|
||||
__tablename__ = "admin_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
admin_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True) # Nullable for system actions
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
target_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
# Relationships
|
||||
admin: Mapped["User"] = relationship("User", foreign_keys=[admin_id])
|
||||
@@ -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")
|
||||
|
||||
47
backend/app/models/assignment_proof.py
Normal file
47
backend/app/models/assignment_proof.py
Normal 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"
|
||||
)
|
||||
54
backend/app/models/bonus_assignment.py
Normal file
54
backend/app/models/bonus_assignment.py
Normal 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"
|
||||
)
|
||||
@@ -29,6 +29,12 @@ class ProofType(str, Enum):
|
||||
STEAM = "steam"
|
||||
|
||||
|
||||
class ChallengeStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class Challenge(Base):
|
||||
__tablename__ = "challenges"
|
||||
|
||||
@@ -45,8 +51,13 @@ class Challenge(Base):
|
||||
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Proposed challenges support
|
||||
proposed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="approved") # pending, approved, rejected
|
||||
|
||||
# Relationships
|
||||
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
||||
proposed_by: Mapped["User"] = relationship("User", foreign_keys=[proposed_by_id])
|
||||
assignments: Mapped[list["Assignment"]] = relationship(
|
||||
"Assignment",
|
||||
back_populates="challenge"
|
||||
|
||||
42
backend/app/models/coin_transaction.py
Normal file
42
backend/app/models/coin_transaction.py
Normal file
@@ -0,0 +1,42 @@
|
||||
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"
|
||||
PROMO_CODE = "promo_code"
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
30
backend/app/models/consumable_usage.py
Normal file
30
backend/app/models/consumable_usage.py
Normal 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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
39
backend/app/models/inventory.py
Normal file
39
backend/app/models/inventory.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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, Float
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -26,6 +26,22 @@ 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)
|
||||
|
||||
# Lucky Dice state
|
||||
has_lucky_dice: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
lucky_dice_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Undo state - stores last drop data for potential rollback
|
||||
last_drop_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
last_drop_streak_before: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
can_undo: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", back_populates="participations")
|
||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")
|
||||
|
||||
67
backend/app/models/promo_code.py
Normal file
67
backend/app/models/promo_code.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Promo Code models for coins distribution
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Boolean, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PromoCode(Base):
|
||||
"""Promo code for giving coins to users"""
|
||||
__tablename__ = "promo_codes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||
coins_amount: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
max_uses: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = unlimited
|
||||
uses_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_id])
|
||||
redemptions: Mapped[list["PromoCodeRedemption"]] = relationship(
|
||||
"PromoCodeRedemption", back_populates="promo_code", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if promo code is currently valid"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if self.valid_from and now < self.valid_from:
|
||||
return False
|
||||
|
||||
if self.valid_until and now > self.valid_until:
|
||||
return False
|
||||
|
||||
if self.max_uses is not None and self.uses_count >= self.max_uses:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class PromoCodeRedemption(Base):
|
||||
"""Record of promo code redemption by a user"""
|
||||
__tablename__ = "promo_code_redemptions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
promo_code_id: Mapped[int] = mapped_column(ForeignKey("promo_codes.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
coins_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
redeemed_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
promo_code: Mapped["PromoCode"] = relationship("PromoCode", back_populates="redemptions")
|
||||
user: Mapped["User"] = relationship("User")
|
||||
83
backend/app/models/shop.py
Normal file
83
backend/app/models/shop.py
Normal file
@@ -0,0 +1,83 @@
|
||||
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"
|
||||
BOOST = "boost"
|
||||
WILD_CARD = "wild_card"
|
||||
LUCKY_DICE = "lucky_dice"
|
||||
COPYCAT = "copycat"
|
||||
UNDO = "undo"
|
||||
|
||||
|
||||
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
|
||||
20
backend/app/models/static_content.py
Normal file
20
backend/app/models/static_content.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Integer, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class StaticContent(Base):
|
||||
__tablename__ = "static_content"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
updated_by: Mapped["User | None"] = relationship("User", foreign_keys=[updated_by_id])
|
||||
@@ -1,10 +1,16 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, BigInteger, DateTime
|
||||
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from 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"
|
||||
@@ -27,6 +33,27 @@ class User(Base):
|
||||
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Ban fields
|
||||
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
banned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
banned_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = permanent
|
||||
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# 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",
|
||||
@@ -47,6 +74,37 @@ class User(Base):
|
||||
back_populates="approved_by",
|
||||
foreign_keys="Game.approved_by_id"
|
||||
)
|
||||
banned_by: Mapped["User | None"] = relationship(
|
||||
"User",
|
||||
remote_side="User.id",
|
||||
foreign_keys=[banned_by_id]
|
||||
)
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -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,
|
||||
@@ -81,6 +87,53 @@ from app.schemas.dispute import (
|
||||
AssignmentDetailResponse,
|
||||
ReturnedAssignmentResponse,
|
||||
)
|
||||
from app.schemas.admin import (
|
||||
BanUserRequest,
|
||||
AdminResetPasswordRequest,
|
||||
AdminUserResponse,
|
||||
AdminLogResponse,
|
||||
AdminLogsListResponse,
|
||||
BroadcastRequest,
|
||||
BroadcastResponse,
|
||||
StaticContentResponse,
|
||||
StaticContentUpdate,
|
||||
StaticContentCreate,
|
||||
TwoFactorInitiateRequest,
|
||||
TwoFactorInitiateResponse,
|
||||
TwoFactorVerifyRequest,
|
||||
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.promo_code import (
|
||||
PromoCodeCreate,
|
||||
PromoCodeUpdate,
|
||||
PromoCodeResponse,
|
||||
PromoCodeRedeemRequest,
|
||||
PromoCodeRedeemResponse,
|
||||
PromoCodeRedemptionResponse,
|
||||
PromoCodeRedemptionUser,
|
||||
)
|
||||
from app.schemas.user import ShopItemPublic
|
||||
|
||||
__all__ = [
|
||||
# User
|
||||
@@ -94,6 +147,8 @@ __all__ = [
|
||||
"PasswordChange",
|
||||
"UserStats",
|
||||
"UserProfilePublic",
|
||||
"NotificationSettings",
|
||||
"NotificationSettingsUpdate",
|
||||
# Marathon
|
||||
"MarathonCreate",
|
||||
"MarathonUpdate",
|
||||
@@ -127,6 +182,10 @@ __all__ = [
|
||||
"CompleteResult",
|
||||
"DropResult",
|
||||
"EventAssignmentResponse",
|
||||
"BonusAssignmentResponse",
|
||||
"CompleteBonusAssignment",
|
||||
"BonusCompleteResult",
|
||||
"AvailableGamesCount",
|
||||
# Activity
|
||||
"ActivityResponse",
|
||||
"FeedResponse",
|
||||
@@ -157,4 +216,48 @@ __all__ = [
|
||||
"DisputeResponse",
|
||||
"AssignmentDetailResponse",
|
||||
"ReturnedAssignmentResponse",
|
||||
# Admin
|
||||
"BanUserRequest",
|
||||
"AdminResetPasswordRequest",
|
||||
"AdminUserResponse",
|
||||
"AdminLogResponse",
|
||||
"AdminLogsListResponse",
|
||||
"BroadcastRequest",
|
||||
"BroadcastResponse",
|
||||
"StaticContentResponse",
|
||||
"StaticContentUpdate",
|
||||
"StaticContentCreate",
|
||||
"TwoFactorInitiateRequest",
|
||||
"TwoFactorInitiateResponse",
|
||||
"TwoFactorVerifyRequest",
|
||||
"LoginResponse",
|
||||
"DashboardStats",
|
||||
# Shop
|
||||
"ShopItemCreate",
|
||||
"ShopItemUpdate",
|
||||
"ShopItemResponse",
|
||||
"ShopItemPublic",
|
||||
"InventoryItemResponse",
|
||||
"PurchaseRequest",
|
||||
"PurchaseResponse",
|
||||
"UseConsumableRequest",
|
||||
"UseConsumableResponse",
|
||||
"EquipItemRequest",
|
||||
"EquipItemResponse",
|
||||
"CoinTransactionResponse",
|
||||
"CoinsBalanceResponse",
|
||||
"AdminCoinsRequest",
|
||||
"UserCosmeticsResponse",
|
||||
"CertificationRequestSchema",
|
||||
"CertificationReviewRequest",
|
||||
"CertificationStatusResponse",
|
||||
"ConsumablesStatusResponse",
|
||||
# Promo
|
||||
"PromoCodeCreate",
|
||||
"PromoCodeUpdate",
|
||||
"PromoCodeResponse",
|
||||
"PromoCodeRedeemRequest",
|
||||
"PromoCodeRedeemResponse",
|
||||
"PromoCodeRedemptionResponse",
|
||||
"PromoCodeRedemptionUser",
|
||||
]
|
||||
|
||||
127
backend/app/schemas/admin.py
Normal file
127
backend/app/schemas/admin.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ============ User Ban ============
|
||||
class BanUserRequest(BaseModel):
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
banned_until: datetime | None = None # None = permanent ban
|
||||
|
||||
|
||||
class AdminResetPasswordRequest(BaseModel):
|
||||
new_password: str = Field(..., min_length=6, max_length=100)
|
||||
|
||||
|
||||
class AdminUserResponse(BaseModel):
|
||||
id: int
|
||||
login: str
|
||||
nickname: str
|
||||
role: str
|
||||
avatar_url: str | None = None
|
||||
telegram_id: int | None = None
|
||||
telegram_username: str | None = None
|
||||
marathons_count: int = 0
|
||||
created_at: str
|
||||
is_banned: bool = False
|
||||
banned_at: str | None = None
|
||||
banned_until: str | None = None # None = permanent
|
||||
ban_reason: str | None = None
|
||||
# Notification settings
|
||||
notify_events: bool = True
|
||||
notify_disputes: bool = True
|
||||
notify_moderation: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Admin Logs ============
|
||||
class AdminLogResponse(BaseModel):
|
||||
id: int
|
||||
admin_id: int | None = None # Nullable for system actions
|
||||
admin_nickname: str | None = None # Nullable for system actions
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: int
|
||||
details: dict | None = None
|
||||
ip_address: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminLogsListResponse(BaseModel):
|
||||
logs: list[AdminLogResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ============ Broadcast ============
|
||||
class BroadcastRequest(BaseModel):
|
||||
message: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class BroadcastResponse(BaseModel):
|
||||
sent_count: int
|
||||
total_count: int
|
||||
|
||||
|
||||
# ============ Static Content ============
|
||||
class StaticContentResponse(BaseModel):
|
||||
id: int
|
||||
key: str
|
||||
title: str
|
||||
content: str
|
||||
updated_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class StaticContentUpdate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
content: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class StaticContentCreate(BaseModel):
|
||||
key: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-z0-9_-]+$")
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
content: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ============ 2FA ============
|
||||
class TwoFactorInitiateRequest(BaseModel):
|
||||
pass # No additional data needed
|
||||
|
||||
|
||||
class TwoFactorInitiateResponse(BaseModel):
|
||||
session_id: int
|
||||
expires_at: datetime
|
||||
message: str = "Code sent to Telegram"
|
||||
|
||||
|
||||
class TwoFactorVerifyRequest(BaseModel):
|
||||
session_id: int
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Login response that may require 2FA"""
|
||||
access_token: str | None = None
|
||||
token_type: str = "bearer"
|
||||
user: Any = None # UserPrivate
|
||||
requires_2fa: bool = False
|
||||
two_factor_session_id: int | None = None
|
||||
|
||||
|
||||
# ============ Dashboard Stats ============
|
||||
class DashboardStats(BaseModel):
|
||||
users_count: int
|
||||
banned_users_count: int
|
||||
marathons_count: int
|
||||
active_marathons_count: int
|
||||
games_count: int
|
||||
total_participations: int
|
||||
recent_logs: list[AdminLogResponse] = []
|
||||
@@ -1,10 +1,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,6 +79,7 @@ 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):
|
||||
@@ -60,3 +97,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
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.challenge import ChallengeType, Difficulty, ProofType
|
||||
from app.models.challenge import ChallengeType, Difficulty, ProofType, ChallengeStatus
|
||||
from app.schemas.game import GameShort
|
||||
|
||||
|
||||
class ProposedByUser(BaseModel):
|
||||
"""Minimal user info for proposed challenges"""
|
||||
id: int
|
||||
nickname: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ChallengeBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=100)
|
||||
description: str = Field(..., min_length=1)
|
||||
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
|
||||
@@ -25,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
|
||||
@@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase):
|
||||
game: GameShort
|
||||
is_generated: bool
|
||||
created_at: datetime
|
||||
status: str = "approved"
|
||||
proposed_by: ProposedByUser | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ChallengePropose(ChallengeBase):
|
||||
"""Schema for proposing a challenge by a participant"""
|
||||
pass
|
||||
|
||||
|
||||
class ChallengeGenerated(BaseModel):
|
||||
"""Schema for GPT-generated challenges"""
|
||||
title: str
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_active_boost: bool = False
|
||||
has_lucky_dice: bool = False
|
||||
lucky_dice_multiplier: float | None = None
|
||||
can_undo: bool = False
|
||||
|
||||
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
|
||||
|
||||
|
||||
74
backend/app/schemas/promo_code.py
Normal file
74
backend/app/schemas/promo_code.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Promo Code schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# === Create/Update ===
|
||||
|
||||
class PromoCodeCreate(BaseModel):
|
||||
"""Schema for creating a promo code"""
|
||||
code: str | None = Field(None, min_length=3, max_length=50) # None = auto-generate
|
||||
coins_amount: int = Field(..., ge=1, le=100000)
|
||||
max_uses: int | None = Field(None, ge=1) # None = unlimited
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
|
||||
|
||||
class PromoCodeUpdate(BaseModel):
|
||||
"""Schema for updating a promo code"""
|
||||
is_active: bool | None = None
|
||||
max_uses: int | None = None
|
||||
valid_until: datetime | None = None
|
||||
|
||||
|
||||
# === Response ===
|
||||
|
||||
class PromoCodeResponse(BaseModel):
|
||||
"""Schema for promo code in responses"""
|
||||
id: int
|
||||
code: str
|
||||
coins_amount: int
|
||||
max_uses: int | None
|
||||
uses_count: int
|
||||
is_active: bool
|
||||
valid_from: datetime | None
|
||||
valid_until: datetime | None
|
||||
created_at: datetime
|
||||
created_by_nickname: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PromoCodeRedemptionUser(BaseModel):
|
||||
"""User info for redemption"""
|
||||
id: int
|
||||
nickname: str
|
||||
|
||||
|
||||
class PromoCodeRedemptionResponse(BaseModel):
|
||||
"""Schema for redemption record"""
|
||||
id: int
|
||||
user: PromoCodeRedemptionUser
|
||||
coins_awarded: int
|
||||
redeemed_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# === Redeem ===
|
||||
|
||||
class PromoCodeRedeemRequest(BaseModel):
|
||||
"""Schema for redeeming a promo code"""
|
||||
code: str = Field(..., min_length=1, max_length=50)
|
||||
|
||||
|
||||
class PromoCodeRedeemResponse(BaseModel):
|
||||
"""Schema for redeem response"""
|
||||
success: bool
|
||||
coins_awarded: int
|
||||
new_balance: int
|
||||
message: str
|
||||
206
backend/app/schemas/shop.py
Normal file
206
backend/app/schemas/shop.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
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', 'boost', 'wild_card', 'lucky_dice', 'copycat', 'undo'
|
||||
marathon_id: int
|
||||
assignment_id: int | None = None # Required for skip, wild_card, copycat
|
||||
game_id: int | None = None # Required for wild_card
|
||||
target_participant_id: int | None = None # Required for copycat
|
||||
|
||||
|
||||
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
|
||||
boosts_available: int # From inventory
|
||||
has_active_boost: bool # Currently activated (one-time for current assignment)
|
||||
boost_multiplier: float | None # 1.5 if boost active
|
||||
wild_cards_available: int # From inventory
|
||||
lucky_dice_available: int # From inventory
|
||||
has_lucky_dice: bool # Currently activated
|
||||
lucky_dice_multiplier: float | None # Rolled multiplier if active
|
||||
copycats_available: int # From inventory
|
||||
undos_available: int # From inventory
|
||||
can_undo: bool # Has drop data to undo
|
||||
@@ -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
|
||||
|
||||
288
backend/app/services/coins.py
Normal file
288
backend/app/services/coins.py
Normal 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: 10,
|
||||
Difficulty.MEDIUM.value: 20,
|
||||
Difficulty.HARD.value: 35,
|
||||
}
|
||||
|
||||
# Coins for playthrough = points * this ratio
|
||||
PLAYTHROUGH_COIN_RATIO = 0.10 # 10% of points
|
||||
|
||||
# Coins awarded for marathon placements
|
||||
MARATHON_PLACE_COINS = {
|
||||
1: 500, # 1st place
|
||||
2: 250, # 2nd place
|
||||
3: 150, # 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()
|
||||
590
backend/app/services/consumables.py
Normal file
590
backend/app/services/consumables.py
Normal file
@@ -0,0 +1,590 @@
|
||||
"""
|
||||
Consumables Service - handles consumable items usage
|
||||
|
||||
Consumables:
|
||||
- skip: Skip current assignment without penalty
|
||||
- boost: x1.5 multiplier for current assignment
|
||||
- wild_card: Choose a game, get random challenge from it
|
||||
- lucky_dice: Random multiplier (0.5, 1.0, 1.5, 2.0, 2.5, 3.0)
|
||||
- copycat: Copy another participant's assignment
|
||||
- undo: Restore points and streak from last drop
|
||||
"""
|
||||
import random
|
||||
from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import (
|
||||
User, Participant, Marathon, Assignment, AssignmentStatus,
|
||||
ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge,
|
||||
BonusAssignment
|
||||
)
|
||||
|
||||
|
||||
class ConsumablesService:
|
||||
"""Service for consumable items"""
|
||||
|
||||
# Boost settings
|
||||
BOOST_MULTIPLIER = 1.5
|
||||
|
||||
# Lucky Dice multipliers (equal probability, starts from 1.5x)
|
||||
LUCKY_DICE_MULTIPLIERS = [1.5, 2.0, 2.5, 3.0, 3.5, 4.0]
|
||||
|
||||
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_boost(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Activate a Boost - multiplies points for current assignment on complete.
|
||||
|
||||
- Points for completed challenge are multiplied by BOOST_MULTIPLIER
|
||||
- One-time use (consumed on 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_wild_card(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
game_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Wild Card - choose a game and get a random challenge from it.
|
||||
|
||||
- Current assignment is replaced
|
||||
- New challenge is randomly selected from the chosen game
|
||||
- Game must be in the marathon
|
||||
|
||||
Returns: dict with new assignment info
|
||||
|
||||
Raises:
|
||||
HTTPException: If game not in marathon or no challenges available
|
||||
"""
|
||||
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 use wild card on active assignments")
|
||||
|
||||
# Verify game is in this marathon
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.where(
|
||||
Game.id == game_id,
|
||||
Game.marathon_id == marathon.id,
|
||||
)
|
||||
)
|
||||
game = result.scalar_one_or_none()
|
||||
|
||||
if not game:
|
||||
raise HTTPException(status_code=400, detail="Game not found in this marathon")
|
||||
|
||||
# Get random challenge from this game
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.where(Challenge.game_id == game_id)
|
||||
.order_by(func.random())
|
||||
.limit(1)
|
||||
)
|
||||
new_challenge = result.scalar_one_or_none()
|
||||
|
||||
if not new_challenge:
|
||||
raise HTTPException(status_code=400, detail="No challenges available for this game")
|
||||
|
||||
# Consume wild card from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.WILD_CARD.value)
|
||||
|
||||
# Store old assignment info for logging
|
||||
old_game_id = assignment.game_id
|
||||
old_challenge_id = assignment.challenge_id
|
||||
|
||||
# Update assignment with new challenge
|
||||
assignment.game_id = game_id
|
||||
assignment.challenge_id = new_challenge.id
|
||||
# Reset timestamps since it's a new challenge
|
||||
assignment.started_at = datetime.utcnow()
|
||||
assignment.deadline = None # Will be recalculated if needed
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "wild_card",
|
||||
"old_game_id": old_game_id,
|
||||
"old_challenge_id": old_challenge_id,
|
||||
"new_game_id": game_id,
|
||||
"new_challenge_id": new_challenge.id,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"game_id": game_id,
|
||||
"game_name": game.name,
|
||||
"challenge_id": new_challenge.id,
|
||||
"challenge_title": new_challenge.title,
|
||||
}
|
||||
|
||||
async def use_lucky_dice(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Lucky Dice - get a random multiplier for current assignment.
|
||||
|
||||
- Random multiplier from [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
|
||||
- Applied on next complete (stacks with boost if both active)
|
||||
- One-time use
|
||||
|
||||
Returns: dict with rolled multiplier
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or lucky dice already active
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if participant.has_lucky_dice:
|
||||
raise HTTPException(status_code=400, detail="Lucky Dice is already active")
|
||||
|
||||
# Consume lucky dice from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.LUCKY_DICE.value)
|
||||
|
||||
# Roll the dice
|
||||
multiplier = random.choice(self.LUCKY_DICE_MULTIPLIERS)
|
||||
|
||||
# Activate lucky dice
|
||||
participant.has_lucky_dice = True
|
||||
participant.lucky_dice_multiplier = multiplier
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "lucky_dice",
|
||||
"multiplier": multiplier,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"lucky_dice_activated": True,
|
||||
"multiplier": multiplier,
|
||||
}
|
||||
|
||||
async def use_copycat(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
target_participant_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Copycat - copy another participant's assignment.
|
||||
|
||||
- Current assignment is replaced with target's current/last assignment
|
||||
- Can copy even if target already completed theirs
|
||||
- Cannot copy your own assignment
|
||||
|
||||
Returns: dict with copied assignment info
|
||||
|
||||
Raises:
|
||||
HTTPException: If target not found or no assignment to copy
|
||||
"""
|
||||
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 use copycat on active assignments")
|
||||
|
||||
if target_participant_id == participant.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot copy your own assignment")
|
||||
|
||||
# Find target participant
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.where(
|
||||
Participant.id == target_participant_id,
|
||||
Participant.marathon_id == marathon.id,
|
||||
)
|
||||
)
|
||||
target_participant = result.scalar_one_or_none()
|
||||
|
||||
if not target_participant:
|
||||
raise HTTPException(status_code=400, detail="Target participant not found")
|
||||
|
||||
# Get target's most recent assignment (active or completed)
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge),
|
||||
selectinload(Assignment.game).selectinload(Game.challenges),
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == target_participant_id,
|
||||
Assignment.status.in_([
|
||||
AssignmentStatus.ACTIVE.value,
|
||||
AssignmentStatus.COMPLETED.value
|
||||
])
|
||||
)
|
||||
.order_by(Assignment.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
target_assignment = result.scalar_one_or_none()
|
||||
|
||||
if not target_assignment:
|
||||
raise HTTPException(status_code=400, detail="Target has no assignment to copy")
|
||||
|
||||
# Consume copycat from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.COPYCAT.value)
|
||||
|
||||
# Store old assignment info for logging
|
||||
old_game_id = assignment.game_id
|
||||
old_challenge_id = assignment.challenge_id
|
||||
old_is_playthrough = assignment.is_playthrough
|
||||
|
||||
# Copy the assignment - handle both challenge and playthrough
|
||||
assignment.game_id = target_assignment.game_id
|
||||
assignment.challenge_id = target_assignment.challenge_id
|
||||
assignment.is_playthrough = target_assignment.is_playthrough
|
||||
# Reset timestamps
|
||||
assignment.started_at = datetime.utcnow()
|
||||
assignment.deadline = None
|
||||
|
||||
# If copying a playthrough, recreate bonus assignments
|
||||
if target_assignment.is_playthrough:
|
||||
# Delete existing bonus assignments
|
||||
for ba in assignment.bonus_assignments:
|
||||
await db.delete(ba)
|
||||
|
||||
# Create new bonus assignments from target game's challenges
|
||||
if target_assignment.game and target_assignment.game.challenges:
|
||||
for ch in target_assignment.game.challenges:
|
||||
bonus = BonusAssignment(
|
||||
main_assignment_id=assignment.id,
|
||||
challenge_id=ch.id,
|
||||
)
|
||||
db.add(bonus)
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "copycat",
|
||||
"old_challenge_id": old_challenge_id,
|
||||
"old_game_id": old_game_id,
|
||||
"old_is_playthrough": old_is_playthrough,
|
||||
"copied_from_participant_id": target_participant_id,
|
||||
"new_challenge_id": target_assignment.challenge_id,
|
||||
"new_game_id": target_assignment.game_id,
|
||||
"new_is_playthrough": target_assignment.is_playthrough,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
# Prepare response
|
||||
if target_assignment.is_playthrough:
|
||||
title = f"Прохождение: {target_assignment.game.title}" if target_assignment.game else "Прохождение"
|
||||
else:
|
||||
title = target_assignment.challenge.title if target_assignment.challenge else None
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"copied": True,
|
||||
"game_id": target_assignment.game_id,
|
||||
"challenge_id": target_assignment.challenge_id,
|
||||
"is_playthrough": target_assignment.is_playthrough,
|
||||
"challenge_title": title,
|
||||
}
|
||||
|
||||
async def use_undo(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Undo - restore points and streak from last drop.
|
||||
|
||||
- Only works if there was a drop in this marathon
|
||||
- Can only undo once per drop
|
||||
- Restores both points and streak
|
||||
|
||||
Returns: dict with restored values
|
||||
|
||||
Raises:
|
||||
HTTPException: If no drop to undo
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if not participant.can_undo:
|
||||
raise HTTPException(status_code=400, detail="No drop to undo")
|
||||
|
||||
if participant.last_drop_points is None or participant.last_drop_streak_before is None:
|
||||
raise HTTPException(status_code=400, detail="No drop data to restore")
|
||||
|
||||
# Consume undo from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.UNDO.value)
|
||||
|
||||
# Store values for logging
|
||||
points_restored = participant.last_drop_points
|
||||
streak_restored = participant.last_drop_streak_before
|
||||
current_points = participant.total_points
|
||||
current_streak = participant.current_streak
|
||||
|
||||
# Restore points and streak
|
||||
participant.total_points += points_restored
|
||||
participant.current_streak = streak_restored
|
||||
participant.drop_count = max(0, participant.drop_count - 1)
|
||||
|
||||
# Clear undo data
|
||||
participant.can_undo = False
|
||||
participant.last_drop_points = None
|
||||
participant.last_drop_streak_before = None
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "undo",
|
||||
"points_restored": points_restored,
|
||||
"streak_restored_to": streak_restored,
|
||||
"points_before": current_points,
|
||||
"streak_before": current_streak,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"undone": True,
|
||||
"points_restored": points_restored,
|
||||
"streak_restored": streak_restored,
|
||||
"new_total_points": participant.total_points,
|
||||
"new_streak": participant.current_streak,
|
||||
}
|
||||
|
||||
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_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
|
||||
|
||||
def consume_lucky_dice_on_complete(self, participant: Participant) -> float:
|
||||
"""
|
||||
Consume lucky dice when completing assignment (called from wheel.py).
|
||||
One-time use - consumed after single complete.
|
||||
|
||||
Returns: Multiplier value (rolled multiplier if active, 1.0 otherwise)
|
||||
"""
|
||||
if participant.has_lucky_dice and participant.lucky_dice_multiplier is not None:
|
||||
multiplier = participant.lucky_dice_multiplier
|
||||
participant.has_lucky_dice = False
|
||||
participant.lucky_dice_multiplier = None
|
||||
return multiplier
|
||||
return 1.0
|
||||
|
||||
def save_drop_for_undo(
|
||||
self,
|
||||
participant: Participant,
|
||||
points_lost: int,
|
||||
streak_before: int,
|
||||
) -> None:
|
||||
"""
|
||||
Save drop data for potential undo (called from wheel.py before dropping).
|
||||
"""
|
||||
participant.last_drop_points = points_lost
|
||||
participant.last_drop_streak_before = streak_before
|
||||
participant.can_undo = True
|
||||
|
||||
|
||||
# Singleton instance
|
||||
consumables_service = ConsumablesService()
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
297
backend/app/services/shop.py
Normal file
297
backend/app/services/shop.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -276,6 +521,93 @@ class TelegramNotifier:
|
||||
)
|
||||
return await self.notify_user(db, user_id, message)
|
||||
|
||||
async def notify_challenge_approved(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
marathon_title: str,
|
||||
game_title: str,
|
||||
challenge_title: str
|
||||
) -> bool:
|
||||
"""Notify user that their proposed challenge was approved (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"
|
||||
f"Игра: {game_title}\n"
|
||||
f"Задание: {challenge_title}\n\n"
|
||||
f"Теперь оно доступно для всех участников."
|
||||
)
|
||||
return await self.notify_user(db, user_id, message)
|
||||
|
||||
async def notify_challenge_rejected(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
marathon_title: str,
|
||||
game_title: str,
|
||||
challenge_title: str
|
||||
) -> bool:
|
||||
"""Notify user that their proposed challenge was rejected (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"
|
||||
f"Игра: {game_title}\n"
|
||||
f"Задание: {challenge_title}\n\n"
|
||||
f"Ты можешь предложить другой челлендж."
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -27,9 +27,10 @@ services:
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
|
||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
|
||||
BOT_API_SECRET: ${BOT_API_SECRET:-}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
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
381
docs/disputes.md
Normal 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
242
docs/game-types.md
Normal 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
906
docs/tz-game-types.md
Normal 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 │ │ │
|
||||
│ │ │ │
|
||||
│ Доп. задания: │ │ │
|
||||
│ Все челленджи │ │ │
|
||||
│ (опционально) │ │ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Пруф: │ │ Пруф: │
|
||||
│ На прохождение │ │ По типу │
|
||||
│ игры │ │ челленджа │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Очки: │ │ Очки: │
|
||||
│ + За прохождение│ │ + За челлендж │
|
||||
│ + Бонус за доп. │ │ │
|
||||
│ челленджи │ │ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
BIN
frontend/public/telegram_banner.png
Normal file
BIN
frontend/public/telegram_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
frontend/public/telegram_bot_banner.png
Normal file
BIN
frontend/public/telegram_bot_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
||||
import { BannedScreen } from '@/components/BannedScreen'
|
||||
|
||||
// Layout
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
@@ -19,9 +21,24 @@ 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 {
|
||||
AdminLayout,
|
||||
AdminDashboardPage,
|
||||
AdminUsersPage,
|
||||
AdminMarathonsPage,
|
||||
AdminLogsPage,
|
||||
AdminBroadcastPage,
|
||||
AdminContentPage,
|
||||
AdminPromoCodesPage,
|
||||
} from '@/pages/admin'
|
||||
|
||||
// Protected route wrapper
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -46,6 +63,24 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const banInfo = useAuthStore((state) => state.banInfo)
|
||||
const syncUser = useAuthStore((state) => state.syncUser)
|
||||
|
||||
// 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 />
|
||||
<BannedScreen banInfo={banInfo} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
@@ -57,6 +92,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={
|
||||
@@ -150,6 +190,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 />} />
|
||||
@@ -159,6 +218,24 @@ function App() {
|
||||
<Route path="500" element={<ServerErrorPage />} />
|
||||
<Route path="error" element={<ServerErrorPage />} />
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||
<Route path="promo" element={<AdminPromoCodesPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||
<Route path="content" element={<AdminContentPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 404 - must be last */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import client from './client'
|
||||
import type { AdminUser, AdminMarathon, UserRole, PlatformStats } from '@/types'
|
||||
import type {
|
||||
AdminUser,
|
||||
AdminMarathon,
|
||||
UserRole,
|
||||
PlatformStats,
|
||||
AdminLogsResponse,
|
||||
BroadcastResponse,
|
||||
StaticContent,
|
||||
DashboardStats,
|
||||
AdminDispute
|
||||
} from '@/types'
|
||||
|
||||
export const adminApi = {
|
||||
// Dashboard
|
||||
getDashboard: async (): Promise<DashboardStats> => {
|
||||
const response = await client.get<DashboardStats>('/admin/dashboard')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Users
|
||||
listUsers: async (skip = 0, limit = 50, search?: string): Promise<AdminUser[]> => {
|
||||
const params: Record<string, unknown> = { skip, limit }
|
||||
listUsers: async (skip = 0, limit = 50, search?: string, bannedOnly = false): Promise<AdminUser[]> => {
|
||||
const params: Record<string, unknown> = { skip, limit, banned_only: bannedOnly }
|
||||
if (search) params.search = search
|
||||
const response = await client.get<AdminUser[]>('/admin/users', { params })
|
||||
return response.data
|
||||
@@ -24,6 +40,26 @@ export const adminApi = {
|
||||
await client.delete(`/admin/users/${id}`)
|
||||
},
|
||||
|
||||
banUser: async (id: number, reason: string, bannedUntil?: string): Promise<AdminUser> => {
|
||||
const response = await client.post<AdminUser>(`/admin/users/${id}/ban`, {
|
||||
reason,
|
||||
banned_until: bannedUntil || null,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
unbanUser: async (id: number): Promise<AdminUser> => {
|
||||
const response = await client.post<AdminUser>(`/admin/users/${id}/unban`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
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 }
|
||||
@@ -36,9 +72,105 @@ export const adminApi = {
|
||||
await client.delete(`/admin/marathons/${id}`)
|
||||
},
|
||||
|
||||
forceFinishMarathon: async (id: number): Promise<void> => {
|
||||
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')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Logs
|
||||
getLogs: async (skip = 0, limit = 50, action?: string, adminId?: number): Promise<AdminLogsResponse> => {
|
||||
const params: Record<string, unknown> = { skip, limit }
|
||||
if (action) params.action = action
|
||||
if (adminId) params.admin_id = adminId
|
||||
const response = await client.get<AdminLogsResponse>('/admin/logs', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Broadcast
|
||||
broadcastToAll: async (message: string, 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, 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
|
||||
},
|
||||
|
||||
// Static Content
|
||||
listContent: async (): Promise<StaticContent[]> => {
|
||||
const response = await client.get<StaticContent[]>('/admin/content')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getContent: async (key: string): Promise<StaticContent> => {
|
||||
const response = await client.get<StaticContent>(`/admin/content/${key}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||
const response = await client.put<StaticContent>(`/admin/content/${key}`, { title, content })
|
||||
return response.data
|
||||
},
|
||||
|
||||
createContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
|
||||
return response.data
|
||||
},
|
||||
|
||||
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)
|
||||
export const contentApi = {
|
||||
getPublicContent: async (key: string): Promise<StaticContent> => {
|
||||
const response = await client.get<StaticContent>(`/content/${key}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import client from './client'
|
||||
import type { TokenResponse, User } from '@/types'
|
||||
import type { TokenResponse, LoginResponse, User } from '@/types'
|
||||
|
||||
export interface RegisterData {
|
||||
login: string
|
||||
@@ -18,8 +18,15 @@ export const authApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
login: async (data: LoginData): Promise<TokenResponse> => {
|
||||
const response = await client.post<TokenResponse>('/auth/login', data)
|
||||
login: async (data: LoginData): Promise<LoginResponse> => {
|
||||
const response = await client.post<LoginResponse>('/auth/login', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
verify2FA: async (sessionId: number, code: string): Promise<TokenResponse> => {
|
||||
const response = await client.post<TokenResponse>('/auth/2fa/verify', null, {
|
||||
params: { session_id: sessionId, code }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { useAuthStore, type BanInfo } from '@/store/auth'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
@@ -18,16 +19,40 @@ client.interceptors.request.use((config) => {
|
||||
return config
|
||||
})
|
||||
|
||||
// Helper to check if detail is ban info object
|
||||
function isBanInfo(detail: unknown): detail is BanInfo {
|
||||
return (
|
||||
typeof detail === 'object' &&
|
||||
detail !== null &&
|
||||
'banned_at' in detail &&
|
||||
'reason' in detail
|
||||
)
|
||||
}
|
||||
|
||||
// Response interceptor to handle errors
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<{ detail: string }>) => {
|
||||
// Unauthorized - redirect to login
|
||||
(error: AxiosError<{ detail: string | BanInfo }>) => {
|
||||
// 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) {
|
||||
const detail = error.response.data?.detail
|
||||
if (isBanInfo(detail)) {
|
||||
// User is banned - set ban info in store
|
||||
useAuthStore.getState().setBanned(detail)
|
||||
}
|
||||
}
|
||||
|
||||
// Server error or network error - redirect to 500 page
|
||||
if (
|
||||
|
||||
@@ -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
|
||||
@@ -79,6 +111,11 @@ export const gamesApi = {
|
||||
await client.delete(`/challenges/${id}`)
|
||||
},
|
||||
|
||||
updateChallenge: async (id: number, data: Partial<CreateChallengeData>): Promise<Challenge> => {
|
||||
const response = await client.patch<Challenge>(`/challenges/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
|
||||
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
||||
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
|
||||
@@ -89,4 +126,30 @@ export const gamesApi = {
|
||||
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Proposed challenges
|
||||
proposeChallenge: async (gameId: number, data: CreateChallengeData): Promise<Challenge> => {
|
||||
const response = await client.post<Challenge>(`/games/${gameId}/propose-challenge`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/proposed-challenges`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getMyProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/my-proposed-challenges`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
approveChallenge: async (id: number): Promise<Challenge> => {
|
||||
const response = await client.patch<Challenge>(`/challenges/${id}/approve`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
rejectChallenge: async (id: number): Promise<Challenge> => {
|
||||
const response = await client.patch<Challenge>(`/challenges/${id}/reject`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ export { marathonsApi } from './marathons'
|
||||
export { gamesApi } from './games'
|
||||
export { wheelApi } from './wheel'
|
||||
export { feedApi } from './feed'
|
||||
export { adminApi } from './admin'
|
||||
export { adminApi, contentApi } from './admin'
|
||||
export { eventsApi } from './events'
|
||||
export { challengesApi } from './challenges'
|
||||
export { assignmentsApi } from './assignments'
|
||||
export { usersApi } from './users'
|
||||
export { telegramApi } from './telegram'
|
||||
export { shopApi } from './shop'
|
||||
export { promoApi } from './promo'
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
34
frontend/src/api/promo.ts
Normal file
34
frontend/src/api/promo.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import client from './client'
|
||||
import type {
|
||||
PromoCode,
|
||||
PromoCodeCreate,
|
||||
PromoCodeUpdate,
|
||||
PromoCodeRedemption,
|
||||
PromoCodeRedeemResponse,
|
||||
} from '@/types'
|
||||
|
||||
export const promoApi = {
|
||||
// User endpoint - redeem promo code
|
||||
redeem: (code: string) =>
|
||||
client.post<PromoCodeRedeemResponse>('/promo/redeem', { code }),
|
||||
|
||||
// Admin endpoints
|
||||
admin: {
|
||||
list: (includeInactive = false) =>
|
||||
client.get<PromoCode[]>('/promo/admin/list', {
|
||||
params: { include_inactive: includeInactive },
|
||||
}),
|
||||
|
||||
create: (data: PromoCodeCreate) =>
|
||||
client.post<PromoCode>('/promo/admin/create', data),
|
||||
|
||||
update: (id: number, data: PromoCodeUpdate) =>
|
||||
client.put<PromoCode>(`/promo/admin/${id}`, data),
|
||||
|
||||
delete: (id: number) =>
|
||||
client.delete<{ message: string }>(`/promo/admin/${id}`),
|
||||
|
||||
getRedemptions: (id: number) =>
|
||||
client.get<PromoCodeRedemption[]>(`/promo/admin/${id}/redemptions`),
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user