Compare commits
38 Commits
5343a8f2c3
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 481bdabaa8 | |||
| 8e634994bd | |||
| 33f49f4e47 | |||
| 57bad3b4a8 | |||
| e43e579329 | |||
| 967176fab8 | |||
| f371178518 | |||
| 3920a9bf8c | |||
| 790b2d6083 | |||
| 675a0fea0c | |||
| 0b3837b08e | |||
| 7e7cdbcd76 | |||
| debdd66458 | |||
| 332491454d | |||
| 11f7b59471 | |||
| 1c07d8c5ff | |||
| 895e296f44 | |||
| 696dc714c4 | |||
| 08b96fd1f7 | |||
| ca41c207b3 | |||
| 412de3bf05 | |||
| 9fd93a185c | |||
| fe6012b7a3 | |||
| a199952383 | |||
| e32df4d95e | |||
| f57a2ba9ea | |||
| d96f8de568 | |||
| 574140e67d | |||
| 87ecd9756c | |||
| c7966656d8 | |||
| 339a212e57 | |||
| 07e02ce32d | |||
| 9a037cb34f | |||
| 4239ea8516 | |||
| 1a882fb2e0 | |||
| 5db2f9c48d | |||
| d0b8eca600 | |||
| bb9e9a6e1d |
41
.dockerignore
Normal file
41
.dockerignore
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
*/node_modules
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
*.pyc
|
||||||
|
__pycache__
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Environment files (keep .env.example)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test & coverage
|
||||||
|
coverage
|
||||||
|
.pytest_cache
|
||||||
|
.coverage
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
19
.env.example
19
.env.example
@@ -10,6 +10,25 @@ OPENAI_API_KEY=sk-...
|
|||||||
|
|
||||||
# Telegram Bot
|
# Telegram Bot
|
||||||
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
||||||
|
BOT_API_SECRET=change_me_random_secret_for_bot_api
|
||||||
|
|
||||||
|
# S3 Storage - FirstVDS (set S3_ENABLED=true to use)
|
||||||
|
S3_ENABLED=false
|
||||||
|
S3_BUCKET_NAME=your-bucket-name
|
||||||
|
S3_REGION=ru-1
|
||||||
|
S3_ACCESS_KEY_ID=your-access-key-id
|
||||||
|
S3_SECRET_ACCESS_KEY=your-secret-access-key
|
||||||
|
S3_ENDPOINT_URL=https://s3.firstvds.ru
|
||||||
|
S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru
|
||||||
|
|
||||||
|
# Backup Service
|
||||||
|
TELEGRAM_ADMIN_ID=947392854
|
||||||
|
S3_BACKUP_PREFIX=backups/
|
||||||
|
BACKUP_RETENTION_DAYS=14
|
||||||
|
|
||||||
|
# Status Service (optional - for external monitoring)
|
||||||
|
EXTERNAL_URL=https://your-domain.com
|
||||||
|
PUBLIC_URL=https://your-domain.com
|
||||||
|
|
||||||
# Frontend (for build)
|
# Frontend (for build)
|
||||||
VITE_API_URL=/api/v1
|
VITE_API_URL=/api/v1
|
||||||
|
|||||||
162
Makefile
Normal file
162
Makefile
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
.PHONY: help dev up down build build-no-cache logs restart clean migrate shell db-shell frontend-shell backend-shell lint test
|
||||||
|
|
||||||
|
DC = sudo docker-compose
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
help:
|
||||||
|
@echo "Marathon WebApp - Available commands:"
|
||||||
|
@echo ""
|
||||||
|
@echo " Development:"
|
||||||
|
@echo " make dev - Start all services in development mode"
|
||||||
|
@echo " make up - Start all services (detached)"
|
||||||
|
@echo " make down - Stop all services"
|
||||||
|
@echo " make restart - Restart all services"
|
||||||
|
@echo " make logs - Show logs (all services)"
|
||||||
|
@echo " make logs-b - Show backend logs"
|
||||||
|
@echo " make logs-f - Show frontend logs"
|
||||||
|
@echo ""
|
||||||
|
@echo " Build:"
|
||||||
|
@echo " make build - Build all containers (with cache)"
|
||||||
|
@echo " make build-no-cache - Build all containers (no cache)"
|
||||||
|
@echo " make reup - Rebuild with cache: down + build + up"
|
||||||
|
@echo " make rebuild - Full rebuild: down + build --no-cache + up"
|
||||||
|
@echo " make rebuild-frontend - Rebuild only frontend"
|
||||||
|
@echo " make rebuild-backend - Rebuild only backend"
|
||||||
|
@echo ""
|
||||||
|
@echo " Database:"
|
||||||
|
@echo " make migrate - Run database migrations"
|
||||||
|
@echo " make db-shell - Open PostgreSQL shell"
|
||||||
|
@echo ""
|
||||||
|
@echo " Shell access:"
|
||||||
|
@echo " make shell - Open backend shell"
|
||||||
|
@echo " make frontend-sh - Open frontend shell"
|
||||||
|
@echo ""
|
||||||
|
@echo " Backup:"
|
||||||
|
@echo " make backup-now - Run backup immediately"
|
||||||
|
@echo " make backup-list - List available backups in S3"
|
||||||
|
@echo " make backup-restore - Restore from backup (interactive)"
|
||||||
|
@echo " make backup-logs - Show backup service logs"
|
||||||
|
@echo ""
|
||||||
|
@echo " Cleanup:"
|
||||||
|
@echo " make clean - Stop and remove containers, volumes"
|
||||||
|
@echo " make prune - Remove unused Docker resources"
|
||||||
|
|
||||||
|
# Development
|
||||||
|
dev:
|
||||||
|
$(DC) up
|
||||||
|
|
||||||
|
up:
|
||||||
|
$(DC) up -d
|
||||||
|
|
||||||
|
down:
|
||||||
|
$(DC) down
|
||||||
|
|
||||||
|
restart:
|
||||||
|
$(DC) restart
|
||||||
|
|
||||||
|
logs:
|
||||||
|
$(DC) logs -f
|
||||||
|
|
||||||
|
logs-b:
|
||||||
|
$(DC) logs -f backend
|
||||||
|
|
||||||
|
logs-f:
|
||||||
|
$(DC) logs -f frontend
|
||||||
|
|
||||||
|
# Build
|
||||||
|
build:
|
||||||
|
$(DC) build
|
||||||
|
|
||||||
|
build-no-cache:
|
||||||
|
$(DC) build --no-cache
|
||||||
|
|
||||||
|
reup:
|
||||||
|
$(DC) down
|
||||||
|
$(DC) build
|
||||||
|
$(DC) up -d
|
||||||
|
|
||||||
|
rebuild:
|
||||||
|
$(DC) down
|
||||||
|
$(DC) build --no-cache
|
||||||
|
$(DC) up -d
|
||||||
|
|
||||||
|
rebuild-frontend:
|
||||||
|
$(DC) down
|
||||||
|
sudo docker rmi marathon-frontend || true
|
||||||
|
$(DC) build --no-cache frontend
|
||||||
|
$(DC) up -d
|
||||||
|
|
||||||
|
rebuild-backend:
|
||||||
|
$(DC) down
|
||||||
|
sudo docker rmi marathon-backend || true
|
||||||
|
$(DC) build --no-cache backend
|
||||||
|
$(DC) up -d
|
||||||
|
|
||||||
|
# Database
|
||||||
|
migrate:
|
||||||
|
$(DC) exec backend alembic upgrade head
|
||||||
|
|
||||||
|
migrate-new:
|
||||||
|
@read -p "Migration message: " msg; \
|
||||||
|
$(DC) exec backend alembic revision --autogenerate -m "$$msg"
|
||||||
|
|
||||||
|
db-shell:
|
||||||
|
$(DC) exec db psql -U marathon -d marathon
|
||||||
|
|
||||||
|
# Shell access
|
||||||
|
shell:
|
||||||
|
$(DC) exec backend bash
|
||||||
|
|
||||||
|
backend-shell: shell
|
||||||
|
|
||||||
|
frontend-sh:
|
||||||
|
$(DC) exec frontend sh
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
clean:
|
||||||
|
$(DC) down -v --remove-orphans
|
||||||
|
|
||||||
|
prune:
|
||||||
|
sudo docker system prune -f
|
||||||
|
|
||||||
|
# Local development (without Docker)
|
||||||
|
install:
|
||||||
|
cd backend && pip install -r requirements.txt
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
run-backend:
|
||||||
|
cd backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
run-frontend:
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
# Linting and testing
|
||||||
|
lint-backend:
|
||||||
|
cd backend && ruff check app
|
||||||
|
|
||||||
|
lint-frontend:
|
||||||
|
cd frontend && npm run lint
|
||||||
|
|
||||||
|
test-backend:
|
||||||
|
cd backend && pytest
|
||||||
|
|
||||||
|
# Production
|
||||||
|
prod:
|
||||||
|
$(DC) -f docker-compose.yml up -d --build
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
backup-now:
|
||||||
|
$(DC) exec backup python /app/backup.py
|
||||||
|
|
||||||
|
backup-list:
|
||||||
|
$(DC) exec backup python /app/restore.py
|
||||||
|
|
||||||
|
backup-restore:
|
||||||
|
@read -p "Backup filename: " file; \
|
||||||
|
$(DC) exec -it backup python /app/restore.py "$$file"
|
||||||
|
|
||||||
|
backup-logs:
|
||||||
|
$(DC) logs -f backup
|
||||||
|
|
||||||
|
backup-shell:
|
||||||
|
$(DC) exec backup bash
|
||||||
389
REDESIGN_PLAN.md
Normal file
389
REDESIGN_PLAN.md
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
# План редизайна фронтенда Game Marathon
|
||||||
|
|
||||||
|
## Концепция дизайна
|
||||||
|
|
||||||
|
**Стиль:** Минималистичный геймерский дизайн
|
||||||
|
- Темная база с неоновыми акцентами (cyan/purple/pink градиенты)
|
||||||
|
- Glitch эффекты на заголовках и при hover
|
||||||
|
- Glassmorphism для карточек (blur + transparency)
|
||||||
|
- Subtle grain/noise текстура на фоне
|
||||||
|
- Геометрические паттерны и линии
|
||||||
|
- Микро-анимации везде
|
||||||
|
|
||||||
|
**Цветовая палитра:**
|
||||||
|
```
|
||||||
|
Background: #0a0a0f (почти черный с синим оттенком)
|
||||||
|
Surface: #12121a (карточки)
|
||||||
|
Border: #1e1e2e (границы)
|
||||||
|
Primary: #00f0ff (cyan неон)
|
||||||
|
Secondary: #a855f7 (purple)
|
||||||
|
Accent: #f0abfc (pink)
|
||||||
|
Success: #22c55e
|
||||||
|
Error: #ef4444
|
||||||
|
Text: #e2e8f0
|
||||||
|
Text Muted: #64748b
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 1: Базовая инфраструктура
|
||||||
|
|
||||||
|
### 1.1 Обновление Tailwind Config
|
||||||
|
- [ ] Новая цветовая палитра (neon colors)
|
||||||
|
- [ ] Кастомные анимации:
|
||||||
|
- `glitch` - glitch эффект для текста
|
||||||
|
- `glow-pulse` - пульсация свечения
|
||||||
|
- `float` - плавное парение
|
||||||
|
- `slide-in-left/right/up/down` - слайды
|
||||||
|
- `scale-in` - появление с масштабом
|
||||||
|
- `shimmer` - блик на элементах
|
||||||
|
- [ ] Кастомные backdrop-blur классы
|
||||||
|
- [ ] Градиентные утилиты
|
||||||
|
|
||||||
|
### 1.2 Глобальные стили (index.css)
|
||||||
|
- [ ] CSS переменные для цветов
|
||||||
|
- [ ] Glitch keyframes анимация
|
||||||
|
- [ ] Noise/grain overlay
|
||||||
|
- [ ] Glow эффекты (box-shadow неон)
|
||||||
|
- [ ] Custom scrollbar (неоновый)
|
||||||
|
- [ ] Selection стили (выделение текста)
|
||||||
|
|
||||||
|
### 1.3 Новые UI компоненты
|
||||||
|
- [ ] `GlitchText` - текст с glitch эффектом
|
||||||
|
- [ ] `NeonButton` - кнопка с неоновым свечением
|
||||||
|
- [ ] `GlassCard` - карточка с glassmorphism
|
||||||
|
- [ ] `AnimatedCounter` - анимированные числа
|
||||||
|
- [ ] `ProgressBar` - неоновый прогресс бар
|
||||||
|
- [ ] `Badge` - бейджи со свечением
|
||||||
|
- [ ] `Skeleton` - скелетоны загрузки
|
||||||
|
- [ ] `Tooltip` - тултипы
|
||||||
|
- [ ] `Tabs` - табы с анимацией
|
||||||
|
- [ ] `Modal` - переработанная модалка
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 2: Layout и навигация
|
||||||
|
|
||||||
|
### 2.1 Новый Header
|
||||||
|
- [ ] Sticky header с blur при скролле
|
||||||
|
- [ ] Логотип с glitch эффектом при hover
|
||||||
|
- [ ] Анимированная навигация (underline slide)
|
||||||
|
- [ ] Notification bell с badge
|
||||||
|
- [ ] User dropdown с аватаром
|
||||||
|
- [ ] Mobile hamburger menu с slide-in
|
||||||
|
|
||||||
|
### 2.2 Sidebar (новый компонент)
|
||||||
|
- [ ] Collapsible sidebar для desktop
|
||||||
|
- [ ] Иконки с tooltip
|
||||||
|
- [ ] Active state с неоновой подсветкой
|
||||||
|
- [ ] Quick stats внизу
|
||||||
|
|
||||||
|
### 2.3 Footer
|
||||||
|
- [ ] Минималистичный footer
|
||||||
|
- [ ] Social links
|
||||||
|
- [ ] Version info
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 3: Страницы
|
||||||
|
|
||||||
|
### 3.1 HomePage (полный редизайн)
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ HERO SECTION │
|
||||||
|
│ ┌─────────────────────────────────────┐ │
|
||||||
|
│ │ Animated background (particles/ │ │
|
||||||
|
│ │ geometric shapes) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ GAME <glitch>MARATHON</glitch> │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Tagline with typing effect │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [Start Playing] [Watch Demo] │ │
|
||||||
|
│ └─────────────────────────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ FEATURES SECTION (3 glass cards) │
|
||||||
|
│ ┌───────┐ ┌───────┐ ┌───────┐ │
|
||||||
|
│ │ Icon │ │ Icon │ │ Icon │ hover: │
|
||||||
|
│ │ Title │ │ Title │ │ Title │ lift + │
|
||||||
|
│ │ Desc │ │ Desc │ │ Desc │ glow │
|
||||||
|
│ └───────┘ └───────┘ └───────┘ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ HOW IT WORKS (timeline style) │
|
||||||
|
│ ○───────○───────○───────○ │
|
||||||
|
│ 1 2 3 4 │
|
||||||
|
│ Create Add Spin Win │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ LIVE STATS (animated counters) │
|
||||||
|
│ [ 1,234 Marathons ] [ 5,678 Challenges ] │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ CTA SECTION │
|
||||||
|
│ Ready to compete? [Join Now] │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Login/Register Pages
|
||||||
|
- [ ] Centered card с glassmorphism
|
||||||
|
- [ ] Animated background (subtle)
|
||||||
|
- [ ] Form inputs с glow при focus
|
||||||
|
- [ ] Password strength indicator
|
||||||
|
- [ ] Social login buttons (future)
|
||||||
|
- [ ] Smooth transitions между login/register
|
||||||
|
|
||||||
|
### 3.3 MarathonsPage (Dashboard)
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Header: "My Marathons" + Create button │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Quick Stats Bar │
|
||||||
|
│ [Active: 2] [Completed: 5] [Total Wins: 3]│
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Filters/Tabs: All | Active | Completed │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Marathon Cards Grid (2-3 columns) │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Cover image/ │ │ │ │
|
||||||
|
│ │ gradient │ │ │ │
|
||||||
|
│ │ ──────────── │ │ │ │
|
||||||
|
│ │ Title │ │ │ │
|
||||||
|
│ │ Status badge │ │ │ │
|
||||||
|
│ │ Participants │ │ │ │
|
||||||
|
│ │ Progress bar │ │ │ │
|
||||||
|
│ └──────────────────┘ └──────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Join by Code (expandable section) │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 MarathonPage (Detail)
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Hero Banner │
|
||||||
|
│ ┌─────────────────────────────────────┐ │
|
||||||
|
│ │ Background gradient + pattern │ │
|
||||||
|
│ │ Marathon Title (large) │ │
|
||||||
|
│ │ Status | Dates | Participants │ │
|
||||||
|
│ │ [Play] [Leaderboard] [Settings] │ │
|
||||||
|
│ └─────────────────────────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Event Banner (if active) - animated │
|
||||||
|
├────────────────────┬────────────────────────┤
|
||||||
|
│ Main Content │ Sidebar │
|
||||||
|
│ ┌──────────────┐ │ ┌──────────────────┐ │
|
||||||
|
│ │ Your Stats │ │ │ Activity Feed │ │
|
||||||
|
│ │ Points/Streak│ │ │ (scrollable) │ │
|
||||||
|
│ └──────────────┘ │ │ │ │
|
||||||
|
│ ┌──────────────┐ │ │ │ │
|
||||||
|
│ │ Quick Actions│ │ │ │ │
|
||||||
|
│ └──────────────┘ │ │ │ │
|
||||||
|
│ ┌──────────────┐ │ │ │ │
|
||||||
|
│ │ Games List │ │ │ │ │
|
||||||
|
│ │ (collapsible)│ │ │ │ │
|
||||||
|
│ └──────────────┘ │ └──────────────────┘ │
|
||||||
|
└────────────────────┴────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 PlayPage (Game Screen) - ГЛАВНАЯ СТРАНИЦА
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Top Bar: Points | Streak | Event Timer │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────────────────────────┐ │
|
||||||
|
│ │ SPIN WHEEL │ │
|
||||||
|
│ │ (redesigned, neon style) │ │
|
||||||
|
│ │ ┌─────────┐ │ │
|
||||||
|
│ │ │ WHEEL │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ └─────────┘ │ │
|
||||||
|
│ │ [SPIN BUTTON] │ │
|
||||||
|
│ └─────────────────────────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Active Challenge Card (если есть) │
|
||||||
|
│ ┌─────────────────────────────────────┐ │
|
||||||
|
│ │ Game: [Title] | Difficulty: [★★★] │ │
|
||||||
|
│ │ ─────────────────────────────────── │ │
|
||||||
|
│ │ Challenge Title │ │
|
||||||
|
│ │ Description... │ │
|
||||||
|
│ │ ─────────────────────────────────── │ │
|
||||||
|
│ │ Points: 100 | Time: ~2h │ │
|
||||||
|
│ │ ─────────────────────────────────── │ │
|
||||||
|
│ │ Proof Upload Area │ │
|
||||||
|
│ │ [File] [URL] [Comment] │ │
|
||||||
|
│ │ ─────────────────────────────────── │ │
|
||||||
|
│ │ [Complete ✓] [Skip ✗] │ │
|
||||||
|
│ └─────────────────────────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Mini Leaderboard (top 3 + you) │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 LeaderboardPage
|
||||||
|
- [ ] Animated podium для top 3
|
||||||
|
- [ ] Table с hover эффектами
|
||||||
|
- [ ] Highlight для текущего пользователя
|
||||||
|
- [ ] Streak fire animation
|
||||||
|
- [ ] Sorting/filtering
|
||||||
|
|
||||||
|
### 3.7 ProfilePage
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Profile Header │
|
||||||
|
│ ┌─────────────────────────────────────┐ │
|
||||||
|
│ │ Avatar (large, glow border) │ │
|
||||||
|
│ │ Nickname [Edit] │ │
|
||||||
|
│ │ Member since: Date │ │
|
||||||
|
│ └─────────────────────────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Stats Cards (animated counters) │
|
||||||
|
│ [Marathons] [Wins] [Challenges] [Points] │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Achievements Section (future) │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Telegram Connection Card │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Security Section (password change) │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.8 LobbyPage
|
||||||
|
- [ ] Step-by-step wizard UI
|
||||||
|
- [ ] Game cards grid с preview
|
||||||
|
- [ ] Challenge preview с difficulty badges
|
||||||
|
- [ ] AI generation progress animation
|
||||||
|
- [ ] Launch countdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 4: Специальные компоненты
|
||||||
|
|
||||||
|
### 4.1 SpinWheel (полный редизайн)
|
||||||
|
- [ ] 3D perspective эффект
|
||||||
|
- [ ] Неоновые сегменты с названиями игр
|
||||||
|
- [ ] Particle effects при кручении
|
||||||
|
- [ ] Glow trail за указателем
|
||||||
|
- [ ] Sound effects (optional)
|
||||||
|
- [ ] Confetti при выигрыше
|
||||||
|
|
||||||
|
### 4.2 EventBanner (переработка)
|
||||||
|
- [ ] Pulsating glow border
|
||||||
|
- [ ] Countdown timer с flip animation
|
||||||
|
- [ ] Event-specific icons/colors
|
||||||
|
- [ ] Dismiss animation
|
||||||
|
|
||||||
|
### 4.3 ActivityFeed (переработка)
|
||||||
|
- [ ] Timeline style
|
||||||
|
- [ ] Avatar circles
|
||||||
|
- [ ] Type-specific icons
|
||||||
|
- [ ] Hover для деталей
|
||||||
|
- [ ] New items animation (slide-in)
|
||||||
|
|
||||||
|
### 4.4 Challenge Cards
|
||||||
|
- [ ] Difficulty stars/badges
|
||||||
|
- [ ] Progress indicator
|
||||||
|
- [ ] Expandable details
|
||||||
|
- [ ] Proof preview thumbnail
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 5: Анимации и эффекты
|
||||||
|
|
||||||
|
### 5.1 Page Transitions
|
||||||
|
- [ ] Framer Motion page transitions
|
||||||
|
- [ ] Fade + slide between routes
|
||||||
|
- [ ] Loading skeleton screens
|
||||||
|
|
||||||
|
### 5.2 Micro-interactions
|
||||||
|
- [ ] Button press effects
|
||||||
|
- [ ] Input focus glow
|
||||||
|
- [ ] Success checkmark animation
|
||||||
|
- [ ] Error shake animation
|
||||||
|
- [ ] Loading spinners (custom)
|
||||||
|
|
||||||
|
### 5.3 Background Effects
|
||||||
|
- [ ] Animated gradient mesh
|
||||||
|
- [ ] Floating particles (optional)
|
||||||
|
- [ ] Grid pattern overlay
|
||||||
|
- [ ] Noise texture
|
||||||
|
|
||||||
|
### 5.4 Special Effects
|
||||||
|
- [ ] Glitch text на заголовках
|
||||||
|
- [ ] Neon glow на важных элементах
|
||||||
|
- [ ] Shimmer effect на loading
|
||||||
|
- [ ] Confetti на achievements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 6: Responsive и Polish
|
||||||
|
|
||||||
|
### 6.1 Mobile Optimization
|
||||||
|
- [ ] Touch-friendly targets
|
||||||
|
- [ ] Swipe gestures
|
||||||
|
- [ ] Bottom navigation (mobile)
|
||||||
|
- [ ] Collapsible sections
|
||||||
|
|
||||||
|
### 6.2 Accessibility
|
||||||
|
- [ ] Keyboard navigation
|
||||||
|
- [ ] Focus indicators
|
||||||
|
- [ ] Screen reader support
|
||||||
|
- [ ] Reduced motion option
|
||||||
|
|
||||||
|
### 6.3 Performance
|
||||||
|
- [ ] Lazy loading images
|
||||||
|
- [ ] Code splitting
|
||||||
|
- [ ] Animation optimization
|
||||||
|
- [ ] Bundle size check
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок реализации
|
||||||
|
|
||||||
|
### Sprint 1: Фундамент (2-3 дня)
|
||||||
|
1. Tailwind config + colors
|
||||||
|
2. Global CSS + animations
|
||||||
|
3. Base UI components (Button, Card, Input)
|
||||||
|
4. GlitchText component
|
||||||
|
5. Updated Layout/Header
|
||||||
|
|
||||||
|
### Sprint 2: Core Pages (3-4 дня)
|
||||||
|
1. HomePage с hero
|
||||||
|
2. Login/Register
|
||||||
|
3. MarathonsPage dashboard
|
||||||
|
4. Profile page
|
||||||
|
|
||||||
|
### Sprint 3: Game Flow (3-4 дня)
|
||||||
|
1. MarathonPage detail
|
||||||
|
2. SpinWheel redesign
|
||||||
|
3. PlayPage
|
||||||
|
4. LeaderboardPage
|
||||||
|
|
||||||
|
### Sprint 4: Polish (2-3 дня)
|
||||||
|
1. LobbyPage
|
||||||
|
2. Event components
|
||||||
|
3. Activity feed
|
||||||
|
4. Animations & transitions
|
||||||
|
|
||||||
|
### Sprint 5: Finalization (1-2 дня)
|
||||||
|
1. Mobile testing
|
||||||
|
2. Performance optimization
|
||||||
|
3. Bug fixes
|
||||||
|
4. Final polish
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Референсы для вдохновления
|
||||||
|
|
||||||
|
- Cyberpunk 2077 UI
|
||||||
|
- Discord dark theme
|
||||||
|
- Valorant game UI
|
||||||
|
- Steam profile pages
|
||||||
|
- Twitch streaming UI
|
||||||
|
- Epic Games Store
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технические заметки
|
||||||
|
|
||||||
|
**Framer Motion:** Использовать для page transitions и сложных анимаций
|
||||||
|
**CSS:** Использовать для простых transitions и hover эффектов
|
||||||
|
**Tailwind:** Основной инструмент для стилей
|
||||||
|
**Custom Hooks:** useAnimation, useGlitch для переиспользования логики
|
||||||
72
backend/alembic/versions/001_add_roles_system.py
Normal file
72
backend/alembic/versions/001_add_roles_system.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Add roles system
|
||||||
|
|
||||||
|
Revision ID: 001_add_roles
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-12-14
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '001_add_roles'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add role column to users table
|
||||||
|
op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user'))
|
||||||
|
|
||||||
|
# Add role column to participants table
|
||||||
|
op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant'))
|
||||||
|
|
||||||
|
# Rename organizer_id to creator_id in marathons table
|
||||||
|
op.alter_column('marathons', 'organizer_id', new_column_name='creator_id')
|
||||||
|
|
||||||
|
# Update existing participants: set role='organizer' for marathon creators
|
||||||
|
op.execute("""
|
||||||
|
UPDATE participants p
|
||||||
|
SET role = 'organizer'
|
||||||
|
FROM marathons m
|
||||||
|
WHERE p.marathon_id = m.id AND p.user_id = m.creator_id
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Add status column to games table
|
||||||
|
op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved'))
|
||||||
|
|
||||||
|
# Rename added_by_id to proposed_by_id in games table
|
||||||
|
op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id')
|
||||||
|
|
||||||
|
# Add approved_by_id column to games table
|
||||||
|
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
'fk_games_approved_by_id',
|
||||||
|
'games', 'users',
|
||||||
|
['approved_by_id'], ['id'],
|
||||||
|
ondelete='SET NULL'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove approved_by_id from games
|
||||||
|
op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey')
|
||||||
|
op.drop_column('games', 'approved_by_id')
|
||||||
|
|
||||||
|
# Rename proposed_by_id back to added_by_id
|
||||||
|
op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id')
|
||||||
|
|
||||||
|
# Remove status from games
|
||||||
|
op.drop_column('games', 'status')
|
||||||
|
|
||||||
|
# Rename creator_id back to organizer_id
|
||||||
|
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
|
||||||
|
|
||||||
|
# Remove role from participants
|
||||||
|
op.drop_column('participants', 'role')
|
||||||
|
|
||||||
|
# Remove role from users
|
||||||
|
op.drop_column('users', 'role')
|
||||||
32
backend/alembic/versions/002_marathon_settings.py
Normal file
32
backend/alembic/versions/002_marathon_settings.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add marathon settings (is_public, game_proposal_mode)
|
||||||
|
|
||||||
|
Revision ID: 002_marathon_settings
|
||||||
|
Revises: 001_add_roles
|
||||||
|
Create Date: 2024-12-14
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '002_marathon_settings'
|
||||||
|
down_revision: Union[str, None] = '001_add_roles'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add is_public column to marathons table (default False = private)
|
||||||
|
op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
|
||||||
|
# Add game_proposal_mode column to marathons table
|
||||||
|
# 'all_participants' - anyone can propose games (with moderation)
|
||||||
|
# 'organizer_only' - only organizers can add games
|
||||||
|
op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('marathons', 'game_proposal_mode')
|
||||||
|
op.drop_column('marathons', 'is_public')
|
||||||
38
backend/alembic/versions/003_create_admin_user.py
Normal file
38
backend/alembic/versions/003_create_admin_user.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Create admin user
|
||||||
|
|
||||||
|
Revision ID: 003_create_admin
|
||||||
|
Revises: 002_marathon_settings
|
||||||
|
Create Date: 2024-12-14
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '003_create_admin'
|
||||||
|
down_revision: Union[str, None] = '002_marathon_settings'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Hash the password
|
||||||
|
password_hash = pwd_context.hash("RPQ586qq")
|
||||||
|
|
||||||
|
# Insert admin user (ignore if already exists)
|
||||||
|
op.execute(f"""
|
||||||
|
INSERT INTO users (login, password_hash, nickname, role, created_at)
|
||||||
|
VALUES ('admin', '{password_hash}', 'Admin', 'admin', NOW())
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
password_hash = '{password_hash}',
|
||||||
|
role = 'admin'
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DELETE FROM users WHERE login = 'admin'")
|
||||||
60
backend/alembic/versions/004_add_events.py
Normal file
60
backend/alembic/versions/004_add_events.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Add events table and auto_events_enabled to marathons
|
||||||
|
|
||||||
|
Revision ID: 004_add_events
|
||||||
|
Revises: 003_create_admin
|
||||||
|
Create Date: 2024-12-14
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '004_add_events'
|
||||||
|
down_revision: Union[str, None] = '003_create_admin'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create events table if it doesn't exist
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if 'events' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'events',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('marathon_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('type', sa.String(30), nullable=False),
|
||||||
|
sa.Column('start_time', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('created_by_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['marathon_id'], ['marathons.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create index if it doesn't exist
|
||||||
|
indexes = [idx['name'] for idx in inspector.get_indexes('events')]
|
||||||
|
if 'ix_events_marathon_id' not in indexes:
|
||||||
|
op.create_index('ix_events_marathon_id', 'events', ['marathon_id'])
|
||||||
|
|
||||||
|
# Add auto_events_enabled to marathons if it doesn't exist
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('marathons')]
|
||||||
|
if 'auto_events_enabled' not in columns:
|
||||||
|
op.add_column(
|
||||||
|
'marathons',
|
||||||
|
sa.Column('auto_events_enabled', sa.Boolean(), nullable=False, server_default='true')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('marathons', 'auto_events_enabled')
|
||||||
|
op.drop_index('ix_events_marathon_id', table_name='events')
|
||||||
|
op.drop_table('events')
|
||||||
34
backend/alembic/versions/005_add_assignment_event_type.py
Normal file
34
backend/alembic/versions/005_add_assignment_event_type.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Add event_type to assignments
|
||||||
|
|
||||||
|
Revision ID: 005_add_assignment_event_type
|
||||||
|
Revises: 004_add_events
|
||||||
|
Create Date: 2024-12-14
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '005_add_assignment_event_type'
|
||||||
|
down_revision: Union[str, None] = '004_add_events'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add event_type column to assignments if it doesn't exist
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('assignments')]
|
||||||
|
|
||||||
|
if 'event_type' not in columns:
|
||||||
|
op.add_column(
|
||||||
|
'assignments',
|
||||||
|
sa.Column('event_type', sa.String(30), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('assignments', 'event_type')
|
||||||
54
backend/alembic/versions/006_add_swap_requests.py
Normal file
54
backend/alembic/versions/006_add_swap_requests.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Add swap_requests table for two-sided swap confirmation
|
||||||
|
|
||||||
|
Revision ID: 006_add_swap_requests
|
||||||
|
Revises: 005_assignment_event
|
||||||
|
Create Date: 2024-12-15
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '006_add_swap_requests'
|
||||||
|
down_revision: Union[str, None] = '005_add_assignment_event_type'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create swap_requests table if it doesn't exist
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if 'swap_requests' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'swap_requests',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('event_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('from_participant_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('to_participant_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('from_assignment_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('to_assignment_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('responded_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['from_participant_id'], ['participants.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['to_participant_id'], ['participants.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['from_assignment_id'], ['assignments.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['to_assignment_id'], ['assignments.id'], ondelete='CASCADE'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_swap_requests_event_id', 'swap_requests', ['event_id'])
|
||||||
|
op.create_index('ix_swap_requests_from_participant_id', 'swap_requests', ['from_participant_id'])
|
||||||
|
op.create_index('ix_swap_requests_to_participant_id', 'swap_requests', ['to_participant_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_swap_requests_to_participant_id', table_name='swap_requests')
|
||||||
|
op.drop_index('ix_swap_requests_from_participant_id', table_name='swap_requests')
|
||||||
|
op.drop_index('ix_swap_requests_event_id', table_name='swap_requests')
|
||||||
|
op.drop_table('swap_requests')
|
||||||
54
backend/alembic/versions/007_add_event_assignment_fields.py
Normal file
54
backend/alembic/versions/007_add_event_assignment_fields.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Add is_event_assignment and event_id to assignments for Common Enemy support
|
||||||
|
|
||||||
|
Revision ID: 007_add_event_assignment_fields
|
||||||
|
Revises: 006_add_swap_requests
|
||||||
|
Create Date: 2024-12-15
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '007_add_event_assignment_fields'
|
||||||
|
down_revision: Union[str, None] = '006_add_swap_requests'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add is_event_assignment column with default False
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('assignments')]
|
||||||
|
|
||||||
|
if 'is_event_assignment' not in columns:
|
||||||
|
op.add_column(
|
||||||
|
'assignments',
|
||||||
|
sa.Column('is_event_assignment', sa.Boolean(), nullable=False, server_default=sa.false())
|
||||||
|
)
|
||||||
|
op.create_index('ix_assignments_is_event_assignment', 'assignments', ['is_event_assignment'])
|
||||||
|
|
||||||
|
if 'event_id' not in columns:
|
||||||
|
op.add_column(
|
||||||
|
'assignments',
|
||||||
|
sa.Column('event_id', sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
'fk_assignments_event_id',
|
||||||
|
'assignments',
|
||||||
|
'events',
|
||||||
|
['event_id'],
|
||||||
|
['id'],
|
||||||
|
ondelete='SET NULL'
|
||||||
|
)
|
||||||
|
op.create_index('ix_assignments_event_id', 'assignments', ['event_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_assignments_event_id', table_name='assignments')
|
||||||
|
op.drop_constraint('fk_assignments_event_id', 'assignments', type_='foreignkey')
|
||||||
|
op.drop_column('assignments', 'event_id')
|
||||||
|
op.drop_index('ix_assignments_is_event_assignment', table_name='assignments')
|
||||||
|
op.drop_column('assignments', 'is_event_assignment')
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Rename rematch event type to game_choice
|
||||||
|
|
||||||
|
Revision ID: 008_rename_to_game_choice
|
||||||
|
Revises: 007_add_event_assignment_fields
|
||||||
|
Create Date: 2024-12-15
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "008_rename_to_game_choice"
|
||||||
|
down_revision = "007_add_event_assignment_fields"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Update event type from 'rematch' to 'game_choice' in events table
|
||||||
|
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
|
||||||
|
|
||||||
|
# Update event_type in assignments table
|
||||||
|
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
|
||||||
|
|
||||||
|
# Update activity data that references rematch event
|
||||||
|
op.execute("""
|
||||||
|
UPDATE activities
|
||||||
|
SET data = jsonb_set(data, '{event_type}', '"game_choice"')
|
||||||
|
WHERE data->>'event_type' = 'rematch'
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Revert event type from 'game_choice' to 'rematch'
|
||||||
|
op.execute("UPDATE events SET type = 'rematch' WHERE type = 'game_choice'")
|
||||||
|
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
|
||||||
|
op.execute("""
|
||||||
|
UPDATE activities
|
||||||
|
SET data = jsonb_set(data, '{event_type}', '"rematch"')
|
||||||
|
WHERE data->>'event_type' = 'game_choice'
|
||||||
|
""")
|
||||||
81
backend/alembic/versions/009_add_disputes.py
Normal file
81
backend/alembic/versions/009_add_disputes.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Add disputes tables for proof verification system
|
||||||
|
|
||||||
|
Revision ID: 009_add_disputes
|
||||||
|
Revises: 008_rename_to_game_choice
|
||||||
|
Create Date: 2024-12-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '009_add_disputes'
|
||||||
|
down_revision: Union[str, None] = '008_rename_to_game_choice'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
# Create disputes table
|
||||||
|
if 'disputes' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'disputes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('assignment_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('raised_by_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('reason', sa.Text(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(20), nullable=False, server_default='open'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('resolved_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['assignment_id'], ['assignments.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['raised_by_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.UniqueConstraint('assignment_id', name='uq_dispute_assignment'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_disputes_assignment_id', 'disputes', ['assignment_id'])
|
||||||
|
|
||||||
|
# Create dispute_comments table
|
||||||
|
if 'dispute_comments' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'dispute_comments',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('dispute_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('text', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_dispute_comments_dispute_id', 'dispute_comments', ['dispute_id'])
|
||||||
|
|
||||||
|
# Create dispute_votes table
|
||||||
|
if 'dispute_votes' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'dispute_votes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('dispute_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('vote', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.UniqueConstraint('dispute_id', 'user_id', name='uq_dispute_vote_user'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_dispute_votes_dispute_id', 'dispute_votes', ['dispute_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_dispute_votes_dispute_id', table_name='dispute_votes')
|
||||||
|
op.drop_table('dispute_votes')
|
||||||
|
op.drop_index('ix_dispute_comments_dispute_id', table_name='dispute_comments')
|
||||||
|
op.drop_table('dispute_comments')
|
||||||
|
op.drop_index('ix_disputes_assignment_id', table_name='disputes')
|
||||||
|
op.drop_table('disputes')
|
||||||
30
backend/alembic/versions/010_add_telegram_profile.py
Normal file
30
backend/alembic/versions/010_add_telegram_profile.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Add telegram profile fields to users
|
||||||
|
|
||||||
|
Revision ID: 010_add_telegram_profile
|
||||||
|
Revises: 009_add_disputes
|
||||||
|
Create Date: 2024-12-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '010_add_telegram_profile'
|
||||||
|
down_revision: Union[str, None] = '009_add_disputes'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'telegram_avatar_url')
|
||||||
|
op.drop_column('users', 'telegram_last_name')
|
||||||
|
op.drop_column('users', 'telegram_first_name')
|
||||||
28
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
28
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add challenge proposals support
|
||||||
|
|
||||||
|
Revision ID: 011_add_challenge_proposals
|
||||||
|
Revises: 010_add_telegram_profile
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '011_add_challenge_proposals'
|
||||||
|
down_revision: Union[str, None] = '010_add_telegram_profile'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
||||||
|
op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('challenges', 'status')
|
||||||
|
op.drop_column('challenges', 'proposed_by_id')
|
||||||
32
backend/alembic/versions/012_add_user_banned.py
Normal file
32
backend/alembic/versions/012_add_user_banned.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add user banned fields
|
||||||
|
|
||||||
|
Revision ID: 012_add_user_banned
|
||||||
|
Revises: 011_add_challenge_proposals
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '012_add_user_banned'
|
||||||
|
down_revision: Union[str, None] = '011_add_challenge_proposals'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False))
|
||||||
|
op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'ban_reason')
|
||||||
|
op.drop_column('users', 'banned_by_id')
|
||||||
|
op.drop_column('users', 'banned_at')
|
||||||
|
op.drop_column('users', 'is_banned')
|
||||||
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Add admin_logs table
|
||||||
|
|
||||||
|
Revision ID: 013_add_admin_logs
|
||||||
|
Revises: 012_add_user_banned
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '013_add_admin_logs'
|
||||||
|
down_revision: Union[str, None] = '012_add_user_banned'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('admin_logs'):
|
||||||
|
op.create_table(
|
||||||
|
'admin_logs',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('admin_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('action', sa.String(50), nullable=False),
|
||||||
|
sa.Column('target_type', sa.String(50), nullable=False),
|
||||||
|
sa.Column('target_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('details', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('ip_address', sa.String(50), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_admin_id'):
|
||||||
|
op.create_index('ix_admin_logs_admin_id', 'admin_logs', ['admin_id'])
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_action'):
|
||||||
|
op.create_index('ix_admin_logs_action', 'admin_logs', ['action'])
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_created_at'):
|
||||||
|
op.create_index('ix_admin_logs_created_at', 'admin_logs', ['created_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_admin_logs_created_at', 'admin_logs')
|
||||||
|
op.drop_index('ix_admin_logs_action', 'admin_logs')
|
||||||
|
op.drop_index('ix_admin_logs_admin_id', 'admin_logs')
|
||||||
|
op.drop_table('admin_logs')
|
||||||
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Add admin_2fa_sessions table
|
||||||
|
|
||||||
|
Revision ID: 014_add_admin_2fa
|
||||||
|
Revises: 013_add_admin_logs
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '014_add_admin_2fa'
|
||||||
|
down_revision: Union[str, None] = '013_add_admin_logs'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('admin_2fa_sessions'):
|
||||||
|
op.create_table(
|
||||||
|
'admin_2fa_sessions',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('code', sa.String(6), nullable=False),
|
||||||
|
sa.Column('telegram_sent', sa.Boolean(), server_default='false', nullable=False),
|
||||||
|
sa.Column('is_verified', sa.Boolean(), server_default='false', nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_user_id'):
|
||||||
|
op.create_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions', ['user_id'])
|
||||||
|
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_expires_at'):
|
||||||
|
op.create_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions', ['expires_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions')
|
||||||
|
op.drop_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions')
|
||||||
|
op.drop_table('admin_2fa_sessions')
|
||||||
54
backend/alembic/versions/015_add_static_content.py
Normal file
54
backend/alembic/versions/015_add_static_content.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Add static_content table
|
||||||
|
|
||||||
|
Revision ID: 015_add_static_content
|
||||||
|
Revises: 014_add_admin_2fa
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '015_add_static_content'
|
||||||
|
down_revision: Union[str, None] = '014_add_admin_2fa'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('static_content'):
|
||||||
|
op.create_table(
|
||||||
|
'static_content',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('key', sa.String(100), unique=True, nullable=False),
|
||||||
|
sa.Column('title', sa.String(200), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('updated_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('static_content', 'ix_static_content_key'):
|
||||||
|
op.create_index('ix_static_content_key', 'static_content', ['key'], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_static_content_key', 'static_content')
|
||||||
|
op.drop_table('static_content')
|
||||||
36
backend/alembic/versions/016_add_banned_until.py
Normal file
36
backend/alembic/versions/016_add_banned_until.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Add banned_until field
|
||||||
|
|
||||||
|
Revision ID: 016_add_banned_until
|
||||||
|
Revises: 015_add_static_content
|
||||||
|
Create Date: 2024-12-19
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '016_add_banned_until'
|
||||||
|
down_revision: Union[str, None] = '015_add_static_content'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists('users', 'banned_until'):
|
||||||
|
op.add_column('users', sa.Column('banned_until', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists('users', 'banned_until'):
|
||||||
|
op.drop_column('users', 'banned_until')
|
||||||
32
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
32
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Make admin_id nullable in admin_logs for system actions
|
||||||
|
|
||||||
|
Revision ID: 017_admin_logs_nullable_admin_id
|
||||||
|
Revises: 016_add_banned_until
|
||||||
|
Create Date: 2024-12-19
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '017_admin_logs_nullable_admin_id'
|
||||||
|
down_revision: Union[str, None] = '016_add_banned_until'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Make admin_id nullable for system actions (like auto-unban)
|
||||||
|
op.alter_column('admin_logs', 'admin_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Revert to not nullable (will fail if there are NULL values)
|
||||||
|
op.alter_column('admin_logs', 'admin_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=False)
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
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.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import decode_access_token
|
from app.core.security import decode_access_token
|
||||||
from app.models import User
|
from app.models import User, Participant, Marathon, UserRole, ParticipantRole, AdminLog, AdminActionType
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
@@ -42,9 +44,183 @@ async def get_current_user(
|
|||||||
detail="User not found",
|
detail="User not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if user is banned
|
||||||
|
if user.is_banned:
|
||||||
|
# Auto-unban if ban expired
|
||||||
|
if user.banned_until and datetime.utcnow() > user.banned_until:
|
||||||
|
# Save ban info for logging before clearing
|
||||||
|
old_ban_reason = user.ban_reason
|
||||||
|
old_banned_until = user.banned_until.isoformat() if user.banned_until else None
|
||||||
|
|
||||||
|
user.is_banned = False
|
||||||
|
user.banned_at = None
|
||||||
|
user.banned_until = None
|
||||||
|
user.banned_by_id = None
|
||||||
|
user.ban_reason = None
|
||||||
|
|
||||||
|
# Log system auto-unban action
|
||||||
|
log = AdminLog(
|
||||||
|
admin_id=None, # System action, no admin
|
||||||
|
action=AdminActionType.USER_AUTO_UNBAN.value,
|
||||||
|
target_type="user",
|
||||||
|
target_id=user.id,
|
||||||
|
details={
|
||||||
|
"nickname": user.nickname,
|
||||||
|
"reason": old_ban_reason,
|
||||||
|
"banned_until": old_banned_until,
|
||||||
|
"system": True,
|
||||||
|
},
|
||||||
|
ip_address=None,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
else:
|
||||||
|
# Still banned - return ban info in error
|
||||||
|
ban_info = {
|
||||||
|
"banned_at": user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
"banned_until": user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
"reason": user.ban_reason,
|
||||||
|
}
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=ban_info,
|
||||||
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user: User) -> User:
|
||||||
|
"""Check if user is admin"""
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin access required",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin_with_2fa(user: User) -> User:
|
||||||
|
"""Check if user is admin with Telegram linked (2FA enabled)"""
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin access required",
|
||||||
|
)
|
||||||
|
if not user.telegram_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Для доступа к админ-панели необходимо привязать Telegram в профиле",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_participant(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_id: int,
|
||||||
|
) -> Participant | None:
|
||||||
|
"""Get participant record for user in marathon"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == user_id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def require_participant(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_id: int,
|
||||||
|
) -> Participant:
|
||||||
|
"""Require user to be participant of marathon"""
|
||||||
|
participant = await get_participant(db, user_id, marathon_id)
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You are not a participant of this marathon",
|
||||||
|
)
|
||||||
|
return participant
|
||||||
|
|
||||||
|
|
||||||
|
async def require_organizer(
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
marathon_id: int,
|
||||||
|
) -> Participant:
|
||||||
|
"""Require user to be organizer of marathon (or admin)"""
|
||||||
|
if user.is_admin:
|
||||||
|
# Admins can act as organizers
|
||||||
|
participant = await get_participant(db, user.id, marathon_id)
|
||||||
|
if participant:
|
||||||
|
return participant
|
||||||
|
# Create virtual participant for admin
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
# Return a temporary object for admin
|
||||||
|
return Participant(
|
||||||
|
user_id=user.id,
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
role=ParticipantRole.ORGANIZER.value
|
||||||
|
)
|
||||||
|
|
||||||
|
participant = await get_participant(db, user.id, marathon_id)
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You are not a participant of this marathon",
|
||||||
|
)
|
||||||
|
if not participant.is_organizer:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only organizers can perform this action",
|
||||||
|
)
|
||||||
|
return participant
|
||||||
|
|
||||||
|
|
||||||
|
async def require_creator(
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
marathon_id: int,
|
||||||
|
) -> Marathon:
|
||||||
|
"""Require user to be creator of marathon (or admin)"""
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
if not user.is_admin and marathon.creator_id != user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only the creator can perform this action",
|
||||||
|
)
|
||||||
|
return marathon
|
||||||
|
|
||||||
|
|
||||||
# Type aliases for cleaner dependency injection
|
# Type aliases for cleaner dependency injection
|
||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_bot_secret(
|
||||||
|
x_bot_secret: str | None = Header(None, alias="X-Bot-Secret")
|
||||||
|
) -> None:
|
||||||
|
"""Verify that request comes from trusted bot using secret key."""
|
||||||
|
if not settings.BOT_API_SECRET:
|
||||||
|
# If secret is not configured, skip check (for development)
|
||||||
|
return
|
||||||
|
|
||||||
|
if x_bot_secret != settings.BOT_API_SECRET:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invalid or missing bot secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BotSecretDep = Annotated[None, Depends(verify_bot_secret)]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed
|
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -11,3 +11,8 @@ router.include_router(games.router)
|
|||||||
router.include_router(challenges.router)
|
router.include_router(challenges.router)
|
||||||
router.include_router(wheel.router)
|
router.include_router(wheel.router)
|
||||||
router.include_router(feed.router)
|
router.include_router(feed.router)
|
||||||
|
router.include_router(admin.router)
|
||||||
|
router.include_router(events.router)
|
||||||
|
router.include_router(assignments.router)
|
||||||
|
router.include_router(telegram.router)
|
||||||
|
router.include_router(content.router)
|
||||||
|
|||||||
747
backend/app/api/v1/admin.py
Normal file
747
backend/app/api/v1/admin.py
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
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.schemas import (
|
||||||
|
UserPublic, MessageResponse,
|
||||||
|
AdminUserResponse, BanUserRequest, AdminLogResponse, AdminLogsListResponse,
|
||||||
|
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
|
||||||
|
StaticContentCreate, DashboardStats
|
||||||
|
)
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
class SetUserRole(BaseModel):
|
||||||
|
role: str = Field(..., pattern="^(user|admin)$")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminMarathonResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
status: str
|
||||||
|
creator: UserPublic
|
||||||
|
participants_count: int
|
||||||
|
games_count: int
|
||||||
|
start_date: str | None
|
||||||
|
end_date: str | None
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Helper Functions ============
|
||||||
|
async def log_admin_action(
|
||||||
|
db,
|
||||||
|
admin_id: int,
|
||||||
|
action: str,
|
||||||
|
target_type: str,
|
||||||
|
target_id: int,
|
||||||
|
details: dict | None = None,
|
||||||
|
ip_address: str | None = None
|
||||||
|
):
|
||||||
|
"""Log an admin action."""
|
||||||
|
log = AdminLog(
|
||||||
|
admin_id=admin_id,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
details=details,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=list[AdminUserResponse])
|
||||||
|
async def list_users(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
search: str | None = None,
|
||||||
|
banned_only: bool = False,
|
||||||
|
):
|
||||||
|
"""List all users. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
query = select(User).order_by(User.created_at.desc())
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.where(
|
||||||
|
(User.login.ilike(f"%{search}%")) |
|
||||||
|
(User.nickname.ilike(f"%{search}%"))
|
||||||
|
)
|
||||||
|
|
||||||
|
if banned_only:
|
||||||
|
query = query.where(User.is_banned == True)
|
||||||
|
|
||||||
|
query = query.offset(skip).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
response = []
|
||||||
|
for user in users:
|
||||||
|
# Count marathons user participates in
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
response.append(AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}", response_model=AdminUserResponse)
|
||||||
|
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Get user details. Admin only."""
|
||||||
|
require_admin_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")
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
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)
|
||||||
|
async def set_user_role(
|
||||||
|
user_id: int,
|
||||||
|
data: SetUserRole,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
request: Request,
|
||||||
|
):
|
||||||
|
"""Set user's global role. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Cannot change own role
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot change your own role")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
old_role = user.role
|
||||||
|
user.role = data.role
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_ROLE_CHANGE.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"old_role": old_role, "new_role": data.role, "nickname": user.nickname},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
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)
|
||||||
|
async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Delete a user. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Cannot delete yourself
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Cannot delete another admin
|
||||||
|
if user.role == UserRole.ADMIN.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete another admin")
|
||||||
|
|
||||||
|
await db.delete(user)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(message="User deleted")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons", response_model=list[AdminMarathonResponse])
|
||||||
|
async def list_marathons(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
search: str | None = None,
|
||||||
|
):
|
||||||
|
"""List all marathons. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(Marathon)
|
||||||
|
.options(selectinload(Marathon.creator))
|
||||||
|
.order_by(Marathon.created_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.where(Marathon.title.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
query = query.offset(skip).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
marathons = result.scalars().all()
|
||||||
|
|
||||||
|
response = []
|
||||||
|
for marathon in marathons:
|
||||||
|
participants_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon.id)
|
||||||
|
)
|
||||||
|
games_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Game).where(Game.marathon_id == marathon.id)
|
||||||
|
)
|
||||||
|
response.append(AdminMarathonResponse(
|
||||||
|
id=marathon.id,
|
||||||
|
title=marathon.title,
|
||||||
|
status=marathon.status,
|
||||||
|
creator=UserPublic.model_validate(marathon.creator),
|
||||||
|
participants_count=participants_count,
|
||||||
|
games_count=games_count,
|
||||||
|
start_date=marathon.start_date.isoformat() if marathon.start_date else None,
|
||||||
|
end_date=marathon.end_date.isoformat() if marathon.end_date else None,
|
||||||
|
created_at=marathon.created_at.isoformat(),
|
||||||
|
))
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
|
||||||
|
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession, request: Request):
|
||||||
|
"""Delete 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")
|
||||||
|
|
||||||
|
marathon_title = marathon.title
|
||||||
|
await db.delete(marathon)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.MARATHON_DELETE.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon_title},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return MessageResponse(message="Marathon deleted")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_stats(current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Get platform statistics. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
users_count = await db.scalar(select(func.count()).select_from(User))
|
||||||
|
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
||||||
|
games_count = await db.scalar(select(func.count()).select_from(Game))
|
||||||
|
participants_count = await db.scalar(select(func.count()).select_from(Participant))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"users_count": users_count,
|
||||||
|
"marathons_count": marathons_count,
|
||||||
|
"games_count": games_count,
|
||||||
|
"total_participations": participants_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Ban/Unban Users ============
|
||||||
|
@router.post("/users/{user_id}/ban", response_model=AdminUserResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def ban_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
data: BanUserRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Ban a user. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot ban yourself")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if user.role == UserRole.ADMIN.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot ban another admin")
|
||||||
|
|
||||||
|
if user.is_banned:
|
||||||
|
raise HTTPException(status_code=400, detail="User is already banned")
|
||||||
|
|
||||||
|
user.is_banned = True
|
||||||
|
user.banned_at = datetime.utcnow()
|
||||||
|
# Normalize to naive datetime (remove tzinfo) to match banned_at
|
||||||
|
user.banned_until = data.banned_until.replace(tzinfo=None) if data.banned_until else None
|
||||||
|
user.banned_by_id = current_user.id
|
||||||
|
user.ban_reason = data.reason
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_BAN.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"nickname": user.nickname, "reason": data.reason},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
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)
|
||||||
|
async def unban_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Unban 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")
|
||||||
|
|
||||||
|
if not user.is_banned:
|
||||||
|
raise HTTPException(status_code=400, detail="User is not banned")
|
||||||
|
|
||||||
|
user.is_banned = False
|
||||||
|
user.banned_at = None
|
||||||
|
user.banned_until = None
|
||||||
|
user.banned_by_id = None
|
||||||
|
user.ban_reason = None
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_UNBAN.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"nickname": user.nickname},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=None,
|
||||||
|
banned_until=None,
|
||||||
|
ban_reason=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Force Finish Marathon ============
|
||||||
|
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
|
||||||
|
async def force_finish_marathon(
|
||||||
|
request: Request,
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Force finish 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.status == MarathonStatus.FINISHED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Marathon is already finished")
|
||||||
|
|
||||||
|
old_status = marathon.status
|
||||||
|
marathon.status = MarathonStatus.FINISHED.value
|
||||||
|
marathon.end_date = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.MARATHON_FORCE_FINISH.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon.title, "old_status": old_status},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify participants
|
||||||
|
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
|
||||||
|
|
||||||
|
return MessageResponse(message="Marathon finished")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Admin Logs ============
|
||||||
|
@router.get("/logs", response_model=AdminLogsListResponse)
|
||||||
|
async def get_logs(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
action: str | None = None,
|
||||||
|
admin_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""Get admin action logs. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(AdminLog)
|
||||||
|
.options(selectinload(AdminLog.admin))
|
||||||
|
.order_by(AdminLog.created_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
if action:
|
||||||
|
query = query.where(AdminLog.action == action)
|
||||||
|
if admin_id:
|
||||||
|
query = query.where(AdminLog.admin_id == admin_id)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = select(func.count()).select_from(AdminLog)
|
||||||
|
if action:
|
||||||
|
count_query = count_query.where(AdminLog.action == action)
|
||||||
|
if admin_id:
|
||||||
|
count_query = count_query.where(AdminLog.admin_id == admin_id)
|
||||||
|
total = await db.scalar(count_query)
|
||||||
|
|
||||||
|
query = query.offset(skip).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
|
||||||
|
return AdminLogsListResponse(
|
||||||
|
logs=[
|
||||||
|
AdminLogResponse(
|
||||||
|
id=log.id,
|
||||||
|
admin_id=log.admin_id,
|
||||||
|
admin_nickname=log.admin.nickname if log.admin else None,
|
||||||
|
action=log.action,
|
||||||
|
target_type=log.target_type,
|
||||||
|
target_id=log.target_id,
|
||||||
|
details=log.details,
|
||||||
|
ip_address=log.ip_address,
|
||||||
|
created_at=log.created_at,
|
||||||
|
)
|
||||||
|
for log in logs
|
||||||
|
],
|
||||||
|
total=total or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Broadcast ============
|
||||||
|
@router.post("/broadcast/all", response_model=BroadcastResponse)
|
||||||
|
@limiter.limit("1/minute")
|
||||||
|
async def broadcast_to_all(
|
||||||
|
request: Request,
|
||||||
|
data: BroadcastRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Send broadcast message to all users with Telegram linked. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Get all users with telegram_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id.isnot(None))
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
total_count = len(users)
|
||||||
|
sent_count = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
if await telegram_notifier.send_message(user.telegram_id, data.message):
|
||||||
|
sent_count += 1
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
|
||||||
|
"broadcast", 0,
|
||||||
|
{"message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/broadcast/marathon/{marathon_id}", response_model=BroadcastResponse)
|
||||||
|
@limiter.limit("3/minute")
|
||||||
|
async def broadcast_to_marathon(
|
||||||
|
request: Request,
|
||||||
|
marathon_id: int,
|
||||||
|
data: BroadcastRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Send broadcast message to marathon participants. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Get participants count
|
||||||
|
total_result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.join(Participant, Participant.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
User.telegram_id.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
users = total_result.scalars().all()
|
||||||
|
total_count = len(users)
|
||||||
|
|
||||||
|
sent_count = await telegram_notifier.notify_marathon_participants(
|
||||||
|
db, marathon_id, data.message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Static Content ============
|
||||||
|
@router.get("/content", response_model=list[StaticContentResponse])
|
||||||
|
async def list_content(current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List all static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).order_by(StaticContent.key)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/content/{key}", response_model=StaticContentResponse)
|
||||||
|
async def get_content(key: str, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Get static content by key. 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")
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/content/{key}", response_model=StaticContentResponse)
|
||||||
|
async def update_content(
|
||||||
|
request: Request,
|
||||||
|
key: str,
|
||||||
|
data: StaticContentUpdate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Update 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")
|
||||||
|
|
||||||
|
content.title = data.title
|
||||||
|
content.content = data.content
|
||||||
|
content.updated_by_id = current_user.id
|
||||||
|
content.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(content)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
|
||||||
|
"content", content.id,
|
||||||
|
{"key": key, "title": data.title},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/content", response_model=StaticContentResponse)
|
||||||
|
async def create_content(
|
||||||
|
request: Request,
|
||||||
|
data: StaticContentCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Create static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Check if key exists
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == data.key)
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="Content with this key already exists")
|
||||||
|
|
||||||
|
content = StaticContent(
|
||||||
|
key=data.key,
|
||||||
|
title=data.title,
|
||||||
|
content=data.content,
|
||||||
|
updated_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(content)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(content)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Dashboard ============
|
||||||
|
@router.get("/dashboard", response_model=DashboardStats)
|
||||||
|
async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Get dashboard statistics. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
users_count = await db.scalar(select(func.count()).select_from(User))
|
||||||
|
banned_users_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(User).where(User.is_banned == True)
|
||||||
|
)
|
||||||
|
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
||||||
|
active_marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Marathon).where(Marathon.status == MarathonStatus.ACTIVE.value)
|
||||||
|
)
|
||||||
|
games_count = await db.scalar(select(func.count()).select_from(Game))
|
||||||
|
total_participations = await db.scalar(select(func.count()).select_from(Participant))
|
||||||
|
|
||||||
|
# Get recent logs
|
||||||
|
result = await db.execute(
|
||||||
|
select(AdminLog)
|
||||||
|
.options(selectinload(AdminLog.admin))
|
||||||
|
.order_by(AdminLog.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
recent_logs = result.scalars().all()
|
||||||
|
|
||||||
|
return DashboardStats(
|
||||||
|
users_count=users_count or 0,
|
||||||
|
banned_users_count=banned_users_count or 0,
|
||||||
|
marathons_count=marathons_count or 0,
|
||||||
|
active_marathons_count=active_marathons_count or 0,
|
||||||
|
games_count=games_count or 0,
|
||||||
|
total_participations=total_participations or 0,
|
||||||
|
recent_logs=[
|
||||||
|
AdminLogResponse(
|
||||||
|
id=log.id,
|
||||||
|
admin_id=log.admin_id,
|
||||||
|
admin_nickname=log.admin.nickname if log.admin else None,
|
||||||
|
action=log.action,
|
||||||
|
target_type=log.target_type,
|
||||||
|
target_id=log.target_id,
|
||||||
|
details=log.details,
|
||||||
|
ip_address=log.ip_address,
|
||||||
|
created_at=log.created_at,
|
||||||
|
)
|
||||||
|
for log in recent_logs
|
||||||
|
],
|
||||||
|
)
|
||||||
556
backend/app/api/v1/assignments.py
Normal file
556
backend/app/api/v1/assignments.py
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
"""
|
||||||
|
Assignment details and dispute system endpoints.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, CurrentUser
|
||||||
|
from app.models import (
|
||||||
|
Assignment, AssignmentStatus, Participant, Challenge, User, Marathon,
|
||||||
|
Dispute, DisputeStatus, DisputeComment, DisputeVote,
|
||||||
|
)
|
||||||
|
from app.schemas import (
|
||||||
|
AssignmentDetailResponse, DisputeCreate, DisputeResponse,
|
||||||
|
DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate,
|
||||||
|
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.user import UserPublic
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
|
router = APIRouter(tags=["assignments"])
|
||||||
|
|
||||||
|
# Dispute window: 24 hours after completion
|
||||||
|
DISPUTE_WINDOW_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
|
def user_to_public(user: User) -> UserPublic:
|
||||||
|
"""Convert User model to UserPublic schema"""
|
||||||
|
return UserPublic(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
avatar_url=None,
|
||||||
|
role=user.role,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_dispute_response(dispute: Dispute, current_user_id: int) -> DisputeResponse:
|
||||||
|
"""Build DisputeResponse from Dispute model"""
|
||||||
|
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
||||||
|
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
||||||
|
|
||||||
|
my_vote = None
|
||||||
|
for v in dispute.votes:
|
||||||
|
if v.user_id == current_user_id:
|
||||||
|
my_vote = v.vote
|
||||||
|
break
|
||||||
|
|
||||||
|
# Ensure expires_at has UTC timezone info for correct frontend parsing
|
||||||
|
created_at_utc = dispute.created_at.replace(tzinfo=timezone.utc) if dispute.created_at.tzinfo is None else dispute.created_at
|
||||||
|
expires_at = created_at_utc + timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||||
|
|
||||||
|
return DisputeResponse(
|
||||||
|
id=dispute.id,
|
||||||
|
raised_by=user_to_public(dispute.raised_by),
|
||||||
|
reason=dispute.reason,
|
||||||
|
status=dispute.status,
|
||||||
|
comments=[
|
||||||
|
DisputeCommentResponse(
|
||||||
|
id=c.id,
|
||||||
|
user=user_to_public(c.user),
|
||||||
|
text=c.text,
|
||||||
|
created_at=c.created_at,
|
||||||
|
)
|
||||||
|
for c in sorted(dispute.comments, key=lambda x: x.created_at)
|
||||||
|
],
|
||||||
|
votes=[
|
||||||
|
{
|
||||||
|
"user": user_to_public(v.user),
|
||||||
|
"vote": v.vote,
|
||||||
|
"created_at": v.created_at,
|
||||||
|
}
|
||||||
|
for v in dispute.votes
|
||||||
|
],
|
||||||
|
votes_valid=votes_valid,
|
||||||
|
votes_invalid=votes_invalid,
|
||||||
|
my_vote=my_vote,
|
||||||
|
expires_at=expires_at,
|
||||||
|
created_at=dispute.created_at,
|
||||||
|
resolved_at=dispute.resolved_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assignments/{assignment_id}", response_model=AssignmentDetailResponse)
|
||||||
|
async def get_assignment_detail(
|
||||||
|
assignment_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get detailed information about an assignment including proofs and dispute"""
|
||||||
|
# Get assignment with all relationships
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.participant).selectinload(Participant.user),
|
||||||
|
selectinload(Assignment.dispute).selectinload(Dispute.raised_by),
|
||||||
|
selectinload(Assignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
|
||||||
|
selectinload(Assignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user),
|
||||||
|
)
|
||||||
|
.where(Assignment.id == assignment_id)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
|
# Check user is participant of the marathon
|
||||||
|
marathon_id = assignment.challenge.game.marathon_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
challenge = assignment.challenge
|
||||||
|
game = challenge.game
|
||||||
|
owner_user = assignment.participant.user
|
||||||
|
|
||||||
|
# Determine if user can dispute
|
||||||
|
can_dispute = False
|
||||||
|
if (
|
||||||
|
assignment.status == AssignmentStatus.COMPLETED.value
|
||||||
|
and assignment.completed_at
|
||||||
|
and assignment.participant.user_id != current_user.id
|
||||||
|
and assignment.dispute is None
|
||||||
|
):
|
||||||
|
time_since_completion = datetime.utcnow() - assignment.completed_at
|
||||||
|
can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||||
|
|
||||||
|
# Build proof URLs
|
||||||
|
proof_image_url = storage_service.get_url(assignment.proof_path, "proofs")
|
||||||
|
|
||||||
|
return AssignmentDetailResponse(
|
||||||
|
id=assignment.id,
|
||||||
|
challenge=ChallengeResponse(
|
||||||
|
id=challenge.id,
|
||||||
|
title=challenge.title,
|
||||||
|
description=challenge.description,
|
||||||
|
type=challenge.type,
|
||||||
|
difficulty=challenge.difficulty,
|
||||||
|
points=challenge.points,
|
||||||
|
estimated_time=challenge.estimated_time,
|
||||||
|
proof_type=challenge.proof_type,
|
||||||
|
proof_hint=challenge.proof_hint,
|
||||||
|
game=GameShort(
|
||||||
|
id=game.id,
|
||||||
|
title=game.title,
|
||||||
|
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||||
|
),
|
||||||
|
is_generated=challenge.is_generated,
|
||||||
|
created_at=challenge.created_at,
|
||||||
|
),
|
||||||
|
participant=user_to_public(owner_user),
|
||||||
|
status=assignment.status,
|
||||||
|
proof_url=assignment.proof_url,
|
||||||
|
proof_image_url=proof_image_url,
|
||||||
|
proof_comment=assignment.proof_comment,
|
||||||
|
points_earned=assignment.points_earned,
|
||||||
|
streak_at_completion=assignment.streak_at_completion,
|
||||||
|
started_at=assignment.started_at,
|
||||||
|
completed_at=assignment.completed_at,
|
||||||
|
can_dispute=can_dispute,
|
||||||
|
dispute=build_dispute_response(assignment.dispute, current_user.id) if assignment.dispute else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assignments/{assignment_id}/proof-media")
|
||||||
|
async def get_assignment_proof_media(
|
||||||
|
assignment_id: int,
|
||||||
|
request: Request,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Stream the proof media (image or video) for an assignment with Range support"""
|
||||||
|
# Get assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
)
|
||||||
|
.where(Assignment.id == assignment_id)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
|
# Check user is participant of the marathon
|
||||||
|
marathon_id = assignment.challenge.game.marathon_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Check if proof exists
|
||||||
|
if not assignment.proof_path:
|
||||||
|
raise HTTPException(status_code=404, detail="No proof media for this assignment")
|
||||||
|
|
||||||
|
# Get file from storage
|
||||||
|
file_data = await storage_service.get_file(assignment.proof_path, "proofs")
|
||||||
|
if not file_data:
|
||||||
|
raise HTTPException(status_code=404, detail="Proof media not found in storage")
|
||||||
|
|
||||||
|
content, content_type = file_data
|
||||||
|
file_size = len(content)
|
||||||
|
|
||||||
|
# Check if it's a video and handle Range requests
|
||||||
|
is_video = content_type.startswith("video/")
|
||||||
|
|
||||||
|
if is_video:
|
||||||
|
range_header = request.headers.get("range")
|
||||||
|
|
||||||
|
if range_header:
|
||||||
|
# Parse range header
|
||||||
|
range_match = range_header.replace("bytes=", "").split("-")
|
||||||
|
start = int(range_match[0]) if range_match[0] else 0
|
||||||
|
end = int(range_match[1]) if range_match[1] else file_size - 1
|
||||||
|
|
||||||
|
# Ensure valid range
|
||||||
|
if start >= file_size:
|
||||||
|
raise HTTPException(status_code=416, detail="Range not satisfiable")
|
||||||
|
|
||||||
|
end = min(end, file_size - 1)
|
||||||
|
chunk = content[start:end + 1]
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=chunk,
|
||||||
|
status_code=206,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": str(len(chunk)),
|
||||||
|
"Cache-Control": "public, max-age=31536000",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# No range header - return full video with Accept-Ranges
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": str(file_size),
|
||||||
|
"Cache-Control": "public, max-age=31536000",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# For images, just return the content
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "public, max-age=31536000",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Keep old endpoint for backwards compatibility
|
||||||
|
@router.get("/assignments/{assignment_id}/proof-image")
|
||||||
|
async def get_assignment_proof_image(
|
||||||
|
assignment_id: int,
|
||||||
|
request: Request,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Deprecated: Use proof-media instead. Redirects to proof-media."""
|
||||||
|
return await get_assignment_proof_media(assignment_id, request, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse)
|
||||||
|
async def create_dispute(
|
||||||
|
assignment_id: int,
|
||||||
|
data: DisputeCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Create a dispute against an assignment's proof"""
|
||||||
|
# Get assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.participant),
|
||||||
|
selectinload(Assignment.dispute),
|
||||||
|
)
|
||||||
|
.where(Assignment.id == assignment_id)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
|
# Check user is participant of the marathon
|
||||||
|
marathon_id = assignment.challenge.game.marathon_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if assignment.status != AssignmentStatus.COMPLETED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only dispute completed assignments")
|
||||||
|
|
||||||
|
if assignment.participant.user_id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot dispute your own assignment")
|
||||||
|
|
||||||
|
if assignment.dispute:
|
||||||
|
raise HTTPException(status_code=400, detail="A dispute already exists for this assignment")
|
||||||
|
|
||||||
|
if not assignment.completed_at:
|
||||||
|
raise HTTPException(status_code=400, detail="Assignment has no completion date")
|
||||||
|
|
||||||
|
time_since_completion = datetime.utcnow() - assignment.completed_at
|
||||||
|
if time_since_completion >= timedelta(hours=DISPUTE_WINDOW_HOURS):
|
||||||
|
raise HTTPException(status_code=400, detail="Dispute window has expired (24 hours)")
|
||||||
|
|
||||||
|
# Create dispute
|
||||||
|
dispute = Dispute(
|
||||||
|
assignment_id=assignment_id,
|
||||||
|
raised_by_id=current_user.id,
|
||||||
|
reason=data.reason,
|
||||||
|
status=DisputeStatus.OPEN.value,
|
||||||
|
)
|
||||||
|
db.add(dispute)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(dispute)
|
||||||
|
|
||||||
|
# Send notification to assignment owner
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if marathon:
|
||||||
|
await telegram_notifier.notify_dispute_raised(
|
||||||
|
db,
|
||||||
|
user_id=assignment.participant.user_id,
|
||||||
|
marathon_title=marathon.title,
|
||||||
|
challenge_title=assignment.challenge.title,
|
||||||
|
assignment_id=assignment_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load relationships for response
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.raised_by),
|
||||||
|
selectinload(Dispute.comments).selectinload(DisputeComment.user),
|
||||||
|
selectinload(Dispute.votes).selectinload(DisputeVote.user),
|
||||||
|
)
|
||||||
|
.where(Dispute.id == dispute.id)
|
||||||
|
)
|
||||||
|
dispute = result.scalar_one()
|
||||||
|
|
||||||
|
return build_dispute_response(dispute, current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disputes/{dispute_id}/comments", response_model=DisputeCommentResponse)
|
||||||
|
async def add_dispute_comment(
|
||||||
|
dispute_id: int,
|
||||||
|
data: DisputeCommentCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Add a comment to a dispute discussion"""
|
||||||
|
# Get dispute with assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
)
|
||||||
|
.where(Dispute.id == dispute_id)
|
||||||
|
)
|
||||||
|
dispute = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not dispute:
|
||||||
|
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||||
|
|
||||||
|
if dispute.status != DisputeStatus.OPEN.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||||
|
|
||||||
|
# Check user is participant of the marathon
|
||||||
|
marathon_id = dispute.assignment.challenge.game.marathon_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Create comment
|
||||||
|
comment = DisputeComment(
|
||||||
|
dispute_id=dispute_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
text=data.text,
|
||||||
|
)
|
||||||
|
db.add(comment)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(comment)
|
||||||
|
|
||||||
|
# Get user for response
|
||||||
|
result = await db.execute(select(User).where(User.id == current_user.id))
|
||||||
|
user = result.scalar_one()
|
||||||
|
|
||||||
|
return DisputeCommentResponse(
|
||||||
|
id=comment.id,
|
||||||
|
user=user_to_public(user),
|
||||||
|
text=comment.text,
|
||||||
|
created_at=comment.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disputes/{dispute_id}/vote", response_model=MessageResponse)
|
||||||
|
async def vote_on_dispute(
|
||||||
|
dispute_id: int,
|
||||||
|
data: DisputeVoteCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Vote on a dispute (True = valid/proof is OK, False = invalid/proof is not OK)"""
|
||||||
|
# Get dispute with assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
)
|
||||||
|
.where(Dispute.id == dispute_id)
|
||||||
|
)
|
||||||
|
dispute = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not dispute:
|
||||||
|
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||||
|
|
||||||
|
if dispute.status != DisputeStatus.OPEN.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||||
|
|
||||||
|
# Check user is participant of the marathon
|
||||||
|
marathon_id = dispute.assignment.challenge.game.marathon_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Check if user already voted
|
||||||
|
result = await db.execute(
|
||||||
|
select(DisputeVote).where(
|
||||||
|
DisputeVote.dispute_id == dispute_id,
|
||||||
|
DisputeVote.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_vote = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_vote:
|
||||||
|
# Update existing vote
|
||||||
|
existing_vote.vote = data.vote
|
||||||
|
existing_vote.created_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
# Create new vote
|
||||||
|
vote = DisputeVote(
|
||||||
|
dispute_id=dispute_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
vote=data.vote,
|
||||||
|
)
|
||||||
|
db.add(vote)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
vote_text = "валидным" if data.vote else "невалидным"
|
||||||
|
return MessageResponse(message=f"Вы проголосовали: пруф {vote_text}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/returned-assignments", response_model=list[ReturnedAssignmentResponse])
|
||||||
|
async def get_returned_assignments(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get current user's returned assignments that need to be redone"""
|
||||||
|
# Check user is participant
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Get returned assignments
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.dispute),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.status == AssignmentStatus.RETURNED.value,
|
||||||
|
)
|
||||||
|
.order_by(Assignment.completed_at.asc()) # Oldest first
|
||||||
|
)
|
||||||
|
assignments = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
ReturnedAssignmentResponse(
|
||||||
|
id=a.id,
|
||||||
|
challenge=ChallengeResponse(
|
||||||
|
id=a.challenge.id,
|
||||||
|
title=a.challenge.title,
|
||||||
|
description=a.challenge.description,
|
||||||
|
type=a.challenge.type,
|
||||||
|
difficulty=a.challenge.difficulty,
|
||||||
|
points=a.challenge.points,
|
||||||
|
estimated_time=a.challenge.estimated_time,
|
||||||
|
proof_type=a.challenge.proof_type,
|
||||||
|
proof_hint=a.challenge.proof_hint,
|
||||||
|
game=GameShort(
|
||||||
|
id=a.challenge.game.id,
|
||||||
|
title=a.challenge.game.title,
|
||||||
|
cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"),
|
||||||
|
),
|
||||||
|
is_generated=a.challenge.is_generated,
|
||||||
|
created_at=a.challenge.created_at,
|
||||||
|
),
|
||||||
|
original_completed_at=a.completed_at,
|
||||||
|
dispute_reason=a.dispute.reason if a.dispute else "Оспорено",
|
||||||
|
)
|
||||||
|
for a in assignments
|
||||||
|
]
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from datetime import datetime, timedelta
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
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
|
||||||
from app.models import User
|
from app.core.rate_limit import limiter
|
||||||
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPublic
|
from app.models import User, UserRole, Admin2FASession
|
||||||
|
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse)
|
@router.post("/register", response_model=TokenResponse)
|
||||||
async def register(data: UserRegister, db: DbSession):
|
@limiter.limit("5/minute")
|
||||||
|
async def register(request: Request, data: UserRegister, db: DbSession):
|
||||||
# Check if login already exists
|
# Check if login already exists
|
||||||
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
||||||
if result.scalar_one_or_none():
|
if result.scalar_one_or_none():
|
||||||
@@ -34,12 +40,13 @@ async def register(data: UserRegister, db: DbSession):
|
|||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
user=UserPublic.model_validate(user),
|
user=UserPrivate.model_validate(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(data: UserLogin, db: DbSession):
|
@limiter.limit("10/minute")
|
||||||
|
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()))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
@@ -50,15 +57,109 @@ async def login(data: UserLogin, db: DbSession):
|
|||||||
detail="Incorrect login or password",
|
detail="Incorrect login or password",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if user is banned
|
||||||
|
if user.is_banned:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Your account has been banned",
|
||||||
|
)
|
||||||
|
|
||||||
|
# If admin with Telegram linked, require 2FA
|
||||||
|
if user.role == UserRole.ADMIN.value and user.telegram_id:
|
||||||
|
# Generate 6-digit code
|
||||||
|
code = "".join([str(secrets.randbelow(10)) for _ in range(6)])
|
||||||
|
|
||||||
|
# Create 2FA session (expires in 5 minutes)
|
||||||
|
session = Admin2FASession(
|
||||||
|
user_id=user.id,
|
||||||
|
code=code,
|
||||||
|
expires_at=datetime.utcnow() + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
|
||||||
|
# Send code to Telegram
|
||||||
|
message = f"🔐 <b>Код подтверждения для входа в админку</b>\n\nВаш код: <code>{code}</code>\n\nКод действителен 5 минут."
|
||||||
|
sent = await telegram_notifier.send_message(user.telegram_id, message)
|
||||||
|
|
||||||
|
if sent:
|
||||||
|
session.telegram_sent = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
requires_2fa=True,
|
||||||
|
two_factor_session_id=session.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regular user or admin without Telegram - generate token immediately
|
||||||
|
# Admin without Telegram can login but admin panel will check for Telegram
|
||||||
|
access_token = create_access_token(subject=user.id)
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
user=UserPrivate.model_validate(user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/verify", response_model=TokenResponse)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession):
|
||||||
|
"""Verify 2FA code and return JWT token."""
|
||||||
|
# Find session
|
||||||
|
result = await db.execute(
|
||||||
|
select(Admin2FASession).where(Admin2FASession.id == session_id)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid session",
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.is_verified:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Session already verified",
|
||||||
|
)
|
||||||
|
|
||||||
|
if datetime.utcnow() > session.expires_at:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Code expired",
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.code != code:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid code",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark as verified
|
||||||
|
session.is_verified = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(select(User).where(User.id == session.user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
# Generate token
|
# Generate token
|
||||||
access_token = create_access_token(subject=user.id)
|
access_token = create_access_token(subject=user.id)
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
user=UserPublic.model_validate(user),
|
user=UserPrivate.model_validate(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserPublic)
|
@router.get("/me", response_model=UserPrivate)
|
||||||
async def get_me(current_user: CurrentUser):
|
async def get_me(current_user: CurrentUser):
|
||||||
return UserPublic.model_validate(current_user)
|
"""Get current user's full profile (including private data)"""
|
||||||
|
return UserPrivate.model_validate(current_user)
|
||||||
|
|||||||
@@ -2,26 +2,31 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
|
||||||
from app.models import Marathon, MarathonStatus, Game, Challenge, Participant
|
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge, User
|
||||||
|
from app.models.challenge import ChallengeStatus
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
ChallengeCreate,
|
ChallengeCreate,
|
||||||
ChallengeUpdate,
|
ChallengeUpdate,
|
||||||
ChallengeResponse,
|
ChallengeResponse,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
GameShort,
|
GameShort,
|
||||||
|
ChallengePreview,
|
||||||
|
ChallengesPreviewResponse,
|
||||||
|
ChallengesSaveRequest,
|
||||||
|
ChallengesGenerateRequest,
|
||||||
)
|
)
|
||||||
from app.services.gpt import GPTService
|
from app.schemas.challenge import ChallengePropose, ProposedByUser
|
||||||
|
from app.services.gpt import gpt_service
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(tags=["challenges"])
|
router = APIRouter(tags=["challenges"])
|
||||||
|
|
||||||
gpt_service = GPTService()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Challenge)
|
select(Challenge)
|
||||||
.options(selectinload(Challenge.game))
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
.where(Challenge.id == challenge_id)
|
.where(Challenge.id == challenge_id)
|
||||||
)
|
)
|
||||||
challenge = result.scalar_one_or_none()
|
challenge = result.scalar_one_or_none()
|
||||||
@@ -30,95 +35,14 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
|||||||
return challenge
|
return challenge
|
||||||
|
|
||||||
|
|
||||||
async def check_participant(db, user_id: int, marathon_id: int) -> Participant:
|
def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeResponse:
|
||||||
result = await db.execute(
|
"""Helper to build ChallengeResponse with proposed_by"""
|
||||||
select(Participant).where(
|
proposed_by = None
|
||||||
Participant.user_id == user_id,
|
if challenge.proposed_by:
|
||||||
Participant.marathon_id == marathon_id,
|
proposed_by = ProposedByUser(
|
||||||
|
id=challenge.proposed_by.id,
|
||||||
|
nickname=challenge.proposed_by.nickname
|
||||||
)
|
)
|
||||||
)
|
|
||||||
participant = result.scalar_one_or_none()
|
|
||||||
if not participant:
|
|
||||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
|
||||||
return participant
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
|
|
||||||
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
|
|
||||||
# Get game and check access
|
|
||||||
result = await db.execute(
|
|
||||||
select(Game).where(Game.id == game_id)
|
|
||||||
)
|
|
||||||
game = result.scalar_one_or_none()
|
|
||||||
if not game:
|
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
|
||||||
|
|
||||||
await check_participant(db, current_user.id, game.marathon_id)
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(Challenge)
|
|
||||||
.where(Challenge.game_id == game_id)
|
|
||||||
.order_by(Challenge.difficulty, Challenge.created_at)
|
|
||||||
)
|
|
||||||
challenges = result.scalars().all()
|
|
||||||
|
|
||||||
return [
|
|
||||||
ChallengeResponse(
|
|
||||||
id=c.id,
|
|
||||||
title=c.title,
|
|
||||||
description=c.description,
|
|
||||||
type=c.type,
|
|
||||||
difficulty=c.difficulty,
|
|
||||||
points=c.points,
|
|
||||||
estimated_time=c.estimated_time,
|
|
||||||
proof_type=c.proof_type,
|
|
||||||
proof_hint=c.proof_hint,
|
|
||||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
|
||||||
is_generated=c.is_generated,
|
|
||||||
created_at=c.created_at,
|
|
||||||
)
|
|
||||||
for c in challenges
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
|
||||||
async def create_challenge(
|
|
||||||
game_id: int,
|
|
||||||
data: ChallengeCreate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbSession,
|
|
||||||
):
|
|
||||||
# Get game and check access
|
|
||||||
result = await db.execute(
|
|
||||||
select(Game).where(Game.id == game_id)
|
|
||||||
)
|
|
||||||
game = result.scalar_one_or_none()
|
|
||||||
if not game:
|
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
|
||||||
|
|
||||||
# Check marathon is in preparing state
|
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
|
||||||
marathon = result.scalar_one()
|
|
||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
|
|
||||||
|
|
||||||
await check_participant(db, current_user.id, game.marathon_id)
|
|
||||||
|
|
||||||
challenge = Challenge(
|
|
||||||
game_id=game_id,
|
|
||||||
title=data.title,
|
|
||||||
description=data.description,
|
|
||||||
type=data.type.value,
|
|
||||||
difficulty=data.difficulty.value,
|
|
||||||
points=data.points,
|
|
||||||
estimated_time=data.estimated_time,
|
|
||||||
proof_type=data.proof_type.value,
|
|
||||||
proof_hint=data.proof_hint,
|
|
||||||
is_generated=False,
|
|
||||||
)
|
|
||||||
db.add(challenge)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(challenge)
|
|
||||||
|
|
||||||
return ChallengeResponse(
|
return ChallengeResponse(
|
||||||
id=challenge.id,
|
id=challenge.id,
|
||||||
@@ -133,12 +57,134 @@ async def create_challenge(
|
|||||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||||
is_generated=challenge.is_generated,
|
is_generated=challenge.is_generated,
|
||||||
created_at=challenge.created_at,
|
created_at=challenge.created_at,
|
||||||
|
status=challenge.status,
|
||||||
|
proposed_by=proposed_by,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/generate-challenges", response_model=MessageResponse)
|
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
|
||||||
async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Generate challenges for all games in marathon using GPT"""
|
"""List challenges for a game. Participants can view approved and pending challenges."""
|
||||||
|
# Get game and check access
|
||||||
|
result = await db.execute(
|
||||||
|
select(Game).where(Game.id == game_id)
|
||||||
|
)
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
|
||||||
|
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
if not current_user.is_admin:
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
# Regular participants can only see challenges for approved games or their own games
|
||||||
|
if not participant.is_organizer:
|
||||||
|
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Game not accessible")
|
||||||
|
|
||||||
|
# Get challenges with proposed_by
|
||||||
|
query = select(Challenge).options(selectinload(Challenge.proposed_by)).where(Challenge.game_id == game_id)
|
||||||
|
|
||||||
|
# Regular participants see approved and pending challenges (but not rejected)
|
||||||
|
if not current_user.is_admin and participant and not participant.is_organizer:
|
||||||
|
query = query.where(Challenge.status.in_([ChallengeStatus.APPROVED.value, ChallengeStatus.PENDING.value]))
|
||||||
|
|
||||||
|
result = await db.execute(query.order_by(Challenge.status.desc(), Challenge.difficulty, Challenge.created_at))
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
return [build_challenge_response(c, game) for c in challenges]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
||||||
|
async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List all challenges for a marathon (from all approved games). Participants only."""
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Check user is participant or admin
|
||||||
|
participant = await get_participant(db, current_user.id, marathon_id)
|
||||||
|
if not current_user.is_admin and not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Get all approved challenges from approved games in this marathon
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
Challenge.status == ChallengeStatus.APPROVED.value,
|
||||||
|
)
|
||||||
|
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
||||||
|
async def create_challenge(
|
||||||
|
game_id: int,
|
||||||
|
data: ChallengeCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Create a challenge for a game. Organizers only."""
|
||||||
|
# Get game and check access
|
||||||
|
result = await db.execute(
|
||||||
|
select(Game).where(Game.id == game_id)
|
||||||
|
)
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
|
||||||
|
|
||||||
|
# Only organizers can add challenges
|
||||||
|
await require_organizer(db, current_user, game.marathon_id)
|
||||||
|
|
||||||
|
# Can only add challenges to approved games
|
||||||
|
if game.status != GameStatus.APPROVED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only add challenges to approved games")
|
||||||
|
|
||||||
|
challenge = Challenge(
|
||||||
|
game_id=game_id,
|
||||||
|
title=data.title,
|
||||||
|
description=data.description,
|
||||||
|
type=data.type.value,
|
||||||
|
difficulty=data.difficulty.value,
|
||||||
|
points=data.points,
|
||||||
|
estimated_time=data.estimated_time,
|
||||||
|
proof_type=data.proof_type.value,
|
||||||
|
proof_hint=data.proof_hint,
|
||||||
|
is_generated=False,
|
||||||
|
status=ChallengeStatus.APPROVED.value, # Organizer-created challenges are auto-approved
|
||||||
|
)
|
||||||
|
db.add(challenge)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, game)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
||||||
|
async def preview_challenges(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
data: ChallengesGenerateRequest | None = None,
|
||||||
|
):
|
||||||
|
"""Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only."""
|
||||||
# Check marathon
|
# Check marathon
|
||||||
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()
|
||||||
@@ -148,32 +194,64 @@ async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: D
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot generate challenges for active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot generate challenges for active or finished marathon")
|
||||||
|
|
||||||
await check_participant(db, current_user.id, marathon_id)
|
# Only organizers can generate challenges
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
# Get all games
|
# Get only APPROVED games
|
||||||
result = await db.execute(
|
query = select(Game).where(
|
||||||
select(Game).where(Game.marathon_id == marathon_id)
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.APPROVED.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter by specific game IDs if provided
|
||||||
|
if data and data.game_ids:
|
||||||
|
query = query.where(Game.id.in_(data.game_ids))
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
games = result.scalars().all()
|
games = result.scalars().all()
|
||||||
|
|
||||||
if not games:
|
if not games:
|
||||||
raise HTTPException(status_code=400, detail="No games in marathon")
|
raise HTTPException(status_code=400, detail="No approved games found")
|
||||||
|
|
||||||
generated_count = 0
|
# Build games list for generation (skip games that already have challenges, unless specific IDs requested)
|
||||||
|
games_to_generate = []
|
||||||
|
game_map = {}
|
||||||
for game in games:
|
for game in games:
|
||||||
# Check if game already has challenges
|
# If specific games requested, generate even if they have challenges
|
||||||
|
if data and data.game_ids:
|
||||||
|
games_to_generate.append({
|
||||||
|
"id": game.id,
|
||||||
|
"title": game.title,
|
||||||
|
"genre": game.genre
|
||||||
|
})
|
||||||
|
game_map[game.id] = game.title
|
||||||
|
else:
|
||||||
|
# Otherwise only generate for games without challenges
|
||||||
existing = await db.scalar(
|
existing = await db.scalar(
|
||||||
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
|
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
|
||||||
)
|
)
|
||||||
if existing:
|
if not existing:
|
||||||
continue # Skip if already has challenges
|
games_to_generate.append({
|
||||||
|
"id": game.id,
|
||||||
|
"title": game.title,
|
||||||
|
"genre": game.genre
|
||||||
|
})
|
||||||
|
game_map[game.id] = game.title
|
||||||
|
|
||||||
|
if not games_to_generate:
|
||||||
|
return ChallengesPreviewResponse(challenges=[])
|
||||||
|
|
||||||
|
# Generate challenges for all games in one API call
|
||||||
|
preview_challenges = []
|
||||||
try:
|
try:
|
||||||
challenges_data = await gpt_service.generate_challenges(game.title, game.genre)
|
challenges_by_game = await gpt_service.generate_challenges(games_to_generate)
|
||||||
|
|
||||||
|
for game_id, challenges_data in challenges_by_game.items():
|
||||||
|
game_title = game_map.get(game_id, "Unknown")
|
||||||
for ch_data in challenges_data:
|
for ch_data in challenges_data:
|
||||||
challenge = Challenge(
|
preview_challenges.append(ChallengePreview(
|
||||||
game_id=game.id,
|
game_id=game_id,
|
||||||
|
game_title=game_title,
|
||||||
title=ch_data.title,
|
title=ch_data.title,
|
||||||
description=ch_data.description,
|
description=ch_data.description,
|
||||||
type=ch_data.type,
|
type=ch_data.type,
|
||||||
@@ -182,18 +260,81 @@ async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: D
|
|||||||
estimated_time=ch_data.estimated_time,
|
estimated_time=ch_data.estimated_time,
|
||||||
proof_type=ch_data.proof_type,
|
proof_type=ch_data.proof_type,
|
||||||
proof_hint=ch_data.proof_hint,
|
proof_hint=ch_data.proof_hint,
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating challenges: {e}")
|
||||||
|
|
||||||
|
return ChallengesPreviewResponse(challenges=preview_challenges)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/marathons/{marathon_id}/save-challenges", response_model=MessageResponse)
|
||||||
|
async def save_challenges(
|
||||||
|
marathon_id: int,
|
||||||
|
data: ChallengesSaveRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Save previewed challenges to database. Organizers only."""
|
||||||
|
# Check marathon
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
|
||||||
|
|
||||||
|
# Only organizers can save challenges
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
|
# Verify all games belong to this marathon AND are approved
|
||||||
|
result = await db.execute(
|
||||||
|
select(Game.id).where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
valid_game_ids = set(row[0] for row in result.fetchall())
|
||||||
|
|
||||||
|
saved_count = 0
|
||||||
|
for ch_data in data.challenges:
|
||||||
|
if ch_data.game_id not in valid_game_ids:
|
||||||
|
continue # Skip challenges for invalid/unapproved games
|
||||||
|
|
||||||
|
# Validate type
|
||||||
|
ch_type = ch_data.type
|
||||||
|
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
|
||||||
|
ch_type = "completion"
|
||||||
|
|
||||||
|
# Validate difficulty
|
||||||
|
difficulty = ch_data.difficulty
|
||||||
|
if difficulty not in ["easy", "medium", "hard"]:
|
||||||
|
difficulty = "medium"
|
||||||
|
|
||||||
|
# Validate proof_type
|
||||||
|
proof_type = ch_data.proof_type
|
||||||
|
if proof_type not in ["screenshot", "video", "steam"]:
|
||||||
|
proof_type = "screenshot"
|
||||||
|
|
||||||
|
challenge = Challenge(
|
||||||
|
game_id=ch_data.game_id,
|
||||||
|
title=ch_data.title[:100],
|
||||||
|
description=ch_data.description,
|
||||||
|
type=ch_type,
|
||||||
|
difficulty=difficulty,
|
||||||
|
points=max(1, min(500, ch_data.points)),
|
||||||
|
estimated_time=ch_data.estimated_time,
|
||||||
|
proof_type=proof_type,
|
||||||
|
proof_hint=ch_data.proof_hint,
|
||||||
is_generated=True,
|
is_generated=True,
|
||||||
)
|
)
|
||||||
db.add(challenge)
|
db.add(challenge)
|
||||||
generated_count += 1
|
saved_count += 1
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Log error but continue with other games
|
|
||||||
print(f"Error generating challenges for {game.title}: {e}")
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message=f"Generated {generated_count} challenges")
|
return MessageResponse(message=f"Сохранено {saved_count} заданий")
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/challenges/{challenge_id}", response_model=ChallengeResponse)
|
@router.patch("/challenges/{challenge_id}", response_model=ChallengeResponse)
|
||||||
@@ -203,6 +344,7 @@ async def update_challenge(
|
|||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
):
|
):
|
||||||
|
"""Update a challenge. Organizers only."""
|
||||||
challenge = await get_challenge_or_404(db, challenge_id)
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
# Check marathon is in preparing state
|
# Check marathon is in preparing state
|
||||||
@@ -211,7 +353,8 @@ async def update_challenge(
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot update challenges in active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot update challenges in active or finished marathon")
|
||||||
|
|
||||||
await check_participant(db, current_user.id, challenge.game.marathon_id)
|
# Only organizers can update challenges
|
||||||
|
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||||
|
|
||||||
if data.title is not None:
|
if data.title is not None:
|
||||||
challenge.title = data.title
|
challenge.title = data.title
|
||||||
@@ -233,25 +376,12 @@ async def update_challenge(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(challenge)
|
await db.refresh(challenge)
|
||||||
|
|
||||||
game = challenge.game
|
return build_challenge_response(challenge, challenge.game)
|
||||||
return ChallengeResponse(
|
|
||||||
id=challenge.id,
|
|
||||||
title=challenge.title,
|
|
||||||
description=challenge.description,
|
|
||||||
type=challenge.type,
|
|
||||||
difficulty=challenge.difficulty,
|
|
||||||
points=challenge.points,
|
|
||||||
estimated_time=challenge.estimated_time,
|
|
||||||
proof_type=challenge.proof_type,
|
|
||||||
proof_hint=challenge.proof_hint,
|
|
||||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
|
||||||
is_generated=challenge.is_generated,
|
|
||||||
created_at=challenge.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
||||||
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Delete a challenge. Organizers can delete any, participants can delete their own pending."""
|
||||||
challenge = await get_challenge_or_404(db, challenge_id)
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
# Check marathon is in preparing state
|
# Check marathon is in preparing state
|
||||||
@@ -260,9 +390,206 @@ async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbS
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
||||||
|
|
||||||
await check_participant(db, current_user.id, challenge.game.marathon_id)
|
participant = await get_participant(db, current_user.id, challenge.game.marathon_id)
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if current_user.is_admin or (participant and participant.is_organizer):
|
||||||
|
# Organizers can delete any challenge
|
||||||
|
pass
|
||||||
|
elif challenge.proposed_by_id == current_user.id and challenge.status == ChallengeStatus.PENDING.value:
|
||||||
|
# Participants can delete their own pending challenges
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail="You can only delete your own pending challenges")
|
||||||
|
|
||||||
await db.delete(challenge)
|
await db.delete(challenge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message="Challenge deleted")
|
return MessageResponse(message="Challenge deleted")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Proposed challenges endpoints ============
|
||||||
|
|
||||||
|
@router.post("/games/{game_id}/propose-challenge", response_model=ChallengeResponse)
|
||||||
|
async def propose_challenge(
|
||||||
|
game_id: int,
|
||||||
|
data: ChallengePropose,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Propose a challenge for a game. Participants only, during PREPARING phase."""
|
||||||
|
# Get game
|
||||||
|
result = await db.execute(select(Game).where(Game.id == game_id))
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot propose challenges to active or finished marathon")
|
||||||
|
|
||||||
|
# Check user is participant
|
||||||
|
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||||
|
if not participant and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Can only propose challenges to approved games
|
||||||
|
if game.status != GameStatus.APPROVED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only propose challenges to approved games")
|
||||||
|
|
||||||
|
challenge = Challenge(
|
||||||
|
game_id=game_id,
|
||||||
|
title=data.title,
|
||||||
|
description=data.description,
|
||||||
|
type=data.type.value,
|
||||||
|
difficulty=data.difficulty.value,
|
||||||
|
points=data.points,
|
||||||
|
estimated_time=data.estimated_time,
|
||||||
|
proof_type=data.proof_type.value,
|
||||||
|
proof_hint=data.proof_hint,
|
||||||
|
is_generated=False,
|
||||||
|
proposed_by_id=current_user.id,
|
||||||
|
status=ChallengeStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
db.add(challenge)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Load proposed_by relationship
|
||||||
|
challenge.proposed_by = current_user
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, game)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/proposed-challenges", response_model=list[ChallengeResponse])
|
||||||
|
async def list_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List all pending proposed challenges for a marathon. Organizers only."""
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Only organizers can see all proposed challenges
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
|
# Get all pending challenges from approved games
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
Challenge.status == ChallengeStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
.order_by(Challenge.created_at.desc())
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/my-proposed-challenges", response_model=list[ChallengeResponse])
|
||||||
|
async def list_my_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List current user's proposed challenges for a marathon."""
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Check user is participant
|
||||||
|
participant = await get_participant(db, current_user.id, marathon_id)
|
||||||
|
if not participant and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Get user's proposed challenges
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Challenge.proposed_by_id == current_user.id,
|
||||||
|
)
|
||||||
|
.order_by(Challenge.created_at.desc())
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/challenges/{challenge_id}/approve", response_model=ChallengeResponse)
|
||||||
|
async def approve_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Approve a proposed challenge. Organizers only."""
|
||||||
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot approve challenges in active or finished marathon")
|
||||||
|
|
||||||
|
# Only organizers can approve
|
||||||
|
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||||
|
|
||||||
|
if challenge.status != ChallengeStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||||
|
|
||||||
|
challenge.status = ChallengeStatus.APPROVED.value
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Send Telegram notification to proposer
|
||||||
|
if challenge.proposed_by_id:
|
||||||
|
await telegram_notifier.notify_challenge_approved(
|
||||||
|
db,
|
||||||
|
challenge.proposed_by_id,
|
||||||
|
marathon.title,
|
||||||
|
challenge.game.title,
|
||||||
|
challenge.title
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, challenge.game)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/challenges/{challenge_id}/reject", response_model=ChallengeResponse)
|
||||||
|
async def reject_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Reject a proposed challenge. Organizers only."""
|
||||||
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot reject challenges in active or finished marathon")
|
||||||
|
|
||||||
|
# Only organizers can reject
|
||||||
|
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||||
|
|
||||||
|
if challenge.status != ChallengeStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||||
|
|
||||||
|
# Save info for notification before changing status
|
||||||
|
proposer_id = challenge.proposed_by_id
|
||||||
|
game_title = challenge.game.title
|
||||||
|
challenge_title = challenge.title
|
||||||
|
|
||||||
|
challenge.status = ChallengeStatus.REJECTED.value
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Send Telegram notification to proposer
|
||||||
|
if proposer_id:
|
||||||
|
await telegram_notifier.notify_challenge_rejected(
|
||||||
|
db,
|
||||||
|
proposer_id,
|
||||||
|
marathon.title,
|
||||||
|
game_title,
|
||||||
|
challenge_title
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, challenge.game)
|
||||||
|
|||||||
20
backend/app/api/v1/content.py
Normal file
20
backend/app/api/v1/content.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.deps import DbSession
|
||||||
|
from app.models import StaticContent
|
||||||
|
from app.schemas import StaticContentResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/content", tags=["content"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{key}", response_model=StaticContentResponse)
|
||||||
|
async def get_public_content(key: str, db: DbSession):
|
||||||
|
"""Get public static content by key. No authentication required."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
return content
|
||||||
1173
backend/app/api/v1/events.py
Normal file
1173
backend/app/api/v1/events.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,8 @@ 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
|
from app.models import Activity, Participant, Dispute, ActivityType
|
||||||
|
from app.models.dispute import DisputeStatus
|
||||||
from app.schemas import FeedResponse, ActivityResponse, UserPublic
|
from app.schemas import FeedResponse, ActivityResponse, UserPublic
|
||||||
|
|
||||||
router = APIRouter(tags=["feed"])
|
router = APIRouter(tags=["feed"])
|
||||||
@@ -44,16 +45,40 @@ async def get_feed(
|
|||||||
)
|
)
|
||||||
activities = result.scalars().all()
|
activities = result.scalars().all()
|
||||||
|
|
||||||
items = [
|
# Get assignment_ids from complete activities to check for disputes
|
||||||
|
complete_assignment_ids = []
|
||||||
|
for a in activities:
|
||||||
|
if a.type == ActivityType.COMPLETE.value and a.data and a.data.get("assignment_id"):
|
||||||
|
complete_assignment_ids.append(a.data["assignment_id"])
|
||||||
|
|
||||||
|
# Get disputes for these assignments
|
||||||
|
disputes_map: dict[int, str] = {}
|
||||||
|
if complete_assignment_ids:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute).where(Dispute.assignment_id.in_(complete_assignment_ids))
|
||||||
|
)
|
||||||
|
for dispute in result.scalars().all():
|
||||||
|
disputes_map[dispute.assignment_id] = dispute.status
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for a in activities:
|
||||||
|
data = dict(a.data) if a.data else {}
|
||||||
|
|
||||||
|
# Add dispute status to complete activities
|
||||||
|
if a.type == ActivityType.COMPLETE.value and a.data and a.data.get("assignment_id"):
|
||||||
|
assignment_id = a.data["assignment_id"]
|
||||||
|
if assignment_id in disputes_map:
|
||||||
|
data["dispute_status"] = disputes_map[assignment_id]
|
||||||
|
|
||||||
|
items.append(
|
||||||
ActivityResponse(
|
ActivityResponse(
|
||||||
id=a.id,
|
id=a.id,
|
||||||
type=a.type,
|
type=a.type,
|
||||||
user=UserPublic.model_validate(a.user),
|
user=UserPublic.model_validate(a.user),
|
||||||
data=a.data,
|
data=data if data else None,
|
||||||
created_at=a.created_at,
|
created_at=a.created_at,
|
||||||
)
|
)
|
||||||
for a in activities
|
)
|
||||||
]
|
|
||||||
|
|
||||||
return FeedResponse(
|
return FeedResponse(
|
||||||
items=items,
|
items=items,
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status, UploadFile, File
|
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import (
|
||||||
|
DbSession, CurrentUser,
|
||||||
|
require_participant, require_organizer, get_participant,
|
||||||
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import Marathon, MarathonStatus, Game, Challenge, Participant
|
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
||||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(tags=["games"])
|
router = APIRouter(tags=["games"])
|
||||||
|
|
||||||
@@ -15,7 +18,10 @@ router = APIRouter(tags=["games"])
|
|||||||
async def get_game_or_404(db, game_id: int) -> Game:
|
async def get_game_or_404(db, game_id: int) -> Game:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Game)
|
select(Game)
|
||||||
.options(selectinload(Game.added_by_user))
|
.options(
|
||||||
|
selectinload(Game.proposed_by),
|
||||||
|
selectinload(Game.approved_by),
|
||||||
|
)
|
||||||
.where(Game.id == game_id)
|
.where(Game.id == game_id)
|
||||||
)
|
)
|
||||||
game = result.scalar_one_or_none()
|
game = result.scalar_one_or_none()
|
||||||
@@ -24,47 +30,84 @@ async def get_game_or_404(db, game_id: int) -> Game:
|
|||||||
return game
|
return game
|
||||||
|
|
||||||
|
|
||||||
async def check_participant(db, user_id: int, marathon_id: int) -> Participant:
|
def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
|
||||||
result = await db.execute(
|
"""Convert Game model to GameResponse schema"""
|
||||||
select(Participant).where(
|
return GameResponse(
|
||||||
Participant.user_id == user_id,
|
id=game.id,
|
||||||
Participant.marathon_id == marathon_id,
|
title=game.title,
|
||||||
|
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||||
|
download_url=game.download_url,
|
||||||
|
genre=game.genre,
|
||||||
|
status=game.status,
|
||||||
|
proposed_by=UserPublic.model_validate(game.proposed_by) if game.proposed_by else None,
|
||||||
|
approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None,
|
||||||
|
challenges_count=challenges_count,
|
||||||
|
created_at=game.created_at,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
participant = result.scalar_one_or_none()
|
|
||||||
if not participant:
|
|
||||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
|
||||||
return participant
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/games", response_model=list[GameResponse])
|
@router.get("/marathons/{marathon_id}/games", response_model=list[GameResponse])
|
||||||
async def list_games(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def list_games(
|
||||||
await check_participant(db, current_user.id, marathon_id)
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
status_filter: str | None = Query(None, alias="status"),
|
||||||
|
):
|
||||||
|
"""List games in marathon. Organizers/admins see all, participants see only approved."""
|
||||||
|
# Admins can view without being participant
|
||||||
|
participant = await get_participant(db, current_user.id, marathon_id)
|
||||||
|
if not participant and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
result = await db.execute(
|
query = (
|
||||||
select(Game, func.count(Challenge.id).label("challenges_count"))
|
select(Game, func.count(Challenge.id).label("challenges_count"))
|
||||||
.outerjoin(Challenge)
|
.outerjoin(Challenge)
|
||||||
.options(selectinload(Game.added_by_user))
|
.options(
|
||||||
|
selectinload(Game.proposed_by),
|
||||||
|
selectinload(Game.approved_by),
|
||||||
|
)
|
||||||
.where(Game.marathon_id == marathon_id)
|
.where(Game.marathon_id == marathon_id)
|
||||||
.group_by(Game.id)
|
.group_by(Game.id)
|
||||||
.order_by(Game.created_at.desc())
|
.order_by(Game.created_at.desc())
|
||||||
)
|
)
|
||||||
|
|
||||||
games = []
|
# Filter by status if provided
|
||||||
for row in result.all():
|
is_organizer = current_user.is_admin or (participant and participant.is_organizer)
|
||||||
game = row[0]
|
if status_filter:
|
||||||
games.append(GameResponse(
|
query = query.where(Game.status == status_filter)
|
||||||
id=game.id,
|
elif not is_organizer:
|
||||||
title=game.title,
|
# Regular participants only see approved games + their own pending games
|
||||||
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
|
query = query.where(
|
||||||
download_url=game.download_url,
|
(Game.status == GameStatus.APPROVED.value) |
|
||||||
genre=game.genre,
|
(Game.proposed_by_id == current_user.id)
|
||||||
added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
|
)
|
||||||
challenges_count=row[1],
|
|
||||||
created_at=game.created_at,
|
|
||||||
))
|
|
||||||
|
|
||||||
return games
|
result = await db.execute(query)
|
||||||
|
|
||||||
|
return [game_to_response(row[0], row[1]) for row in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/games/pending", response_model=list[GameResponse])
|
||||||
|
async def list_pending_games(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List pending games for moderation. Organizers only."""
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Game, func.count(Challenge.id).label("challenges_count"))
|
||||||
|
.outerjoin(Challenge)
|
||||||
|
.options(
|
||||||
|
selectinload(Game.proposed_by),
|
||||||
|
selectinload(Game.approved_by),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
.group_by(Game.id)
|
||||||
|
.order_by(Game.created_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
return [game_to_response(row[0], row[1]) for row in result.all()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/games", response_model=GameResponse)
|
@router.post("/marathons/{marathon_id}/games", response_model=GameResponse)
|
||||||
@@ -74,6 +117,7 @@ async def add_game(
|
|||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
):
|
):
|
||||||
|
"""Propose a new game. Organizers can auto-approve."""
|
||||||
# Check marathon exists and is preparing
|
# Check marathon exists and is preparing
|
||||||
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()
|
||||||
@@ -83,16 +127,36 @@ async def add_game(
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot add games to active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot add games to active or finished marathon")
|
||||||
|
|
||||||
await check_participant(db, current_user.id, marathon_id)
|
participant = await require_participant(db, current_user.id, marathon_id)
|
||||||
|
|
||||||
|
# Check if user can propose games based on marathon settings
|
||||||
|
is_organizer = participant.is_organizer or current_user.is_admin
|
||||||
|
if marathon.game_proposal_mode == GameProposalMode.ORGANIZER_ONLY.value and not is_organizer:
|
||||||
|
raise HTTPException(status_code=403, detail="Only organizers can add games to this marathon")
|
||||||
|
|
||||||
|
# Organizers can auto-approve their games
|
||||||
|
game_status = GameStatus.APPROVED.value if is_organizer else GameStatus.PENDING.value
|
||||||
|
|
||||||
game = Game(
|
game = Game(
|
||||||
marathon_id=marathon_id,
|
marathon_id=marathon_id,
|
||||||
title=data.title,
|
title=data.title,
|
||||||
download_url=data.download_url,
|
download_url=data.download_url,
|
||||||
genre=data.genre,
|
genre=data.genre,
|
||||||
added_by_id=current_user.id,
|
proposed_by_id=current_user.id,
|
||||||
|
status=game_status,
|
||||||
|
approved_by_id=current_user.id if is_organizer else None,
|
||||||
)
|
)
|
||||||
db.add(game)
|
db.add(game)
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
activity = Activity(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
type=ActivityType.ADD_GAME.value,
|
||||||
|
data={"title": game.title, "status": game_status},
|
||||||
|
)
|
||||||
|
db.add(activity)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(game)
|
await db.refresh(game)
|
||||||
|
|
||||||
@@ -102,7 +166,9 @@ async def add_game(
|
|||||||
cover_url=None,
|
cover_url=None,
|
||||||
download_url=game.download_url,
|
download_url=game.download_url,
|
||||||
genre=game.genre,
|
genre=game.genre,
|
||||||
added_by=UserPublic.model_validate(current_user),
|
status=game.status,
|
||||||
|
proposed_by=UserPublic.model_validate(current_user),
|
||||||
|
approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
|
||||||
challenges_count=0,
|
challenges_count=0,
|
||||||
created_at=game.created_at,
|
created_at=game.created_at,
|
||||||
)
|
)
|
||||||
@@ -111,22 +177,21 @@ async def add_game(
|
|||||||
@router.get("/games/{game_id}", response_model=GameResponse)
|
@router.get("/games/{game_id}", response_model=GameResponse)
|
||||||
async def get_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
game = await get_game_or_404(db, game_id)
|
game = await get_game_or_404(db, game_id)
|
||||||
await check_participant(db, current_user.id, game.marathon_id)
|
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||||
|
|
||||||
|
# Check access: organizers see all, participants see approved + own
|
||||||
|
if not current_user.is_admin:
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
if not participant.is_organizer:
|
||||||
|
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Game not found")
|
||||||
|
|
||||||
challenges_count = await db.scalar(
|
challenges_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)
|
||||||
)
|
)
|
||||||
|
|
||||||
return GameResponse(
|
return game_to_response(game, challenges_count)
|
||||||
id=game.id,
|
|
||||||
title=game.title,
|
|
||||||
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
|
|
||||||
download_url=game.download_url,
|
|
||||||
genre=game.genre,
|
|
||||||
added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
|
|
||||||
challenges_count=challenges_count,
|
|
||||||
created_at=game.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/games/{game_id}", response_model=GameResponse)
|
@router.patch("/games/{game_id}", response_model=GameResponse)
|
||||||
@@ -144,9 +209,16 @@ async def update_game(
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot update games in active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot update games in active or finished marathon")
|
||||||
|
|
||||||
# Only the one who added or organizer can update
|
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||||
if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Only the one who added the game or organizer can update it")
|
# Only the one who proposed, organizers, or admin can update
|
||||||
|
can_update = (
|
||||||
|
current_user.is_admin or
|
||||||
|
(participant and participant.is_organizer) or
|
||||||
|
game.proposed_by_id == current_user.id
|
||||||
|
)
|
||||||
|
if not can_update:
|
||||||
|
raise HTTPException(status_code=403, detail="Only the one who proposed the game or organizer can update it")
|
||||||
|
|
||||||
if data.title is not None:
|
if data.title is not None:
|
||||||
game.title = data.title
|
game.title = data.title
|
||||||
@@ -170,9 +242,16 @@ async def delete_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete games from active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot delete games from active or finished marathon")
|
||||||
|
|
||||||
# Only the one who added or organizer can delete
|
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||||
if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Only the one who added the game or organizer can delete it")
|
# Only the one who proposed, organizers, or admin can delete
|
||||||
|
can_delete = (
|
||||||
|
current_user.is_admin or
|
||||||
|
(participant and participant.is_organizer) or
|
||||||
|
game.proposed_by_id == current_user.id
|
||||||
|
)
|
||||||
|
if not can_delete:
|
||||||
|
raise HTTPException(status_code=403, detail="Only the one who proposed the game or organizer can delete it")
|
||||||
|
|
||||||
await db.delete(game)
|
await db.delete(game)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -180,6 +259,100 @@ async def delete_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
return MessageResponse(message="Game deleted")
|
return MessageResponse(message="Game deleted")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/games/{game_id}/approve", response_model=GameResponse)
|
||||||
|
async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Approve a pending game. Organizers only."""
|
||||||
|
game = await get_game_or_404(db, game_id)
|
||||||
|
|
||||||
|
await require_organizer(db, current_user, game.marathon_id)
|
||||||
|
|
||||||
|
if game.status != GameStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Game is not pending")
|
||||||
|
|
||||||
|
# Get marathon title for notification
|
||||||
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
||||||
|
marathon = marathon_result.scalar_one()
|
||||||
|
|
||||||
|
# Save proposer id before status change
|
||||||
|
proposer_id = game.proposed_by_id
|
||||||
|
|
||||||
|
game.status = GameStatus.APPROVED.value
|
||||||
|
game.approved_by_id = current_user.id
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
activity = Activity(
|
||||||
|
marathon_id=game.marathon_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
type=ActivityType.APPROVE_GAME.value,
|
||||||
|
data={"title": game.title},
|
||||||
|
)
|
||||||
|
db.add(activity)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(game)
|
||||||
|
|
||||||
|
# Notify proposer (if not self-approving)
|
||||||
|
if proposer_id and proposer_id != current_user.id:
|
||||||
|
await telegram_notifier.notify_game_approved(
|
||||||
|
db, proposer_id, marathon.title, game.title
|
||||||
|
)
|
||||||
|
|
||||||
|
# Need to reload relationships
|
||||||
|
game = await get_game_or_404(db, game_id)
|
||||||
|
challenges_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return game_to_response(game, challenges_count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/games/{game_id}/reject", response_model=GameResponse)
|
||||||
|
async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Reject a pending game. Organizers only."""
|
||||||
|
game = await get_game_or_404(db, game_id)
|
||||||
|
|
||||||
|
await require_organizer(db, current_user, game.marathon_id)
|
||||||
|
|
||||||
|
if game.status != GameStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Game is not pending")
|
||||||
|
|
||||||
|
# Get marathon title for notification
|
||||||
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
||||||
|
marathon = marathon_result.scalar_one()
|
||||||
|
|
||||||
|
# Save proposer id and game title before changes
|
||||||
|
proposer_id = game.proposed_by_id
|
||||||
|
game_title = game.title
|
||||||
|
|
||||||
|
game.status = GameStatus.REJECTED.value
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
activity = Activity(
|
||||||
|
marathon_id=game.marathon_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
type=ActivityType.REJECT_GAME.value,
|
||||||
|
data={"title": game.title},
|
||||||
|
)
|
||||||
|
db.add(activity)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(game)
|
||||||
|
|
||||||
|
# Notify proposer
|
||||||
|
if proposer_id and proposer_id != current_user.id:
|
||||||
|
await telegram_notifier.notify_game_rejected(
|
||||||
|
db, proposer_id, marathon.title, game_title
|
||||||
|
)
|
||||||
|
|
||||||
|
# Need to reload relationships
|
||||||
|
game = await get_game_or_404(db, game_id)
|
||||||
|
challenges_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return game_to_response(game, challenges_count)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/games/{game_id}/cover", response_model=GameResponse)
|
@router.post("/games/{game_id}/cover", response_model=GameResponse)
|
||||||
async def upload_cover(
|
async def upload_cover(
|
||||||
game_id: int,
|
game_id: int,
|
||||||
@@ -188,7 +361,7 @@ async def upload_cover(
|
|||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
):
|
):
|
||||||
game = await get_game_or_404(db, game_id)
|
game = await get_game_or_404(db, game_id)
|
||||||
await check_participant(db, current_user.id, game.marathon_id)
|
await require_participant(db, current_user.id, game.marathon_id)
|
||||||
|
|
||||||
# Validate file
|
# Validate file
|
||||||
if not file.content_type.startswith("image/"):
|
if not file.content_type.startswith("image/"):
|
||||||
@@ -208,15 +381,20 @@ async def upload_cover(
|
|||||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save file
|
# Delete old cover if exists
|
||||||
filename = f"{game_id}_{uuid.uuid4().hex}.{ext}"
|
if game.cover_path:
|
||||||
filepath = Path(settings.UPLOAD_DIR) / "covers" / filename
|
await storage_service.delete_file(game.cover_path)
|
||||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with open(filepath, "wb") as f:
|
# Upload file
|
||||||
f.write(contents)
|
filename = storage_service.generate_filename(game_id, file.filename)
|
||||||
|
file_path = await storage_service.upload_file(
|
||||||
|
content=contents,
|
||||||
|
folder="covers",
|
||||||
|
filename=filename,
|
||||||
|
content_type=file.content_type or "image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
game.cover_path = str(filepath)
|
game.cover_path = file_path
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return await get_game(game_id, current_user, db)
|
return await get_game(game_id, current_user, db)
|
||||||
|
|||||||
@@ -1,35 +1,82 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import secrets
|
import secrets
|
||||||
from fastapi import APIRouter, HTTPException, status
|
import string
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Depends
|
||||||
|
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
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import (
|
||||||
from app.models import Marathon, Participant, MarathonStatus, Game, Assignment, AssignmentStatus, Activity, ActivityType
|
DbSession, CurrentUser,
|
||||||
|
require_participant, require_organizer, require_creator,
|
||||||
|
get_participant,
|
||||||
|
)
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
|
||||||
|
# Optional auth for endpoints that need it conditionally
|
||||||
|
optional_auth = HTTPBearer(auto_error=False)
|
||||||
|
from app.models import (
|
||||||
|
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||||
|
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||||
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
MarathonCreate,
|
MarathonCreate,
|
||||||
MarathonUpdate,
|
MarathonUpdate,
|
||||||
MarathonResponse,
|
MarathonResponse,
|
||||||
MarathonListItem,
|
MarathonListItem,
|
||||||
|
MarathonPublicInfo,
|
||||||
JoinMarathon,
|
JoinMarathon,
|
||||||
ParticipantInfo,
|
ParticipantInfo,
|
||||||
ParticipantWithUser,
|
ParticipantWithUser,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
UserPublic,
|
UserPublic,
|
||||||
|
SetParticipantRole,
|
||||||
)
|
)
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(prefix="/marathons", tags=["marathons"])
|
router = APIRouter(prefix="/marathons", tags=["marathons"])
|
||||||
|
|
||||||
|
|
||||||
|
# Public endpoint (no auth required)
|
||||||
|
@router.get("/by-code/{invite_code}", response_model=MarathonPublicInfo)
|
||||||
|
async def get_marathon_by_code(invite_code: str, db: DbSession):
|
||||||
|
"""Get public marathon info by invite code. No authentication required."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon, func.count(Participant.id).label("participants_count"))
|
||||||
|
.outerjoin(Participant)
|
||||||
|
.options(selectinload(Marathon.creator))
|
||||||
|
.where(func.upper(Marathon.invite_code) == invite_code.upper())
|
||||||
|
.group_by(Marathon.id)
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
marathon = row[0]
|
||||||
|
participants_count = row[1]
|
||||||
|
|
||||||
|
return MarathonPublicInfo(
|
||||||
|
id=marathon.id,
|
||||||
|
title=marathon.title,
|
||||||
|
description=marathon.description,
|
||||||
|
status=marathon.status,
|
||||||
|
participants_count=participants_count,
|
||||||
|
creator_nickname=marathon.creator.nickname,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_invite_code() -> str:
|
def generate_invite_code() -> str:
|
||||||
return secrets.token_urlsafe(8)
|
"""Generate a clean 8-character uppercase alphanumeric code."""
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
return ''.join(secrets.choice(alphabet) for _ in range(8))
|
||||||
|
|
||||||
|
|
||||||
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
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.organizer))
|
.options(selectinload(Marathon.creator))
|
||||||
.where(Marathon.id == marathon_id)
|
.where(Marathon.id == marathon_id)
|
||||||
)
|
)
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
@@ -50,13 +97,24 @@ async def get_participation(db, user_id: int, marathon_id: int) -> Participant |
|
|||||||
|
|
||||||
@router.get("", response_model=list[MarathonListItem])
|
@router.get("", response_model=list[MarathonListItem])
|
||||||
async def list_marathons(current_user: CurrentUser, db: DbSession):
|
async def list_marathons(current_user: CurrentUser, db: DbSession):
|
||||||
"""Get all marathons where user is participant or organizer"""
|
"""Get all marathons where user is participant, creator, or public marathons"""
|
||||||
|
# Admin can see all marathons
|
||||||
|
if current_user.is_admin:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon, func.count(Participant.id).label("participants_count"))
|
||||||
|
.outerjoin(Participant)
|
||||||
|
.group_by(Marathon.id)
|
||||||
|
.order_by(Marathon.created_at.desc())
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# User can see: own marathons, participated marathons, and public marathons
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Marathon, func.count(Participant.id).label("participants_count"))
|
select(Marathon, func.count(Participant.id).label("participants_count"))
|
||||||
.outerjoin(Participant)
|
.outerjoin(Participant)
|
||||||
.where(
|
.where(
|
||||||
(Marathon.organizer_id == current_user.id) |
|
(Marathon.creator_id == current_user.id) |
|
||||||
(Participant.user_id == current_user.id)
|
(Participant.user_id == current_user.id) |
|
||||||
|
(Marathon.is_public == True)
|
||||||
)
|
)
|
||||||
.group_by(Marathon.id)
|
.group_by(Marathon.id)
|
||||||
.order_by(Marathon.created_at.desc())
|
.order_by(Marathon.created_at.desc())
|
||||||
@@ -69,6 +127,7 @@ async def list_marathons(current_user: CurrentUser, db: DbSession):
|
|||||||
id=marathon.id,
|
id=marathon.id,
|
||||||
title=marathon.title,
|
title=marathon.title,
|
||||||
status=marathon.status,
|
status=marathon.status,
|
||||||
|
is_public=marathon.is_public,
|
||||||
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,
|
||||||
@@ -90,18 +149,21 @@ async def create_marathon(
|
|||||||
marathon = Marathon(
|
marathon = Marathon(
|
||||||
title=data.title,
|
title=data.title,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
organizer_id=current_user.id,
|
creator_id=current_user.id,
|
||||||
invite_code=generate_invite_code(),
|
invite_code=generate_invite_code(),
|
||||||
|
is_public=data.is_public,
|
||||||
|
game_proposal_mode=data.game_proposal_mode,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
db.add(marathon)
|
db.add(marathon)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Auto-add organizer as participant
|
# Auto-add creator as organizer participant
|
||||||
participant = Participant(
|
participant = Participant(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
marathon_id=marathon.id,
|
marathon_id=marathon.id,
|
||||||
|
role=ParticipantRole.ORGANIZER.value, # Creator is organizer
|
||||||
)
|
)
|
||||||
db.add(participant)
|
db.add(participant)
|
||||||
|
|
||||||
@@ -112,9 +174,12 @@ async def create_marathon(
|
|||||||
id=marathon.id,
|
id=marathon.id,
|
||||||
title=marathon.title,
|
title=marathon.title,
|
||||||
description=marathon.description,
|
description=marathon.description,
|
||||||
organizer=UserPublic.model_validate(current_user),
|
creator=UserPublic.model_validate(current_user),
|
||||||
status=marathon.status,
|
status=marathon.status,
|
||||||
invite_code=marathon.invite_code,
|
invite_code=marathon.invite_code,
|
||||||
|
is_public=marathon.is_public,
|
||||||
|
game_proposal_mode=marathon.game_proposal_mode,
|
||||||
|
auto_events_enabled=marathon.auto_events_enabled,
|
||||||
start_date=marathon.start_date,
|
start_date=marathon.start_date,
|
||||||
end_date=marathon.end_date,
|
end_date=marathon.end_date,
|
||||||
participants_count=1,
|
participants_count=1,
|
||||||
@@ -128,12 +193,24 @@ async def create_marathon(
|
|||||||
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
marathon = await get_marathon_or_404(db, marathon_id)
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
# Count participants and games
|
# For private marathons, require participation (or admin/creator)
|
||||||
|
if not marathon.is_public and not current_user.is_admin:
|
||||||
|
participation = await get_participation(db, current_user.id, marathon_id)
|
||||||
|
if not participation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You are not a participant of this private marathon",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count participants and approved games
|
||||||
participants_count = await db.scalar(
|
participants_count = await db.scalar(
|
||||||
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
|
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
|
||||||
)
|
)
|
||||||
games_count = await db.scalar(
|
games_count = await db.scalar(
|
||||||
select(func.count()).select_from(Game).where(Game.marathon_id == marathon_id)
|
select(func.count()).select_from(Game).where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get user's participation
|
# Get user's participation
|
||||||
@@ -143,9 +220,12 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
|
|||||||
id=marathon.id,
|
id=marathon.id,
|
||||||
title=marathon.title,
|
title=marathon.title,
|
||||||
description=marathon.description,
|
description=marathon.description,
|
||||||
organizer=UserPublic.model_validate(marathon.organizer),
|
creator=UserPublic.model_validate(marathon.creator),
|
||||||
status=marathon.status,
|
status=marathon.status,
|
||||||
invite_code=marathon.invite_code,
|
invite_code=marathon.invite_code,
|
||||||
|
is_public=marathon.is_public,
|
||||||
|
game_proposal_mode=marathon.game_proposal_mode,
|
||||||
|
auto_events_enabled=marathon.auto_events_enabled,
|
||||||
start_date=marathon.start_date,
|
start_date=marathon.start_date,
|
||||||
end_date=marathon.end_date,
|
end_date=marathon.end_date,
|
||||||
participants_count=participants_count,
|
participants_count=participants_count,
|
||||||
@@ -162,11 +242,10 @@ async def update_marathon(
|
|||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
):
|
):
|
||||||
|
# Require organizer role
|
||||||
|
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)
|
||||||
|
|
||||||
if marathon.organizer_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Only organizer can update marathon")
|
|
||||||
|
|
||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot update active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot update active or finished marathon")
|
||||||
|
|
||||||
@@ -177,6 +256,12 @@ async def update_marathon(
|
|||||||
if data.start_date is not None:
|
if data.start_date is not None:
|
||||||
# Strip timezone info for naive datetime columns
|
# Strip timezone info for naive datetime columns
|
||||||
marathon.start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date
|
marathon.start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date
|
||||||
|
if data.is_public is not None:
|
||||||
|
marathon.is_public = data.is_public
|
||||||
|
if data.game_proposal_mode is not None:
|
||||||
|
marathon.game_proposal_mode = data.game_proposal_mode
|
||||||
|
if data.auto_events_enabled is not None:
|
||||||
|
marathon.auto_events_enabled = data.auto_events_enabled
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -185,11 +270,10 @@ async def update_marathon(
|
|||||||
|
|
||||||
@router.delete("/{marathon_id}", response_model=MessageResponse)
|
@router.delete("/{marathon_id}", response_model=MessageResponse)
|
||||||
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
# Only creator or admin can delete
|
||||||
|
await require_creator(db, current_user, marathon_id)
|
||||||
marathon = await get_marathon_or_404(db, marathon_id)
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
if marathon.organizer_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Only organizer can delete marathon")
|
|
||||||
|
|
||||||
await db.delete(marathon)
|
await db.delete(marathon)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -198,20 +282,40 @@ async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
|
|||||||
|
|
||||||
@router.post("/{marathon_id}/start", response_model=MarathonResponse)
|
@router.post("/{marathon_id}/start", response_model=MarathonResponse)
|
||||||
async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
# Require organizer role
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
marathon = await get_marathon_or_404(db, marathon_id)
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
if marathon.organizer_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Only organizer can start marathon")
|
|
||||||
|
|
||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
|
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
|
||||||
|
|
||||||
# Check if there are games with challenges
|
# Check if there are approved games
|
||||||
games_count = await db.scalar(
|
games_result = await db.execute(
|
||||||
select(func.count()).select_from(Game).where(Game.marathon_id == marathon_id)
|
select(Game).where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
approved_games = games_result.scalars().all()
|
||||||
|
|
||||||
|
if len(approved_games) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
|
||||||
|
|
||||||
|
# Check that all approved games have at least one challenge
|
||||||
|
games_without_challenges = []
|
||||||
|
for game in approved_games:
|
||||||
|
challenge_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
||||||
|
)
|
||||||
|
if challenge_count == 0:
|
||||||
|
games_without_challenges.append(game.title)
|
||||||
|
|
||||||
|
if games_without_challenges:
|
||||||
|
games_list = ", ".join(games_without_challenges)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"У следующих игр нет челленджей: {games_list}"
|
||||||
)
|
)
|
||||||
if games_count == 0:
|
|
||||||
raise HTTPException(status_code=400, detail="Add at least one game before starting")
|
|
||||||
|
|
||||||
marathon.status = MarathonStatus.ACTIVE.value
|
marathon.status = MarathonStatus.ACTIVE.value
|
||||||
|
|
||||||
@@ -226,16 +330,18 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Send Telegram notifications
|
||||||
|
await telegram_notifier.notify_marathon_start(db, marathon_id, marathon.title)
|
||||||
|
|
||||||
return await get_marathon(marathon_id, current_user, db)
|
return await get_marathon(marathon_id, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
@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):
|
||||||
|
# Require organizer role
|
||||||
|
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)
|
||||||
|
|
||||||
if marathon.organizer_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Only organizer can finish marathon")
|
|
||||||
|
|
||||||
if marathon.status != MarathonStatus.ACTIVE.value:
|
if marathon.status != MarathonStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail="Marathon is not active")
|
raise HTTPException(status_code=400, detail="Marathon is not active")
|
||||||
|
|
||||||
@@ -252,13 +358,16 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Send Telegram notifications
|
||||||
|
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
|
||||||
|
|
||||||
return await get_marathon(marathon_id, current_user, db)
|
return await get_marathon(marathon_id, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/join", response_model=MarathonResponse)
|
@router.post("/join", response_model=MarathonResponse)
|
||||||
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
|
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Marathon).where(Marathon.invite_code == data.invite_code)
|
select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper())
|
||||||
)
|
)
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -276,6 +385,44 @@ async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSes
|
|||||||
participant = Participant(
|
participant = Participant(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
marathon_id=marathon.id,
|
marathon_id=marathon.id,
|
||||||
|
role=ParticipantRole.PARTICIPANT.value, # Regular participant
|
||||||
|
)
|
||||||
|
db.add(participant)
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
activity = Activity(
|
||||||
|
marathon_id=marathon.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
type=ActivityType.JOIN.value,
|
||||||
|
data={"nickname": current_user.nickname},
|
||||||
|
)
|
||||||
|
db.add(activity)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return await get_marathon(marathon.id, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{marathon_id}/join", response_model=MarathonResponse)
|
||||||
|
async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Join a public marathon without invite code"""
|
||||||
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
if not marathon.is_public:
|
||||||
|
raise HTTPException(status_code=403, detail="This marathon is private. Use invite code to join.")
|
||||||
|
|
||||||
|
if marathon.status == MarathonStatus.FINISHED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Marathon has already finished")
|
||||||
|
|
||||||
|
# Check if already participant
|
||||||
|
existing = await get_participation(db, current_user.id, marathon.id)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Already joined this marathon")
|
||||||
|
|
||||||
|
participant = Participant(
|
||||||
|
user_id=current_user.id,
|
||||||
|
marathon_id=marathon.id,
|
||||||
|
role=ParticipantRole.PARTICIPANT.value,
|
||||||
)
|
)
|
||||||
db.add(participant)
|
db.add(participant)
|
||||||
|
|
||||||
@@ -295,7 +442,16 @@ async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSes
|
|||||||
|
|
||||||
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
|
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
|
||||||
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
await get_marathon_or_404(db, marathon_id)
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
# For private marathons, require participation (or admin)
|
||||||
|
if not marathon.is_public and not current_user.is_admin:
|
||||||
|
participation = await get_participation(db, current_user.id, marathon_id)
|
||||||
|
if not participation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You are not a participant of this private marathon",
|
||||||
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant)
|
select(Participant)
|
||||||
@@ -308,6 +464,7 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
|
|||||||
return [
|
return [
|
||||||
ParticipantWithUser(
|
ParticipantWithUser(
|
||||||
id=p.id,
|
id=p.id,
|
||||||
|
role=p.role,
|
||||||
total_points=p.total_points,
|
total_points=p.total_points,
|
||||||
current_streak=p.current_streak,
|
current_streak=p.current_streak,
|
||||||
drop_count=p.drop_count,
|
drop_count=p.drop_count,
|
||||||
@@ -318,9 +475,87 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{marathon_id}/participants/{user_id}/role", response_model=ParticipantWithUser)
|
||||||
|
async def set_participant_role(
|
||||||
|
marathon_id: int,
|
||||||
|
user_id: int,
|
||||||
|
data: SetParticipantRole,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Set participant's role (only creator can do this)"""
|
||||||
|
# Only creator can change roles
|
||||||
|
marathon = await require_creator(db, current_user, marathon_id)
|
||||||
|
|
||||||
|
# Cannot change creator's role
|
||||||
|
if user_id == marathon.creator_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot change creator's role")
|
||||||
|
|
||||||
|
# Get participant
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant)
|
||||||
|
.options(selectinload(Participant.user))
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
Participant.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=404, detail="Participant not found")
|
||||||
|
|
||||||
|
participant.role = data.role
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(participant)
|
||||||
|
|
||||||
|
return ParticipantWithUser(
|
||||||
|
id=participant.id,
|
||||||
|
role=participant.role,
|
||||||
|
total_points=participant.total_points,
|
||||||
|
current_streak=participant.current_streak,
|
||||||
|
drop_count=participant.drop_count,
|
||||||
|
joined_at=participant.joined_at,
|
||||||
|
user=UserPublic.model_validate(participant.user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
|
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
|
||||||
async def get_leaderboard(marathon_id: int, db: DbSession):
|
async def get_leaderboard(
|
||||||
await get_marathon_or_404(db, marathon_id)
|
marathon_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(optional_auth),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get marathon leaderboard.
|
||||||
|
Public marathons: no auth required.
|
||||||
|
Private marathons: requires auth + participation check.
|
||||||
|
"""
|
||||||
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
# For private marathons, require authentication and participation
|
||||||
|
if not marathon.is_public:
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication required for private marathon leaderboard",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = int(payload.get("sub"))
|
||||||
|
participant = await get_participant(db, user_id, marathon_id)
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You are not a participant of this marathon",
|
||||||
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant)
|
select(Participant)
|
||||||
|
|||||||
393
backend/app/api/v1/telegram.py
Normal file
393
backend/app/api/v1/telegram.py
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, CurrentUser, BotSecretDep
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_telegram_link_token, verify_telegram_link_token
|
||||||
|
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/telegram", tags=["telegram"])
|
||||||
|
|
||||||
|
|
||||||
|
# Schemas
|
||||||
|
class TelegramLinkToken(BaseModel):
|
||||||
|
token: str
|
||||||
|
bot_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfirmLink(BaseModel):
|
||||||
|
token: str
|
||||||
|
telegram_id: int
|
||||||
|
telegram_username: str | None = None
|
||||||
|
telegram_first_name: str | None = None
|
||||||
|
telegram_last_name: str | None = None
|
||||||
|
telegram_avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramLinkResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
nickname: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramUserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
nickname: str
|
||||||
|
login: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramMarathonResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
status: str
|
||||||
|
total_points: int
|
||||||
|
position: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramMarathonDetails(BaseModel):
|
||||||
|
marathon: dict
|
||||||
|
participant: dict
|
||||||
|
position: int
|
||||||
|
active_events: list[dict]
|
||||||
|
current_assignment: dict | None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramStatsResponse(BaseModel):
|
||||||
|
marathons_completed: int
|
||||||
|
marathons_active: int
|
||||||
|
challenges_completed: int
|
||||||
|
total_points: int
|
||||||
|
best_streak: int
|
||||||
|
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
@router.post("/generate-link-token", response_model=TelegramLinkToken)
|
||||||
|
async def generate_link_token(current_user: CurrentUser):
|
||||||
|
"""Generate a one-time token for Telegram account linking."""
|
||||||
|
logger.info(f"[TG_LINK] Generating link token for user {current_user.id} ({current_user.nickname})")
|
||||||
|
|
||||||
|
# Create a short token (≤64 chars) for Telegram deep link
|
||||||
|
token = create_telegram_link_token(
|
||||||
|
user_id=current_user.id,
|
||||||
|
expire_minutes=settings.TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES
|
||||||
|
)
|
||||||
|
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
|
||||||
|
|
||||||
|
bot_username = settings.TELEGRAM_BOT_USERNAME or "BCMarathonbot"
|
||||||
|
bot_url = f"https://t.me/{bot_username}?start={token}"
|
||||||
|
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
|
||||||
|
|
||||||
|
return TelegramLinkToken(token=token, bot_url=bot_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/confirm-link", response_model=TelegramLinkResponse)
|
||||||
|
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession, _: BotSecretDep):
|
||||||
|
"""Confirm Telegram account linking (called by bot)."""
|
||||||
|
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
|
||||||
|
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
|
||||||
|
logger.info(f"[TG_CONFIRM] telegram_username: {data.telegram_username}")
|
||||||
|
logger.info(f"[TG_CONFIRM] token: {data.token}")
|
||||||
|
|
||||||
|
# Verify short token and extract user_id
|
||||||
|
user_id = verify_telegram_link_token(data.token)
|
||||||
|
logger.info(f"[TG_CONFIRM] Verified user_id: {user_id}")
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
logger.error(f"[TG_CONFIRM] FAILED: Token invalid or expired")
|
||||||
|
return TelegramLinkResponse(success=False, error="Ссылка недействительна или устарела")
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
logger.info(f"[TG_CONFIRM] Found user: {user.nickname if user else 'NOT FOUND'}")
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.error(f"[TG_CONFIRM] FAILED: User not found")
|
||||||
|
return TelegramLinkResponse(success=False, error="Пользователь не найден")
|
||||||
|
|
||||||
|
# Check if telegram_id already linked to another user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == data.telegram_id, User.id != user_id)
|
||||||
|
)
|
||||||
|
existing_user = result.scalar_one_or_none()
|
||||||
|
if existing_user:
|
||||||
|
logger.error(f"[TG_CONFIRM] FAILED: Telegram already linked to user {existing_user.id}")
|
||||||
|
return TelegramLinkResponse(
|
||||||
|
success=False,
|
||||||
|
error="Этот Telegram аккаунт уже привязан к другому пользователю"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Link account
|
||||||
|
logger.info(f"[TG_CONFIRM] Linking telegram_id={data.telegram_id} to user_id={user_id}")
|
||||||
|
user.telegram_id = data.telegram_id
|
||||||
|
user.telegram_username = data.telegram_username
|
||||||
|
user.telegram_first_name = data.telegram_first_name
|
||||||
|
user.telegram_last_name = data.telegram_last_name
|
||||||
|
user.telegram_avatar_url = data.telegram_avatar_url
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"[TG_CONFIRM] SUCCESS! User {user.nickname} linked to Telegram {data.telegram_id}")
|
||||||
|
|
||||||
|
return TelegramLinkResponse(success=True, nickname=user.nickname)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
|
||||||
|
async def get_user_by_telegram_id(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
|
"""Get user by Telegram ID."""
|
||||||
|
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.info(f"[TG_USER] No user found for telegram_id={telegram_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(f"[TG_USER] Found user: {user.id} ({user.nickname})")
|
||||||
|
return TelegramUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
nickname=user.nickname,
|
||||||
|
login=user.login,
|
||||||
|
avatar_url=user.avatar_url
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
|
||||||
|
async def unlink_telegram(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
|
"""Unlink Telegram account."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return TelegramLinkResponse(success=False, error="Аккаунт не найден")
|
||||||
|
|
||||||
|
user.telegram_id = None
|
||||||
|
user.telegram_username = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return TelegramLinkResponse(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
|
||||||
|
async def get_user_marathons(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
|
"""Get user's marathons by Telegram ID."""
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get participations with marathons
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant, Marathon)
|
||||||
|
.join(Marathon, Participant.marathon_id == Marathon.id)
|
||||||
|
.where(Participant.user_id == user.id)
|
||||||
|
.order_by(Marathon.created_at.desc())
|
||||||
|
)
|
||||||
|
participations = result.all()
|
||||||
|
|
||||||
|
marathons = []
|
||||||
|
for participant, marathon in participations:
|
||||||
|
# Calculate position
|
||||||
|
position_result = await db.execute(
|
||||||
|
select(func.count(Participant.id) + 1)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon.id,
|
||||||
|
Participant.total_points > participant.total_points
|
||||||
|
)
|
||||||
|
)
|
||||||
|
position = position_result.scalar() or 1
|
||||||
|
|
||||||
|
marathons.append(TelegramMarathonResponse(
|
||||||
|
id=marathon.id,
|
||||||
|
title=marathon.title,
|
||||||
|
status=marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
|
||||||
|
total_points=participant.total_points,
|
||||||
|
position=position
|
||||||
|
))
|
||||||
|
|
||||||
|
return marathons
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
|
||||||
|
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
|
"""Get marathon details for user by Telegram ID."""
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get marathon
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon).where(Marathon.id == marathon_id)
|
||||||
|
)
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not marathon:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get participant
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant)
|
||||||
|
.where(Participant.marathon_id == marathon_id, Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not participant:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate position
|
||||||
|
position_result = await db.execute(
|
||||||
|
select(func.count(Participant.id) + 1)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
Participant.total_points > participant.total_points
|
||||||
|
)
|
||||||
|
)
|
||||||
|
position = position_result.scalar() or 1
|
||||||
|
|
||||||
|
# Get active events
|
||||||
|
result = await db.execute(
|
||||||
|
select(Event)
|
||||||
|
.where(Event.marathon_id == marathon_id, Event.is_active == True)
|
||||||
|
)
|
||||||
|
active_events = result.scalars().all()
|
||||||
|
|
||||||
|
events_data = [
|
||||||
|
{
|
||||||
|
"id": e.id,
|
||||||
|
"type": e.type.value if hasattr(e.type, 'value') else e.type,
|
||||||
|
"start_time": e.start_time.isoformat() if e.start_time else None,
|
||||||
|
"end_time": e.end_time.isoformat() if e.end_time else None
|
||||||
|
}
|
||||||
|
for e in active_events
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get current assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.status == "active"
|
||||||
|
)
|
||||||
|
.order_by(Assignment.started_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
assignment_data = None
|
||||||
|
if assignment:
|
||||||
|
challenge = assignment.challenge
|
||||||
|
game = challenge.game if challenge else None
|
||||||
|
assignment_data = {
|
||||||
|
"id": assignment.id,
|
||||||
|
"status": assignment.status.value if hasattr(assignment.status, 'value') else assignment.status,
|
||||||
|
"challenge": {
|
||||||
|
"id": challenge.id if challenge else None,
|
||||||
|
"title": challenge.title if challenge else None,
|
||||||
|
"difficulty": challenge.difficulty.value if challenge and hasattr(challenge.difficulty, 'value') else (challenge.difficulty if challenge else None),
|
||||||
|
"points": challenge.points if challenge else None,
|
||||||
|
"game": {
|
||||||
|
"id": game.id if game else None,
|
||||||
|
"title": game.title if game else None
|
||||||
|
}
|
||||||
|
} if challenge else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return TelegramMarathonDetails(
|
||||||
|
marathon={
|
||||||
|
"id": marathon.id,
|
||||||
|
"title": marathon.title,
|
||||||
|
"status": marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
|
||||||
|
"description": marathon.description
|
||||||
|
},
|
||||||
|
participant={
|
||||||
|
"total_points": participant.total_points,
|
||||||
|
"current_streak": participant.current_streak,
|
||||||
|
"drop_count": participant.drop_count
|
||||||
|
},
|
||||||
|
position=position,
|
||||||
|
active_events=events_data,
|
||||||
|
current_assignment=assignment_data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
|
||||||
|
async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
|
"""Get user's overall statistics by Telegram ID."""
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get participations
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant, Marathon)
|
||||||
|
.join(Marathon, Participant.marathon_id == Marathon.id)
|
||||||
|
.where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
participations = result.all()
|
||||||
|
|
||||||
|
marathons_completed = 0
|
||||||
|
marathons_active = 0
|
||||||
|
total_points = 0
|
||||||
|
best_streak = 0
|
||||||
|
|
||||||
|
for participant, marathon in participations:
|
||||||
|
status = marathon.status.value if hasattr(marathon.status, 'value') else marathon.status
|
||||||
|
if status == "finished":
|
||||||
|
marathons_completed += 1
|
||||||
|
elif status == "active":
|
||||||
|
marathons_active += 1
|
||||||
|
|
||||||
|
total_points += participant.total_points
|
||||||
|
if participant.current_streak > best_streak:
|
||||||
|
best_streak = participant.current_streak
|
||||||
|
|
||||||
|
# Count completed assignments
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(Assignment.id))
|
||||||
|
.join(Participant, Assignment.participant_id == Participant.id)
|
||||||
|
.where(Participant.user_id == user.id, Assignment.status == "completed")
|
||||||
|
)
|
||||||
|
challenges_completed = result.scalar() or 0
|
||||||
|
|
||||||
|
return TelegramStatsResponse(
|
||||||
|
marathons_completed=marathons_completed,
|
||||||
|
marathons_active=marathons_active,
|
||||||
|
challenges_completed=challenges_completed,
|
||||||
|
total_points=total_points,
|
||||||
|
best_streak=best_streak
|
||||||
|
)
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status, UploadFile, File
|
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
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
|
||||||
from app.models import User
|
from app.core.security import verify_password, get_password_hash
|
||||||
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse
|
from app.models import User, Participant, Assignment, Marathon
|
||||||
|
from app.models.assignment import AssignmentStatus
|
||||||
|
from app.models.marathon import MarathonStatus
|
||||||
|
from app.schemas import (
|
||||||
|
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
|
||||||
|
PasswordChange, UserStats, UserProfilePublic,
|
||||||
|
)
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
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):
|
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||||
|
"""Get user profile. Requires authentication."""
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -25,23 +31,53 @@ async def get_user(user_id: int, db: DbSession):
|
|||||||
return UserPublic.model_validate(user)
|
return UserPublic.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me", response_model=UserPublic)
|
@router.get("/{user_id}/avatar")
|
||||||
|
async def get_user_avatar(user_id: int, db: DbSession):
|
||||||
|
"""Stream user avatar from storage"""
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if not user.avatar_path:
|
||||||
|
raise HTTPException(status_code=404, detail="User has no avatar")
|
||||||
|
|
||||||
|
# Get file from storage
|
||||||
|
file_data = await storage_service.get_file(user.avatar_path, "avatars")
|
||||||
|
if not file_data:
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar not found in storage")
|
||||||
|
|
||||||
|
content, content_type = file_data
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me", response_model=UserPrivate)
|
||||||
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
|
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Update current user's profile"""
|
||||||
if data.nickname is not None:
|
if data.nickname is not None:
|
||||||
current_user.nickname = data.nickname
|
current_user.nickname = data.nickname
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
|
|
||||||
return UserPublic.model_validate(current_user)
|
return UserPrivate.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/avatar", response_model=UserPublic)
|
@router.post("/me/avatar", response_model=UserPrivate)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
):
|
):
|
||||||
|
"""Upload current user's avatar"""
|
||||||
# Validate file
|
# Validate file
|
||||||
if not file.content_type.startswith("image/"):
|
if not file.content_type.startswith("image/"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -64,20 +100,25 @@ async def upload_avatar(
|
|||||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save file
|
# Delete old avatar if exists
|
||||||
filename = f"{current_user.id}_{uuid.uuid4().hex}.{ext}"
|
if current_user.avatar_path:
|
||||||
filepath = Path(settings.UPLOAD_DIR) / "avatars" / filename
|
await storage_service.delete_file(current_user.avatar_path)
|
||||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with open(filepath, "wb") as f:
|
# Upload file
|
||||||
f.write(contents)
|
filename = storage_service.generate_filename(current_user.id, file.filename)
|
||||||
|
file_path = await storage_service.upload_file(
|
||||||
|
content=contents,
|
||||||
|
folder="avatars",
|
||||||
|
filename=filename,
|
||||||
|
content_type=file.content_type or "image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
# Update user
|
# Update user
|
||||||
current_user.avatar_path = str(filepath)
|
current_user.avatar_path = file_path
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
|
|
||||||
return UserPublic.model_validate(current_user)
|
return UserPrivate.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/telegram", response_model=MessageResponse)
|
@router.post("/me/telegram", response_model=MessageResponse)
|
||||||
@@ -102,3 +143,161 @@ async def link_telegram(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message="Telegram account linked successfully")
|
return MessageResponse(message="Telegram account linked successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/telegram/unlink", response_model=MessageResponse)
|
||||||
|
async def unlink_telegram(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
if not current_user.telegram_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Telegram account is not linked",
|
||||||
|
)
|
||||||
|
|
||||||
|
current_user.telegram_id = None
|
||||||
|
current_user.telegram_username = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(message="Telegram account unlinked successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/password", response_model=MessageResponse)
|
||||||
|
async def change_password(
|
||||||
|
data: PasswordChange,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Смена пароля текущего пользователя"""
|
||||||
|
if not verify_password(data.current_password, current_user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Неверный текущий пароль",
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.current_password == data.new_password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Новый пароль должен отличаться от текущего",
|
||||||
|
)
|
||||||
|
|
||||||
|
current_user.password_hash = get_password_hash(data.new_password)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(message="Пароль успешно изменен")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/stats", response_model=UserStats)
|
||||||
|
async def get_my_stats(current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Получить свою статистику"""
|
||||||
|
return await _get_user_stats(current_user.id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/stats", response_model=UserStats)
|
||||||
|
async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||||
|
"""Получить статистику пользователя. Requires authentication."""
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _get_user_stats(user_id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
|
||||||
|
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||||
|
"""Получить публичный профиль пользователя со статистикой. Requires authentication."""
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = await _get_user_stats(user_id, db)
|
||||||
|
|
||||||
|
return UserProfilePublic(
|
||||||
|
id=user.id,
|
||||||
|
nickname=user.nickname,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
created_at=user.created_at,
|
||||||
|
stats=stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user_stats(user_id: int, db) -> UserStats:
|
||||||
|
"""Вспомогательная функция для подсчета статистики пользователя"""
|
||||||
|
|
||||||
|
# 1. Количество марафонов (участий)
|
||||||
|
marathons_result = await db.execute(
|
||||||
|
select(func.count(Participant.id))
|
||||||
|
.where(Participant.user_id == user_id)
|
||||||
|
)
|
||||||
|
marathons_count = marathons_result.scalar() or 0
|
||||||
|
|
||||||
|
# 2. Количество побед (1 место в завершенных марафонах)
|
||||||
|
wins_count = 0
|
||||||
|
user_participations = await db.execute(
|
||||||
|
select(Participant)
|
||||||
|
.join(Marathon, Marathon.id == Participant.marathon_id)
|
||||||
|
.where(
|
||||||
|
Participant.user_id == user_id,
|
||||||
|
Marathon.status == MarathonStatus.FINISHED.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for participation in user_participations.scalars():
|
||||||
|
# Для каждого марафона проверяем, был ли пользователь первым
|
||||||
|
max_points_result = await db.execute(
|
||||||
|
select(func.max(Participant.total_points))
|
||||||
|
.where(Participant.marathon_id == participation.marathon_id)
|
||||||
|
)
|
||||||
|
max_points = max_points_result.scalar() or 0
|
||||||
|
|
||||||
|
if participation.total_points == max_points and max_points > 0:
|
||||||
|
# Проверяем что он единственный с такими очками (не ничья)
|
||||||
|
count_with_max = await db.execute(
|
||||||
|
select(func.count(Participant.id))
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == participation.marathon_id,
|
||||||
|
Participant.total_points == max_points
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if count_with_max.scalar() == 1:
|
||||||
|
wins_count += 1
|
||||||
|
|
||||||
|
# 3. Выполненных заданий
|
||||||
|
completed_result = await db.execute(
|
||||||
|
select(func.count(Assignment.id))
|
||||||
|
.join(Participant, Participant.id == Assignment.participant_id)
|
||||||
|
.where(
|
||||||
|
Participant.user_id == user_id,
|
||||||
|
Assignment.status == AssignmentStatus.COMPLETED.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
completed_assignments = completed_result.scalar() or 0
|
||||||
|
|
||||||
|
# 4. Всего очков заработано
|
||||||
|
points_result = await db.execute(
|
||||||
|
select(func.coalesce(func.sum(Assignment.points_earned), 0))
|
||||||
|
.join(Participant, Participant.id == Assignment.participant_id)
|
||||||
|
.where(
|
||||||
|
Participant.user_id == user_id,
|
||||||
|
Assignment.status == AssignmentStatus.COMPLETED.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_points_earned = points_result.scalar() or 0
|
||||||
|
|
||||||
|
return UserStats(
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
wins_count=wins_count,
|
||||||
|
completed_assignments=completed_assignments,
|
||||||
|
total_points_earned=total_points_earned,
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,20 +3,21 @@ from datetime import datetime
|
|||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser
|
||||||
from app.core.config import settings
|
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
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||||
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
|
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
|
||||||
)
|
)
|
||||||
from app.services.points import PointsService
|
from app.services.points import PointsService
|
||||||
|
from app.services.events import event_service
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
|
||||||
router = APIRouter(tags=["wheel"])
|
router = APIRouter(tags=["wheel"])
|
||||||
|
|
||||||
@@ -36,7 +37,14 @@ async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Particip
|
|||||||
return participant
|
return participant
|
||||||
|
|
||||||
|
|
||||||
async def get_active_assignment(db, participant_id: int) -> Assignment | None:
|
async def get_active_assignment(db, participant_id: int, is_event: bool = False) -> Assignment | None:
|
||||||
|
"""Get active assignment for participant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
participant_id: Participant ID
|
||||||
|
is_event: If True, get event assignment (Common Enemy). If False, get regular assignment.
|
||||||
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
@@ -45,11 +53,45 @@ async def get_active_assignment(db, participant_id: int) -> Assignment | None:
|
|||||||
.where(
|
.where(
|
||||||
Assignment.participant_id == participant_id,
|
Assignment.participant_id == participant_id,
|
||||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||||
|
Assignment.is_event_assignment == is_event,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment | None:
|
||||||
|
"""Get the oldest returned assignment that needs to be redone."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant_id,
|
||||||
|
Assignment.status == AssignmentStatus.RETURNED.value,
|
||||||
|
Assignment.is_event_assignment == False,
|
||||||
|
)
|
||||||
|
.order_by(Assignment.completed_at.asc()) # Oldest first
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def activate_returned_assignment(db, returned_assignment: Assignment) -> None:
|
||||||
|
"""
|
||||||
|
Re-activate a returned assignment.
|
||||||
|
Simply changes the status back to ACTIVE.
|
||||||
|
"""
|
||||||
|
returned_assignment.status = AssignmentStatus.ACTIVE.value
|
||||||
|
returned_assignment.started_at = datetime.utcnow()
|
||||||
|
# Clear previous proof data for fresh attempt
|
||||||
|
returned_assignment.proof_path = None
|
||||||
|
returned_assignment.proof_url = None
|
||||||
|
returned_assignment.proof_comment = None
|
||||||
|
returned_assignment.completed_at = None
|
||||||
|
returned_assignment.points_earned = 0
|
||||||
|
|
||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
|
@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"""
|
||||||
@@ -62,14 +104,40 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
if marathon.status != MarathonStatus.ACTIVE.value:
|
if marathon.status != MarathonStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail="Marathon is not active")
|
raise HTTPException(status_code=400, detail="Marathon is not active")
|
||||||
|
|
||||||
|
# Check if marathon has expired by end_date
|
||||||
|
if marathon.end_date and datetime.utcnow() > marathon.end_date:
|
||||||
|
raise HTTPException(status_code=400, detail="Marathon has ended")
|
||||||
|
|
||||||
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
||||||
|
|
||||||
# Check no active assignment
|
# Check no active regular assignment (event assignments are separate)
|
||||||
active = await get_active_assignment(db, participant.id)
|
active = await get_active_assignment(db, participant.id, is_event=False)
|
||||||
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 all games with challenges
|
# Check active event
|
||||||
|
active_event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
|
||||||
|
game = None
|
||||||
|
challenge = None
|
||||||
|
|
||||||
|
# Handle special event cases (excluding Common Enemy - it has separate flow)
|
||||||
|
if active_event:
|
||||||
|
if active_event.type == EventType.JACKPOT.value:
|
||||||
|
# Jackpot: Get hard challenge only
|
||||||
|
challenge = await event_service.get_random_hard_challenge(db, marathon_id)
|
||||||
|
if challenge:
|
||||||
|
# Load game for challenge
|
||||||
|
result = await db.execute(
|
||||||
|
select(Game).where(Game.id == challenge.game_id)
|
||||||
|
)
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
# Consume jackpot (one-time use)
|
||||||
|
await event_service.consume_jackpot(db, active_event.id)
|
||||||
|
# Note: Common Enemy is handled separately via event-assignment endpoints
|
||||||
|
|
||||||
|
# Normal random selection if no special event handling
|
||||||
|
if not game or not challenge:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Game)
|
select(Game)
|
||||||
.options(selectinload(Game.challenges))
|
.options(selectinload(Game.challenges))
|
||||||
@@ -80,46 +148,61 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
if not games:
|
if not games:
|
||||||
raise HTTPException(status_code=400, detail="No games with challenges available")
|
raise HTTPException(status_code=400, detail="No games with challenges available")
|
||||||
|
|
||||||
# Random selection
|
|
||||||
game = random.choice(games)
|
game = random.choice(games)
|
||||||
challenge = random.choice(game.challenges)
|
challenge = random.choice(game.challenges)
|
||||||
|
|
||||||
# Create assignment
|
# Create assignment (store event_type for jackpot multiplier on completion)
|
||||||
assignment = Assignment(
|
assignment = Assignment(
|
||||||
participant_id=participant.id,
|
participant_id=participant.id,
|
||||||
challenge_id=challenge.id,
|
challenge_id=challenge.id,
|
||||||
status=AssignmentStatus.ACTIVE.value,
|
status=AssignmentStatus.ACTIVE.value,
|
||||||
|
event_type=active_event.type if active_event else None,
|
||||||
)
|
)
|
||||||
db.add(assignment)
|
db.add(assignment)
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
|
activity_data = {
|
||||||
|
"game": game.title,
|
||||||
|
"challenge": challenge.title,
|
||||||
|
"difficulty": challenge.difficulty,
|
||||||
|
"points": challenge.points,
|
||||||
|
}
|
||||||
|
if active_event:
|
||||||
|
activity_data["event_type"] = active_event.type
|
||||||
|
|
||||||
activity = Activity(
|
activity = Activity(
|
||||||
marathon_id=marathon_id,
|
marathon_id=marathon_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
type=ActivityType.SPIN.value,
|
type=ActivityType.SPIN.value,
|
||||||
data={
|
data=activity_data,
|
||||||
"game": game.title,
|
|
||||||
"challenge": challenge.title,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
db.add(activity)
|
db.add(activity)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(assignment)
|
await db.refresh(assignment)
|
||||||
|
|
||||||
# Calculate drop penalty
|
# Calculate drop penalty (considers active event for double_risk)
|
||||||
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count)
|
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
|
||||||
|
|
||||||
|
# Get challenges count (avoid lazy loading in async context)
|
||||||
|
challenges_count = 0
|
||||||
|
if 'challenges' in game.__dict__:
|
||||||
|
challenges_count = len(game.challenges)
|
||||||
|
else:
|
||||||
|
challenges_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
||||||
|
)
|
||||||
|
|
||||||
return SpinResult(
|
return SpinResult(
|
||||||
assignment_id=assignment.id,
|
assignment_id=assignment.id,
|
||||||
game=GameResponse(
|
game=GameResponse(
|
||||||
id=game.id,
|
id=game.id,
|
||||||
title=game.title,
|
title=game.title,
|
||||||
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
|
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||||
download_url=game.download_url,
|
download_url=game.download_url,
|
||||||
genre=game.genre,
|
genre=game.genre,
|
||||||
added_by=None,
|
added_by=None,
|
||||||
challenges_count=len(game.challenges),
|
challenges_count=challenges_count,
|
||||||
created_at=game.created_at,
|
created_at=game.created_at,
|
||||||
),
|
),
|
||||||
challenge=ChallengeResponse(
|
challenge=ChallengeResponse(
|
||||||
@@ -143,9 +226,9 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
|
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
|
||||||
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Get current active assignment"""
|
"""Get current active regular assignment (not event assignments)"""
|
||||||
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
||||||
assignment = await get_active_assignment(db, participant.id)
|
assignment = await get_active_assignment(db, participant.id, is_event=False)
|
||||||
|
|
||||||
if not assignment:
|
if not assignment:
|
||||||
return None
|
return None
|
||||||
@@ -153,6 +236,10 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
|||||||
challenge = assignment.challenge
|
challenge = assignment.challenge
|
||||||
game = challenge.game
|
game = challenge.game
|
||||||
|
|
||||||
|
# Calculate drop penalty (considers active event for double_risk)
|
||||||
|
active_event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
|
||||||
|
|
||||||
return AssignmentResponse(
|
return AssignmentResponse(
|
||||||
id=assignment.id,
|
id=assignment.id,
|
||||||
challenge=ChallengeResponse(
|
challenge=ChallengeResponse(
|
||||||
@@ -170,12 +257,13 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
|||||||
created_at=challenge.created_at,
|
created_at=challenge.created_at,
|
||||||
),
|
),
|
||||||
status=assignment.status,
|
status=assignment.status,
|
||||||
proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url,
|
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
|
||||||
proof_comment=assignment.proof_comment,
|
proof_comment=assignment.proof_comment,
|
||||||
points_earned=assignment.points_earned,
|
points_earned=assignment.points_earned,
|
||||||
streak_at_completion=assignment.streak_at_completion,
|
streak_at_completion=assignment.streak_at_completion,
|
||||||
started_at=assignment.started_at,
|
started_at=assignment.started_at,
|
||||||
completed_at=assignment.completed_at,
|
completed_at=assignment.completed_at,
|
||||||
|
drop_penalty=drop_penalty,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,7 +276,7 @@ async def complete_assignment(
|
|||||||
comment: str | None = Form(None),
|
comment: str | None = Form(None),
|
||||||
proof_file: UploadFile | None = File(None),
|
proof_file: UploadFile | None = File(None),
|
||||||
):
|
):
|
||||||
"""Complete an assignment with proof"""
|
"""Complete a regular assignment with proof (not event assignments)"""
|
||||||
# Get assignment
|
# Get assignment
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
@@ -209,6 +297,10 @@ async def complete_assignment(
|
|||||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail="Assignment is not active")
|
raise HTTPException(status_code=400, detail="Assignment is not active")
|
||||||
|
|
||||||
|
# Event assignments should be completed via /event-assignments/{id}/complete
|
||||||
|
if assignment.is_event_assignment:
|
||||||
|
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
|
||||||
|
|
||||||
# Need either file or URL
|
# Need either file or URL
|
||||||
if not proof_file and not proof_url:
|
if not proof_file 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)")
|
||||||
@@ -229,14 +321,16 @@ async def complete_assignment(
|
|||||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
|
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
|
||||||
)
|
)
|
||||||
|
|
||||||
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}"
|
# Upload file to storage
|
||||||
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename
|
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
|
||||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
file_path = await storage_service.upload_file(
|
||||||
|
content=contents,
|
||||||
|
folder="proofs",
|
||||||
|
filename=filename,
|
||||||
|
content_type=proof_file.content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
|
||||||
with open(filepath, "wb") as f:
|
assignment.proof_path = file_path
|
||||||
f.write(contents)
|
|
||||||
|
|
||||||
assignment.proof_path = str(filepath)
|
|
||||||
else:
|
else:
|
||||||
assignment.proof_url = proof_url
|
assignment.proof_url = proof_url
|
||||||
|
|
||||||
@@ -246,9 +340,42 @@ async def complete_assignment(
|
|||||||
participant = assignment.participant
|
participant = assignment.participant
|
||||||
challenge = assignment.challenge
|
challenge = assignment.challenge
|
||||||
|
|
||||||
total_points, streak_bonus = points_service.calculate_completion_points(
|
# Get marathon_id for activity and event check
|
||||||
challenge.points, participant.current_streak
|
result = await db.execute(
|
||||||
|
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
|
||||||
)
|
)
|
||||||
|
full_challenge = result.scalar_one()
|
||||||
|
marathon_id = full_challenge.game.marathon_id
|
||||||
|
|
||||||
|
# Check active event for point multipliers
|
||||||
|
active_event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
|
||||||
|
# For jackpot: use the event_type stored in assignment (since event may be over)
|
||||||
|
# For other events: use the currently active event
|
||||||
|
effective_event = active_event
|
||||||
|
|
||||||
|
# Handle assignment-level event types (jackpot)
|
||||||
|
if assignment.event_type == EventType.JACKPOT.value:
|
||||||
|
# Create a mock event object for point calculation
|
||||||
|
class MockEvent:
|
||||||
|
def __init__(self, event_type):
|
||||||
|
self.type = event_type
|
||||||
|
effective_event = MockEvent(assignment.event_type)
|
||||||
|
|
||||||
|
total_points, streak_bonus, event_bonus = points_service.calculate_completion_points(
|
||||||
|
challenge.points, participant.current_streak, effective_event
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle common enemy bonus
|
||||||
|
common_enemy_bonus = 0
|
||||||
|
common_enemy_closed = False
|
||||||
|
common_enemy_winners = None
|
||||||
|
if active_event and active_event.type == EventType.COMMON_ENEMY.value:
|
||||||
|
common_enemy_bonus, common_enemy_closed, common_enemy_winners = await event_service.record_common_enemy_completion(
|
||||||
|
db, active_event, participant.id, current_user.id
|
||||||
|
)
|
||||||
|
total_points += common_enemy_bonus
|
||||||
|
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.COMPLETED.value
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
@@ -261,27 +388,76 @@ async def complete_assignment(
|
|||||||
participant.current_streak += 1
|
participant.current_streak += 1
|
||||||
participant.drop_count = 0 # Reset drop counter on success
|
participant.drop_count = 0 # Reset drop counter on success
|
||||||
|
|
||||||
# Get marathon_id for activity
|
|
||||||
result = await db.execute(
|
|
||||||
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
|
|
||||||
)
|
|
||||||
full_challenge = result.scalar_one()
|
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
activity = Activity(
|
activity_data = {
|
||||||
marathon_id=full_challenge.game.marathon_id,
|
"assignment_id": assignment.id,
|
||||||
user_id=current_user.id,
|
"game": full_challenge.game.title,
|
||||||
type=ActivityType.COMPLETE.value,
|
|
||||||
data={
|
|
||||||
"challenge": challenge.title,
|
"challenge": challenge.title,
|
||||||
|
"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 assignment.event_type == EventType.JACKPOT.value:
|
||||||
|
activity_data["event_type"] = assignment.event_type
|
||||||
|
activity_data["event_bonus"] = event_bonus
|
||||||
|
elif active_event:
|
||||||
|
activity_data["event_type"] = active_event.type
|
||||||
|
activity_data["event_bonus"] = event_bonus
|
||||||
|
if common_enemy_bonus:
|
||||||
|
activity_data["common_enemy_bonus"] = common_enemy_bonus
|
||||||
|
|
||||||
|
activity = Activity(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
type=ActivityType.COMPLETE.value,
|
||||||
|
data=activity_data,
|
||||||
)
|
)
|
||||||
db.add(activity)
|
db.add(activity)
|
||||||
|
|
||||||
|
# If common enemy event auto-closed, log the event end with winners
|
||||||
|
if common_enemy_closed and common_enemy_winners:
|
||||||
|
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||||
|
# Load winner nicknames
|
||||||
|
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
|
||||||
|
users_result = await db.execute(
|
||||||
|
select(User).where(User.id.in_(winner_user_ids))
|
||||||
|
)
|
||||||
|
users_map = {u.id: u.nickname for u in users_result.scalars().all()}
|
||||||
|
|
||||||
|
winners_data = [
|
||||||
|
{
|
||||||
|
"user_id": w["user_id"],
|
||||||
|
"nickname": users_map.get(w["user_id"], "Unknown"),
|
||||||
|
"rank": w["rank"],
|
||||||
|
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
|
||||||
|
}
|
||||||
|
for w in common_enemy_winners
|
||||||
|
]
|
||||||
|
print(f"[COMMON_ENEMY] Creating event_end activity with winners: {winners_data}")
|
||||||
|
|
||||||
|
event_end_activity = Activity(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
user_id=current_user.id, # Last completer triggers the close
|
||||||
|
type=ActivityType.EVENT_END.value,
|
||||||
|
data={
|
||||||
|
"event_type": EventType.COMMON_ENEMY.value,
|
||||||
|
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
|
||||||
|
"auto_closed": True,
|
||||||
|
"winners": winners_data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(event_end_activity)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Check for returned assignments and activate the oldest one
|
||||||
|
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
|
||||||
|
if returned_assignment:
|
||||||
|
await activate_returned_assignment(db, returned_assignment)
|
||||||
|
await db.commit()
|
||||||
|
print(f"[WHEEL] Auto-activated returned assignment {returned_assignment.id} for participant {participant.id}")
|
||||||
|
|
||||||
return CompleteResult(
|
return CompleteResult(
|
||||||
points_earned=total_points,
|
points_earned=total_points,
|
||||||
streak_bonus=streak_bonus,
|
streak_bonus=streak_bonus,
|
||||||
@@ -314,9 +490,13 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
|||||||
raise HTTPException(status_code=400, detail="Assignment is not active")
|
raise HTTPException(status_code=400, detail="Assignment is not active")
|
||||||
|
|
||||||
participant = assignment.participant
|
participant = assignment.participant
|
||||||
|
marathon_id = assignment.challenge.game.marathon_id
|
||||||
|
|
||||||
# Calculate penalty
|
# Check active event for free drops (double_risk)
|
||||||
penalty = points_service.calculate_drop_penalty(participant.drop_count)
|
active_event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
|
||||||
|
# Calculate penalty (0 if double_risk event is active)
|
||||||
|
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.DROPPED.value
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
@@ -328,14 +508,22 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
|||||||
participant.drop_count += 1
|
participant.drop_count += 1
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
|
activity_data = {
|
||||||
|
"game": assignment.challenge.game.title,
|
||||||
|
"challenge": assignment.challenge.title,
|
||||||
|
"difficulty": assignment.challenge.difficulty,
|
||||||
|
"penalty": penalty,
|
||||||
|
}
|
||||||
|
if active_event:
|
||||||
|
activity_data["event_type"] = active_event.type
|
||||||
|
if active_event.type == EventType.DOUBLE_RISK.value:
|
||||||
|
activity_data["free_drop"] = True
|
||||||
|
|
||||||
activity = Activity(
|
activity = Activity(
|
||||||
marathon_id=assignment.challenge.game.marathon_id,
|
marathon_id=marathon_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
type=ActivityType.DROP.value,
|
type=ActivityType.DROP.value,
|
||||||
data={
|
data=activity_data,
|
||||||
"challenge": assignment.challenge.title,
|
|
||||||
"penalty": penalty,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
db.add(activity)
|
db.add(activity)
|
||||||
|
|
||||||
@@ -393,7 +581,7 @@ async def get_my_history(
|
|||||||
created_at=a.challenge.created_at,
|
created_at=a.challenge.created_at,
|
||||||
),
|
),
|
||||||
status=a.status,
|
status=a.status,
|
||||||
proof_url=f"/uploads/proofs/{a.proof_path.split('/')[-1]}" if a.proof_path else a.proof_url,
|
proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
|
||||||
proof_comment=a.proof_comment,
|
proof_comment=a.proof_comment,
|
||||||
points_earned=a.points_earned,
|
points_earned=a.points_earned,
|
||||||
streak_at_completion=a.streak_at_completion,
|
streak_at_completion=a.streak_at_completion,
|
||||||
|
|||||||
@@ -20,13 +20,30 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Telegram
|
# Telegram
|
||||||
TELEGRAM_BOT_TOKEN: str = ""
|
TELEGRAM_BOT_TOKEN: str = ""
|
||||||
|
TELEGRAM_BOT_USERNAME: str = ""
|
||||||
|
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
|
||||||
|
BOT_API_SECRET: str = "" # Secret key for bot-to-backend communication
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
FRONTEND_URL: str = "http://localhost:3000"
|
||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
UPLOAD_DIR: str = "uploads"
|
UPLOAD_DIR: str = "uploads"
|
||||||
MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 MB
|
MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024 # 5 MB for avatars
|
||||||
|
MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB
|
||||||
|
MAX_VIDEO_SIZE: int = 30 * 1024 * 1024 # 30 MB
|
||||||
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}
|
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}
|
||||||
ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"}
|
ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"}
|
||||||
|
|
||||||
|
# S3 Storage (FirstVDS)
|
||||||
|
S3_ENABLED: bool = False
|
||||||
|
S3_BUCKET_NAME: str = ""
|
||||||
|
S3_REGION: str = "ru-1"
|
||||||
|
S3_ACCESS_KEY_ID: str = ""
|
||||||
|
S3_SECRET_ACCESS_KEY: str = ""
|
||||||
|
S3_ENDPOINT_URL: str = ""
|
||||||
|
S3_PUBLIC_URL: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ALLOWED_EXTENSIONS(self) -> set:
|
def ALLOWED_EXTENSIONS(self) -> set:
|
||||||
return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS
|
return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS
|
||||||
|
|||||||
5
backend/app/core/rate_limit.py
Normal file
5
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
# Rate limiter using client IP address as key
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -35,3 +40,71 @@ def decode_access_token(token: str) -> dict | None:
|
|||||||
return payload
|
return payload
|
||||||
except jwt.JWTError:
|
except jwt.JWTError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_telegram_link_token(user_id: int, expire_minutes: int = 10) -> str:
|
||||||
|
"""
|
||||||
|
Create a short token for Telegram account linking.
|
||||||
|
Format: base64url encoded binary data (no separators).
|
||||||
|
Structure: user_id (4 bytes) + expire_at (4 bytes) + signature (8 bytes) = 16 bytes -> 22 chars base64url.
|
||||||
|
"""
|
||||||
|
expire_at = int(time.time()) + (expire_minutes * 60)
|
||||||
|
|
||||||
|
# Pack user_id and expire_at as unsigned 32-bit integers (8 bytes total)
|
||||||
|
data = struct.pack(">II", user_id, expire_at)
|
||||||
|
|
||||||
|
# Create HMAC signature (take first 8 bytes)
|
||||||
|
signature = hmac.new(
|
||||||
|
settings.SECRET_KEY.encode(),
|
||||||
|
data,
|
||||||
|
hashlib.sha256
|
||||||
|
).digest()[:8]
|
||||||
|
|
||||||
|
# Combine data + signature (16 bytes)
|
||||||
|
token_bytes = data + signature
|
||||||
|
|
||||||
|
# Encode as base64url without padding
|
||||||
|
token = base64.urlsafe_b64encode(token_bytes).decode().rstrip("=")
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def verify_telegram_link_token(token: str) -> int | None:
|
||||||
|
"""
|
||||||
|
Verify Telegram link token and return user_id if valid.
|
||||||
|
Returns None if token is invalid or expired.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Add padding if needed for base64 decoding
|
||||||
|
padding = 4 - (len(token) % 4)
|
||||||
|
if padding != 4:
|
||||||
|
token += "=" * padding
|
||||||
|
|
||||||
|
token_bytes = base64.urlsafe_b64decode(token)
|
||||||
|
|
||||||
|
if len(token_bytes) != 16:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Unpack data
|
||||||
|
data = token_bytes[:8]
|
||||||
|
provided_signature = token_bytes[8:]
|
||||||
|
|
||||||
|
user_id, expire_at = struct.unpack(">II", data)
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
if time.time() > expire_at:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify signature
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
settings.SECRET_KEY.encode(),
|
||||||
|
data,
|
||||||
|
hashlib.sha256
|
||||||
|
).digest()[:8]
|
||||||
|
|
||||||
|
if not hmac.compare_digest(provided_signature, expected_signature):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return user_id
|
||||||
|
except (ValueError, struct.error, Exception):
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from slowapi import _rate_limit_exceeded_handler
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import engine, Base
|
from app.core.database import engine, Base, async_session_maker
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
from app.api.v1 import router as api_router
|
from app.api.v1 import router as api_router
|
||||||
|
from app.services.event_scheduler import event_scheduler
|
||||||
|
from app.services.dispute_scheduler import dispute_scheduler
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -22,9 +35,15 @@ async def lifespan(app: FastAPI):
|
|||||||
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
|
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
|
||||||
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
|
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Start schedulers
|
||||||
|
await event_scheduler.start(async_session_maker)
|
||||||
|
await dispute_scheduler.start(async_session_maker)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
|
await event_scheduler.stop()
|
||||||
|
await dispute_scheduler.stop()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +53,10 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
from app.models.user import User
|
from app.models.user import User, UserRole
|
||||||
from app.models.marathon import Marathon, MarathonStatus
|
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
|
||||||
from app.models.participant import Participant
|
from app.models.participant import Participant, ParticipantRole
|
||||||
from app.models.game import Game
|
from app.models.game import Game, GameStatus
|
||||||
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.activity import Activity, ActivityType
|
from app.models.activity import Activity, ActivityType
|
||||||
|
from app.models.event import Event, EventType
|
||||||
|
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||||
|
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
|
||||||
|
from app.models.admin_log import AdminLog, AdminActionType
|
||||||
|
from app.models.admin_2fa import Admin2FASession
|
||||||
|
from app.models.static_content import StaticContent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
|
"UserRole",
|
||||||
"Marathon",
|
"Marathon",
|
||||||
"MarathonStatus",
|
"MarathonStatus",
|
||||||
|
"GameProposalMode",
|
||||||
"Participant",
|
"Participant",
|
||||||
|
"ParticipantRole",
|
||||||
"Game",
|
"Game",
|
||||||
|
"GameStatus",
|
||||||
"Challenge",
|
"Challenge",
|
||||||
"ChallengeType",
|
"ChallengeType",
|
||||||
"Difficulty",
|
"Difficulty",
|
||||||
@@ -20,4 +30,16 @@ __all__ = [
|
|||||||
"AssignmentStatus",
|
"AssignmentStatus",
|
||||||
"Activity",
|
"Activity",
|
||||||
"ActivityType",
|
"ActivityType",
|
||||||
|
"Event",
|
||||||
|
"EventType",
|
||||||
|
"SwapRequest",
|
||||||
|
"SwapRequestStatus",
|
||||||
|
"Dispute",
|
||||||
|
"DisputeStatus",
|
||||||
|
"DisputeComment",
|
||||||
|
"DisputeVote",
|
||||||
|
"AdminLog",
|
||||||
|
"AdminActionType",
|
||||||
|
"Admin2FASession",
|
||||||
|
"StaticContent",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ class ActivityType(str, Enum):
|
|||||||
DROP = "drop"
|
DROP = "drop"
|
||||||
START_MARATHON = "start_marathon"
|
START_MARATHON = "start_marathon"
|
||||||
FINISH_MARATHON = "finish_marathon"
|
FINISH_MARATHON = "finish_marathon"
|
||||||
|
ADD_GAME = "add_game"
|
||||||
|
APPROVE_GAME = "approve_game"
|
||||||
|
REJECT_GAME = "reject_game"
|
||||||
|
EVENT_START = "event_start"
|
||||||
|
EVENT_END = "event_end"
|
||||||
|
SWAP = "swap"
|
||||||
|
GAME_CHOICE = "game_choice"
|
||||||
|
|
||||||
|
|
||||||
class Activity(Base):
|
class Activity(Base):
|
||||||
|
|||||||
20
backend/app/models/admin_2fa.py
Normal file
20
backend/app/models/admin_2fa.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Admin2FASession(Base):
|
||||||
|
__tablename__ = "admin_2fa_sessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(6), nullable=False)
|
||||||
|
telegram_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
||||||
46
backend/app/models/admin_log.py
Normal file
46
backend/app/models/admin_log.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AdminActionType(str, Enum):
|
||||||
|
# User actions
|
||||||
|
USER_BAN = "user_ban"
|
||||||
|
USER_UNBAN = "user_unban"
|
||||||
|
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
|
||||||
|
USER_ROLE_CHANGE = "user_role_change"
|
||||||
|
|
||||||
|
# Marathon actions
|
||||||
|
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
||||||
|
MARATHON_DELETE = "marathon_delete"
|
||||||
|
|
||||||
|
# Content actions
|
||||||
|
CONTENT_UPDATE = "content_update"
|
||||||
|
|
||||||
|
# Broadcast actions
|
||||||
|
BROADCAST_ALL = "broadcast_all"
|
||||||
|
BROADCAST_MARATHON = "broadcast_marathon"
|
||||||
|
|
||||||
|
# Auth actions
|
||||||
|
ADMIN_LOGIN = "admin_login"
|
||||||
|
ADMIN_2FA_SUCCESS = "admin_2fa_success"
|
||||||
|
ADMIN_2FA_FAIL = "admin_2fa_fail"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLog(Base):
|
||||||
|
__tablename__ = "admin_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
admin_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True) # Nullable for system actions
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
target_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
admin: Mapped["User"] = relationship("User", foreign_keys=[admin_id])
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -10,6 +10,7 @@ class AssignmentStatus(str, Enum):
|
|||||||
ACTIVE = "active"
|
ACTIVE = "active"
|
||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
DROPPED = "dropped"
|
DROPPED = "dropped"
|
||||||
|
RETURNED = "returned" # Disputed and needs to be redone
|
||||||
|
|
||||||
|
|
||||||
class Assignment(Base):
|
class Assignment(Base):
|
||||||
@@ -19,6 +20,9 @@ class Assignment(Base):
|
|||||||
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] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
|
||||||
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
|
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
|
||||||
|
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
|
||||||
|
is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments
|
||||||
|
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
|
||||||
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
@@ -30,3 +34,5 @@ class Assignment(Base):
|
|||||||
# Relationships
|
# 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"] = relationship("Challenge", back_populates="assignments")
|
||||||
|
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
|
||||||
|
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True)
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ class ProofType(str, Enum):
|
|||||||
STEAM = "steam"
|
STEAM = "steam"
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
class Challenge(Base):
|
class Challenge(Base):
|
||||||
__tablename__ = "challenges"
|
__tablename__ = "challenges"
|
||||||
|
|
||||||
@@ -45,8 +51,13 @@ class Challenge(Base):
|
|||||||
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Proposed challenges support
|
||||||
|
proposed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="approved") # pending, approved, rejected
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
||||||
|
proposed_by: Mapped["User"] = relationship("User", foreign_keys=[proposed_by_id])
|
||||||
assignments: Mapped[list["Assignment"]] = relationship(
|
assignments: Mapped[list["Assignment"]] = relationship(
|
||||||
"Assignment",
|
"Assignment",
|
||||||
back_populates="challenge"
|
back_populates="challenge"
|
||||||
|
|||||||
66
backend/app/models/dispute.py
Normal file
66
backend/app/models/dispute.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeStatus(str, Enum):
|
||||||
|
OPEN = "open"
|
||||||
|
RESOLVED_VALID = "valid"
|
||||||
|
RESOLVED_INVALID = "invalid"
|
||||||
|
|
||||||
|
|
||||||
|
class Dispute(Base):
|
||||||
|
"""Dispute against a completed assignment's proof"""
|
||||||
|
__tablename__ = "disputes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True)
|
||||||
|
raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute")
|
||||||
|
raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id])
|
||||||
|
comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan")
|
||||||
|
votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeComment(Base):
|
||||||
|
"""Comment in a dispute discussion"""
|
||||||
|
__tablename__ = "dispute_comments"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="comments")
|
||||||
|
user: Mapped["User"] = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeVote(Base):
|
||||||
|
"""Vote in a dispute (valid or invalid)"""
|
||||||
|
__tablename__ = "dispute_votes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
vote: Mapped[bool] = mapped_column(Boolean, nullable=False) # True = valid, False = invalid
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Unique constraint: one vote per user per dispute
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("dispute_id", "user_id", name="uq_dispute_vote_user"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="votes")
|
||||||
|
user: Mapped["User"] = relationship("User")
|
||||||
40
backend/app/models/event.py
Normal file
40
backend/app/models/event.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, JSON, Boolean
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(str, Enum):
|
||||||
|
GOLDEN_HOUR = "golden_hour" # x1.5 очков
|
||||||
|
COMMON_ENEMY = "common_enemy" # общий челлендж для всех
|
||||||
|
DOUBLE_RISK = "double_risk" # дропы бесплатны, x0.5 очков
|
||||||
|
JACKPOT = "jackpot" # x3 за сложный челлендж
|
||||||
|
SWAP = "swap" # обмен заданиями
|
||||||
|
GAME_CHOICE = "game_choice" # выбор игры (2-3 челленджа на выбор)
|
||||||
|
|
||||||
|
|
||||||
|
class Event(Base):
|
||||||
|
__tablename__ = "events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
marathon_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("marathons.id", ondelete="CASCADE"),
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
start_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
end_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_by_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events")
|
||||||
|
created_by: Mapped["User | None"] = relationship("User")
|
||||||
|
assignments: Mapped[list["Assignment"]] = relationship("Assignment", back_populates="event")
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from sqlalchemy import String, DateTime, ForeignKey, Text
|
from sqlalchemy import String, DateTime, ForeignKey, Text
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
class GameStatus(str, Enum):
|
||||||
|
PENDING = "pending" # Предложена участником, ждёт модерации
|
||||||
|
APPROVED = "approved" # Одобрена организатором
|
||||||
|
REJECTED = "rejected" # Отклонена
|
||||||
|
|
||||||
|
|
||||||
class Game(Base):
|
class Game(Base):
|
||||||
__tablename__ = "games"
|
__tablename__ = "games"
|
||||||
|
|
||||||
@@ -14,14 +21,33 @@ class Game(Base):
|
|||||||
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
download_url: Mapped[str] = mapped_column(Text, nullable=False)
|
download_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
genre: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
genre: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
added_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
status: Mapped[str] = mapped_column(String(20), default=GameStatus.PENDING.value)
|
||||||
|
proposed_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
|
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
|
||||||
added_by_user: Mapped["User"] = relationship("User", back_populates="added_games")
|
proposed_by: Mapped["User"] = relationship(
|
||||||
|
"User",
|
||||||
|
back_populates="proposed_games",
|
||||||
|
foreign_keys=[proposed_by_id]
|
||||||
|
)
|
||||||
|
approved_by: Mapped["User | None"] = relationship(
|
||||||
|
"User",
|
||||||
|
back_populates="approved_games",
|
||||||
|
foreign_keys=[approved_by_id]
|
||||||
|
)
|
||||||
challenges: Mapped[list["Challenge"]] = relationship(
|
challenges: Mapped[list["Challenge"]] = relationship(
|
||||||
"Challenge",
|
"Challenge",
|
||||||
back_populates="game",
|
back_populates="game",
|
||||||
cascade="all, delete-orphan"
|
cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_approved(self) -> bool:
|
||||||
|
return self.status == GameStatus.APPROVED.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_pending(self) -> bool:
|
||||||
|
return self.status == GameStatus.PENDING.value
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -12,24 +12,32 @@ class MarathonStatus(str, Enum):
|
|||||||
FINISHED = "finished"
|
FINISHED = "finished"
|
||||||
|
|
||||||
|
|
||||||
|
class GameProposalMode(str, Enum):
|
||||||
|
ALL_PARTICIPANTS = "all_participants"
|
||||||
|
ORGANIZER_ONLY = "organizer_only"
|
||||||
|
|
||||||
|
|
||||||
class Marathon(Base):
|
class Marathon(Base):
|
||||||
__tablename__ = "marathons"
|
__tablename__ = "marathons"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
organizer_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
creator_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
status: Mapped[str] = mapped_column(String(20), default=MarathonStatus.PREPARING.value)
|
status: Mapped[str] = mapped_column(String(20), default=MarathonStatus.PREPARING.value)
|
||||||
invite_code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
|
invite_code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
|
||||||
|
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
game_proposal_mode: Mapped[str] = mapped_column(String(20), default=GameProposalMode.ALL_PARTICIPANTS.value)
|
||||||
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
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)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
organizer: Mapped["User"] = relationship(
|
creator: Mapped["User"] = relationship(
|
||||||
"User",
|
"User",
|
||||||
back_populates="organized_marathons",
|
back_populates="created_marathons",
|
||||||
foreign_keys=[organizer_id]
|
foreign_keys=[creator_id]
|
||||||
)
|
)
|
||||||
participants: Mapped[list["Participant"]] = relationship(
|
participants: Mapped[list["Participant"]] = relationship(
|
||||||
"Participant",
|
"Participant",
|
||||||
@@ -46,3 +54,8 @@ class Marathon(Base):
|
|||||||
back_populates="marathon",
|
back_populates="marathon",
|
||||||
cascade="all, delete-orphan"
|
cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
events: Mapped[list["Event"]] = relationship(
|
||||||
|
"Event",
|
||||||
|
back_populates="marathon",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint
|
from enum import Enum
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantRole(str, Enum):
|
||||||
|
PARTICIPANT = "participant"
|
||||||
|
ORGANIZER = "organizer"
|
||||||
|
|
||||||
|
|
||||||
class Participant(Base):
|
class Participant(Base):
|
||||||
__tablename__ = "participants"
|
__tablename__ = "participants"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -14,6 +20,7 @@ class Participant(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True)
|
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), default=ParticipantRole.PARTICIPANT.value)
|
||||||
total_points: Mapped[int] = mapped_column(Integer, default=0)
|
total_points: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
current_streak: Mapped[int] = mapped_column(Integer, default=0)
|
current_streak: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
|
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
|
||||||
@@ -27,3 +34,7 @@ class Participant(Base):
|
|||||||
back_populates="participant",
|
back_populates="participant",
|
||||||
cascade="all, delete-orphan"
|
cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_organizer(self) -> bool:
|
||||||
|
return self.role == ParticipantRole.ORGANIZER.value
|
||||||
|
|||||||
20
backend/app/models/static_content.py
Normal file
20
backend/app/models/static_content.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContent(Base):
|
||||||
|
__tablename__ = "static_content"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
updated_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
updated_by: Mapped["User | None"] = relationship("User", foreign_keys=[updated_by_id])
|
||||||
62
backend/app/models/swap_request.py
Normal file
62
backend/app/models/swap_request.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SwapRequestStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
ACCEPTED = "accepted"
|
||||||
|
DECLINED = "declined"
|
||||||
|
CANCELLED = "cancelled" # Cancelled by requester or event ended
|
||||||
|
|
||||||
|
|
||||||
|
class SwapRequest(Base):
|
||||||
|
__tablename__ = "swap_requests"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
event_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("events.id", ondelete="CASCADE"),
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
from_participant_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("participants.id", ondelete="CASCADE"),
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
to_participant_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("participants.id", ondelete="CASCADE"),
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
from_assignment_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("assignments.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
to_assignment_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("assignments.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
default=SwapRequestStatus.PENDING.value
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
responded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
event: Mapped["Event"] = relationship("Event")
|
||||||
|
from_participant: Mapped["Participant"] = relationship(
|
||||||
|
"Participant",
|
||||||
|
foreign_keys=[from_participant_id]
|
||||||
|
)
|
||||||
|
to_participant: Mapped["Participant"] = relationship(
|
||||||
|
"Participant",
|
||||||
|
foreign_keys=[to_participant_id]
|
||||||
|
)
|
||||||
|
from_assignment: Mapped["Assignment"] = relationship(
|
||||||
|
"Assignment",
|
||||||
|
foreign_keys=[from_assignment_id]
|
||||||
|
)
|
||||||
|
to_assignment: Mapped["Assignment"] = relationship(
|
||||||
|
"Assignment",
|
||||||
|
foreign_keys=[to_assignment_id]
|
||||||
|
)
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import String, BigInteger, DateTime
|
from enum import Enum
|
||||||
|
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 app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, Enum):
|
||||||
|
USER = "user"
|
||||||
|
ADMIN = "admin"
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -15,19 +21,53 @@ class User(Base):
|
|||||||
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
|
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
|
||||||
telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
telegram_first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
telegram_last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
telegram_avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Ban fields
|
||||||
|
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
banned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
banned_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = permanent
|
||||||
|
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
organized_marathons: Mapped[list["Marathon"]] = relationship(
|
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||||
"Marathon",
|
"Marathon",
|
||||||
back_populates="organizer",
|
back_populates="creator",
|
||||||
foreign_keys="Marathon.organizer_id"
|
foreign_keys="Marathon.creator_id"
|
||||||
)
|
)
|
||||||
participations: Mapped[list["Participant"]] = relationship(
|
participations: Mapped[list["Participant"]] = relationship(
|
||||||
"Participant",
|
"Participant",
|
||||||
back_populates="user"
|
back_populates="user"
|
||||||
)
|
)
|
||||||
added_games: Mapped[list["Game"]] = relationship(
|
proposed_games: Mapped[list["Game"]] = relationship(
|
||||||
"Game",
|
"Game",
|
||||||
back_populates="added_by_user"
|
back_populates="proposed_by",
|
||||||
|
foreign_keys="Game.proposed_by_id"
|
||||||
)
|
)
|
||||||
|
approved_games: Mapped[list["Game"]] = relationship(
|
||||||
|
"Game",
|
||||||
|
back_populates="approved_by",
|
||||||
|
foreign_keys="Game.approved_by_id"
|
||||||
|
)
|
||||||
|
banned_by: Mapped["User | None"] = relationship(
|
||||||
|
"User",
|
||||||
|
remote_side="User.id",
|
||||||
|
foreign_keys=[banned_by_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
return self.role == UserRole.ADMIN.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def avatar_url(self) -> str | None:
|
||||||
|
if self.avatar_path:
|
||||||
|
# Lazy import to avoid circular dependency
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
return storage_service.get_url(self.avatar_path, "avatars")
|
||||||
|
return None
|
||||||
|
|||||||
@@ -3,19 +3,24 @@ from app.schemas.user import (
|
|||||||
UserLogin,
|
UserLogin,
|
||||||
UserUpdate,
|
UserUpdate,
|
||||||
UserPublic,
|
UserPublic,
|
||||||
UserWithTelegram,
|
UserPrivate,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
TelegramLink,
|
TelegramLink,
|
||||||
|
PasswordChange,
|
||||||
|
UserStats,
|
||||||
|
UserProfilePublic,
|
||||||
)
|
)
|
||||||
from app.schemas.marathon import (
|
from app.schemas.marathon import (
|
||||||
MarathonCreate,
|
MarathonCreate,
|
||||||
MarathonUpdate,
|
MarathonUpdate,
|
||||||
MarathonResponse,
|
MarathonResponse,
|
||||||
MarathonListItem,
|
MarathonListItem,
|
||||||
|
MarathonPublicInfo,
|
||||||
ParticipantInfo,
|
ParticipantInfo,
|
||||||
ParticipantWithUser,
|
ParticipantWithUser,
|
||||||
JoinMarathon,
|
JoinMarathon,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
|
SetParticipantRole,
|
||||||
)
|
)
|
||||||
from app.schemas.game import (
|
from app.schemas.game import (
|
||||||
GameCreate,
|
GameCreate,
|
||||||
@@ -28,6 +33,11 @@ from app.schemas.challenge import (
|
|||||||
ChallengeUpdate,
|
ChallengeUpdate,
|
||||||
ChallengeResponse,
|
ChallengeResponse,
|
||||||
ChallengeGenerated,
|
ChallengeGenerated,
|
||||||
|
ChallengePreview,
|
||||||
|
ChallengesPreviewResponse,
|
||||||
|
ChallengeSaveItem,
|
||||||
|
ChallengesSaveRequest,
|
||||||
|
ChallengesGenerateRequest,
|
||||||
)
|
)
|
||||||
from app.schemas.assignment import (
|
from app.schemas.assignment import (
|
||||||
CompleteAssignment,
|
CompleteAssignment,
|
||||||
@@ -35,16 +45,58 @@ from app.schemas.assignment import (
|
|||||||
SpinResult,
|
SpinResult,
|
||||||
CompleteResult,
|
CompleteResult,
|
||||||
DropResult,
|
DropResult,
|
||||||
|
EventAssignmentResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.activity import (
|
from app.schemas.activity import (
|
||||||
ActivityResponse,
|
ActivityResponse,
|
||||||
FeedResponse,
|
FeedResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.event import (
|
||||||
|
EventCreate,
|
||||||
|
EventResponse,
|
||||||
|
EventEffects,
|
||||||
|
ActiveEventResponse,
|
||||||
|
SwapRequest,
|
||||||
|
SwapCandidate,
|
||||||
|
CommonEnemyLeaderboard,
|
||||||
|
EVENT_INFO,
|
||||||
|
COMMON_ENEMY_BONUSES,
|
||||||
|
SwapRequestCreate,
|
||||||
|
SwapRequestResponse,
|
||||||
|
SwapRequestChallengeInfo,
|
||||||
|
MySwapRequests,
|
||||||
|
)
|
||||||
from app.schemas.common import (
|
from app.schemas.common import (
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
PaginationParams,
|
PaginationParams,
|
||||||
)
|
)
|
||||||
|
from app.schemas.dispute import (
|
||||||
|
DisputeCreate,
|
||||||
|
DisputeCommentCreate,
|
||||||
|
DisputeVoteCreate,
|
||||||
|
DisputeCommentResponse,
|
||||||
|
DisputeVoteResponse,
|
||||||
|
DisputeResponse,
|
||||||
|
AssignmentDetailResponse,
|
||||||
|
ReturnedAssignmentResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.admin import (
|
||||||
|
BanUserRequest,
|
||||||
|
AdminUserResponse,
|
||||||
|
AdminLogResponse,
|
||||||
|
AdminLogsListResponse,
|
||||||
|
BroadcastRequest,
|
||||||
|
BroadcastResponse,
|
||||||
|
StaticContentResponse,
|
||||||
|
StaticContentUpdate,
|
||||||
|
StaticContentCreate,
|
||||||
|
TwoFactorInitiateRequest,
|
||||||
|
TwoFactorInitiateResponse,
|
||||||
|
TwoFactorVerifyRequest,
|
||||||
|
LoginResponse,
|
||||||
|
DashboardStats,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# User
|
# User
|
||||||
@@ -52,18 +104,23 @@ __all__ = [
|
|||||||
"UserLogin",
|
"UserLogin",
|
||||||
"UserUpdate",
|
"UserUpdate",
|
||||||
"UserPublic",
|
"UserPublic",
|
||||||
"UserWithTelegram",
|
"UserPrivate",
|
||||||
"TokenResponse",
|
"TokenResponse",
|
||||||
"TelegramLink",
|
"TelegramLink",
|
||||||
|
"PasswordChange",
|
||||||
|
"UserStats",
|
||||||
|
"UserProfilePublic",
|
||||||
# Marathon
|
# Marathon
|
||||||
"MarathonCreate",
|
"MarathonCreate",
|
||||||
"MarathonUpdate",
|
"MarathonUpdate",
|
||||||
"MarathonResponse",
|
"MarathonResponse",
|
||||||
"MarathonListItem",
|
"MarathonListItem",
|
||||||
|
"MarathonPublicInfo",
|
||||||
"ParticipantInfo",
|
"ParticipantInfo",
|
||||||
"ParticipantWithUser",
|
"ParticipantWithUser",
|
||||||
"JoinMarathon",
|
"JoinMarathon",
|
||||||
"LeaderboardEntry",
|
"LeaderboardEntry",
|
||||||
|
"SetParticipantRole",
|
||||||
# Game
|
# Game
|
||||||
"GameCreate",
|
"GameCreate",
|
||||||
"GameUpdate",
|
"GameUpdate",
|
||||||
@@ -74,17 +131,61 @@ __all__ = [
|
|||||||
"ChallengeUpdate",
|
"ChallengeUpdate",
|
||||||
"ChallengeResponse",
|
"ChallengeResponse",
|
||||||
"ChallengeGenerated",
|
"ChallengeGenerated",
|
||||||
|
"ChallengePreview",
|
||||||
|
"ChallengesPreviewResponse",
|
||||||
|
"ChallengeSaveItem",
|
||||||
|
"ChallengesSaveRequest",
|
||||||
|
"ChallengesGenerateRequest",
|
||||||
# Assignment
|
# Assignment
|
||||||
"CompleteAssignment",
|
"CompleteAssignment",
|
||||||
"AssignmentResponse",
|
"AssignmentResponse",
|
||||||
"SpinResult",
|
"SpinResult",
|
||||||
"CompleteResult",
|
"CompleteResult",
|
||||||
"DropResult",
|
"DropResult",
|
||||||
|
"EventAssignmentResponse",
|
||||||
# Activity
|
# Activity
|
||||||
"ActivityResponse",
|
"ActivityResponse",
|
||||||
"FeedResponse",
|
"FeedResponse",
|
||||||
|
# Event
|
||||||
|
"EventCreate",
|
||||||
|
"EventResponse",
|
||||||
|
"EventEffects",
|
||||||
|
"ActiveEventResponse",
|
||||||
|
"SwapRequest",
|
||||||
|
"SwapCandidate",
|
||||||
|
"CommonEnemyLeaderboard",
|
||||||
|
"EVENT_INFO",
|
||||||
|
"COMMON_ENEMY_BONUSES",
|
||||||
|
"SwapRequestCreate",
|
||||||
|
"SwapRequestResponse",
|
||||||
|
"SwapRequestChallengeInfo",
|
||||||
|
"MySwapRequests",
|
||||||
# Common
|
# Common
|
||||||
"MessageResponse",
|
"MessageResponse",
|
||||||
"ErrorResponse",
|
"ErrorResponse",
|
||||||
"PaginationParams",
|
"PaginationParams",
|
||||||
|
# Dispute
|
||||||
|
"DisputeCreate",
|
||||||
|
"DisputeCommentCreate",
|
||||||
|
"DisputeVoteCreate",
|
||||||
|
"DisputeCommentResponse",
|
||||||
|
"DisputeVoteResponse",
|
||||||
|
"DisputeResponse",
|
||||||
|
"AssignmentDetailResponse",
|
||||||
|
"ReturnedAssignmentResponse",
|
||||||
|
# Admin
|
||||||
|
"BanUserRequest",
|
||||||
|
"AdminUserResponse",
|
||||||
|
"AdminLogResponse",
|
||||||
|
"AdminLogsListResponse",
|
||||||
|
"BroadcastRequest",
|
||||||
|
"BroadcastResponse",
|
||||||
|
"StaticContentResponse",
|
||||||
|
"StaticContentUpdate",
|
||||||
|
"StaticContentCreate",
|
||||||
|
"TwoFactorInitiateRequest",
|
||||||
|
"TwoFactorInitiateResponse",
|
||||||
|
"TwoFactorVerifyRequest",
|
||||||
|
"LoginResponse",
|
||||||
|
"DashboardStats",
|
||||||
]
|
]
|
||||||
|
|||||||
119
backend/app/schemas/admin.py
Normal file
119
backend/app/schemas/admin.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# ============ User Ban ============
|
||||||
|
class BanUserRequest(BaseModel):
|
||||||
|
reason: str = Field(..., min_length=1, max_length=500)
|
||||||
|
banned_until: datetime | None = None # None = permanent ban
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
login: str
|
||||||
|
nickname: str
|
||||||
|
role: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
telegram_id: int | None = None
|
||||||
|
telegram_username: str | None = None
|
||||||
|
marathons_count: int = 0
|
||||||
|
created_at: str
|
||||||
|
is_banned: bool = False
|
||||||
|
banned_at: str | None = None
|
||||||
|
banned_until: str | None = None # None = permanent
|
||||||
|
ban_reason: str | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Admin Logs ============
|
||||||
|
class AdminLogResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
admin_id: int | None = None # Nullable for system actions
|
||||||
|
admin_nickname: str | None = None # Nullable for system actions
|
||||||
|
action: str
|
||||||
|
target_type: str
|
||||||
|
target_id: int
|
||||||
|
details: dict | None = None
|
||||||
|
ip_address: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLogsListResponse(BaseModel):
|
||||||
|
logs: list[AdminLogResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Broadcast ============
|
||||||
|
class BroadcastRequest(BaseModel):
|
||||||
|
message: str = Field(..., min_length=1, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastResponse(BaseModel):
|
||||||
|
sent_count: int
|
||||||
|
total_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Static Content ============
|
||||||
|
class StaticContentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
updated_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContentUpdate(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContentCreate(BaseModel):
|
||||||
|
key: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-z0-9_-]+$")
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 2FA ============
|
||||||
|
class TwoFactorInitiateRequest(BaseModel):
|
||||||
|
pass # No additional data needed
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorInitiateResponse(BaseModel):
|
||||||
|
session_id: int
|
||||||
|
expires_at: datetime
|
||||||
|
message: str = "Code sent to Telegram"
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorVerifyRequest(BaseModel):
|
||||||
|
session_id: int
|
||||||
|
code: str = Field(..., min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
"""Login response that may require 2FA"""
|
||||||
|
access_token: str | None = None
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: Any = None # UserPrivate
|
||||||
|
requires_2fa: bool = False
|
||||||
|
two_factor_session_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Dashboard Stats ============
|
||||||
|
class DashboardStats(BaseModel):
|
||||||
|
users_count: int
|
||||||
|
banned_users_count: int
|
||||||
|
marathons_count: int
|
||||||
|
active_marathons_count: int
|
||||||
|
games_count: int
|
||||||
|
total_participations: int
|
||||||
|
recent_logs: list[AdminLogResponse] = []
|
||||||
@@ -24,6 +24,7 @@ class AssignmentResponse(BaseModel):
|
|||||||
streak_at_completion: int | None = None
|
streak_at_completion: int | None = None
|
||||||
started_at: datetime
|
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
|
||||||
@@ -48,3 +49,14 @@ class DropResult(BaseModel):
|
|||||||
penalty: int
|
penalty: int
|
||||||
total_points: int
|
total_points: int
|
||||||
new_drop_count: int
|
new_drop_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class EventAssignmentResponse(BaseModel):
|
||||||
|
"""Response for event-specific assignment (Common Enemy)"""
|
||||||
|
assignment: AssignmentResponse | None
|
||||||
|
event_id: int | None
|
||||||
|
challenge_id: int | None
|
||||||
|
is_completed: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.models.challenge import ChallengeType, Difficulty, ProofType
|
from app.models.challenge import ChallengeType, Difficulty, ProofType, ChallengeStatus
|
||||||
from app.schemas.game import GameShort
|
from app.schemas.game import GameShort
|
||||||
|
|
||||||
|
|
||||||
|
class ProposedByUser(BaseModel):
|
||||||
|
"""Minimal user info for proposed challenges"""
|
||||||
|
id: int
|
||||||
|
nickname: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class ChallengeBase(BaseModel):
|
class ChallengeBase(BaseModel):
|
||||||
title: str = Field(..., min_length=1, max_length=100)
|
title: str = Field(..., min_length=1, max_length=100)
|
||||||
description: str = Field(..., min_length=1)
|
description: str = Field(..., min_length=1)
|
||||||
@@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase):
|
|||||||
game: GameShort
|
game: GameShort
|
||||||
is_generated: bool
|
is_generated: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
status: str = "approved"
|
||||||
|
proposed_by: ProposedByUser | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengePropose(ChallengeBase):
|
||||||
|
"""Schema for proposing a challenge by a participant"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ChallengeGenerated(BaseModel):
|
class ChallengeGenerated(BaseModel):
|
||||||
"""Schema for GPT-generated challenges"""
|
"""Schema for GPT-generated challenges"""
|
||||||
title: str
|
title: str
|
||||||
@@ -51,3 +67,45 @@ class ChallengeGenerated(BaseModel):
|
|||||||
estimated_time: int | None = None
|
estimated_time: int | None = None
|
||||||
proof_type: str
|
proof_type: str
|
||||||
proof_hint: str | None = None
|
proof_hint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengePreview(BaseModel):
|
||||||
|
"""Schema for challenge preview (with game info)"""
|
||||||
|
game_id: int
|
||||||
|
game_title: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
type: str
|
||||||
|
difficulty: str
|
||||||
|
points: int
|
||||||
|
estimated_time: int | None = None
|
||||||
|
proof_type: str
|
||||||
|
proof_hint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengesPreviewResponse(BaseModel):
|
||||||
|
"""Response with generated challenges for preview"""
|
||||||
|
challenges: list[ChallengePreview]
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeSaveItem(BaseModel):
|
||||||
|
"""Single challenge to save"""
|
||||||
|
game_id: int
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
type: str
|
||||||
|
difficulty: str
|
||||||
|
points: int
|
||||||
|
estimated_time: int | None = None
|
||||||
|
proof_type: str
|
||||||
|
proof_hint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengesSaveRequest(BaseModel):
|
||||||
|
"""Request to save previewed challenges"""
|
||||||
|
challenges: list[ChallengeSaveItem]
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengesGenerateRequest(BaseModel):
|
||||||
|
"""Request to generate challenges for specific games"""
|
||||||
|
game_ids: list[int] | None = None # If None, generate for all approved games without challenges
|
||||||
|
|||||||
91
backend/app/schemas/dispute.py
Normal file
91
backend/app/schemas/dispute.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.user import UserPublic
|
||||||
|
from app.schemas.challenge import ChallengeResponse
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeCreate(BaseModel):
|
||||||
|
"""Request to create a dispute"""
|
||||||
|
reason: str = Field(..., min_length=10, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeCommentCreate(BaseModel):
|
||||||
|
"""Request to add a comment to a dispute"""
|
||||||
|
text: str = Field(..., min_length=1, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeVoteCreate(BaseModel):
|
||||||
|
"""Request to vote on a dispute"""
|
||||||
|
vote: bool # True = valid (proof is OK), False = invalid (proof is not OK)
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeCommentResponse(BaseModel):
|
||||||
|
"""Comment in a dispute discussion"""
|
||||||
|
id: int
|
||||||
|
user: UserPublic
|
||||||
|
text: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeVoteResponse(BaseModel):
|
||||||
|
"""Vote in a dispute"""
|
||||||
|
user: UserPublic
|
||||||
|
vote: bool # True = valid, False = invalid
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeResponse(BaseModel):
|
||||||
|
"""Full dispute information"""
|
||||||
|
id: int
|
||||||
|
raised_by: UserPublic
|
||||||
|
reason: str
|
||||||
|
status: str # "open", "valid", "invalid"
|
||||||
|
comments: list[DisputeCommentResponse]
|
||||||
|
votes: list[DisputeVoteResponse]
|
||||||
|
votes_valid: int
|
||||||
|
votes_invalid: int
|
||||||
|
my_vote: bool | None # Current user's vote, None if not voted
|
||||||
|
expires_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
resolved_at: datetime | None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentDetailResponse(BaseModel):
|
||||||
|
"""Detailed assignment information with proofs and dispute"""
|
||||||
|
id: int
|
||||||
|
challenge: ChallengeResponse
|
||||||
|
participant: UserPublic
|
||||||
|
status: str
|
||||||
|
proof_url: str | None # External URL (YouTube, etc.)
|
||||||
|
proof_image_url: str | None # Uploaded file URL
|
||||||
|
proof_comment: str | None
|
||||||
|
points_earned: int
|
||||||
|
streak_at_completion: int | None
|
||||||
|
started_at: datetime
|
||||||
|
completed_at: datetime | None
|
||||||
|
can_dispute: bool # True if <24h since completion and not own assignment
|
||||||
|
dispute: DisputeResponse | None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnedAssignmentResponse(BaseModel):
|
||||||
|
"""Returned assignment that needs to be redone"""
|
||||||
|
id: int
|
||||||
|
challenge: ChallengeResponse
|
||||||
|
original_completed_at: datetime
|
||||||
|
dispute_reason: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
174
backend/app/schemas/event.py
Normal file
174
backend/app/schemas/event.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from app.models.event import EventType
|
||||||
|
from app.schemas.user import UserPublic
|
||||||
|
|
||||||
|
|
||||||
|
# Event type literals for Pydantic
|
||||||
|
EventTypeLiteral = Literal[
|
||||||
|
"golden_hour",
|
||||||
|
"common_enemy",
|
||||||
|
"double_risk",
|
||||||
|
"jackpot",
|
||||||
|
"swap",
|
||||||
|
"game_choice",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EventCreate(BaseModel):
|
||||||
|
type: EventTypeLiteral
|
||||||
|
duration_minutes: int | None = Field(
|
||||||
|
None,
|
||||||
|
description="Duration in minutes. If not provided, uses default for event type."
|
||||||
|
)
|
||||||
|
challenge_id: int | None = Field(
|
||||||
|
None,
|
||||||
|
description="For common_enemy event - the challenge everyone will get"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventEffects(BaseModel):
|
||||||
|
points_multiplier: float = 1.0
|
||||||
|
drop_free: bool = False
|
||||||
|
special_action: str | None = None # "swap", "game_choice"
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class EventResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
type: EventTypeLiteral
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime | None
|
||||||
|
is_active: bool
|
||||||
|
created_by: UserPublic | None
|
||||||
|
data: dict | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveEventResponse(BaseModel):
|
||||||
|
event: EventResponse | None
|
||||||
|
effects: EventEffects
|
||||||
|
time_remaining_seconds: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SwapRequest(BaseModel):
|
||||||
|
target_participant_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class CommonEnemyLeaderboard(BaseModel):
|
||||||
|
participant_id: int
|
||||||
|
user: UserPublic
|
||||||
|
completed_at: datetime | None
|
||||||
|
rank: int | None
|
||||||
|
bonus_points: int
|
||||||
|
|
||||||
|
|
||||||
|
# Event descriptions and default durations
|
||||||
|
EVENT_INFO = {
|
||||||
|
EventType.GOLDEN_HOUR: {
|
||||||
|
"name": "Золотой час",
|
||||||
|
"description": "Все очки x1.5!",
|
||||||
|
"default_duration": 45,
|
||||||
|
"points_multiplier": 1.5,
|
||||||
|
"drop_free": False,
|
||||||
|
},
|
||||||
|
EventType.COMMON_ENEMY: {
|
||||||
|
"name": "Общий враг",
|
||||||
|
"description": "Все получают одинаковый челлендж. Первые 3 получают бонус!",
|
||||||
|
"default_duration": None, # Until all complete
|
||||||
|
"points_multiplier": 1.0,
|
||||||
|
"drop_free": False,
|
||||||
|
},
|
||||||
|
EventType.DOUBLE_RISK: {
|
||||||
|
"name": "Безопасная игра",
|
||||||
|
"description": "Дропы бесплатны, но очки x0.5",
|
||||||
|
"default_duration": 120,
|
||||||
|
"points_multiplier": 0.5,
|
||||||
|
"drop_free": True,
|
||||||
|
},
|
||||||
|
EventType.JACKPOT: {
|
||||||
|
"name": "Джекпот",
|
||||||
|
"description": "Следующий спин — сложный челлендж с x3 очками!",
|
||||||
|
"default_duration": None, # 1 spin
|
||||||
|
"points_multiplier": 3.0,
|
||||||
|
"drop_free": False,
|
||||||
|
},
|
||||||
|
EventType.SWAP: {
|
||||||
|
"name": "Обмен",
|
||||||
|
"description": "Можно поменяться заданием с другим участником",
|
||||||
|
"default_duration": 60,
|
||||||
|
"points_multiplier": 1.0,
|
||||||
|
"drop_free": False,
|
||||||
|
"special_action": "swap",
|
||||||
|
},
|
||||||
|
EventType.GAME_CHOICE: {
|
||||||
|
"name": "Выбор игры",
|
||||||
|
"description": "Выбери игру и один из 3 челленджей. Можно заменить текущее задание без штрафа!",
|
||||||
|
"default_duration": 120,
|
||||||
|
"points_multiplier": 1.0,
|
||||||
|
"drop_free": True, # Free replacement of current assignment
|
||||||
|
"special_action": "game_choice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bonus points for Common Enemy top 3
|
||||||
|
COMMON_ENEMY_BONUSES = {
|
||||||
|
1: 50,
|
||||||
|
2: 30,
|
||||||
|
3: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SwapCandidate(BaseModel):
|
||||||
|
"""Participant available for assignment swap"""
|
||||||
|
participant_id: int
|
||||||
|
user: UserPublic
|
||||||
|
challenge_title: str
|
||||||
|
challenge_description: str
|
||||||
|
challenge_points: int
|
||||||
|
challenge_difficulty: str
|
||||||
|
game_title: str
|
||||||
|
|
||||||
|
|
||||||
|
# Two-sided swap confirmation schemas
|
||||||
|
SwapRequestStatusLiteral = Literal["pending", "accepted", "declined", "cancelled"]
|
||||||
|
|
||||||
|
|
||||||
|
class SwapRequestCreate(BaseModel):
|
||||||
|
"""Request to swap assignment with another participant"""
|
||||||
|
target_participant_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class SwapRequestChallengeInfo(BaseModel):
|
||||||
|
"""Challenge info for swap request display"""
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
points: int
|
||||||
|
difficulty: str
|
||||||
|
game_title: str
|
||||||
|
|
||||||
|
|
||||||
|
class SwapRequestResponse(BaseModel):
|
||||||
|
"""Response for a swap request"""
|
||||||
|
id: int
|
||||||
|
status: SwapRequestStatusLiteral
|
||||||
|
from_user: UserPublic
|
||||||
|
to_user: UserPublic
|
||||||
|
from_challenge: SwapRequestChallengeInfo
|
||||||
|
to_challenge: SwapRequestChallengeInfo
|
||||||
|
created_at: datetime
|
||||||
|
responded_at: datetime | None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class MySwapRequests(BaseModel):
|
||||||
|
"""User's incoming and outgoing swap requests"""
|
||||||
|
incoming: list[SwapRequestResponse]
|
||||||
|
outgoing: list[SwapRequestResponse]
|
||||||
@@ -32,7 +32,9 @@ class GameShort(BaseModel):
|
|||||||
class GameResponse(GameBase):
|
class GameResponse(GameBase):
|
||||||
id: int
|
id: int
|
||||||
cover_url: str | None = None
|
cover_url: str | None = None
|
||||||
added_by: UserPublic | None = None
|
status: str = "pending"
|
||||||
|
proposed_by: UserPublic | None = None
|
||||||
|
approved_by: UserPublic | None = None
|
||||||
challenges_count: int = 0
|
challenges_count: int = 0
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,22 @@ class MarathonBase(BaseModel):
|
|||||||
class MarathonCreate(MarathonBase):
|
class MarathonCreate(MarathonBase):
|
||||||
start_date: datetime
|
start_date: datetime
|
||||||
duration_days: int = Field(default=30, ge=1, le=365)
|
duration_days: int = Field(default=30, ge=1, le=365)
|
||||||
|
is_public: bool = False
|
||||||
|
game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$")
|
||||||
|
|
||||||
|
|
||||||
class MarathonUpdate(BaseModel):
|
class MarathonUpdate(BaseModel):
|
||||||
title: str | None = Field(None, min_length=1, max_length=100)
|
title: str | None = Field(None, min_length=1, max_length=100)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
start_date: datetime | None = None
|
start_date: datetime | None = None
|
||||||
|
is_public: bool | None = None
|
||||||
|
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
|
||||||
|
auto_events_enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ParticipantInfo(BaseModel):
|
class ParticipantInfo(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
role: str = "participant"
|
||||||
total_points: int
|
total_points: int
|
||||||
current_streak: int
|
current_streak: int
|
||||||
drop_count: int
|
drop_count: int
|
||||||
@@ -37,9 +43,12 @@ class ParticipantWithUser(ParticipantInfo):
|
|||||||
|
|
||||||
class MarathonResponse(MarathonBase):
|
class MarathonResponse(MarathonBase):
|
||||||
id: int
|
id: int
|
||||||
organizer: UserPublic
|
creator: UserPublic
|
||||||
status: str
|
status: str
|
||||||
invite_code: str
|
invite_code: str
|
||||||
|
is_public: bool
|
||||||
|
game_proposal_mode: str
|
||||||
|
auto_events_enabled: bool
|
||||||
start_date: datetime | None
|
start_date: datetime | None
|
||||||
end_date: datetime | None
|
end_date: datetime | None
|
||||||
participants_count: int
|
participants_count: int
|
||||||
@@ -51,10 +60,15 @@ class MarathonResponse(MarathonBase):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SetParticipantRole(BaseModel):
|
||||||
|
role: str = Field(..., pattern="^(participant|organizer)$")
|
||||||
|
|
||||||
|
|
||||||
class MarathonListItem(BaseModel):
|
class MarathonListItem(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
status: str
|
status: str
|
||||||
|
is_public: bool
|
||||||
participants_count: int
|
participants_count: int
|
||||||
start_date: datetime | None
|
start_date: datetime | None
|
||||||
end_date: datetime | None
|
end_date: datetime | None
|
||||||
@@ -67,6 +81,19 @@ class JoinMarathon(BaseModel):
|
|||||||
invite_code: str
|
invite_code: str
|
||||||
|
|
||||||
|
|
||||||
|
class MarathonPublicInfo(BaseModel):
|
||||||
|
"""Public info about marathon for invite page (no auth required)"""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
description: str | None
|
||||||
|
status: str
|
||||||
|
participants_count: int
|
||||||
|
creator_nickname: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardEntry(BaseModel):
|
class LeaderboardEntry(BaseModel):
|
||||||
rank: int
|
rank: int
|
||||||
user: UserPublic
|
user: UserPublic
|
||||||
|
|||||||
@@ -29,26 +29,57 @@ class UserUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserPublic(UserBase):
|
class UserPublic(UserBase):
|
||||||
|
"""Public user info visible to other users - minimal data"""
|
||||||
id: int
|
id: int
|
||||||
login: str
|
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
|
role: str = "user"
|
||||||
|
telegram_avatar_url: str | None = None # Only TG avatar is public
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class UserWithTelegram(UserPublic):
|
class UserPrivate(UserPublic):
|
||||||
|
"""Full user info visible only to the user themselves"""
|
||||||
|
login: str
|
||||||
telegram_id: int | None = None
|
telegram_id: int | None = None
|
||||||
telegram_username: str | None = None
|
telegram_username: str | None = None
|
||||||
|
telegram_first_name: str | None = None
|
||||||
|
telegram_last_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
user: UserPublic
|
user: UserPrivate
|
||||||
|
|
||||||
|
|
||||||
class TelegramLink(BaseModel):
|
class TelegramLink(BaseModel):
|
||||||
telegram_id: int
|
telegram_id: int
|
||||||
telegram_username: str | None = None
|
telegram_username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
current_password: str = Field(..., min_length=6)
|
||||||
|
new_password: str = Field(..., min_length=6, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class UserStats(BaseModel):
|
||||||
|
"""Статистика пользователя по марафонам"""
|
||||||
|
marathons_count: int
|
||||||
|
wins_count: int
|
||||||
|
completed_assignments: int
|
||||||
|
total_points_earned: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfilePublic(BaseModel):
|
||||||
|
"""Публичный профиль пользователя со статистикой"""
|
||||||
|
id: int
|
||||||
|
nickname: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
stats: UserStats
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|||||||
89
backend/app/services/dispute_scheduler.py
Normal file
89
backend/app/services/dispute_scheduler.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
Dispute Scheduler for automatic dispute resolution after 24 hours.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
|
||||||
|
from app.services.disputes import dispute_service
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
|
||||||
|
DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeScheduler:
|
||||||
|
"""Background scheduler for automatic dispute resolution."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._running = False
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def start(self, session_factory) -> None:
|
||||||
|
"""Start the scheduler background task."""
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._task = asyncio.create_task(self._run_loop(session_factory))
|
||||||
|
print("[DisputeScheduler] Started")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the scheduler."""
|
||||||
|
self._running = False
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
print("[DisputeScheduler] Stopped")
|
||||||
|
|
||||||
|
async def _run_loop(self, session_factory) -> None:
|
||||||
|
"""Main scheduler loop."""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
async with session_factory() as db:
|
||||||
|
await self._process_expired_disputes(db)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DisputeScheduler] Error in loop: {e}")
|
||||||
|
|
||||||
|
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
async def _process_expired_disputes(self, db: AsyncSession) -> None:
|
||||||
|
"""Process and resolve expired disputes."""
|
||||||
|
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||||
|
|
||||||
|
# Find all open disputes that have expired
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.votes),
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Dispute.status == DisputeStatus.OPEN.value,
|
||||||
|
Dispute.created_at < cutoff_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expired_disputes = result.scalars().all()
|
||||||
|
|
||||||
|
for dispute in expired_disputes:
|
||||||
|
try:
|
||||||
|
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute(
|
||||||
|
db, dispute.id
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: "
|
||||||
|
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Global scheduler instance
|
||||||
|
dispute_scheduler = DisputeScheduler()
|
||||||
149
backend/app/services/disputes.py
Normal file
149
backend/app/services/disputes.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Dispute resolution service.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
Dispute, DisputeStatus, DisputeVote,
|
||||||
|
Assignment, AssignmentStatus, Participant, Marathon, Challenge, Game,
|
||||||
|
)
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeService:
|
||||||
|
"""Service for dispute resolution logic"""
|
||||||
|
|
||||||
|
async def resolve_dispute(self, db: AsyncSession, dispute_id: int) -> tuple[str, int, int]:
|
||||||
|
"""
|
||||||
|
Resolve a dispute based on votes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (result_status, votes_valid, votes_invalid)
|
||||||
|
"""
|
||||||
|
# Get dispute with votes and assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.votes),
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||||
|
)
|
||||||
|
.where(Dispute.id == dispute_id)
|
||||||
|
)
|
||||||
|
dispute = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not dispute:
|
||||||
|
raise ValueError(f"Dispute {dispute_id} not found")
|
||||||
|
|
||||||
|
if dispute.status != DisputeStatus.OPEN.value:
|
||||||
|
raise ValueError(f"Dispute {dispute_id} is already resolved")
|
||||||
|
|
||||||
|
# Count votes
|
||||||
|
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
||||||
|
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
||||||
|
|
||||||
|
# Determine result: tie goes to the accused (valid)
|
||||||
|
if votes_invalid > votes_valid:
|
||||||
|
# Proof is invalid - mark assignment as RETURNED
|
||||||
|
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||||
|
await self._handle_invalid_proof(db, dispute)
|
||||||
|
else:
|
||||||
|
# Proof is valid (or tie)
|
||||||
|
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||||
|
|
||||||
|
# Update dispute
|
||||||
|
dispute.status = result_status
|
||||||
|
dispute.resolved_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Send Telegram notification about dispute resolution
|
||||||
|
await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value)
|
||||||
|
|
||||||
|
return result_status, votes_valid, votes_invalid
|
||||||
|
|
||||||
|
async def _notify_dispute_resolved(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
dispute: Dispute,
|
||||||
|
is_valid: bool
|
||||||
|
) -> None:
|
||||||
|
"""Send notification about dispute resolution to the assignment owner."""
|
||||||
|
try:
|
||||||
|
# Get assignment with challenge and marathon info
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.participant),
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||||
|
)
|
||||||
|
.where(Assignment.id == dispute.assignment_id)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
if not assignment:
|
||||||
|
return
|
||||||
|
|
||||||
|
participant = assignment.participant
|
||||||
|
challenge = assignment.challenge
|
||||||
|
game = challenge.game if challenge else None
|
||||||
|
|
||||||
|
# Get marathon
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon).where(Marathon.id == game.marathon_id if game else 0)
|
||||||
|
)
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if marathon and participant:
|
||||||
|
await telegram_notifier.notify_dispute_resolved(
|
||||||
|
db,
|
||||||
|
user_id=participant.user_id,
|
||||||
|
marathon_title=marathon.title,
|
||||||
|
challenge_title=challenge.title if challenge else "Unknown",
|
||||||
|
is_valid=is_valid
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DisputeService] Failed to send notification: {e}")
|
||||||
|
|
||||||
|
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
|
||||||
|
"""
|
||||||
|
Handle the case when proof is determined to be invalid.
|
||||||
|
|
||||||
|
- Mark assignment as RETURNED
|
||||||
|
- Subtract points from participant
|
||||||
|
- Reset streak if it was affected
|
||||||
|
"""
|
||||||
|
assignment = dispute.assignment
|
||||||
|
participant = assignment.participant
|
||||||
|
|
||||||
|
# Subtract points that were earned
|
||||||
|
points_to_subtract = assignment.points_earned
|
||||||
|
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||||
|
|
||||||
|
# Reset assignment
|
||||||
|
assignment.status = AssignmentStatus.RETURNED.value
|
||||||
|
assignment.points_earned = 0
|
||||||
|
# Keep proof data so it can be reviewed
|
||||||
|
|
||||||
|
print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, "
|
||||||
|
f"subtracted {points_to_subtract} points from participant {participant.id}")
|
||||||
|
|
||||||
|
async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
|
||||||
|
"""Get all open disputes older than specified hours"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.where(
|
||||||
|
Dispute.status == DisputeStatus.OPEN.value,
|
||||||
|
Dispute.created_at < cutoff_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
# Global service instance
|
||||||
|
dispute_service = DisputeService()
|
||||||
150
backend/app/services/event_scheduler.py
Normal file
150
backend/app/services/event_scheduler.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Event Scheduler for automatic event launching in marathons.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import Marathon, MarathonStatus, Event, EventType
|
||||||
|
from app.services.events import EventService
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
|
||||||
|
EVENT_PROBABILITY = 0.1 # 10% chance per check to start an event
|
||||||
|
MIN_EVENT_GAP_HOURS = 4 # Minimum hours between events
|
||||||
|
|
||||||
|
# Events that can be auto-triggered (excluding common_enemy which needs a challenge_id)
|
||||||
|
AUTO_EVENT_TYPES = [
|
||||||
|
EventType.GOLDEN_HOUR,
|
||||||
|
EventType.DOUBLE_RISK,
|
||||||
|
EventType.JACKPOT,
|
||||||
|
EventType.GAME_CHOICE,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EventScheduler:
|
||||||
|
"""Background scheduler for automatic event management."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._running = False
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def start(self, session_factory) -> None:
|
||||||
|
"""Start the scheduler background task."""
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._task = asyncio.create_task(self._run_loop(session_factory))
|
||||||
|
print("[EventScheduler] Started")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the scheduler."""
|
||||||
|
self._running = False
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
print("[EventScheduler] Stopped")
|
||||||
|
|
||||||
|
async def _run_loop(self, session_factory) -> None:
|
||||||
|
"""Main scheduler loop."""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
async with session_factory() as db:
|
||||||
|
await self._process_events(db)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[EventScheduler] Error in loop: {e}")
|
||||||
|
|
||||||
|
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
async def _process_events(self, db: AsyncSession) -> None:
|
||||||
|
"""Process events - cleanup expired and potentially start new ones."""
|
||||||
|
# 1. Cleanup expired events
|
||||||
|
await self._cleanup_expired_events(db)
|
||||||
|
|
||||||
|
# 2. Maybe start new events for eligible marathons
|
||||||
|
await self._maybe_start_events(db)
|
||||||
|
|
||||||
|
async def _cleanup_expired_events(self, db: AsyncSession) -> None:
|
||||||
|
"""End any events that have expired."""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Event).where(
|
||||||
|
Event.is_active == True,
|
||||||
|
Event.end_time < now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expired_events = result.scalars().all()
|
||||||
|
|
||||||
|
for event in expired_events:
|
||||||
|
event.is_active = False
|
||||||
|
print(f"[EventScheduler] Ended expired event {event.id} ({event.type})")
|
||||||
|
|
||||||
|
if expired_events:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def _maybe_start_events(self, db: AsyncSession) -> None:
|
||||||
|
"""Potentially start new events for eligible marathons."""
|
||||||
|
# Get active marathons with auto_events enabled
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon).where(
|
||||||
|
Marathon.status == MarathonStatus.ACTIVE.value,
|
||||||
|
Marathon.auto_events_enabled == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
marathons = result.scalars().all()
|
||||||
|
|
||||||
|
event_service = EventService()
|
||||||
|
|
||||||
|
for marathon in marathons:
|
||||||
|
# Skip if random chance doesn't hit
|
||||||
|
if random.random() > EVENT_PROBABILITY:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if there's already an active event
|
||||||
|
active_event = await event_service.get_active_event(db, marathon.id)
|
||||||
|
if active_event:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if enough time has passed since last event
|
||||||
|
result = await db.execute(
|
||||||
|
select(Event)
|
||||||
|
.where(Event.marathon_id == marathon.id)
|
||||||
|
.order_by(Event.end_time.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
last_event = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if last_event:
|
||||||
|
time_since_last = datetime.utcnow() - last_event.end_time
|
||||||
|
if time_since_last < timedelta(hours=MIN_EVENT_GAP_HOURS):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Start a random event
|
||||||
|
event_type = random.choice(AUTO_EVENT_TYPES)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = await event_service.start_event(
|
||||||
|
db=db,
|
||||||
|
marathon_id=marathon.id,
|
||||||
|
event_type=event_type.value,
|
||||||
|
created_by_id=None, # null = auto-started
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[EventScheduler] Auto-started {event_type.value} for marathon {marathon.id}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(
|
||||||
|
f"[EventScheduler] Failed to start event for marathon {marathon.id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global scheduler instance
|
||||||
|
event_scheduler = EventScheduler()
|
||||||
302
backend/app/services/events.py
Normal file
302
backend/app/services/events.py
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import Event, EventType, Marathon, Challenge, Difficulty, Participant, Assignment, AssignmentStatus
|
||||||
|
from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
|
|
||||||
|
class EventService:
|
||||||
|
"""Service for managing marathon events"""
|
||||||
|
|
||||||
|
async def get_active_event(self, db: AsyncSession, marathon_id: int) -> Event | None:
|
||||||
|
"""Get currently active event for marathon"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
result = await db.execute(
|
||||||
|
select(Event)
|
||||||
|
.options(selectinload(Event.created_by))
|
||||||
|
.where(
|
||||||
|
Event.marathon_id == marathon_id,
|
||||||
|
Event.is_active == True,
|
||||||
|
Event.start_time <= now,
|
||||||
|
)
|
||||||
|
.order_by(Event.start_time.desc())
|
||||||
|
)
|
||||||
|
event = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Check if event has expired
|
||||||
|
if event and event.end_time and event.end_time < now:
|
||||||
|
await self.end_event(db, event.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
async def can_start_event(self, db: AsyncSession, marathon_id: int) -> bool:
|
||||||
|
"""Check if we can start a new event (no active event exists)"""
|
||||||
|
active = await self.get_active_event(db, marathon_id)
|
||||||
|
return active is None
|
||||||
|
|
||||||
|
async def start_event(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
event_type: str,
|
||||||
|
created_by_id: int | None = None,
|
||||||
|
duration_minutes: int | None = None,
|
||||||
|
challenge_id: int | None = None,
|
||||||
|
) -> Event:
|
||||||
|
"""Start a new event"""
|
||||||
|
# Check no active event
|
||||||
|
if not await self.can_start_event(db, marathon_id):
|
||||||
|
raise ValueError("An event is already active")
|
||||||
|
|
||||||
|
# Get default duration if not provided
|
||||||
|
event_info = EVENT_INFO.get(EventType(event_type), {})
|
||||||
|
if duration_minutes is None:
|
||||||
|
duration_minutes = event_info.get("default_duration")
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
end_time = now + timedelta(minutes=duration_minutes) if duration_minutes else None
|
||||||
|
|
||||||
|
# Build event data
|
||||||
|
data = {}
|
||||||
|
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
|
||||||
|
data["challenge_id"] = challenge_id
|
||||||
|
data["completions"] = [] # Track who completed and when
|
||||||
|
|
||||||
|
event = Event(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
type=event_type,
|
||||||
|
start_time=now,
|
||||||
|
end_time=end_time,
|
||||||
|
is_active=True,
|
||||||
|
created_by_id=created_by_id,
|
||||||
|
data=data if data else None,
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
await db.flush() # Get event.id before committing
|
||||||
|
|
||||||
|
# Auto-assign challenge to all participants for Common Enemy
|
||||||
|
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
|
||||||
|
await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(event)
|
||||||
|
|
||||||
|
# Load created_by relationship
|
||||||
|
if created_by_id:
|
||||||
|
await db.refresh(event, ["created_by"])
|
||||||
|
|
||||||
|
# Send Telegram notifications
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if marathon:
|
||||||
|
await telegram_notifier.notify_event_start(
|
||||||
|
db, marathon_id, event_type, marathon.title
|
||||||
|
)
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
async def _assign_common_enemy_to_all(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
event_id: int,
|
||||||
|
challenge_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Create event assignments for all participants in the marathon"""
|
||||||
|
# Get all participants
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(Participant.marathon_id == marathon_id)
|
||||||
|
)
|
||||||
|
participants = result.scalars().all()
|
||||||
|
|
||||||
|
# Create event assignment for each participant
|
||||||
|
for participant in participants:
|
||||||
|
assignment = Assignment(
|
||||||
|
participant_id=participant.id,
|
||||||
|
challenge_id=challenge_id,
|
||||||
|
status=AssignmentStatus.ACTIVE.value,
|
||||||
|
event_type=EventType.COMMON_ENEMY.value,
|
||||||
|
is_event_assignment=True,
|
||||||
|
event_id=event_id,
|
||||||
|
)
|
||||||
|
db.add(assignment)
|
||||||
|
|
||||||
|
async def end_event(self, db: AsyncSession, event_id: int) -> None:
|
||||||
|
"""End an event and mark incomplete event assignments as expired"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
result = await db.execute(select(Event).where(Event.id == event_id))
|
||||||
|
event = result.scalar_one_or_none()
|
||||||
|
if event:
|
||||||
|
event_type = event.type
|
||||||
|
marathon_id = event.marathon_id
|
||||||
|
|
||||||
|
event.is_active = False
|
||||||
|
if not event.end_time:
|
||||||
|
event.end_time = datetime.utcnow()
|
||||||
|
|
||||||
|
# Mark all incomplete event assignments for this event as dropped
|
||||||
|
if event.type == EventType.COMMON_ENEMY.value:
|
||||||
|
await db.execute(
|
||||||
|
update(Assignment)
|
||||||
|
.where(
|
||||||
|
Assignment.event_id == event_id,
|
||||||
|
Assignment.is_event_assignment == True,
|
||||||
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
status=AssignmentStatus.DROPPED.value,
|
||||||
|
completed_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Send Telegram notifications about event end
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if marathon:
|
||||||
|
await telegram_notifier.notify_event_end(
|
||||||
|
db, marathon_id, event_type, marathon.title
|
||||||
|
)
|
||||||
|
|
||||||
|
async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None:
|
||||||
|
"""Consume jackpot event after one spin"""
|
||||||
|
await self.end_event(db, event_id)
|
||||||
|
|
||||||
|
def get_event_effects(self, event: Event | None) -> EventEffects:
|
||||||
|
"""Get effects of an event"""
|
||||||
|
if not event:
|
||||||
|
return EventEffects(description="Нет активного события")
|
||||||
|
|
||||||
|
event_info = EVENT_INFO.get(EventType(event.type), {})
|
||||||
|
|
||||||
|
return EventEffects(
|
||||||
|
points_multiplier=event_info.get("points_multiplier", 1.0),
|
||||||
|
drop_free=event_info.get("drop_free", False),
|
||||||
|
special_action=event_info.get("special_action"),
|
||||||
|
description=event_info.get("description", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_random_hard_challenge(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int
|
||||||
|
) -> Challenge | None:
|
||||||
|
"""Get a random hard challenge for jackpot event"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.join(Challenge.game)
|
||||||
|
.where(
|
||||||
|
Challenge.game.has(marathon_id=marathon_id),
|
||||||
|
Challenge.difficulty == Difficulty.HARD.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
if not challenges:
|
||||||
|
# Fallback to any challenge
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.join(Challenge.game)
|
||||||
|
.where(Challenge.game.has(marathon_id=marathon_id))
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
if challenges:
|
||||||
|
import random
|
||||||
|
return random.choice(challenges)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def record_common_enemy_completion(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
event: Event,
|
||||||
|
participant_id: int,
|
||||||
|
user_id: int,
|
||||||
|
) -> tuple[int, bool, list[dict] | None]:
|
||||||
|
"""
|
||||||
|
Record completion for common enemy event.
|
||||||
|
Returns: (bonus_points, event_closed, winners_list)
|
||||||
|
- bonus_points: bonus for this completion (top 3 get bonuses)
|
||||||
|
- event_closed: True if event was auto-closed (3 completions reached)
|
||||||
|
- winners_list: list of winners if event closed, None otherwise
|
||||||
|
"""
|
||||||
|
if event.type != EventType.COMMON_ENEMY.value:
|
||||||
|
print(f"[COMMON_ENEMY] Event type mismatch: {event.type}")
|
||||||
|
return 0, False, None
|
||||||
|
|
||||||
|
data = event.data or {}
|
||||||
|
completions = data.get("completions", [])
|
||||||
|
print(f"[COMMON_ENEMY] Current completions count: {len(completions)}")
|
||||||
|
|
||||||
|
# Check if already completed
|
||||||
|
if any(c["participant_id"] == participant_id for c in completions):
|
||||||
|
print(f"[COMMON_ENEMY] Participant {participant_id} already completed")
|
||||||
|
return 0, False, None
|
||||||
|
|
||||||
|
# Add completion
|
||||||
|
rank = len(completions) + 1
|
||||||
|
completions.append({
|
||||||
|
"participant_id": participant_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"completed_at": datetime.utcnow().isoformat(),
|
||||||
|
"rank": rank,
|
||||||
|
})
|
||||||
|
print(f"[COMMON_ENEMY] Added completion for user {user_id}, rank={rank}")
|
||||||
|
|
||||||
|
# Update event data - need to flag_modified for SQLAlchemy to detect JSON changes
|
||||||
|
event.data = {**data, "completions": completions}
|
||||||
|
flag_modified(event, "data")
|
||||||
|
|
||||||
|
bonus = COMMON_ENEMY_BONUSES.get(rank, 0)
|
||||||
|
|
||||||
|
# Auto-close event when 3 players completed
|
||||||
|
event_closed = False
|
||||||
|
winners_list = None
|
||||||
|
if rank >= 3:
|
||||||
|
event.is_active = False
|
||||||
|
event.end_time = datetime.utcnow()
|
||||||
|
event_closed = True
|
||||||
|
winners_list = completions[:3] # Top 3
|
||||||
|
print(f"[COMMON_ENEMY] Event auto-closed! Winners: {winners_list}")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return bonus, event_closed, winners_list
|
||||||
|
|
||||||
|
async def get_common_enemy_challenge(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
event: Event
|
||||||
|
) -> Challenge | None:
|
||||||
|
"""Get the challenge for common enemy event"""
|
||||||
|
if event.type != EventType.COMMON_ENEMY.value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = event.data or {}
|
||||||
|
challenge_id = data.get("challenge_id")
|
||||||
|
if not challenge_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.options(selectinload(Challenge.game))
|
||||||
|
.where(Challenge.id == challenge_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
def get_time_remaining(self, event: Event | None) -> int | None:
|
||||||
|
"""Get remaining time in seconds for an event"""
|
||||||
|
if not event or not event.end_time:
|
||||||
|
return None
|
||||||
|
|
||||||
|
remaining = (event.end_time - datetime.utcnow()).total_seconds()
|
||||||
|
return max(0, int(remaining))
|
||||||
|
|
||||||
|
|
||||||
|
event_service = EventService()
|
||||||
@@ -13,76 +13,125 @@ class GPTService:
|
|||||||
|
|
||||||
async def generate_challenges(
|
async def generate_challenges(
|
||||||
self,
|
self,
|
||||||
game_title: str,
|
games: list[dict]
|
||||||
game_genre: str | None = None
|
) -> dict[int, list[ChallengeGenerated]]:
|
||||||
) -> list[ChallengeGenerated]:
|
|
||||||
"""
|
"""
|
||||||
Generate challenges for a game using GPT.
|
Generate challenges for multiple games in one API call.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
game_title: Name of the game
|
games: List of dicts with keys: id, title, genre
|
||||||
game_genre: Optional genre of the game
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of generated challenges
|
Dict mapping game_id to list of generated challenges
|
||||||
"""
|
"""
|
||||||
genre_text = f" (жанр: {game_genre})" if game_genre else ""
|
if not games:
|
||||||
|
return {}
|
||||||
|
|
||||||
prompt = f"""Для видеоигры "{game_title}"{genre_text} сгенерируй 6 челленджей для игрового марафона.
|
games_text = "\n".join([
|
||||||
|
f"- {g['title']}" + (f" (жанр: {g['genre']})" if g.get('genre') else "")
|
||||||
|
for g in games
|
||||||
|
])
|
||||||
|
|
||||||
Требования:
|
prompt = f"""Ты — эксперт по видеоиграм. Сгенерируй по 6 КОНКРЕТНЫХ челленджей для каждой из следующих игр:
|
||||||
- 2 лёгких челленджа (15-30 минут игры)
|
|
||||||
- 2 средних челленджа (1-2 часа игры)
|
|
||||||
- 2 сложных челленджа (3+ часов или высокая сложность)
|
|
||||||
|
|
||||||
Для каждого челленджа укажи:
|
{games_text}
|
||||||
- title: короткое название на русском (до 50 символов)
|
|
||||||
- description: что нужно сделать на русском (1-2 предложения)
|
|
||||||
- type: один из [completion, no_death, speedrun, collection, achievement, challenge_run]
|
|
||||||
- difficulty: easy/medium/hard
|
|
||||||
- points: очки (easy: 30-50, medium: 60-100, hard: 120-200)
|
|
||||||
- estimated_time: примерное время в минутах
|
|
||||||
- proof_type: screenshot/video/steam (что лучше подойдёт для проверки)
|
|
||||||
- proof_hint: что должно быть на скриншоте/видео для подтверждения на русском
|
|
||||||
|
|
||||||
Ответь ТОЛЬКО валидным JSON объектом с ключом "challenges" содержащим массив челленджей.
|
ВАЖНО:
|
||||||
Пример формата:
|
- ВСЕ ТЕКСТЫ (title, description, proof_hint) ОБЯЗАТЕЛЬНО ПИШИ НА РУССКОМ ЯЗЫКЕ!
|
||||||
{{"challenges": [{{"title": "...", "description": "...", "type": "...", "difficulty": "...", "points": 50, "estimated_time": 30, "proof_type": "...", "proof_hint": "..."}}]}}"""
|
- Используй интернет для поиска актуальной информации об играх
|
||||||
|
- Челленджи должны быть СПЕЦИФИЧНЫМИ для каждой игры!
|
||||||
|
- Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры
|
||||||
|
- Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре
|
||||||
|
- НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов"
|
||||||
|
|
||||||
|
Требования по сложности ДЛЯ КАЖДОЙ ИГРЫ:
|
||||||
|
- 2 лёгких (15-30 мин): простые задачи
|
||||||
|
- 2 средних (1-2 часа): требуют навыка
|
||||||
|
- 2 сложных (3-12 часов): серьёзный челлендж
|
||||||
|
|
||||||
|
Формат ответа — JSON с объектом где ключи это ТОЧНЫЕ названия игр, как они указаны в запросе:
|
||||||
|
{{
|
||||||
|
"Название игры 1": {{
|
||||||
|
"challenges": [
|
||||||
|
{{"title": "...", "description": "...", "type": "completion|no_death|speedrun|collection|achievement|challenge_run", "difficulty": "easy|medium|hard", "points": 50, "estimated_time": 30, "proof_type": "screenshot|video|steam", "proof_hint": "..."}}
|
||||||
|
]
|
||||||
|
}},
|
||||||
|
"Название игры 2": {{
|
||||||
|
"challenges": [...]
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
points: easy=20-40, medium=45-75, hard=90-150
|
||||||
|
Ответь ТОЛЬКО JSON. ОПИСАНИЕ И НАЗВАНИЕ ЧЕЛЛЕНДЖА ТОЛЬКО НА РУССКОМ ЯЗЫКЕ!"""
|
||||||
|
|
||||||
response = await self.client.chat.completions.create(
|
response = await self.client.chat.completions.create(
|
||||||
model="gpt-4o-mini",
|
model="gpt-5",
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
response_format={"type": "json_object"},
|
response_format={"type": "json_object"},
|
||||||
temperature=0.7,
|
|
||||||
max_tokens=2000,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
content = response.choices[0].message.content
|
content = response.choices[0].message.content
|
||||||
data = json.loads(content)
|
data = json.loads(content)
|
||||||
|
|
||||||
|
# Map game titles to IDs (case-insensitive, strip whitespace)
|
||||||
|
title_to_id = {g['title'].lower().strip(): g['id'] for g in games}
|
||||||
|
|
||||||
|
# Also keep original titles for logging
|
||||||
|
id_to_title = {g['id']: g['title'] for g in games}
|
||||||
|
|
||||||
|
print(f"[GPT] Requested games: {[g['title'] for g in games]}")
|
||||||
|
print(f"[GPT] Response keys: {list(data.keys())}")
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for game_title, game_data in data.items():
|
||||||
|
# Try exact match first, then case-insensitive
|
||||||
|
game_id = title_to_id.get(game_title.lower().strip())
|
||||||
|
|
||||||
|
if not game_id:
|
||||||
|
# Try partial match if exact match fails
|
||||||
|
for stored_title, gid in title_to_id.items():
|
||||||
|
if stored_title in game_title.lower() or game_title.lower() in stored_title:
|
||||||
|
game_id = gid
|
||||||
|
break
|
||||||
|
|
||||||
|
if not game_id:
|
||||||
|
print(f"[GPT] Could not match game: '{game_title}'")
|
||||||
|
continue
|
||||||
|
|
||||||
challenges = []
|
challenges = []
|
||||||
for ch in data.get("challenges", []):
|
for ch in game_data.get("challenges", []):
|
||||||
# Validate and normalize type
|
challenges.append(self._parse_challenge(ch))
|
||||||
|
|
||||||
|
result[game_id] = challenges
|
||||||
|
print(f"[GPT] Generated {len(challenges)} challenges for '{id_to_title.get(game_id)}'")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _parse_challenge(self, ch: dict) -> ChallengeGenerated:
|
||||||
|
"""Parse and validate a single challenge from GPT response"""
|
||||||
ch_type = ch.get("type", "completion")
|
ch_type = ch.get("type", "completion")
|
||||||
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
|
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
|
||||||
ch_type = "completion"
|
ch_type = "completion"
|
||||||
|
|
||||||
# Validate difficulty
|
|
||||||
difficulty = ch.get("difficulty", "medium")
|
difficulty = ch.get("difficulty", "medium")
|
||||||
if difficulty not in ["easy", "medium", "hard"]:
|
if difficulty not in ["easy", "medium", "hard"]:
|
||||||
difficulty = "medium"
|
difficulty = "medium"
|
||||||
|
|
||||||
# Validate proof_type
|
|
||||||
proof_type = ch.get("proof_type", "screenshot")
|
proof_type = ch.get("proof_type", "screenshot")
|
||||||
if proof_type not in ["screenshot", "video", "steam"]:
|
if proof_type not in ["screenshot", "video", "steam"]:
|
||||||
proof_type = "screenshot"
|
proof_type = "screenshot"
|
||||||
|
|
||||||
# Validate points
|
points = ch.get("points", 30)
|
||||||
points = ch.get("points", 50)
|
|
||||||
if not isinstance(points, int) or points < 1:
|
if not isinstance(points, int) or points < 1:
|
||||||
points = 50
|
points = 30
|
||||||
|
if difficulty == "easy":
|
||||||
|
points = max(20, min(40, points))
|
||||||
|
elif difficulty == "medium":
|
||||||
|
points = max(45, min(75, points))
|
||||||
|
elif difficulty == "hard":
|
||||||
|
points = max(90, min(150, points))
|
||||||
|
|
||||||
challenges.append(ChallengeGenerated(
|
return ChallengeGenerated(
|
||||||
title=ch.get("title", "Unnamed Challenge")[:100],
|
title=ch.get("title", "Unnamed Challenge")[:100],
|
||||||
description=ch.get("description", "Complete the challenge"),
|
description=ch.get("description", "Complete the challenge"),
|
||||||
type=ch_type,
|
type=ch_type,
|
||||||
@@ -91,6 +140,7 @@ class GPTService:
|
|||||||
estimated_time=ch.get("estimated_time"),
|
estimated_time=ch.get("estimated_time"),
|
||||||
proof_type=proof_type,
|
proof_type=proof_type,
|
||||||
proof_hint=ch.get("proof_hint"),
|
proof_hint=ch.get("proof_hint"),
|
||||||
))
|
)
|
||||||
|
|
||||||
return challenges
|
|
||||||
|
gpt_service = GPTService()
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from app.models import Event, EventType
|
||||||
|
|
||||||
|
|
||||||
class PointsService:
|
class PointsService:
|
||||||
"""Service for calculating points and penalties"""
|
"""Service for calculating points and penalties"""
|
||||||
|
|
||||||
@@ -10,46 +13,87 @@ class PointsService:
|
|||||||
}
|
}
|
||||||
MAX_STREAK_MULTIPLIER = 0.4
|
MAX_STREAK_MULTIPLIER = 0.4
|
||||||
|
|
||||||
DROP_PENALTIES = {
|
# Drop penalty as percentage of challenge points
|
||||||
0: 0, # First drop is free
|
DROP_PENALTY_PERCENTAGES = {
|
||||||
1: 10,
|
0: 0.5, # 1st drop: 50%
|
||||||
2: 25,
|
1: 0.75, # 2nd drop: 75%
|
||||||
|
}
|
||||||
|
MAX_DROP_PENALTY_PERCENTAGE = 1.0 # 3rd+ drop: 100%
|
||||||
|
|
||||||
|
# Event point multipliers
|
||||||
|
EVENT_MULTIPLIERS = {
|
||||||
|
EventType.GOLDEN_HOUR.value: 1.5,
|
||||||
|
EventType.DOUBLE_RISK.value: 0.5,
|
||||||
|
EventType.JACKPOT.value: 3.0,
|
||||||
|
# GAME_CHOICE uses 1.0 multiplier (default)
|
||||||
}
|
}
|
||||||
MAX_DROP_PENALTY = 50
|
|
||||||
|
|
||||||
def calculate_completion_points(
|
def calculate_completion_points(
|
||||||
self,
|
self,
|
||||||
base_points: int,
|
base_points: int,
|
||||||
current_streak: int
|
current_streak: int,
|
||||||
) -> tuple[int, int]:
|
event: Event | None = None,
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
"""
|
"""
|
||||||
Calculate points earned for completing a challenge.
|
Calculate points earned for completing a challenge.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_points: Base points for the challenge
|
base_points: Base points for the challenge
|
||||||
current_streak: Current streak before this completion
|
current_streak: Current streak before this completion
|
||||||
|
event: Active event (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (total_points, streak_bonus)
|
Tuple of (total_points, streak_bonus, event_bonus)
|
||||||
"""
|
"""
|
||||||
multiplier = self.STREAK_MULTIPLIERS.get(
|
# Apply event multiplier first
|
||||||
|
event_multiplier = 1.0
|
||||||
|
if event:
|
||||||
|
event_multiplier = self.EVENT_MULTIPLIERS.get(event.type, 1.0)
|
||||||
|
|
||||||
|
adjusted_base = int(base_points * event_multiplier)
|
||||||
|
event_bonus = adjusted_base - base_points
|
||||||
|
|
||||||
|
# Then apply streak bonus
|
||||||
|
streak_multiplier = self.STREAK_MULTIPLIERS.get(
|
||||||
current_streak,
|
current_streak,
|
||||||
self.MAX_STREAK_MULTIPLIER
|
self.MAX_STREAK_MULTIPLIER
|
||||||
)
|
)
|
||||||
bonus = int(base_points * multiplier)
|
streak_bonus = int(adjusted_base * streak_multiplier)
|
||||||
return base_points + bonus, bonus
|
|
||||||
|
|
||||||
def calculate_drop_penalty(self, consecutive_drops: int) -> int:
|
total_points = adjusted_base + streak_bonus
|
||||||
|
return total_points, streak_bonus, event_bonus
|
||||||
|
|
||||||
|
def calculate_drop_penalty(
|
||||||
|
self,
|
||||||
|
consecutive_drops: int,
|
||||||
|
challenge_points: int,
|
||||||
|
event: Event | None = None
|
||||||
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Calculate penalty for dropping a challenge.
|
Calculate penalty for dropping a challenge.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
consecutive_drops: Number of drops since last completion
|
consecutive_drops: Number of drops since last completion
|
||||||
|
challenge_points: Base points of the challenge being dropped
|
||||||
|
event: Active event (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Penalty points to subtract
|
Penalty points to subtract
|
||||||
"""
|
"""
|
||||||
return self.DROP_PENALTIES.get(
|
# Double risk event = free drops
|
||||||
|
if event and event.type == EventType.DOUBLE_RISK.value:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
penalty_percentage = self.DROP_PENALTY_PERCENTAGES.get(
|
||||||
consecutive_drops,
|
consecutive_drops,
|
||||||
self.MAX_DROP_PENALTY
|
self.MAX_DROP_PENALTY_PERCENTAGE
|
||||||
)
|
)
|
||||||
|
return int(challenge_points * penalty_percentage)
|
||||||
|
|
||||||
|
def apply_event_multiplier(self, base_points: int, event: Event | None) -> int:
|
||||||
|
"""Apply event multiplier to points"""
|
||||||
|
if not event:
|
||||||
|
return base_points
|
||||||
|
|
||||||
|
multiplier = self.EVENT_MULTIPLIERS.get(event.type, 1.0)
|
||||||
|
return int(base_points * multiplier)
|
||||||
|
|||||||
269
backend/app/services/storage.py
Normal file
269
backend/app/services/storage.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Storage service for file uploads.
|
||||||
|
Supports both local filesystem and S3-compatible storage (FirstVDS).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError, BotoCoreError
|
||||||
|
from botocore.config import Config
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
StorageFolder = Literal["avatars", "covers", "proofs"]
|
||||||
|
|
||||||
|
|
||||||
|
class StorageService:
|
||||||
|
"""Unified storage service with S3 and local filesystem support."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._s3_client = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def s3_client(self):
|
||||||
|
"""Lazy initialization of S3 client."""
|
||||||
|
if self._s3_client is None and settings.S3_ENABLED:
|
||||||
|
logger.info(f"Initializing S3 client: endpoint={settings.S3_ENDPOINT_URL}, bucket={settings.S3_BUCKET_NAME}")
|
||||||
|
try:
|
||||||
|
# Use signature_version=s3v4 for S3-compatible storage
|
||||||
|
self._s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.S3_ENDPOINT_URL,
|
||||||
|
aws_access_key_id=settings.S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.S3_SECRET_ACCESS_KEY,
|
||||||
|
region_name=settings.S3_REGION or "us-east-1",
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
logger.info("S3 client initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize S3 client: {e}")
|
||||||
|
self._s3_client = None
|
||||||
|
return self._s3_client
|
||||||
|
|
||||||
|
def generate_filename(self, prefix: str | int, original_filename: str | None) -> str:
|
||||||
|
"""Generate unique filename with prefix."""
|
||||||
|
ext = "jpg"
|
||||||
|
if original_filename and "." in original_filename:
|
||||||
|
ext = original_filename.rsplit(".", 1)[-1].lower()
|
||||||
|
return f"{prefix}_{uuid.uuid4().hex}.{ext}"
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
folder: StorageFolder,
|
||||||
|
filename: str,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Upload file to storage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path/key to the uploaded file (relative path for local, S3 key for S3)
|
||||||
|
"""
|
||||||
|
if settings.S3_ENABLED:
|
||||||
|
try:
|
||||||
|
return await self._upload_to_s3(content, folder, filename, content_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"S3 upload failed, falling back to local: {e}")
|
||||||
|
return await self._upload_to_local(content, folder, filename)
|
||||||
|
else:
|
||||||
|
return await self._upload_to_local(content, folder, filename)
|
||||||
|
|
||||||
|
async def _upload_to_s3(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
folder: StorageFolder,
|
||||||
|
filename: str,
|
||||||
|
content_type: str,
|
||||||
|
) -> str:
|
||||||
|
"""Upload file to S3."""
|
||||||
|
key = f"{folder}/{filename}"
|
||||||
|
|
||||||
|
if not self.s3_client:
|
||||||
|
raise RuntimeError("S3 client not initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Uploading to S3: bucket={settings.S3_BUCKET_NAME}, key={key}")
|
||||||
|
self.s3_client.put_object(
|
||||||
|
Bucket=settings.S3_BUCKET_NAME,
|
||||||
|
Key=key,
|
||||||
|
Body=content,
|
||||||
|
ContentType=content_type,
|
||||||
|
)
|
||||||
|
logger.info(f"Successfully uploaded to S3: {key}")
|
||||||
|
return key
|
||||||
|
except (ClientError, BotoCoreError) as e:
|
||||||
|
logger.error(f"S3 upload error: {e}")
|
||||||
|
raise RuntimeError(f"Failed to upload to S3: {e}")
|
||||||
|
|
||||||
|
async def _upload_to_local(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
folder: StorageFolder,
|
||||||
|
filename: str,
|
||||||
|
) -> str:
|
||||||
|
"""Upload file to local filesystem."""
|
||||||
|
filepath = Path(settings.UPLOAD_DIR) / folder / filename
|
||||||
|
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
return str(filepath)
|
||||||
|
|
||||||
|
def get_url(self, path: str | None, folder: StorageFolder) -> str | None:
|
||||||
|
"""
|
||||||
|
Get public URL for a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File path/key (can be full path or just filename)
|
||||||
|
folder: Storage folder (avatars, covers, proofs)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Public URL or None if path is None
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract filename from path
|
||||||
|
filename = path.split("/")[-1]
|
||||||
|
|
||||||
|
if settings.S3_ENABLED:
|
||||||
|
# S3 URL
|
||||||
|
return f"{settings.S3_PUBLIC_URL}/{folder}/{filename}"
|
||||||
|
else:
|
||||||
|
# Local URL
|
||||||
|
return f"/uploads/{folder}/{filename}"
|
||||||
|
|
||||||
|
async def delete_file(self, path: str | None) -> bool:
|
||||||
|
"""
|
||||||
|
Delete file from storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File path/key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False otherwise
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if settings.S3_ENABLED:
|
||||||
|
return await self._delete_from_s3(path)
|
||||||
|
else:
|
||||||
|
return await self._delete_from_local(path)
|
||||||
|
|
||||||
|
async def _delete_from_s3(self, key: str) -> bool:
|
||||||
|
"""Delete file from S3."""
|
||||||
|
try:
|
||||||
|
self.s3_client.delete_object(
|
||||||
|
Bucket=settings.S3_BUCKET_NAME,
|
||||||
|
Key=key,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except ClientError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _delete_from_local(self, path: str) -> bool:
|
||||||
|
"""Delete file from local filesystem."""
|
||||||
|
try:
|
||||||
|
filepath = Path(path)
|
||||||
|
if filepath.exists():
|
||||||
|
filepath.unlink()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_file(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
folder: StorageFolder,
|
||||||
|
) -> tuple[bytes, str] | None:
|
||||||
|
"""
|
||||||
|
Get file content from storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File path/key (can be full path or just filename)
|
||||||
|
folder: Storage folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (content bytes, content_type) or None if not found
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract filename from path
|
||||||
|
filename = path.split("/")[-1]
|
||||||
|
|
||||||
|
if settings.S3_ENABLED:
|
||||||
|
return await self._get_from_s3(folder, filename)
|
||||||
|
else:
|
||||||
|
return await self._get_from_local(folder, filename)
|
||||||
|
|
||||||
|
async def _get_from_s3(
|
||||||
|
self,
|
||||||
|
folder: StorageFolder,
|
||||||
|
filename: str,
|
||||||
|
) -> tuple[bytes, str] | None:
|
||||||
|
"""Get file from S3."""
|
||||||
|
key = f"{folder}/{filename}"
|
||||||
|
|
||||||
|
if not self.s3_client:
|
||||||
|
logger.error("S3 client not initialized")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.s3_client.get_object(
|
||||||
|
Bucket=settings.S3_BUCKET_NAME,
|
||||||
|
Key=key,
|
||||||
|
)
|
||||||
|
content = response["Body"].read()
|
||||||
|
content_type = response.get("ContentType", "application/octet-stream")
|
||||||
|
return content, content_type
|
||||||
|
except ClientError as e:
|
||||||
|
logger.error(f"S3 get error for {key}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _get_from_local(
|
||||||
|
self,
|
||||||
|
folder: StorageFolder,
|
||||||
|
filename: str,
|
||||||
|
) -> tuple[bytes, str] | None:
|
||||||
|
"""Get file from local filesystem."""
|
||||||
|
filepath = Path(settings.UPLOAD_DIR) / folder / filename
|
||||||
|
|
||||||
|
if not filepath.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Determine content type from extension
|
||||||
|
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||||
|
content_types = {
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"png": "image/png",
|
||||||
|
"gif": "image/gif",
|
||||||
|
"webp": "image/webp",
|
||||||
|
"mp4": "video/mp4",
|
||||||
|
"webm": "video/webm",
|
||||||
|
"mov": "video/quicktime",
|
||||||
|
}
|
||||||
|
content_type = content_types.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
return content, content_type
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Local get error for {filepath}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
storage_service = StorageService()
|
||||||
317
backend/app/services/telegram_notifier.py
Normal file
317
backend/app/services/telegram_notifier.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models import User, Participant, Marathon
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramNotifier:
|
||||||
|
"""Service for sending Telegram notifications."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.bot_token = settings.TELEGRAM_BOT_TOKEN
|
||||||
|
self.api_url = f"https://api.telegram.org/bot{self.bot_token}"
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
text: str,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
reply_markup: dict | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Send a message to a Telegram chat."""
|
||||||
|
if not self.bot_token:
|
||||||
|
logger.warning("Telegram bot token not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": parse_mode
|
||||||
|
}
|
||||||
|
if reply_markup:
|
||||||
|
payload["reply_markup"] = reply_markup
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.api_url}/sendMessage",
|
||||||
|
json=payload,
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to send message: {response.text}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending Telegram message: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def notify_user(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
message: str,
|
||||||
|
reply_markup: dict | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Send notification to a user by user_id."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.id == user_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.warning(f"[Notify] User {user_id} not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not user.telegram_id:
|
||||||
|
logger.warning(f"[Notify] User {user_id} ({user.nickname}) has no telegram_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"[Notify] Sending to user {user.nickname} (telegram_id={user.telegram_id})")
|
||||||
|
return await self.send_message(user.telegram_id, message, reply_markup=reply_markup)
|
||||||
|
|
||||||
|
async def notify_marathon_participants(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
message: str,
|
||||||
|
exclude_user_id: int | None = None
|
||||||
|
) -> int:
|
||||||
|
"""Send notification to all marathon participants with linked Telegram."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.join(Participant, Participant.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
User.telegram_id.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
for user in users:
|
||||||
|
if exclude_user_id and user.id == exclude_user_id:
|
||||||
|
continue
|
||||||
|
if await self.send_message(user.telegram_id, message):
|
||||||
|
sent_count += 1
|
||||||
|
|
||||||
|
return sent_count
|
||||||
|
|
||||||
|
# Notification templates
|
||||||
|
async def notify_event_start(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
event_type: str,
|
||||||
|
marathon_title: str
|
||||||
|
) -> int:
|
||||||
|
"""Notify participants about event start."""
|
||||||
|
event_messages = {
|
||||||
|
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
|
||||||
|
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
|
||||||
|
"double_risk": f"⚡ <b>Double Risk</b> в «{marathon_title}»!\n\nПоловина очков, но дропы бесплатны!",
|
||||||
|
"common_enemy": f"👥 <b>Common Enemy</b> в «{marathon_title}»!\n\nВсе получают одинаковый челлендж. Первые 3 — бонус!",
|
||||||
|
"swap": f"🔄 <b>Swap</b> в «{marathon_title}»!\n\nМожно поменяться заданием с другим участником!",
|
||||||
|
"game_choice": f"🎲 <b>Выбор игры</b> в «{marathon_title}»!\n\nВыбери игру и один из 3 челленджей!"
|
||||||
|
}
|
||||||
|
|
||||||
|
message = event_messages.get(
|
||||||
|
event_type,
|
||||||
|
f"📌 Новое событие в «{marathon_title}»!"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self.notify_marathon_participants(db, marathon_id, message)
|
||||||
|
|
||||||
|
async def notify_event_end(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
event_type: str,
|
||||||
|
marathon_title: str
|
||||||
|
) -> int:
|
||||||
|
"""Notify participants about event end."""
|
||||||
|
event_names = {
|
||||||
|
"golden_hour": "Golden Hour",
|
||||||
|
"jackpot": "Jackpot",
|
||||||
|
"double_risk": "Double Risk",
|
||||||
|
"common_enemy": "Common Enemy",
|
||||||
|
"swap": "Swap",
|
||||||
|
"game_choice": "Выбор игры"
|
||||||
|
}
|
||||||
|
|
||||||
|
event_name = event_names.get(event_type, "Событие")
|
||||||
|
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
|
||||||
|
|
||||||
|
return await self.notify_marathon_participants(db, marathon_id, message)
|
||||||
|
|
||||||
|
async def notify_marathon_start(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
marathon_title: str
|
||||||
|
) -> int:
|
||||||
|
"""Notify participants about marathon start."""
|
||||||
|
message = (
|
||||||
|
f"🚀 <b>Марафон «{marathon_title}» начался!</b>\n\n"
|
||||||
|
f"Время крутить колесо и получить первое задание!"
|
||||||
|
)
|
||||||
|
return await self.notify_marathon_participants(db, marathon_id, message)
|
||||||
|
|
||||||
|
async def notify_marathon_finish(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
marathon_title: str
|
||||||
|
) -> int:
|
||||||
|
"""Notify participants about marathon finish."""
|
||||||
|
message = (
|
||||||
|
f"🏆 <b>Марафон «{marathon_title}» завершён!</b>\n\n"
|
||||||
|
f"Зайди на сайт, чтобы увидеть итоговую таблицу!"
|
||||||
|
)
|
||||||
|
return await self.notify_marathon_participants(db, marathon_id, message)
|
||||||
|
|
||||||
|
async def notify_dispute_raised(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
challenge_title: str,
|
||||||
|
assignment_id: int
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user about dispute raised on their assignment."""
|
||||||
|
logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}")
|
||||||
|
|
||||||
|
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
|
||||||
|
logger.info(f"[Dispute] URL: {dispute_url}")
|
||||||
|
|
||||||
|
# Telegram requires HTTPS for inline keyboard URLs
|
||||||
|
use_inline_button = dispute_url.startswith("https://")
|
||||||
|
|
||||||
|
if use_inline_button:
|
||||||
|
message = (
|
||||||
|
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Задание: {challenge_title}"
|
||||||
|
)
|
||||||
|
reply_markup = {
|
||||||
|
"inline_keyboard": [[
|
||||||
|
{"text": "Открыть спор", "url": dispute_url}
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"🔗 {dispute_url}"
|
||||||
|
)
|
||||||
|
reply_markup = None
|
||||||
|
|
||||||
|
result = await self.notify_user(db, user_id, message, reply_markup=reply_markup)
|
||||||
|
logger.info(f"[Dispute] Notification result: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def notify_dispute_resolved(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
challenge_title: str,
|
||||||
|
is_valid: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user about dispute resolution."""
|
||||||
|
if is_valid:
|
||||||
|
message = (
|
||||||
|
f"❌ <b>Спор признан обоснованным</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"Задание возвращено. Выполни его заново."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f"✅ <b>Спор отклонён</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"Твоё выполнение засчитано!"
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_game_approved(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user that their proposed game was approved."""
|
||||||
|
message = (
|
||||||
|
f"✅ <b>Твоя игра одобрена!</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Игра: {game_title}\n\n"
|
||||||
|
f"Теперь она доступна для всех участников."
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_game_rejected(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user that their proposed game was rejected."""
|
||||||
|
message = (
|
||||||
|
f"❌ <b>Твоя игра отклонена</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Игра: {game_title}\n\n"
|
||||||
|
f"Ты можешь предложить другую игру."
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_challenge_approved(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str,
|
||||||
|
challenge_title: str
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user that their proposed challenge was approved."""
|
||||||
|
message = (
|
||||||
|
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Игра: {game_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"Теперь оно доступно для всех участников."
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_challenge_rejected(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str,
|
||||||
|
challenge_title: str
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user that their proposed challenge was rejected."""
|
||||||
|
message = (
|
||||||
|
f"❌ <b>Твой челлендж отклонён</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Игра: {game_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"Ты можешь предложить другой челлендж."
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
telegram_notifier = TelegramNotifier()
|
||||||
@@ -19,7 +19,7 @@ pydantic-settings==2.1.0
|
|||||||
email-validator==2.1.0
|
email-validator==2.1.0
|
||||||
|
|
||||||
# OpenAI
|
# OpenAI
|
||||||
openai==1.12.0
|
openai==2.12.0
|
||||||
|
|
||||||
# Telegram notifications
|
# Telegram notifications
|
||||||
httpx==0.26.0
|
httpx==0.26.0
|
||||||
@@ -28,5 +28,11 @@ httpx==0.26.0
|
|||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
python-magic==0.4.27
|
python-magic==0.4.27
|
||||||
|
|
||||||
|
# S3 Storage
|
||||||
|
boto3==1.34.0
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
slowapi==0.1.9
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
|||||||
30
backup-service/Dockerfile
Normal file
30
backup-service/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install PostgreSQL client (for pg_dump and psql) and cron
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
postgresql-client \
|
||||||
|
cron \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Make scripts executable
|
||||||
|
RUN chmod +x backup.py restore.py
|
||||||
|
|
||||||
|
# Setup cron
|
||||||
|
COPY crontab /etc/cron.d/backup-cron
|
||||||
|
RUN chmod 0644 /etc/cron.d/backup-cron
|
||||||
|
RUN crontab /etc/cron.d/backup-cron
|
||||||
|
|
||||||
|
# Create log file
|
||||||
|
RUN touch /var/log/cron.log
|
||||||
|
|
||||||
|
# Start cron in foreground and tail logs
|
||||||
|
CMD ["sh", "-c", "printenv > /etc/environment && cron && tail -f /var/log/cron.log"]
|
||||||
217
backup-service/backup.py
Normal file
217
backup-service/backup.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PostgreSQL Backup Service for WebApp.
|
||||||
|
|
||||||
|
- Creates pg_dump backup
|
||||||
|
- Compresses with gzip
|
||||||
|
- Uploads to S3 FirstVDS
|
||||||
|
- Rotates old backups (configurable retention)
|
||||||
|
- Sends Telegram notifications
|
||||||
|
"""
|
||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import httpx
|
||||||
|
from botocore.config import Config as BotoConfig
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
def create_s3_client():
|
||||||
|
"""Initialize S3 client (same pattern as backend storage.py)."""
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=config.S3_ENDPOINT_URL,
|
||||||
|
aws_access_key_id=config.S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
|
||||||
|
region_name=config.S3_REGION or "us-east-1",
|
||||||
|
config=BotoConfig(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram_notification(message: str, is_error: bool = False) -> None:
|
||||||
|
"""Send notification to Telegram admin."""
|
||||||
|
if not config.TELEGRAM_BOT_TOKEN or not config.TELEGRAM_ADMIN_ID:
|
||||||
|
print("Telegram not configured, skipping notification")
|
||||||
|
return
|
||||||
|
|
||||||
|
emoji = "\u274c" if is_error else "\u2705"
|
||||||
|
text = f"{emoji} *Database Backup*\n\n{message}"
|
||||||
|
|
||||||
|
url = f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||||
|
data = {
|
||||||
|
"chat_id": config.TELEGRAM_ADMIN_ID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = httpx.post(url, json=data, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
print("Telegram notification sent")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send Telegram notification: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup() -> tuple[str, bytes]:
|
||||||
|
"""Create pg_dump backup and compress it."""
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"marathon_backup_{timestamp}.sql.gz"
|
||||||
|
|
||||||
|
# Build pg_dump command
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PGPASSWORD"] = config.DB_PASSWORD
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"pg_dump",
|
||||||
|
"-h",
|
||||||
|
config.DB_HOST,
|
||||||
|
"-p",
|
||||||
|
config.DB_PORT,
|
||||||
|
"-U",
|
||||||
|
config.DB_USER,
|
||||||
|
"-d",
|
||||||
|
config.DB_NAME,
|
||||||
|
"--no-owner",
|
||||||
|
"--no-acl",
|
||||||
|
"-F",
|
||||||
|
"p", # plain SQL format
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"Running pg_dump for database {config.DB_NAME}...")
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise Exception(f"pg_dump failed: {result.stderr.decode()}")
|
||||||
|
|
||||||
|
# Compress the output
|
||||||
|
print("Compressing backup...")
|
||||||
|
compressed = gzip.compress(result.stdout, compresslevel=9)
|
||||||
|
|
||||||
|
return filename, compressed
|
||||||
|
|
||||||
|
|
||||||
|
def upload_to_s3(s3_client, filename: str, data: bytes) -> str:
|
||||||
|
"""Upload backup to S3."""
|
||||||
|
key = f"{config.S3_BACKUP_PREFIX}{filename}"
|
||||||
|
|
||||||
|
print(f"Uploading to S3: {key}...")
|
||||||
|
s3_client.put_object(
|
||||||
|
Bucket=config.S3_BUCKET_NAME,
|
||||||
|
Key=key,
|
||||||
|
Body=data,
|
||||||
|
ContentType="application/gzip",
|
||||||
|
)
|
||||||
|
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_old_backups(s3_client) -> int:
|
||||||
|
"""Delete backups older than BACKUP_RETENTION_DAYS."""
|
||||||
|
cutoff_date = datetime.now(timezone.utc) - timedelta(
|
||||||
|
days=config.BACKUP_RETENTION_DAYS
|
||||||
|
)
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
print(f"Rotating backups older than {config.BACKUP_RETENTION_DAYS} days...")
|
||||||
|
|
||||||
|
# List all objects with backup prefix
|
||||||
|
try:
|
||||||
|
paginator = s3_client.get_paginator("list_objects_v2")
|
||||||
|
pages = paginator.paginate(
|
||||||
|
Bucket=config.S3_BUCKET_NAME,
|
||||||
|
Prefix=config.S3_BACKUP_PREFIX,
|
||||||
|
)
|
||||||
|
|
||||||
|
for page in pages:
|
||||||
|
for obj in page.get("Contents", []):
|
||||||
|
last_modified = obj["LastModified"]
|
||||||
|
if last_modified.tzinfo is None:
|
||||||
|
last_modified = last_modified.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if last_modified < cutoff_date:
|
||||||
|
s3_client.delete_object(
|
||||||
|
Bucket=config.S3_BUCKET_NAME,
|
||||||
|
Key=obj["Key"],
|
||||||
|
)
|
||||||
|
deleted_count += 1
|
||||||
|
print(f"Deleted old backup: {obj['Key']}")
|
||||||
|
except ClientError as e:
|
||||||
|
print(f"Error during rotation: {e}")
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Main backup routine."""
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
print(f"Backup started at {start_time}")
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate configuration
|
||||||
|
if not config.S3_BUCKET_NAME:
|
||||||
|
raise Exception("S3_BUCKET_NAME is not configured")
|
||||||
|
if not config.S3_ACCESS_KEY_ID:
|
||||||
|
raise Exception("S3_ACCESS_KEY_ID is not configured")
|
||||||
|
if not config.S3_SECRET_ACCESS_KEY:
|
||||||
|
raise Exception("S3_SECRET_ACCESS_KEY is not configured")
|
||||||
|
if not config.S3_ENDPOINT_URL:
|
||||||
|
raise Exception("S3_ENDPOINT_URL is not configured")
|
||||||
|
|
||||||
|
# Create S3 client
|
||||||
|
s3_client = create_s3_client()
|
||||||
|
|
||||||
|
# Create backup
|
||||||
|
filename, data = create_backup()
|
||||||
|
size_mb = len(data) / (1024 * 1024)
|
||||||
|
print(f"Backup created: {filename} ({size_mb:.2f} MB)")
|
||||||
|
|
||||||
|
# Upload to S3
|
||||||
|
s3_key = upload_to_s3(s3_client, filename, data)
|
||||||
|
print(f"Uploaded to S3: {s3_key}")
|
||||||
|
|
||||||
|
# Rotate old backups
|
||||||
|
deleted_count = rotate_old_backups(s3_client)
|
||||||
|
print(f"Deleted {deleted_count} old backups")
|
||||||
|
|
||||||
|
# Calculate duration
|
||||||
|
duration = datetime.now() - start_time
|
||||||
|
|
||||||
|
# Send success notification
|
||||||
|
message = (
|
||||||
|
f"Backup completed successfully!\n\n"
|
||||||
|
f"*File:* `{filename}`\n"
|
||||||
|
f"*Size:* {size_mb:.2f} MB\n"
|
||||||
|
f"*Duration:* {duration.seconds}s\n"
|
||||||
|
f"*Deleted old:* {deleted_count} files"
|
||||||
|
)
|
||||||
|
send_telegram_notification(message, is_error=False)
|
||||||
|
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
print("Backup completed successfully!")
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Backup failed!\n\n*Error:* `{str(e)}`"
|
||||||
|
send_telegram_notification(error_msg, is_error=True)
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
print(f"Backup failed: {e}")
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
33
backup-service/config.py
Normal file
33
backup-service/config.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Configuration for backup service."""
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
"""Backup service configuration from environment variables."""
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST: str = os.getenv("DB_HOST", "db")
|
||||||
|
DB_PORT: str = os.getenv("DB_PORT", "5432")
|
||||||
|
DB_NAME: str = os.getenv("DB_NAME", "marathon")
|
||||||
|
DB_USER: str = os.getenv("DB_USER", "marathon")
|
||||||
|
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "123")
|
||||||
|
|
||||||
|
# S3
|
||||||
|
S3_BUCKET_NAME: str = os.getenv("S3_BUCKET_NAME", "")
|
||||||
|
S3_REGION: str = os.getenv("S3_REGION", "ru-1")
|
||||||
|
S3_ACCESS_KEY_ID: str = os.getenv("S3_ACCESS_KEY_ID", "")
|
||||||
|
S3_SECRET_ACCESS_KEY: str = os.getenv("S3_SECRET_ACCESS_KEY", "")
|
||||||
|
S3_ENDPOINT_URL: str = os.getenv("S3_ENDPOINT_URL", "")
|
||||||
|
S3_BACKUP_PREFIX: str = os.getenv("S3_BACKUP_PREFIX", "backups/")
|
||||||
|
|
||||||
|
# Telegram
|
||||||
|
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
TELEGRAM_ADMIN_ID: str = os.getenv("TELEGRAM_ADMIN_ID", "947392854")
|
||||||
|
|
||||||
|
# Backup settings
|
||||||
|
BACKUP_RETENTION_DAYS: int = int(os.getenv("BACKUP_RETENTION_DAYS", "14"))
|
||||||
|
|
||||||
|
|
||||||
|
config = Config()
|
||||||
4
backup-service/crontab
Normal file
4
backup-service/crontab
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Backup cron job
|
||||||
|
# Run backup daily at 3:00 AM UTC
|
||||||
|
0 3 * * * /usr/local/bin/python /app/backup.py >> /var/log/cron.log 2>&1
|
||||||
|
# Empty line required at end of crontab
|
||||||
2
backup-service/requirements.txt
Normal file
2
backup-service/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
boto3==1.34.0
|
||||||
|
httpx==0.26.0
|
||||||
158
backup-service/restore.py
Normal file
158
backup-service/restore.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Restore PostgreSQL database from S3 backup.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python restore.py - List available backups
|
||||||
|
python restore.py <filename> - Restore from specific backup
|
||||||
|
"""
|
||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config as BotoConfig
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
def create_s3_client():
|
||||||
|
"""Initialize S3 client."""
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=config.S3_ENDPOINT_URL,
|
||||||
|
aws_access_key_id=config.S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
|
||||||
|
region_name=config.S3_REGION or "us-east-1",
|
||||||
|
config=BotoConfig(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_backups(s3_client) -> list[tuple[str, float, str]]:
|
||||||
|
"""List all available backups."""
|
||||||
|
print("Available backups:\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
paginator = s3_client.get_paginator("list_objects_v2")
|
||||||
|
pages = paginator.paginate(
|
||||||
|
Bucket=config.S3_BUCKET_NAME,
|
||||||
|
Prefix=config.S3_BACKUP_PREFIX,
|
||||||
|
)
|
||||||
|
|
||||||
|
backups = []
|
||||||
|
for page in pages:
|
||||||
|
for obj in page.get("Contents", []):
|
||||||
|
filename = obj["Key"].replace(config.S3_BACKUP_PREFIX, "")
|
||||||
|
size_mb = obj["Size"] / (1024 * 1024)
|
||||||
|
modified = obj["LastModified"].strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
backups.append((filename, size_mb, modified))
|
||||||
|
|
||||||
|
# Sort by date descending (newest first)
|
||||||
|
backups.sort(key=lambda x: x[2], reverse=True)
|
||||||
|
|
||||||
|
for filename, size_mb, modified in backups:
|
||||||
|
print(f" {filename} ({size_mb:.2f} MB) - {modified}")
|
||||||
|
|
||||||
|
return backups
|
||||||
|
|
||||||
|
except ClientError as e:
|
||||||
|
print(f"Error listing backups: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup(s3_client, filename: str) -> None:
|
||||||
|
"""Download and restore backup."""
|
||||||
|
key = f"{config.S3_BACKUP_PREFIX}{filename}"
|
||||||
|
|
||||||
|
print(f"Downloading {filename} from S3...")
|
||||||
|
try:
|
||||||
|
response = s3_client.get_object(
|
||||||
|
Bucket=config.S3_BUCKET_NAME,
|
||||||
|
Key=key,
|
||||||
|
)
|
||||||
|
compressed_data = response["Body"].read()
|
||||||
|
except ClientError as e:
|
||||||
|
raise Exception(f"Failed to download backup: {e}")
|
||||||
|
|
||||||
|
print("Decompressing...")
|
||||||
|
sql_data = gzip.decompress(compressed_data)
|
||||||
|
|
||||||
|
print(f"Restoring to database {config.DB_NAME}...")
|
||||||
|
|
||||||
|
# Build psql command
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PGPASSWORD"] = config.DB_PASSWORD
|
||||||
|
|
||||||
|
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=sql_data,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = result.stderr.decode()
|
||||||
|
# psql may return warnings that aren't fatal errors
|
||||||
|
if "ERROR" in stderr:
|
||||||
|
raise Exception(f"psql restore failed: {stderr}")
|
||||||
|
else:
|
||||||
|
print(f"Warnings: {stderr}")
|
||||||
|
|
||||||
|
print("Restore completed successfully!")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Main restore routine."""
|
||||||
|
# Validate configuration
|
||||||
|
if not config.S3_BUCKET_NAME:
|
||||||
|
print("Error: S3_BUCKET_NAME is not configured")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
s3_client = create_s3_client()
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
# List available backups
|
||||||
|
backups = list_backups(s3_client)
|
||||||
|
if backups:
|
||||||
|
print(f"\nTo restore, run: python restore.py <filename>")
|
||||||
|
else:
|
||||||
|
print("No backups found.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
filename = sys.argv[1]
|
||||||
|
|
||||||
|
# Confirm restore
|
||||||
|
print(f"WARNING: This will restore database from {filename}")
|
||||||
|
print("This may overwrite existing data!")
|
||||||
|
print()
|
||||||
|
|
||||||
|
confirm = input("Type 'yes' to continue: ")
|
||||||
|
|
||||||
|
if confirm.lower() != "yes":
|
||||||
|
print("Restore cancelled.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
restore_backup(s3_client, filename)
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Restore failed: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
10
bot/Dockerfile
Normal file
10
bot/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["python", "main.py"]
|
||||||
15
bot/config.py
Normal file
15
bot/config.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
TELEGRAM_BOT_TOKEN: str
|
||||||
|
API_URL: str = "http://backend:8000"
|
||||||
|
BOT_USERNAME: str = "" # Will be set dynamically on startup
|
||||||
|
BOT_API_SECRET: str = "" # Secret for backend API communication
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
1
bot/handlers/__init__.py
Normal file
1
bot/handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot handlers
|
||||||
60
bot/handlers/link.py
Normal file
60
bot/handlers/link.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from keyboards.main_menu import get_main_menu
|
||||||
|
from services.api_client import api_client
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("unlink"))
|
||||||
|
async def cmd_unlink(message: Message):
|
||||||
|
"""Handle /unlink command to disconnect Telegram account."""
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
await message.answer(
|
||||||
|
"Твой аккаунт не привязан к Game Marathon.\n"
|
||||||
|
"Привяжи его через настройки профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await api_client.unlink_telegram(message.from_user.id)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
await message.answer(
|
||||||
|
"<b>Аккаунт отвязан</b>\n\n"
|
||||||
|
"Ты больше не будешь получать уведомления.\n"
|
||||||
|
"Чтобы привязать аккаунт снова, используй кнопку в настройках профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.answer(
|
||||||
|
"Произошла ошибка при отвязке аккаунта.\n"
|
||||||
|
"Попробуй позже или обратись к администратору.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("status"))
|
||||||
|
async def cmd_status(message: Message):
|
||||||
|
"""Check account link status."""
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
await message.answer(
|
||||||
|
f"<b>Статус аккаунта</b>\n\n"
|
||||||
|
f"✅ Аккаунт привязан\n"
|
||||||
|
f"👤 Никнейм: <b>{user.get('nickname', 'N/A')}</b>\n"
|
||||||
|
f"🆔 ID: {user.get('id', 'N/A')}",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.answer(
|
||||||
|
"<b>Статус аккаунта</b>\n\n"
|
||||||
|
"❌ Аккаунт не привязан\n\n"
|
||||||
|
"Привяжи его через настройки профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
211
bot/handlers/marathons.py
Normal file
211
bot/handlers/marathons.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
|
||||||
|
from keyboards.main_menu import get_main_menu
|
||||||
|
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard
|
||||||
|
from services.api_client import api_client
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("marathons"))
|
||||||
|
@router.message(F.text == "📊 Мои марафоны")
|
||||||
|
async def cmd_marathons(message: Message):
|
||||||
|
"""Show user's marathons."""
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
await message.answer(
|
||||||
|
"Сначала привяжи аккаунт через настройки профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
marathons = await api_client.get_user_marathons(message.from_user.id)
|
||||||
|
|
||||||
|
if not marathons:
|
||||||
|
await message.answer(
|
||||||
|
"<b>Мои марафоны</b>\n\n"
|
||||||
|
"У тебя пока нет активных марафонов.\n"
|
||||||
|
"Присоединись к марафону на сайте!",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "<b>📊 Мои марафоны</b>\n\n"
|
||||||
|
|
||||||
|
for m in marathons:
|
||||||
|
status_emoji = {
|
||||||
|
"preparing": "⏳",
|
||||||
|
"active": "🎮",
|
||||||
|
"finished": "🏁"
|
||||||
|
}.get(m.get("status"), "❓")
|
||||||
|
|
||||||
|
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
|
||||||
|
text += f" Очки: {m.get('total_points', 0)} | "
|
||||||
|
text += f"Место: #{m.get('position', '?')}\n\n"
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text,
|
||||||
|
reply_markup=get_marathons_keyboard(marathons)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("marathon:"))
|
||||||
|
async def marathon_details(callback: CallbackQuery):
|
||||||
|
"""Show marathon details."""
|
||||||
|
marathon_id = int(callback.data.split(":")[1])
|
||||||
|
|
||||||
|
details = await api_client.get_marathon_details(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
telegram_id=callback.from_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not details:
|
||||||
|
await callback.answer("Не удалось загрузить данные марафона", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
marathon = details.get("marathon", {})
|
||||||
|
participant = details.get("participant", {})
|
||||||
|
active_events = details.get("active_events", [])
|
||||||
|
current_assignment = details.get("current_assignment")
|
||||||
|
|
||||||
|
status_text = {
|
||||||
|
"preparing": "⏳ Подготовка",
|
||||||
|
"active": "🎮 Активен",
|
||||||
|
"finished": "🏁 Завершён"
|
||||||
|
}.get(marathon.get("status"), "❓")
|
||||||
|
|
||||||
|
text = f"<b>{marathon.get('title')}</b>\n"
|
||||||
|
text += f"Статус: {status_text}\n\n"
|
||||||
|
|
||||||
|
text += f"<b>📈 Твоя статистика:</b>\n"
|
||||||
|
text += f"• Очки: <b>{participant.get('total_points', 0)}</b>\n"
|
||||||
|
text += f"• Место: <b>#{details.get('position', '?')}</b>\n"
|
||||||
|
text += f"• Стрик: <b>{participant.get('current_streak', 0)}</b> 🔥\n"
|
||||||
|
text += f"• Дропов: <b>{participant.get('drop_count', 0)}</b>\n\n"
|
||||||
|
|
||||||
|
if active_events:
|
||||||
|
text += "<b>⚡ Активные события:</b>\n"
|
||||||
|
for event in active_events:
|
||||||
|
event_emoji = {
|
||||||
|
"golden_hour": "🌟",
|
||||||
|
"jackpot": "🎰",
|
||||||
|
"double_risk": "⚡",
|
||||||
|
"common_enemy": "👥",
|
||||||
|
"swap": "🔄",
|
||||||
|
"game_choice": "🎲"
|
||||||
|
}.get(event.get("type"), "📌")
|
||||||
|
text += f"{event_emoji} {event.get('type', '').replace('_', ' ').title()}\n"
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
if current_assignment:
|
||||||
|
challenge = current_assignment.get("challenge", {})
|
||||||
|
game = challenge.get("game", {})
|
||||||
|
text += f"<b>🎯 Текущее задание:</b>\n"
|
||||||
|
text += f"Игра: {game.get('title', 'N/A')}\n"
|
||||||
|
text += f"Задание: {challenge.get('title', 'N/A')}\n"
|
||||||
|
text += f"Сложность: {challenge.get('difficulty', 'N/A')}\n"
|
||||||
|
text += f"Очки: {challenge.get('points', 0)}\n"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=get_marathon_details_keyboard(marathon_id)
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "back_to_marathons")
|
||||||
|
async def back_to_marathons(callback: CallbackQuery):
|
||||||
|
"""Go back to marathons list."""
|
||||||
|
marathons = await api_client.get_user_marathons(callback.from_user.id)
|
||||||
|
|
||||||
|
if not marathons:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"<b>Мои марафоны</b>\n\n"
|
||||||
|
"У тебя пока нет активных марафонов."
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "<b>📊 Мои марафоны</b>\n\n"
|
||||||
|
|
||||||
|
for m in marathons:
|
||||||
|
status_emoji = {
|
||||||
|
"preparing": "⏳",
|
||||||
|
"active": "🎮",
|
||||||
|
"finished": "🏁"
|
||||||
|
}.get(m.get("status"), "❓")
|
||||||
|
|
||||||
|
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
|
||||||
|
text += f" Очки: {m.get('total_points', 0)} | "
|
||||||
|
text += f"Место: #{m.get('position', '?')}\n\n"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=get_marathons_keyboard(marathons)
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("stats"))
|
||||||
|
@router.message(F.text == "📈 Статистика")
|
||||||
|
async def cmd_stats(message: Message):
|
||||||
|
"""Show user's overall statistics."""
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
await message.answer(
|
||||||
|
"Сначала привяжи аккаунт через настройки профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = await api_client.get_user_stats(message.from_user.id)
|
||||||
|
|
||||||
|
if not stats:
|
||||||
|
await message.answer(
|
||||||
|
"<b>📈 Статистика</b>\n\n"
|
||||||
|
"Пока нет данных для отображения.\n"
|
||||||
|
"Начни участвовать в марафонах!",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = f"<b>📈 Общая статистика</b>\n\n"
|
||||||
|
text += f"👤 <b>{user.get('nickname', 'Игрок')}</b>\n\n"
|
||||||
|
text += f"🏆 Марафонов завершено: <b>{stats.get('marathons_completed', 0)}</b>\n"
|
||||||
|
text += f"🎮 Марафонов активно: <b>{stats.get('marathons_active', 0)}</b>\n"
|
||||||
|
text += f"✅ Заданий выполнено: <b>{stats.get('challenges_completed', 0)}</b>\n"
|
||||||
|
text += f"💰 Всего очков: <b>{stats.get('total_points', 0)}</b>\n"
|
||||||
|
text += f"🔥 Лучший стрик: <b>{stats.get('best_streak', 0)}</b>\n"
|
||||||
|
|
||||||
|
await message.answer(text, reply_markup=get_main_menu())
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("settings"))
|
||||||
|
@router.message(F.text == "⚙️ Настройки")
|
||||||
|
async def cmd_settings(message: Message):
|
||||||
|
"""Show notification settings."""
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
await message.answer(
|
||||||
|
"Сначала привяжи аккаунт через настройки профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"<b>⚙️ Настройки</b>\n\n"
|
||||||
|
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
|
||||||
|
"Сейчас ты получаешь все уведомления:\n"
|
||||||
|
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
|
||||||
|
"• 🚀 Старт/финиш марафонов\n"
|
||||||
|
"• ⚠️ Споры по заданиям\n\n"
|
||||||
|
"Команды:\n"
|
||||||
|
"/unlink - Отвязать аккаунт\n"
|
||||||
|
"/status - Проверить привязку",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
146
bot/handlers/start.py
Normal file
146
bot/handlers/start.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Router, F, Bot
|
||||||
|
from aiogram.filters import CommandStart, Command, CommandObject
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from keyboards.main_menu import get_main_menu
|
||||||
|
from services.api_client import api_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_avatar_url(bot: Bot, user_id: int) -> str | None:
|
||||||
|
"""Get user's Telegram profile photo URL."""
|
||||||
|
try:
|
||||||
|
photos = await bot.get_user_profile_photos(user_id, limit=1)
|
||||||
|
if photos.total_count > 0 and photos.photos:
|
||||||
|
# Get the largest photo (last in the list)
|
||||||
|
photo = photos.photos[0][-1]
|
||||||
|
file = await bot.get_file(photo.file_id)
|
||||||
|
if file.file_path:
|
||||||
|
return f"https://api.telegram.org/file/bot{settings.TELEGRAM_BOT_TOKEN}/{file.file_path}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[START] Could not get user avatar: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
async def cmd_start(message: Message, command: CommandObject):
|
||||||
|
"""Handle /start command with or without deep link."""
|
||||||
|
logger.info(f"[START] ==================== START COMMAND ====================")
|
||||||
|
logger.info(f"[START] Telegram user: id={message.from_user.id}, username=@{message.from_user.username}")
|
||||||
|
logger.info(f"[START] Full message text: '{message.text}'")
|
||||||
|
logger.info(f"[START] Deep link args (command.args): '{command.args}'")
|
||||||
|
|
||||||
|
# Check if there's a deep link token (for account linking)
|
||||||
|
token = command.args
|
||||||
|
if token:
|
||||||
|
logger.info(f"[START] -------- TOKEN RECEIVED --------")
|
||||||
|
logger.info(f"[START] Token: {token}")
|
||||||
|
logger.info(f"[START] Token length: {len(token)} chars")
|
||||||
|
|
||||||
|
# Get user's avatar
|
||||||
|
avatar_url = await get_user_avatar_url(message.bot, message.from_user.id)
|
||||||
|
logger.info(f"[START] User avatar URL: {avatar_url}")
|
||||||
|
|
||||||
|
logger.info(f"[START] -------- CALLING API --------")
|
||||||
|
logger.info(f"[START] Sending to /telegram/confirm-link:")
|
||||||
|
logger.info(f"[START] - token: {token}")
|
||||||
|
logger.info(f"[START] - telegram_id: {message.from_user.id}")
|
||||||
|
logger.info(f"[START] - telegram_username: {message.from_user.username}")
|
||||||
|
logger.info(f"[START] - telegram_first_name: {message.from_user.first_name}")
|
||||||
|
logger.info(f"[START] - telegram_last_name: {message.from_user.last_name}")
|
||||||
|
logger.info(f"[START] - telegram_avatar_url: {avatar_url}")
|
||||||
|
|
||||||
|
result = await api_client.confirm_telegram_link(
|
||||||
|
token=token,
|
||||||
|
telegram_id=message.from_user.id,
|
||||||
|
telegram_username=message.from_user.username,
|
||||||
|
telegram_first_name=message.from_user.first_name,
|
||||||
|
telegram_last_name=message.from_user.last_name,
|
||||||
|
telegram_avatar_url=avatar_url
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[START] -------- API RESPONSE --------")
|
||||||
|
logger.info(f"[START] Response: {result}")
|
||||||
|
logger.info(f"[START] Success: {result.get('success')}")
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
user_nickname = result.get("nickname", "пользователь")
|
||||||
|
logger.info(f"[START] ✅ LINK SUCCESS! User '{user_nickname}' linked to telegram_id={message.from_user.id}")
|
||||||
|
await message.answer(
|
||||||
|
f"<b>Аккаунт успешно привязан!</b>\n\n"
|
||||||
|
f"Привет, <b>{user_nickname}</b>!\n\n"
|
||||||
|
f"Теперь ты будешь получать уведомления о:\n"
|
||||||
|
f"• Начале и окончании событий (Golden Hour, Jackpot и др.)\n"
|
||||||
|
f"• Старте и завершении марафонов\n"
|
||||||
|
f"• Спорах по твоим заданиям\n\n"
|
||||||
|
f"Используй меню ниже для навигации:",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
error = result.get("error", "Неизвестная ошибка")
|
||||||
|
logger.error(f"[START] ❌ LINK FAILED!")
|
||||||
|
logger.error(f"[START] Error: {error}")
|
||||||
|
logger.error(f"[START] Token was: {token}")
|
||||||
|
await message.answer(
|
||||||
|
f"<b>Ошибка привязки аккаунта</b>\n\n"
|
||||||
|
f"{error}\n\n"
|
||||||
|
f"Попробуй получить новую ссылку на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# No token - regular start
|
||||||
|
logger.info(f"[START] No token, checking if user is already linked...")
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
logger.info(f"[START] API response: {user}")
|
||||||
|
|
||||||
|
if user:
|
||||||
|
await message.answer(
|
||||||
|
f"<b>С возвращением, {user.get('nickname', 'игрок')}!</b>\n\n"
|
||||||
|
f"Твой аккаунт привязан. Используй меню для навигации:",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.answer(
|
||||||
|
"<b>Добро пожаловать в Game Marathon Bot!</b>\n\n"
|
||||||
|
"Этот бот поможет тебе следить за марафонами и "
|
||||||
|
"получать уведомления о важных событиях.\n\n"
|
||||||
|
"<b>Для начала работы:</b>\n"
|
||||||
|
"1. Зайди на сайт в настройки профиля\n"
|
||||||
|
"2. Нажми кнопку «Привязать Telegram»\n"
|
||||||
|
"3. Перейди по полученной ссылке\n\n"
|
||||||
|
"После привязки ты сможешь:\n"
|
||||||
|
"• Смотреть свои марафоны\n"
|
||||||
|
"• Получать уведомления о событиях\n"
|
||||||
|
"• Следить за статистикой",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("help"))
|
||||||
|
@router.message(F.text == "❓ Помощь")
|
||||||
|
async def cmd_help(message: Message):
|
||||||
|
"""Handle /help command."""
|
||||||
|
await message.answer(
|
||||||
|
"<b>Справка по командам:</b>\n\n"
|
||||||
|
"/start - Начать работу с ботом\n"
|
||||||
|
"/marathons - Мои марафоны\n"
|
||||||
|
"/stats - Моя статистика\n"
|
||||||
|
"/settings - Настройки уведомлений\n"
|
||||||
|
"/help - Эта справка\n\n"
|
||||||
|
"<b>Уведомления:</b>\n"
|
||||||
|
"Бот присылает уведомления о:\n"
|
||||||
|
"• 🌟 Golden Hour - очки x1.5\n"
|
||||||
|
"• 🎰 Jackpot - очки x3\n"
|
||||||
|
"• ⚡ Double Risk - половина очков, дропы бесплатны\n"
|
||||||
|
"• 👥 Common Enemy - общий челлендж\n"
|
||||||
|
"• 🚀 Старт/финиш марафонов\n"
|
||||||
|
"• ⚠️ Споры по заданиям",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
1
bot/keyboards/__init__.py
Normal file
1
bot/keyboards/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot keyboards
|
||||||
42
bot/keyboards/inline.py
Normal file
42
bot/keyboards/inline.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
|
||||||
|
|
||||||
|
def get_marathons_keyboard(marathons: list) -> InlineKeyboardMarkup:
|
||||||
|
"""Create keyboard with marathon buttons."""
|
||||||
|
buttons = []
|
||||||
|
|
||||||
|
for marathon in marathons:
|
||||||
|
status_emoji = {
|
||||||
|
"preparing": "⏳",
|
||||||
|
"active": "🎮",
|
||||||
|
"finished": "🏁"
|
||||||
|
}.get(marathon.get("status"), "❓")
|
||||||
|
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{status_emoji} {marathon.get('title', 'Marathon')}",
|
||||||
|
callback_data=f"marathon:{marathon.get('id')}"
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
|
def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
|
||||||
|
"""Create keyboard for marathon details view."""
|
||||||
|
buttons = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔄 Обновить",
|
||||||
|
callback_data=f"marathon:{marathon_id}"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="◀️ Назад к списку",
|
||||||
|
callback_data="back_to_marathons"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
21
bot/keyboards/main_menu.py
Normal file
21
bot/keyboards/main_menu.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
|
||||||
|
|
||||||
|
|
||||||
|
def get_main_menu() -> ReplyKeyboardMarkup:
|
||||||
|
"""Create main menu keyboard."""
|
||||||
|
keyboard = [
|
||||||
|
[
|
||||||
|
KeyboardButton(text="📊 Мои марафоны"),
|
||||||
|
KeyboardButton(text="📈 Статистика")
|
||||||
|
],
|
||||||
|
[
|
||||||
|
KeyboardButton(text="⚙️ Настройки"),
|
||||||
|
KeyboardButton(text="❓ Помощь")
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
return ReplyKeyboardMarkup(
|
||||||
|
keyboard=keyboard,
|
||||||
|
resize_keyboard=True,
|
||||||
|
input_field_placeholder="Выбери действие..."
|
||||||
|
)
|
||||||
100
bot/main.py
Normal file
100
bot/main.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from handlers import start, marathons, link
|
||||||
|
from middlewares.logging import LoggingMiddleware
|
||||||
|
|
||||||
|
# Configure logging to stdout with DEBUG level
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Set aiogram logging level
|
||||||
|
logging.getLogger("aiogram").setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Health check state
|
||||||
|
bot_running = False
|
||||||
|
|
||||||
|
|
||||||
|
async def health_handler(request):
|
||||||
|
"""Health check endpoint"""
|
||||||
|
if bot_running:
|
||||||
|
return web.json_response({"status": "ok", "service": "telegram-bot"})
|
||||||
|
return web.json_response({"status": "starting"}, status=503)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_health_server():
|
||||||
|
"""Start health check HTTP server"""
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_get("/health", health_handler)
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "0.0.0.0", 8080)
|
||||||
|
await site.start()
|
||||||
|
logger.info("Health check server started on port 8080")
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
global bot_running
|
||||||
|
|
||||||
|
logger.info("="*50)
|
||||||
|
logger.info("Starting Game Marathon Bot...")
|
||||||
|
logger.info(f"API_URL: {settings.API_URL}")
|
||||||
|
logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...")
|
||||||
|
logger.info("="*50)
|
||||||
|
|
||||||
|
# Start health check server
|
||||||
|
health_runner = await start_health_server()
|
||||||
|
|
||||||
|
bot = Bot(
|
||||||
|
token=settings.TELEGRAM_BOT_TOKEN,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get bot username for deep links
|
||||||
|
bot_info = await bot.get_me()
|
||||||
|
settings.BOT_USERNAME = bot_info.username
|
||||||
|
logger.info(f"Bot info: @{settings.BOT_USERNAME} (id={bot_info.id})")
|
||||||
|
|
||||||
|
dp = Dispatcher()
|
||||||
|
|
||||||
|
# Register middleware
|
||||||
|
dp.message.middleware(LoggingMiddleware())
|
||||||
|
logger.info("Logging middleware registered")
|
||||||
|
|
||||||
|
# Register routers
|
||||||
|
logger.info("Registering routers...")
|
||||||
|
dp.include_router(start.router)
|
||||||
|
dp.include_router(link.router)
|
||||||
|
dp.include_router(marathons.router)
|
||||||
|
logger.info("Routers registered: start, link, marathons")
|
||||||
|
|
||||||
|
# Mark bot as running
|
||||||
|
bot_running = True
|
||||||
|
|
||||||
|
# Start polling
|
||||||
|
logger.info("Deleting webhook and starting polling...")
|
||||||
|
await bot.delete_webhook(drop_pending_updates=True)
|
||||||
|
logger.info("Polling started! Waiting for messages...")
|
||||||
|
try:
|
||||||
|
await dp.start_polling(bot)
|
||||||
|
finally:
|
||||||
|
bot_running = False
|
||||||
|
await health_runner.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
1
bot/middlewares/__init__.py
Normal file
1
bot/middlewares/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot middlewares
|
||||||
28
bot/middlewares/logging.py
Normal file
28
bot/middlewares/logging.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Awaitable, Callable, Dict
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import Message, Update
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingMiddleware(BaseMiddleware):
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: Message,
|
||||||
|
data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
logger.info("="*60)
|
||||||
|
logger.info(f"[MIDDLEWARE] Incoming message from user {event.from_user.id}")
|
||||||
|
logger.info(f"[MIDDLEWARE] Username: @{event.from_user.username}")
|
||||||
|
logger.info(f"[MIDDLEWARE] Text: {event.text}")
|
||||||
|
logger.info(f"[MIDDLEWARE] Message ID: {event.message_id}")
|
||||||
|
logger.info(f"[MIDDLEWARE] Chat ID: {event.chat.id}")
|
||||||
|
logger.info("="*60)
|
||||||
|
|
||||||
|
result = await handler(event, data)
|
||||||
|
|
||||||
|
logger.info(f"[MIDDLEWARE] Handler completed for message {event.message_id}")
|
||||||
|
return result
|
||||||
5
bot/requirements.txt
Normal file
5
bot/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
aiogram==3.23.0
|
||||||
|
aiohttp==3.10.5
|
||||||
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
|
python-dotenv==1.0.1
|
||||||
1
bot/services/__init__.py
Normal file
1
bot/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot services
|
||||||
134
bot/services/api_client.py
Normal file
134
bot/services/api_client.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class APIClient:
|
||||||
|
"""HTTP client for backend API communication."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = settings.API_URL
|
||||||
|
self._session: aiohttp.ClientSession | None = None
|
||||||
|
logger.info(f"[APIClient] Initialized with base_url: {self.base_url}")
|
||||||
|
|
||||||
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
logger.info("[APIClient] Creating new aiohttp session")
|
||||||
|
self._session = aiohttp.ClientSession()
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
**kwargs
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Make HTTP request to backend API."""
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f"{self.base_url}/api/v1{endpoint}"
|
||||||
|
|
||||||
|
# Add bot secret header for authentication
|
||||||
|
headers = kwargs.pop("headers", {})
|
||||||
|
if settings.BOT_API_SECRET:
|
||||||
|
headers["X-Bot-Secret"] = settings.BOT_API_SECRET
|
||||||
|
|
||||||
|
logger.info(f"[APIClient] {method} {url}")
|
||||||
|
if 'json' in kwargs:
|
||||||
|
logger.info(f"[APIClient] Request body: {kwargs['json']}")
|
||||||
|
if 'params' in kwargs:
|
||||||
|
logger.info(f"[APIClient] Request params: {kwargs['params']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.request(method, url, headers=headers, **kwargs) as response:
|
||||||
|
logger.info(f"[APIClient] Response status: {response.status}")
|
||||||
|
response_text = await response.text()
|
||||||
|
logger.info(f"[APIClient] Response body: {response_text[:500]}")
|
||||||
|
|
||||||
|
if response.status == 200:
|
||||||
|
import json
|
||||||
|
return json.loads(response_text)
|
||||||
|
elif response.status == 404:
|
||||||
|
logger.warning(f"[APIClient] 404 Not Found")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.error(f"[APIClient] API error {response.status}: {response_text}")
|
||||||
|
return {"error": response_text}
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.error(f"[APIClient] Request failed: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[APIClient] Unexpected error: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
async def confirm_telegram_link(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
telegram_id: int,
|
||||||
|
telegram_username: str | None,
|
||||||
|
telegram_first_name: str | None = None,
|
||||||
|
telegram_last_name: str | None = None,
|
||||||
|
telegram_avatar_url: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Confirm Telegram account linking."""
|
||||||
|
result = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/telegram/confirm-link",
|
||||||
|
json={
|
||||||
|
"token": token,
|
||||||
|
"telegram_id": telegram_id,
|
||||||
|
"telegram_username": telegram_username,
|
||||||
|
"telegram_first_name": telegram_first_name,
|
||||||
|
"telegram_last_name": telegram_last_name,
|
||||||
|
"telegram_avatar_url": telegram_avatar_url
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result or {"error": "Не удалось связаться с сервером"}
|
||||||
|
|
||||||
|
async def get_user_by_telegram_id(self, telegram_id: int) -> dict[str, Any] | None:
|
||||||
|
"""Get user by Telegram ID."""
|
||||||
|
return await self._request("GET", f"/telegram/user/{telegram_id}")
|
||||||
|
|
||||||
|
async def unlink_telegram(self, telegram_id: int) -> dict[str, Any]:
|
||||||
|
"""Unlink Telegram account."""
|
||||||
|
result = await self._request(
|
||||||
|
"POST",
|
||||||
|
f"/telegram/unlink/{telegram_id}"
|
||||||
|
)
|
||||||
|
return result or {"error": "Не удалось связаться с сервером"}
|
||||||
|
|
||||||
|
async def get_user_marathons(self, telegram_id: int) -> list[dict[str, Any]]:
|
||||||
|
"""Get user's marathons."""
|
||||||
|
result = await self._request("GET", f"/telegram/marathons/{telegram_id}")
|
||||||
|
if isinstance(result, list):
|
||||||
|
return result
|
||||||
|
return result.get("marathons", []) if result else []
|
||||||
|
|
||||||
|
async def get_marathon_details(
|
||||||
|
self,
|
||||||
|
marathon_id: int,
|
||||||
|
telegram_id: int
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Get marathon details for user."""
|
||||||
|
return await self._request(
|
||||||
|
"GET",
|
||||||
|
f"/telegram/marathon/{marathon_id}",
|
||||||
|
params={"telegram_id": telegram_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_user_stats(self, telegram_id: int) -> dict[str, Any] | None:
|
||||||
|
"""Get user's overall statistics."""
|
||||||
|
return await self._request("GET", f"/telegram/stats/{telegram_id}")
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the HTTP session."""
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Global API client instance
|
||||||
|
api_client = APIClient()
|
||||||
@@ -27,7 +27,17 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||||
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
|
||||||
|
BOT_API_SECRET: ${BOT_API_SECRET:-}
|
||||||
DEBUG: ${DEBUG:-false}
|
DEBUG: ${DEBUG:-false}
|
||||||
|
# S3 Storage
|
||||||
|
S3_ENABLED: ${S3_ENABLED:-false}
|
||||||
|
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
|
||||||
|
S3_REGION: ${S3_REGION:-ru-1}
|
||||||
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||||
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||||
|
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
|
||||||
|
S3_PUBLIC_URL: ${S3_PUBLIC_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/uploads:/app/uploads
|
- ./backend/uploads:/app/uploads
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
@@ -64,5 +74,68 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: ./bot
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: marathon-bot
|
||||||
|
environment:
|
||||||
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||||
|
- API_URL=http://backend:8000
|
||||||
|
- BOT_API_SECRET=${BOT_API_SECRET:-}
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
status:
|
||||||
|
build:
|
||||||
|
context: ./status-service
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: marathon-status
|
||||||
|
environment:
|
||||||
|
BACKEND_URL: http://backend:8000
|
||||||
|
FRONTEND_URL: http://frontend:80
|
||||||
|
BOT_URL: http://bot:8080
|
||||||
|
EXTERNAL_URL: ${EXTERNAL_URL:-}
|
||||||
|
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||||
|
CHECK_INTERVAL: "30"
|
||||||
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||||
|
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
|
||||||
|
volumes:
|
||||||
|
- status_data:/app/data
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
- bot
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backup:
|
||||||
|
build:
|
||||||
|
context: ./backup-service
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: marathon-backup
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: "5432"
|
||||||
|
DB_NAME: marathon
|
||||||
|
DB_USER: marathon
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-marathon}
|
||||||
|
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
|
||||||
|
S3_REGION: ${S3_REGION:-ru-1}
|
||||||
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||||
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||||
|
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
|
||||||
|
S3_BACKUP_PREFIX: ${S3_BACKUP_PREFIX:-backups/}
|
||||||
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||||
|
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
|
||||||
|
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
status_data:
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ FROM node:20-alpine as build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY package.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm install
|
RUN npm ci --network-timeout 300000
|
||||||
|
|
||||||
# Copy source
|
# Copy source
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
4253
frontend/package-lock.json
generated
Normal file
4253
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
||||||
|
import { BannedScreen } from '@/components/BannedScreen'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
import { Layout } from '@/components/layout/Layout'
|
import { Layout } from '@/components/layout/Layout'
|
||||||
@@ -14,6 +16,24 @@ import { MarathonPage } from '@/pages/MarathonPage'
|
|||||||
import { LobbyPage } from '@/pages/LobbyPage'
|
import { LobbyPage } from '@/pages/LobbyPage'
|
||||||
import { PlayPage } from '@/pages/PlayPage'
|
import { PlayPage } from '@/pages/PlayPage'
|
||||||
import { LeaderboardPage } from '@/pages/LeaderboardPage'
|
import { LeaderboardPage } from '@/pages/LeaderboardPage'
|
||||||
|
import { InvitePage } from '@/pages/InvitePage'
|
||||||
|
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
|
||||||
|
import { ProfilePage } from '@/pages/ProfilePage'
|
||||||
|
import { UserProfilePage } from '@/pages/UserProfilePage'
|
||||||
|
import { NotFoundPage } from '@/pages/NotFoundPage'
|
||||||
|
import { TeapotPage } from '@/pages/TeapotPage'
|
||||||
|
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
||||||
|
|
||||||
|
// Admin Pages
|
||||||
|
import {
|
||||||
|
AdminLayout,
|
||||||
|
AdminDashboardPage,
|
||||||
|
AdminUsersPage,
|
||||||
|
AdminMarathonsPage,
|
||||||
|
AdminLogsPage,
|
||||||
|
AdminBroadcastPage,
|
||||||
|
AdminContentPage,
|
||||||
|
} from '@/pages/admin'
|
||||||
|
|
||||||
// Protected route wrapper
|
// Protected route wrapper
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -38,11 +58,30 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const banInfo = useAuthStore((state) => state.banInfo)
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||||
|
|
||||||
|
// Show banned screen if user is authenticated and banned
|
||||||
|
if (isAuthenticated && banInfo) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ToastContainer />
|
||||||
|
<BannedScreen banInfo={banInfo} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToastContainer />
|
||||||
|
<ConfirmModal />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
|
|
||||||
|
{/* Public invite page */}
|
||||||
|
<Route path="invite/:code" element={<InvitePage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="login"
|
path="login"
|
||||||
element={
|
element={
|
||||||
@@ -114,8 +153,59 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="assignments/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AssignmentDetailPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Profile routes */}
|
||||||
|
<Route
|
||||||
|
path="profile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProfilePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="users/:id" element={<UserProfilePage />} />
|
||||||
|
|
||||||
|
{/* Easter egg - 418 I'm a teapot */}
|
||||||
|
<Route path="418" element={<TeapotPage />} />
|
||||||
|
<Route path="teapot" element={<TeapotPage />} />
|
||||||
|
<Route path="tea" element={<TeapotPage />} />
|
||||||
|
|
||||||
|
{/* Server error page */}
|
||||||
|
<Route path="500" element={<ServerErrorPage />} />
|
||||||
|
<Route path="error" element={<ServerErrorPage />} />
|
||||||
|
|
||||||
|
{/* Admin routes */}
|
||||||
|
<Route
|
||||||
|
path="admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<AdminDashboardPage />} />
|
||||||
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
|
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||||
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
|
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||||
|
<Route path="content" element={<AdminContentPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* 404 - must be last */}
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
125
frontend/src/api/admin.ts
Normal file
125
frontend/src/api/admin.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import client from './client'
|
||||||
|
import type {
|
||||||
|
AdminUser,
|
||||||
|
AdminMarathon,
|
||||||
|
UserRole,
|
||||||
|
PlatformStats,
|
||||||
|
AdminLogsResponse,
|
||||||
|
BroadcastResponse,
|
||||||
|
StaticContent,
|
||||||
|
DashboardStats
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
// Dashboard
|
||||||
|
getDashboard: async (): Promise<DashboardStats> => {
|
||||||
|
const response = await client.get<DashboardStats>('/admin/dashboard')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Users
|
||||||
|
listUsers: async (skip = 0, limit = 50, search?: string, bannedOnly = false): Promise<AdminUser[]> => {
|
||||||
|
const params: Record<string, unknown> = { skip, limit, banned_only: bannedOnly }
|
||||||
|
if (search) params.search = search
|
||||||
|
const response = await client.get<AdminUser[]>('/admin/users', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser: async (id: number): Promise<AdminUser> => {
|
||||||
|
const response = await client.get<AdminUser>(`/admin/users/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
setUserRole: async (id: number, role: UserRole): Promise<AdminUser> => {
|
||||||
|
const response = await client.patch<AdminUser>(`/admin/users/${id}/role`, { role })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: async (id: number): Promise<void> => {
|
||||||
|
await client.delete(`/admin/users/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
banUser: async (id: number, reason: string, bannedUntil?: string): Promise<AdminUser> => {
|
||||||
|
const response = await client.post<AdminUser>(`/admin/users/${id}/ban`, {
|
||||||
|
reason,
|
||||||
|
banned_until: bannedUntil || null,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
unbanUser: async (id: number): Promise<AdminUser> => {
|
||||||
|
const response = await client.post<AdminUser>(`/admin/users/${id}/unban`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Marathons
|
||||||
|
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
||||||
|
const params: Record<string, unknown> = { skip, limit }
|
||||||
|
if (search) params.search = search
|
||||||
|
const response = await client.get<AdminMarathon[]>('/admin/marathons', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMarathon: async (id: number): Promise<void> => {
|
||||||
|
await client.delete(`/admin/marathons/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
forceFinishMarathon: async (id: number): Promise<void> => {
|
||||||
|
await client.post(`/admin/marathons/${id}/force-finish`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
getStats: async (): Promise<PlatformStats> => {
|
||||||
|
const response = await client.get<PlatformStats>('/admin/stats')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
getLogs: async (skip = 0, limit = 50, action?: string, adminId?: number): Promise<AdminLogsResponse> => {
|
||||||
|
const params: Record<string, unknown> = { skip, limit }
|
||||||
|
if (action) params.action = action
|
||||||
|
if (adminId) params.admin_id = adminId
|
||||||
|
const response = await client.get<AdminLogsResponse>('/admin/logs', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Broadcast
|
||||||
|
broadcastToAll: async (message: string): Promise<BroadcastResponse> => {
|
||||||
|
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => {
|
||||||
|
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Static Content
|
||||||
|
listContent: async (): Promise<StaticContent[]> => {
|
||||||
|
const response = await client.get<StaticContent[]>('/admin/content')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getContent: async (key: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.get<StaticContent>(`/admin/content/${key}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
updateContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.put<StaticContent>(`/admin/content/${key}`, { title, content })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
createContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public content API (no auth required)
|
||||||
|
export const contentApi = {
|
||||||
|
getPublicContent: async (key: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.get<StaticContent>(`/content/${key}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
47
frontend/src/api/assignments.ts
Normal file
47
frontend/src/api/assignments.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import client from './client'
|
||||||
|
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment } from '@/types'
|
||||||
|
|
||||||
|
export const assignmentsApi = {
|
||||||
|
// Get detailed assignment info with proofs and dispute
|
||||||
|
getDetail: async (assignmentId: number): Promise<AssignmentDetail> => {
|
||||||
|
const response = await client.get<AssignmentDetail>(`/assignments/${assignmentId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create a dispute against an assignment
|
||||||
|
createDispute: async (assignmentId: number, reason: string): Promise<Dispute> => {
|
||||||
|
const response = await client.post<Dispute>(`/assignments/${assignmentId}/dispute`, { reason })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add a comment to a dispute
|
||||||
|
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
|
||||||
|
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vote on a dispute (true = valid/proof is OK, false = invalid/proof is not OK)
|
||||||
|
vote: async (disputeId: number, vote: boolean): Promise<{ message: string }> => {
|
||||||
|
const response = await client.post<{ message: string }>(`/disputes/${disputeId}/vote`, { vote })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get current user's returned assignments
|
||||||
|
getReturnedAssignments: async (marathonId: number): Promise<ReturnedAssignment[]> => {
|
||||||
|
const response = await client.get<ReturnedAssignment[]>(`/marathons/${marathonId}/returned-assignments`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get proof media as blob URL (supports both images and videos)
|
||||||
|
getProofMediaUrl: async (assignmentId: number): Promise<{ url: string; type: 'image' | 'video' }> => {
|
||||||
|
const response = await client.get(`/assignments/${assignmentId}/proof-media`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
const contentType = response.headers['content-type'] || ''
|
||||||
|
const isVideo = contentType.startsWith('video/')
|
||||||
|
return {
|
||||||
|
url: URL.createObjectURL(response.data),
|
||||||
|
type: isVideo ? 'video' : 'image',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { TokenResponse, User } from '@/types'
|
import type { TokenResponse, LoginResponse, User } from '@/types'
|
||||||
|
|
||||||
export interface RegisterData {
|
export interface RegisterData {
|
||||||
login: string
|
login: string
|
||||||
@@ -18,8 +18,15 @@ export const authApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
login: async (data: LoginData): Promise<TokenResponse> => {
|
login: async (data: LoginData): Promise<LoginResponse> => {
|
||||||
const response = await client.post<TokenResponse>('/auth/login', data)
|
const response = await client.post<LoginResponse>('/auth/login', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
verify2FA: async (sessionId: number, code: string): Promise<TokenResponse> => {
|
||||||
|
const response = await client.post<TokenResponse>('/auth/2fa/verify', null, {
|
||||||
|
params: { session_id: sessionId, code }
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
9
frontend/src/api/challenges.ts
Normal file
9
frontend/src/api/challenges.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import client from './client'
|
||||||
|
import type { Challenge } from '@/types'
|
||||||
|
|
||||||
|
export const challengesApi = {
|
||||||
|
list: async (marathonId: number): Promise<Challenge[]> => {
|
||||||
|
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/challenges`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user