40 Commits

Author SHA1 Message Date
2b6f2888ee Fix site 2026-01-10 08:48:53 +07:00
b6eecc4483 Time tracker app 2026-01-10 08:48:52 +07:00
3256c40841 Add widget preview and combined widget
- Add live preview iframe in widget settings modal
- Create combined widget (all-in-one: leaderboard + current + progress)
- Add widget type tabs for switching preview
- Update documentation with completed tasks

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 19:55:48 +03:00
146ed5e489 Add OBS widgets for streamers
- Add widget token authentication system
- Create leaderboard, current assignment, and progress widgets
- Support dark, light, and neon themes
- Add widget settings modal for URL generation
- Fix avatar loading through backend API proxy

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 19:16:50 +03:00
cd78a99ce7 Remove points limit 2026-01-09 19:02:08 +07:00
76de7ccbdb fix 2026-01-08 10:06:59 +07:00
e63d6c8489 Promocode system 2026-01-08 10:02:15 +07:00
1751c4dd4c rework shop 2026-01-08 08:49:51 +07:00
2874b64481 Bug fixes 2026-01-08 06:51:15 +07:00
4488a13808 Merge branch 'master' into marathon-v2 2026-01-08 05:37:27 +07:00
ca49e42f74 Fix common enemy 2026-01-08 05:29:55 +07:00
18fe95effc Fix events 2026-01-05 23:41:22 +07:00
6a7717a474 Add shop 2026-01-05 08:42:49 +07:00
65b2512d8c Add upload images 2026-01-04 04:58:41 +07:00
81d992abe6 remake send push systems 2026-01-04 04:16:54 +07:00
9014d5d79d Add notification status to users table in AP 2026-01-04 03:42:11 +07:00
18ffff5473 Fix games list 2026-01-04 03:17:17 +07:00
475e2cf4cd Add notification settings 2026-01-04 02:47:38 +07:00
7a3576aec0 a 2026-01-03 00:43:26 +07:00
d295ff2aff Исключить игры типа Прохождение из проверки челленджей
При старте марафона теперь проверяются только игры с типом challenges.
Игры с типом playthrough не требуют наличия челленджей.

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'
""")

View 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}')
""")

View 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')

View File

@@ -0,0 +1,30 @@
"""Add tracked_time_minutes to assignments
Revision ID: 029_add_tracked_time
Revises: 028_add_promo_codes
Create Date: 2026-01-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '029_add_tracked_time'
down_revision: Union[str, None] = '028_add_promo_codes'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add tracked_time_minutes column to assignments table
op.add_column(
'assignments',
sa.Column('tracked_time_minutes', sa.Integer(), nullable=False, server_default='0')
)
def downgrade() -> None:
op.drop_column('assignments', 'tracked_time_minutes')

View File

@@ -0,0 +1,36 @@
"""Add widget tokens
Revision ID: 029
Revises: 028
Create Date: 2025-01-09
"""
from alembic import op
import sqlalchemy as sa
revision = '029_add_widget_tokens'
down_revision = '028_add_promo_codes'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'widget_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(64), nullable=False),
sa.Column('participant_id', sa.Integer(), nullable=False),
sa.Column('marathon_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['participant_id'], ['participants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['marathon_id'], ['marathons.id'], ondelete='CASCADE'),
)
op.create_index('ix_widget_tokens_token', 'widget_tokens', ['token'], unique=True)
def downgrade():
op.drop_index('ix_widget_tokens_token', table_name='widget_tokens')
op.drop_table('widget_tokens')

View File

@@ -4,6 +4,7 @@ from datetime import datetime
from fastapi import Depends, HTTPException, status, Header from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
@@ -35,7 +36,16 @@ async def get_current_user(
detail="Invalid token payload", 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() user = result.scalar_one_or_none()
if user is None: if user is None:

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser 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.models.dispute import DisputeStatus
from app.schemas import FeedResponse, ActivityResponse, UserPublic from app.schemas import FeedResponse, ActivityResponse, UserPublic
@@ -37,7 +37,12 @@ async def get_feed(
# Get activities # Get activities
result = await db.execute( result = await db.execute(
select(Activity) 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) .where(Activity.marathon_id == marathon_id)
.order_by(Activity.created_at.desc()) .order_by(Activity.created_at.desc())
.limit(limit) .limit(limit)

View File

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

View File

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

299
backend/app/api/v1/promo.py Normal file
View 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
View 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,
)

View File

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

View File

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

View File

@@ -9,15 +9,21 @@ from app.core.config import settings
from app.models import ( from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant, Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType, Assignment, AssignmentStatus, Activity, ActivityType,
EventType, Difficulty, User EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType,
DisputeStatus,
) )
from app.schemas import ( from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult, SpinResult, AssignmentResponse, CompleteResult, DropResult,
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
TrackTimeRequest,
) )
from app.schemas.game import PlaythroughInfo
from app.services.points import PointsService from app.services.points import PointsService
from app.services.events import event_service from app.services.events import event_service
from app.services.storage import storage_service from app.services.storage import storage_service
from app.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"]) router = APIRouter(tags=["wheel"])
@@ -48,7 +54,9 @@ async def get_active_assignment(db, participant_id: int, is_event: bool = False)
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
) )
.where( .where(
Assignment.participant_id == participant_id, Assignment.participant_id == participant_id,
@@ -64,7 +72,9 @@ async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
) )
.where( .where(
Assignment.participant_id == participant_id, Assignment.participant_id == participant_id,
@@ -94,7 +104,7 @@ async def activate_returned_assignment(db, returned_assignment: Assignment) -> N
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult) @router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession): async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Spin the wheel to get a random game and challenge""" """Spin the wheel to get a random game and challenge (or playthrough)"""
# Check marathon is active # Check marathon is active
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()
@@ -115,43 +125,115 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if active: if active:
raise HTTPException(status_code=400, detail="You already have an active assignment") raise HTTPException(status_code=400, detail="You already have an active assignment")
# Get available games (filtered by completion status)
available_games, _ = await get_available_games_for_participant(db, participant, marathon_id)
if not available_games:
raise HTTPException(status_code=400, detail="No games available for spin")
# Check active event # Check active event
active_event = await event_service.get_active_event(db, marathon_id) active_event = await event_service.get_active_event(db, marathon_id)
game = None game = None
challenge = None challenge = None
is_playthrough = False
# Handle special event cases (excluding Common Enemy - it has separate flow) # Handle special event cases (excluding Common Enemy - it has separate flow)
# Events only apply to challenges-type games, not playthrough
if active_event: if active_event:
if active_event.type == EventType.JACKPOT.value: if active_event.type == EventType.JACKPOT.value:
# Jackpot: Get hard challenge only # Jackpot: Get hard challenge only (from challenges-type games)
challenge = await event_service.get_random_hard_challenge(db, marathon_id) challenge = await event_service.get_random_hard_challenge(db, marathon_id)
if challenge: if challenge:
# Load game for challenge # Check if this game is available for the participant
result = await db.execute( result = await db.execute(
select(Game).where(Game.id == challenge.game_id) select(Game).where(Game.id == challenge.game_id)
) )
game = result.scalar_one_or_none() game = result.scalar_one_or_none()
if game and game.id in [g.id for g in available_games]:
# Consume jackpot (one-time use) # Consume jackpot (one-time use)
await event_service.consume_jackpot(db, active_event.id) await event_service.consume_jackpot(db, active_event.id)
else:
# Game not available, fall back to normal selection
game = None
challenge = None
# Note: Common Enemy is handled separately via event-assignment endpoints # Note: Common Enemy is handled separately via event-assignment endpoints
# Normal random selection if no special event handling # Normal random selection if no special event handling
if not game or not challenge: if not game:
game = random.choice(available_games)
if game.game_type == GameType.PLAYTHROUGH.value:
# Playthrough game - no challenge selection
# Events that apply to playthrough: GOLDEN_HOUR, DOUBLE_RISK, COMMON_ENEMY
# Events that DON'T apply: JACKPOT (hard challenges only)
is_playthrough = True
challenge = None
if active_event and active_event.type == EventType.JACKPOT.value:
active_event = None # Jackpot doesn't apply to playthrough
else:
# Challenges game - select random challenge
if not game.challenges:
# Reload challenges if not loaded
result = await db.execute( result = await db.execute(
select(Game) select(Game)
.options(selectinload(Game.challenges)) .options(selectinload(Game.challenges))
.where(Game.marathon_id == marathon_id) .where(Game.id == game.id)
) )
games = [g for g in result.scalars().all() if g.challenges] game = result.scalar_one()
if not games: # Filter out already completed challenges
raise HTTPException(status_code=400, detail="No games with challenges available") completed_result = await db.execute(
select(Assignment.challenge_id)
.where(
Assignment.participant_id == participant.id,
Assignment.challenge_id.in_([c.id for c in game.challenges]),
Assignment.status == AssignmentStatus.COMPLETED.value,
)
)
completed_ids = set(completed_result.scalars().all())
available_challenges = [c for c in game.challenges if c.id not in completed_ids]
game = random.choice(games) if not available_challenges:
challenge = random.choice(game.challenges) raise HTTPException(status_code=400, detail="No challenges available for this game")
# Create assignment (store event_type for jackpot multiplier on completion) challenge = random.choice(available_challenges)
# Create assignment
if is_playthrough:
# Playthrough assignment - link to game, not challenge
assignment = Assignment(
participant_id=participant.id,
game_id=game.id,
is_playthrough=True,
status=AssignmentStatus.ACTIVE.value,
event_type=active_event.type if active_event else None,
)
db.add(assignment)
await db.flush() # Get assignment.id for bonus assignments
# Create bonus assignments for all challenges
bonus_challenges = []
if game.challenges:
for ch in game.challenges:
bonus = BonusAssignment(
main_assignment_id=assignment.id,
challenge_id=ch.id,
)
db.add(bonus)
bonus_challenges.append(ch)
# Log activity
activity_data = {
"game": game.title,
"is_playthrough": True,
"points": game.playthrough_points,
"bonus_challenges_count": len(bonus_challenges),
}
if active_event:
activity_data["event_type"] = active_event.type
else:
# Regular challenge assignment
assignment = Assignment( assignment = Assignment(
participant_id=participant.id, participant_id=participant.id,
challenge_id=challenge.id, challenge_id=challenge.id,
@@ -181,10 +263,17 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
await db.commit() await db.commit()
await db.refresh(assignment) await db.refresh(assignment)
# Calculate drop penalty (considers active event for double_risk) # Calculate drop penalty
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event) if is_playthrough:
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, None # No events for playthrough
)
else:
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, challenge.points, active_event
)
# Get challenges count (avoid lazy loading in async context) # Get challenges count
challenges_count = 0 challenges_count = 0
if 'challenges' in game.__dict__: if 'challenges' in game.__dict__:
challenges_count = len(game.challenges) challenges_count = len(game.challenges)
@@ -193,9 +282,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id) select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
) )
return SpinResult( # Build response
assignment_id=assignment.id, game_response = GameResponse(
game=GameResponse(
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"), cover_url=storage_service.get_url(game.cover_path, "covers"),
@@ -204,7 +292,52 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
added_by=None, added_by=None,
challenges_count=challenges_count, challenges_count=challenges_count,
created_at=game.created_at, created_at=game.created_at,
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
)
if is_playthrough:
# Return playthrough result
return SpinResult(
assignment_id=assignment.id,
game=game_response,
challenge=None,
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
), ),
bonus_challenges=[
ChallengeResponse(
id=ch.id,
title=ch.title,
description=ch.description,
type=ch.type,
difficulty=ch.difficulty,
points=ch.points,
estimated_time=ch.estimated_time,
proof_type=ch.proof_type,
proof_hint=ch.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=ch.is_generated,
created_at=ch.created_at,
)
for ch in bonus_challenges
],
can_drop=True,
drop_penalty=drop_penalty,
event_type=active_event.type if active_event else None,
)
else:
# Return challenge result
return SpinResult(
assignment_id=assignment.id,
game=game_response,
challenge=ChallengeResponse( challenge=ChallengeResponse(
id=challenge.id, id=challenge.id,
title=challenge.title, title=challenge.title,
@@ -215,12 +348,14 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
estimated_time=challenge.estimated_time, estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type, proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint, proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None), game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
), ),
is_playthrough=False,
can_drop=True, can_drop=True,
drop_penalty=drop_penalty, drop_penalty=drop_penalty,
event_type=active_event.type if active_event else None,
) )
@@ -230,9 +365,86 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
participant = await get_participant_or_403(db, current_user.id, marathon_id) participant = await get_participant_or_403(db, current_user.id, marathon_id)
assignment = await get_active_assignment(db, participant.id, is_event=False) assignment = await get_active_assignment(db, participant.id, is_event=False)
# If no active assignment, check for returned assignments
if not assignment:
returned = await get_oldest_returned_assignment(db, participant.id)
if returned:
# Activate the returned assignment
await activate_returned_assignment(db, returned)
await db.commit()
# Reload with all relationships
assignment = await get_active_assignment(db, participant.id, is_event=False)
if not assignment: if not assignment:
return None return None
# Handle playthrough assignments
if assignment.is_playthrough:
game = assignment.game
# Use stored event_type for playthrough
# All events except JACKPOT apply (DOUBLE_RISK = free drop, others affect points)
playthrough_event = None
if assignment.event_type and assignment.event_type != EventType.JACKPOT.value:
class MockEvent:
def __init__(self, event_type):
self.type = event_type
playthrough_event = MockEvent(assignment.event_type)
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, playthrough_event
)
# Build bonus challenges response
from app.schemas.assignment import BonusAssignmentResponse
bonus_responses = []
for ba in assignment.bonus_assignments:
bonus_responses.append(BonusAssignmentResponse(
id=ba.id,
challenge=ChallengeResponse(
id=ba.challenge.id,
title=ba.challenge.title,
description=ba.challenge.description,
type=ba.challenge.type,
difficulty=ba.challenge.difficulty,
points=ba.challenge.points,
estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at,
),
status=ba.status,
proof_url=ba.proof_url,
proof_comment=ba.proof_comment,
points_earned=ba.points_earned,
completed_at=ba.completed_at,
))
return AssignmentResponse(
id=assignment.id,
challenge=None,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
),
status=assignment.status,
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
drop_penalty=drop_penalty,
bonus_challenges=bonus_responses,
event_type=assignment.event_type,
)
# Regular challenge assignment
challenge = assignment.challenge challenge = assignment.challenge
game = challenge.game game = challenge.game
@@ -252,7 +464,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
estimated_time=challenge.estimated_time, estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type, proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint, proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None), game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
), ),
@@ -264,6 +476,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
started_at=assignment.started_at, started_at=assignment.started_at,
completed_at=assignment.completed_at, completed_at=assignment.completed_at,
drop_penalty=drop_penalty, drop_penalty=drop_penalty,
event_type=assignment.event_type,
) )
@@ -274,15 +487,19 @@ async def complete_assignment(
db: DbSession, db: DbSession,
proof_url: str | None = Form(None), proof_url: str | None = Form(None),
comment: str | None = Form(None), comment: str | None = Form(None),
proof_file: UploadFile | None = File(None), proof_file: UploadFile | None = File(None), # Legacy single file support
proof_files: list[UploadFile] = File([]), # Multiple files support
): ):
"""Complete a regular assignment with proof (not event assignments)""" """Complete a regular assignment with proof (not event assignments)"""
# Get assignment # Get assignment with all needed relationships
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.participant), selectinload(Assignment.participant),
selectinload(Assignment.challenge), selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For bonus points
selectinload(Assignment.dispute), # To check if it was previously disputed
) )
.where(Assignment.id == assignment_id) .where(Assignment.id == assignment_id)
) )
@@ -301,62 +518,208 @@ async def complete_assignment(
if assignment.is_event_assignment: if assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments") raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
# Need either file or URL # Combine legacy single file with new multiple files
if not proof_file and not proof_url: all_files = []
if proof_file:
all_files.append(proof_file)
if proof_files:
all_files.extend(proof_files)
# For playthrough: need either file(s) or URL or comment (proof is flexible)
# For challenges: need either file(s) or URL
if assignment.is_playthrough:
if not all_files and not proof_url and not comment:
raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)")
else:
if not all_files and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)") raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
# Handle file upload # Handle multiple file uploads
if proof_file: if all_files:
contents = await proof_file.read() from app.models import AssignmentProof
for idx, file in enumerate(all_files):
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE: if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", detail=f"File {file.filename} too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
) )
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg" ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS: if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", detail=f"Invalid file type for {file.filename}. Allowed: {settings.ALLOWED_EXTENSIONS}",
) )
# Determine file type (image or video)
file_type = "video" if ext in ["mp4", "webm", "mov", "avi"] else "image"
# Upload file to storage # Upload file to storage
filename = storage_service.generate_filename(assignment_id, proof_file.filename) filename = storage_service.generate_filename(f"{assignment_id}_{idx}", file.filename)
file_path = await storage_service.upload_file( file_path = await storage_service.upload_file(
content=contents, content=contents,
folder="proofs", folder="proofs",
filename=filename, filename=filename,
content_type=proof_file.content_type or "application/octet-stream", content_type=file.content_type or "application/octet-stream",
) )
# Create AssignmentProof record
proof_record = AssignmentProof(
assignment_id=assignment_id,
file_path=file_path,
file_type=file_type,
order_index=idx
)
db.add(proof_record)
# Legacy: set proof_path on first file for backward compatibility
if idx == 0:
assignment.proof_path = file_path assignment.proof_path = file_path
else:
# Set proof URL if provided
if proof_url:
assignment.proof_url = proof_url assignment.proof_url = proof_url
assignment.proof_comment = comment assignment.proof_comment = comment
# Calculate points
participant = assignment.participant participant = assignment.participant
challenge = assignment.challenge
# Get marathon_id for activity and event check # Handle playthrough completion
result = await db.execute( if assignment.is_playthrough:
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id) game = assignment.game
marathon_id = game.marathon_id
# If tracked time exists (from desktop app), calculate points as hours * 30
# Otherwise use admin-set playthrough_points
if assignment.tracked_time_minutes > 0:
hours = assignment.tracked_time_minutes / 60
base_playthrough_points = int(hours * 30)
else:
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 # Check active event for point multipliers
active_event = await event_service.get_active_event(db, marathon_id) active_event = await event_service.get_active_event(db, marathon_id)
# For jackpot: use the event_type stored in assignment (since event may be over) # For jackpot: use the event_type stored in assignment (since event may be over)
# For other events: use the currently active event
effective_event = active_event effective_event = active_event
# Handle assignment-level event types (jackpot) # Handle assignment-level event types (jackpot)
if assignment.event_type == EventType.JACKPOT.value: if assignment.event_type == EventType.JACKPOT.value:
# Create a mock event object for point calculation
class MockEvent: class MockEvent:
def __init__(self, event_type): def __init__(self, event_type):
self.type = event_type self.type = event_type
@@ -377,6 +740,13 @@ async def complete_assignment(
total_points += common_enemy_bonus total_points += common_enemy_bonus
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}") 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 # Update assignment
assignment.status = AssignmentStatus.COMPLETED.value assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points assignment.points_earned = total_points
@@ -386,18 +756,40 @@ async def complete_assignment(
# Update participant # Update participant
participant.total_points += total_points participant.total_points += total_points
participant.current_streak += 1 participant.current_streak += 1
participant.drop_count = 0 # Reset drop counter on success participant.drop_count = 0
# 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 # Log activity
activity_data = { activity_data = {
"assignment_id": assignment.id, "assignment_id": assignment.id,
"game": full_challenge.game.title, "game": challenge.game.title,
"challenge": challenge.title, "challenge": challenge.title,
"difficulty": challenge.difficulty, "difficulty": challenge.difficulty,
"points": total_points, "points": total_points,
"streak": participant.current_streak, "streak": participant.current_streak,
} }
# Log event info (use assignment's event_type for jackpot, active_event for others) if is_redo:
activity_data["is_redo"] = True
if 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: if assignment.event_type == EventType.JACKPOT.value:
activity_data["event_type"] = assignment.event_type activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus activity_data["event_bonus"] = event_bonus
@@ -418,7 +810,6 @@ async def complete_assignment(
# If common enemy event auto-closed, log the event end with winners # If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners: if common_enemy_closed and common_enemy_winners:
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
# Load winner nicknames
winner_user_ids = [w["user_id"] for w in common_enemy_winners] winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute( users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids)) select(User).where(User.id.in_(winner_user_ids))
@@ -438,7 +829,7 @@ async def complete_assignment(
event_end_activity = Activity( event_end_activity = Activity(
marathon_id=marathon_id, marathon_id=marathon_id,
user_id=current_user.id, # Last completer triggers the close user_id=current_user.id,
type=ActivityType.EVENT_END.value, type=ActivityType.EVENT_END.value,
data={ data={
"event_type": EventType.COMMON_ENEMY.value, "event_type": EventType.COMMON_ENEMY.value,
@@ -451,7 +842,7 @@ async def complete_assignment(
await db.commit() await db.commit()
# Check for returned assignments and activate the oldest one # Check for returned assignments
returned_assignment = await get_oldest_returned_assignment(db, participant.id) returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment: if returned_assignment:
await activate_returned_assignment(db, returned_assignment) await activate_returned_assignment(db, returned_assignment)
@@ -463,18 +854,52 @@ async def complete_assignment(
streak_bonus=streak_bonus, streak_bonus=streak_bonus,
total_points=participant.total_points, total_points=participant.total_points,
new_streak=participant.current_streak, new_streak=participant.current_streak,
coins_earned=coins_earned,
) )
@router.patch("/assignments/{assignment_id}/track-time", response_model=MessageResponse)
async def track_assignment_time(
assignment_id: int,
data: TrackTimeRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Update tracked time for an assignment (from desktop app)"""
result = await db.execute(
select(Assignment)
.options(selectinload(Assignment.participant))
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="This is not your assignment")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
# Update tracked time (replace with new value)
assignment.tracked_time_minutes = max(0, data.minutes)
await db.commit()
return MessageResponse(message=f"Tracked time updated to {data.minutes} minutes")
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult) @router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession): async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
"""Drop current assignment""" """Drop current assignment"""
# Get assignment # Get assignment with all needed relationships
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.participant), selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments), # For resetting bonuses on drop
) )
.where(Assignment.id == assignment_id) .where(Assignment.id == assignment_id)
) )
@@ -490,6 +915,79 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
raise HTTPException(status_code=400, detail="Assignment is not active") raise HTTPException(status_code=400, detail="Assignment is not active")
participant = assignment.participant participant = assignment.participant
# Handle playthrough drop
if assignment.is_playthrough:
game = assignment.game
marathon_id = game.marathon_id
# Use stored event_type for drop penalty calculation
# DOUBLE_RISK = free drop (0 penalty)
playthrough_event = None
if assignment.event_type and assignment.event_type != EventType.JACKPOT.value:
class MockEvent:
def __init__(self, event_type):
self.type = event_type
playthrough_event = MockEvent(assignment.event_type)
penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, playthrough_event
)
# 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 marathon_id = assignment.challenge.game.marathon_id
# Check active event for free drops (double_risk) # Check active event for free drops (double_risk)
@@ -498,6 +996,11 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
# Calculate penalty (0 if double_risk event is active) # Calculate penalty (0 if double_risk event is active)
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event) 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 # Update assignment
assignment.status = AssignmentStatus.DROPPED.value assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow() assignment.completed_at = datetime.utcnow()
@@ -550,7 +1053,9 @@ async def get_my_history(
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
) )
.where(Assignment.participant_id == participant.id) .where(Assignment.participant_id == participant.id)
.order_by(Assignment.started_at.desc()) .order_by(Assignment.started_at.desc())
@@ -559,8 +1064,61 @@ async def get_my_history(
) )
assignments = result.scalars().all() assignments = result.scalars().all()
return [ responses = []
AssignmentResponse( for a in assignments:
if a.is_playthrough:
# Playthrough assignment
game = a.game
from app.schemas.assignment import BonusAssignmentResponse
bonus_responses = [
BonusAssignmentResponse(
id=ba.id,
challenge=ChallengeResponse(
id=ba.challenge.id,
title=ba.challenge.title,
description=ba.challenge.description,
type=ba.challenge.type,
difficulty=ba.challenge.difficulty,
points=ba.challenge.points,
estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at,
),
status=ba.status,
proof_url=ba.proof_url,
proof_comment=ba.proof_comment,
points_earned=ba.points_earned,
completed_at=ba.completed_at,
)
for ba in a.bonus_assignments
]
responses.append(AssignmentResponse(
id=a.id,
challenge=None,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
),
status=a.status,
proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
proof_comment=a.proof_comment,
points_earned=a.points_earned,
streak_at_completion=a.streak_at_completion,
started_at=a.started_at,
completed_at=a.completed_at,
bonus_challenges=bonus_responses,
))
else:
# Regular challenge assignment
responses.append(AssignmentResponse(
id=a.id, id=a.id,
challenge=ChallengeResponse( challenge=ChallengeResponse(
id=a.challenge.id, id=a.challenge.id,
@@ -575,7 +1133,9 @@ async def get_my_history(
game=GameShort( game=GameShort(
id=a.challenge.game.id, id=a.challenge.game.id,
title=a.challenge.game.title, title=a.challenge.game.title,
cover_url=None cover_url=None,
download_url=a.challenge.game.download_url,
game_type=a.challenge.game.game_type,
), ),
is_generated=a.challenge.is_generated, is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at, created_at=a.challenge.created_at,
@@ -587,6 +1147,6 @@ async def get_my_history(
streak_at_completion=a.streak_at_completion, streak_at_completion=a.streak_at_completion,
started_at=a.started_at, started_at=a.started_at,
completed_at=a.completed_at, completed_at=a.completed_at,
) ))
for a in assignments
] return responses

View File

@@ -0,0 +1,423 @@
import secrets
from datetime import datetime
from fastapi import APIRouter, HTTPException, status, Query
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser, require_participant
from app.models import (
WidgetToken, Participant, Marathon, Assignment, AssignmentStatus,
BonusAssignment, BonusAssignmentStatus,
)
from app.schemas.widget import (
WidgetTokenResponse,
WidgetTokenListItem,
WidgetLeaderboardEntry,
WidgetLeaderboardResponse,
WidgetCurrentResponse,
WidgetProgressResponse,
)
from app.schemas.common import MessageResponse
from app.core.config import settings
router = APIRouter(prefix="/widgets", tags=["widgets"])
def get_avatar_url(user) -> str | None:
"""Get avatar URL - through backend API if user has avatar, else telegram"""
if user.avatar_path:
return f"/api/v1/users/{user.id}/avatar"
return user.telegram_avatar_url
def generate_widget_token() -> str:
"""Generate a secure widget token"""
return f"wgt_{secrets.token_urlsafe(32)}"
def build_widget_urls(marathon_id: int, token: str) -> dict[str, str]:
"""Build widget URLs for the token"""
base_url = settings.FRONTEND_URL or "http://localhost:5173"
params = f"marathon={marathon_id}&token={token}"
return {
"leaderboard": f"{base_url}/widget/leaderboard?{params}",
"current": f"{base_url}/widget/current?{params}",
"progress": f"{base_url}/widget/progress?{params}",
}
# === Token management (authenticated) ===
@router.post("/marathons/{marathon_id}/token", response_model=WidgetTokenResponse)
async def create_widget_token(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Create a widget token for the current user in a marathon"""
participant = await require_participant(db, current_user.id, marathon_id)
# Check if user already has an active token
existing = await db.scalar(
select(WidgetToken).where(
WidgetToken.participant_id == participant.id,
WidgetToken.marathon_id == marathon_id,
WidgetToken.is_active == True,
)
)
if existing:
# Return existing token
return WidgetTokenResponse(
id=existing.id,
token=existing.token,
created_at=existing.created_at,
expires_at=existing.expires_at,
is_active=existing.is_active,
urls=build_widget_urls(marathon_id, existing.token),
)
# Create new token
token = generate_widget_token()
widget_token = WidgetToken(
token=token,
participant_id=participant.id,
marathon_id=marathon_id,
)
db.add(widget_token)
await db.commit()
await db.refresh(widget_token)
return WidgetTokenResponse(
id=widget_token.id,
token=widget_token.token,
created_at=widget_token.created_at,
expires_at=widget_token.expires_at,
is_active=widget_token.is_active,
urls=build_widget_urls(marathon_id, widget_token.token),
)
@router.get("/marathons/{marathon_id}/tokens", response_model=list[WidgetTokenListItem])
async def list_widget_tokens(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""List all widget tokens for the current user in a marathon"""
participant = await require_participant(db, current_user.id, marathon_id)
result = await db.execute(
select(WidgetToken)
.where(
WidgetToken.participant_id == participant.id,
WidgetToken.marathon_id == marathon_id,
)
.order_by(WidgetToken.created_at.desc())
)
tokens = result.scalars().all()
return [
WidgetTokenListItem(
id=t.id,
token=t.token,
created_at=t.created_at,
is_active=t.is_active,
)
for t in tokens
]
@router.delete("/tokens/{token_id}", response_model=MessageResponse)
async def revoke_widget_token(
token_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Revoke a widget token"""
result = await db.execute(
select(WidgetToken)
.options(selectinload(WidgetToken.participant))
.where(WidgetToken.id == token_id)
)
widget_token = result.scalar_one_or_none()
if not widget_token:
raise HTTPException(status_code=404, detail="Token not found")
if widget_token.participant.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not authorized to revoke this token")
widget_token.is_active = False
await db.commit()
return MessageResponse(message="Token revoked")
@router.post("/tokens/{token_id}/regenerate", response_model=WidgetTokenResponse)
async def regenerate_widget_token(
token_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Regenerate a widget token (deactivates old, creates new)"""
result = await db.execute(
select(WidgetToken)
.options(selectinload(WidgetToken.participant))
.where(WidgetToken.id == token_id)
)
old_token = result.scalar_one_or_none()
if not old_token:
raise HTTPException(status_code=404, detail="Token not found")
if old_token.participant.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not authorized")
# Deactivate old token
old_token.is_active = False
# Create new token
new_token = WidgetToken(
token=generate_widget_token(),
participant_id=old_token.participant_id,
marathon_id=old_token.marathon_id,
)
db.add(new_token)
await db.commit()
await db.refresh(new_token)
return WidgetTokenResponse(
id=new_token.id,
token=new_token.token,
created_at=new_token.created_at,
expires_at=new_token.expires_at,
is_active=new_token.is_active,
urls=build_widget_urls(new_token.marathon_id, new_token.token),
)
# === Public widget endpoints (authenticated via widget token) ===
async def validate_widget_token(token: str, marathon_id: int, db) -> WidgetToken:
"""Validate widget token and return it"""
result = await db.execute(
select(WidgetToken)
.options(
selectinload(WidgetToken.participant).selectinload(Participant.user),
selectinload(WidgetToken.marathon),
)
.where(
WidgetToken.token == token,
WidgetToken.marathon_id == marathon_id,
WidgetToken.is_active == True,
)
)
widget_token = result.scalar_one_or_none()
if not widget_token:
raise HTTPException(status_code=401, detail="Invalid widget token")
if widget_token.expires_at and widget_token.expires_at < datetime.utcnow():
raise HTTPException(status_code=401, detail="Widget token expired")
return widget_token
@router.get("/data/leaderboard", response_model=WidgetLeaderboardResponse)
async def widget_leaderboard(
marathon: int = Query(..., description="Marathon ID"),
token: str = Query(..., description="Widget token"),
count: int = Query(5, ge=1, le=50, description="Number of participants"),
db: DbSession = None,
):
"""Get leaderboard data for widget"""
widget_token = await validate_widget_token(token, marathon, db)
current_participant = widget_token.participant
# Get all participants ordered by points
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon)
.order_by(Participant.total_points.desc())
)
all_participants = result.scalars().all()
total_participants = len(all_participants)
current_user_rank = None
# Find current user rank and build entries
entries = []
for rank, p in enumerate(all_participants, 1):
if p.id == current_participant.id:
current_user_rank = rank
if rank <= count:
user = p.user
entries.append(WidgetLeaderboardEntry(
rank=rank,
nickname=user.nickname,
avatar_url=get_avatar_url(user),
total_points=p.total_points,
current_streak=p.current_streak,
is_current_user=(p.id == current_participant.id),
))
return WidgetLeaderboardResponse(
entries=entries,
current_user_rank=current_user_rank,
total_participants=total_participants,
marathon_title=widget_token.marathon.title,
)
@router.get("/data/current", response_model=WidgetCurrentResponse)
async def widget_current_assignment(
marathon: int = Query(..., description="Marathon ID"),
token: str = Query(..., description="Widget token"),
db: DbSession = None,
):
"""Get current assignment data for widget"""
widget_token = await validate_widget_token(token, marathon, db)
participant = widget_token.participant
# Get active assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge),
selectinload(Assignment.game),
)
.where(
Assignment.participant_id == participant.id,
Assignment.status.in_([
AssignmentStatus.ACTIVE.value,
AssignmentStatus.RETURNED.value,
]),
)
.order_by(Assignment.started_at.desc())
.limit(1)
)
assignment = result.scalar_one_or_none()
if not assignment:
return WidgetCurrentResponse(has_assignment=False)
# Determine assignment type and details
if assignment.is_playthrough:
game = assignment.game
assignment_type = "playthrough"
challenge_title = "Прохождение"
challenge_description = game.playthrough_description
points = game.playthrough_points
difficulty = None
# Count bonus challenges
bonus_result = await db.execute(
select(func.count()).select_from(BonusAssignment)
.where(BonusAssignment.main_assignment_id == assignment.id)
)
bonus_total = bonus_result.scalar() or 0
completed_result = await db.execute(
select(func.count()).select_from(BonusAssignment)
.where(
BonusAssignment.main_assignment_id == assignment.id,
BonusAssignment.status == BonusAssignmentStatus.COMPLETED.value,
)
)
bonus_completed = completed_result.scalar() or 0
game_title = game.title
game_cover_url = f"/api/v1/games/{game.id}/cover" if game.cover_path else None
else:
challenge = assignment.challenge
assignment_type = "challenge"
challenge_title = challenge.title
challenge_description = challenge.description
points = challenge.points
difficulty = challenge.difficulty
bonus_completed = None
bonus_total = None
game = challenge.game if hasattr(challenge, 'game') else None
if not game:
# Load game via challenge
from app.models import Game
game_result = await db.execute(
select(Game).where(Game.id == challenge.game_id)
)
game = game_result.scalar_one_or_none()
game_title = game.title if game else None
game_cover_url = f"/api/v1/games/{game.id}/cover" if game and game.cover_path else None
return WidgetCurrentResponse(
has_assignment=True,
game_title=game_title,
game_cover_url=game_cover_url,
assignment_type=assignment_type,
challenge_title=challenge_title,
challenge_description=challenge_description,
points=points,
difficulty=difficulty,
bonus_completed=bonus_completed,
bonus_total=bonus_total,
)
@router.get("/data/progress", response_model=WidgetProgressResponse)
async def widget_progress(
marathon: int = Query(..., description="Marathon ID"),
token: str = Query(..., description="Widget token"),
db: DbSession = None,
):
"""Get participant progress data for widget"""
widget_token = await validate_widget_token(token, marathon, db)
participant = widget_token.participant
user = participant.user
# Calculate rank
result = await db.execute(
select(func.count())
.select_from(Participant)
.where(
Participant.marathon_id == marathon,
Participant.total_points > participant.total_points,
)
)
higher_count = result.scalar() or 0
rank = higher_count + 1
# Count completed and dropped assignments
completed_result = await db.execute(
select(func.count())
.select_from(Assignment)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.COMPLETED.value,
)
)
completed_count = completed_result.scalar() or 0
dropped_result = await db.execute(
select(func.count())
.select_from(Assignment)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.DROPPED.value,
)
)
dropped_count = dropped_result.scalar() or 0
return WidgetProgressResponse(
nickname=user.nickname,
avatar_url=get_avatar_url(user),
rank=rank,
total_points=participant.total_points,
current_streak=participant.current_streak,
completed_count=completed_count,
dropped_count=dropped_count,
marathon_title=widget_token.marathon.title,
)

View File

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

View File

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

View File

@@ -60,7 +60,12 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173", # Desktop app dev
"http://127.0.0.1:5173",
],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@@ -1,9 +1,11 @@
from app.models.user import User, UserRole from app.models.user import User, UserRole
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode from app.models.marathon import Marathon, MarathonStatus, GameProposalMode, CertificationStatus
from app.models.participant import Participant, ParticipantRole from app.models.participant import Participant, ParticipantRole
from app.models.game import Game, GameStatus from app.models.game import Game, GameStatus, GameType
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
from app.models.assignment import Assignment, AssignmentStatus from app.models.assignment import Assignment, AssignmentStatus
from app.models.bonus_assignment import BonusAssignment, BonusAssignmentStatus
from app.models.assignment_proof import AssignmentProof, BonusAssignmentProof
from app.models.activity import Activity, ActivityType from app.models.activity import Activity, ActivityType
from app.models.event import Event, EventType from app.models.event import Event, EventType
from app.models.swap_request import SwapRequest, SwapRequestStatus from app.models.swap_request import SwapRequest, SwapRequestStatus
@@ -11,6 +13,12 @@ from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVo
from app.models.admin_log import AdminLog, AdminActionType from app.models.admin_log import AdminLog, AdminActionType
from app.models.admin_2fa import Admin2FASession from app.models.admin_2fa import Admin2FASession
from app.models.static_content import StaticContent 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
from app.models.widget_token import WidgetToken
__all__ = [ __all__ = [
"User", "User",
@@ -18,16 +26,22 @@ __all__ = [
"Marathon", "Marathon",
"MarathonStatus", "MarathonStatus",
"GameProposalMode", "GameProposalMode",
"CertificationStatus",
"Participant", "Participant",
"ParticipantRole", "ParticipantRole",
"Game", "Game",
"GameStatus", "GameStatus",
"GameType",
"Challenge", "Challenge",
"ChallengeType", "ChallengeType",
"Difficulty", "Difficulty",
"ProofType", "ProofType",
"Assignment", "Assignment",
"AssignmentStatus", "AssignmentStatus",
"BonusAssignment",
"BonusAssignmentStatus",
"AssignmentProof",
"BonusAssignmentProof",
"Activity", "Activity",
"ActivityType", "ActivityType",
"Event", "Event",
@@ -42,4 +56,15 @@ __all__ = [
"AdminActionType", "AdminActionType",
"Admin2FASession", "Admin2FASession",
"StaticContent", "StaticContent",
"ShopItem",
"ShopItemType",
"ItemRarity",
"ConsumableType",
"UserInventory",
"CoinTransaction",
"CoinTransactionType",
"ConsumableUsage",
"PromoCode",
"PromoCodeRedemption",
"WidgetToken",
] ]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,13 @@ class GameProposalMode(str, Enum):
ORGANIZER_ONLY = "organizer_only" ORGANIZER_ONLY = "organizer_only"
class CertificationStatus(str, Enum):
NONE = "none"
PENDING = "pending"
CERTIFIED = "certified"
REJECTED = "rejected"
class Marathon(Base): class Marathon(Base):
__tablename__ = "marathons" __tablename__ = "marathons"
@@ -31,14 +38,32 @@ class Marathon(Base):
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True) auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# 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 # Relationships
creator: Mapped["User"] = relationship( creator: Mapped["User"] = relationship(
"User", "User",
back_populates="created_marathons", back_populates="created_marathons",
foreign_keys=[creator_id] foreign_keys=[creator_id]
) )
certified_by: Mapped["User | None"] = relationship(
"User",
foreign_keys=[certified_by_id]
)
participants: Mapped[list["Participant"]] = relationship( participants: Mapped[list["Participant"]] = relationship(
"Participant", "Participant",
back_populates="marathon", back_populates="marathon",
@@ -59,3 +84,7 @@ class Marathon(Base):
back_populates="marathon", back_populates="marathon",
cascade="all, delete-orphan" cascade="all, delete-orphan"
) )
@property
def is_certified(self) -> bool:
return self.certification_status == CertificationStatus.CERTIFIED.value

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from enum import Enum 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base 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 drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 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 # Relationships
user: Mapped["User"] = relationship("User", back_populates="participations") user: Mapped["User"] = relationship("User", back_populates="participations")
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants") marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")

View 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")

View 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

View File

@@ -2,9 +2,15 @@ from datetime import datetime
from enum import Enum from enum import Enum
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base 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): class UserRole(str, Enum):
USER = "user" USER = "user"
@@ -34,6 +40,20 @@ class User(Base):
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True) banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Notification settings (all enabled by default)
notify_events: Mapped[bool] = mapped_column(Boolean, default=True)
notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True)
notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True)
# 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 # Relationships
created_marathons: Mapped[list["Marathon"]] = relationship( created_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon", "Marathon",
@@ -60,6 +80,32 @@ class User(Base):
foreign_keys=[banned_by_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 @property
def is_admin(self) -> bool: def is_admin(self) -> bool:
return self.role == UserRole.ADMIN.value return self.role == UserRole.ADMIN.value

View File

@@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class WidgetToken(Base):
"""Токен для авторизации OBS виджетов"""
__tablename__ = "widget_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"))
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Relationships
participant: Mapped["Participant"] = relationship("Participant")
marathon: Mapped["Marathon"] = relationship("Marathon")

View File

@@ -9,6 +9,8 @@ from app.schemas.user import (
PasswordChange, PasswordChange,
UserStats, UserStats,
UserProfilePublic, UserProfilePublic,
NotificationSettings,
NotificationSettingsUpdate,
) )
from app.schemas.marathon import ( from app.schemas.marathon import (
MarathonCreate, MarathonCreate,
@@ -46,6 +48,11 @@ from app.schemas.assignment import (
CompleteResult, CompleteResult,
DropResult, DropResult,
EventAssignmentResponse, EventAssignmentResponse,
BonusAssignmentResponse,
CompleteBonusAssignment,
BonusCompleteResult,
AvailableGamesCount,
TrackTimeRequest,
) )
from app.schemas.activity import ( from app.schemas.activity import (
ActivityResponse, ActivityResponse,
@@ -83,6 +90,7 @@ from app.schemas.dispute import (
) )
from app.schemas.admin import ( from app.schemas.admin import (
BanUserRequest, BanUserRequest,
AdminResetPasswordRequest,
AdminUserResponse, AdminUserResponse,
AdminLogResponse, AdminLogResponse,
AdminLogsListResponse, AdminLogsListResponse,
@@ -97,6 +105,45 @@ from app.schemas.admin import (
LoginResponse, LoginResponse,
DashboardStats, 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
from app.schemas.widget import (
WidgetTokenCreate,
WidgetTokenResponse,
WidgetTokenListItem,
WidgetLeaderboardEntry,
WidgetLeaderboardResponse,
WidgetCurrentResponse,
WidgetProgressResponse,
)
__all__ = [ __all__ = [
# User # User
@@ -110,6 +157,8 @@ __all__ = [
"PasswordChange", "PasswordChange",
"UserStats", "UserStats",
"UserProfilePublic", "UserProfilePublic",
"NotificationSettings",
"NotificationSettingsUpdate",
# Marathon # Marathon
"MarathonCreate", "MarathonCreate",
"MarathonUpdate", "MarathonUpdate",
@@ -143,6 +192,10 @@ __all__ = [
"CompleteResult", "CompleteResult",
"DropResult", "DropResult",
"EventAssignmentResponse", "EventAssignmentResponse",
"BonusAssignmentResponse",
"CompleteBonusAssignment",
"BonusCompleteResult",
"AvailableGamesCount",
# Activity # Activity
"ActivityResponse", "ActivityResponse",
"FeedResponse", "FeedResponse",
@@ -175,6 +228,7 @@ __all__ = [
"ReturnedAssignmentResponse", "ReturnedAssignmentResponse",
# Admin # Admin
"BanUserRequest", "BanUserRequest",
"AdminResetPasswordRequest",
"AdminUserResponse", "AdminUserResponse",
"AdminLogResponse", "AdminLogResponse",
"AdminLogsListResponse", "AdminLogsListResponse",
@@ -188,4 +242,40 @@ __all__ = [
"TwoFactorVerifyRequest", "TwoFactorVerifyRequest",
"LoginResponse", "LoginResponse",
"DashboardStats", "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",
# Widget
"WidgetTokenCreate",
"WidgetTokenResponse",
"WidgetTokenListItem",
"WidgetLeaderboardEntry",
"WidgetLeaderboardResponse",
"WidgetCurrentResponse",
"WidgetProgressResponse",
] ]

View File

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

View File

@@ -1,10 +1,21 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
from app.schemas.game import GameResponse from app.schemas.game import GameResponse, GameShort, PlaythroughInfo
from app.schemas.challenge import ChallengeResponse from app.schemas.challenge import ChallengeResponse
class ProofFileResponse(BaseModel):
"""Информация о файле-доказательстве"""
id: int
file_type: str # image или video
order_index: int
created_at: datetime
class Config:
from_attributes = True
class AssignmentBase(BaseModel): class AssignmentBase(BaseModel):
pass pass
@@ -14,28 +25,59 @@ class CompleteAssignment(BaseModel):
comment: str | None = None comment: str | None = None
class AssignmentResponse(BaseModel): class BonusAssignmentResponse(BaseModel):
"""Ответ с информацией о бонусном челлендже"""
id: int id: int
challenge: ChallengeResponse challenge: ChallengeResponse
status: str status: str # pending, completed
proof_url: str | None = None 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 proof_comment: str | None = None
points_earned: int points_earned: int = 0
streak_at_completion: int | None = None
started_at: datetime
completed_at: datetime | None = None completed_at: datetime | None = None
drop_penalty: int = 0 # Calculated penalty if dropped
class Config: class Config:
from_attributes = True 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
points_earned: int
streak_at_completion: int | None = None
tracked_time_minutes: int = 0 # Time tracked by desktop app
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
class TrackTimeRequest(BaseModel):
"""Request to update tracked time for an assignment"""
minutes: int # Total minutes tracked (replaces previous value)
class SpinResult(BaseModel): class SpinResult(BaseModel):
assignment_id: int assignment_id: int
game: GameResponse game: GameResponse
challenge: ChallengeResponse challenge: ChallengeResponse | None # None для playthrough
is_playthrough: bool = False
playthrough_info: PlaythroughInfo | None = None # Заполняется для playthrough
bonus_challenges: list[ChallengeResponse] = [] # Для playthrough - список доступных бонусных челленджей
can_drop: bool can_drop: bool
drop_penalty: int drop_penalty: int
event_type: str | None = None # Event type if active during spin
class CompleteResult(BaseModel): class CompleteResult(BaseModel):
@@ -43,6 +85,7 @@ class CompleteResult(BaseModel):
streak_bonus: int streak_bonus: int
total_points: int total_points: int
new_streak: int new_streak: int
coins_earned: int = 0 # Coins earned (only in certified marathons)
class DropResult(BaseModel): class DropResult(BaseModel):
@@ -60,3 +103,22 @@ class EventAssignmentResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class CompleteBonusAssignment(BaseModel):
"""Запрос на завершение бонусного челленджа"""
proof_url: str | None = None
comment: str | None = None
class BonusCompleteResult(BaseModel):
"""Результат завершения бонусного челленджа"""
bonus_assignment_id: int
points_earned: int
total_bonus_points: int # Сумма очков за все бонусные челленджи
class AvailableGamesCount(BaseModel):
"""Количество доступных игр для спина"""
available: int
total: int

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,10 @@ class MarathonCreate(MarathonBase):
duration_days: int = Field(default=30, ge=1, le=365) duration_days: int = Field(default=30, ge=1, le=365)
is_public: bool = False is_public: bool = False
game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$") 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): class MarathonUpdate(BaseModel):
@@ -23,6 +27,10 @@ class MarathonUpdate(BaseModel):
is_public: bool | None = None is_public: bool | None = None
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$") game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
auto_events_enabled: bool | None = None 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): class ParticipantInfo(BaseModel):
@@ -32,6 +40,13 @@ class ParticipantInfo(BaseModel):
current_streak: int current_streak: int
drop_count: int drop_count: int
joined_at: datetime 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: class Config:
from_attributes = True from_attributes = True
@@ -49,12 +64,20 @@ class MarathonResponse(MarathonBase):
is_public: bool is_public: bool
game_proposal_mode: str game_proposal_mode: str
auto_events_enabled: bool auto_events_enabled: bool
cover_url: str | None
start_date: datetime | None start_date: datetime | None
end_date: datetime | None end_date: datetime | None
participants_count: int participants_count: int
games_count: int games_count: int
created_at: datetime created_at: datetime
my_participation: ParticipantInfo | None = None 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: class Config:
from_attributes = True from_attributes = True
@@ -69,9 +92,12 @@ class MarathonListItem(BaseModel):
title: str title: str
status: str status: str
is_public: bool is_public: bool
cover_url: str | None
participants_count: int participants_count: int
start_date: datetime | None start_date: datetime | None
end_date: datetime | None end_date: datetime | None
# Certification badge
is_certified: bool = False
class Config: class Config:
from_attributes = True from_attributes = True
@@ -87,6 +113,7 @@ class MarathonPublicInfo(BaseModel):
title: str title: str
description: str | None description: str | None
status: str status: str
cover_url: str | None
participants_count: int participants_count: int
creator_nickname: str creator_nickname: str

View File

@@ -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
View 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

View File

@@ -28,6 +28,19 @@ class UserUpdate(BaseModel):
nickname: str | None = Field(None, min_length=2, max_length=50) 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): class UserPublic(UserBase):
"""Public user info visible to other users - minimal data""" """Public user info visible to other users - minimal data"""
id: int id: int
@@ -35,6 +48,11 @@ class UserPublic(UserBase):
role: str = "user" role: str = "user"
telegram_avatar_url: str | None = None # Only TG avatar is public telegram_avatar_url: str | None = None # Only TG avatar is public
created_at: datetime 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: class Config:
from_attributes = True from_attributes = True
@@ -47,6 +65,12 @@ class UserPrivate(UserPublic):
telegram_username: str | None = None telegram_username: str | None = None
telegram_first_name: str | None = None telegram_first_name: str | None = None
telegram_last_name: str | None = None telegram_last_name: str | None = None
# Notification settings
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
# Shop: coins balance (only visible to self)
coins_balance: int = 0
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
@@ -78,8 +102,32 @@ class UserProfilePublic(BaseModel):
id: int id: int
nickname: str nickname: str
avatar_url: str | None = None avatar_url: str | None = None
telegram_avatar_url: str | None = None
role: str = "user"
created_at: datetime created_at: datetime
stats: UserStats 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: class Config:
from_attributes = True from_attributes = True
class NotificationSettings(BaseModel):
"""Notification settings for Telegram bot"""
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
class Config:
from_attributes = True
class NotificationSettingsUpdate(BaseModel):
"""Update notification settings"""
notify_events: bool | None = None
notify_disputes: bool | None = None
notify_moderation: bool | None = None

View File

@@ -0,0 +1,79 @@
from pydantic import BaseModel
from datetime import datetime
# === Token schemas ===
class WidgetTokenCreate(BaseModel):
"""Создание токена виджета"""
pass # Не требует параметров
class WidgetTokenResponse(BaseModel):
"""Ответ с токеном виджета"""
id: int
token: str
created_at: datetime
expires_at: datetime | None
is_active: bool
urls: dict[str, str] # Готовые URL для виджетов
class Config:
from_attributes = True
class WidgetTokenListItem(BaseModel):
"""Элемент списка токенов"""
id: int
token: str
created_at: datetime
is_active: bool
class Config:
from_attributes = True
# === Widget data schemas ===
class WidgetLeaderboardEntry(BaseModel):
"""Запись в лидерборде виджета"""
rank: int
nickname: str
avatar_url: str | None
total_points: int
current_streak: int
is_current_user: bool # Для подсветки
class WidgetLeaderboardResponse(BaseModel):
"""Ответ лидерборда для виджета"""
entries: list[WidgetLeaderboardEntry]
current_user_rank: int | None
total_participants: int
marathon_title: str
class WidgetCurrentResponse(BaseModel):
"""Текущее задание для виджета"""
has_assignment: bool
game_title: str | None = None
game_cover_url: str | None = None
assignment_type: str | None = None # "challenge" | "playthrough"
challenge_title: str | None = None
challenge_description: str | None = None
points: int | None = None
difficulty: str | None = None # easy, medium, hard
bonus_completed: int | None = None # Для прохождений
bonus_total: int | None = None
class WidgetProgressResponse(BaseModel):
"""Прогресс участника для виджета"""
nickname: str
avatar_url: str | None
rank: int
total_points: int
current_streak: int
completed_count: int
dropped_count: int
marathon_title: str

View File

@@ -0,0 +1,288 @@
"""
Coins Service - handles all coin-related operations
Coins are earned only in certified marathons and can be spent in the shop.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User, Participant, Marathon, CoinTransaction, CoinTransactionType
from app.models.challenge import Difficulty
class CoinsService:
"""Service for managing coin transactions and balances"""
# Coins awarded per challenge difficulty (only in certified marathons)
CHALLENGE_COINS = {
Difficulty.EASY.value: 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()

View 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()

View File

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

View File

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

View File

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

View File

@@ -124,12 +124,6 @@ points: easy=20-40, medium=45-75, hard=90-150
points = ch.get("points", 30) points = ch.get("points", 30)
if not isinstance(points, int) or points < 1: if not isinstance(points, int) or points < 1:
points = 30 points = 30
if difficulty == "easy":
points = max(20, min(40, points))
elif difficulty == "medium":
points = max(45, min(75, points))
elif difficulty == "hard":
points = max(90, min(150, points))
return ChallengeGenerated( return ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100], title=ch.get("title", "Unnamed Challenge")[:100],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

28
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Dependencies
node_modules/
# Build output
dist/
release/
# Logs
*.log
npm-debug.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Electron
*.asar

6893
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

80
desktop/package.json Normal file
View File

@@ -0,0 +1,80 @@
{
"name": "game-marathon-tracker",
"version": "1.0.0",
"description": "Desktop app for tracking game time in Game Marathon",
"main": "dist/main/main/index.js",
"author": "Game Marathon",
"license": "MIT",
"scripts": {
"dev": "concurrently -k \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"",
"dev:main": "tsc -p tsconfig.main.json --watch",
"dev:renderer": "vite",
"dev:electron": "wait-on http://localhost:5173 && electron .",
"build": "npm run build:main && npm run build:renderer",
"build:main": "tsc -p tsconfig.main.json",
"build:renderer": "vite build && node -e \"require('fs').copyFileSync('src/renderer/splash.html', 'dist/renderer/splash.html'); require('fs').copyFileSync('src/renderer/logo.jpg', 'dist/renderer/logo.jpg')\"",
"start": "electron .",
"pack": "electron-builder --dir",
"dist": "npm run build && electron-builder --win"
},
"dependencies": {
"auto-launch": "^5.0.6",
"axios": "^1.6.7",
"clsx": "^2.1.0",
"electron-store": "^8.1.0",
"electron-updater": "^6.7.3",
"lucide-react": "^0.323.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"tailwind-merge": "^2.2.1",
"vdf-parser": "^1.0.3",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/auto-launch": "^5.0.5",
"@types/node": "^20.11.16",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"concurrently": "^8.2.2",
"electron": "^28.2.0",
"electron-builder": "^24.9.1",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.0",
"wait-on": "^7.2.0"
},
"build": {
"appId": "com.gamemarathon.tracker",
"productName": "Game Marathon Tracker",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"resources/**/*"
],
"win": {
"target": [
"nsis",
"portable"
],
"icon": "resources/icon.ico",
"signAndEditExecutable": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"publish": {
"provider": "github",
"owner": "Oronemu",
"repo": "marathon_tracker"
}
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,2 @@
# Resources placeholder
# Add icon.ico and tray-icon.png here

BIN
desktop/resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

BIN
desktop/resources/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,145 @@
import https from 'https'
import http from 'http'
import { URL } from 'url'
import type { StoreType } from './storeTypes'
interface ApiResponse<T = unknown> {
data: T
status: number
}
interface ApiError {
status: number
message: string
detail?: unknown
}
export class ApiClient {
private store: StoreType
constructor(store: StoreType) {
this.store = store
}
private getBaseUrl(): string {
return this.store.get('settings').apiUrl || 'https://marathon.animeenigma.ru/api/v1'
}
private getToken(): string | null {
return this.store.get('token')
}
async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
endpoint: string,
data?: unknown
): Promise<ApiResponse<T>> {
const baseUrl = this.getBaseUrl().replace(/\/$/, '') // Remove trailing slash
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
const fullUrl = `${baseUrl}${cleanEndpoint}`
const url = new URL(fullUrl)
const token = this.getToken()
const isHttps = url.protocol === 'https:'
const httpModule = isHttps ? https : http
const body = data ? JSON.stringify(data) : undefined
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname + url.search,
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...(body ? { 'Content-Length': Buffer.byteLength(body) } : {}),
},
}
console.log(`[ApiClient] ${method} ${url.href}`)
return new Promise((resolve, reject) => {
const req = httpModule.request(options, (res) => {
let responseData = ''
res.on('data', (chunk) => {
responseData += chunk
})
res.on('end', () => {
console.log(`[ApiClient] Response status: ${res.statusCode}`)
console.log(`[ApiClient] Response body: ${responseData.substring(0, 500)}`)
try {
const parsed = responseData ? JSON.parse(responseData) : {}
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve({
data: parsed as T,
status: res.statusCode,
})
} else {
const error: ApiError = {
status: res.statusCode || 500,
message: parsed.detail || 'Request failed',
detail: parsed.detail,
}
reject(error)
}
} catch (e) {
console.error('[ApiClient] Parse error:', e)
console.error('[ApiClient] Raw response:', responseData)
reject({
status: res.statusCode || 500,
message: 'Failed to parse response',
})
}
})
})
req.on('error', (e) => {
console.error('[ApiClient] Request error:', e)
reject({
status: 0,
message: e.message || 'Network error',
})
})
req.setTimeout(30000, () => {
console.error('[ApiClient] Request timeout')
req.destroy()
reject({
status: 0,
message: 'Request timeout',
})
})
if (body) {
req.write(body)
}
req.end()
})
}
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>('GET', endpoint)
}
async post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('POST', endpoint, data)
}
async put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('PUT', endpoint, data)
}
async patch<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('PATCH', endpoint, data)
}
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>('DELETE', endpoint)
}
}

View File

@@ -0,0 +1,42 @@
import AutoLaunch from 'auto-launch'
import { app } from 'electron'
let autoLauncher: AutoLaunch | null = null
export async function setupAutoLaunch(enabled: boolean): Promise<void> {
if (!autoLauncher) {
autoLauncher = new AutoLaunch({
name: 'Game Marathon Tracker',
path: app.getPath('exe'),
})
}
try {
const isEnabled = await autoLauncher.isEnabled()
if (enabled && !isEnabled) {
await autoLauncher.enable()
console.log('Auto-launch enabled')
} else if (!enabled && isEnabled) {
await autoLauncher.disable()
console.log('Auto-launch disabled')
}
} catch (error) {
console.error('Failed to setup auto-launch:', error)
}
}
export async function isAutoLaunchEnabled(): Promise<boolean> {
if (!autoLauncher) {
autoLauncher = new AutoLaunch({
name: 'Game Marathon Tracker',
path: app.getPath('exe'),
})
}
try {
return await autoLauncher.isEnabled()
} catch {
return false
}
}

181
desktop/src/main/index.ts Normal file
View File

@@ -0,0 +1,181 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import * as path from 'path'
import Store from 'electron-store'
import { setupTray, destroyTray } from './tray'
import { setupAutoLaunch } from './autolaunch'
import { setupIpcHandlers } from './ipc'
import { ProcessTracker } from './tracking/processTracker'
import { createSplashWindow, setupAutoUpdater, setupUpdateIpcHandlers } from './updater'
import type { StoreType } from './storeTypes'
import './storeTypes' // Import for global type declarations
// Initialize electron store
const store = new Store({
defaults: {
settings: {
autoLaunch: false,
minimizeToTray: true,
trackingInterval: 5000,
apiUrl: 'https://marathon.animeenigma.ru/api/v1',
theme: 'dark',
},
token: null,
trackedGames: {},
trackingData: {},
},
}) as StoreType
let mainWindow: BrowserWindow | null = null
let processTracker: ProcessTracker | null = null
let isMonitoringEnabled = false
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
// Prevent multiple instances
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
}
function createWindow() {
// __dirname is dist/main/main/ in both dev and prod
const iconPath = path.join(__dirname, '../../../resources/icon.ico')
mainWindow = new BrowserWindow({
width: 450,
height: 750,
resizable: false,
frame: false,
titleBarStyle: 'hidden',
backgroundColor: '#0d0e14',
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
},
icon: iconPath,
})
// Load the app
if (isDev) {
mainWindow.loadURL('http://localhost:5173')
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
// In production: __dirname is dist/main/main/, so go up twice to dist/renderer/
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'))
}
// Handle close to tray
mainWindow.on('close', (event) => {
const settings = store.get('settings')
if (settings.minimizeToTray && !app.isQuitting) {
event.preventDefault()
mainWindow?.hide()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
// Setup tray icon
setupTray(mainWindow, store)
return mainWindow
}
app.whenReady().then(async () => {
// Setup IPC handlers
setupIpcHandlers(store, () => mainWindow)
setupUpdateIpcHandlers()
// Show splash screen and check for updates
createSplashWindow()
setupAutoUpdater(async () => {
// This runs after update check is complete (or skipped)
// Create the main window
createWindow()
// Setup auto-launch
const settings = store.get('settings')
await setupAutoLaunch(settings.autoLaunch)
// Initialize process tracker (but don't start automatically)
processTracker = new ProcessTracker(
store,
(stats) => {
mainWindow?.webContents.send('tracking-update', stats)
},
(event) => {
// Game started
mainWindow?.webContents.send('game-started', event.gameName, event.gameId)
},
(event) => {
// Game stopped
mainWindow?.webContents.send('game-stopped', event.gameName, event.duration || 0)
}
)
// Don't start automatically - user will start via button
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
// Don't quit on Windows if minimize to tray is enabled
const settings = store.get('settings')
if (!settings.minimizeToTray) {
app.quit()
}
}
})
app.on('before-quit', () => {
app.isQuitting = true
processTracker?.stop()
destroyTray()
})
// Handle IPC for window controls
ipcMain.on('minimize-to-tray', () => {
mainWindow?.hide()
})
ipcMain.on('quit-app', () => {
app.isQuitting = true
app.quit()
})
// Monitoring control
ipcMain.handle('start-monitoring', () => {
if (!isMonitoringEnabled && processTracker) {
processTracker.start()
isMonitoringEnabled = true
console.log('Monitoring started')
}
return isMonitoringEnabled
})
ipcMain.handle('stop-monitoring', () => {
if (isMonitoringEnabled && processTracker) {
processTracker.stop()
isMonitoringEnabled = false
console.log('Monitoring stopped')
}
return isMonitoringEnabled
})
ipcMain.handle('get-monitoring-status', () => {
return isMonitoringEnabled
})
// Export for use in other modules
export { store, mainWindow }

174
desktop/src/main/ipc.ts Normal file
View File

@@ -0,0 +1,174 @@
import { ipcMain, BrowserWindow } from 'electron'
import { setupAutoLaunch } from './autolaunch'
import { getRunningProcesses, getForegroundWindow } from './tracking/processTracker'
import { getSteamGames, getSteamPath } from './tracking/steamIntegration'
import { getTrackingStats, getTrackedGames, addTrackedGame, removeTrackedGame } from './tracking/timeStorage'
import { ApiClient } from './apiClient'
import type { TrackedGame, AppSettings, User, LoginResponse } from '../shared/types'
import type { StoreType } from './storeTypes'
export function setupIpcHandlers(
store: StoreType,
getMainWindow: () => BrowserWindow | null
) {
const apiClient = new ApiClient(store)
// Settings handlers
ipcMain.handle('get-settings', () => {
return store.get('settings')
})
ipcMain.handle('save-settings', async (_event, settings: Partial<AppSettings>) => {
const currentSettings = store.get('settings')
const newSettings = { ...currentSettings, ...settings }
store.set('settings', newSettings)
// Handle auto-launch setting change
if (settings.autoLaunch !== undefined) {
await setupAutoLaunch(settings.autoLaunch)
}
return newSettings
})
// Auth handlers
ipcMain.handle('get-token', () => {
return store.get('token')
})
ipcMain.handle('save-token', (_event, token: string) => {
store.set('token', token)
})
ipcMain.handle('clear-token', () => {
store.set('token', null)
})
// Process tracking handlers
ipcMain.handle('get-running-processes', async () => {
return await getRunningProcesses()
})
ipcMain.handle('get-foreground-window', async () => {
return await getForegroundWindow()
})
ipcMain.handle('get-tracking-stats', () => {
return getTrackingStats(store)
})
// Steam handlers
ipcMain.handle('get-steam-games', async () => {
return await getSteamGames()
})
ipcMain.handle('get-steam-path', () => {
return getSteamPath()
})
// Tracked games handlers
ipcMain.handle('get-tracked-games', () => {
return getTrackedGames(store)
})
ipcMain.handle('add-tracked-game', (_event, game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => {
return addTrackedGame(store, game)
})
ipcMain.handle('remove-tracked-game', (_event, gameId: string) => {
removeTrackedGame(store, gameId)
})
// API handlers - all requests go through main process (no CORS issues)
ipcMain.handle('api-login', async (_event, login: string, password: string) => {
console.log('[API] Login attempt for:', login)
try {
const response = await apiClient.post<LoginResponse>('/auth/login', { login, password })
console.log('[API] Login response:', response.status)
// Save token if login successful
if (response.data.access_token) {
store.set('token', response.data.access_token)
}
return { success: true, data: response.data }
} catch (error: unknown) {
console.error('[API] Login error:', error)
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Login failed',
status: err.status
}
}
})
ipcMain.handle('api-get-me', async () => {
try {
const response = await apiClient.get<User>('/auth/me')
return { success: true, data: response.data }
} catch (error: unknown) {
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Failed to get user',
status: err.status
}
}
})
ipcMain.handle('api-2fa-verify', async (_event, sessionId: number, code: string) => {
console.log('[API] 2FA verify attempt')
try {
const response = await apiClient.post<LoginResponse>(`/auth/2fa/verify?session_id=${sessionId}&code=${code}`)
console.log('[API] 2FA verify response:', response.status)
// Save token if verification successful
if (response.data.access_token) {
store.set('token', response.data.access_token)
}
return { success: true, data: response.data }
} catch (error: unknown) {
console.error('[API] 2FA verify error:', error)
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || '2FA verification failed',
status: err.status
}
}
})
ipcMain.handle('api-request', async (_event, method: string, endpoint: string, data?: unknown) => {
try {
let response
switch (method.toUpperCase()) {
case 'GET':
response = await apiClient.get(endpoint)
break
case 'POST':
response = await apiClient.post(endpoint, data)
break
case 'PUT':
response = await apiClient.put(endpoint, data)
break
case 'PATCH':
response = await apiClient.patch(endpoint, data)
break
case 'DELETE':
response = await apiClient.delete(endpoint)
break
default:
throw new Error(`Unknown method: ${method}`)
}
return { success: true, data: response.data }
} catch (error: unknown) {
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Request failed',
status: err.status
}
}
})
}

View File

@@ -0,0 +1,28 @@
import Store from 'electron-store'
import type { AppSettings, TrackedGame } from '../shared/types'
export interface GameTrackingData {
totalTime: number
sessions: Array<{
startTime: number
endTime: number
duration: number
}>
lastPlayed: number
}
export type StoreType = Store<{
settings: AppSettings
token: string | null
trackedGames: Record<string, TrackedGame>
trackingData: Record<string, GameTrackingData>
}>
// Extend Electron App type
declare global {
namespace Electron {
interface App {
isQuitting?: boolean
}
}
}

View File

@@ -0,0 +1,284 @@
import { exec } from 'child_process'
import { promisify } from 'util'
import type { TrackedProcess, TrackingStats, TrackedGame } from '../../shared/types'
import type { StoreType } from '../storeTypes'
import { updateGameTime, getTrackedGames } from './timeStorage'
import { updateTrayMenu } from '../tray'
const execAsync = promisify(exec)
interface ProcessInfo {
ProcessName: string
MainWindowTitle: string
Id: number
}
export async function getRunningProcesses(): Promise<TrackedProcess[]> {
try {
const { stdout } = await execAsync(
'powershell -NoProfile -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Process | Where-Object {$_.MainWindowTitle} | Select-Object ProcessName, MainWindowTitle, Id | ConvertTo-Json -Compress"',
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
)
if (!stdout.trim()) {
return []
}
let processes: ProcessInfo[]
try {
const parsed = JSON.parse(stdout)
processes = Array.isArray(parsed) ? parsed : [parsed]
} catch {
return []
}
return processes.map((proc) => ({
id: proc.Id.toString(),
name: proc.ProcessName,
displayName: proc.MainWindowTitle || proc.ProcessName,
windowTitle: proc.MainWindowTitle,
isGame: isLikelyGame(proc.ProcessName, proc.MainWindowTitle),
}))
} catch (error) {
console.error('Failed to get running processes:', error)
return []
}
}
export async function getForegroundWindow(): Promise<string | null> {
try {
// Use base64 encoded script to avoid escaping issues
const script = `
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class FGWindow {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}
"@
\$hwnd = [FGWindow]::GetForegroundWindow()
\$processId = 0
[void][FGWindow]::GetWindowThreadProcessId(\$hwnd, [ref]\$processId)
\$proc = Get-Process -Id \$processId -ErrorAction SilentlyContinue
if (\$proc) { Write-Output \$proc.ProcessName }
`
// Encode script as base64
const base64Script = Buffer.from(script, 'utf16le').toString('base64')
const { stdout } = await execAsync(
`powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${base64Script}`,
{ encoding: 'utf8', timeout: 5000 }
)
const result = stdout.trim()
return result || null
} catch (error) {
console.error('[getForegroundWindow] Error:', error)
return null
}
}
function isLikelyGame(processName: string, windowTitle: string): boolean {
const gameIndicators = [
'game', 'steam', 'epic', 'uplay', 'origin', 'battle.net',
'unity', 'unreal', 'godot', 'ue4', 'ue5',
]
const lowerName = processName.toLowerCase()
const lowerTitle = (windowTitle || '').toLowerCase()
// Check for common game launchers/engines
for (const indicator of gameIndicators) {
if (lowerName.includes(indicator) || lowerTitle.includes(indicator)) {
return true
}
}
// Exclude common non-game processes
const nonGameProcesses = [
'explorer', 'chrome', 'firefox', 'edge', 'opera', 'brave',
'code', 'idea', 'webstorm', 'pycharm', 'rider',
'discord', 'slack', 'teams', 'zoom', 'telegram',
'spotify', 'vlc', 'foobar', 'winamp',
'notepad', 'word', 'excel', 'powerpoint', 'outlook',
'cmd', 'powershell', 'terminal', 'windowsterminal',
]
for (const nonGame of nonGameProcesses) {
if (lowerName.includes(nonGame)) {
return false
}
}
return true
}
interface GameEvent {
gameName: string
gameId: string
duration?: number
}
export class ProcessTracker {
private intervalId: NodeJS.Timeout | null = null
private currentGame: string | null = null
private currentGameName: string | null = null
private sessionStart: number | null = null
private store: StoreType
private onUpdate: (stats: TrackingStats) => void
private onGameStarted: (event: GameEvent) => void
private onGameStopped: (event: GameEvent) => void
constructor(
store: StoreType,
onUpdate: (stats: TrackingStats) => void,
onGameStarted?: (event: GameEvent) => void,
onGameStopped?: (event: GameEvent) => void
) {
this.store = store
this.onUpdate = onUpdate
this.onGameStarted = onGameStarted || (() => {})
this.onGameStopped = onGameStopped || (() => {})
}
start() {
const settings = this.store.get('settings')
const interval = settings.trackingInterval || 5000
this.intervalId = setInterval(() => this.tick(), interval)
console.log(`Process tracker started with ${interval}ms interval`)
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = null
}
// End current session if any
if (this.currentGame && this.sessionStart) {
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
}
this.currentGame = null
this.sessionStart = null
console.log('Process tracker stopped')
}
private async tick() {
const foregroundProcess = await getForegroundWindow()
const trackedGames = getTrackedGames(this.store)
// Debug logging - ALWAYS log
console.log('[Tracker] Foreground:', foregroundProcess || 'NULL', '| Tracked:', trackedGames.length, 'games:', trackedGames.map(g => g.executableName).join(', ') || 'none')
// Find if foreground process matches any tracked game
let matchedGame: TrackedGame | null = null
if (foregroundProcess) {
const lowerForeground = foregroundProcess.toLowerCase().replace('.exe', '')
for (const game of trackedGames) {
const lowerExe = game.executableName.toLowerCase().replace('.exe', '')
// More flexible matching
const matches = lowerForeground === lowerExe ||
lowerForeground.includes(lowerExe) ||
lowerExe.includes(lowerForeground)
if (matches) {
console.log('[Tracker] MATCH:', foregroundProcess, '===', game.executableName)
matchedGame = game
break
}
}
}
// Handle game state changes
if (matchedGame && matchedGame.id !== this.currentGame) {
// New game started
if (this.currentGame && this.sessionStart && this.currentGameName) {
// End previous session
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
// Emit game stopped event for previous game
this.onGameStopped({
gameName: this.currentGameName,
gameId: this.currentGame,
duration
})
}
this.currentGame = matchedGame.id
this.currentGameName = matchedGame.name
this.sessionStart = Date.now()
console.log(`Started tracking: ${matchedGame.name}`)
updateTrayMenu(null, true, matchedGame.name)
// Emit game started event
this.onGameStarted({
gameName: matchedGame.name,
gameId: matchedGame.id
})
} else if (!matchedGame && this.currentGame) {
// Game stopped
if (this.sessionStart) {
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
// Emit game stopped event
if (this.currentGameName) {
this.onGameStopped({
gameName: this.currentGameName,
gameId: this.currentGame,
duration
})
}
}
console.log(`Stopped tracking: ${this.currentGame}`)
this.currentGame = null
this.currentGameName = null
this.sessionStart = null
updateTrayMenu(null, false)
}
// Emit update
const stats = this.getStats()
this.onUpdate(stats)
}
private getStats(): TrackingStats {
const trackedGames = getTrackedGames(this.store)
const now = Date.now()
const todayStart = new Date().setHours(0, 0, 0, 0)
const weekStart = now - 7 * 24 * 60 * 60 * 1000
const monthStart = now - 30 * 24 * 60 * 60 * 1000
let totalTimeToday = 0
let totalTimeWeek = 0
let totalTimeMonth = 0
// Add current session time if active
if (this.currentGame && this.sessionStart) {
const currentSessionTime = now - this.sessionStart
totalTimeToday += currentSessionTime
totalTimeWeek += currentSessionTime
totalTimeMonth += currentSessionTime
}
// This is a simplified version - full implementation would track sessions with timestamps
for (const game of trackedGames) {
totalTimeMonth += game.totalTime
// For simplicity, assume all recorded time is from this week/today
// A full implementation would store session timestamps
}
return {
totalTimeToday,
totalTimeWeek,
totalTimeMonth,
sessions: [],
currentGame: this.currentGameName,
currentSessionDuration: this.currentGame && this.sessionStart ? now - this.sessionStart : 0,
}
}
}

View File

@@ -0,0 +1,215 @@
import * as fs from 'fs'
import * as path from 'path'
import type { SteamGame } from '../../shared/types'
// Common Steam installation paths on Windows
const STEAM_PATHS = [
'C:\\Program Files (x86)\\Steam',
'C:\\Program Files\\Steam',
'D:\\Steam',
'D:\\SteamLibrary',
'E:\\Steam',
'E:\\SteamLibrary',
]
let cachedSteamPath: string | null = null
export function getSteamPath(): string | null {
if (cachedSteamPath) {
return cachedSteamPath
}
// Try common paths
for (const steamPath of STEAM_PATHS) {
if (fs.existsSync(path.join(steamPath, 'steam.exe')) ||
fs.existsSync(path.join(steamPath, 'steamapps'))) {
cachedSteamPath = steamPath
return steamPath
}
}
// Try to find via registry (would require node-winreg or similar)
// For now, just check common paths
return null
}
export async function getSteamGames(): Promise<SteamGame[]> {
const steamPath = getSteamPath()
if (!steamPath) {
console.log('Steam not found')
return []
}
const games: SteamGame[] = []
const libraryPaths = await getLibraryPaths(steamPath)
for (const libraryPath of libraryPaths) {
const steamAppsPath = path.join(libraryPath, 'steamapps')
if (!fs.existsSync(steamAppsPath)) continue
try {
const files = fs.readdirSync(steamAppsPath)
const manifests = files.filter((f) => f.startsWith('appmanifest_') && f.endsWith('.acf'))
for (const manifest of manifests) {
const game = await parseAppManifest(path.join(steamAppsPath, manifest), libraryPath)
if (game) {
games.push(game)
}
}
} catch (error) {
console.error(`Error reading steam apps from ${steamAppsPath}:`, error)
}
}
return games.sort((a, b) => a.name.localeCompare(b.name))
}
async function getLibraryPaths(steamPath: string): Promise<string[]> {
const paths: string[] = [steamPath]
const libraryFoldersPath = path.join(steamPath, 'steamapps', 'libraryfolders.vdf')
if (!fs.existsSync(libraryFoldersPath)) {
return paths
}
try {
const content = fs.readFileSync(libraryFoldersPath, 'utf8')
const libraryPaths = parseLibraryFolders(content)
paths.push(...libraryPaths.filter((p) => !paths.includes(p)))
} catch (error) {
console.error('Error reading library folders:', error)
}
return paths
}
function parseLibraryFolders(content: string): string[] {
const paths: string[] = []
// Simple VDF parser for library folders
// Format: "path" "C:\\SteamLibrary"
const pathRegex = /"path"\s+"([^"]+)"/g
let match
while ((match = pathRegex.exec(content)) !== null) {
const libPath = match[1].replace(/\\\\/g, '\\')
if (fs.existsSync(libPath)) {
paths.push(libPath)
}
}
return paths
}
async function parseAppManifest(manifestPath: string, libraryPath: string): Promise<SteamGame | null> {
try {
const content = fs.readFileSync(manifestPath, 'utf8')
const appIdMatch = content.match(/"appid"\s+"(\d+)"/)
const nameMatch = content.match(/"name"\s+"([^"]+)"/)
const installDirMatch = content.match(/"installdir"\s+"([^"]+)"/)
if (!appIdMatch || !nameMatch || !installDirMatch) {
return null
}
const appId = appIdMatch[1]
const name = nameMatch[1]
const installDir = installDirMatch[1]
// Filter out tools, servers, etc.
const skipTypes = ['Tool', 'Config', 'DLC', 'Music', 'Video']
const typeMatch = content.match(/"type"\s+"([^"]+)"/)
if (typeMatch && skipTypes.includes(typeMatch[1])) {
return null
}
const fullInstallPath = path.join(libraryPath, 'steamapps', 'common', installDir)
let executable: string | undefined
// Try to find main executable
if (fs.existsSync(fullInstallPath)) {
executable = findMainExecutable(fullInstallPath, name)
}
return {
appId,
name,
installDir: fullInstallPath,
executable,
iconPath: getGameIconPath(steamPath, appId),
}
} catch (error) {
console.error(`Error parsing manifest ${manifestPath}:`, error)
return null
}
}
function findMainExecutable(installPath: string, gameName: string): string | undefined {
try {
const files = fs.readdirSync(installPath)
const exeFiles = files.filter((f) => f.endsWith('.exe'))
if (exeFiles.length === 0) {
// Check subdirectories (one level deep)
for (const dir of files) {
const subPath = path.join(installPath, dir)
if (fs.statSync(subPath).isDirectory()) {
const subFiles = fs.readdirSync(subPath)
const subExe = subFiles.filter((f) => f.endsWith('.exe'))
exeFiles.push(...subExe.map((f) => path.join(dir, f)))
}
}
}
if (exeFiles.length === 0) return undefined
// Try to find exe that matches game name
const lowerName = gameName.toLowerCase().replace(/[^a-z0-9]/g, '')
for (const exe of exeFiles) {
const lowerExe = exe.toLowerCase().replace(/[^a-z0-9]/g, '')
if (lowerExe.includes(lowerName) || lowerName.includes(lowerExe.replace('.exe', ''))) {
return exe
}
}
// Filter out common non-game executables
const skipExes = [
'unins', 'setup', 'install', 'config', 'crash', 'report',
'launcher', 'updater', 'redistributable', 'vcredist', 'directx',
'dxsetup', 'ue4prereqsetup', 'dotnet',
]
const gameExes = exeFiles.filter((exe) => {
const lower = exe.toLowerCase()
return !skipExes.some((skip) => lower.includes(skip))
})
return gameExes[0] || exeFiles[0]
} catch {
return undefined
}
}
function getGameIconPath(steamPath: string | null, appId: string): string | undefined {
if (!steamPath) return undefined
// Steam stores icons in appcache/librarycache
const iconPath = path.join(steamPath, 'appcache', 'librarycache', `${appId}_icon.jpg`)
if (fs.existsSync(iconPath)) {
return iconPath
}
// Try header image
const headerPath = path.join(steamPath, 'appcache', 'librarycache', `${appId}_header.jpg`)
if (fs.existsSync(headerPath)) {
return headerPath
}
return undefined
}
// Re-export for use
const steamPath = getSteamPath()

View File

@@ -0,0 +1,155 @@
import type { TrackedGame, TrackingStats, GameSession } from '../../shared/types'
import type { StoreType, GameTrackingData } from '../storeTypes'
export type { GameTrackingData }
export function getTrackedGames(store: StoreType): TrackedGame[] {
const trackedGames = store.get('trackedGames') || {}
return Object.values(trackedGames)
}
export function addTrackedGame(
store: StoreType,
game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>
): TrackedGame {
const trackedGames = store.get('trackedGames') || {}
const newGame: TrackedGame = {
...game,
totalTime: 0,
lastPlayed: undefined,
}
trackedGames[game.id] = newGame
store.set('trackedGames', trackedGames)
// Initialize tracking data
const trackingData = store.get('trackingData') || {}
trackingData[game.id] = {
totalTime: 0,
sessions: [],
lastPlayed: 0,
}
store.set('trackingData', trackingData)
return newGame
}
export function removeTrackedGame(store: StoreType, gameId: string): void {
const trackedGames = store.get('trackedGames') || {}
delete trackedGames[gameId]
store.set('trackedGames', trackedGames)
const trackingData = store.get('trackingData') || {}
delete trackingData[gameId]
store.set('trackingData', trackingData)
}
export function updateGameTime(store: StoreType, gameId: string, duration: number): void {
// Update tracked games
const trackedGames = store.get('trackedGames') || {}
if (trackedGames[gameId]) {
trackedGames[gameId].totalTime += duration
trackedGames[gameId].lastPlayed = Date.now()
store.set('trackedGames', trackedGames)
}
// Update tracking data with session
const trackingData = store.get('trackingData') || {}
if (!trackingData[gameId]) {
trackingData[gameId] = {
totalTime: 0,
sessions: [],
lastPlayed: 0,
}
}
const now = Date.now()
trackingData[gameId].totalTime += duration
trackingData[gameId].lastPlayed = now
trackingData[gameId].sessions.push({
startTime: now - duration,
endTime: now,
duration,
})
// Keep only last 100 sessions to prevent data bloat
if (trackingData[gameId].sessions.length > 100) {
trackingData[gameId].sessions = trackingData[gameId].sessions.slice(-100)
}
store.set('trackingData', trackingData)
}
export function getTrackingStats(store: StoreType): TrackingStats {
const trackingData = store.get('trackingData') || {}
const now = Date.now()
const todayStart = new Date().setHours(0, 0, 0, 0)
const weekStart = now - 7 * 24 * 60 * 60 * 1000
const monthStart = now - 30 * 24 * 60 * 60 * 1000
let totalTimeToday = 0
let totalTimeWeek = 0
let totalTimeMonth = 0
const recentSessions: GameSession[] = []
for (const [gameId, data] of Object.entries(trackingData)) {
for (const session of data.sessions) {
if (session.endTime >= monthStart) {
totalTimeMonth += session.duration
if (session.endTime >= weekStart) {
totalTimeWeek += session.duration
}
if (session.endTime >= todayStart) {
totalTimeToday += session.duration
}
}
}
// Get last session for each game
if (data.sessions.length > 0) {
const lastSession = data.sessions[data.sessions.length - 1]
const trackedGames = store.get('trackedGames') || {}
const game = trackedGames[gameId]
if (game && lastSession.endTime >= weekStart) {
recentSessions.push({
gameId,
gameName: game.name,
startTime: lastSession.startTime,
endTime: lastSession.endTime,
duration: lastSession.duration,
isActive: false,
})
}
}
}
// Sort by most recent
recentSessions.sort((a, b) => (b.endTime || 0) - (a.endTime || 0))
return {
totalTimeToday,
totalTimeWeek,
totalTimeMonth,
sessions: recentSessions.slice(0, 10),
}
}
export function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
return `${hours}ч ${remainingMinutes}м`
} else if (minutes > 0) {
return `${minutes}м`
} else {
return `${seconds}с`
}
}

115
desktop/src/main/tray.ts Normal file
View File

@@ -0,0 +1,115 @@
import { Tray, Menu, nativeImage, BrowserWindow, app, NativeImage } from 'electron'
import * as path from 'path'
import type { StoreType } from './storeTypes'
let tray: Tray | null = null
export function setupTray(
mainWindow: BrowserWindow | null,
store: StoreType
) {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
// In dev: __dirname is dist/main/main/, in prod: same
const iconPath = isDev
? path.join(__dirname, '../../../resources/icon.ico')
: path.join(__dirname, '../../../resources/icon.ico')
// Create tray icon
let trayIcon: NativeImage
try {
trayIcon = nativeImage.createFromPath(iconPath)
if (trayIcon.isEmpty()) {
trayIcon = nativeImage.createEmpty()
}
} catch {
trayIcon = nativeImage.createEmpty()
}
// Resize for tray (16x16 on Windows)
if (!trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 16, height: 16 })
}
tray = new Tray(trayIcon)
tray.setToolTip('Game Marathon Tracker')
const contextMenu = Menu.buildFromTemplate([
{
label: 'Открыть',
click: () => {
mainWindow?.show()
mainWindow?.focus()
},
},
{ type: 'separator' },
{
label: 'Статус: Отслеживание',
enabled: false,
},
{ type: 'separator' },
{
label: 'Выход',
click: () => {
app.isQuitting = true
app.quit()
},
},
])
tray.setContextMenu(contextMenu)
// Double-click to show window
tray.on('double-click', () => {
mainWindow?.show()
mainWindow?.focus()
})
return tray
}
export function updateTrayMenu(
mainWindow: BrowserWindow | null,
isTracking: boolean,
currentGame?: string
) {
if (!tray) return
const statusLabel = isTracking
? `Отслеживание: ${currentGame || 'Активно'}`
: 'Отслеживание: Неактивно'
const contextMenu = Menu.buildFromTemplate([
{
label: 'Открыть',
click: () => {
mainWindow?.show()
mainWindow?.focus()
},
},
{ type: 'separator' },
{
label: statusLabel,
enabled: false,
},
{ type: 'separator' },
{
label: 'Выход',
click: () => {
app.isQuitting = true
app.quit()
},
},
])
tray.setContextMenu(contextMenu)
}
export function destroyTray() {
if (tray) {
tray.destroy()
tray = null
}
}
export { tray }

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