Compare commits
34 Commits
a513dc2207
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b1490dec8 | |||
| 765da3c37f | |||
| 9f79daf796 | |||
| 58c390c768 | |||
| 72089d1b47 | |||
| 9cfe99ff7e | |||
| 2d8e80f258 | |||
| f78eacb1a5 | |||
| cf0df928b1 | |||
| 5c452c5c74 | |||
| 2b6f2888ee | |||
| b6eecc4483 | |||
| 3256c40841 | |||
| 146ed5e489 | |||
| cd78a99ce7 | |||
| 76de7ccbdb | |||
| e63d6c8489 | |||
| 1751c4dd4c | |||
| 2874b64481 | |||
| 4488a13808 | |||
| ca49e42f74 | |||
| 18fe95effc | |||
| 6a7717a474 | |||
| 65b2512d8c | |||
| 81d992abe6 | |||
| 9014d5d79d | |||
| 18ffff5473 | |||
| 475e2cf4cd | |||
| 7a3576aec0 | |||
| d295ff2aff | |||
| 1e751f7af3 | |||
| 89dbe2c018 | |||
| 1cedfeb3ee | |||
| 1e723e7bcd |
@@ -32,3 +32,5 @@ PUBLIC_URL=https://your-domain.com
|
||||
|
||||
# Frontend (for build)
|
||||
VITE_API_URL=/api/v1
|
||||
|
||||
RATE_LIMIT_ENABLED=false
|
||||
6
Makefile
6
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help dev up down build build-no-cache logs restart clean migrate shell db-shell frontend-shell backend-shell lint test
|
||||
.PHONY: help dev up down build build-no-cache logs logs-bot restart clean migrate shell db-shell frontend-shell backend-shell lint test
|
||||
|
||||
DC = sudo docker-compose
|
||||
|
||||
@@ -14,6 +14,7 @@ help:
|
||||
@echo " make logs - Show logs (all services)"
|
||||
@echo " make logs-b - Show backend logs"
|
||||
@echo " make logs-f - Show frontend logs"
|
||||
@echo " make logs-bot - Show Telegram bot logs"
|
||||
@echo ""
|
||||
@echo " Build:"
|
||||
@echo " make build - Build all containers (with cache)"
|
||||
@@ -63,6 +64,9 @@ logs-b:
|
||||
logs-f:
|
||||
$(DC) logs -f frontend
|
||||
|
||||
logs-bot:
|
||||
$(DC) logs -f bot
|
||||
|
||||
# Build
|
||||
build:
|
||||
$(DC) build
|
||||
|
||||
@@ -26,8 +26,8 @@ def upgrade() -> None:
|
||||
|
||||
# Insert admin user (ignore if already exists)
|
||||
op.execute(f"""
|
||||
INSERT INTO users (login, password_hash, nickname, role, created_at)
|
||||
VALUES ('admin', '{password_hash}', 'Admin', 'admin', NOW())
|
||||
INSERT INTO users (login, password_hash, nickname, role, is_banned, created_at)
|
||||
VALUES ('admin', '{password_hash}', 'Admin', 'admin', false, NOW())
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
password_hash = '{password_hash}',
|
||||
role = 'admin'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Add marathon cover_url field
|
||||
"""Add marathon cover fields (cover_path and cover_url)
|
||||
|
||||
Revision ID: 019_add_marathon_cover
|
||||
Revises: 018_seed_static_content
|
||||
@@ -27,6 +27,11 @@ def column_exists(table_name: str, column_name: str) -> bool:
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# cover_path - путь к файлу в S3 хранилище
|
||||
if not column_exists('marathons', 'cover_path'):
|
||||
op.add_column('marathons', sa.Column('cover_path', sa.String(500), nullable=True))
|
||||
|
||||
# cover_url - API URL для доступа к обложке
|
||||
if not column_exists('marathons', 'cover_url'):
|
||||
op.add_column('marathons', sa.Column('cover_url', sa.String(500), nullable=True))
|
||||
|
||||
@@ -34,3 +39,5 @@ def upgrade() -> None:
|
||||
def downgrade() -> None:
|
||||
if column_exists('marathons', 'cover_url'):
|
||||
op.drop_column('marathons', 'cover_url')
|
||||
if column_exists('marathons', 'cover_path'):
|
||||
op.drop_column('marathons', 'cover_path')
|
||||
|
||||
156
backend/alembic/versions/020_add_game_types.py
Normal file
156
backend/alembic/versions/020_add_game_types.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Add game types (playthrough/challenges) and bonus assignments
|
||||
|
||||
Revision ID: 020_add_game_types
|
||||
Revises: 019_add_marathon_cover
|
||||
Create Date: 2024-12-26
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '020_add_game_types'
|
||||
down_revision: Union[str, None] = '019_add_marathon_cover'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === Games table: добавляем поля для типа игры ===
|
||||
|
||||
# game_type - тип игры (playthrough/challenges)
|
||||
if not column_exists('games', 'game_type'):
|
||||
op.add_column('games', sa.Column(
|
||||
'game_type',
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default='challenges'
|
||||
))
|
||||
|
||||
# playthrough_points - очки за прохождение
|
||||
if not column_exists('games', 'playthrough_points'):
|
||||
op.add_column('games', sa.Column(
|
||||
'playthrough_points',
|
||||
sa.Integer(),
|
||||
nullable=True
|
||||
))
|
||||
|
||||
# playthrough_description - описание прохождения
|
||||
if not column_exists('games', 'playthrough_description'):
|
||||
op.add_column('games', sa.Column(
|
||||
'playthrough_description',
|
||||
sa.Text(),
|
||||
nullable=True
|
||||
))
|
||||
|
||||
# playthrough_proof_type - тип пруфа для прохождения
|
||||
if not column_exists('games', 'playthrough_proof_type'):
|
||||
op.add_column('games', sa.Column(
|
||||
'playthrough_proof_type',
|
||||
sa.String(20),
|
||||
nullable=True
|
||||
))
|
||||
|
||||
# playthrough_proof_hint - подсказка для пруфа
|
||||
if not column_exists('games', 'playthrough_proof_hint'):
|
||||
op.add_column('games', sa.Column(
|
||||
'playthrough_proof_hint',
|
||||
sa.Text(),
|
||||
nullable=True
|
||||
))
|
||||
|
||||
# === Assignments table: добавляем поля для прохождений ===
|
||||
|
||||
# game_id - ссылка на игру (для playthrough)
|
||||
if not column_exists('assignments', 'game_id'):
|
||||
op.add_column('assignments', sa.Column(
|
||||
'game_id',
|
||||
sa.Integer(),
|
||||
sa.ForeignKey('games.id', ondelete='CASCADE'),
|
||||
nullable=True
|
||||
))
|
||||
op.create_index('ix_assignments_game_id', 'assignments', ['game_id'])
|
||||
|
||||
# is_playthrough - флаг прохождения
|
||||
if not column_exists('assignments', 'is_playthrough'):
|
||||
op.add_column('assignments', sa.Column(
|
||||
'is_playthrough',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default='false'
|
||||
))
|
||||
|
||||
# Делаем challenge_id nullable (для playthrough заданий)
|
||||
# SQLite не поддерживает ALTER COLUMN, поэтому проверяем dialect
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != 'sqlite':
|
||||
op.alter_column('assignments', 'challenge_id', nullable=True)
|
||||
|
||||
# === Создаём таблицу bonus_assignments ===
|
||||
|
||||
if not table_exists('bonus_assignments'):
|
||||
op.create_table(
|
||||
'bonus_assignments',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('main_assignment_id', sa.Integer(),
|
||||
sa.ForeignKey('assignments.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True),
|
||||
sa.Column('challenge_id', sa.Integer(),
|
||||
sa.ForeignKey('challenges.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
|
||||
sa.Column('proof_path', sa.String(500), nullable=True),
|
||||
sa.Column('proof_url', sa.Text(), nullable=True),
|
||||
sa.Column('proof_comment', sa.Text(), nullable=True),
|
||||
sa.Column('points_earned', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Удаляем таблицу bonus_assignments
|
||||
if table_exists('bonus_assignments'):
|
||||
op.drop_table('bonus_assignments')
|
||||
|
||||
# Удаляем поля из assignments
|
||||
if column_exists('assignments', 'is_playthrough'):
|
||||
op.drop_column('assignments', 'is_playthrough')
|
||||
|
||||
if column_exists('assignments', 'game_id'):
|
||||
op.drop_index('ix_assignments_game_id', 'assignments')
|
||||
op.drop_column('assignments', 'game_id')
|
||||
|
||||
# Удаляем поля из games
|
||||
if column_exists('games', 'playthrough_proof_hint'):
|
||||
op.drop_column('games', 'playthrough_proof_hint')
|
||||
|
||||
if column_exists('games', 'playthrough_proof_type'):
|
||||
op.drop_column('games', 'playthrough_proof_type')
|
||||
|
||||
if column_exists('games', 'playthrough_description'):
|
||||
op.drop_column('games', 'playthrough_description')
|
||||
|
||||
if column_exists('games', 'playthrough_points'):
|
||||
op.drop_column('games', 'playthrough_points')
|
||||
|
||||
if column_exists('games', 'game_type'):
|
||||
op.drop_column('games', 'game_type')
|
||||
100
backend/alembic/versions/021_add_bonus_disputes.py
Normal file
100
backend/alembic/versions/021_add_bonus_disputes.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Add bonus assignment disputes support
|
||||
|
||||
Revision ID: 021_add_bonus_disputes
|
||||
Revises: 020_add_game_types
|
||||
Create Date: 2024-12-29
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '021_add_bonus_disputes'
|
||||
down_revision: Union[str, None] = '020_add_game_types'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
constraints = inspector.get_unique_constraints(table_name)
|
||||
return any(c['name'] == constraint_name for c in constraints)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
# Add bonus_assignment_id column to disputes
|
||||
if not column_exists('disputes', 'bonus_assignment_id'):
|
||||
op.add_column('disputes', sa.Column(
|
||||
'bonus_assignment_id',
|
||||
sa.Integer(),
|
||||
nullable=True
|
||||
))
|
||||
op.create_foreign_key(
|
||||
'fk_disputes_bonus_assignment_id',
|
||||
'disputes',
|
||||
'bonus_assignments',
|
||||
['bonus_assignment_id'],
|
||||
['id'],
|
||||
ondelete='CASCADE'
|
||||
)
|
||||
op.create_index('ix_disputes_bonus_assignment_id', 'disputes', ['bonus_assignment_id'])
|
||||
|
||||
# Drop the unique index on assignment_id first (required before making nullable)
|
||||
if bind.dialect.name != 'sqlite':
|
||||
try:
|
||||
op.drop_index('ix_disputes_assignment_id', 'disputes')
|
||||
except Exception:
|
||||
pass # Index might not exist
|
||||
|
||||
# Make assignment_id nullable (PostgreSQL only, SQLite doesn't support ALTER COLUMN)
|
||||
if bind.dialect.name != 'sqlite':
|
||||
op.alter_column('disputes', 'assignment_id', nullable=True)
|
||||
|
||||
# Create a non-unique index on assignment_id
|
||||
try:
|
||||
op.create_index('ix_disputes_assignment_id_non_unique', 'disputes', ['assignment_id'])
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
# Remove non-unique index
|
||||
try:
|
||||
op.drop_index('ix_disputes_assignment_id_non_unique', table_name='disputes')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Make assignment_id not nullable again
|
||||
if bind.dialect.name != 'sqlite':
|
||||
op.alter_column('disputes', 'assignment_id', nullable=False)
|
||||
|
||||
# Recreate unique index
|
||||
try:
|
||||
op.create_index('ix_disputes_assignment_id', 'disputes', ['assignment_id'], unique=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove foreign key, index and column
|
||||
if column_exists('disputes', 'bonus_assignment_id'):
|
||||
try:
|
||||
op.drop_constraint('fk_disputes_bonus_assignment_id', 'disputes', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
op.drop_index('ix_disputes_bonus_assignment_id', table_name='disputes')
|
||||
op.drop_column('disputes', 'bonus_assignment_id')
|
||||
45
backend/alembic/versions/022_add_notification_settings.py
Normal file
45
backend/alembic/versions/022_add_notification_settings.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Add notification settings to users
|
||||
|
||||
Revision ID: 022_add_notification_settings
|
||||
Revises: 021_add_bonus_disputes
|
||||
Create Date: 2025-01-04
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '022_add_notification_settings'
|
||||
down_revision: Union[str, None] = '021_add_bonus_disputes'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add notification settings (all enabled by default)
|
||||
if not column_exists('users', 'notify_events'):
|
||||
op.add_column('users', sa.Column('notify_events', sa.Boolean(), server_default='true', nullable=False))
|
||||
if not column_exists('users', 'notify_disputes'):
|
||||
op.add_column('users', sa.Column('notify_disputes', sa.Boolean(), server_default='true', nullable=False))
|
||||
if not column_exists('users', 'notify_moderation'):
|
||||
op.add_column('users', sa.Column('notify_moderation', sa.Boolean(), server_default='true', nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists('users', 'notify_moderation'):
|
||||
op.drop_column('users', 'notify_moderation')
|
||||
if column_exists('users', 'notify_disputes'):
|
||||
op.drop_column('users', 'notify_disputes')
|
||||
if column_exists('users', 'notify_events'):
|
||||
op.drop_column('users', 'notify_events')
|
||||
230
backend/alembic/versions/023_add_shop_system.py
Normal file
230
backend/alembic/versions/023_add_shop_system.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""Add shop system with coins, items, inventory, certification
|
||||
|
||||
Revision ID: 023_add_shop_system
|
||||
Revises: 022_add_notification_settings
|
||||
Create Date: 2025-01-05
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '023_add_shop_system'
|
||||
down_revision: Union[str, None] = '022_add_notification_settings'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === 1. Создаём таблицу shop_items ===
|
||||
if not table_exists('shop_items'):
|
||||
op.create_table(
|
||||
'shop_items',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('item_type', sa.String(30), nullable=False, index=True),
|
||||
sa.Column('code', sa.String(50), nullable=False, unique=True, index=True),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('price', sa.Integer(), nullable=False),
|
||||
sa.Column('rarity', sa.String(20), nullable=False, server_default='common'),
|
||||
sa.Column('asset_data', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('available_from', sa.DateTime(), nullable=True),
|
||||
sa.Column('available_until', sa.DateTime(), nullable=True),
|
||||
sa.Column('stock_limit', sa.Integer(), nullable=True),
|
||||
sa.Column('stock_remaining', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# === 2. Создаём таблицу user_inventory ===
|
||||
if not table_exists('user_inventory'):
|
||||
op.create_table(
|
||||
'user_inventory',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('quantity', sa.Integer(), nullable=False, server_default='1'),
|
||||
sa.Column('equipped', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('purchased_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
# === 3. Создаём таблицу coin_transactions ===
|
||||
if not table_exists('coin_transactions'):
|
||||
op.create_table(
|
||||
'coin_transactions',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('amount', sa.Integer(), nullable=False),
|
||||
sa.Column('transaction_type', sa.String(30), nullable=False),
|
||||
sa.Column('reference_type', sa.String(30), nullable=True),
|
||||
sa.Column('reference_id', sa.Integer(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# === 4. Создаём таблицу consumable_usages ===
|
||||
if not table_exists('consumable_usages'):
|
||||
op.create_table(
|
||||
'consumable_usages',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('marathon_id', sa.Integer(), sa.ForeignKey('marathons.id', ondelete='CASCADE'), nullable=True),
|
||||
sa.Column('assignment_id', sa.Integer(), sa.ForeignKey('assignments.id', ondelete='CASCADE'), nullable=True),
|
||||
sa.Column('used_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('effect_data', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# === 5. Добавляем поля в users ===
|
||||
|
||||
# coins_balance - баланс монет
|
||||
if not column_exists('users', 'coins_balance'):
|
||||
op.add_column('users', sa.Column('coins_balance', sa.Integer(), nullable=False, server_default='0'))
|
||||
|
||||
# equipped_frame_id - экипированная рамка
|
||||
if not column_exists('users', 'equipped_frame_id'):
|
||||
op.add_column('users', sa.Column('equipped_frame_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
|
||||
|
||||
# equipped_title_id - экипированный титул
|
||||
if not column_exists('users', 'equipped_title_id'):
|
||||
op.add_column('users', sa.Column('equipped_title_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
|
||||
|
||||
# equipped_name_color_id - экипированный цвет ника
|
||||
if not column_exists('users', 'equipped_name_color_id'):
|
||||
op.add_column('users', sa.Column('equipped_name_color_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
|
||||
|
||||
# equipped_background_id - экипированный фон
|
||||
if not column_exists('users', 'equipped_background_id'):
|
||||
op.add_column('users', sa.Column('equipped_background_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
|
||||
|
||||
# === 6. Добавляем поля сертификации в marathons ===
|
||||
|
||||
# certification_status - статус сертификации
|
||||
if not column_exists('marathons', 'certification_status'):
|
||||
op.add_column('marathons', sa.Column('certification_status', sa.String(20), nullable=False, server_default='none'))
|
||||
|
||||
# certification_requested_at - когда подана заявка
|
||||
if not column_exists('marathons', 'certification_requested_at'):
|
||||
op.add_column('marathons', sa.Column('certification_requested_at', sa.DateTime(), nullable=True))
|
||||
|
||||
# certified_at - когда сертифицирован
|
||||
if not column_exists('marathons', 'certified_at'):
|
||||
op.add_column('marathons', sa.Column('certified_at', sa.DateTime(), nullable=True))
|
||||
|
||||
# certified_by_id - кем сертифицирован
|
||||
if not column_exists('marathons', 'certified_by_id'):
|
||||
op.add_column('marathons', sa.Column('certified_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True))
|
||||
|
||||
# certification_rejection_reason - причина отказа
|
||||
if not column_exists('marathons', 'certification_rejection_reason'):
|
||||
op.add_column('marathons', sa.Column('certification_rejection_reason', sa.Text(), nullable=True))
|
||||
|
||||
# === 7. Добавляем настройки consumables в marathons ===
|
||||
|
||||
# allow_skips - разрешены ли скипы
|
||||
if not column_exists('marathons', 'allow_skips'):
|
||||
op.add_column('marathons', sa.Column('allow_skips', sa.Boolean(), nullable=False, server_default='true'))
|
||||
|
||||
# max_skips_per_participant - лимит скипов на участника
|
||||
if not column_exists('marathons', 'max_skips_per_participant'):
|
||||
op.add_column('marathons', sa.Column('max_skips_per_participant', sa.Integer(), nullable=True))
|
||||
|
||||
# allow_consumables - разрешены ли расходуемые
|
||||
if not column_exists('marathons', 'allow_consumables'):
|
||||
op.add_column('marathons', sa.Column('allow_consumables', sa.Boolean(), nullable=False, server_default='true'))
|
||||
|
||||
# === 8. Добавляем поля в participants ===
|
||||
|
||||
# coins_earned - заработано монет в марафоне
|
||||
if not column_exists('participants', 'coins_earned'):
|
||||
op.add_column('participants', sa.Column('coins_earned', sa.Integer(), nullable=False, server_default='0'))
|
||||
|
||||
# skips_used - использовано скипов
|
||||
if not column_exists('participants', 'skips_used'):
|
||||
op.add_column('participants', sa.Column('skips_used', sa.Integer(), nullable=False, server_default='0'))
|
||||
|
||||
# active_boost_multiplier - активный множитель буста
|
||||
if not column_exists('participants', 'active_boost_multiplier'):
|
||||
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
|
||||
|
||||
# active_boost_expires_at - когда истекает буст
|
||||
if not column_exists('participants', 'active_boost_expires_at'):
|
||||
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
|
||||
|
||||
# has_shield - есть ли активный щит
|
||||
if not column_exists('participants', 'has_shield'):
|
||||
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# === Удаляем поля из participants ===
|
||||
if column_exists('participants', 'has_shield'):
|
||||
op.drop_column('participants', 'has_shield')
|
||||
if column_exists('participants', 'active_boost_expires_at'):
|
||||
op.drop_column('participants', 'active_boost_expires_at')
|
||||
if column_exists('participants', 'active_boost_multiplier'):
|
||||
op.drop_column('participants', 'active_boost_multiplier')
|
||||
if column_exists('participants', 'skips_used'):
|
||||
op.drop_column('participants', 'skips_used')
|
||||
if column_exists('participants', 'coins_earned'):
|
||||
op.drop_column('participants', 'coins_earned')
|
||||
|
||||
# === Удаляем поля consumables из marathons ===
|
||||
if column_exists('marathons', 'allow_consumables'):
|
||||
op.drop_column('marathons', 'allow_consumables')
|
||||
if column_exists('marathons', 'max_skips_per_participant'):
|
||||
op.drop_column('marathons', 'max_skips_per_participant')
|
||||
if column_exists('marathons', 'allow_skips'):
|
||||
op.drop_column('marathons', 'allow_skips')
|
||||
|
||||
# === Удаляем поля сертификации из marathons ===
|
||||
if column_exists('marathons', 'certification_rejection_reason'):
|
||||
op.drop_column('marathons', 'certification_rejection_reason')
|
||||
if column_exists('marathons', 'certified_by_id'):
|
||||
op.drop_column('marathons', 'certified_by_id')
|
||||
if column_exists('marathons', 'certified_at'):
|
||||
op.drop_column('marathons', 'certified_at')
|
||||
if column_exists('marathons', 'certification_requested_at'):
|
||||
op.drop_column('marathons', 'certification_requested_at')
|
||||
if column_exists('marathons', 'certification_status'):
|
||||
op.drop_column('marathons', 'certification_status')
|
||||
|
||||
# === Удаляем поля из users ===
|
||||
if column_exists('users', 'equipped_background_id'):
|
||||
op.drop_column('users', 'equipped_background_id')
|
||||
if column_exists('users', 'equipped_name_color_id'):
|
||||
op.drop_column('users', 'equipped_name_color_id')
|
||||
if column_exists('users', 'equipped_title_id'):
|
||||
op.drop_column('users', 'equipped_title_id')
|
||||
if column_exists('users', 'equipped_frame_id'):
|
||||
op.drop_column('users', 'equipped_frame_id')
|
||||
if column_exists('users', 'coins_balance'):
|
||||
op.drop_column('users', 'coins_balance')
|
||||
|
||||
# === Удаляем таблицы ===
|
||||
if table_exists('consumable_usages'):
|
||||
op.drop_table('consumable_usages')
|
||||
if table_exists('coin_transactions'):
|
||||
op.drop_table('coin_transactions')
|
||||
if table_exists('user_inventory'):
|
||||
op.drop_table('user_inventory')
|
||||
if table_exists('shop_items'):
|
||||
op.drop_table('shop_items')
|
||||
495
backend/alembic/versions/024_seed_shop_items.py
Normal file
495
backend/alembic/versions/024_seed_shop_items.py
Normal file
@@ -0,0 +1,495 @@
|
||||
"""Seed shop items (frames, titles, consumables)
|
||||
|
||||
Revision ID: 024_seed_shop_items
|
||||
Revises: 023_add_shop_system
|
||||
Create Date: 2025-01-05
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from datetime import datetime
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '024_seed_shop_items'
|
||||
down_revision: Union[str, None] = '023_add_shop_system'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not table_exists('shop_items'):
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Таблица shop_items
|
||||
shop_items = sa.table(
|
||||
'shop_items',
|
||||
sa.column('id', sa.Integer),
|
||||
sa.column('item_type', sa.String),
|
||||
sa.column('code', sa.String),
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('price', sa.Integer),
|
||||
sa.column('rarity', sa.String),
|
||||
sa.column('asset_data', sa.JSON),
|
||||
sa.column('is_active', sa.Boolean),
|
||||
sa.column('created_at', sa.DateTime),
|
||||
)
|
||||
|
||||
# === Рамки аватара ===
|
||||
frames = [
|
||||
{
|
||||
'item_type': 'frame',
|
||||
'code': 'frame_bronze',
|
||||
'name': 'Бронзовая рамка',
|
||||
'description': 'Простая бронзовая рамка для начинающих',
|
||||
'price': 50,
|
||||
'rarity': 'common',
|
||||
'asset_data': {
|
||||
'border_color': '#CD7F32',
|
||||
'border_width': 3,
|
||||
'border_style': 'solid'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'frame',
|
||||
'code': 'frame_silver',
|
||||
'name': 'Серебряная рамка',
|
||||
'description': 'Элегантная серебряная рамка',
|
||||
'price': 100,
|
||||
'rarity': 'uncommon',
|
||||
'asset_data': {
|
||||
'border_color': '#C0C0C0',
|
||||
'border_width': 3,
|
||||
'border_style': 'solid'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'frame',
|
||||
'code': 'frame_gold',
|
||||
'name': 'Золотая рамка',
|
||||
'description': 'Престижная золотая рамка',
|
||||
'price': 200,
|
||||
'rarity': 'rare',
|
||||
'asset_data': {
|
||||
'border_color': '#FFD700',
|
||||
'border_width': 4,
|
||||
'border_style': 'solid'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'frame',
|
||||
'code': 'frame_diamond',
|
||||
'name': 'Бриллиантовая рамка',
|
||||
'description': 'Сверкающая бриллиантовая рамка для истинных ценителей',
|
||||
'price': 500,
|
||||
'rarity': 'epic',
|
||||
'asset_data': {
|
||||
'border_color': '#B9F2FF',
|
||||
'border_width': 4,
|
||||
'border_style': 'double',
|
||||
'glow': True,
|
||||
'glow_color': '#B9F2FF'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'frame',
|
||||
'code': 'frame_fire',
|
||||
'name': 'Огненная рамка',
|
||||
'description': 'Анимированная рамка с эффектом пламени',
|
||||
'price': 1000,
|
||||
'rarity': 'legendary',
|
||||
'asset_data': {
|
||||
'border_style': 'gradient',
|
||||
'gradient': ['#FF4500', '#FF8C00', '#FFD700'],
|
||||
'animated': True,
|
||||
'animation': 'fire-pulse'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'frame',
|
||||
'code': 'frame_neon',
|
||||
'name': 'Неоновая рамка',
|
||||
'description': 'Яркая неоновая рамка с свечением',
|
||||
'price': 800,
|
||||
'rarity': 'epic',
|
||||
'asset_data': {
|
||||
'border_color': '#00FF00',
|
||||
'border_width': 3,
|
||||
'glow': True,
|
||||
'glow_color': '#00FF00',
|
||||
'glow_intensity': 10
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'frame',
|
||||
'code': 'frame_rainbow',
|
||||
'name': 'Радужная рамка',
|
||||
'description': 'Переливающаяся радужная рамка',
|
||||
'price': 1500,
|
||||
'rarity': 'legendary',
|
||||
'asset_data': {
|
||||
'border_style': 'gradient',
|
||||
'gradient': ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#9400D3'],
|
||||
'animated': True,
|
||||
'animation': 'rainbow-rotate'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
]
|
||||
|
||||
# === Титулы ===
|
||||
titles = [
|
||||
{
|
||||
'item_type': 'title',
|
||||
'code': 'title_newcomer',
|
||||
'name': 'Новичок',
|
||||
'description': 'Первый шаг в мир марафонов',
|
||||
'price': 30,
|
||||
'rarity': 'common',
|
||||
'asset_data': {
|
||||
'text': 'Новичок',
|
||||
'color': '#808080'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'title',
|
||||
'code': 'title_runner',
|
||||
'name': 'Марафонец',
|
||||
'description': 'Опытный участник марафонов',
|
||||
'price': 100,
|
||||
'rarity': 'uncommon',
|
||||
'asset_data': {
|
||||
'text': 'Марафонец',
|
||||
'color': '#4169E1'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'title',
|
||||
'code': 'title_hunter',
|
||||
'name': 'Охотник за челленджами',
|
||||
'description': 'Мастер выполнения сложных заданий',
|
||||
'price': 200,
|
||||
'rarity': 'rare',
|
||||
'asset_data': {
|
||||
'text': 'Охотник за челленджами',
|
||||
'color': '#228B22'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'title',
|
||||
'code': 'title_veteran',
|
||||
'name': 'Ветеран',
|
||||
'description': 'Закаленный в боях участник',
|
||||
'price': 300,
|
||||
'rarity': 'rare',
|
||||
'asset_data': {
|
||||
'text': 'Ветеран',
|
||||
'color': '#8B4513'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'title',
|
||||
'code': 'title_champion',
|
||||
'name': 'Чемпион',
|
||||
'description': 'Победитель марафонов',
|
||||
'price': 500,
|
||||
'rarity': 'epic',
|
||||
'asset_data': {
|
||||
'text': 'Чемпион',
|
||||
'color': '#FFD700',
|
||||
'icon': 'trophy'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'title',
|
||||
'code': 'title_legend',
|
||||
'name': 'Легенда',
|
||||
'description': 'Легендарный участник марафонов',
|
||||
'price': 1000,
|
||||
'rarity': 'legendary',
|
||||
'asset_data': {
|
||||
'text': 'Легенда',
|
||||
'color': '#FF4500',
|
||||
'glow': True,
|
||||
'icon': 'star'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
]
|
||||
|
||||
# === Цвета никнейма ===
|
||||
name_colors = [
|
||||
{
|
||||
'item_type': 'name_color',
|
||||
'code': 'color_red',
|
||||
'name': 'Красный ник',
|
||||
'description': 'Яркий красный цвет никнейма',
|
||||
'price': 150,
|
||||
'rarity': 'uncommon',
|
||||
'asset_data': {
|
||||
'style': 'solid',
|
||||
'color': '#FF4444'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'name_color',
|
||||
'code': 'color_blue',
|
||||
'name': 'Синий ник',
|
||||
'description': 'Глубокий синий цвет никнейма',
|
||||
'price': 150,
|
||||
'rarity': 'uncommon',
|
||||
'asset_data': {
|
||||
'style': 'solid',
|
||||
'color': '#4444FF'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'name_color',
|
||||
'code': 'color_green',
|
||||
'name': 'Зеленый ник',
|
||||
'description': 'Сочный зеленый цвет никнейма',
|
||||
'price': 150,
|
||||
'rarity': 'uncommon',
|
||||
'asset_data': {
|
||||
'style': 'solid',
|
||||
'color': '#44FF44'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'name_color',
|
||||
'code': 'color_purple',
|
||||
'name': 'Фиолетовый ник',
|
||||
'description': 'Королевский фиолетовый цвет',
|
||||
'price': 200,
|
||||
'rarity': 'rare',
|
||||
'asset_data': {
|
||||
'style': 'solid',
|
||||
'color': '#9932CC'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'name_color',
|
||||
'code': 'color_gold',
|
||||
'name': 'Золотой ник',
|
||||
'description': 'Престижный золотой цвет',
|
||||
'price': 300,
|
||||
'rarity': 'rare',
|
||||
'asset_data': {
|
||||
'style': 'solid',
|
||||
'color': '#FFD700'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'name_color',
|
||||
'code': 'color_gradient_sunset',
|
||||
'name': 'Закат',
|
||||
'description': 'Красивый градиент заката',
|
||||
'price': 500,
|
||||
'rarity': 'epic',
|
||||
'asset_data': {
|
||||
'style': 'gradient',
|
||||
'gradient': ['#FF6B6B', '#FFE66D']
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'name_color',
|
||||
'code': 'color_gradient_ocean',
|
||||
'name': 'Океан',
|
||||
'description': 'Градиент морских глубин',
|
||||
'price': 500,
|
||||
'rarity': 'epic',
|
||||
'asset_data': {
|
||||
'style': 'gradient',
|
||||
'gradient': ['#4ECDC4', '#44A3FF']
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'name_color',
|
||||
'code': 'color_rainbow',
|
||||
'name': 'Радужный ник',
|
||||
'description': 'Анимированный радужный цвет',
|
||||
'price': 1000,
|
||||
'rarity': 'legendary',
|
||||
'asset_data': {
|
||||
'style': 'animated',
|
||||
'animation': 'rainbow-shift'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
]
|
||||
|
||||
# === Фоны профиля ===
|
||||
backgrounds = [
|
||||
{
|
||||
'item_type': 'background',
|
||||
'code': 'bg_dark',
|
||||
'name': 'Тёмный фон',
|
||||
'description': 'Элегантный тёмный фон',
|
||||
'price': 100,
|
||||
'rarity': 'common',
|
||||
'asset_data': {
|
||||
'type': 'solid',
|
||||
'color': '#1a1a2e'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'background',
|
||||
'code': 'bg_gradient_purple',
|
||||
'name': 'Фиолетовый градиент',
|
||||
'description': 'Красивый фиолетовый градиент',
|
||||
'price': 200,
|
||||
'rarity': 'uncommon',
|
||||
'asset_data': {
|
||||
'type': 'gradient',
|
||||
'gradient': ['#1a1a2e', '#4a0080']
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'background',
|
||||
'code': 'bg_stars',
|
||||
'name': 'Звёздное небо',
|
||||
'description': 'Фон с мерцающими звёздами',
|
||||
'price': 400,
|
||||
'rarity': 'rare',
|
||||
'asset_data': {
|
||||
'type': 'pattern',
|
||||
'pattern': 'stars',
|
||||
'animated': True
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'background',
|
||||
'code': 'bg_gaming',
|
||||
'name': 'Игровой фон',
|
||||
'description': 'Фон с игровыми элементами',
|
||||
'price': 500,
|
||||
'rarity': 'epic',
|
||||
'asset_data': {
|
||||
'type': 'pattern',
|
||||
'pattern': 'gaming-icons'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'background',
|
||||
'code': 'bg_fire',
|
||||
'name': 'Огненный фон',
|
||||
'description': 'Анимированный огненный фон',
|
||||
'price': 800,
|
||||
'rarity': 'legendary',
|
||||
'asset_data': {
|
||||
'type': 'animated',
|
||||
'animation': 'fire-particles'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
]
|
||||
|
||||
# === Расходуемые предметы ===
|
||||
consumables = [
|
||||
{
|
||||
'item_type': 'consumable',
|
||||
'code': 'skip',
|
||||
'name': 'Пропуск',
|
||||
'description': 'Пропустить текущее задание без штрафа и потери streak',
|
||||
'price': 100,
|
||||
'rarity': 'common',
|
||||
'asset_data': {
|
||||
'effect': 'skip',
|
||||
'icon': 'skip-forward'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'consumable',
|
||||
'code': 'shield',
|
||||
'name': 'Щит',
|
||||
'description': 'Защита от штрафа при следующем дропе. Streak сохраняется.',
|
||||
'price': 150,
|
||||
'rarity': 'uncommon',
|
||||
'asset_data': {
|
||||
'effect': 'shield',
|
||||
'icon': 'shield'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'consumable',
|
||||
'code': 'boost',
|
||||
'name': 'Буст x1.5',
|
||||
'description': 'Множитель очков x1.5 на текущее задание',
|
||||
'price': 200,
|
||||
'rarity': 'rare',
|
||||
'asset_data': {
|
||||
'effect': 'boost',
|
||||
'multiplier': 1.5,
|
||||
'one_time': True,
|
||||
'icon': 'zap'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'item_type': 'consumable',
|
||||
'code': 'reroll',
|
||||
'name': 'Перекрут',
|
||||
'description': 'Перекрутить колесо и получить новое задание',
|
||||
'price': 80,
|
||||
'rarity': 'common',
|
||||
'asset_data': {
|
||||
'effect': 'reroll',
|
||||
'icon': 'refresh-cw'
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
]
|
||||
|
||||
# Вставляем все товары
|
||||
all_items = frames + titles + name_colors + backgrounds + consumables
|
||||
|
||||
# Добавляем created_at ко всем товарам
|
||||
for item in all_items:
|
||||
item['created_at'] = now
|
||||
|
||||
op.bulk_insert(shop_items, all_items)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Удаляем все seed-товары по коду
|
||||
op.execute("DELETE FROM shop_items WHERE code LIKE 'frame_%'")
|
||||
op.execute("DELETE FROM shop_items WHERE code LIKE 'title_%'")
|
||||
op.execute("DELETE FROM shop_items WHERE code LIKE 'color_%'")
|
||||
op.execute("DELETE FROM shop_items WHERE code LIKE 'bg_%'")
|
||||
op.execute("DELETE FROM shop_items WHERE code IN ('skip', 'shield', 'boost', 'reroll')")
|
||||
52
backend/alembic/versions/025_simplify_boost_consumable.py
Normal file
52
backend/alembic/versions/025_simplify_boost_consumable.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Simplify boost consumable - make it one-time instead of timed
|
||||
|
||||
Revision ID: 025_simplify_boost
|
||||
Revises: 024_seed_shop_items
|
||||
Create Date: 2026-01-08
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '025_simplify_boost'
|
||||
down_revision: Union[str, None] = '024_seed_shop_items'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c['name'] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add new boolean column for one-time boost
|
||||
if not column_exists('participants', 'has_active_boost'):
|
||||
op.add_column('participants', sa.Column('has_active_boost', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
# Remove old timed boost columns
|
||||
if column_exists('participants', 'active_boost_multiplier'):
|
||||
op.drop_column('participants', 'active_boost_multiplier')
|
||||
|
||||
if column_exists('participants', 'active_boost_expires_at'):
|
||||
op.drop_column('participants', 'active_boost_expires_at')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Restore old columns
|
||||
if not column_exists('participants', 'active_boost_multiplier'):
|
||||
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
|
||||
|
||||
if not column_exists('participants', 'active_boost_expires_at'):
|
||||
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
|
||||
|
||||
# Remove new column
|
||||
if column_exists('participants', 'has_active_boost'):
|
||||
op.drop_column('participants', 'has_active_boost')
|
||||
46
backend/alembic/versions/026_update_boost_description.py
Normal file
46
backend/alembic/versions/026_update_boost_description.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Update boost description to one-time usage
|
||||
|
||||
Revision ID: 026_update_boost_desc
|
||||
Revises: 025_simplify_boost
|
||||
Create Date: 2026-01-08
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '026_update_boost_desc'
|
||||
down_revision: Union[str, None] = '025_simplify_boost'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Update boost description in shop_items table
|
||||
op.execute("""
|
||||
UPDATE shop_items
|
||||
SET description = 'Множитель очков x1.5 на текущее задание',
|
||||
asset_data = jsonb_set(
|
||||
asset_data::jsonb - 'duration_hours',
|
||||
'{one_time}',
|
||||
'true'
|
||||
)
|
||||
WHERE code = 'boost' AND item_type = 'consumable'
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert boost description
|
||||
op.execute("""
|
||||
UPDATE shop_items
|
||||
SET description = 'Множитель очков x1.5 на следующие 2 часа',
|
||||
asset_data = jsonb_set(
|
||||
asset_data::jsonb - 'one_time',
|
||||
'{duration_hours}',
|
||||
'2'
|
||||
)
|
||||
WHERE code = 'boost' AND item_type = 'consumable'
|
||||
""")
|
||||
83
backend/alembic/versions/027_consumables_redesign.py
Normal file
83
backend/alembic/versions/027_consumables_redesign.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Consumables redesign: remove shield/reroll, add wild_card/lucky_dice/copycat/undo
|
||||
|
||||
Revision ID: 027_consumables_redesign
|
||||
Revises: 026_update_boost_desc
|
||||
Create Date: 2026-01-08
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from datetime import datetime
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '027_consumables_redesign'
|
||||
down_revision: Union[str, None] = '026_update_boost_desc'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Remove has_shield column from participants
|
||||
op.drop_column('participants', 'has_shield')
|
||||
|
||||
# 2. Add new columns for lucky_dice and undo
|
||||
op.add_column('participants', sa.Column('has_lucky_dice', sa.Boolean(), nullable=False, server_default='false'))
|
||||
op.add_column('participants', sa.Column('lucky_dice_multiplier', sa.Float(), nullable=True))
|
||||
op.add_column('participants', sa.Column('last_drop_points', sa.Integer(), nullable=True))
|
||||
op.add_column('participants', sa.Column('last_drop_streak_before', sa.Integer(), nullable=True))
|
||||
op.add_column('participants', sa.Column('can_undo', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
# 3. Remove old consumables from shop
|
||||
op.execute("DELETE FROM shop_items WHERE code IN ('reroll', 'shield')")
|
||||
|
||||
# 4. Update boost price from 200 to 150
|
||||
op.execute("UPDATE shop_items SET price = 150 WHERE code = 'boost'")
|
||||
|
||||
# 5. Add new consumables to shop
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
op.execute(f"""
|
||||
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
|
||||
VALUES
|
||||
('consumable', 'wild_card', 'Дикая карта', 'Выбери игру и получи случайное задание из неё', 150, 'uncommon',
|
||||
'{{"effect": "wild_card", "icon": "shuffle"}}', true, '{now}'),
|
||||
('consumable', 'lucky_dice', 'Счастливые кости', 'Случайный множитель очков (1.5x - 4.0x)', 250, 'rare',
|
||||
'{{"effect": "lucky_dice", "multipliers": [1.5, 2.0, 2.5, 3.0, 3.5, 4.0], "icon": "dice"}}', true, '{now}'),
|
||||
('consumable', 'copycat', 'Копикэт', 'Скопируй задание любого участника марафона', 300, 'epic',
|
||||
'{{"effect": "copycat", "icon": "copy"}}', true, '{now}'),
|
||||
('consumable', 'undo', 'Отмена', 'Отмени последний дроп и верни очки со стриком', 300, 'epic',
|
||||
'{{"effect": "undo", "icon": "undo"}}', true, '{now}')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. Remove new columns
|
||||
op.drop_column('participants', 'can_undo')
|
||||
op.drop_column('participants', 'last_drop_streak_before')
|
||||
op.drop_column('participants', 'last_drop_points')
|
||||
op.drop_column('participants', 'lucky_dice_multiplier')
|
||||
op.drop_column('participants', 'has_lucky_dice')
|
||||
|
||||
# 2. Add back has_shield
|
||||
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
# 3. Remove new consumables
|
||||
op.execute("DELETE FROM shop_items WHERE code IN ('wild_card', 'lucky_dice', 'copycat', 'undo')")
|
||||
|
||||
# 4. Restore boost price back to 200
|
||||
op.execute("UPDATE shop_items SET price = 200 WHERE code = 'boost'")
|
||||
|
||||
# 5. Add back old consumables
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
op.execute(f"""
|
||||
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
|
||||
VALUES
|
||||
('consumable', 'shield', 'Щит', 'Защита от штрафа при следующем дропе. Streak сохраняется.', 150, 'uncommon',
|
||||
'{{"effect": "shield", "icon": "shield"}}', true, '{now}'),
|
||||
('consumable', 'reroll', 'Перекрут', 'Перекрутить колесо и получить новое задание', 80, 'common',
|
||||
'{{"effect": "reroll", "icon": "refresh-cw"}}', true, '{now}')
|
||||
""")
|
||||
58
backend/alembic/versions/028_add_promo_codes.py
Normal file
58
backend/alembic/versions/028_add_promo_codes.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Add promo codes system
|
||||
|
||||
Revision ID: 028_add_promo_codes
|
||||
Revises: 027_consumables_redesign
|
||||
Create Date: 2026-01-08
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '028_add_promo_codes'
|
||||
down_revision: Union[str, None] = '027_consumables_redesign'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create promo_codes table
|
||||
op.create_table(
|
||||
'promo_codes',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('code', sa.String(50), nullable=False),
|
||||
sa.Column('coins_amount', sa.Integer(), nullable=False),
|
||||
sa.Column('max_uses', sa.Integer(), nullable=True),
|
||||
sa.Column('uses_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=False),
|
||||
sa.Column('valid_from', sa.DateTime(), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index('ix_promo_codes_code', 'promo_codes', ['code'], unique=True)
|
||||
|
||||
# Create promo_code_redemptions table
|
||||
op.create_table(
|
||||
'promo_code_redemptions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('promo_code_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('coins_awarded', sa.Integer(), nullable=False),
|
||||
sa.Column('redeemed_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['promo_code_id'], ['promo_codes.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
|
||||
)
|
||||
op.create_index('ix_promo_code_redemptions_user_id', 'promo_code_redemptions', ['user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('promo_code_redemptions')
|
||||
op.drop_table('promo_codes')
|
||||
30
backend/alembic/versions/029_add_tracked_time.py
Normal file
30
backend/alembic/versions/029_add_tracked_time.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Add tracked_time_minutes to assignments
|
||||
|
||||
Revision ID: 029_add_tracked_time
|
||||
Revises: 028_add_promo_codes
|
||||
Create Date: 2026-01-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '029_add_tracked_time'
|
||||
down_revision: Union[str, None] = '028_add_promo_codes'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add tracked_time_minutes column to assignments table
|
||||
op.add_column(
|
||||
'assignments',
|
||||
sa.Column('tracked_time_minutes', sa.Integer(), nullable=False, server_default='0')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('assignments', 'tracked_time_minutes')
|
||||
46
backend/alembic/versions/029_add_widget_tokens.py
Normal file
46
backend/alembic/versions/029_add_widget_tokens.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Add widget tokens
|
||||
|
||||
Revision ID: 029
|
||||
Revises: 028
|
||||
Create Date: 2025-01-09
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '029_add_widget_tokens'
|
||||
down_revision = '028_add_promo_codes'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade():
|
||||
if table_exists('widget_tokens'):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
'widget_tokens',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('token', sa.String(64), nullable=False),
|
||||
sa.Column('participant_id', sa.Integer(), nullable=False),
|
||||
sa.Column('marathon_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['participant_id'], ['participants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['marathon_id'], ['marathons.id'], ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index('ix_widget_tokens_token', 'widget_tokens', ['token'], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_widget_tokens_token', table_name='widget_tokens')
|
||||
op.drop_table('widget_tokens')
|
||||
28
backend/alembic/versions/030_merge_029_heads.py
Normal file
28
backend/alembic/versions/030_merge_029_heads.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Merge 029 heads
|
||||
|
||||
Revision ID: 030_merge_029_heads
|
||||
Revises: 029_add_tracked_time, 029_add_widget_tokens
|
||||
Create Date: 2026-01-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '030_merge_029_heads'
|
||||
down_revision: Union[str, Sequence[str]] = ('029_add_tracked_time', '029_add_widget_tokens')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Merge migration - no changes needed
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Merge migration - no changes needed
|
||||
pass
|
||||
65
backend/alembic/versions/031_add_exiled_games.py
Normal file
65
backend/alembic/versions/031_add_exiled_games.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Add exiled games and skip_exile consumable
|
||||
|
||||
Revision ID: 030
|
||||
Revises: 029
|
||||
Create Date: 2025-01-10
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '031_add_exiled_games'
|
||||
down_revision = '030_merge_029_heads'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Create exiled_games table if not exists
|
||||
if not table_exists('exiled_games'):
|
||||
op.create_table(
|
||||
'exiled_games',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('participant_id', sa.Integer(), nullable=False),
|
||||
sa.Column('game_id', sa.Integer(), nullable=False),
|
||||
sa.Column('assignment_id', sa.Integer(), nullable=True),
|
||||
sa.Column('exiled_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('exiled_by', sa.String(20), nullable=False),
|
||||
sa.Column('reason', sa.String(500), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('unexiled_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('unexiled_by', sa.String(20), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['participant_id'], ['participants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['assignment_id'], ['assignments.id'], ondelete='SET NULL'),
|
||||
sa.UniqueConstraint('participant_id', 'game_id', name='unique_participant_game_exile'),
|
||||
)
|
||||
op.create_index('ix_exiled_games_participant_id', 'exiled_games', ['participant_id'])
|
||||
op.create_index('ix_exiled_games_active', 'exiled_games', ['participant_id', 'is_active'])
|
||||
|
||||
# Add skip_exile consumable to shop if not exists
|
||||
op.execute("""
|
||||
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
|
||||
SELECT 'consumable', 'skip_exile', 'Скип с изгнанием',
|
||||
'Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула и больше не выпадет.',
|
||||
150, 'rare', '{"effect": "skip_exile", "icon": "x-circle"}', true, NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE code = 'skip_exile')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Remove skip_exile from shop
|
||||
op.execute("DELETE FROM shop_items WHERE code = 'skip_exile'")
|
||||
|
||||
# Drop exiled_games table
|
||||
op.drop_index('ix_exiled_games_active', table_name='exiled_games')
|
||||
op.drop_index('ix_exiled_games_participant_id', table_name='exiled_games')
|
||||
op.drop_table('exiled_games')
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime
|
||||
from fastapi import Depends, HTTPException, status, Header
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -35,7 +36,16 @@ async def get_current_user(
|
||||
detail="Invalid token payload",
|
||||
)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == int(user_id))
|
||||
.options(
|
||||
selectinload(User.equipped_frame),
|
||||
selectinload(User.equipped_title),
|
||||
selectinload(User.equipped_name_color),
|
||||
selectinload(User.equipped_background),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo, widgets
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -16,3 +16,6 @@ router.include_router(events.router)
|
||||
router.include_router(assignments.router)
|
||||
router.include_router(telegram.router)
|
||||
router.include_router(content.router)
|
||||
router.include_router(shop.router)
|
||||
router.include_router(promo.router)
|
||||
router.include_router(widgets.router)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Form
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
|
||||
from app.models import (
|
||||
User, UserRole, Marathon, MarathonStatus, CertificationStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
|
||||
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
|
||||
)
|
||||
from app.schemas import (
|
||||
UserPublic, MessageResponse,
|
||||
AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
|
||||
@@ -33,6 +37,8 @@ class AdminMarathonResponse(BaseModel):
|
||||
start_date: str | None
|
||||
end_date: str | None
|
||||
created_at: str
|
||||
certification_status: str = "none"
|
||||
is_certified: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -61,6 +67,28 @@ async def log_admin_action(
|
||||
await db.commit()
|
||||
|
||||
|
||||
def build_admin_user_response(user: User, marathons_count: int) -> AdminUserResponse:
|
||||
"""Build AdminUserResponse from User model."""
|
||||
return AdminUserResponse(
|
||||
id=user.id,
|
||||
login=user.login,
|
||||
nickname=user.nickname,
|
||||
role=user.role,
|
||||
avatar_url=user.avatar_url,
|
||||
telegram_id=user.telegram_id,
|
||||
telegram_username=user.telegram_username,
|
||||
marathons_count=marathons_count,
|
||||
created_at=user.created_at.isoformat(),
|
||||
is_banned=user.is_banned,
|
||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||
ban_reason=user.ban_reason,
|
||||
notify_events=user.notify_events,
|
||||
notify_disputes=user.notify_disputes,
|
||||
notify_moderation=user.notify_moderation,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserResponse])
|
||||
async def list_users(
|
||||
current_user: CurrentUser,
|
||||
@@ -94,21 +122,7 @@ async def list_users(
|
||||
marathons_count = await db.scalar(
|
||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||
)
|
||||
response.append(AdminUserResponse(
|
||||
id=user.id,
|
||||
login=user.login,
|
||||
nickname=user.nickname,
|
||||
role=user.role,
|
||||
avatar_url=user.avatar_url,
|
||||
telegram_id=user.telegram_id,
|
||||
telegram_username=user.telegram_username,
|
||||
marathons_count=marathons_count,
|
||||
created_at=user.created_at.isoformat(),
|
||||
is_banned=user.is_banned,
|
||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||
ban_reason=user.ban_reason,
|
||||
))
|
||||
response.append(build_admin_user_response(user, marathons_count))
|
||||
|
||||
return response
|
||||
|
||||
@@ -127,21 +141,7 @@ async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||
)
|
||||
|
||||
return AdminUserResponse(
|
||||
id=user.id,
|
||||
login=user.login,
|
||||
nickname=user.nickname,
|
||||
role=user.role,
|
||||
avatar_url=user.avatar_url,
|
||||
telegram_id=user.telegram_id,
|
||||
telegram_username=user.telegram_username,
|
||||
marathons_count=marathons_count,
|
||||
created_at=user.created_at.isoformat(),
|
||||
is_banned=user.is_banned,
|
||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||
ban_reason=user.ban_reason,
|
||||
)
|
||||
return build_admin_user_response(user, marathons_count)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/role", response_model=AdminUserResponse)
|
||||
@@ -181,21 +181,7 @@ async def set_user_role(
|
||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||
)
|
||||
|
||||
return AdminUserResponse(
|
||||
id=user.id,
|
||||
login=user.login,
|
||||
nickname=user.nickname,
|
||||
role=user.role,
|
||||
avatar_url=user.avatar_url,
|
||||
telegram_id=user.telegram_id,
|
||||
telegram_username=user.telegram_username,
|
||||
marathons_count=marathons_count,
|
||||
created_at=user.created_at.isoformat(),
|
||||
is_banned=user.is_banned,
|
||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||
ban_reason=user.ban_reason,
|
||||
)
|
||||
return build_admin_user_response(user, marathons_count)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", response_model=MessageResponse)
|
||||
@@ -227,7 +213,7 @@ async def list_marathons(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
search: str | None = None,
|
||||
):
|
||||
"""List all marathons. Admin only."""
|
||||
@@ -235,7 +221,12 @@ async def list_marathons(
|
||||
|
||||
query = (
|
||||
select(Marathon)
|
||||
.options(selectinload(Marathon.creator))
|
||||
.options(
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_frame),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_title),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_background),
|
||||
)
|
||||
.order_by(Marathon.created_at.desc())
|
||||
)
|
||||
|
||||
@@ -264,6 +255,8 @@ async def list_marathons(
|
||||
start_date=marathon.start_date.isoformat() if marathon.start_date else None,
|
||||
end_date=marathon.end_date.isoformat() if marathon.end_date else None,
|
||||
created_at=marathon.created_at.isoformat(),
|
||||
certification_status=marathon.certification_status,
|
||||
is_certified=marathon.is_certified,
|
||||
))
|
||||
|
||||
return response
|
||||
@@ -360,21 +353,7 @@ async def ban_user(
|
||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||
)
|
||||
|
||||
return AdminUserResponse(
|
||||
id=user.id,
|
||||
login=user.login,
|
||||
nickname=user.nickname,
|
||||
role=user.role,
|
||||
avatar_url=user.avatar_url,
|
||||
telegram_id=user.telegram_id,
|
||||
telegram_username=user.telegram_username,
|
||||
marathons_count=marathons_count,
|
||||
created_at=user.created_at.isoformat(),
|
||||
is_banned=user.is_banned,
|
||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||
ban_reason=user.ban_reason,
|
||||
)
|
||||
return build_admin_user_response(user, marathons_count)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
|
||||
@@ -415,21 +394,7 @@ async def unban_user(
|
||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||
)
|
||||
|
||||
return AdminUserResponse(
|
||||
id=user.id,
|
||||
login=user.login,
|
||||
nickname=user.nickname,
|
||||
role=user.role,
|
||||
avatar_url=user.avatar_url,
|
||||
telegram_id=user.telegram_id,
|
||||
telegram_username=user.telegram_username,
|
||||
marathons_count=marathons_count,
|
||||
created_at=user.created_at.isoformat(),
|
||||
is_banned=user.is_banned,
|
||||
banned_at=None,
|
||||
banned_until=None,
|
||||
ban_reason=None,
|
||||
)
|
||||
return build_admin_user_response(user, marathons_count)
|
||||
|
||||
|
||||
# ============ Reset Password ============
|
||||
@@ -475,21 +440,7 @@ async def reset_user_password(
|
||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||
)
|
||||
|
||||
return AdminUserResponse(
|
||||
id=user.id,
|
||||
login=user.login,
|
||||
nickname=user.nickname,
|
||||
role=user.role,
|
||||
avatar_url=user.avatar_url,
|
||||
telegram_id=user.telegram_id,
|
||||
telegram_username=user.telegram_username,
|
||||
marathons_count=marathons_count,
|
||||
created_at=user.created_at.isoformat(),
|
||||
is_banned=user.is_banned,
|
||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||
ban_reason=user.ban_reason,
|
||||
)
|
||||
return build_admin_user_response(user, marathons_count)
|
||||
|
||||
|
||||
# ============ Force Finish Marathon ============
|
||||
@@ -501,6 +452,8 @@ async def force_finish_marathon(
|
||||
db: DbSession,
|
||||
):
|
||||
"""Force finish a marathon. Admin only."""
|
||||
from app.services.coins import coins_service
|
||||
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
@@ -514,6 +467,24 @@ async def force_finish_marathon(
|
||||
old_status = marathon.status
|
||||
marathon.status = MarathonStatus.FINISHED.value
|
||||
marathon.end_date = datetime.utcnow()
|
||||
|
||||
# Award coins for top 3 places (only in certified marathons)
|
||||
if marathon.is_certified:
|
||||
top_result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(Participant.marathon_id == marathon_id)
|
||||
.order_by(Participant.total_points.desc())
|
||||
.limit(3)
|
||||
)
|
||||
top_participants = top_result.scalars().all()
|
||||
|
||||
for place, participant in enumerate(top_participants, start=1):
|
||||
if participant.total_points > 0:
|
||||
await coins_service.award_marathon_place(
|
||||
db, participant.user, marathon, place
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Log action
|
||||
@@ -590,9 +561,10 @@ async def get_logs(
|
||||
@limiter.limit("1/minute")
|
||||
async def broadcast_to_all(
|
||||
request: Request,
|
||||
data: BroadcastRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
message: str = Form(""),
|
||||
media: list[UploadFile] = File(default=[]),
|
||||
):
|
||||
"""Send broadcast message to all users with Telegram linked. Admin only."""
|
||||
require_admin_with_2fa(current_user)
|
||||
@@ -606,15 +578,40 @@ async def broadcast_to_all(
|
||||
total_count = len(users)
|
||||
sent_count = 0
|
||||
|
||||
# Read media files if provided (up to 10 files, Telegram limit)
|
||||
media_items = []
|
||||
for file in media[:10]:
|
||||
if file and file.filename:
|
||||
file_data = await file.read()
|
||||
content_type = file.content_type or ""
|
||||
if content_type.startswith("image/"):
|
||||
media_items.append({
|
||||
"type": "photo",
|
||||
"data": file_data,
|
||||
"filename": file.filename,
|
||||
"content_type": content_type
|
||||
})
|
||||
elif content_type.startswith("video/"):
|
||||
media_items.append({
|
||||
"type": "video",
|
||||
"data": file_data,
|
||||
"filename": file.filename,
|
||||
"content_type": content_type
|
||||
})
|
||||
|
||||
for user in users:
|
||||
if await telegram_notifier.send_message(user.telegram_id, data.message):
|
||||
if await telegram_notifier.send_media_message(
|
||||
user.telegram_id,
|
||||
text=message if message.strip() else None,
|
||||
media_items=media_items if media_items else None
|
||||
):
|
||||
sent_count += 1
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
|
||||
"broadcast", 0,
|
||||
{"message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||
{"message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)},
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
@@ -626,9 +623,10 @@ async def broadcast_to_all(
|
||||
async def broadcast_to_marathon(
|
||||
request: Request,
|
||||
marathon_id: int,
|
||||
data: BroadcastRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
message: str = Form(""),
|
||||
media: list[UploadFile] = File(default=[]),
|
||||
):
|
||||
"""Send broadcast message to marathon participants. Admin only."""
|
||||
require_admin_with_2fa(current_user)
|
||||
@@ -639,7 +637,7 @@ async def broadcast_to_marathon(
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
# Get participants count
|
||||
# Get participants with telegram
|
||||
total_result = await db.execute(
|
||||
select(User)
|
||||
.join(Participant, Participant.user_id == User.id)
|
||||
@@ -651,15 +649,41 @@ async def broadcast_to_marathon(
|
||||
users = total_result.scalars().all()
|
||||
total_count = len(users)
|
||||
|
||||
sent_count = await telegram_notifier.notify_marathon_participants(
|
||||
db, marathon_id, data.message
|
||||
)
|
||||
# Read media files if provided (up to 10 files, Telegram limit)
|
||||
media_items = []
|
||||
for file in media[:10]:
|
||||
if file and file.filename:
|
||||
file_data = await file.read()
|
||||
content_type = file.content_type or ""
|
||||
if content_type.startswith("image/"):
|
||||
media_items.append({
|
||||
"type": "photo",
|
||||
"data": file_data,
|
||||
"filename": file.filename,
|
||||
"content_type": content_type
|
||||
})
|
||||
elif content_type.startswith("video/"):
|
||||
media_items.append({
|
||||
"type": "video",
|
||||
"data": file_data,
|
||||
"filename": file.filename,
|
||||
"content_type": content_type
|
||||
})
|
||||
|
||||
sent_count = 0
|
||||
for user in users:
|
||||
if await telegram_notifier.send_media_message(
|
||||
user.telegram_id,
|
||||
text=message if message.strip() else None,
|
||||
media_items=media_items if media_items else None
|
||||
):
|
||||
sent_count += 1
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
|
||||
"marathon", marathon_id,
|
||||
{"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||
{"title": marathon.title, "message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)},
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
@@ -837,3 +861,345 @@ async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
||||
for log in recent_logs
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ============ Disputes Management ============
|
||||
|
||||
class AdminDisputeResponse(BaseModel):
|
||||
id: int
|
||||
assignment_id: int | None
|
||||
bonus_assignment_id: int | None
|
||||
marathon_id: int
|
||||
marathon_title: str
|
||||
challenge_title: str
|
||||
participant_nickname: str
|
||||
raised_by_nickname: str
|
||||
reason: str
|
||||
status: str
|
||||
votes_valid: int
|
||||
votes_invalid: int
|
||||
created_at: str
|
||||
expires_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResolveDisputeRequest(BaseModel):
|
||||
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
|
||||
|
||||
|
||||
@router.get("/disputes", response_model=list[AdminDisputeResponse])
|
||||
async def list_disputes(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
status: str = Query("pending", pattern="^(open|pending|all)$"),
|
||||
):
|
||||
"""List all disputes. Admin only.
|
||||
|
||||
Status filter:
|
||||
- pending: disputes waiting for admin decision (default)
|
||||
- open: disputes still in voting phase
|
||||
- all: all disputes
|
||||
"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
from datetime import timedelta
|
||||
DISPUTE_WINDOW_HOURS = 24
|
||||
|
||||
query = (
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.raised_by),
|
||||
selectinload(Dispute.votes),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game), # For playthrough
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.order_by(Dispute.created_at.desc())
|
||||
)
|
||||
|
||||
if status == "pending":
|
||||
# Disputes waiting for admin decision
|
||||
query = query.where(Dispute.status == DisputeStatus.PENDING_ADMIN.value)
|
||||
elif status == "open":
|
||||
# Disputes still in voting phase
|
||||
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
disputes = result.scalars().all()
|
||||
|
||||
response = []
|
||||
for dispute in disputes:
|
||||
# Get info based on dispute type
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
main_assignment = bonus.main_assignment
|
||||
participant = main_assignment.participant
|
||||
challenge_title = f"Бонус: {bonus.challenge.title}"
|
||||
marathon_id = main_assignment.game.marathon_id
|
||||
|
||||
# Get marathon title
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
marathon_title = marathon.title if marathon else "Unknown"
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
if assignment.is_playthrough:
|
||||
challenge_title = f"Прохождение: {assignment.game.title}"
|
||||
marathon_id = assignment.game.marathon_id
|
||||
else:
|
||||
challenge_title = assignment.challenge.title
|
||||
marathon_id = assignment.challenge.game.marathon_id
|
||||
|
||||
# Get marathon title
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
marathon_title = marathon.title if marathon else "Unknown"
|
||||
|
||||
# Count votes
|
||||
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
||||
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
||||
|
||||
# Calculate expiry
|
||||
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||
|
||||
response.append(AdminDisputeResponse(
|
||||
id=dispute.id,
|
||||
assignment_id=dispute.assignment_id,
|
||||
bonus_assignment_id=dispute.bonus_assignment_id,
|
||||
marathon_id=marathon_id,
|
||||
marathon_title=marathon_title,
|
||||
challenge_title=challenge_title,
|
||||
participant_nickname=participant.user.nickname,
|
||||
raised_by_nickname=dispute.raised_by.nickname,
|
||||
reason=dispute.reason,
|
||||
status=dispute.status,
|
||||
votes_valid=votes_valid,
|
||||
votes_invalid=votes_invalid,
|
||||
created_at=dispute.created_at.isoformat(),
|
||||
expires_at=expires_at.isoformat(),
|
||||
))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/disputes/{dispute_id}/resolve", response_model=MessageResponse)
|
||||
async def resolve_dispute(
|
||||
request: Request,
|
||||
dispute_id: int,
|
||||
data: ResolveDisputeRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Manually resolve a dispute. Admin only."""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Get dispute
|
||||
result = await db.execute(
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.where(Dispute.id == dispute_id)
|
||||
)
|
||||
dispute = result.scalar_one_or_none()
|
||||
|
||||
if not dispute:
|
||||
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||
|
||||
# Allow resolving disputes that are either open or pending admin decision
|
||||
if dispute.status not in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]:
|
||||
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||
|
||||
# Determine result
|
||||
if data.is_valid:
|
||||
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||
action_type = AdminActionType.DISPUTE_RESOLVE_VALID.value
|
||||
else:
|
||||
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||
action_type = AdminActionType.DISPUTE_RESOLVE_INVALID.value
|
||||
|
||||
# Handle invalid proof
|
||||
if dispute.bonus_assignment_id:
|
||||
# Reset bonus assignment
|
||||
bonus = dispute.bonus_assignment
|
||||
main_assignment = bonus.main_assignment
|
||||
participant = main_assignment.participant
|
||||
|
||||
# Only subtract points if main playthrough was already completed
|
||||
# (bonus points are added only when main playthrough is completed)
|
||||
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
||||
points_to_subtract = bonus.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
# Also reduce the points_earned on the main assignment
|
||||
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
|
||||
|
||||
bonus.status = BonusAssignmentStatus.PENDING.value
|
||||
bonus.proof_path = None
|
||||
bonus.proof_url = None
|
||||
bonus.proof_comment = None
|
||||
bonus.points_earned = 0
|
||||
bonus.completed_at = None
|
||||
else:
|
||||
# Reset main assignment
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
|
||||
# Subtract points
|
||||
points_to_subtract = assignment.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
|
||||
# Reset streak - the completion was invalid
|
||||
participant.current_streak = 0
|
||||
|
||||
# Reset assignment
|
||||
assignment.status = AssignmentStatus.RETURNED.value
|
||||
assignment.points_earned = 0
|
||||
|
||||
# For playthrough: reset all bonus assignments
|
||||
if assignment.is_playthrough:
|
||||
bonus_result = await db.execute(
|
||||
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
for ba in bonus_result.scalars().all():
|
||||
ba.status = BonusAssignmentStatus.PENDING.value
|
||||
ba.proof_path = None
|
||||
ba.proof_url = None
|
||||
ba.proof_comment = None
|
||||
ba.points_earned = 0
|
||||
ba.completed_at = None
|
||||
|
||||
# Update dispute
|
||||
dispute.status = result_status
|
||||
dispute.resolved_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Get details for logging
|
||||
if dispute.bonus_assignment_id:
|
||||
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
|
||||
marathon_id = dispute.bonus_assignment.main_assignment.game.marathon_id
|
||||
elif dispute.assignment.is_playthrough:
|
||||
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
|
||||
marathon_id = dispute.assignment.game.marathon_id
|
||||
else:
|
||||
challenge_title = dispute.assignment.challenge.title
|
||||
marathon_id = dispute.assignment.challenge.game.marathon_id
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, action_type,
|
||||
"dispute", dispute_id,
|
||||
{
|
||||
"challenge_title": challenge_title,
|
||||
"marathon_id": marathon_id,
|
||||
"is_valid": data.is_valid,
|
||||
},
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
# Send notification
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
if dispute.bonus_assignment_id:
|
||||
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
|
||||
else:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
|
||||
if marathon:
|
||||
await telegram_notifier.notify_dispute_resolved(
|
||||
db,
|
||||
user_id=participant_user_id,
|
||||
marathon_title=marathon.title,
|
||||
challenge_title=challenge_title,
|
||||
is_valid=data.is_valid
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
||||
)
|
||||
|
||||
|
||||
# ============ Marathon Certification ============
|
||||
@router.post("/marathons/{marathon_id}/certify", response_model=MessageResponse)
|
||||
async def certify_marathon(
|
||||
request: Request,
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Certify (verify) a marathon. Admin only."""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
if marathon.certification_status == CertificationStatus.CERTIFIED.value:
|
||||
raise HTTPException(status_code=400, detail="Marathon is already certified")
|
||||
|
||||
marathon.certification_status = CertificationStatus.CERTIFIED.value
|
||||
marathon.certified_at = datetime.utcnow()
|
||||
marathon.certified_by_id = current_user.id
|
||||
marathon.certification_rejection_reason = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, AdminActionType.MARATHON_CERTIFY.value,
|
||||
"marathon", marathon_id,
|
||||
{"title": marathon.title},
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return MessageResponse(message="Marathon certified successfully")
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/revoke-certification", response_model=MessageResponse)
|
||||
async def revoke_marathon_certification(
|
||||
request: Request,
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Revoke certification from a marathon. Admin only."""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
if marathon.certification_status != CertificationStatus.CERTIFIED.value:
|
||||
raise HTTPException(status_code=400, detail="Marathon is not certified")
|
||||
|
||||
marathon.certification_status = CertificationStatus.NONE.value
|
||||
marathon.certified_at = None
|
||||
marathon.certified_by_id = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, AdminActionType.MARATHON_REVOKE_CERTIFICATION.value,
|
||||
"marathon", marathon_id,
|
||||
{"title": marathon.title},
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return MessageResponse(message="Marathon certification revoked")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||
@@ -48,7 +49,16 @@ async def register(request: Request, data: UserRegister, db: DbSession):
|
||||
@limiter.limit("10/minute")
|
||||
async def login(request: Request, data: UserLogin, db: DbSession):
|
||||
# Find user
|
||||
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.login == data.login.lower())
|
||||
.options(
|
||||
selectinload(User.equipped_frame),
|
||||
selectinload(User.equipped_title),
|
||||
selectinload(User.equipped_name_color),
|
||||
selectinload(User.equipped_background),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(data.password, user.password_hash):
|
||||
@@ -147,7 +157,16 @@ async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession
|
||||
await db.commit()
|
||||
|
||||
# Get user
|
||||
result = await db.execute(select(User).where(User.id == session.user_id))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == session.user_id)
|
||||
.options(
|
||||
selectinload(User.equipped_frame),
|
||||
selectinload(User.equipped_title),
|
||||
selectinload(User.equipped_name_color),
|
||||
selectinload(User.equipped_background),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
|
||||
@@ -54,7 +54,7 @@ def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeRespo
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
status=challenge.status,
|
||||
@@ -99,7 +99,10 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
|
||||
|
||||
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
||||
async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""List all challenges for a marathon (from all approved games). Participants only."""
|
||||
"""List all challenges for a marathon (from all approved games). Participants only.
|
||||
Also includes virtual challenges for playthrough-type games."""
|
||||
from app.models.game import GameType
|
||||
|
||||
# Check marathon exists
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
@@ -111,7 +114,7 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
|
||||
if not current_user.is_admin and not participant:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
# Get all approved challenges from approved games in this marathon
|
||||
# Get all approved challenges from approved games (challenges type) in this marathon
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.join(Game, Challenge.game_id == Game.id)
|
||||
@@ -125,7 +128,47 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
|
||||
)
|
||||
challenges = result.scalars().all()
|
||||
|
||||
return [build_challenge_response(c, c.game) for c in challenges]
|
||||
responses = [build_challenge_response(c, c.game) for c in challenges]
|
||||
|
||||
# Also get playthrough-type games and create virtual challenges for them
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.status == GameStatus.APPROVED.value,
|
||||
Game.game_type == GameType.PLAYTHROUGH.value,
|
||||
)
|
||||
.order_by(Game.title)
|
||||
)
|
||||
playthrough_games = result.scalars().all()
|
||||
|
||||
for game in playthrough_games:
|
||||
# Create virtual challenge response for playthrough game
|
||||
virtual_challenge = ChallengeResponse(
|
||||
id=-game.id, # Negative ID to distinguish from real challenges
|
||||
title=f"Прохождение: {game.title}",
|
||||
description=game.playthrough_description or "Пройдите игру",
|
||||
type="completion",
|
||||
difficulty="medium",
|
||||
points=game.playthrough_points or 0,
|
||||
estimated_time=None,
|
||||
proof_type=game.playthrough_proof_type or "screenshot",
|
||||
proof_hint=game.playthrough_proof_hint,
|
||||
game=GameShort(
|
||||
id=game.id,
|
||||
title=game.title,
|
||||
cover_url=None,
|
||||
download_url=game.download_url,
|
||||
game_type=game.game_type
|
||||
),
|
||||
is_generated=False,
|
||||
created_at=game.created_at,
|
||||
status="approved",
|
||||
proposed_by=None,
|
||||
)
|
||||
responses.append(virtual_challenge)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
||||
@@ -323,7 +366,7 @@ async def save_challenges(
|
||||
description=ch_data.description,
|
||||
type=ch_type,
|
||||
difficulty=difficulty,
|
||||
points=max(1, min(500, ch_data.points)),
|
||||
points=max(1, ch_data.points),
|
||||
estimated_time=ch_data.estimated_time,
|
||||
proof_type=proof_type,
|
||||
proof_hint=ch_data.proof_hint,
|
||||
|
||||
@@ -7,9 +7,10 @@ from sqlalchemy.orm import selectinload
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.models import (
|
||||
Marathon, MarathonStatus, Participant, ParticipantRole,
|
||||
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
|
||||
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
|
||||
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
|
||||
)
|
||||
from app.models.bonus_assignment import BonusAssignment
|
||||
from fastapi import UploadFile, File, Form
|
||||
|
||||
from app.schemas import (
|
||||
@@ -150,6 +151,46 @@ async def start_event(
|
||||
detail="Common enemy event requires challenge_id"
|
||||
)
|
||||
|
||||
# Handle playthrough games (negative challenge_id = -game_id)
|
||||
challenge_id = data.challenge_id
|
||||
game_id = None
|
||||
is_playthrough = False
|
||||
|
||||
if data.type == EventType.COMMON_ENEMY.value and challenge_id and challenge_id < 0:
|
||||
# This is a playthrough game, not a real challenge
|
||||
game_id = -challenge_id # Convert negative to positive game_id
|
||||
challenge_id = None
|
||||
is_playthrough = True
|
||||
|
||||
# Verify game exists and is a playthrough game
|
||||
from app.models.game import GameType
|
||||
result = await db.execute(
|
||||
select(Game).where(
|
||||
Game.id == game_id,
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.game_type == GameType.PLAYTHROUGH.value,
|
||||
)
|
||||
)
|
||||
game = result.scalar_one_or_none()
|
||||
if not game:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Playthrough game not found"
|
||||
)
|
||||
elif data.type == EventType.COMMON_ENEMY.value and challenge_id and challenge_id > 0:
|
||||
# Verify regular challenge exists
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.options(selectinload(Challenge.game))
|
||||
.where(Challenge.id == challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one_or_none()
|
||||
if not challenge or challenge.game.marathon_id != marathon_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Challenge not found in this marathon"
|
||||
)
|
||||
|
||||
try:
|
||||
event = await event_service.start_event(
|
||||
db=db,
|
||||
@@ -157,7 +198,9 @@ async def start_event(
|
||||
event_type=data.type,
|
||||
created_by_id=current_user.id,
|
||||
duration_minutes=data.duration_minutes,
|
||||
challenge_id=data.challenge_id,
|
||||
challenge_id=challenge_id,
|
||||
game_id=game_id,
|
||||
is_playthrough=is_playthrough,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -233,6 +276,26 @@ async def stop_event(
|
||||
return MessageResponse(message="Event stopped")
|
||||
|
||||
|
||||
def build_assignment_info(assignment: Assignment) -> SwapRequestChallengeInfo:
|
||||
"""Build SwapRequestChallengeInfo from assignment (challenge or playthrough)"""
|
||||
if assignment.is_playthrough:
|
||||
return SwapRequestChallengeInfo(
|
||||
is_playthrough=True,
|
||||
playthrough_description=assignment.game.playthrough_description,
|
||||
playthrough_points=assignment.game.playthrough_points,
|
||||
game_title=assignment.game.title,
|
||||
)
|
||||
else:
|
||||
return SwapRequestChallengeInfo(
|
||||
is_playthrough=False,
|
||||
title=assignment.challenge.title,
|
||||
description=assignment.challenge.description,
|
||||
points=assignment.challenge.points,
|
||||
difficulty=assignment.challenge.difficulty,
|
||||
game_title=assignment.challenge.game.title,
|
||||
)
|
||||
|
||||
|
||||
def build_swap_request_response(
|
||||
swap_req: SwapRequestModel,
|
||||
) -> SwapRequestResponse:
|
||||
@@ -256,20 +319,8 @@ def build_swap_request_response(
|
||||
role=swap_req.to_participant.user.role,
|
||||
created_at=swap_req.to_participant.user.created_at,
|
||||
),
|
||||
from_challenge=SwapRequestChallengeInfo(
|
||||
title=swap_req.from_assignment.challenge.title,
|
||||
description=swap_req.from_assignment.challenge.description,
|
||||
points=swap_req.from_assignment.challenge.points,
|
||||
difficulty=swap_req.from_assignment.challenge.difficulty,
|
||||
game_title=swap_req.from_assignment.challenge.game.title,
|
||||
),
|
||||
to_challenge=SwapRequestChallengeInfo(
|
||||
title=swap_req.to_assignment.challenge.title,
|
||||
description=swap_req.to_assignment.challenge.description,
|
||||
points=swap_req.to_assignment.challenge.points,
|
||||
difficulty=swap_req.to_assignment.challenge.difficulty,
|
||||
game_title=swap_req.to_assignment.challenge.game.title,
|
||||
),
|
||||
from_challenge=build_assignment_info(swap_req.from_assignment),
|
||||
to_challenge=build_assignment_info(swap_req.to_assignment),
|
||||
created_at=swap_req.created_at,
|
||||
responded_at=swap_req.responded_at,
|
||||
)
|
||||
@@ -307,11 +358,12 @@ async def create_swap_request(
|
||||
if target.id == participant.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot swap with yourself")
|
||||
|
||||
# Get both active assignments
|
||||
# Get both active assignments (with challenge.game or game for playthrough)
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game), # For playthrough
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
@@ -323,7 +375,8 @@ async def create_swap_request(
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game), # For playthrough
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == target.id,
|
||||
@@ -375,7 +428,7 @@ async def create_swap_request(
|
||||
await db.commit()
|
||||
await db.refresh(swap_request)
|
||||
|
||||
# Load relationships for response
|
||||
# Load relationships for response (including game for playthrough)
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel)
|
||||
.options(
|
||||
@@ -384,9 +437,13 @@ async def create_swap_request(
|
||||
selectinload(SwapRequestModel.from_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
selectinload(SwapRequestModel.from_assignment)
|
||||
.selectinload(Assignment.game),
|
||||
selectinload(SwapRequestModel.to_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
selectinload(SwapRequestModel.to_assignment)
|
||||
.selectinload(Assignment.game),
|
||||
)
|
||||
.where(SwapRequestModel.id == swap_request.id)
|
||||
)
|
||||
@@ -419,9 +476,13 @@ async def get_my_swap_requests(
|
||||
selectinload(SwapRequestModel.from_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
selectinload(SwapRequestModel.from_assignment)
|
||||
.selectinload(Assignment.game),
|
||||
selectinload(SwapRequestModel.to_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
selectinload(SwapRequestModel.to_assignment)
|
||||
.selectinload(Assignment.game),
|
||||
)
|
||||
.where(
|
||||
SwapRequestModel.event_id == event.id,
|
||||
@@ -511,10 +572,39 @@ async def accept_swap_request(
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=400, detail="One or both assignments are no longer active")
|
||||
|
||||
# Perform the swap
|
||||
# Perform the swap (swap challenge_id, game_id, and is_playthrough)
|
||||
from_challenge_id = from_assignment.challenge_id
|
||||
from_game_id = from_assignment.game_id
|
||||
from_is_playthrough = from_assignment.is_playthrough
|
||||
|
||||
from_assignment.challenge_id = to_assignment.challenge_id
|
||||
from_assignment.game_id = to_assignment.game_id
|
||||
from_assignment.is_playthrough = to_assignment.is_playthrough
|
||||
|
||||
to_assignment.challenge_id = from_challenge_id
|
||||
to_assignment.game_id = from_game_id
|
||||
to_assignment.is_playthrough = from_is_playthrough
|
||||
|
||||
# Swap bonus assignments between the two assignments
|
||||
from sqlalchemy import update as sql_update
|
||||
|
||||
# Get bonus assignments for both
|
||||
from_bonus_result = await db.execute(
|
||||
select(BonusAssignment).where(BonusAssignment.main_assignment_id == from_assignment.id)
|
||||
)
|
||||
from_bonus_assignments = from_bonus_result.scalars().all()
|
||||
|
||||
to_bonus_result = await db.execute(
|
||||
select(BonusAssignment).where(BonusAssignment.main_assignment_id == to_assignment.id)
|
||||
)
|
||||
to_bonus_assignments = to_bonus_result.scalars().all()
|
||||
|
||||
# Move bonus assignments: from -> to, to -> from
|
||||
for bonus in from_bonus_assignments:
|
||||
bonus.main_assignment_id = to_assignment.id
|
||||
|
||||
for bonus in to_bonus_assignments:
|
||||
bonus.main_assignment_id = from_assignment.id
|
||||
|
||||
# Update request status
|
||||
swap_request.status = SwapRequestStatus.ACCEPTED.value
|
||||
@@ -823,8 +913,10 @@ async def get_swap_candidates(
|
||||
if not event or event.type != EventType.SWAP.value:
|
||||
raise HTTPException(status_code=400, detail="No active swap event")
|
||||
|
||||
# Get all participants except current user with active assignments
|
||||
from app.models import Game
|
||||
candidates = []
|
||||
|
||||
# Get challenge-based assignments
|
||||
result = await db.execute(
|
||||
select(Participant, Assignment, Challenge, Game)
|
||||
.join(Assignment, Assignment.participant_id == Participant.id)
|
||||
@@ -835,12 +927,11 @@ async def get_swap_candidates(
|
||||
Participant.marathon_id == marathon_id,
|
||||
Participant.id != participant.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
Assignment.is_playthrough == False,
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
SwapCandidate(
|
||||
for p, assignment, challenge, game in result.all():
|
||||
candidates.append(SwapCandidate(
|
||||
participant_id=p.id,
|
||||
user=UserPublic(
|
||||
id=p.user.id,
|
||||
@@ -850,14 +941,45 @@ async def get_swap_candidates(
|
||||
role=p.user.role,
|
||||
created_at=p.user.created_at,
|
||||
),
|
||||
is_playthrough=False,
|
||||
challenge_title=challenge.title,
|
||||
challenge_description=challenge.description,
|
||||
challenge_points=challenge.points,
|
||||
challenge_difficulty=challenge.difficulty,
|
||||
game_title=game.title,
|
||||
))
|
||||
|
||||
# Get playthrough-based assignments
|
||||
result = await db.execute(
|
||||
select(Participant, Assignment, Game)
|
||||
.join(Assignment, Assignment.participant_id == Participant.id)
|
||||
.join(Game, Assignment.game_id == Game.id)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(
|
||||
Participant.marathon_id == marathon_id,
|
||||
Participant.id != participant.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
Assignment.is_playthrough == True,
|
||||
)
|
||||
for p, assignment, challenge, game in rows
|
||||
]
|
||||
)
|
||||
for p, assignment, game in result.all():
|
||||
candidates.append(SwapCandidate(
|
||||
participant_id=p.id,
|
||||
user=UserPublic(
|
||||
id=p.user.id,
|
||||
login=p.user.login,
|
||||
nickname=p.user.nickname,
|
||||
avatar_url=None,
|
||||
role=p.user.role,
|
||||
created_at=p.user.created_at,
|
||||
),
|
||||
is_playthrough=True,
|
||||
playthrough_description=game.playthrough_description,
|
||||
playthrough_points=game.playthrough_points,
|
||||
game_title=game.title,
|
||||
))
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/common-enemy-leaderboard", response_model=list[CommonEnemyLeaderboard])
|
||||
@@ -919,6 +1041,42 @@ async def get_common_enemy_leaderboard(
|
||||
|
||||
def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
|
||||
"""Convert Assignment model to AssignmentResponse"""
|
||||
# Handle playthrough assignments (no challenge, only game)
|
||||
if assignment.is_playthrough and assignment.game:
|
||||
game = assignment.game
|
||||
return AssignmentResponse(
|
||||
id=assignment.id,
|
||||
challenge=ChallengeResponse(
|
||||
id=-game.id, # Negative ID for playthrough
|
||||
title=f"Прохождение: {game.title}",
|
||||
description=game.playthrough_description or "Пройдите игру",
|
||||
type="completion",
|
||||
difficulty="medium",
|
||||
points=game.playthrough_points or 0,
|
||||
estimated_time=None,
|
||||
proof_type=game.playthrough_proof_type or "screenshot",
|
||||
proof_hint=game.playthrough_proof_hint,
|
||||
game=GameShort(
|
||||
id=game.id,
|
||||
title=game.title,
|
||||
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||
download_url=game.download_url,
|
||||
game_type=game.game_type,
|
||||
),
|
||||
is_generated=False,
|
||||
created_at=game.created_at,
|
||||
),
|
||||
status=assignment.status,
|
||||
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
|
||||
proof_comment=assignment.proof_comment,
|
||||
points_earned=assignment.points_earned,
|
||||
streak_at_completion=assignment.streak_at_completion,
|
||||
started_at=assignment.started_at,
|
||||
completed_at=assignment.completed_at,
|
||||
tracked_time_minutes=assignment.tracked_time_minutes,
|
||||
)
|
||||
|
||||
# Regular challenge assignment
|
||||
challenge = assignment.challenge
|
||||
game = challenge.game
|
||||
return AssignmentResponse(
|
||||
@@ -937,6 +1095,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
|
||||
id=game.id,
|
||||
title=game.title,
|
||||
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||
download_url=game.download_url,
|
||||
),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
@@ -948,6 +1107,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
|
||||
streak_at_completion=assignment.streak_at_completion,
|
||||
started_at=assignment.started_at,
|
||||
completed_at=assignment.completed_at,
|
||||
tracked_time_minutes=assignment.tracked_time_minutes,
|
||||
)
|
||||
|
||||
|
||||
@@ -968,7 +1128,8 @@ async def get_event_assignment(
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game), # For playthrough assignments
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
@@ -999,10 +1160,19 @@ async def get_event_assignment(
|
||||
is_completed=False,
|
||||
)
|
||||
|
||||
# Determine challenge_id for response (negative for playthrough)
|
||||
challenge_id_response = None
|
||||
if event and event.data:
|
||||
if event.data.get("is_playthrough"):
|
||||
game_id = event.data.get("game_id")
|
||||
challenge_id_response = -game_id if game_id else None
|
||||
else:
|
||||
challenge_id_response = event.data.get("challenge_id")
|
||||
|
||||
return EventAssignmentResponse(
|
||||
assignment=assignment_to_response(assignment) if assignment else None,
|
||||
event_id=event.id if event else None,
|
||||
challenge_id=event.data.get("challenge_id") if event and event.data else None,
|
||||
challenge_id=challenge_id_response,
|
||||
is_completed=is_completed,
|
||||
)
|
||||
|
||||
@@ -1026,6 +1196,7 @@ async def complete_event_assignment(
|
||||
.options(
|
||||
selectinload(Assignment.participant),
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game), # For playthrough assignments
|
||||
)
|
||||
.where(Assignment.id == assignment_id)
|
||||
)
|
||||
@@ -1079,17 +1250,25 @@ async def complete_event_assignment(
|
||||
|
||||
assignment.proof_comment = comment
|
||||
|
||||
# Get marathon_id
|
||||
marathon_id = assignment.challenge.game.marathon_id
|
||||
# Get marathon_id and base points (handle playthrough vs regular challenge)
|
||||
participant = assignment.participant
|
||||
if assignment.is_playthrough and assignment.game:
|
||||
marathon_id = assignment.game.marathon_id
|
||||
base_points = assignment.game.playthrough_points or 0
|
||||
challenge_title = f"Прохождение: {assignment.game.title}"
|
||||
game_title = assignment.game.title
|
||||
difficulty = "medium"
|
||||
else:
|
||||
challenge = assignment.challenge
|
||||
marathon_id = challenge.game.marathon_id
|
||||
base_points = challenge.points
|
||||
challenge_title = challenge.title
|
||||
game_title = challenge.game.title
|
||||
difficulty = challenge.difficulty
|
||||
|
||||
# Get active event for bonus calculation
|
||||
active_event = await event_service.get_active_event(db, marathon_id)
|
||||
|
||||
# Calculate base points (no streak bonus for event assignments)
|
||||
participant = assignment.participant
|
||||
challenge = assignment.challenge
|
||||
base_points = challenge.points
|
||||
|
||||
# Handle common enemy bonus
|
||||
common_enemy_bonus = 0
|
||||
common_enemy_closed = False
|
||||
@@ -1113,12 +1292,13 @@ async def complete_event_assignment(
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"assignment_id": assignment.id,
|
||||
"game": challenge.game.title,
|
||||
"challenge": challenge.title,
|
||||
"difficulty": challenge.difficulty,
|
||||
"game": game_title,
|
||||
"challenge": challenge_title,
|
||||
"difficulty": difficulty,
|
||||
"points": total_points,
|
||||
"event_type": EventType.COMMON_ENEMY.value,
|
||||
"is_event_assignment": True,
|
||||
"is_playthrough": assignment.is_playthrough,
|
||||
}
|
||||
if common_enemy_bonus:
|
||||
activity_data["common_enemy_bonus"] = common_enemy_bonus
|
||||
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.models import Activity, Participant, Dispute, ActivityType
|
||||
from app.models import Activity, Participant, Dispute, ActivityType, User
|
||||
from app.models.dispute import DisputeStatus
|
||||
from app.schemas import FeedResponse, ActivityResponse, UserPublic
|
||||
|
||||
@@ -37,7 +37,12 @@ async def get_feed(
|
||||
# Get activities
|
||||
result = await db.execute(
|
||||
select(Activity)
|
||||
.options(selectinload(Activity.user))
|
||||
.options(
|
||||
selectinload(Activity.user).selectinload(User.equipped_frame),
|
||||
selectinload(Activity.user).selectinload(User.equipped_title),
|
||||
selectinload(Activity.user).selectinload(User.equipped_name_color),
|
||||
selectinload(Activity.user).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Activity.marathon_id == marathon_id)
|
||||
.order_by(Activity.created_at.desc())
|
||||
.limit(limit)
|
||||
|
||||
@@ -7,8 +7,13 @@ from app.api.deps import (
|
||||
require_participant, require_organizer, get_participant,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
||||
from app.models import (
|
||||
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
|
||||
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User,
|
||||
ExiledGame
|
||||
)
|
||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||
from app.schemas.assignment import AvailableGamesCount
|
||||
from app.services.storage import storage_service
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
@@ -19,8 +24,14 @@ async def get_game_or_404(db, game_id: int) -> Game:
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(
|
||||
selectinload(Game.proposed_by),
|
||||
selectinload(Game.approved_by),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_background),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Game.id == game_id)
|
||||
)
|
||||
@@ -43,6 +54,12 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
|
||||
approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None,
|
||||
challenges_count=challenges_count,
|
||||
created_at=game.created_at,
|
||||
# Поля для типа игры
|
||||
game_type=game.game_type,
|
||||
playthrough_points=game.playthrough_points,
|
||||
playthrough_description=game.playthrough_description,
|
||||
playthrough_proof_type=game.playthrough_proof_type,
|
||||
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||
)
|
||||
|
||||
|
||||
@@ -63,8 +80,14 @@ async def list_games(
|
||||
select(Game, func.count(Challenge.id).label("challenges_count"))
|
||||
.outerjoin(Challenge)
|
||||
.options(
|
||||
selectinload(Game.proposed_by),
|
||||
selectinload(Game.approved_by),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_background),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Game.marathon_id == marathon_id)
|
||||
.group_by(Game.id)
|
||||
@@ -96,8 +119,14 @@ async def list_pending_games(marathon_id: int, current_user: CurrentUser, db: Db
|
||||
select(Game, func.count(Challenge.id).label("challenges_count"))
|
||||
.outerjoin(Challenge)
|
||||
.options(
|
||||
selectinload(Game.proposed_by),
|
||||
selectinload(Game.approved_by),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_background),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
@@ -145,6 +174,12 @@ async def add_game(
|
||||
proposed_by_id=current_user.id,
|
||||
status=game_status,
|
||||
approved_by_id=current_user.id if is_organizer else None,
|
||||
# Поля для типа игры
|
||||
game_type=data.game_type.value,
|
||||
playthrough_points=data.playthrough_points,
|
||||
playthrough_description=data.playthrough_description,
|
||||
playthrough_proof_type=data.playthrough_proof_type.value if data.playthrough_proof_type else None,
|
||||
playthrough_proof_hint=data.playthrough_proof_hint,
|
||||
)
|
||||
db.add(game)
|
||||
|
||||
@@ -171,6 +206,12 @@ async def add_game(
|
||||
approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
|
||||
challenges_count=0,
|
||||
created_at=game.created_at,
|
||||
# Поля для типа игры
|
||||
game_type=game.game_type,
|
||||
playthrough_points=game.playthrough_points,
|
||||
playthrough_description=game.playthrough_description,
|
||||
playthrough_proof_type=game.playthrough_proof_type,
|
||||
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||
)
|
||||
|
||||
|
||||
@@ -227,6 +268,18 @@ async def update_game(
|
||||
if data.genre is not None:
|
||||
game.genre = data.genre
|
||||
|
||||
# Поля для типа игры
|
||||
if data.game_type is not None:
|
||||
game.game_type = data.game_type.value
|
||||
if data.playthrough_points is not None:
|
||||
game.playthrough_points = data.playthrough_points
|
||||
if data.playthrough_description is not None:
|
||||
game.playthrough_description = data.playthrough_description
|
||||
if data.playthrough_proof_type is not None:
|
||||
game.playthrough_proof_type = data.playthrough_proof_type.value
|
||||
if data.playthrough_proof_hint is not None:
|
||||
game.playthrough_proof_hint = data.playthrough_proof_hint
|
||||
|
||||
await db.commit()
|
||||
|
||||
return await get_game(game_id, current_user, db)
|
||||
@@ -398,3 +451,173 @@ async def upload_cover(
|
||||
await db.commit()
|
||||
|
||||
return await get_game(game_id, current_user, db)
|
||||
|
||||
|
||||
async def get_available_games_for_participant(
|
||||
db, participant: Participant, marathon_id: int
|
||||
) -> tuple[list[Game], int]:
|
||||
"""
|
||||
Получить список игр, доступных для спина участника.
|
||||
|
||||
Возвращает кортеж (доступные игры, всего игр).
|
||||
|
||||
Логика исключения:
|
||||
- playthrough: игра исключается если участник завершил ИЛИ дропнул прохождение
|
||||
- challenges: игра исключается если участник выполнил ВСЕ челленджи
|
||||
"""
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
# Получаем все одобренные игры с челленджами
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(selectinload(Game.challenges))
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.status == GameStatus.APPROVED.value
|
||||
)
|
||||
)
|
||||
all_games = list(result.scalars().all())
|
||||
|
||||
# Фильтруем игры с челленджами (для типа challenges)
|
||||
# или игры с заполненными playthrough полями (для типа playthrough)
|
||||
games_with_content = []
|
||||
for game in all_games:
|
||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||
# Для playthrough не нужны челленджи
|
||||
if game.playthrough_points and game.playthrough_description:
|
||||
games_with_content.append(game)
|
||||
else:
|
||||
# Для challenges нужны челленджи
|
||||
if game.challenges:
|
||||
games_with_content.append(game)
|
||||
|
||||
total_games = len(games_with_content)
|
||||
if total_games == 0:
|
||||
return [], 0
|
||||
|
||||
# Получаем завершённые/дропнутые assignments участника
|
||||
finished_statuses = [AssignmentStatus.COMPLETED.value, AssignmentStatus.DROPPED.value]
|
||||
|
||||
# Для playthrough: получаем game_id завершённых/дропнутых прохождений
|
||||
playthrough_result = await db.execute(
|
||||
select(Assignment.game_id)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.is_playthrough == True,
|
||||
Assignment.status.in_(finished_statuses)
|
||||
)
|
||||
)
|
||||
finished_playthrough_game_ids = set(playthrough_result.scalars().all())
|
||||
|
||||
# Для challenges: получаем challenge_id завершённых заданий
|
||||
challenges_result = await db.execute(
|
||||
select(Assignment.challenge_id)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.is_playthrough == False,
|
||||
Assignment.status == AssignmentStatus.COMPLETED.value
|
||||
)
|
||||
)
|
||||
completed_challenge_ids = set(challenges_result.scalars().all())
|
||||
|
||||
# Получаем изгнанные игры (is_active=True означает что игра изгнана)
|
||||
exiled_result = await db.execute(
|
||||
select(ExiledGame.game_id)
|
||||
.where(
|
||||
ExiledGame.participant_id == participant.id,
|
||||
ExiledGame.is_active == True,
|
||||
)
|
||||
)
|
||||
exiled_game_ids = set(exiled_result.scalars().all())
|
||||
|
||||
# Фильтруем доступные игры
|
||||
available_games = []
|
||||
for game in games_with_content:
|
||||
# Исключаем изгнанные игры
|
||||
if game.id in exiled_game_ids:
|
||||
continue
|
||||
|
||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||
# Исключаем если игра уже завершена/дропнута
|
||||
if game.id not in finished_playthrough_game_ids:
|
||||
available_games.append(game)
|
||||
else:
|
||||
# Для challenges: исключаем если все челленджи выполнены
|
||||
game_challenge_ids = {c.id for c in game.challenges}
|
||||
if not game_challenge_ids.issubset(completed_challenge_ids):
|
||||
available_games.append(game)
|
||||
|
||||
return available_games, total_games
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/available-games-count", response_model=AvailableGamesCount)
|
||||
async def get_available_games_count(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""
|
||||
Получить количество игр, доступных для спина.
|
||||
|
||||
Возвращает { available: X, total: Y } где:
|
||||
- available: количество игр, которые могут выпасть
|
||||
- total: общее количество игр в марафоне
|
||||
"""
|
||||
participant = await get_participant(db, current_user.id, marathon_id)
|
||||
if not participant:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
available_games, total_games = await get_available_games_for_participant(
|
||||
db, participant, marathon_id
|
||||
)
|
||||
|
||||
return AvailableGamesCount(
|
||||
available=len(available_games),
|
||||
total=total_games
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/available-games", response_model=list[GameResponse])
|
||||
async def get_available_games(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""
|
||||
Получить список игр, доступных для спина.
|
||||
|
||||
Возвращает только те игры, которые могут выпасть участнику:
|
||||
- Для playthrough: исключаются игры которые уже завершены/дропнуты
|
||||
- Для challenges: исключаются игры где все челленджи выполнены
|
||||
"""
|
||||
participant = await get_participant(db, current_user.id, marathon_id)
|
||||
if not participant:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
available_games, _ = await get_available_games_for_participant(
|
||||
db, participant, marathon_id
|
||||
)
|
||||
|
||||
# Convert to response with challenges count
|
||||
result = []
|
||||
for game in available_games:
|
||||
challenges_count = len(game.challenges) if game.challenges else 0
|
||||
result.append(GameResponse(
|
||||
id=game.id,
|
||||
title=game.title,
|
||||
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||
download_url=game.download_url,
|
||||
genre=game.genre,
|
||||
status=game.status,
|
||||
proposed_by=None,
|
||||
approved_by=None,
|
||||
challenges_count=challenges_count,
|
||||
created_at=game.created_at,
|
||||
game_type=game.game_type,
|
||||
playthrough_points=game.playthrough_points,
|
||||
playthrough_description=game.playthrough_description,
|
||||
playthrough_proof_type=game.playthrough_proof_type,
|
||||
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
@@ -20,6 +20,8 @@ optional_auth = HTTPBearer(auto_error=False)
|
||||
from app.models import (
|
||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
|
||||
ExiledGame,
|
||||
)
|
||||
from app.schemas import (
|
||||
MarathonCreate,
|
||||
@@ -34,6 +36,8 @@ from app.schemas import (
|
||||
MessageResponse,
|
||||
UserPublic,
|
||||
SetParticipantRole,
|
||||
OrganizerSkipRequest,
|
||||
ExiledGameResponse,
|
||||
)
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
@@ -79,7 +83,12 @@ def generate_invite_code() -> str:
|
||||
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
||||
result = await db.execute(
|
||||
select(Marathon)
|
||||
.options(selectinload(Marathon.creator))
|
||||
.options(
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_frame),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_title),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Marathon.id == marathon_id)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
@@ -307,9 +316,12 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
|
||||
if len(approved_games) == 0:
|
||||
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
|
||||
|
||||
# Check that all approved games have at least one challenge
|
||||
# Check that all approved challenge-based games have at least one challenge
|
||||
# Playthrough games don't need challenges
|
||||
games_without_challenges = []
|
||||
for game in approved_games:
|
||||
if game.is_playthrough:
|
||||
continue # Игры типа "Прохождение" не требуют челленджей
|
||||
challenge_count = await db.scalar(
|
||||
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
||||
)
|
||||
@@ -344,6 +356,8 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
|
||||
|
||||
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
|
||||
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
from app.services.coins import coins_service
|
||||
|
||||
# Require organizer role
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
@@ -353,6 +367,24 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
|
||||
|
||||
marathon.status = MarathonStatus.FINISHED.value
|
||||
|
||||
# Award coins for top 3 places (only in certified marathons)
|
||||
if marathon.is_certified:
|
||||
# Get top 3 participants by total_points
|
||||
top_result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(Participant.marathon_id == marathon_id)
|
||||
.order_by(Participant.total_points.desc())
|
||||
.limit(3)
|
||||
)
|
||||
top_participants = top_result.scalars().all()
|
||||
|
||||
for place, participant in enumerate(top_participants, start=1):
|
||||
if participant.total_points > 0: # Only award if they have points
|
||||
await coins_service.award_marathon_place(
|
||||
db, participant.user, marathon, place
|
||||
)
|
||||
|
||||
# Log activity
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
@@ -461,7 +493,12 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
|
||||
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.options(
|
||||
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||||
selectinload(Participant.user).selectinload(User.equipped_title),
|
||||
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||||
selectinload(Participant.user).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Participant.marathon_id == marathon_id)
|
||||
.order_by(Participant.joined_at)
|
||||
)
|
||||
@@ -500,7 +537,12 @@ async def set_participant_role(
|
||||
# Get participant
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.options(
|
||||
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||||
selectinload(Participant.user).selectinload(User.equipped_title),
|
||||
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||||
selectinload(Participant.user).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(
|
||||
Participant.marathon_id == marathon_id,
|
||||
Participant.user_id == user_id,
|
||||
@@ -565,7 +607,12 @@ async def get_leaderboard(
|
||||
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.options(
|
||||
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||||
selectinload(Participant.user).selectinload(User.equipped_title),
|
||||
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||||
selectinload(Participant.user).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Participant.marathon_id == marathon_id)
|
||||
.order_by(Participant.total_points.desc())
|
||||
)
|
||||
@@ -703,3 +750,481 @@ async def delete_marathon_cover(
|
||||
await db.commit()
|
||||
|
||||
return await get_marathon(marathon_id, current_user, db)
|
||||
|
||||
|
||||
# ============ Marathon Disputes (for organizers) ============
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class MarathonDisputeResponse(BaseModel):
|
||||
id: int
|
||||
assignment_id: int | None
|
||||
bonus_assignment_id: int | None
|
||||
challenge_title: str
|
||||
participant_nickname: str
|
||||
raised_by_nickname: str
|
||||
reason: str
|
||||
status: str
|
||||
votes_valid: int
|
||||
votes_invalid: int
|
||||
created_at: str
|
||||
expires_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResolveDisputeRequest(BaseModel):
|
||||
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
|
||||
|
||||
|
||||
@router.get("/{marathon_id}/disputes", response_model=list[MarathonDisputeResponse])
|
||||
async def list_marathon_disputes(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
status_filter: str = "open",
|
||||
):
|
||||
"""List disputes in a marathon. Organizers only."""
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
|
||||
from datetime import timedelta
|
||||
DISPUTE_WINDOW_HOURS = 24
|
||||
|
||||
# Get all assignments in this marathon (through games)
|
||||
games_result = await db.execute(
|
||||
select(Game.id).where(Game.marathon_id == marathon_id)
|
||||
)
|
||||
game_ids = [g[0] for g in games_result.all()]
|
||||
|
||||
if not game_ids:
|
||||
return []
|
||||
|
||||
# Get disputes for assignments in these games
|
||||
# Using selectinload for eager loading - no explicit joins needed
|
||||
query = (
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.raised_by),
|
||||
selectinload(Dispute.votes),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.order_by(Dispute.created_at.desc())
|
||||
)
|
||||
|
||||
if status_filter == "open":
|
||||
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
all_disputes = result.scalars().unique().all()
|
||||
|
||||
# Filter disputes that belong to this marathon's games
|
||||
response = []
|
||||
for dispute in all_disputes:
|
||||
# Check if dispute belongs to this marathon
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
if not bonus or not bonus.main_assignment:
|
||||
continue
|
||||
if bonus.main_assignment.game_id not in game_ids:
|
||||
continue
|
||||
participant = bonus.main_assignment.participant
|
||||
challenge_title = f"Бонус: {bonus.challenge.title}"
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
if not assignment:
|
||||
continue
|
||||
if assignment.is_playthrough:
|
||||
if assignment.game_id not in game_ids:
|
||||
continue
|
||||
challenge_title = f"Прохождение: {assignment.game.title}"
|
||||
else:
|
||||
if not assignment.challenge or assignment.challenge.game_id not in game_ids:
|
||||
continue
|
||||
challenge_title = assignment.challenge.title
|
||||
participant = assignment.participant
|
||||
|
||||
# Count votes
|
||||
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
||||
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
||||
|
||||
# Calculate expiry
|
||||
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||
|
||||
response.append(MarathonDisputeResponse(
|
||||
id=dispute.id,
|
||||
assignment_id=dispute.assignment_id,
|
||||
bonus_assignment_id=dispute.bonus_assignment_id,
|
||||
challenge_title=challenge_title,
|
||||
participant_nickname=participant.user.nickname,
|
||||
raised_by_nickname=dispute.raised_by.nickname,
|
||||
reason=dispute.reason,
|
||||
status=dispute.status,
|
||||
votes_valid=votes_valid,
|
||||
votes_invalid=votes_invalid,
|
||||
created_at=dispute.created_at.isoformat(),
|
||||
expires_at=expires_at.isoformat(),
|
||||
))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{marathon_id}/disputes/{dispute_id}/resolve", response_model=MessageResponse)
|
||||
async def resolve_marathon_dispute(
|
||||
marathon_id: int,
|
||||
dispute_id: int,
|
||||
data: ResolveDisputeRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Manually resolve a dispute in a marathon. Organizers only."""
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
|
||||
# Get dispute
|
||||
result = await db.execute(
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.where(Dispute.id == dispute_id)
|
||||
)
|
||||
dispute = result.scalar_one_or_none()
|
||||
|
||||
if not dispute:
|
||||
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||
|
||||
# Verify dispute belongs to this marathon
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
if bonus.main_assignment.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
if assignment.is_playthrough:
|
||||
if assignment.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
else:
|
||||
if assignment.challenge.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
|
||||
if dispute.status != DisputeStatus.OPEN.value:
|
||||
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||
|
||||
# Determine result
|
||||
if data.is_valid:
|
||||
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||
else:
|
||||
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||
|
||||
# Handle invalid proof
|
||||
if dispute.bonus_assignment_id:
|
||||
# Reset bonus assignment
|
||||
bonus = dispute.bonus_assignment
|
||||
main_assignment = bonus.main_assignment
|
||||
participant = main_assignment.participant
|
||||
|
||||
# Only subtract points if main playthrough was already completed
|
||||
# (bonus points are added only when main playthrough is completed)
|
||||
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
||||
points_to_subtract = bonus.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
# Also reduce the points_earned on the main assignment
|
||||
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
|
||||
|
||||
bonus.status = BonusAssignmentStatus.PENDING.value
|
||||
bonus.proof_path = None
|
||||
bonus.proof_url = None
|
||||
bonus.proof_comment = None
|
||||
bonus.points_earned = 0
|
||||
bonus.completed_at = None
|
||||
else:
|
||||
# Reset main assignment
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
|
||||
# Subtract points
|
||||
points_to_subtract = assignment.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
|
||||
# Reset streak - the completion was invalid
|
||||
participant.current_streak = 0
|
||||
|
||||
# Reset assignment
|
||||
assignment.status = AssignmentStatus.RETURNED.value
|
||||
assignment.points_earned = 0
|
||||
|
||||
# For playthrough: reset all bonus assignments
|
||||
if assignment.is_playthrough:
|
||||
bonus_result = await db.execute(
|
||||
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
for ba in bonus_result.scalars().all():
|
||||
ba.status = BonusAssignmentStatus.PENDING.value
|
||||
ba.proof_path = None
|
||||
ba.proof_url = None
|
||||
ba.proof_comment = None
|
||||
ba.points_earned = 0
|
||||
ba.completed_at = None
|
||||
|
||||
# Update dispute
|
||||
dispute.status = result_status
|
||||
dispute.resolved_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send notification
|
||||
if dispute.bonus_assignment_id:
|
||||
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
|
||||
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
|
||||
elif dispute.assignment.is_playthrough:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
|
||||
else:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
challenge_title = dispute.assignment.challenge.title
|
||||
|
||||
await telegram_notifier.notify_dispute_resolved(
|
||||
db,
|
||||
user_id=participant_user_id,
|
||||
marathon_title=marathon.title,
|
||||
challenge_title=challenge_title,
|
||||
is_valid=data.is_valid
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
||||
)
|
||||
|
||||
|
||||
# ============= Moderation Endpoints =============
|
||||
|
||||
@router.post("/{marathon_id}/participants/{user_id}/skip-assignment", response_model=MessageResponse)
|
||||
async def organizer_skip_assignment(
|
||||
marathon_id: int,
|
||||
user_id: int,
|
||||
data: OrganizerSkipRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""
|
||||
Organizer skips a participant's current assignment.
|
||||
|
||||
- No penalty for participant
|
||||
- Streak is preserved
|
||||
- Optionally exile the game from participant's pool
|
||||
"""
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
|
||||
# Get 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")
|
||||
|
||||
# Get target 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")
|
||||
|
||||
# Get active assignment (exclude event assignments)
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game),
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
Assignment.is_event_assignment == False,
|
||||
)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=400, detail="Participant has no active assignment")
|
||||
|
||||
# Get game info
|
||||
if assignment.is_playthrough:
|
||||
game = assignment.game
|
||||
game_id = game.id
|
||||
game_title = game.title
|
||||
else:
|
||||
game = assignment.challenge.game
|
||||
game_id = game.id
|
||||
game_title = game.title
|
||||
|
||||
# Skip the assignment (no penalty)
|
||||
from datetime import datetime
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
# Note: We do NOT reset streak or increment drop_count
|
||||
|
||||
# Exile the game if requested
|
||||
if data.exile:
|
||||
# Check if already exiled
|
||||
existing = await db.execute(
|
||||
select(ExiledGame).where(
|
||||
ExiledGame.participant_id == participant.id,
|
||||
ExiledGame.game_id == game_id,
|
||||
ExiledGame.is_active == True,
|
||||
)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
exiled = ExiledGame(
|
||||
participant_id=participant.id,
|
||||
game_id=game_id,
|
||||
assignment_id=assignment.id,
|
||||
exiled_by="organizer",
|
||||
reason=data.reason,
|
||||
)
|
||||
db.add(exiled)
|
||||
|
||||
# Log activity
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.MODERATION.value,
|
||||
data={
|
||||
"action": "skip_assignment",
|
||||
"target_user_id": user_id,
|
||||
"target_nickname": participant.user.nickname,
|
||||
"assignment_id": assignment.id,
|
||||
"game_id": game_id,
|
||||
"game_title": game_title,
|
||||
"exile": data.exile,
|
||||
"reason": data.reason,
|
||||
}
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send notification
|
||||
await telegram_notifier.notify_assignment_skipped_by_moderator(
|
||||
db,
|
||||
user=participant.user,
|
||||
marathon_title=marathon.title,
|
||||
game_title=game_title,
|
||||
exiled=data.exile,
|
||||
reason=data.reason,
|
||||
moderator_nickname=current_user.nickname,
|
||||
)
|
||||
|
||||
exile_msg = " and exiled from pool" if data.exile else ""
|
||||
return MessageResponse(message=f"Assignment skipped{exile_msg}")
|
||||
|
||||
|
||||
@router.get("/{marathon_id}/participants/{user_id}/exiled-games", response_model=list[ExiledGameResponse])
|
||||
async def get_participant_exiled_games(
|
||||
marathon_id: int,
|
||||
user_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get list of exiled games for a participant (organizers only)"""
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
|
||||
# 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:
|
||||
raise HTTPException(status_code=404, detail="Participant not found")
|
||||
|
||||
# Get exiled games
|
||||
result = await db.execute(
|
||||
select(ExiledGame)
|
||||
.options(selectinload(ExiledGame.game))
|
||||
.where(
|
||||
ExiledGame.participant_id == participant.id,
|
||||
ExiledGame.is_active == True,
|
||||
)
|
||||
.order_by(ExiledGame.exiled_at.desc())
|
||||
)
|
||||
exiled_games = result.scalars().all()
|
||||
|
||||
return [
|
||||
ExiledGameResponse(
|
||||
id=eg.id,
|
||||
game_id=eg.game_id,
|
||||
game_title=eg.game.title,
|
||||
exiled_at=eg.exiled_at,
|
||||
exiled_by=eg.exiled_by,
|
||||
reason=eg.reason,
|
||||
)
|
||||
for eg in exiled_games
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{marathon_id}/participants/{user_id}/exiled-games/{game_id}/restore", response_model=MessageResponse)
|
||||
async def restore_exiled_game(
|
||||
marathon_id: int,
|
||||
user_id: int,
|
||||
game_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Restore an exiled game back to participant's pool (organizers only)"""
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
|
||||
# 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:
|
||||
raise HTTPException(status_code=404, detail="Participant not found")
|
||||
|
||||
# Get exiled game
|
||||
result = await db.execute(
|
||||
select(ExiledGame)
|
||||
.options(selectinload(ExiledGame.game))
|
||||
.where(
|
||||
ExiledGame.participant_id == participant.id,
|
||||
ExiledGame.game_id == game_id,
|
||||
ExiledGame.is_active == True,
|
||||
)
|
||||
)
|
||||
exiled_game = result.scalar_one_or_none()
|
||||
if not exiled_game:
|
||||
raise HTTPException(status_code=404, detail="Exiled game not found")
|
||||
|
||||
# Restore (soft-delete)
|
||||
from datetime import datetime
|
||||
exiled_game.is_active = False
|
||||
exiled_game.unexiled_at = datetime.utcnow()
|
||||
exiled_game.unexiled_by = "organizer"
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message=f"Game '{exiled_game.game.title}' restored to pool")
|
||||
|
||||
299
backend/app/api/v1/promo.py
Normal file
299
backend/app/api/v1/promo.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Promo Code API endpoints - user redemption and admin management
|
||||
"""
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession, require_admin_with_2fa
|
||||
from app.models import User, CoinTransaction, CoinTransactionType
|
||||
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
||||
from app.schemas.promo_code import (
|
||||
PromoCodeCreate,
|
||||
PromoCodeUpdate,
|
||||
PromoCodeResponse,
|
||||
PromoCodeRedeemRequest,
|
||||
PromoCodeRedeemResponse,
|
||||
PromoCodeRedemptionResponse,
|
||||
PromoCodeRedemptionUser,
|
||||
)
|
||||
from app.schemas.common import MessageResponse
|
||||
|
||||
router = APIRouter(prefix="/promo", tags=["promo"])
|
||||
|
||||
|
||||
def generate_promo_code(length: int = 8) -> str:
|
||||
"""Generate a random promo code"""
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
return ''.join(secrets.choice(chars) for _ in range(length))
|
||||
|
||||
|
||||
# === User endpoints ===
|
||||
|
||||
@router.post("/redeem", response_model=PromoCodeRedeemResponse)
|
||||
async def redeem_promo_code(
|
||||
data: PromoCodeRedeemRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Redeem a promo code to receive coins"""
|
||||
# Find promo code
|
||||
result = await db.execute(
|
||||
select(PromoCode).where(PromoCode.code == data.code.upper().strip())
|
||||
)
|
||||
promo = result.scalar_one_or_none()
|
||||
|
||||
if not promo:
|
||||
raise HTTPException(status_code=404, detail="Промокод не найден")
|
||||
|
||||
# Check if valid
|
||||
if not promo.is_active:
|
||||
raise HTTPException(status_code=400, detail="Промокод деактивирован")
|
||||
|
||||
now = datetime.utcnow()
|
||||
if promo.valid_from and now < promo.valid_from:
|
||||
raise HTTPException(status_code=400, detail="Промокод ещё не активен")
|
||||
|
||||
if promo.valid_until and now > promo.valid_until:
|
||||
raise HTTPException(status_code=400, detail="Промокод истёк")
|
||||
|
||||
if promo.max_uses is not None and promo.uses_count >= promo.max_uses:
|
||||
raise HTTPException(status_code=400, detail="Лимит использований исчерпан")
|
||||
|
||||
# Check if user already redeemed
|
||||
result = await db.execute(
|
||||
select(PromoCodeRedemption).where(
|
||||
PromoCodeRedemption.promo_code_id == promo.id,
|
||||
PromoCodeRedemption.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Вы уже использовали этот промокод")
|
||||
|
||||
# Create redemption record
|
||||
redemption = PromoCodeRedemption(
|
||||
promo_code_id=promo.id,
|
||||
user_id=current_user.id,
|
||||
coins_awarded=promo.coins_amount,
|
||||
)
|
||||
db.add(redemption)
|
||||
|
||||
# Update uses count
|
||||
promo.uses_count += 1
|
||||
|
||||
# Award coins
|
||||
transaction = CoinTransaction(
|
||||
user_id=current_user.id,
|
||||
amount=promo.coins_amount,
|
||||
transaction_type=CoinTransactionType.PROMO_CODE.value,
|
||||
reference_type="promo_code",
|
||||
reference_id=promo.id,
|
||||
description=f"Промокод: {promo.code}",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
current_user.coins_balance += promo.coins_amount
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return PromoCodeRedeemResponse(
|
||||
success=True,
|
||||
coins_awarded=promo.coins_amount,
|
||||
new_balance=current_user.coins_balance,
|
||||
message=f"Вы получили {promo.coins_amount} монет!",
|
||||
)
|
||||
|
||||
|
||||
# === Admin endpoints ===
|
||||
|
||||
@router.get("/admin/list", response_model=list[PromoCodeResponse])
|
||||
async def admin_list_promo_codes(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""Get all promo codes (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
query = select(PromoCode).options(selectinload(PromoCode.created_by))
|
||||
if not include_inactive:
|
||||
query = query.where(PromoCode.is_active == True)
|
||||
|
||||
query = query.order_by(PromoCode.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
promos = result.scalars().all()
|
||||
|
||||
return [
|
||||
PromoCodeResponse(
|
||||
id=p.id,
|
||||
code=p.code,
|
||||
coins_amount=p.coins_amount,
|
||||
max_uses=p.max_uses,
|
||||
uses_count=p.uses_count,
|
||||
is_active=p.is_active,
|
||||
valid_from=p.valid_from,
|
||||
valid_until=p.valid_until,
|
||||
created_at=p.created_at,
|
||||
created_by_nickname=p.created_by.nickname if p.created_by else None,
|
||||
)
|
||||
for p in promos
|
||||
]
|
||||
|
||||
|
||||
@router.post("/admin/create", response_model=PromoCodeResponse)
|
||||
async def admin_create_promo_code(
|
||||
data: PromoCodeCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Create a new promo code (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Generate or use provided code
|
||||
code = data.code.upper().strip() if data.code else generate_promo_code()
|
||||
|
||||
# Check uniqueness
|
||||
result = await db.execute(
|
||||
select(PromoCode).where(PromoCode.code == code)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail=f"Промокод '{code}' уже существует")
|
||||
|
||||
promo = PromoCode(
|
||||
code=code,
|
||||
coins_amount=data.coins_amount,
|
||||
max_uses=data.max_uses,
|
||||
valid_from=data.valid_from,
|
||||
valid_until=data.valid_until,
|
||||
created_by_id=current_user.id,
|
||||
)
|
||||
db.add(promo)
|
||||
await db.commit()
|
||||
await db.refresh(promo)
|
||||
|
||||
return PromoCodeResponse(
|
||||
id=promo.id,
|
||||
code=promo.code,
|
||||
coins_amount=promo.coins_amount,
|
||||
max_uses=promo.max_uses,
|
||||
uses_count=promo.uses_count,
|
||||
is_active=promo.is_active,
|
||||
valid_from=promo.valid_from,
|
||||
valid_until=promo.valid_until,
|
||||
created_at=promo.created_at,
|
||||
created_by_nickname=current_user.nickname,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/admin/{promo_id}", response_model=PromoCodeResponse)
|
||||
async def admin_update_promo_code(
|
||||
promo_id: int,
|
||||
data: PromoCodeUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Update a promo code (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(selectinload(PromoCode.created_by))
|
||||
.where(PromoCode.id == promo_id)
|
||||
)
|
||||
promo = result.scalar_one_or_none()
|
||||
|
||||
if not promo:
|
||||
raise HTTPException(status_code=404, detail="Промокод не найден")
|
||||
|
||||
if data.is_active is not None:
|
||||
promo.is_active = data.is_active
|
||||
if data.max_uses is not None:
|
||||
promo.max_uses = data.max_uses
|
||||
if data.valid_until is not None:
|
||||
promo.valid_until = data.valid_until
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(promo)
|
||||
|
||||
return PromoCodeResponse(
|
||||
id=promo.id,
|
||||
code=promo.code,
|
||||
coins_amount=promo.coins_amount,
|
||||
max_uses=promo.max_uses,
|
||||
uses_count=promo.uses_count,
|
||||
is_active=promo.is_active,
|
||||
valid_from=promo.valid_from,
|
||||
valid_until=promo.valid_until,
|
||||
created_at=promo.created_at,
|
||||
created_by_nickname=promo.created_by.nickname if promo.created_by else None,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/admin/{promo_id}", response_model=MessageResponse)
|
||||
async def admin_delete_promo_code(
|
||||
promo_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Delete a promo code (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoCode).where(PromoCode.id == promo_id)
|
||||
)
|
||||
promo = result.scalar_one_or_none()
|
||||
|
||||
if not promo:
|
||||
raise HTTPException(status_code=404, detail="Промокод не найден")
|
||||
|
||||
await db.delete(promo)
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message=f"Промокод '{promo.code}' удалён")
|
||||
|
||||
|
||||
@router.get("/admin/{promo_id}/redemptions", response_model=list[PromoCodeRedemptionResponse])
|
||||
async def admin_get_promo_redemptions(
|
||||
promo_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get list of users who redeemed a promo code (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Check promo exists
|
||||
result = await db.execute(
|
||||
select(PromoCode).where(PromoCode.id == promo_id)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Промокод не найден")
|
||||
|
||||
# Get redemptions
|
||||
result = await db.execute(
|
||||
select(PromoCodeRedemption)
|
||||
.options(selectinload(PromoCodeRedemption.user))
|
||||
.where(PromoCodeRedemption.promo_code_id == promo_id)
|
||||
.order_by(PromoCodeRedemption.redeemed_at.desc())
|
||||
)
|
||||
redemptions = result.scalars().all()
|
||||
|
||||
return [
|
||||
PromoCodeRedemptionResponse(
|
||||
id=r.id,
|
||||
user=PromoCodeRedemptionUser(
|
||||
id=r.user.id,
|
||||
nickname=r.user.nickname,
|
||||
),
|
||||
coins_awarded=r.coins_awarded,
|
||||
redeemed_at=r.redeemed_at,
|
||||
)
|
||||
for r in redemptions
|
||||
]
|
||||
904
backend/app/api/v1/shop.py
Normal file
904
backend/app/api/v1/shop.py
Normal file
@@ -0,0 +1,904 @@
|
||||
"""
|
||||
Shop API endpoints - catalog, purchases, inventory, cosmetics, consumables
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession, require_participant, require_admin_with_2fa
|
||||
from app.models import (
|
||||
User, Marathon, Participant, Assignment, AssignmentStatus,
|
||||
ShopItem, UserInventory, CoinTransaction, ShopItemType,
|
||||
CertificationStatus, Challenge, Game,
|
||||
)
|
||||
from app.schemas import (
|
||||
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
|
||||
InventoryItemResponse, PurchaseRequest, PurchaseResponse,
|
||||
UseConsumableRequest, UseConsumableResponse,
|
||||
EquipItemRequest, EquipItemResponse,
|
||||
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
|
||||
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
|
||||
ConsumablesStatusResponse, MessageResponse, SwapCandidate,
|
||||
AdminGrantItemRequest,
|
||||
)
|
||||
from app.schemas.user import UserPublic
|
||||
from app.services.shop import shop_service
|
||||
from app.services.coins import coins_service
|
||||
from app.services.consumables import consumables_service
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
router = APIRouter(prefix="/shop", tags=["shop"])
|
||||
|
||||
|
||||
# === Catalog ===
|
||||
|
||||
@router.get("/items", response_model=list[ShopItemResponse])
|
||||
async def get_shop_items(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
item_type: str | None = None,
|
||||
include_unavailable: bool = False,
|
||||
):
|
||||
"""Get list of shop items"""
|
||||
items = await shop_service.get_available_items(db, item_type, include_unavailable)
|
||||
|
||||
# Get user's inventory to mark owned/equipped items
|
||||
user_inventory = await shop_service.get_user_inventory(db, current_user.id)
|
||||
owned_ids = {inv.item_id for inv in user_inventory}
|
||||
equipped_ids = {inv.item_id for inv in user_inventory if inv.equipped}
|
||||
|
||||
result = []
|
||||
for item in items:
|
||||
item_dict = ShopItemResponse.model_validate(item).model_dump()
|
||||
item_dict["is_owned"] = item.id in owned_ids
|
||||
item_dict["is_equipped"] = item.id in equipped_ids
|
||||
result.append(ShopItemResponse(**item_dict))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/items/{item_id}", response_model=ShopItemResponse)
|
||||
async def get_shop_item(
|
||||
item_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get single shop item by ID"""
|
||||
item = await shop_service.get_item_by_id(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
is_owned = await shop_service.check_user_owns_item(db, current_user.id, item_id)
|
||||
|
||||
# Check if equipped
|
||||
is_equipped = False
|
||||
if is_owned:
|
||||
inventory = await shop_service.get_user_inventory(db, current_user.id, item.item_type)
|
||||
is_equipped = any(inv.equipped and inv.item_id == item_id for inv in inventory)
|
||||
|
||||
response = ShopItemResponse.model_validate(item)
|
||||
response.is_owned = is_owned
|
||||
response.is_equipped = is_equipped
|
||||
return response
|
||||
|
||||
|
||||
# === Purchases ===
|
||||
|
||||
@router.post("/purchase", response_model=PurchaseResponse)
|
||||
async def purchase_item(
|
||||
data: PurchaseRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Purchase an item from the shop"""
|
||||
inv_item, total_cost = await shop_service.purchase_item(
|
||||
db, current_user, data.item_id, data.quantity
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
item = await shop_service.get_item_by_id(db, data.item_id)
|
||||
|
||||
return PurchaseResponse(
|
||||
success=True,
|
||||
item=ShopItemResponse.model_validate(item),
|
||||
quantity=data.quantity,
|
||||
total_cost=total_cost,
|
||||
new_balance=current_user.coins_balance,
|
||||
message=f"Successfully purchased {item.name} x{data.quantity}",
|
||||
)
|
||||
|
||||
|
||||
# === Inventory ===
|
||||
|
||||
@router.get("/inventory", response_model=list[InventoryItemResponse])
|
||||
async def get_my_inventory(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
item_type: str | None = None,
|
||||
):
|
||||
"""Get current user's inventory"""
|
||||
inventory = await shop_service.get_user_inventory(db, current_user.id, item_type)
|
||||
return [InventoryItemResponse.model_validate(inv) for inv in inventory]
|
||||
|
||||
|
||||
# === Equip/Unequip ===
|
||||
|
||||
@router.post("/equip", response_model=EquipItemResponse)
|
||||
async def equip_item(
|
||||
data: EquipItemRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Equip a cosmetic item from inventory"""
|
||||
item = await shop_service.equip_item(db, current_user, data.inventory_id)
|
||||
await db.commit()
|
||||
|
||||
return EquipItemResponse(
|
||||
success=True,
|
||||
item_type=item.item_type,
|
||||
equipped_item=ShopItemResponse.model_validate(item),
|
||||
message=f"Equipped {item.name}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/unequip/{item_type}", response_model=EquipItemResponse)
|
||||
async def unequip_item(
|
||||
item_type: str,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Unequip item of specified type"""
|
||||
valid_types = [ShopItemType.FRAME.value, ShopItemType.TITLE.value,
|
||||
ShopItemType.NAME_COLOR.value, ShopItemType.BACKGROUND.value]
|
||||
if item_type not in valid_types:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid item type: {item_type}")
|
||||
|
||||
await shop_service.unequip_item(db, current_user, item_type)
|
||||
await db.commit()
|
||||
|
||||
return EquipItemResponse(
|
||||
success=True,
|
||||
item_type=item_type,
|
||||
equipped_item=None,
|
||||
message=f"Unequipped {item_type}",
|
||||
)
|
||||
|
||||
|
||||
# === Consumables ===
|
||||
|
||||
@router.post("/use", response_model=UseConsumableResponse)
|
||||
async def use_consumable(
|
||||
data: UseConsumableRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Use a consumable item"""
|
||||
# Get marathon
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == data.marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
# Get participant
|
||||
participant = await require_participant(db, current_user.id, data.marathon_id)
|
||||
|
||||
# For some consumables, we need the assignment
|
||||
assignment = None
|
||||
if data.item_code in ["skip", "skip_exile", "wild_card", "copycat"]:
|
||||
if not data.assignment_id:
|
||||
raise HTTPException(status_code=400, detail=f"assignment_id is required for {data.item_code}")
|
||||
|
||||
# For copycat and wild_card, we need bonus_assignments to properly handle playthrough
|
||||
if data.item_code in ("copycat", "wild_card"):
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(selectinload(Assignment.bonus_assignments))
|
||||
.where(
|
||||
Assignment.id == data.assignment_id,
|
||||
Assignment.participant_id == participant.id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(Assignment).where(
|
||||
Assignment.id == data.assignment_id,
|
||||
Assignment.participant_id == participant.id,
|
||||
)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Use the consumable
|
||||
if data.item_code == "skip":
|
||||
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
|
||||
effect_description = "Assignment skipped without penalty"
|
||||
elif data.item_code == "skip_exile":
|
||||
effect = await consumables_service.use_skip_exile(db, current_user, participant, marathon, assignment)
|
||||
effect_description = "Assignment skipped, game exiled from pool"
|
||||
elif data.item_code == "boost":
|
||||
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
|
||||
effect_description = f"Boost x{effect['multiplier']} activated for current assignment"
|
||||
elif data.item_code == "wild_card":
|
||||
if data.game_id is None:
|
||||
raise HTTPException(status_code=400, detail="game_id is required for wild_card")
|
||||
effect = await consumables_service.use_wild_card(
|
||||
db, current_user, participant, marathon, assignment, data.game_id
|
||||
)
|
||||
effect_description = f"New challenge from {effect['game_name']}: {effect['challenge_title']}"
|
||||
elif data.item_code == "lucky_dice":
|
||||
effect = await consumables_service.use_lucky_dice(db, current_user, participant, marathon)
|
||||
effect_description = f"Lucky Dice rolled: x{effect['multiplier']} multiplier"
|
||||
elif data.item_code == "copycat":
|
||||
if data.target_participant_id is None:
|
||||
raise HTTPException(status_code=400, detail="target_participant_id is required for copycat")
|
||||
effect = await consumables_service.use_copycat(
|
||||
db, current_user, participant, marathon, assignment, data.target_participant_id
|
||||
)
|
||||
effect_description = f"Copied challenge: {effect['challenge_title']}"
|
||||
elif data.item_code == "undo":
|
||||
effect = await consumables_service.use_undo(db, current_user, participant, marathon)
|
||||
effect_description = f"Restored {effect['points_restored']} points and streak {effect['streak_restored']}"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Get remaining quantity
|
||||
remaining = await consumables_service.get_consumable_count(db, current_user.id, data.item_code)
|
||||
|
||||
return UseConsumableResponse(
|
||||
success=True,
|
||||
item_code=data.item_code,
|
||||
remaining_quantity=remaining,
|
||||
effect_description=effect_description,
|
||||
effect_data=effect,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/consumables/{marathon_id}", response_model=ConsumablesStatusResponse)
|
||||
async def get_consumables_status(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get consumables status for participant in marathon"""
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Get inventory counts for all consumables
|
||||
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
|
||||
skip_exiles_available = await consumables_service.get_consumable_count(db, current_user.id, "skip_exile")
|
||||
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
|
||||
wild_cards_available = await consumables_service.get_consumable_count(db, current_user.id, "wild_card")
|
||||
lucky_dice_available = await consumables_service.get_consumable_count(db, current_user.id, "lucky_dice")
|
||||
copycats_available = await consumables_service.get_consumable_count(db, current_user.id, "copycat")
|
||||
undos_available = await consumables_service.get_consumable_count(db, current_user.id, "undo")
|
||||
|
||||
# Calculate remaining skips for this marathon
|
||||
skips_remaining = None
|
||||
if marathon.max_skips_per_participant is not None:
|
||||
skips_remaining = max(0, marathon.max_skips_per_participant - participant.skips_used)
|
||||
|
||||
return ConsumablesStatusResponse(
|
||||
skips_available=skips_available,
|
||||
skip_exiles_available=skip_exiles_available,
|
||||
skips_used=participant.skips_used,
|
||||
skips_remaining=skips_remaining,
|
||||
boosts_available=boosts_available,
|
||||
has_active_boost=participant.has_active_boost,
|
||||
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
|
||||
wild_cards_available=wild_cards_available,
|
||||
lucky_dice_available=lucky_dice_available,
|
||||
has_lucky_dice=participant.has_lucky_dice,
|
||||
lucky_dice_multiplier=participant.lucky_dice_multiplier,
|
||||
copycats_available=copycats_available,
|
||||
undos_available=undos_available,
|
||||
can_undo=participant.can_undo,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/copycat-candidates/{marathon_id}", response_model=list[SwapCandidate])
|
||||
async def get_copycat_candidates(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get participants with active assignments available for copycat (no event required)"""
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Get all participants except current user with active assignments
|
||||
# Support both challenge assignments and playthrough assignments
|
||||
result = await db.execute(
|
||||
select(Participant, Assignment, Challenge, Game)
|
||||
.join(Assignment, Assignment.participant_id == Participant.id)
|
||||
.outerjoin(Challenge, Assignment.challenge_id == Challenge.id)
|
||||
.outerjoin(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(
|
||||
Participant.marathon_id == marathon_id,
|
||||
Participant.id != participant.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
candidates = []
|
||||
for p, assignment, challenge, game in rows:
|
||||
# For playthrough assignments, challenge is None
|
||||
if assignment.is_playthrough:
|
||||
# Need to get game info for playthrough
|
||||
game_result = await db.execute(
|
||||
select(Game).where(Game.id == assignment.game_id)
|
||||
)
|
||||
playthrough_game = game_result.scalar_one_or_none()
|
||||
if playthrough_game:
|
||||
candidates.append(SwapCandidate(
|
||||
participant_id=p.id,
|
||||
user=UserPublic(
|
||||
id=p.user.id,
|
||||
nickname=p.user.nickname,
|
||||
avatar_url=p.user.avatar_url,
|
||||
role=p.user.role,
|
||||
telegram_avatar_url=p.user.telegram_avatar_url,
|
||||
created_at=p.user.created_at,
|
||||
equipped_frame=None,
|
||||
equipped_title=None,
|
||||
equipped_name_color=None,
|
||||
equipped_background=None,
|
||||
),
|
||||
challenge_title=f"Прохождение: {playthrough_game.title}",
|
||||
challenge_description=playthrough_game.playthrough_description or "Прохождение игры",
|
||||
challenge_points=playthrough_game.playthrough_points or 0,
|
||||
challenge_difficulty="medium",
|
||||
game_title=playthrough_game.title,
|
||||
))
|
||||
elif challenge and game:
|
||||
candidates.append(SwapCandidate(
|
||||
participant_id=p.id,
|
||||
user=UserPublic(
|
||||
id=p.user.id,
|
||||
nickname=p.user.nickname,
|
||||
avatar_url=p.user.avatar_url,
|
||||
role=p.user.role,
|
||||
telegram_avatar_url=p.user.telegram_avatar_url,
|
||||
created_at=p.user.created_at,
|
||||
equipped_frame=None,
|
||||
equipped_title=None,
|
||||
equipped_name_color=None,
|
||||
equipped_background=None,
|
||||
),
|
||||
challenge_title=challenge.title,
|
||||
challenge_description=challenge.description,
|
||||
challenge_points=challenge.points,
|
||||
challenge_difficulty=challenge.difficulty,
|
||||
game_title=game.title,
|
||||
))
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
# === Coins ===
|
||||
|
||||
@router.get("/balance", response_model=CoinsBalanceResponse)
|
||||
async def get_coins_balance(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get current user's coins balance with recent transactions"""
|
||||
result = await db.execute(
|
||||
select(CoinTransaction)
|
||||
.where(CoinTransaction.user_id == current_user.id)
|
||||
.order_by(CoinTransaction.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
transactions = result.scalars().all()
|
||||
|
||||
return CoinsBalanceResponse(
|
||||
balance=current_user.coins_balance,
|
||||
recent_transactions=[CoinTransactionResponse.model_validate(t) for t in transactions],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/transactions", response_model=list[CoinTransactionResponse])
|
||||
async def get_coin_transactions(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
):
|
||||
"""Get user's coin transaction history"""
|
||||
result = await db.execute(
|
||||
select(CoinTransaction)
|
||||
.where(CoinTransaction.user_id == current_user.id)
|
||||
.order_by(CoinTransaction.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(min(limit, 100))
|
||||
)
|
||||
transactions = result.scalars().all()
|
||||
return [CoinTransactionResponse.model_validate(t) for t in transactions]
|
||||
|
||||
|
||||
# === Certification (organizer endpoints) ===
|
||||
|
||||
@router.post("/certification/{marathon_id}/request", response_model=CertificationStatusResponse)
|
||||
async def request_certification(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Request certification for a marathon (organizer only)"""
|
||||
# Check user is organizer
|
||||
result = await db.execute(
|
||||
select(Marathon).where(Marathon.id == marathon_id)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
if marathon.creator_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only the creator can request certification")
|
||||
|
||||
if marathon.certification_status != CertificationStatus.NONE.value:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Marathon already has certification status: {marathon.certification_status}"
|
||||
)
|
||||
|
||||
marathon.certification_status = CertificationStatus.PENDING.value
|
||||
marathon.certification_requested_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(marathon)
|
||||
|
||||
return CertificationStatusResponse(
|
||||
marathon_id=marathon.id,
|
||||
certification_status=marathon.certification_status,
|
||||
is_certified=marathon.is_certified,
|
||||
certification_requested_at=marathon.certification_requested_at,
|
||||
certified_at=marathon.certified_at,
|
||||
certified_by_nickname=None,
|
||||
rejection_reason=None,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/certification/{marathon_id}/request", response_model=MessageResponse)
|
||||
async def cancel_certification_request(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Cancel certification request (organizer only)"""
|
||||
result = await db.execute(
|
||||
select(Marathon).where(Marathon.id == marathon_id)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
if marathon.creator_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only the creator can cancel certification request")
|
||||
|
||||
if marathon.certification_status != CertificationStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="No pending certification request to cancel")
|
||||
|
||||
marathon.certification_status = CertificationStatus.NONE.value
|
||||
marathon.certification_requested_at = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Certification request cancelled")
|
||||
|
||||
|
||||
@router.get("/certification/{marathon_id}", response_model=CertificationStatusResponse)
|
||||
async def get_certification_status(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get certification status of a marathon"""
|
||||
result = await db.execute(
|
||||
select(Marathon)
|
||||
.options(selectinload(Marathon.certified_by))
|
||||
.where(Marathon.id == marathon_id)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
return CertificationStatusResponse(
|
||||
marathon_id=marathon.id,
|
||||
certification_status=marathon.certification_status,
|
||||
is_certified=marathon.is_certified,
|
||||
certification_requested_at=marathon.certification_requested_at,
|
||||
certified_at=marathon.certified_at,
|
||||
certified_by_nickname=marathon.certified_by.nickname if marathon.certified_by else None,
|
||||
rejection_reason=marathon.certification_rejection_reason,
|
||||
)
|
||||
|
||||
|
||||
# === Admin endpoints ===
|
||||
|
||||
@router.get("/admin/items", response_model=list[ShopItemResponse])
|
||||
async def admin_get_all_items(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get all shop items including inactive (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
items = await shop_service.get_available_items(db, include_unavailable=True)
|
||||
return [ShopItemResponse.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.post("/admin/items", response_model=ShopItemResponse)
|
||||
async def admin_create_item(
|
||||
data: ShopItemCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Create a new shop item (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Check code uniqueness
|
||||
existing = await shop_service.get_item_by_code(db, data.code)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"Item with code '{data.code}' already exists")
|
||||
|
||||
item = ShopItem(
|
||||
item_type=data.item_type,
|
||||
code=data.code,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
price=data.price,
|
||||
rarity=data.rarity,
|
||||
asset_data=data.asset_data,
|
||||
is_active=data.is_active,
|
||||
available_from=data.available_from,
|
||||
available_until=data.available_until,
|
||||
stock_limit=data.stock_limit,
|
||||
stock_remaining=data.stock_limit, # Initialize remaining = limit
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return ShopItemResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.put("/admin/items/{item_id}", response_model=ShopItemResponse)
|
||||
async def admin_update_item(
|
||||
item_id: int,
|
||||
data: ShopItemUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Update a shop item (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
item = await shop_service.get_item_by_id(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
# Update fields
|
||||
if data.name is not None:
|
||||
item.name = data.name
|
||||
if data.description is not None:
|
||||
item.description = data.description
|
||||
if data.price is not None:
|
||||
item.price = data.price
|
||||
if data.rarity is not None:
|
||||
item.rarity = data.rarity
|
||||
if data.asset_data is not None:
|
||||
item.asset_data = data.asset_data
|
||||
if data.is_active is not None:
|
||||
item.is_active = data.is_active
|
||||
if data.available_from is not None:
|
||||
item.available_from = data.available_from
|
||||
if data.available_until is not None:
|
||||
item.available_until = data.available_until
|
||||
if data.stock_limit is not None:
|
||||
# If increasing limit, also increase remaining
|
||||
if item.stock_limit is not None and data.stock_limit > item.stock_limit:
|
||||
diff = data.stock_limit - item.stock_limit
|
||||
item.stock_remaining = (item.stock_remaining or 0) + diff
|
||||
item.stock_limit = data.stock_limit
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return ShopItemResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/admin/items/{item_id}", response_model=MessageResponse)
|
||||
async def admin_delete_item(
|
||||
item_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Delete a shop item (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
item = await shop_service.get_item_by_id(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message=f"Item '{item.name}' deleted")
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/coins/grant", response_model=MessageResponse)
|
||||
async def admin_grant_coins(
|
||||
user_id: int,
|
||||
data: AdminCoinsRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Grant coins to a user (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
await coins_service.admin_grant_coins(db, user, data.amount, data.reason, current_user.id)
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message=f"Granted {data.amount} coins to {user.nickname}")
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/coins/deduct", response_model=MessageResponse)
|
||||
async def admin_deduct_coins(
|
||||
user_id: int,
|
||||
data: AdminCoinsRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Deduct coins from a user (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
success = await coins_service.admin_deduct_coins(db, user, data.amount, data.reason, current_user.id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="User doesn't have enough coins")
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message=f"Deducted {data.amount} coins from {user.nickname}")
|
||||
|
||||
|
||||
@router.get("/admin/certification/pending", response_model=list[dict])
|
||||
async def admin_get_pending_certifications(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get list of marathons pending certification (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(
|
||||
select(Marathon)
|
||||
.options(selectinload(Marathon.creator))
|
||||
.where(Marathon.certification_status == CertificationStatus.PENDING.value)
|
||||
.order_by(Marathon.certification_requested_at.asc())
|
||||
)
|
||||
marathons = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"title": m.title,
|
||||
"creator_nickname": m.creator.nickname,
|
||||
"status": m.status,
|
||||
"participants_count": len(m.participants) if m.participants else 0,
|
||||
"certification_requested_at": m.certification_requested_at,
|
||||
}
|
||||
for m in marathons
|
||||
]
|
||||
|
||||
|
||||
@router.post("/admin/certification/{marathon_id}/review", response_model=CertificationStatusResponse)
|
||||
async def admin_review_certification(
|
||||
marathon_id: int,
|
||||
data: CertificationReviewRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Approve or reject marathon certification (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(
|
||||
select(Marathon).where(Marathon.id == marathon_id)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
if marathon.certification_status != CertificationStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="Marathon is not pending certification")
|
||||
|
||||
if data.approve:
|
||||
marathon.certification_status = CertificationStatus.CERTIFIED.value
|
||||
marathon.certified_at = datetime.utcnow()
|
||||
marathon.certified_by_id = current_user.id
|
||||
marathon.certification_rejection_reason = None
|
||||
else:
|
||||
if not data.rejection_reason:
|
||||
raise HTTPException(status_code=400, detail="Rejection reason is required")
|
||||
marathon.certification_status = CertificationStatus.REJECTED.value
|
||||
marathon.certification_rejection_reason = data.rejection_reason
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(marathon)
|
||||
|
||||
return CertificationStatusResponse(
|
||||
marathon_id=marathon.id,
|
||||
certification_status=marathon.certification_status,
|
||||
is_certified=marathon.is_certified,
|
||||
certification_requested_at=marathon.certification_requested_at,
|
||||
certified_at=marathon.certified_at,
|
||||
certified_by_nickname=current_user.nickname if data.approve else None,
|
||||
rejection_reason=marathon.certification_rejection_reason,
|
||||
)
|
||||
|
||||
|
||||
# === Admin Item Granting ===
|
||||
|
||||
@router.post("/admin/users/{user_id}/items/grant", response_model=MessageResponse)
|
||||
async def admin_grant_item(
|
||||
user_id: int,
|
||||
data: AdminGrantItemRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Grant an item to a user (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Get target 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")
|
||||
|
||||
# Get item
|
||||
item = await shop_service.get_item_by_id(db, data.item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
# Check if user already has this item in inventory
|
||||
result = await db.execute(
|
||||
select(UserInventory).where(
|
||||
UserInventory.user_id == user_id,
|
||||
UserInventory.item_id == data.item_id,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Add to quantity
|
||||
existing.quantity += data.quantity
|
||||
else:
|
||||
# Create new inventory item
|
||||
inventory_item = UserInventory(
|
||||
user_id=user_id,
|
||||
item_id=data.item_id,
|
||||
quantity=data.quantity,
|
||||
)
|
||||
db.add(inventory_item)
|
||||
|
||||
# Log the action (using coin transaction as audit log)
|
||||
transaction = CoinTransaction(
|
||||
user_id=user_id,
|
||||
amount=0,
|
||||
transaction_type="admin_grant_item",
|
||||
description=f"Admin granted {item.name} x{data.quantity}: {data.reason}",
|
||||
reference_type="admin_action",
|
||||
reference_id=current_user.id,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send Telegram notification
|
||||
await telegram_notifier.notify_item_granted(
|
||||
user=user,
|
||||
item_name=item.name,
|
||||
quantity=data.quantity,
|
||||
reason=data.reason,
|
||||
admin_nickname=current_user.nickname,
|
||||
)
|
||||
|
||||
return MessageResponse(message=f"Granted {item.name} x{data.quantity} to {user.nickname}")
|
||||
|
||||
|
||||
@router.get("/admin/users/{user_id}/inventory", response_model=list[InventoryItemResponse])
|
||||
async def admin_get_user_inventory(
|
||||
user_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
item_type: str | None = None,
|
||||
):
|
||||
"""Get a user's inventory (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Check user exists
|
||||
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")
|
||||
|
||||
inventory = await shop_service.get_user_inventory(db, user_id, item_type)
|
||||
return [InventoryItemResponse.model_validate(inv) for inv in inventory]
|
||||
|
||||
|
||||
@router.delete("/admin/users/{user_id}/inventory/{inventory_id}", response_model=MessageResponse)
|
||||
async def admin_remove_inventory_item(
|
||||
user_id: int,
|
||||
inventory_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
quantity: int = 1,
|
||||
):
|
||||
"""Remove an item from user's inventory (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Check user exists
|
||||
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")
|
||||
|
||||
# Get inventory item
|
||||
result = await db.execute(
|
||||
select(UserInventory)
|
||||
.options(selectinload(UserInventory.item))
|
||||
.where(
|
||||
UserInventory.id == inventory_id,
|
||||
UserInventory.user_id == user_id,
|
||||
)
|
||||
)
|
||||
inv = result.scalar_one_or_none()
|
||||
if not inv:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
|
||||
item_name = inv.item.name
|
||||
|
||||
if quantity >= inv.quantity:
|
||||
# Remove entirely
|
||||
await db.delete(inv)
|
||||
removed_qty = inv.quantity
|
||||
else:
|
||||
# Reduce quantity
|
||||
inv.quantity -= quantity
|
||||
removed_qty = quantity
|
||||
|
||||
# Log the action
|
||||
transaction = CoinTransaction(
|
||||
user_id=user_id,
|
||||
amount=0,
|
||||
transaction_type="admin_remove_item",
|
||||
description=f"Admin removed {item_name} x{removed_qty}",
|
||||
reference_type="admin_action",
|
||||
reference_id=current_user.id,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message=f"Removed {item_name} x{removed_qty} from {user.nickname}")
|
||||
@@ -73,6 +73,21 @@ class TelegramStatsResponse(BaseModel):
|
||||
best_streak: int
|
||||
|
||||
|
||||
class TelegramNotificationSettings(BaseModel):
|
||||
notify_events: bool = True
|
||||
notify_disputes: bool = True
|
||||
notify_moderation: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TelegramNotificationSettingsUpdate(BaseModel):
|
||||
notify_events: bool | None = None
|
||||
notify_disputes: bool | None = None
|
||||
notify_moderation: bool | None = None
|
||||
|
||||
|
||||
# Endpoints
|
||||
@router.post("/generate-link-token", response_model=TelegramLinkToken)
|
||||
async def generate_link_token(current_user: CurrentUser):
|
||||
@@ -391,3 +406,46 @@ async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||
total_points=total_points,
|
||||
best_streak=best_streak
|
||||
)
|
||||
|
||||
|
||||
@router.get("/notifications/{telegram_id}", response_model=TelegramNotificationSettings | None)
|
||||
async def get_notification_settings(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||
"""Get user's notification settings by Telegram ID."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
return TelegramNotificationSettings.model_validate(user)
|
||||
|
||||
|
||||
@router.patch("/notifications/{telegram_id}", response_model=TelegramNotificationSettings | None)
|
||||
async def update_notification_settings(
|
||||
telegram_id: int,
|
||||
data: TelegramNotificationSettingsUpdate,
|
||||
db: DbSession,
|
||||
_: BotSecretDep
|
||||
):
|
||||
"""Update user's notification settings by Telegram ID."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if data.notify_events is not None:
|
||||
user.notify_events = data.notify_events
|
||||
if data.notify_disputes is not None:
|
||||
user.notify_disputes = data.notify_disputes
|
||||
if data.notify_moderation is not None:
|
||||
user.notify_moderation = data.notify_moderation
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return TelegramNotificationSettings.model_validate(user)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.core.config import settings
|
||||
@@ -9,7 +10,8 @@ from app.models.assignment import AssignmentStatus
|
||||
from app.models.marathon import MarathonStatus
|
||||
from app.schemas import (
|
||||
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
|
||||
PasswordChange, UserStats, UserProfilePublic,
|
||||
PasswordChange, UserStats, UserProfilePublic, NotificationSettings,
|
||||
NotificationSettingsUpdate,
|
||||
)
|
||||
from app.services.storage import storage_service
|
||||
|
||||
@@ -19,7 +21,16 @@ router = APIRouter(prefix="/users", tags=["users"])
|
||||
@router.get("/{user_id}", response_model=UserPublic)
|
||||
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||
"""Get user profile. Requires authentication."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user_id)
|
||||
.options(
|
||||
selectinload(User.equipped_frame),
|
||||
selectinload(User.equipped_title),
|
||||
selectinload(User.equipped_name_color),
|
||||
selectinload(User.equipped_background),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
@@ -189,6 +200,32 @@ async def change_password(
|
||||
return MessageResponse(message="Пароль успешно изменен")
|
||||
|
||||
|
||||
@router.get("/me/notifications", response_model=NotificationSettings)
|
||||
async def get_notification_settings(current_user: CurrentUser):
|
||||
"""Get current user's notification settings"""
|
||||
return NotificationSettings.model_validate(current_user)
|
||||
|
||||
|
||||
@router.patch("/me/notifications", response_model=NotificationSettings)
|
||||
async def update_notification_settings(
|
||||
data: NotificationSettingsUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Update current user's notification settings"""
|
||||
if data.notify_events is not None:
|
||||
current_user.notify_events = data.notify_events
|
||||
if data.notify_disputes is not None:
|
||||
current_user.notify_disputes = data.notify_disputes
|
||||
if data.notify_moderation is not None:
|
||||
current_user.notify_moderation = data.notify_moderation
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return NotificationSettings.model_validate(current_user)
|
||||
|
||||
|
||||
@router.get("/me/stats", response_model=UserStats)
|
||||
async def get_my_stats(current_user: CurrentUser, db: DbSession):
|
||||
"""Получить свою статистику"""
|
||||
@@ -212,7 +249,16 @@ async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser)
|
||||
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
|
||||
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||
"""Получить публичный профиль пользователя со статистикой. Requires authentication."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user_id)
|
||||
.options(
|
||||
selectinload(User.equipped_frame),
|
||||
selectinload(User.equipped_title),
|
||||
selectinload(User.equipped_name_color),
|
||||
selectinload(User.equipped_background),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
@@ -227,8 +273,14 @@ async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUse
|
||||
id=user.id,
|
||||
nickname=user.nickname,
|
||||
avatar_url=user.avatar_url,
|
||||
telegram_avatar_url=user.telegram_avatar_url,
|
||||
role=user.role,
|
||||
created_at=user.created_at,
|
||||
stats=stats,
|
||||
equipped_frame=user.equipped_frame,
|
||||
equipped_title=user.equipped_title,
|
||||
equipped_name_color=user.equipped_name_color,
|
||||
equipped_background=user.equipped_background,
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
423
backend/app/api/v1/widgets.py
Normal file
423
backend/app/api/v1/widgets.py
Normal file
@@ -0,0 +1,423 @@
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, status, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser, require_participant
|
||||
from app.models import (
|
||||
WidgetToken, Participant, Marathon, Assignment, AssignmentStatus,
|
||||
BonusAssignment, BonusAssignmentStatus,
|
||||
)
|
||||
from app.schemas.widget import (
|
||||
WidgetTokenResponse,
|
||||
WidgetTokenListItem,
|
||||
WidgetLeaderboardEntry,
|
||||
WidgetLeaderboardResponse,
|
||||
WidgetCurrentResponse,
|
||||
WidgetProgressResponse,
|
||||
)
|
||||
from app.schemas.common import MessageResponse
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/widgets", tags=["widgets"])
|
||||
|
||||
|
||||
def get_avatar_url(user) -> str | None:
|
||||
"""Get avatar URL - through backend API if user has avatar, else telegram"""
|
||||
if user.avatar_path:
|
||||
return f"/api/v1/users/{user.id}/avatar"
|
||||
return user.telegram_avatar_url
|
||||
|
||||
|
||||
def generate_widget_token() -> str:
|
||||
"""Generate a secure widget token"""
|
||||
return f"wgt_{secrets.token_urlsafe(32)}"
|
||||
|
||||
|
||||
def build_widget_urls(marathon_id: int, token: str) -> dict[str, str]:
|
||||
"""Build widget URLs for the token"""
|
||||
base_url = settings.FRONTEND_URL or "http://localhost:5173"
|
||||
params = f"marathon={marathon_id}&token={token}"
|
||||
return {
|
||||
"leaderboard": f"{base_url}/widget/leaderboard?{params}",
|
||||
"current": f"{base_url}/widget/current?{params}",
|
||||
"progress": f"{base_url}/widget/progress?{params}",
|
||||
}
|
||||
|
||||
|
||||
# === Token management (authenticated) ===
|
||||
|
||||
@router.post("/marathons/{marathon_id}/token", response_model=WidgetTokenResponse)
|
||||
async def create_widget_token(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Create a widget token for the current user in a marathon"""
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check if user already has an active token
|
||||
existing = await db.scalar(
|
||||
select(WidgetToken).where(
|
||||
WidgetToken.participant_id == participant.id,
|
||||
WidgetToken.marathon_id == marathon_id,
|
||||
WidgetToken.is_active == True,
|
||||
)
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Return existing token
|
||||
return WidgetTokenResponse(
|
||||
id=existing.id,
|
||||
token=existing.token,
|
||||
created_at=existing.created_at,
|
||||
expires_at=existing.expires_at,
|
||||
is_active=existing.is_active,
|
||||
urls=build_widget_urls(marathon_id, existing.token),
|
||||
)
|
||||
|
||||
# Create new token
|
||||
token = generate_widget_token()
|
||||
widget_token = WidgetToken(
|
||||
token=token,
|
||||
participant_id=participant.id,
|
||||
marathon_id=marathon_id,
|
||||
)
|
||||
db.add(widget_token)
|
||||
await db.commit()
|
||||
await db.refresh(widget_token)
|
||||
|
||||
return WidgetTokenResponse(
|
||||
id=widget_token.id,
|
||||
token=widget_token.token,
|
||||
created_at=widget_token.created_at,
|
||||
expires_at=widget_token.expires_at,
|
||||
is_active=widget_token.is_active,
|
||||
urls=build_widget_urls(marathon_id, widget_token.token),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/tokens", response_model=list[WidgetTokenListItem])
|
||||
async def list_widget_tokens(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""List all widget tokens for the current user in a marathon"""
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.where(
|
||||
WidgetToken.participant_id == participant.id,
|
||||
WidgetToken.marathon_id == marathon_id,
|
||||
)
|
||||
.order_by(WidgetToken.created_at.desc())
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
return [
|
||||
WidgetTokenListItem(
|
||||
id=t.id,
|
||||
token=t.token,
|
||||
created_at=t.created_at,
|
||||
is_active=t.is_active,
|
||||
)
|
||||
for t in tokens
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id}", response_model=MessageResponse)
|
||||
async def revoke_widget_token(
|
||||
token_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Revoke a widget token"""
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.options(selectinload(WidgetToken.participant))
|
||||
.where(WidgetToken.id == token_id)
|
||||
)
|
||||
widget_token = result.scalar_one_or_none()
|
||||
|
||||
if not widget_token:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
|
||||
if widget_token.participant.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to revoke this token")
|
||||
|
||||
widget_token.is_active = False
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Token revoked")
|
||||
|
||||
|
||||
@router.post("/tokens/{token_id}/regenerate", response_model=WidgetTokenResponse)
|
||||
async def regenerate_widget_token(
|
||||
token_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Regenerate a widget token (deactivates old, creates new)"""
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.options(selectinload(WidgetToken.participant))
|
||||
.where(WidgetToken.id == token_id)
|
||||
)
|
||||
old_token = result.scalar_one_or_none()
|
||||
|
||||
if not old_token:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
|
||||
if old_token.participant.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
# Deactivate old token
|
||||
old_token.is_active = False
|
||||
|
||||
# Create new token
|
||||
new_token = WidgetToken(
|
||||
token=generate_widget_token(),
|
||||
participant_id=old_token.participant_id,
|
||||
marathon_id=old_token.marathon_id,
|
||||
)
|
||||
db.add(new_token)
|
||||
await db.commit()
|
||||
await db.refresh(new_token)
|
||||
|
||||
return WidgetTokenResponse(
|
||||
id=new_token.id,
|
||||
token=new_token.token,
|
||||
created_at=new_token.created_at,
|
||||
expires_at=new_token.expires_at,
|
||||
is_active=new_token.is_active,
|
||||
urls=build_widget_urls(new_token.marathon_id, new_token.token),
|
||||
)
|
||||
|
||||
|
||||
# === Public widget endpoints (authenticated via widget token) ===
|
||||
|
||||
async def validate_widget_token(token: str, marathon_id: int, db) -> WidgetToken:
|
||||
"""Validate widget token and return it"""
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.options(
|
||||
selectinload(WidgetToken.participant).selectinload(Participant.user),
|
||||
selectinload(WidgetToken.marathon),
|
||||
)
|
||||
.where(
|
||||
WidgetToken.token == token,
|
||||
WidgetToken.marathon_id == marathon_id,
|
||||
WidgetToken.is_active == True,
|
||||
)
|
||||
)
|
||||
widget_token = result.scalar_one_or_none()
|
||||
|
||||
if not widget_token:
|
||||
raise HTTPException(status_code=401, detail="Invalid widget token")
|
||||
|
||||
if widget_token.expires_at and widget_token.expires_at < datetime.utcnow():
|
||||
raise HTTPException(status_code=401, detail="Widget token expired")
|
||||
|
||||
return widget_token
|
||||
|
||||
|
||||
@router.get("/data/leaderboard", response_model=WidgetLeaderboardResponse)
|
||||
async def widget_leaderboard(
|
||||
marathon: int = Query(..., description="Marathon ID"),
|
||||
token: str = Query(..., description="Widget token"),
|
||||
count: int = Query(5, ge=1, le=50, description="Number of participants"),
|
||||
db: DbSession = None,
|
||||
):
|
||||
"""Get leaderboard data for widget"""
|
||||
widget_token = await validate_widget_token(token, marathon, db)
|
||||
current_participant = widget_token.participant
|
||||
|
||||
# Get all participants ordered by points
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(Participant.marathon_id == marathon)
|
||||
.order_by(Participant.total_points.desc())
|
||||
)
|
||||
all_participants = result.scalars().all()
|
||||
|
||||
total_participants = len(all_participants)
|
||||
current_user_rank = None
|
||||
|
||||
# Find current user rank and build entries
|
||||
entries = []
|
||||
for rank, p in enumerate(all_participants, 1):
|
||||
if p.id == current_participant.id:
|
||||
current_user_rank = rank
|
||||
|
||||
if rank <= count:
|
||||
user = p.user
|
||||
entries.append(WidgetLeaderboardEntry(
|
||||
rank=rank,
|
||||
nickname=user.nickname,
|
||||
avatar_url=get_avatar_url(user),
|
||||
total_points=p.total_points,
|
||||
current_streak=p.current_streak,
|
||||
is_current_user=(p.id == current_participant.id),
|
||||
))
|
||||
|
||||
return WidgetLeaderboardResponse(
|
||||
entries=entries,
|
||||
current_user_rank=current_user_rank,
|
||||
total_participants=total_participants,
|
||||
marathon_title=widget_token.marathon.title,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/data/current", response_model=WidgetCurrentResponse)
|
||||
async def widget_current_assignment(
|
||||
marathon: int = Query(..., description="Marathon ID"),
|
||||
token: str = Query(..., description="Widget token"),
|
||||
db: DbSession = None,
|
||||
):
|
||||
"""Get current assignment data for widget"""
|
||||
widget_token = await validate_widget_token(token, marathon, db)
|
||||
participant = widget_token.participant
|
||||
|
||||
# Get active assignment
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge),
|
||||
selectinload(Assignment.game),
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status.in_([
|
||||
AssignmentStatus.ACTIVE.value,
|
||||
AssignmentStatus.RETURNED.value,
|
||||
]),
|
||||
)
|
||||
.order_by(Assignment.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
|
||||
if not assignment:
|
||||
return WidgetCurrentResponse(has_assignment=False)
|
||||
|
||||
# Determine assignment type and details
|
||||
if assignment.is_playthrough:
|
||||
game = assignment.game
|
||||
assignment_type = "playthrough"
|
||||
challenge_title = "Прохождение"
|
||||
challenge_description = game.playthrough_description
|
||||
points = game.playthrough_points
|
||||
difficulty = None
|
||||
|
||||
# Count bonus challenges
|
||||
bonus_result = await db.execute(
|
||||
select(func.count()).select_from(BonusAssignment)
|
||||
.where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
bonus_total = bonus_result.scalar() or 0
|
||||
|
||||
completed_result = await db.execute(
|
||||
select(func.count()).select_from(BonusAssignment)
|
||||
.where(
|
||||
BonusAssignment.main_assignment_id == assignment.id,
|
||||
BonusAssignment.status == BonusAssignmentStatus.COMPLETED.value,
|
||||
)
|
||||
)
|
||||
bonus_completed = completed_result.scalar() or 0
|
||||
|
||||
game_title = game.title
|
||||
game_cover_url = f"/api/v1/games/{game.id}/cover" if game.cover_path else None
|
||||
else:
|
||||
challenge = assignment.challenge
|
||||
assignment_type = "challenge"
|
||||
challenge_title = challenge.title
|
||||
challenge_description = challenge.description
|
||||
points = challenge.points
|
||||
difficulty = challenge.difficulty
|
||||
bonus_completed = None
|
||||
bonus_total = None
|
||||
|
||||
game = challenge.game if hasattr(challenge, 'game') else None
|
||||
if not game:
|
||||
# Load game via challenge
|
||||
from app.models import Game
|
||||
game_result = await db.execute(
|
||||
select(Game).where(Game.id == challenge.game_id)
|
||||
)
|
||||
game = game_result.scalar_one_or_none()
|
||||
|
||||
game_title = game.title if game else None
|
||||
game_cover_url = f"/api/v1/games/{game.id}/cover" if game and game.cover_path else None
|
||||
|
||||
return WidgetCurrentResponse(
|
||||
has_assignment=True,
|
||||
game_title=game_title,
|
||||
game_cover_url=game_cover_url,
|
||||
assignment_type=assignment_type,
|
||||
challenge_title=challenge_title,
|
||||
challenge_description=challenge_description,
|
||||
points=points,
|
||||
difficulty=difficulty,
|
||||
bonus_completed=bonus_completed,
|
||||
bonus_total=bonus_total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/data/progress", response_model=WidgetProgressResponse)
|
||||
async def widget_progress(
|
||||
marathon: int = Query(..., description="Marathon ID"),
|
||||
token: str = Query(..., description="Widget token"),
|
||||
db: DbSession = None,
|
||||
):
|
||||
"""Get participant progress data for widget"""
|
||||
widget_token = await validate_widget_token(token, marathon, db)
|
||||
participant = widget_token.participant
|
||||
user = participant.user
|
||||
|
||||
# Calculate rank
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Participant)
|
||||
.where(
|
||||
Participant.marathon_id == marathon,
|
||||
Participant.total_points > participant.total_points,
|
||||
)
|
||||
)
|
||||
higher_count = result.scalar() or 0
|
||||
rank = higher_count + 1
|
||||
|
||||
# Count completed and dropped assignments
|
||||
completed_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Assignment)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.COMPLETED.value,
|
||||
)
|
||||
)
|
||||
completed_count = completed_result.scalar() or 0
|
||||
|
||||
dropped_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Assignment)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.DROPPED.value,
|
||||
)
|
||||
)
|
||||
dropped_count = dropped_result.scalar() or 0
|
||||
|
||||
return WidgetProgressResponse(
|
||||
nickname=user.nickname,
|
||||
avatar_url=get_avatar_url(user),
|
||||
rank=rank,
|
||||
total_points=participant.total_points,
|
||||
current_streak=participant.current_streak,
|
||||
completed_count=completed_count,
|
||||
dropped_count=dropped_count,
|
||||
marathon_title=widget_token.marathon.title,
|
||||
)
|
||||
@@ -6,6 +6,7 @@ class Settings(BaseSettings):
|
||||
# App
|
||||
APP_NAME: str = "Game Marathon"
|
||||
DEBUG: bool = False
|
||||
RATE_LIMIT_ENABLED: bool = True # Set to False to disable rate limiting
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://marathon:marathon@localhost:5432/marathon"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from app.core.config import settings
|
||||
|
||||
# Rate limiter using client IP address as key
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
# Can be disabled via RATE_LIMIT_ENABLED=false in .env
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
enabled=settings.RATE_LIMIT_ENABLED
|
||||
)
|
||||
|
||||
@@ -60,7 +60,12 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||
allow_origins=[
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:5173", # Desktop app dev
|
||||
"http://127.0.0.1:5173",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
|
||||
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode, CertificationStatus
|
||||
from app.models.participant import Participant, ParticipantRole
|
||||
from app.models.game import Game, GameStatus
|
||||
from app.models.game import Game, GameStatus, GameType
|
||||
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
|
||||
from app.models.assignment import Assignment, AssignmentStatus
|
||||
from app.models.bonus_assignment import BonusAssignment, BonusAssignmentStatus
|
||||
from app.models.assignment_proof import AssignmentProof, BonusAssignmentProof
|
||||
from app.models.activity import Activity, ActivityType
|
||||
from app.models.event import Event, EventType
|
||||
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||
@@ -11,6 +13,13 @@ from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVo
|
||||
from app.models.admin_log import AdminLog, AdminActionType
|
||||
from app.models.admin_2fa import Admin2FASession
|
||||
from app.models.static_content import StaticContent
|
||||
from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType
|
||||
from app.models.inventory import UserInventory
|
||||
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
|
||||
from app.models.consumable_usage import ConsumableUsage
|
||||
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
||||
from app.models.widget_token import WidgetToken
|
||||
from app.models.exiled_game import ExiledGame
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -18,16 +27,22 @@ __all__ = [
|
||||
"Marathon",
|
||||
"MarathonStatus",
|
||||
"GameProposalMode",
|
||||
"CertificationStatus",
|
||||
"Participant",
|
||||
"ParticipantRole",
|
||||
"Game",
|
||||
"GameStatus",
|
||||
"GameType",
|
||||
"Challenge",
|
||||
"ChallengeType",
|
||||
"Difficulty",
|
||||
"ProofType",
|
||||
"Assignment",
|
||||
"AssignmentStatus",
|
||||
"BonusAssignment",
|
||||
"BonusAssignmentStatus",
|
||||
"AssignmentProof",
|
||||
"BonusAssignmentProof",
|
||||
"Activity",
|
||||
"ActivityType",
|
||||
"Event",
|
||||
@@ -42,4 +57,16 @@ __all__ = [
|
||||
"AdminActionType",
|
||||
"Admin2FASession",
|
||||
"StaticContent",
|
||||
"ShopItem",
|
||||
"ShopItemType",
|
||||
"ItemRarity",
|
||||
"ConsumableType",
|
||||
"UserInventory",
|
||||
"CoinTransaction",
|
||||
"CoinTransactionType",
|
||||
"ConsumableUsage",
|
||||
"PromoCode",
|
||||
"PromoCodeRedemption",
|
||||
"WidgetToken",
|
||||
"ExiledGame",
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ class ActivityType(str, Enum):
|
||||
EVENT_END = "event_end"
|
||||
SWAP = "swap"
|
||||
GAME_CHOICE = "game_choice"
|
||||
MODERATION = "moderation"
|
||||
|
||||
|
||||
class Activity(Base):
|
||||
|
||||
@@ -17,6 +17,8 @@ class AdminActionType(str, Enum):
|
||||
# Marathon actions
|
||||
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
||||
MARATHON_DELETE = "marathon_delete"
|
||||
MARATHON_CERTIFY = "marathon_certify"
|
||||
MARATHON_REVOKE_CERTIFICATION = "marathon_revoke_certification"
|
||||
|
||||
# Content actions
|
||||
CONTENT_UPDATE = "content_update"
|
||||
@@ -30,6 +32,10 @@ class AdminActionType(str, Enum):
|
||||
ADMIN_2FA_SUCCESS = "admin_2fa_success"
|
||||
ADMIN_2FA_FAIL = "admin_2fa_fail"
|
||||
|
||||
# Dispute actions
|
||||
DISPUTE_RESOLVE_VALID = "dispute_resolve_valid"
|
||||
DISPUTE_RESOLVE_INVALID = "dispute_resolve_invalid"
|
||||
|
||||
|
||||
class AdminLog(Base):
|
||||
__tablename__ = "admin_logs"
|
||||
|
||||
@@ -18,8 +18,12 @@ class Assignment(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
|
||||
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
|
||||
challenge_id: Mapped[int | None] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"), nullable=True) # None для playthrough
|
||||
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
|
||||
|
||||
# Для прохождений (playthrough)
|
||||
game_id: Mapped[int | None] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
is_playthrough: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
|
||||
is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments
|
||||
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
|
||||
@@ -28,11 +32,15 @@ class Assignment(Base):
|
||||
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
points_earned: Mapped[int] = mapped_column(Integer, default=0)
|
||||
streak_at_completion: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
tracked_time_minutes: Mapped[int] = mapped_column(Integer, default=0) # Time tracked by desktop app
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
|
||||
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
|
||||
challenge: Mapped["Challenge | None"] = relationship("Challenge", back_populates="assignments")
|
||||
game: Mapped["Game | None"] = relationship("Game", back_populates="playthrough_assignments", foreign_keys=[game_id])
|
||||
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
|
||||
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True)
|
||||
bonus_assignments: Mapped[list["BonusAssignment"]] = relationship("BonusAssignment", back_populates="main_assignment", cascade="all, delete-orphan")
|
||||
proof_files: Mapped[list["AssignmentProof"]] = relationship("AssignmentProof", back_populates="assignment", cascade="all, delete-orphan", order_by="AssignmentProof.order_index")
|
||||
|
||||
47
backend/app/models/assignment_proof.py
Normal file
47
backend/app/models/assignment_proof.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, ForeignKey, Integer, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AssignmentProof(Base):
|
||||
"""Файлы-доказательства для заданий (множественные пруфы)"""
|
||||
__tablename__ = "assignment_proofs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
assignment_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("assignments.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
file_path: Mapped[str] = mapped_column(String(500)) # Путь к файлу в хранилище
|
||||
file_type: Mapped[str] = mapped_column(String(20)) # image или video
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0) # Порядок отображения
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
assignment: Mapped["Assignment"] = relationship(
|
||||
"Assignment",
|
||||
back_populates="proof_files"
|
||||
)
|
||||
|
||||
|
||||
class BonusAssignmentProof(Base):
|
||||
"""Файлы-доказательства для бонусных заданий (множественные пруфы)"""
|
||||
__tablename__ = "bonus_assignment_proofs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
bonus_assignment_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("bonus_assignments.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
file_path: Mapped[str] = mapped_column(String(500)) # Путь к файлу в хранилище
|
||||
file_type: Mapped[str] = mapped_column(String(20)) # image или video
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0) # Порядок отображения
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
bonus_assignment: Mapped["BonusAssignment"] = relationship(
|
||||
"BonusAssignment",
|
||||
back_populates="proof_files"
|
||||
)
|
||||
54
backend/app/models/bonus_assignment.py
Normal file
54
backend/app/models/bonus_assignment.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BonusAssignmentStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
COMPLETED = "completed"
|
||||
|
||||
|
||||
class BonusAssignment(Base):
|
||||
"""Бонусные челленджи для игр типа 'playthrough'"""
|
||||
__tablename__ = "bonus_assignments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
main_assignment_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("assignments.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
challenge_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("challenges.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default=BonusAssignmentStatus.PENDING.value
|
||||
)
|
||||
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
points_earned: Mapped[int] = mapped_column(Integer, default=0)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
main_assignment: Mapped["Assignment"] = relationship(
|
||||
"Assignment",
|
||||
back_populates="bonus_assignments"
|
||||
)
|
||||
challenge: Mapped["Challenge"] = relationship("Challenge")
|
||||
dispute: Mapped["Dispute"] = relationship(
|
||||
"Dispute",
|
||||
back_populates="bonus_assignment",
|
||||
uselist=False,
|
||||
)
|
||||
proof_files: Mapped[list["BonusAssignmentProof"]] = relationship(
|
||||
"BonusAssignmentProof",
|
||||
back_populates="bonus_assignment",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="BonusAssignmentProof.order_index"
|
||||
)
|
||||
42
backend/app/models/coin_transaction.py
Normal file
42
backend/app/models/coin_transaction.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class CoinTransactionType(str, Enum):
|
||||
CHALLENGE_COMPLETE = "challenge_complete"
|
||||
PLAYTHROUGH_COMPLETE = "playthrough_complete"
|
||||
MARATHON_WIN = "marathon_win"
|
||||
MARATHON_PLACE = "marathon_place"
|
||||
COMMON_ENEMY_BONUS = "common_enemy_bonus"
|
||||
PURCHASE = "purchase"
|
||||
REFUND = "refund"
|
||||
ADMIN_GRANT = "admin_grant"
|
||||
ADMIN_DEDUCT = "admin_deduct"
|
||||
PROMO_CODE = "promo_code"
|
||||
|
||||
|
||||
class CoinTransaction(Base):
|
||||
__tablename__ = "coin_transactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
amount: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
transaction_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
reference_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="coin_transactions"
|
||||
)
|
||||
30
backend/app/models/consumable_usage.py
Normal file
30
backend/app/models/consumable_usage.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.shop import ShopItem
|
||||
from app.models.marathon import Marathon
|
||||
from app.models.assignment import Assignment
|
||||
|
||||
|
||||
class ConsumableUsage(Base):
|
||||
__tablename__ = "consumable_usages"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False)
|
||||
marathon_id: Mapped[int | None] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), nullable=True)
|
||||
assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True)
|
||||
used_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
effect_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User")
|
||||
item: Mapped["ShopItem"] = relationship("ShopItem")
|
||||
marathon: Mapped["Marathon | None"] = relationship("Marathon")
|
||||
assignment: Mapped["Assignment | None"] = relationship("Assignment")
|
||||
@@ -8,16 +8,19 @@ from app.core.database import Base
|
||||
|
||||
class DisputeStatus(str, Enum):
|
||||
OPEN = "open"
|
||||
PENDING_ADMIN = "pending_admin" # Voting ended, waiting for admin decision
|
||||
RESOLVED_VALID = "valid"
|
||||
RESOLVED_INVALID = "invalid"
|
||||
|
||||
|
||||
class Dispute(Base):
|
||||
"""Dispute against a completed assignment's proof"""
|
||||
"""Dispute against a completed assignment's or bonus assignment's proof"""
|
||||
__tablename__ = "disputes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True)
|
||||
# Either assignment_id OR bonus_assignment_id should be set (not both)
|
||||
assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
bonus_assignment_id: Mapped[int | None] = mapped_column(ForeignKey("bonus_assignments.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
|
||||
@@ -26,6 +29,7 @@ class Dispute(Base):
|
||||
|
||||
# Relationships
|
||||
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute")
|
||||
bonus_assignment: Mapped["BonusAssignment"] = relationship("BonusAssignment", back_populates="dispute")
|
||||
raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id])
|
||||
comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan")
|
||||
votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan")
|
||||
|
||||
37
backend/app/models/exiled_game.py
Normal file
37
backend/app/models/exiled_game.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Boolean, Integer, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ExiledGame(Base):
|
||||
"""Изгнанные игры участника - не будут выпадать при спине"""
|
||||
__tablename__ = "exiled_games"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("participant_id", "game_id", name="unique_participant_game_exile"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
participant_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("participants.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
game_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("games.id", ondelete="CASCADE")
|
||||
)
|
||||
assignment_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("assignments.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
exiled_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
exiled_by: Mapped[str] = mapped_column(String(20)) # "user" | "organizer" | "admin"
|
||||
reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Soft-delete для истории
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
unexiled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
unexiled_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
# Relationships
|
||||
participant: Mapped["Participant"] = relationship("Participant")
|
||||
game: Mapped["Game"] = relationship("Game")
|
||||
assignment: Mapped["Assignment"] = relationship("Assignment")
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Text
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Text, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -12,6 +12,11 @@ class GameStatus(str, Enum):
|
||||
REJECTED = "rejected" # Отклонена
|
||||
|
||||
|
||||
class GameType(str, Enum):
|
||||
PLAYTHROUGH = "playthrough" # Прохождение игры
|
||||
CHALLENGES = "challenges" # Челленджи
|
||||
|
||||
|
||||
class Game(Base):
|
||||
__tablename__ = "games"
|
||||
|
||||
@@ -26,6 +31,15 @@ class Game(Base):
|
||||
approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Тип игры
|
||||
game_type: Mapped[str] = mapped_column(String(20), default=GameType.CHALLENGES.value, nullable=False)
|
||||
|
||||
# Поля для типа "Прохождение" (заполняются только для playthrough)
|
||||
playthrough_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
playthrough_description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
playthrough_proof_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # screenshot, video, steam
|
||||
playthrough_proof_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
|
||||
proposed_by: Mapped["User"] = relationship(
|
||||
@@ -43,6 +57,12 @@ class Game(Base):
|
||||
back_populates="game",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
# Assignments для прохождений (playthrough)
|
||||
playthrough_assignments: Mapped[list["Assignment"]] = relationship(
|
||||
"Assignment",
|
||||
back_populates="game",
|
||||
foreign_keys="Assignment.game_id"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_approved(self) -> bool:
|
||||
@@ -51,3 +71,11 @@ class Game(Base):
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == GameStatus.PENDING.value
|
||||
|
||||
@property
|
||||
def is_playthrough(self) -> bool:
|
||||
return self.game_type == GameType.PLAYTHROUGH.value
|
||||
|
||||
@property
|
||||
def is_challenges(self) -> bool:
|
||||
return self.game_type == GameType.CHALLENGES.value
|
||||
|
||||
39
backend/app/models/inventory.py
Normal file
39
backend/app/models/inventory.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.shop import ShopItem
|
||||
|
||||
|
||||
class UserInventory(Base):
|
||||
__tablename__ = "user_inventory"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
quantity: Mapped[int] = mapped_column(Integer, default=1)
|
||||
equipped: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
purchased_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="inventory"
|
||||
)
|
||||
item: Mapped["ShopItem"] = relationship(
|
||||
"ShopItem",
|
||||
back_populates="inventory_items"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if item has expired"""
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return datetime.utcnow() > self.expires_at
|
||||
@@ -17,6 +17,13 @@ class GameProposalMode(str, Enum):
|
||||
ORGANIZER_ONLY = "organizer_only"
|
||||
|
||||
|
||||
class CertificationStatus(str, Enum):
|
||||
NONE = "none"
|
||||
PENDING = "pending"
|
||||
CERTIFIED = "certified"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class Marathon(Base):
|
||||
__tablename__ = "marathons"
|
||||
|
||||
@@ -35,12 +42,28 @@ class Marathon(Base):
|
||||
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Certification fields
|
||||
certification_status: Mapped[str] = mapped_column(String(20), default=CertificationStatus.NONE.value)
|
||||
certification_requested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
certified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
certified_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
certification_rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Shop/Consumables settings
|
||||
allow_skips: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
max_skips_per_participant: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
allow_consumables: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
creator: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="created_marathons",
|
||||
foreign_keys=[creator_id]
|
||||
)
|
||||
certified_by: Mapped["User | None"] = relationship(
|
||||
"User",
|
||||
foreign_keys=[certified_by_id]
|
||||
)
|
||||
participants: Mapped[list["Participant"]] = relationship(
|
||||
"Participant",
|
||||
back_populates="marathon",
|
||||
@@ -61,3 +84,7 @@ class Marathon(Base):
|
||||
back_populates="marathon",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_certified(self) -> bool:
|
||||
return self.certification_status == CertificationStatus.CERTIFIED.value
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean, Float
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -26,6 +26,22 @@ class Participant(Base):
|
||||
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
|
||||
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Shop: coins earned in this marathon
|
||||
coins_earned: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Shop: consumables state
|
||||
skips_used: Mapped[int] = mapped_column(Integer, default=0)
|
||||
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Lucky Dice state
|
||||
has_lucky_dice: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
lucky_dice_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Undo state - stores last drop data for potential rollback
|
||||
last_drop_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
last_drop_streak_before: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
can_undo: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", back_populates="participations")
|
||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")
|
||||
|
||||
67
backend/app/models/promo_code.py
Normal file
67
backend/app/models/promo_code.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Promo Code models for coins distribution
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Boolean, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PromoCode(Base):
|
||||
"""Promo code for giving coins to users"""
|
||||
__tablename__ = "promo_codes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||
coins_amount: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
max_uses: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = unlimited
|
||||
uses_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_id])
|
||||
redemptions: Mapped[list["PromoCodeRedemption"]] = relationship(
|
||||
"PromoCodeRedemption", back_populates="promo_code", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if promo code is currently valid"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if self.valid_from and now < self.valid_from:
|
||||
return False
|
||||
|
||||
if self.valid_until and now > self.valid_until:
|
||||
return False
|
||||
|
||||
if self.max_uses is not None and self.uses_count >= self.max_uses:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class PromoCodeRedemption(Base):
|
||||
"""Record of promo code redemption by a user"""
|
||||
__tablename__ = "promo_code_redemptions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
promo_code_id: Mapped[int] = mapped_column(ForeignKey("promo_codes.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
coins_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
redeemed_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
promo_code: Mapped["PromoCode"] = relationship("PromoCode", back_populates="redemptions")
|
||||
user: Mapped["User"] = relationship("User")
|
||||
84
backend/app/models/shop.py
Normal file
84
backend/app/models/shop.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, Text, DateTime, Integer, Boolean, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.inventory import UserInventory
|
||||
|
||||
|
||||
class ShopItemType(str, Enum):
|
||||
FRAME = "frame"
|
||||
TITLE = "title"
|
||||
NAME_COLOR = "name_color"
|
||||
BACKGROUND = "background"
|
||||
CONSUMABLE = "consumable"
|
||||
|
||||
|
||||
class ItemRarity(str, Enum):
|
||||
COMMON = "common"
|
||||
UNCOMMON = "uncommon"
|
||||
RARE = "rare"
|
||||
EPIC = "epic"
|
||||
LEGENDARY = "legendary"
|
||||
|
||||
|
||||
class ConsumableType(str, Enum):
|
||||
SKIP = "skip"
|
||||
SKIP_EXILE = "skip_exile" # Скип с изгнанием игры из пула
|
||||
BOOST = "boost"
|
||||
WILD_CARD = "wild_card"
|
||||
LUCKY_DICE = "lucky_dice"
|
||||
COPYCAT = "copycat"
|
||||
UNDO = "undo"
|
||||
|
||||
|
||||
class ShopItem(Base):
|
||||
__tablename__ = "shop_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
item_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
price: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
rarity: Mapped[str] = mapped_column(String(20), default=ItemRarity.COMMON.value)
|
||||
asset_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
available_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
available_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
stock_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
stock_remaining: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
inventory_items: Mapped[list["UserInventory"]] = relationship(
|
||||
"UserInventory",
|
||||
back_populates="item"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""Check if item is currently available for purchase"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if self.available_from and self.available_from > now:
|
||||
return False
|
||||
|
||||
if self.available_until and self.available_until < now:
|
||||
return False
|
||||
|
||||
if self.stock_remaining is not None and self.stock_remaining <= 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_consumable(self) -> bool:
|
||||
return self.item_type == ShopItemType.CONSUMABLE.value
|
||||
@@ -2,9 +2,15 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.shop import ShopItem
|
||||
from app.models.inventory import UserInventory
|
||||
from app.models.coin_transaction import CoinTransaction
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
USER = "user"
|
||||
@@ -34,6 +40,20 @@ class User(Base):
|
||||
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Notification settings (all enabled by default)
|
||||
notify_events: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Shop: coins balance
|
||||
coins_balance: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Shop: equipped cosmetics
|
||||
equipped_frame_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||
equipped_title_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||
equipped_name_color_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||
equipped_background_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||
"Marathon",
|
||||
@@ -60,6 +80,32 @@ class User(Base):
|
||||
foreign_keys=[banned_by_id]
|
||||
)
|
||||
|
||||
# Shop relationships
|
||||
inventory: Mapped[list["UserInventory"]] = relationship(
|
||||
"UserInventory",
|
||||
back_populates="user"
|
||||
)
|
||||
coin_transactions: Mapped[list["CoinTransaction"]] = relationship(
|
||||
"CoinTransaction",
|
||||
back_populates="user"
|
||||
)
|
||||
equipped_frame: Mapped["ShopItem | None"] = relationship(
|
||||
"ShopItem",
|
||||
foreign_keys=[equipped_frame_id]
|
||||
)
|
||||
equipped_title: Mapped["ShopItem | None"] = relationship(
|
||||
"ShopItem",
|
||||
foreign_keys=[equipped_title_id]
|
||||
)
|
||||
equipped_name_color: Mapped["ShopItem | None"] = relationship(
|
||||
"ShopItem",
|
||||
foreign_keys=[equipped_name_color_id]
|
||||
)
|
||||
equipped_background: Mapped["ShopItem | None"] = relationship(
|
||||
"ShopItem",
|
||||
foreign_keys=[equipped_background_id]
|
||||
)
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
return self.role == UserRole.ADMIN.value
|
||||
|
||||
22
backend/app/models/widget_token.py
Normal file
22
backend/app/models/widget_token.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class WidgetToken(Base):
|
||||
"""Токен для авторизации OBS виджетов"""
|
||||
__tablename__ = "widget_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"))
|
||||
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
participant: Mapped["Participant"] = relationship("Participant")
|
||||
marathon: Mapped["Marathon"] = relationship("Marathon")
|
||||
@@ -9,6 +9,8 @@ from app.schemas.user import (
|
||||
PasswordChange,
|
||||
UserStats,
|
||||
UserProfilePublic,
|
||||
NotificationSettings,
|
||||
NotificationSettingsUpdate,
|
||||
)
|
||||
from app.schemas.marathon import (
|
||||
MarathonCreate,
|
||||
@@ -21,6 +23,8 @@ from app.schemas.marathon import (
|
||||
JoinMarathon,
|
||||
LeaderboardEntry,
|
||||
SetParticipantRole,
|
||||
OrganizerSkipRequest,
|
||||
ExiledGameResponse,
|
||||
)
|
||||
from app.schemas.game import (
|
||||
GameCreate,
|
||||
@@ -46,6 +50,11 @@ from app.schemas.assignment import (
|
||||
CompleteResult,
|
||||
DropResult,
|
||||
EventAssignmentResponse,
|
||||
BonusAssignmentResponse,
|
||||
CompleteBonusAssignment,
|
||||
BonusCompleteResult,
|
||||
AvailableGamesCount,
|
||||
TrackTimeRequest,
|
||||
)
|
||||
from app.schemas.activity import (
|
||||
ActivityResponse,
|
||||
@@ -98,6 +107,46 @@ from app.schemas.admin import (
|
||||
LoginResponse,
|
||||
DashboardStats,
|
||||
)
|
||||
from app.schemas.shop import (
|
||||
ShopItemCreate,
|
||||
ShopItemUpdate,
|
||||
ShopItemResponse,
|
||||
InventoryItemResponse,
|
||||
PurchaseRequest,
|
||||
PurchaseResponse,
|
||||
UseConsumableRequest,
|
||||
UseConsumableResponse,
|
||||
EquipItemRequest,
|
||||
EquipItemResponse,
|
||||
CoinTransactionResponse,
|
||||
CoinsBalanceResponse,
|
||||
AdminCoinsRequest,
|
||||
UserCosmeticsResponse,
|
||||
CertificationRequestSchema,
|
||||
CertificationReviewRequest,
|
||||
CertificationStatusResponse,
|
||||
ConsumablesStatusResponse,
|
||||
AdminGrantItemRequest,
|
||||
)
|
||||
from app.schemas.promo_code import (
|
||||
PromoCodeCreate,
|
||||
PromoCodeUpdate,
|
||||
PromoCodeResponse,
|
||||
PromoCodeRedeemRequest,
|
||||
PromoCodeRedeemResponse,
|
||||
PromoCodeRedemptionResponse,
|
||||
PromoCodeRedemptionUser,
|
||||
)
|
||||
from app.schemas.user import ShopItemPublic
|
||||
from app.schemas.widget import (
|
||||
WidgetTokenCreate,
|
||||
WidgetTokenResponse,
|
||||
WidgetTokenListItem,
|
||||
WidgetLeaderboardEntry,
|
||||
WidgetLeaderboardResponse,
|
||||
WidgetCurrentResponse,
|
||||
WidgetProgressResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# User
|
||||
@@ -111,6 +160,8 @@ __all__ = [
|
||||
"PasswordChange",
|
||||
"UserStats",
|
||||
"UserProfilePublic",
|
||||
"NotificationSettings",
|
||||
"NotificationSettingsUpdate",
|
||||
# Marathon
|
||||
"MarathonCreate",
|
||||
"MarathonUpdate",
|
||||
@@ -122,6 +173,8 @@ __all__ = [
|
||||
"JoinMarathon",
|
||||
"LeaderboardEntry",
|
||||
"SetParticipantRole",
|
||||
"OrganizerSkipRequest",
|
||||
"ExiledGameResponse",
|
||||
# Game
|
||||
"GameCreate",
|
||||
"GameUpdate",
|
||||
@@ -144,6 +197,10 @@ __all__ = [
|
||||
"CompleteResult",
|
||||
"DropResult",
|
||||
"EventAssignmentResponse",
|
||||
"BonusAssignmentResponse",
|
||||
"CompleteBonusAssignment",
|
||||
"BonusCompleteResult",
|
||||
"AvailableGamesCount",
|
||||
# Activity
|
||||
"ActivityResponse",
|
||||
"FeedResponse",
|
||||
@@ -190,4 +247,41 @@ __all__ = [
|
||||
"TwoFactorVerifyRequest",
|
||||
"LoginResponse",
|
||||
"DashboardStats",
|
||||
# Shop
|
||||
"ShopItemCreate",
|
||||
"ShopItemUpdate",
|
||||
"ShopItemResponse",
|
||||
"ShopItemPublic",
|
||||
"InventoryItemResponse",
|
||||
"PurchaseRequest",
|
||||
"PurchaseResponse",
|
||||
"UseConsumableRequest",
|
||||
"UseConsumableResponse",
|
||||
"EquipItemRequest",
|
||||
"EquipItemResponse",
|
||||
"CoinTransactionResponse",
|
||||
"CoinsBalanceResponse",
|
||||
"AdminCoinsRequest",
|
||||
"UserCosmeticsResponse",
|
||||
"CertificationRequestSchema",
|
||||
"CertificationReviewRequest",
|
||||
"CertificationStatusResponse",
|
||||
"ConsumablesStatusResponse",
|
||||
"AdminGrantItemRequest",
|
||||
# Promo
|
||||
"PromoCodeCreate",
|
||||
"PromoCodeUpdate",
|
||||
"PromoCodeResponse",
|
||||
"PromoCodeRedeemRequest",
|
||||
"PromoCodeRedeemResponse",
|
||||
"PromoCodeRedemptionResponse",
|
||||
"PromoCodeRedemptionUser",
|
||||
# Widget
|
||||
"WidgetTokenCreate",
|
||||
"WidgetTokenResponse",
|
||||
"WidgetTokenListItem",
|
||||
"WidgetLeaderboardEntry",
|
||||
"WidgetLeaderboardResponse",
|
||||
"WidgetCurrentResponse",
|
||||
"WidgetProgressResponse",
|
||||
]
|
||||
|
||||
@@ -27,6 +27,10 @@ class AdminUserResponse(BaseModel):
|
||||
banned_at: str | None = None
|
||||
banned_until: str | None = None # None = permanent
|
||||
ban_reason: str | None = None
|
||||
# Notification settings
|
||||
notify_events: bool = True
|
||||
notify_disputes: bool = True
|
||||
notify_moderation: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.game import GameResponse
|
||||
from app.schemas.game import GameResponse, GameShort, PlaythroughInfo
|
||||
from app.schemas.challenge import ChallengeResponse
|
||||
|
||||
|
||||
class ProofFileResponse(BaseModel):
|
||||
"""Информация о файле-доказательстве"""
|
||||
id: int
|
||||
file_type: str # image или video
|
||||
order_index: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AssignmentBase(BaseModel):
|
||||
pass
|
||||
|
||||
@@ -14,28 +25,59 @@ class CompleteAssignment(BaseModel):
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class AssignmentResponse(BaseModel):
|
||||
class BonusAssignmentResponse(BaseModel):
|
||||
"""Ответ с информацией о бонусном челлендже"""
|
||||
id: int
|
||||
challenge: ChallengeResponse
|
||||
status: str
|
||||
status: str # pending, completed
|
||||
proof_url: str | None = None
|
||||
proof_image_url: str | None = None # Legacy, for backward compatibility
|
||||
proof_files: list[ProofFileResponse] = [] # Multiple uploaded files
|
||||
proof_comment: str | None = None
|
||||
points_earned: int
|
||||
streak_at_completion: int | None = None
|
||||
started_at: datetime
|
||||
points_earned: int = 0
|
||||
completed_at: datetime | None = None
|
||||
drop_penalty: int = 0 # Calculated penalty if dropped
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AssignmentResponse(BaseModel):
|
||||
id: int
|
||||
challenge: ChallengeResponse | None # None для playthrough
|
||||
game: GameShort | None = None # Заполняется для playthrough
|
||||
is_playthrough: bool = False
|
||||
playthrough_info: PlaythroughInfo | None = None # Заполняется для playthrough
|
||||
status: str
|
||||
proof_url: str | None = None
|
||||
proof_comment: str | None = None
|
||||
points_earned: int
|
||||
streak_at_completion: int | None = None
|
||||
tracked_time_minutes: int = 0 # Time tracked by desktop app
|
||||
started_at: datetime
|
||||
completed_at: datetime | None = None
|
||||
drop_penalty: int = 0 # Calculated penalty if dropped
|
||||
bonus_challenges: list[BonusAssignmentResponse] = [] # Для playthrough
|
||||
event_type: str | None = None # Event type if assignment was created during event
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TrackTimeRequest(BaseModel):
|
||||
"""Request to update tracked time for an assignment"""
|
||||
minutes: int # Total minutes tracked (replaces previous value)
|
||||
|
||||
|
||||
class SpinResult(BaseModel):
|
||||
assignment_id: int
|
||||
game: GameResponse
|
||||
challenge: ChallengeResponse
|
||||
challenge: ChallengeResponse | None # None для playthrough
|
||||
is_playthrough: bool = False
|
||||
playthrough_info: PlaythroughInfo | None = None # Заполняется для playthrough
|
||||
bonus_challenges: list[ChallengeResponse] = [] # Для playthrough - список доступных бонусных челленджей
|
||||
can_drop: bool
|
||||
drop_penalty: int
|
||||
event_type: str | None = None # Event type if active during spin
|
||||
|
||||
|
||||
class CompleteResult(BaseModel):
|
||||
@@ -43,6 +85,7 @@ class CompleteResult(BaseModel):
|
||||
streak_bonus: int
|
||||
total_points: int
|
||||
new_streak: int
|
||||
coins_earned: int = 0 # Coins earned (only in certified marathons)
|
||||
|
||||
|
||||
class DropResult(BaseModel):
|
||||
@@ -60,3 +103,22 @@ class EventAssignmentResponse(BaseModel):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CompleteBonusAssignment(BaseModel):
|
||||
"""Запрос на завершение бонусного челленджа"""
|
||||
proof_url: str | None = None
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class BonusCompleteResult(BaseModel):
|
||||
"""Результат завершения бонусного челленджа"""
|
||||
bonus_assignment_id: int
|
||||
points_earned: int
|
||||
total_bonus_points: int # Сумма очков за все бонусные челленджи
|
||||
|
||||
|
||||
class AvailableGamesCount(BaseModel):
|
||||
"""Количество доступных игр для спина"""
|
||||
available: int
|
||||
total: int
|
||||
|
||||
@@ -19,7 +19,7 @@ class ChallengeBase(BaseModel):
|
||||
description: str = Field(..., min_length=1)
|
||||
type: ChallengeType
|
||||
difficulty: Difficulty
|
||||
points: int = Field(..., ge=1, le=500)
|
||||
points: int = Field(..., ge=1)
|
||||
estimated_time: int | None = Field(None, ge=1) # minutes
|
||||
proof_type: ProofType
|
||||
proof_hint: str | None = None
|
||||
@@ -34,7 +34,7 @@ class ChallengeUpdate(BaseModel):
|
||||
description: str | None = None
|
||||
type: ChallengeType | None = None
|
||||
difficulty: Difficulty | None = None
|
||||
points: int | None = Field(None, ge=1, le=500)
|
||||
points: int | None = Field(None, ge=1)
|
||||
estimated_time: int | None = None
|
||||
proof_type: ProofType | None = None
|
||||
proof_hint: str | None = None
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.user import UserPublic
|
||||
from app.schemas.challenge import ChallengeResponse
|
||||
from app.schemas.challenge import ChallengeResponse, GameShort
|
||||
from app.schemas.assignment import ProofFileResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.schemas.game import PlaythroughInfo
|
||||
from app.schemas.assignment import BonusAssignmentResponse
|
||||
|
||||
|
||||
class DisputeCreate(BaseModel):
|
||||
@@ -63,11 +69,15 @@ class DisputeResponse(BaseModel):
|
||||
class AssignmentDetailResponse(BaseModel):
|
||||
"""Detailed assignment information with proofs and dispute"""
|
||||
id: int
|
||||
challenge: ChallengeResponse
|
||||
challenge: ChallengeResponse | None # None for playthrough
|
||||
game: GameShort | None = None # For playthrough
|
||||
is_playthrough: bool = False
|
||||
playthrough_info: dict | None = None # For playthrough (description, points, proof_type, proof_hint)
|
||||
participant: UserPublic
|
||||
status: str
|
||||
proof_url: str | None # External URL (YouTube, etc.)
|
||||
proof_image_url: str | None # Uploaded file URL
|
||||
proof_image_url: str | None # Uploaded file URL (legacy, for backward compatibility)
|
||||
proof_files: list[ProofFileResponse] = [] # Multiple uploaded files
|
||||
proof_comment: str | None
|
||||
points_earned: int
|
||||
streak_at_completion: int | None
|
||||
@@ -75,6 +85,7 @@ class AssignmentDetailResponse(BaseModel):
|
||||
completed_at: datetime | None
|
||||
can_dispute: bool # True if <24h since completion and not own assignment
|
||||
dispute: DisputeResponse | None
|
||||
bonus_challenges: list[dict] | None = None # For playthrough
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -83,7 +94,11 @@ class AssignmentDetailResponse(BaseModel):
|
||||
class ReturnedAssignmentResponse(BaseModel):
|
||||
"""Returned assignment that needs to be redone"""
|
||||
id: int
|
||||
challenge: ChallengeResponse
|
||||
challenge: ChallengeResponse | None = None # For challenge assignments
|
||||
is_playthrough: bool = False
|
||||
game_id: int | None = None # For playthrough assignments
|
||||
game_title: str | None = None
|
||||
game_cover_url: str | None = None
|
||||
original_completed_at: datetime
|
||||
dispute_reason: str
|
||||
|
||||
|
||||
@@ -128,10 +128,16 @@ 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
|
||||
is_playthrough: bool = False
|
||||
# Challenge fields (used when is_playthrough=False)
|
||||
challenge_title: str | None = None
|
||||
challenge_description: str | None = None
|
||||
challenge_points: int | None = None
|
||||
challenge_difficulty: str | None = None
|
||||
# Playthrough fields (used when is_playthrough=True)
|
||||
playthrough_description: str | None = None
|
||||
playthrough_points: int | None = None
|
||||
# Common field
|
||||
game_title: str
|
||||
|
||||
|
||||
@@ -145,11 +151,17 @@ class SwapRequestCreate(BaseModel):
|
||||
|
||||
|
||||
class SwapRequestChallengeInfo(BaseModel):
|
||||
"""Challenge info for swap request display"""
|
||||
title: str
|
||||
description: str
|
||||
points: int
|
||||
difficulty: str
|
||||
"""Challenge or playthrough info for swap request display"""
|
||||
is_playthrough: bool = False
|
||||
# Challenge fields (used when is_playthrough=False)
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
points: int | None = None
|
||||
difficulty: str | None = None
|
||||
# Playthrough fields (used when is_playthrough=True)
|
||||
playthrough_description: str | None = None
|
||||
playthrough_points: int | None = None
|
||||
# Common field
|
||||
game_title: str
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from typing import Self
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.models.game import GameType
|
||||
from app.models.challenge import ProofType
|
||||
from app.schemas.user import UserPublic
|
||||
|
||||
|
||||
@@ -13,17 +16,48 @@ class GameBase(BaseModel):
|
||||
class GameCreate(GameBase):
|
||||
cover_url: str | None = None
|
||||
|
||||
# Тип игры
|
||||
game_type: GameType = GameType.CHALLENGES
|
||||
|
||||
# Поля для типа "Прохождение"
|
||||
playthrough_points: int | None = Field(None, ge=1)
|
||||
playthrough_description: str | None = None
|
||||
playthrough_proof_type: ProofType | None = None
|
||||
playthrough_proof_hint: str | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_playthrough_fields(self) -> Self:
|
||||
if self.game_type == GameType.PLAYTHROUGH:
|
||||
if self.playthrough_points is None:
|
||||
raise ValueError('playthrough_points обязателен для типа "Прохождение"')
|
||||
if self.playthrough_description is None:
|
||||
raise ValueError('playthrough_description обязателен для типа "Прохождение"')
|
||||
if self.playthrough_proof_type is None:
|
||||
raise ValueError('playthrough_proof_type обязателен для типа "Прохождение"')
|
||||
return self
|
||||
|
||||
|
||||
class GameUpdate(BaseModel):
|
||||
title: str | None = Field(None, min_length=1, max_length=100)
|
||||
download_url: str | None = None
|
||||
genre: str | None = None
|
||||
|
||||
# Тип игры
|
||||
game_type: GameType | None = None
|
||||
|
||||
# Поля для типа "Прохождение"
|
||||
playthrough_points: int | None = Field(None, ge=1)
|
||||
playthrough_description: str | None = None
|
||||
playthrough_proof_type: ProofType | None = None
|
||||
playthrough_proof_hint: str | None = None
|
||||
|
||||
|
||||
class GameShort(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
cover_url: str | None = None
|
||||
download_url: str
|
||||
game_type: str = "challenges"
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -38,5 +72,22 @@ class GameResponse(GameBase):
|
||||
challenges_count: int = 0
|
||||
created_at: datetime
|
||||
|
||||
# Тип игры
|
||||
game_type: str = "challenges"
|
||||
|
||||
# Поля для типа "Прохождение"
|
||||
playthrough_points: int | None = None
|
||||
playthrough_description: str | None = None
|
||||
playthrough_proof_type: str | None = None
|
||||
playthrough_proof_hint: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PlaythroughInfo(BaseModel):
|
||||
"""Информация о прохождении для игр типа playthrough"""
|
||||
description: str | None = None
|
||||
points: int | None = None
|
||||
proof_type: str | None = None
|
||||
proof_hint: str | None = None
|
||||
|
||||
@@ -14,6 +14,10 @@ class MarathonCreate(MarathonBase):
|
||||
duration_days: int = Field(default=30, ge=1, le=365)
|
||||
is_public: bool = False
|
||||
game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$")
|
||||
# Shop/Consumables settings
|
||||
allow_skips: bool = True
|
||||
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
|
||||
allow_consumables: bool = True
|
||||
|
||||
|
||||
class MarathonUpdate(BaseModel):
|
||||
@@ -23,6 +27,10 @@ class MarathonUpdate(BaseModel):
|
||||
is_public: bool | None = None
|
||||
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
|
||||
auto_events_enabled: bool | None = None
|
||||
# Shop/Consumables settings
|
||||
allow_skips: bool | None = None
|
||||
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
|
||||
allow_consumables: bool | None = None
|
||||
|
||||
|
||||
class ParticipantInfo(BaseModel):
|
||||
@@ -32,6 +40,13 @@ class ParticipantInfo(BaseModel):
|
||||
current_streak: int
|
||||
drop_count: int
|
||||
joined_at: datetime
|
||||
# Shop: coins and consumables status
|
||||
coins_earned: int = 0
|
||||
skips_used: int = 0
|
||||
has_active_boost: bool = False
|
||||
has_lucky_dice: bool = False
|
||||
lucky_dice_multiplier: float | None = None
|
||||
can_undo: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -56,6 +71,13 @@ class MarathonResponse(MarathonBase):
|
||||
games_count: int
|
||||
created_at: datetime
|
||||
my_participation: ParticipantInfo | None = None
|
||||
# Certification
|
||||
certification_status: str = "none"
|
||||
is_certified: bool = False
|
||||
# Shop/Consumables settings
|
||||
allow_skips: bool = True
|
||||
max_skips_per_participant: int | None = None
|
||||
allow_consumables: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -74,6 +96,8 @@ class MarathonListItem(BaseModel):
|
||||
participants_count: int
|
||||
start_date: datetime | None
|
||||
end_date: datetime | None
|
||||
# Certification badge
|
||||
is_certified: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -104,3 +128,23 @@ class LeaderboardEntry(BaseModel):
|
||||
current_streak: int
|
||||
completed_count: int
|
||||
dropped_count: int
|
||||
|
||||
|
||||
# Moderation schemas
|
||||
class OrganizerSkipRequest(BaseModel):
|
||||
"""Request to skip a participant's assignment by organizer"""
|
||||
exile: bool = False # If true, also exile the game from participant's pool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ExiledGameResponse(BaseModel):
|
||||
"""Exiled game info"""
|
||||
id: int
|
||||
game_id: int
|
||||
game_title: str
|
||||
exiled_at: datetime
|
||||
exiled_by: str # "user" | "organizer" | "admin"
|
||||
reason: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
74
backend/app/schemas/promo_code.py
Normal file
74
backend/app/schemas/promo_code.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Promo Code schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# === Create/Update ===
|
||||
|
||||
class PromoCodeCreate(BaseModel):
|
||||
"""Schema for creating a promo code"""
|
||||
code: str | None = Field(None, min_length=3, max_length=50) # None = auto-generate
|
||||
coins_amount: int = Field(..., ge=1, le=100000)
|
||||
max_uses: int | None = Field(None, ge=1) # None = unlimited
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
|
||||
|
||||
class PromoCodeUpdate(BaseModel):
|
||||
"""Schema for updating a promo code"""
|
||||
is_active: bool | None = None
|
||||
max_uses: int | None = None
|
||||
valid_until: datetime | None = None
|
||||
|
||||
|
||||
# === Response ===
|
||||
|
||||
class PromoCodeResponse(BaseModel):
|
||||
"""Schema for promo code in responses"""
|
||||
id: int
|
||||
code: str
|
||||
coins_amount: int
|
||||
max_uses: int | None
|
||||
uses_count: int
|
||||
is_active: bool
|
||||
valid_from: datetime | None
|
||||
valid_until: datetime | None
|
||||
created_at: datetime
|
||||
created_by_nickname: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PromoCodeRedemptionUser(BaseModel):
|
||||
"""User info for redemption"""
|
||||
id: int
|
||||
nickname: str
|
||||
|
||||
|
||||
class PromoCodeRedemptionResponse(BaseModel):
|
||||
"""Schema for redemption record"""
|
||||
id: int
|
||||
user: PromoCodeRedemptionUser
|
||||
coins_awarded: int
|
||||
redeemed_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# === Redeem ===
|
||||
|
||||
class PromoCodeRedeemRequest(BaseModel):
|
||||
"""Schema for redeeming a promo code"""
|
||||
code: str = Field(..., min_length=1, max_length=50)
|
||||
|
||||
|
||||
class PromoCodeRedeemResponse(BaseModel):
|
||||
"""Schema for redeem response"""
|
||||
success: bool
|
||||
coins_awarded: int
|
||||
new_balance: int
|
||||
message: str
|
||||
216
backend/app/schemas/shop.py
Normal file
216
backend/app/schemas/shop.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Pydantic schemas for Shop system
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any
|
||||
|
||||
|
||||
# === Shop Items ===
|
||||
|
||||
class ShopItemBase(BaseModel):
|
||||
"""Base schema for shop items"""
|
||||
item_type: str
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
price: int
|
||||
rarity: str = "common"
|
||||
asset_data: dict | None = None
|
||||
|
||||
|
||||
class ShopItemCreate(ShopItemBase):
|
||||
"""Schema for creating a shop item (admin)"""
|
||||
is_active: bool = True
|
||||
available_from: datetime | None = None
|
||||
available_until: datetime | None = None
|
||||
stock_limit: int | None = None
|
||||
|
||||
|
||||
class ShopItemUpdate(BaseModel):
|
||||
"""Schema for updating a shop item (admin)"""
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
price: int | None = Field(None, ge=1)
|
||||
rarity: str | None = None
|
||||
asset_data: dict | None = None
|
||||
is_active: bool | None = None
|
||||
available_from: datetime | None = None
|
||||
available_until: datetime | None = None
|
||||
stock_limit: int | None = None
|
||||
|
||||
|
||||
class ShopItemResponse(ShopItemBase):
|
||||
"""Schema for shop item response"""
|
||||
id: int
|
||||
is_active: bool
|
||||
available_from: datetime | None
|
||||
available_until: datetime | None
|
||||
stock_limit: int | None
|
||||
stock_remaining: int | None
|
||||
created_at: datetime
|
||||
is_available: bool # Computed property
|
||||
is_owned: bool = False # Set by API based on user
|
||||
is_equipped: bool = False # Set by API based on user
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# === Inventory ===
|
||||
|
||||
class InventoryItemResponse(BaseModel):
|
||||
"""Schema for user inventory item"""
|
||||
id: int
|
||||
item: ShopItemResponse
|
||||
quantity: int
|
||||
equipped: bool
|
||||
purchased_at: datetime
|
||||
expires_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# === Purchases ===
|
||||
|
||||
class PurchaseRequest(BaseModel):
|
||||
"""Schema for purchase request"""
|
||||
item_id: int
|
||||
quantity: int = Field(default=1, ge=1, le=10)
|
||||
|
||||
|
||||
class PurchaseResponse(BaseModel):
|
||||
"""Schema for purchase response"""
|
||||
success: bool
|
||||
item: ShopItemResponse
|
||||
quantity: int
|
||||
total_cost: int
|
||||
new_balance: int
|
||||
message: str
|
||||
|
||||
|
||||
# === Consumables ===
|
||||
|
||||
class UseConsumableRequest(BaseModel):
|
||||
"""Schema for using a consumable"""
|
||||
item_code: str # 'skip', 'boost', 'wild_card', 'lucky_dice', 'copycat', 'undo'
|
||||
marathon_id: int
|
||||
assignment_id: int | None = None # Required for skip, wild_card, copycat
|
||||
game_id: int | None = None # Required for wild_card
|
||||
target_participant_id: int | None = None # Required for copycat
|
||||
|
||||
|
||||
class UseConsumableResponse(BaseModel):
|
||||
"""Schema for consumable use response"""
|
||||
success: bool
|
||||
item_code: str
|
||||
remaining_quantity: int
|
||||
effect_description: str
|
||||
effect_data: dict | None = None
|
||||
|
||||
|
||||
# === Equipment ===
|
||||
|
||||
class EquipItemRequest(BaseModel):
|
||||
"""Schema for equipping an item"""
|
||||
inventory_id: int
|
||||
|
||||
|
||||
class EquipItemResponse(BaseModel):
|
||||
"""Schema for equip response"""
|
||||
success: bool
|
||||
item_type: str
|
||||
equipped_item: ShopItemResponse | None
|
||||
message: str
|
||||
|
||||
|
||||
# === Coins ===
|
||||
|
||||
class CoinTransactionResponse(BaseModel):
|
||||
"""Schema for coin transaction"""
|
||||
id: int
|
||||
amount: int
|
||||
transaction_type: str
|
||||
description: str | None
|
||||
reference_type: str | None
|
||||
reference_id: int | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CoinsBalanceResponse(BaseModel):
|
||||
"""Schema for coins balance with recent transactions"""
|
||||
balance: int
|
||||
recent_transactions: list[CoinTransactionResponse]
|
||||
|
||||
|
||||
class AdminCoinsRequest(BaseModel):
|
||||
"""Schema for admin coin operations"""
|
||||
amount: int = Field(..., ge=1)
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
# === User Cosmetics ===
|
||||
|
||||
class UserCosmeticsResponse(BaseModel):
|
||||
"""Schema for user's equipped cosmetics"""
|
||||
frame: ShopItemResponse | None = None
|
||||
title: ShopItemResponse | None = None
|
||||
name_color: ShopItemResponse | None = None
|
||||
background: ShopItemResponse | None = None
|
||||
|
||||
|
||||
# === Certification ===
|
||||
|
||||
class CertificationRequestSchema(BaseModel):
|
||||
"""Schema for requesting marathon certification"""
|
||||
pass # No fields needed for now
|
||||
|
||||
|
||||
class CertificationReviewRequest(BaseModel):
|
||||
"""Schema for admin reviewing certification"""
|
||||
approve: bool
|
||||
rejection_reason: str | None = Field(None, max_length=1000)
|
||||
|
||||
|
||||
class CertificationStatusResponse(BaseModel):
|
||||
"""Schema for certification status"""
|
||||
marathon_id: int
|
||||
certification_status: str
|
||||
is_certified: bool
|
||||
certification_requested_at: datetime | None
|
||||
certified_at: datetime | None
|
||||
certified_by_nickname: str | None = None
|
||||
rejection_reason: str | None = None
|
||||
|
||||
|
||||
# === Consumables Status ===
|
||||
|
||||
class ConsumablesStatusResponse(BaseModel):
|
||||
"""Schema for participant's consumables status in a marathon"""
|
||||
skips_available: int # From inventory
|
||||
skip_exiles_available: int = 0 # From inventory (skip with exile)
|
||||
skips_used: int # In this marathon
|
||||
skips_remaining: int | None # Based on marathon limit
|
||||
boosts_available: int # From inventory
|
||||
has_active_boost: bool # Currently activated (one-time for current assignment)
|
||||
boost_multiplier: float | None # 1.5 if boost active
|
||||
wild_cards_available: int # From inventory
|
||||
lucky_dice_available: int # From inventory
|
||||
has_lucky_dice: bool # Currently activated
|
||||
lucky_dice_multiplier: float | None # Rolled multiplier if active
|
||||
copycats_available: int # From inventory
|
||||
undos_available: int # From inventory
|
||||
can_undo: bool # Has drop data to undo
|
||||
|
||||
|
||||
# === Admin Item Granting ===
|
||||
|
||||
class AdminGrantItemRequest(BaseModel):
|
||||
"""Schema for admin granting item to user"""
|
||||
item_id: int
|
||||
quantity: int = Field(default=1, ge=1, le=100)
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
@@ -28,6 +28,19 @@ class UserUpdate(BaseModel):
|
||||
nickname: str | None = Field(None, min_length=2, max_length=50)
|
||||
|
||||
|
||||
class ShopItemPublic(BaseModel):
|
||||
"""Minimal shop item info for public display"""
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
item_type: str
|
||||
rarity: str
|
||||
asset_data: dict | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserPublic(UserBase):
|
||||
"""Public user info visible to other users - minimal data"""
|
||||
id: int
|
||||
@@ -35,6 +48,11 @@ class UserPublic(UserBase):
|
||||
role: str = "user"
|
||||
telegram_avatar_url: str | None = None # Only TG avatar is public
|
||||
created_at: datetime
|
||||
# Shop: equipped cosmetics (visible to others)
|
||||
equipped_frame: ShopItemPublic | None = None
|
||||
equipped_title: ShopItemPublic | None = None
|
||||
equipped_name_color: ShopItemPublic | None = None
|
||||
equipped_background: ShopItemPublic | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -47,6 +65,12 @@ class UserPrivate(UserPublic):
|
||||
telegram_username: str | None = None
|
||||
telegram_first_name: str | None = None
|
||||
telegram_last_name: str | None = None
|
||||
# Notification settings
|
||||
notify_events: bool = True
|
||||
notify_disputes: bool = True
|
||||
notify_moderation: bool = True
|
||||
# Shop: coins balance (only visible to self)
|
||||
coins_balance: int = 0
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
@@ -78,8 +102,32 @@ class UserProfilePublic(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
avatar_url: str | None = None
|
||||
telegram_avatar_url: str | None = None
|
||||
role: str = "user"
|
||||
created_at: datetime
|
||||
stats: UserStats
|
||||
# Equipped cosmetics
|
||||
equipped_frame: ShopItemPublic | None = None
|
||||
equipped_title: ShopItemPublic | None = None
|
||||
equipped_name_color: ShopItemPublic | None = None
|
||||
equipped_background: ShopItemPublic | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotificationSettings(BaseModel):
|
||||
"""Notification settings for Telegram bot"""
|
||||
notify_events: bool = True
|
||||
notify_disputes: bool = True
|
||||
notify_moderation: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotificationSettingsUpdate(BaseModel):
|
||||
"""Update notification settings"""
|
||||
notify_events: bool | None = None
|
||||
notify_disputes: bool | None = None
|
||||
notify_moderation: bool | None = None
|
||||
|
||||
79
backend/app/schemas/widget.py
Normal file
79
backend/app/schemas/widget.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# === Token schemas ===
|
||||
|
||||
class WidgetTokenCreate(BaseModel):
|
||||
"""Создание токена виджета"""
|
||||
pass # Не требует параметров
|
||||
|
||||
|
||||
class WidgetTokenResponse(BaseModel):
|
||||
"""Ответ с токеном виджета"""
|
||||
id: int
|
||||
token: str
|
||||
created_at: datetime
|
||||
expires_at: datetime | None
|
||||
is_active: bool
|
||||
urls: dict[str, str] # Готовые URL для виджетов
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WidgetTokenListItem(BaseModel):
|
||||
"""Элемент списка токенов"""
|
||||
id: int
|
||||
token: str
|
||||
created_at: datetime
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# === Widget data schemas ===
|
||||
|
||||
class WidgetLeaderboardEntry(BaseModel):
|
||||
"""Запись в лидерборде виджета"""
|
||||
rank: int
|
||||
nickname: str
|
||||
avatar_url: str | None
|
||||
total_points: int
|
||||
current_streak: int
|
||||
is_current_user: bool # Для подсветки
|
||||
|
||||
|
||||
class WidgetLeaderboardResponse(BaseModel):
|
||||
"""Ответ лидерборда для виджета"""
|
||||
entries: list[WidgetLeaderboardEntry]
|
||||
current_user_rank: int | None
|
||||
total_participants: int
|
||||
marathon_title: str
|
||||
|
||||
|
||||
class WidgetCurrentResponse(BaseModel):
|
||||
"""Текущее задание для виджета"""
|
||||
has_assignment: bool
|
||||
game_title: str | None = None
|
||||
game_cover_url: str | None = None
|
||||
assignment_type: str | None = None # "challenge" | "playthrough"
|
||||
challenge_title: str | None = None
|
||||
challenge_description: str | None = None
|
||||
points: int | None = None
|
||||
difficulty: str | None = None # easy, medium, hard
|
||||
bonus_completed: int | None = None # Для прохождений
|
||||
bonus_total: int | None = None
|
||||
|
||||
|
||||
class WidgetProgressResponse(BaseModel):
|
||||
"""Прогресс участника для виджета"""
|
||||
nickname: str
|
||||
avatar_url: str | None
|
||||
rank: int
|
||||
total_points: int
|
||||
current_streak: int
|
||||
completed_count: int
|
||||
dropped_count: int
|
||||
marathon_title: str
|
||||
288
backend/app/services/coins.py
Normal file
288
backend/app/services/coins.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Coins Service - handles all coin-related operations
|
||||
|
||||
Coins are earned only in certified marathons and can be spent in the shop.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import User, Participant, Marathon, CoinTransaction, CoinTransactionType
|
||||
from app.models.challenge import Difficulty
|
||||
|
||||
|
||||
class CoinsService:
|
||||
"""Service for managing coin transactions and balances"""
|
||||
|
||||
# Coins awarded per challenge difficulty (only in certified marathons)
|
||||
CHALLENGE_COINS = {
|
||||
Difficulty.EASY.value: 10,
|
||||
Difficulty.MEDIUM.value: 20,
|
||||
Difficulty.HARD.value: 35,
|
||||
}
|
||||
|
||||
# Coins for playthrough = points * this ratio
|
||||
PLAYTHROUGH_COIN_RATIO = 0.10 # 10% of points
|
||||
|
||||
# Coins awarded for marathon placements
|
||||
MARATHON_PLACE_COINS = {
|
||||
1: 500, # 1st place
|
||||
2: 250, # 2nd place
|
||||
3: 150, # 3rd place
|
||||
}
|
||||
|
||||
# Bonus coins for Common Enemy event winners
|
||||
COMMON_ENEMY_BONUS_COINS = {
|
||||
1: 15, # First to complete
|
||||
2: 10, # Second
|
||||
3: 5, # Third
|
||||
}
|
||||
|
||||
async def award_challenge_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
difficulty: str,
|
||||
assignment_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award coins for completing a challenge.
|
||||
Only awards coins if marathon is certified.
|
||||
|
||||
Returns: number of coins awarded (0 if marathon not certified)
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = self.CHALLENGE_COINS.get(difficulty, 0)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
# Create transaction
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.CHALLENGE_COMPLETE.value,
|
||||
reference_type="assignment",
|
||||
reference_id=assignment_id,
|
||||
description=f"Challenge completion ({difficulty})",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
# Update balances
|
||||
user.coins_balance += coins
|
||||
participant.coins_earned += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def award_playthrough_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
points: int,
|
||||
assignment_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award coins for completing a playthrough.
|
||||
Coins = points * PLAYTHROUGH_COIN_RATIO
|
||||
|
||||
Returns: number of coins awarded (0 if marathon not certified)
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = int(points * self.PLAYTHROUGH_COIN_RATIO)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.PLAYTHROUGH_COMPLETE.value,
|
||||
reference_type="assignment",
|
||||
reference_id=assignment_id,
|
||||
description=f"Playthrough completion ({points} points)",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += coins
|
||||
participant.coins_earned += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def award_marathon_place(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
marathon: Marathon,
|
||||
place: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award coins for placing in a marathon (1st, 2nd, 3rd).
|
||||
|
||||
Returns: number of coins awarded (0 if not top 3 or not certified)
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = self.MARATHON_PLACE_COINS.get(place, 0)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.MARATHON_PLACE.value,
|
||||
reference_type="marathon",
|
||||
reference_id=marathon.id,
|
||||
description=f"Marathon #{place} place: {marathon.title}",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def award_common_enemy_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
rank: int,
|
||||
event_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award bonus coins for Common Enemy event completion.
|
||||
|
||||
Returns: number of bonus coins awarded
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = self.COMMON_ENEMY_BONUS_COINS.get(rank, 0)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.COMMON_ENEMY_BONUS.value,
|
||||
reference_type="event",
|
||||
reference_id=event_id,
|
||||
description=f"Common Enemy #{rank} place",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += coins
|
||||
participant.coins_earned += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def spend_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
description: str,
|
||||
reference_type: str | None = None,
|
||||
reference_id: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Spend coins (for purchases).
|
||||
|
||||
Returns: True if successful, False if insufficient balance
|
||||
"""
|
||||
if user.coins_balance < amount:
|
||||
return False
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=-amount, # Negative for spending
|
||||
transaction_type=CoinTransactionType.PURCHASE.value,
|
||||
reference_type=reference_type,
|
||||
reference_id=reference_id,
|
||||
description=description,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance -= amount
|
||||
return True
|
||||
|
||||
async def refund_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
description: str,
|
||||
reference_type: str | None = None,
|
||||
reference_id: int | None = None,
|
||||
) -> None:
|
||||
"""Refund coins to user (for failed purchases, etc.)"""
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=amount,
|
||||
transaction_type=CoinTransactionType.REFUND.value,
|
||||
reference_type=reference_type,
|
||||
reference_id=reference_id,
|
||||
description=description,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += amount
|
||||
|
||||
async def admin_grant_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
reason: str,
|
||||
admin_id: int,
|
||||
) -> None:
|
||||
"""Admin grants coins to user"""
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=amount,
|
||||
transaction_type=CoinTransactionType.ADMIN_GRANT.value,
|
||||
reference_type="admin",
|
||||
reference_id=admin_id,
|
||||
description=f"Admin grant: {reason}",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += amount
|
||||
|
||||
async def admin_deduct_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
reason: str,
|
||||
admin_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Admin deducts coins from user.
|
||||
|
||||
Returns: True if successful, False if insufficient balance
|
||||
"""
|
||||
if user.coins_balance < amount:
|
||||
return False
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=-amount,
|
||||
transaction_type=CoinTransactionType.ADMIN_DEDUCT.value,
|
||||
reference_type="admin",
|
||||
reference_id=admin_id,
|
||||
description=f"Admin deduction: {reason}",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance -= amount
|
||||
return True
|
||||
|
||||
|
||||
# Singleton instance
|
||||
coins_service = CoinsService()
|
||||
721
backend/app/services/consumables.py
Normal file
721
backend/app/services/consumables.py
Normal file
@@ -0,0 +1,721 @@
|
||||
"""
|
||||
Consumables Service - handles consumable items usage
|
||||
|
||||
Consumables:
|
||||
- skip: Skip current assignment without penalty
|
||||
- skip_exile: Skip + permanently exile game from pool
|
||||
- boost: x1.5 multiplier for current assignment
|
||||
- wild_card: Choose a game, get random challenge from it
|
||||
- lucky_dice: Random multiplier (0.5, 1.0, 1.5, 2.0, 2.5, 3.0)
|
||||
- copycat: Copy another participant's assignment
|
||||
- undo: Restore points and streak from last drop
|
||||
"""
|
||||
import random
|
||||
from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import (
|
||||
User, Participant, Marathon, Assignment, AssignmentStatus,
|
||||
ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge,
|
||||
BonusAssignment, ExiledGame, GameType
|
||||
)
|
||||
|
||||
|
||||
class ConsumablesService:
|
||||
"""Service for consumable items"""
|
||||
|
||||
# Boost settings
|
||||
BOOST_MULTIPLIER = 1.5
|
||||
|
||||
# Lucky Dice multipliers (equal probability, starts from 1.5x)
|
||||
LUCKY_DICE_MULTIPLIERS = [1.5, 2.0, 2.5, 3.0, 3.5, 4.0]
|
||||
|
||||
async def use_skip(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
) -> dict:
|
||||
"""
|
||||
Use a Skip to bypass current assignment without penalty.
|
||||
|
||||
- No streak loss
|
||||
- No drop penalty
|
||||
- Assignment marked as dropped but without negative effects
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If skips not allowed or limit reached
|
||||
"""
|
||||
# Check marathon settings
|
||||
if not marathon.allow_skips:
|
||||
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
|
||||
|
||||
if marathon.max_skips_per_participant is not None:
|
||||
if participant.skips_used >= marathon.max_skips_per_participant:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)"
|
||||
)
|
||||
|
||||
# Check assignment is active
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Can only skip active assignments")
|
||||
|
||||
# Consume skip from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.SKIP.value)
|
||||
|
||||
# Mark assignment as dropped (but without penalty)
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
# Note: We do NOT increase drop_count or reset streak
|
||||
|
||||
# Track skip usage
|
||||
participant.skips_used += 1
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "skip",
|
||||
"skipped_without_penalty": True,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"skipped": True,
|
||||
"penalty": 0,
|
||||
"streak_preserved": True,
|
||||
}
|
||||
|
||||
async def use_skip_exile(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Skip with Exile - skip assignment AND permanently exile game from pool.
|
||||
|
||||
- No streak loss
|
||||
- No drop penalty
|
||||
- Game is permanently excluded from participant's pool
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If skips not allowed or limit reached
|
||||
"""
|
||||
# Check marathon settings (same as regular skip)
|
||||
if not marathon.allow_skips:
|
||||
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
|
||||
|
||||
if marathon.max_skips_per_participant is not None:
|
||||
if participant.skips_used >= marathon.max_skips_per_participant:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)"
|
||||
)
|
||||
|
||||
# Check assignment is active
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Can only skip active assignments")
|
||||
|
||||
# Get game_id (different for playthrough vs challenges)
|
||||
if assignment.is_playthrough:
|
||||
game_id = assignment.game_id
|
||||
else:
|
||||
# Load challenge to get game_id
|
||||
result = await db.execute(
|
||||
select(Challenge).where(Challenge.id == assignment.challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one()
|
||||
game_id = challenge.game_id
|
||||
|
||||
# Check if game is already exiled
|
||||
existing = await db.execute(
|
||||
select(ExiledGame).where(
|
||||
ExiledGame.participant_id == participant.id,
|
||||
ExiledGame.game_id == game_id,
|
||||
ExiledGame.is_active == True,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Game is already exiled")
|
||||
|
||||
# Consume skip_exile from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.SKIP_EXILE.value)
|
||||
|
||||
# Mark assignment as dropped (without penalty)
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
|
||||
# Track skip usage
|
||||
participant.skips_used += 1
|
||||
|
||||
# Add game to exiled list
|
||||
exiled = ExiledGame(
|
||||
participant_id=participant.id,
|
||||
game_id=game_id,
|
||||
assignment_id=assignment.id,
|
||||
exiled_by="user",
|
||||
)
|
||||
db.add(exiled)
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "skip_exile",
|
||||
"skipped_without_penalty": True,
|
||||
"game_exiled": True,
|
||||
"game_id": game_id,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"skipped": True,
|
||||
"exiled": True,
|
||||
"game_id": game_id,
|
||||
"penalty": 0,
|
||||
"streak_preserved": True,
|
||||
}
|
||||
|
||||
async def use_boost(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Activate a Boost - multiplies points for current assignment on complete.
|
||||
|
||||
- Points for completed challenge are multiplied by BOOST_MULTIPLIER
|
||||
- One-time use (consumed on complete)
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or boost already active
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if participant.has_active_boost:
|
||||
raise HTTPException(status_code=400, detail="Boost is already activated")
|
||||
|
||||
# Consume boost from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.BOOST.value)
|
||||
|
||||
# Activate boost (one-time use)
|
||||
participant.has_active_boost = True
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "boost",
|
||||
"multiplier": self.BOOST_MULTIPLIER,
|
||||
"one_time": True,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"boost_activated": True,
|
||||
"multiplier": self.BOOST_MULTIPLIER,
|
||||
}
|
||||
|
||||
async def use_wild_card(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
game_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Wild Card - choose a game and switch to it.
|
||||
|
||||
For challenges game type:
|
||||
- New challenge is randomly selected from the chosen game
|
||||
- Assignment becomes a regular challenge
|
||||
|
||||
For playthrough game type:
|
||||
- Assignment becomes a playthrough of the chosen game
|
||||
- Bonus assignments are created from game's challenges
|
||||
|
||||
Returns: dict with new assignment info
|
||||
|
||||
Raises:
|
||||
HTTPException: If game not in marathon or no challenges available
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Can only use wild card on active assignments")
|
||||
|
||||
# Verify game is in this marathon and load challenges
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(selectinload(Game.challenges))
|
||||
.where(
|
||||
Game.id == game_id,
|
||||
Game.marathon_id == marathon.id,
|
||||
)
|
||||
)
|
||||
game = result.scalar_one_or_none()
|
||||
|
||||
if not game:
|
||||
raise HTTPException(status_code=400, detail="Game not found in this marathon")
|
||||
|
||||
# Store old assignment info for logging
|
||||
old_game_id = assignment.game_id
|
||||
old_challenge_id = assignment.challenge_id
|
||||
old_is_playthrough = assignment.is_playthrough
|
||||
|
||||
# Consume wild card from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.WILD_CARD.value)
|
||||
|
||||
# Delete existing bonus assignments if any
|
||||
if assignment.bonus_assignments:
|
||||
for ba in assignment.bonus_assignments:
|
||||
await db.delete(ba)
|
||||
|
||||
new_challenge_id = None
|
||||
new_challenge_title = None
|
||||
|
||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||
# Switch to playthrough mode
|
||||
assignment.game_id = game_id
|
||||
assignment.challenge_id = None
|
||||
assignment.is_playthrough = True
|
||||
|
||||
# Create bonus assignments from game's challenges
|
||||
for ch in game.challenges:
|
||||
bonus = BonusAssignment(
|
||||
main_assignment_id=assignment.id,
|
||||
challenge_id=ch.id,
|
||||
)
|
||||
db.add(bonus)
|
||||
|
||||
else:
|
||||
# Switch to challenge mode - get random challenge
|
||||
if not game.challenges:
|
||||
raise HTTPException(status_code=400, detail="No challenges available for this game")
|
||||
|
||||
new_challenge = random.choice(game.challenges)
|
||||
new_challenge_id = new_challenge.id
|
||||
new_challenge_title = new_challenge.title
|
||||
|
||||
assignment.game_id = game_id
|
||||
assignment.challenge_id = new_challenge_id
|
||||
assignment.is_playthrough = False
|
||||
|
||||
# Reset timestamps since it's a new assignment
|
||||
assignment.started_at = datetime.utcnow()
|
||||
assignment.deadline = None
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "wild_card",
|
||||
"old_game_id": old_game_id,
|
||||
"old_challenge_id": old_challenge_id,
|
||||
"old_is_playthrough": old_is_playthrough,
|
||||
"new_game_id": game_id,
|
||||
"new_challenge_id": new_challenge_id,
|
||||
"new_is_playthrough": game.game_type == GameType.PLAYTHROUGH.value,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"game_id": game_id,
|
||||
"game_name": game.title,
|
||||
"game_type": game.game_type,
|
||||
"is_playthrough": game.game_type == GameType.PLAYTHROUGH.value,
|
||||
"challenge_id": new_challenge_id,
|
||||
"challenge_title": new_challenge_title,
|
||||
}
|
||||
|
||||
async def use_lucky_dice(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Lucky Dice - get a random multiplier for current assignment.
|
||||
|
||||
- Random multiplier from [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
|
||||
- Applied on next complete (stacks with boost if both active)
|
||||
- One-time use
|
||||
|
||||
Returns: dict with rolled multiplier
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or lucky dice already active
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if participant.has_lucky_dice:
|
||||
raise HTTPException(status_code=400, detail="Lucky Dice is already active")
|
||||
|
||||
# Consume lucky dice from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.LUCKY_DICE.value)
|
||||
|
||||
# Roll the dice
|
||||
multiplier = random.choice(self.LUCKY_DICE_MULTIPLIERS)
|
||||
|
||||
# Activate lucky dice
|
||||
participant.has_lucky_dice = True
|
||||
participant.lucky_dice_multiplier = multiplier
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "lucky_dice",
|
||||
"multiplier": multiplier,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"lucky_dice_activated": True,
|
||||
"multiplier": multiplier,
|
||||
}
|
||||
|
||||
async def use_copycat(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
target_participant_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Copycat - copy another participant's assignment.
|
||||
|
||||
- Current assignment is replaced with target's current/last assignment
|
||||
- Can copy even if target already completed theirs
|
||||
- Cannot copy your own assignment
|
||||
|
||||
Returns: dict with copied assignment info
|
||||
|
||||
Raises:
|
||||
HTTPException: If target not found or no assignment to copy
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Can only use copycat on active assignments")
|
||||
|
||||
if target_participant_id == participant.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot copy your own assignment")
|
||||
|
||||
# Find target participant
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.where(
|
||||
Participant.id == target_participant_id,
|
||||
Participant.marathon_id == marathon.id,
|
||||
)
|
||||
)
|
||||
target_participant = result.scalar_one_or_none()
|
||||
|
||||
if not target_participant:
|
||||
raise HTTPException(status_code=400, detail="Target participant not found")
|
||||
|
||||
# Get target's most recent assignment (active or completed)
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge),
|
||||
selectinload(Assignment.game).selectinload(Game.challenges),
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == target_participant_id,
|
||||
Assignment.status.in_([
|
||||
AssignmentStatus.ACTIVE.value,
|
||||
AssignmentStatus.COMPLETED.value
|
||||
])
|
||||
)
|
||||
.order_by(Assignment.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
target_assignment = result.scalar_one_or_none()
|
||||
|
||||
if not target_assignment:
|
||||
raise HTTPException(status_code=400, detail="Target has no assignment to copy")
|
||||
|
||||
# Consume copycat from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.COPYCAT.value)
|
||||
|
||||
# Store old assignment info for logging
|
||||
old_game_id = assignment.game_id
|
||||
old_challenge_id = assignment.challenge_id
|
||||
old_is_playthrough = assignment.is_playthrough
|
||||
|
||||
# Copy the assignment - handle both challenge and playthrough
|
||||
assignment.game_id = target_assignment.game_id
|
||||
assignment.challenge_id = target_assignment.challenge_id
|
||||
assignment.is_playthrough = target_assignment.is_playthrough
|
||||
# Reset timestamps
|
||||
assignment.started_at = datetime.utcnow()
|
||||
assignment.deadline = None
|
||||
|
||||
# If copying a playthrough, recreate bonus assignments
|
||||
if target_assignment.is_playthrough:
|
||||
# Delete existing bonus assignments
|
||||
for ba in assignment.bonus_assignments:
|
||||
await db.delete(ba)
|
||||
|
||||
# Create new bonus assignments from target game's challenges
|
||||
if target_assignment.game and target_assignment.game.challenges:
|
||||
for ch in target_assignment.game.challenges:
|
||||
bonus = BonusAssignment(
|
||||
main_assignment_id=assignment.id,
|
||||
challenge_id=ch.id,
|
||||
)
|
||||
db.add(bonus)
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "copycat",
|
||||
"old_challenge_id": old_challenge_id,
|
||||
"old_game_id": old_game_id,
|
||||
"old_is_playthrough": old_is_playthrough,
|
||||
"copied_from_participant_id": target_participant_id,
|
||||
"new_challenge_id": target_assignment.challenge_id,
|
||||
"new_game_id": target_assignment.game_id,
|
||||
"new_is_playthrough": target_assignment.is_playthrough,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
# Prepare response
|
||||
if target_assignment.is_playthrough:
|
||||
title = f"Прохождение: {target_assignment.game.title}" if target_assignment.game else "Прохождение"
|
||||
else:
|
||||
title = target_assignment.challenge.title if target_assignment.challenge else None
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"copied": True,
|
||||
"game_id": target_assignment.game_id,
|
||||
"challenge_id": target_assignment.challenge_id,
|
||||
"is_playthrough": target_assignment.is_playthrough,
|
||||
"challenge_title": title,
|
||||
}
|
||||
|
||||
async def use_undo(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Undo - restore points and streak from last drop.
|
||||
|
||||
- Only works if there was a drop in this marathon
|
||||
- Can only undo once per drop
|
||||
- Restores both points and streak
|
||||
|
||||
Returns: dict with restored values
|
||||
|
||||
Raises:
|
||||
HTTPException: If no drop to undo
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if not participant.can_undo:
|
||||
raise HTTPException(status_code=400, detail="No drop to undo")
|
||||
|
||||
if participant.last_drop_points is None or participant.last_drop_streak_before is None:
|
||||
raise HTTPException(status_code=400, detail="No drop data to restore")
|
||||
|
||||
# Consume undo from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.UNDO.value)
|
||||
|
||||
# Store values for logging
|
||||
points_restored = participant.last_drop_points
|
||||
streak_restored = participant.last_drop_streak_before
|
||||
current_points = participant.total_points
|
||||
current_streak = participant.current_streak
|
||||
|
||||
# Restore points and streak
|
||||
participant.total_points += points_restored
|
||||
participant.current_streak = streak_restored
|
||||
participant.drop_count = max(0, participant.drop_count - 1)
|
||||
|
||||
# Clear undo data
|
||||
participant.can_undo = False
|
||||
participant.last_drop_points = None
|
||||
participant.last_drop_streak_before = None
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "undo",
|
||||
"points_restored": points_restored,
|
||||
"streak_restored_to": streak_restored,
|
||||
"points_before": current_points,
|
||||
"streak_before": current_streak,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"undone": True,
|
||||
"points_restored": points_restored,
|
||||
"streak_restored": streak_restored,
|
||||
"new_total_points": participant.total_points,
|
||||
"new_streak": participant.current_streak,
|
||||
}
|
||||
|
||||
async def _consume_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
item_code: str,
|
||||
) -> ShopItem:
|
||||
"""
|
||||
Consume 1 unit of a consumable from user's inventory.
|
||||
|
||||
Returns: The consumed ShopItem
|
||||
|
||||
Raises:
|
||||
HTTPException: If user doesn't have the item
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(UserInventory)
|
||||
.options(selectinload(UserInventory.item))
|
||||
.join(ShopItem)
|
||||
.where(
|
||||
UserInventory.user_id == user.id,
|
||||
ShopItem.code == item_code,
|
||||
UserInventory.quantity > 0,
|
||||
)
|
||||
)
|
||||
inv_item = result.scalar_one_or_none()
|
||||
|
||||
if not inv_item:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"You don't have any {item_code} in your inventory"
|
||||
)
|
||||
|
||||
# Decrease quantity
|
||||
inv_item.quantity -= 1
|
||||
|
||||
return inv_item.item
|
||||
|
||||
async def get_consumable_count(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
item_code: str,
|
||||
) -> int:
|
||||
"""Get how many of a consumable user has"""
|
||||
result = await db.execute(
|
||||
select(UserInventory.quantity)
|
||||
.join(ShopItem)
|
||||
.where(
|
||||
UserInventory.user_id == user_id,
|
||||
ShopItem.code == item_code,
|
||||
)
|
||||
)
|
||||
quantity = result.scalar_one_or_none()
|
||||
return quantity or 0
|
||||
|
||||
def consume_boost_on_complete(self, participant: Participant) -> float:
|
||||
"""
|
||||
Consume boost when completing assignment (called from wheel.py).
|
||||
One-time use - boost is consumed after single complete.
|
||||
|
||||
Returns: Multiplier value (BOOST_MULTIPLIER if boost was active, 1.0 otherwise)
|
||||
"""
|
||||
if participant.has_active_boost:
|
||||
participant.has_active_boost = False
|
||||
return self.BOOST_MULTIPLIER
|
||||
return 1.0
|
||||
|
||||
def consume_lucky_dice_on_complete(self, participant: Participant) -> float:
|
||||
"""
|
||||
Consume lucky dice when completing assignment (called from wheel.py).
|
||||
One-time use - consumed after single complete.
|
||||
|
||||
Returns: Multiplier value (rolled multiplier if active, 1.0 otherwise)
|
||||
"""
|
||||
if participant.has_lucky_dice and participant.lucky_dice_multiplier is not None:
|
||||
multiplier = participant.lucky_dice_multiplier
|
||||
participant.has_lucky_dice = False
|
||||
participant.lucky_dice_multiplier = None
|
||||
return multiplier
|
||||
return 1.0
|
||||
|
||||
def save_drop_for_undo(
|
||||
self,
|
||||
participant: Participant,
|
||||
points_lost: int,
|
||||
streak_before: int,
|
||||
) -> None:
|
||||
"""
|
||||
Save drop data for potential undo (called from wheel.py before dropping).
|
||||
"""
|
||||
participant.last_drop_points = points_lost
|
||||
participant.last_drop_streak_before = streak_before
|
||||
participant.can_undo = True
|
||||
|
||||
|
||||
# Singleton instance
|
||||
consumables_service = ConsumablesService()
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Dispute Scheduler for automatic dispute resolution after 24 hours.
|
||||
Dispute Scheduler - marks disputes as pending admin review after 24 hours.
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
@@ -8,16 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
|
||||
from app.services.disputes import dispute_service
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
|
||||
# Configuration
|
||||
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
|
||||
DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours
|
||||
DISPUTE_WINDOW_HOURS = 24 # Disputes need admin decision after 24 hours
|
||||
|
||||
|
||||
class DisputeScheduler:
|
||||
"""Background scheduler for automatic dispute resolution."""
|
||||
"""Background scheduler that marks expired disputes for admin review."""
|
||||
|
||||
def __init__(self):
|
||||
self._running = False
|
||||
@@ -55,7 +55,7 @@ class DisputeScheduler:
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
|
||||
async def _process_expired_disputes(self, db: AsyncSession) -> None:
|
||||
"""Process and resolve expired disputes."""
|
||||
"""Mark expired disputes as pending admin review."""
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||
|
||||
# Find all open disputes that have expired
|
||||
@@ -63,7 +63,6 @@ class DisputeScheduler:
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.votes),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||
)
|
||||
.where(
|
||||
Dispute.status == DisputeStatus.OPEN.value,
|
||||
@@ -74,15 +73,25 @@ class DisputeScheduler:
|
||||
|
||||
for dispute in expired_disputes:
|
||||
try:
|
||||
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute(
|
||||
db, dispute.id
|
||||
)
|
||||
# Count votes for logging
|
||||
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
||||
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
||||
|
||||
# Mark as pending admin decision
|
||||
dispute.status = DisputeStatus.PENDING_ADMIN.value
|
||||
|
||||
print(
|
||||
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: "
|
||||
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})"
|
||||
f"[DisputeScheduler] Dispute {dispute.id} marked as pending admin "
|
||||
f"(recommendation: {'invalid' if votes_invalid > votes_valid else 'valid'}, "
|
||||
f"votes: {votes_valid} valid, {votes_invalid} invalid)"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}")
|
||||
print(f"[DisputeScheduler] Failed to process dispute {dispute.id}: {e}")
|
||||
|
||||
if expired_disputes:
|
||||
await db.commit()
|
||||
# Notify admins about pending disputes
|
||||
await telegram_notifier.notify_admin_disputes_pending(db, len(expired_disputes))
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
|
||||
@@ -23,12 +23,15 @@ class DisputeService:
|
||||
Returns:
|
||||
Tuple of (result_status, votes_valid, votes_invalid)
|
||||
"""
|
||||
# Get dispute with votes and assignment
|
||||
from app.models import BonusAssignment, BonusAssignmentStatus
|
||||
|
||||
# Get dispute with votes, assignment and bonus_assignment
|
||||
result = await db.execute(
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.votes),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
||||
)
|
||||
.where(Dispute.id == dispute_id)
|
||||
)
|
||||
@@ -46,9 +49,12 @@ class DisputeService:
|
||||
|
||||
# Determine result: tie goes to the accused (valid)
|
||||
if votes_invalid > votes_valid:
|
||||
# Proof is invalid - mark assignment as RETURNED
|
||||
# Proof is invalid
|
||||
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||
await self._handle_invalid_proof(db, dispute)
|
||||
if dispute.bonus_assignment_id:
|
||||
await self._handle_invalid_bonus_proof(db, dispute)
|
||||
else:
|
||||
await self._handle_invalid_proof(db, dispute)
|
||||
else:
|
||||
# Proof is valid (or tie)
|
||||
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||
@@ -60,7 +66,11 @@ class DisputeService:
|
||||
await db.commit()
|
||||
|
||||
# Send Telegram notification about dispute resolution
|
||||
await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value)
|
||||
is_invalid = result_status == DisputeStatus.RESOLVED_INVALID.value
|
||||
if dispute.bonus_assignment_id:
|
||||
await self._notify_bonus_dispute_resolved(db, dispute, is_invalid)
|
||||
else:
|
||||
await self._notify_dispute_resolved(db, dispute, is_invalid)
|
||||
|
||||
return result_status, votes_valid, votes_invalid
|
||||
|
||||
@@ -72,12 +82,13 @@ class DisputeService:
|
||||
) -> None:
|
||||
"""Send notification about dispute resolution to the assignment owner."""
|
||||
try:
|
||||
# Get assignment with challenge and marathon info
|
||||
# Get assignment with challenge/game and marathon info
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.participant),
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game), # For playthrough
|
||||
)
|
||||
.where(Assignment.id == dispute.assignment_id)
|
||||
)
|
||||
@@ -86,12 +97,19 @@ class DisputeService:
|
||||
return
|
||||
|
||||
participant = assignment.participant
|
||||
challenge = assignment.challenge
|
||||
game = challenge.game if challenge else None
|
||||
|
||||
# Get title and marathon_id based on assignment type
|
||||
if assignment.is_playthrough:
|
||||
title = f"Прохождение: {assignment.game.title}"
|
||||
marathon_id = assignment.game.marathon_id
|
||||
else:
|
||||
challenge = assignment.challenge
|
||||
title = challenge.title if challenge else "Unknown"
|
||||
marathon_id = challenge.game.marathon_id if challenge and challenge.game else 0
|
||||
|
||||
# Get marathon
|
||||
result = await db.execute(
|
||||
select(Marathon).where(Marathon.id == game.marathon_id if game else 0)
|
||||
select(Marathon).where(Marathon.id == marathon_id)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
|
||||
@@ -100,12 +118,86 @@ class DisputeService:
|
||||
db,
|
||||
user_id=participant.user_id,
|
||||
marathon_title=marathon.title,
|
||||
challenge_title=challenge.title if challenge else "Unknown",
|
||||
challenge_title=title,
|
||||
is_valid=is_valid
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[DisputeService] Failed to send notification: {e}")
|
||||
|
||||
async def _notify_bonus_dispute_resolved(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
dispute: Dispute,
|
||||
is_invalid: bool
|
||||
) -> None:
|
||||
"""Send notification about bonus dispute resolution to the assignment owner."""
|
||||
try:
|
||||
bonus_assignment = dispute.bonus_assignment
|
||||
main_assignment = bonus_assignment.main_assignment
|
||||
participant = main_assignment.participant
|
||||
|
||||
# Get marathon info
|
||||
result = await db.execute(
|
||||
select(Game).where(Game.id == main_assignment.game_id)
|
||||
)
|
||||
game = result.scalar_one_or_none()
|
||||
if not game:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(Marathon).where(Marathon.id == game.marathon_id)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
|
||||
# Get challenge title
|
||||
result = await db.execute(
|
||||
select(Challenge).where(Challenge.id == bonus_assignment.challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one_or_none()
|
||||
title = f"Бонус: {challenge.title}" if challenge else "Бонусный челлендж"
|
||||
|
||||
if marathon and participant:
|
||||
await telegram_notifier.notify_dispute_resolved(
|
||||
db,
|
||||
user_id=participant.user_id,
|
||||
marathon_title=marathon.title,
|
||||
challenge_title=title,
|
||||
is_valid=not is_invalid
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[DisputeService] Failed to send bonus dispute notification: {e}")
|
||||
|
||||
async def _handle_invalid_bonus_proof(self, db: AsyncSession, dispute: Dispute) -> None:
|
||||
"""
|
||||
Handle the case when bonus proof is determined to be invalid.
|
||||
|
||||
- Reset bonus assignment to PENDING
|
||||
- If main playthrough was already completed, subtract bonus points from participant
|
||||
"""
|
||||
from app.models import BonusAssignment, BonusAssignmentStatus, AssignmentStatus
|
||||
|
||||
bonus_assignment = dispute.bonus_assignment
|
||||
main_assignment = bonus_assignment.main_assignment
|
||||
participant = main_assignment.participant
|
||||
|
||||
# If main playthrough was already completed, we need to subtract the bonus points
|
||||
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
||||
points_to_subtract = bonus_assignment.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
# Also reduce the points_earned on the main assignment
|
||||
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
|
||||
print(f"[DisputeService] Subtracted {points_to_subtract} points from participant {participant.id}")
|
||||
|
||||
# Reset bonus assignment
|
||||
bonus_assignment.status = BonusAssignmentStatus.PENDING.value
|
||||
bonus_assignment.proof_path = None
|
||||
bonus_assignment.proof_url = None
|
||||
bonus_assignment.proof_comment = None
|
||||
bonus_assignment.points_earned = 0
|
||||
bonus_assignment.completed_at = None
|
||||
|
||||
print(f"[DisputeService] Bonus assignment {bonus_assignment.id} reset to PENDING due to invalid dispute")
|
||||
|
||||
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
|
||||
"""
|
||||
Handle the case when proof is determined to be invalid.
|
||||
@@ -113,7 +205,10 @@ class DisputeService:
|
||||
- Mark assignment as RETURNED
|
||||
- Subtract points from participant
|
||||
- Reset streak if it was affected
|
||||
- For playthrough: also reset bonus assignments
|
||||
"""
|
||||
from app.models import BonusAssignment, BonusAssignmentStatus
|
||||
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
|
||||
@@ -121,22 +216,45 @@ class DisputeService:
|
||||
points_to_subtract = assignment.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
|
||||
# Reset streak - the completion was invalid so streak should be broken
|
||||
participant.current_streak = 0
|
||||
|
||||
# Reset assignment
|
||||
assignment.status = AssignmentStatus.RETURNED.value
|
||||
assignment.points_earned = 0
|
||||
# Keep proof data so it can be reviewed
|
||||
|
||||
# For playthrough: reset all bonus assignments
|
||||
if assignment.is_playthrough:
|
||||
result = await db.execute(
|
||||
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
bonus_assignments = result.scalars().all()
|
||||
for ba in bonus_assignments:
|
||||
ba.status = BonusAssignmentStatus.PENDING.value
|
||||
ba.proof_path = None
|
||||
ba.proof_url = None
|
||||
ba.proof_comment = None
|
||||
ba.points_earned = 0
|
||||
ba.completed_at = None
|
||||
print(f"[DisputeService] Reset {len(bonus_assignments)} bonus assignments for playthrough {assignment.id}")
|
||||
|
||||
print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, "
|
||||
f"subtracted {points_to_subtract} points from participant {participant.id}")
|
||||
|
||||
async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
|
||||
"""Get all open disputes older than specified hours"""
|
||||
"""Get all open disputes (both regular and bonus) older than specified hours"""
|
||||
from datetime import timedelta
|
||||
from app.models import BonusAssignment
|
||||
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.assignment),
|
||||
selectinload(Dispute.bonus_assignment),
|
||||
)
|
||||
.where(
|
||||
Dispute.status == DisputeStatus.OPEN.value,
|
||||
Dispute.created_at < cutoff_time,
|
||||
|
||||
@@ -47,6 +47,8 @@ class EventService:
|
||||
created_by_id: int | None = None,
|
||||
duration_minutes: int | None = None,
|
||||
challenge_id: int | None = None,
|
||||
game_id: int | None = None,
|
||||
is_playthrough: bool = False,
|
||||
) -> Event:
|
||||
"""Start a new event"""
|
||||
# Check no active event
|
||||
@@ -63,8 +65,12 @@ class EventService:
|
||||
|
||||
# Build event data
|
||||
data = {}
|
||||
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
|
||||
data["challenge_id"] = challenge_id
|
||||
if event_type == EventType.COMMON_ENEMY.value and (challenge_id or game_id):
|
||||
if is_playthrough and game_id:
|
||||
data["game_id"] = game_id
|
||||
data["is_playthrough"] = True
|
||||
else:
|
||||
data["challenge_id"] = challenge_id
|
||||
data["completions"] = [] # Track who completed and when
|
||||
|
||||
event = Event(
|
||||
@@ -79,9 +85,11 @@ class EventService:
|
||||
db.add(event)
|
||||
await db.flush() # Get event.id before committing
|
||||
|
||||
# Auto-assign challenge to all participants for Common Enemy
|
||||
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
|
||||
await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id)
|
||||
# Auto-assign challenge/playthrough to all participants for Common Enemy
|
||||
if event_type == EventType.COMMON_ENEMY.value and (challenge_id or game_id):
|
||||
await self._assign_common_enemy_to_all(
|
||||
db, marathon_id, event.id, challenge_id, game_id, is_playthrough
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
@@ -105,7 +113,9 @@ class EventService:
|
||||
db: AsyncSession,
|
||||
marathon_id: int,
|
||||
event_id: int,
|
||||
challenge_id: int,
|
||||
challenge_id: int | None,
|
||||
game_id: int | None = None,
|
||||
is_playthrough: bool = False,
|
||||
) -> None:
|
||||
"""Create event assignments for all participants in the marathon"""
|
||||
# Get all participants
|
||||
@@ -118,7 +128,9 @@ class EventService:
|
||||
for participant in participants:
|
||||
assignment = Assignment(
|
||||
participant_id=participant.id,
|
||||
challenge_id=challenge_id,
|
||||
challenge_id=challenge_id if not is_playthrough else None,
|
||||
game_id=game_id if is_playthrough else None,
|
||||
is_playthrough=is_playthrough,
|
||||
status=AssignmentStatus.ACTIVE.value,
|
||||
event_type=EventType.COMMON_ENEMY.value,
|
||||
is_event_assignment=True,
|
||||
@@ -290,6 +302,30 @@ class EventService:
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_common_enemy_game(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event: Event
|
||||
):
|
||||
"""Get the playthrough game for common enemy event (if it's a playthrough)"""
|
||||
from app.models import Game
|
||||
|
||||
if event.type != EventType.COMMON_ENEMY.value:
|
||||
return None
|
||||
|
||||
data = event.data or {}
|
||||
if not data.get("is_playthrough"):
|
||||
return None
|
||||
|
||||
game_id = data.get("game_id")
|
||||
if not game_id:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(Game).where(Game.id == game_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def get_time_remaining(self, event: Event | None) -> int | None:
|
||||
"""Get remaining time in seconds for an event"""
|
||||
if not event or not event.end_time:
|
||||
|
||||
@@ -124,12 +124,6 @@ points: easy=20-40, medium=45-75, hard=90-150
|
||||
points = ch.get("points", 30)
|
||||
if not isinstance(points, int) or points < 1:
|
||||
points = 30
|
||||
if difficulty == "easy":
|
||||
points = max(20, min(40, points))
|
||||
elif difficulty == "medium":
|
||||
points = max(45, min(75, points))
|
||||
elif difficulty == "hard":
|
||||
points = max(90, min(150, points))
|
||||
|
||||
return ChallengeGenerated(
|
||||
title=ch.get("title", "Unnamed Challenge")[:100],
|
||||
|
||||
@@ -66,7 +66,7 @@ class PointsService:
|
||||
def calculate_drop_penalty(
|
||||
self,
|
||||
consecutive_drops: int,
|
||||
challenge_points: int,
|
||||
challenge_points: int | None,
|
||||
event: Event | None = None
|
||||
) -> int:
|
||||
"""
|
||||
@@ -80,6 +80,10 @@ class PointsService:
|
||||
Returns:
|
||||
Penalty points to subtract
|
||||
"""
|
||||
# No penalty if no points defined
|
||||
if challenge_points is None:
|
||||
return 0
|
||||
|
||||
# Double risk event = free drops
|
||||
if event and event.type == EventType.DOUBLE_RISK.value:
|
||||
return 0
|
||||
|
||||
297
backend/app/services/shop.py
Normal file
297
backend/app/services/shop.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Shop Service - handles shop items, purchases, and inventory management
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import User, ShopItem, UserInventory, ShopItemType
|
||||
from app.services.coins import coins_service
|
||||
|
||||
|
||||
class ShopService:
|
||||
"""Service for shop operations"""
|
||||
|
||||
async def get_available_items(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
item_type: str | None = None,
|
||||
include_unavailable: bool = False,
|
||||
) -> list[ShopItem]:
|
||||
"""
|
||||
Get list of shop items.
|
||||
|
||||
Args:
|
||||
item_type: Filter by item type (frame, title, etc.)
|
||||
include_unavailable: Include inactive/out of stock items
|
||||
"""
|
||||
query = select(ShopItem)
|
||||
|
||||
if item_type:
|
||||
query = query.where(ShopItem.item_type == item_type)
|
||||
|
||||
if not include_unavailable:
|
||||
now = datetime.utcnow()
|
||||
query = query.where(
|
||||
ShopItem.is_active == True,
|
||||
(ShopItem.available_from.is_(None)) | (ShopItem.available_from <= now),
|
||||
(ShopItem.available_until.is_(None)) | (ShopItem.available_until >= now),
|
||||
(ShopItem.stock_remaining.is_(None)) | (ShopItem.stock_remaining > 0),
|
||||
)
|
||||
|
||||
query = query.order_by(ShopItem.price.asc())
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_item_by_id(self, db: AsyncSession, item_id: int) -> ShopItem | None:
|
||||
"""Get shop item by ID"""
|
||||
result = await db.execute(select(ShopItem).where(ShopItem.id == item_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_item_by_code(self, db: AsyncSession, code: str) -> ShopItem | None:
|
||||
"""Get shop item by code"""
|
||||
result = await db.execute(select(ShopItem).where(ShopItem.code == code))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def purchase_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
item_id: int,
|
||||
quantity: int = 1,
|
||||
) -> tuple[UserInventory, int]:
|
||||
"""
|
||||
Purchase an item from the shop.
|
||||
|
||||
Args:
|
||||
user: The purchasing user
|
||||
item_id: ID of item to purchase
|
||||
quantity: Number to purchase (only for consumables)
|
||||
|
||||
Returns:
|
||||
Tuple of (inventory item, total cost)
|
||||
|
||||
Raises:
|
||||
HTTPException: If item not found, not available, or insufficient funds
|
||||
"""
|
||||
# Get item
|
||||
item = await self.get_item_by_id(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
# Check availability
|
||||
if not item.is_available:
|
||||
raise HTTPException(status_code=400, detail="Item is not available")
|
||||
|
||||
# For non-consumables, quantity is always 1
|
||||
if item.item_type != ShopItemType.CONSUMABLE.value:
|
||||
quantity = 1
|
||||
|
||||
# Check if already owned
|
||||
existing = await db.execute(
|
||||
select(UserInventory).where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.item_id == item.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="You already own this item")
|
||||
|
||||
# Check stock
|
||||
if item.stock_remaining is not None and item.stock_remaining < quantity:
|
||||
raise HTTPException(status_code=400, detail="Not enough stock available")
|
||||
|
||||
# Calculate total cost
|
||||
total_cost = item.price * quantity
|
||||
|
||||
# Check balance
|
||||
if user.coins_balance < total_cost:
|
||||
raise HTTPException(status_code=400, detail="Not enough coins")
|
||||
|
||||
# Deduct coins
|
||||
success = await coins_service.spend_coins(
|
||||
db, user, total_cost,
|
||||
f"Purchase: {item.name} x{quantity}",
|
||||
"shop_item", item.id,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Payment failed")
|
||||
|
||||
# Add to inventory
|
||||
if item.item_type == ShopItemType.CONSUMABLE.value:
|
||||
# For consumables, increase quantity if already exists
|
||||
existing_result = await db.execute(
|
||||
select(UserInventory).where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.item_id == item.id,
|
||||
)
|
||||
)
|
||||
inv_item = existing_result.scalar_one_or_none()
|
||||
|
||||
if inv_item:
|
||||
inv_item.quantity += quantity
|
||||
else:
|
||||
inv_item = UserInventory(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
quantity=quantity,
|
||||
)
|
||||
db.add(inv_item)
|
||||
else:
|
||||
# For cosmetics, create new inventory entry
|
||||
inv_item = UserInventory(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
quantity=1,
|
||||
)
|
||||
db.add(inv_item)
|
||||
|
||||
# Decrease stock if limited
|
||||
if item.stock_remaining is not None:
|
||||
item.stock_remaining -= quantity
|
||||
|
||||
await db.flush()
|
||||
return inv_item, total_cost
|
||||
|
||||
async def get_user_inventory(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
item_type: str | None = None,
|
||||
) -> list[UserInventory]:
|
||||
"""Get user's inventory"""
|
||||
query = (
|
||||
select(UserInventory)
|
||||
.options(selectinload(UserInventory.item))
|
||||
.where(UserInventory.user_id == user_id)
|
||||
)
|
||||
|
||||
if item_type:
|
||||
query = query.join(ShopItem).where(ShopItem.item_type == item_type)
|
||||
|
||||
# Exclude empty consumables
|
||||
query = query.where(UserInventory.quantity > 0)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_inventory_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
inventory_id: int,
|
||||
) -> UserInventory | None:
|
||||
"""Get specific inventory item"""
|
||||
result = await db.execute(
|
||||
select(UserInventory)
|
||||
.options(selectinload(UserInventory.item))
|
||||
.where(
|
||||
UserInventory.id == inventory_id,
|
||||
UserInventory.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def equip_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
inventory_id: int,
|
||||
) -> ShopItem:
|
||||
"""
|
||||
Equip a cosmetic item from inventory.
|
||||
|
||||
Returns: The equipped item
|
||||
|
||||
Raises:
|
||||
HTTPException: If item not found or is a consumable
|
||||
"""
|
||||
# Get inventory item
|
||||
inv_item = await self.get_inventory_item(db, user.id, inventory_id)
|
||||
if not inv_item:
|
||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||
|
||||
item = inv_item.item
|
||||
|
||||
if item.item_type == ShopItemType.CONSUMABLE.value:
|
||||
raise HTTPException(status_code=400, detail="Cannot equip consumables")
|
||||
|
||||
# Unequip current item of same type
|
||||
await db.execute(
|
||||
update(UserInventory)
|
||||
.where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.equipped == True,
|
||||
UserInventory.item_id.in_(
|
||||
select(ShopItem.id).where(ShopItem.item_type == item.item_type)
|
||||
),
|
||||
)
|
||||
.values(equipped=False)
|
||||
)
|
||||
|
||||
# Equip new item
|
||||
inv_item.equipped = True
|
||||
|
||||
# Update user's equipped_*_id
|
||||
if item.item_type == ShopItemType.FRAME.value:
|
||||
user.equipped_frame_id = item.id
|
||||
elif item.item_type == ShopItemType.TITLE.value:
|
||||
user.equipped_title_id = item.id
|
||||
elif item.item_type == ShopItemType.NAME_COLOR.value:
|
||||
user.equipped_name_color_id = item.id
|
||||
elif item.item_type == ShopItemType.BACKGROUND.value:
|
||||
user.equipped_background_id = item.id
|
||||
|
||||
return item
|
||||
|
||||
async def unequip_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
item_type: str,
|
||||
) -> None:
|
||||
"""Unequip item of specified type"""
|
||||
# Unequip from inventory
|
||||
await db.execute(
|
||||
update(UserInventory)
|
||||
.where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.equipped == True,
|
||||
UserInventory.item_id.in_(
|
||||
select(ShopItem.id).where(ShopItem.item_type == item_type)
|
||||
),
|
||||
)
|
||||
.values(equipped=False)
|
||||
)
|
||||
|
||||
# Clear user's equipped_*_id
|
||||
if item_type == ShopItemType.FRAME.value:
|
||||
user.equipped_frame_id = None
|
||||
elif item_type == ShopItemType.TITLE.value:
|
||||
user.equipped_title_id = None
|
||||
elif item_type == ShopItemType.NAME_COLOR.value:
|
||||
user.equipped_name_color_id = None
|
||||
elif item_type == ShopItemType.BACKGROUND.value:
|
||||
user.equipped_background_id = None
|
||||
|
||||
async def check_user_owns_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
item_id: int,
|
||||
) -> bool:
|
||||
"""Check if user owns an item"""
|
||||
result = await db.execute(
|
||||
select(UserInventory).where(
|
||||
UserInventory.user_id == user_id,
|
||||
UserInventory.item_id == item_id,
|
||||
UserInventory.quantity > 0,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
shop_service = ShopService()
|
||||
@@ -15,7 +15,7 @@ from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
StorageFolder = Literal["avatars", "covers", "proofs"]
|
||||
StorageFolder = Literal["avatars", "covers", "proofs", "bonus_proofs"]
|
||||
|
||||
|
||||
class StorageService:
|
||||
|
||||
@@ -54,6 +54,209 @@ class TelegramNotifier:
|
||||
logger.error(f"Error sending Telegram message: {e}")
|
||||
return False
|
||||
|
||||
async def send_photo(
|
||||
self,
|
||||
chat_id: int,
|
||||
photo: bytes,
|
||||
caption: str | None = None,
|
||||
parse_mode: str = "HTML",
|
||||
filename: str = "photo.jpg",
|
||||
content_type: str = "image/jpeg"
|
||||
) -> bool:
|
||||
"""Send a photo to a Telegram chat."""
|
||||
if not self.bot_token:
|
||||
logger.warning("Telegram bot token not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
timeout = httpx.Timeout(connect=30.0, read=60.0, write=120.0, pool=30.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
data = {"chat_id": str(chat_id)}
|
||||
if caption:
|
||||
data["caption"] = caption
|
||||
data["parse_mode"] = parse_mode
|
||||
|
||||
files = {"photo": (filename, photo, content_type)}
|
||||
|
||||
response = await client.post(
|
||||
f"{self.api_url}/sendPhoto",
|
||||
data=data,
|
||||
files=files,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to send photo to {chat_id}: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending Telegram photo to {chat_id}: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: int,
|
||||
video: bytes,
|
||||
caption: str | None = None,
|
||||
parse_mode: str = "HTML",
|
||||
filename: str = "video.mp4",
|
||||
content_type: str = "video/mp4"
|
||||
) -> bool:
|
||||
"""Send a video to a Telegram chat."""
|
||||
if not self.bot_token:
|
||||
logger.warning("Telegram bot token not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
timeout = httpx.Timeout(connect=30.0, read=120.0, write=300.0, pool=30.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
data = {"chat_id": str(chat_id)}
|
||||
if caption:
|
||||
data["caption"] = caption
|
||||
data["parse_mode"] = parse_mode
|
||||
|
||||
files = {"video": (filename, video, content_type)}
|
||||
|
||||
response = await client.post(
|
||||
f"{self.api_url}/sendVideo",
|
||||
data=data,
|
||||
files=files,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to send video to {chat_id}: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending Telegram video to {chat_id}: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
async def send_media_group(
|
||||
self,
|
||||
chat_id: int,
|
||||
media_items: list[dict],
|
||||
caption: str | None = None,
|
||||
parse_mode: str = "HTML"
|
||||
) -> bool:
|
||||
"""
|
||||
Send a media group (multiple photos/videos) to a Telegram chat.
|
||||
|
||||
media_items: list of dicts with keys:
|
||||
- type: "photo" or "video"
|
||||
- data: bytes
|
||||
- filename: str
|
||||
- content_type: str
|
||||
"""
|
||||
if not self.bot_token:
|
||||
logger.warning("Telegram bot token not configured")
|
||||
return False
|
||||
|
||||
if not media_items:
|
||||
return False
|
||||
|
||||
try:
|
||||
import json
|
||||
# Use longer timeouts for file uploads
|
||||
timeout = httpx.Timeout(
|
||||
connect=30.0,
|
||||
read=120.0,
|
||||
write=300.0, # 5 minutes for uploading files
|
||||
pool=30.0
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
# Build media array and files dict
|
||||
media_array = []
|
||||
files_dict = {}
|
||||
|
||||
for i, item in enumerate(media_items):
|
||||
attach_name = f"media{i}"
|
||||
media_obj = {
|
||||
"type": item["type"],
|
||||
"media": f"attach://{attach_name}"
|
||||
}
|
||||
# Only first item gets the caption
|
||||
if i == 0 and caption:
|
||||
media_obj["caption"] = caption
|
||||
media_obj["parse_mode"] = parse_mode
|
||||
|
||||
media_array.append(media_obj)
|
||||
files_dict[attach_name] = (
|
||||
item.get("filename", f"file{i}"),
|
||||
item["data"],
|
||||
item.get("content_type", "application/octet-stream")
|
||||
)
|
||||
|
||||
data = {
|
||||
"chat_id": str(chat_id),
|
||||
"media": json.dumps(media_array)
|
||||
}
|
||||
|
||||
logger.info(f"Sending media group to {chat_id}: {len(media_items)} files")
|
||||
response = await client.post(
|
||||
f"{self.api_url}/sendMediaGroup",
|
||||
data=data,
|
||||
files=files_dict,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Successfully sent media group to {chat_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to send media group to {chat_id}: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending Telegram media group to {chat_id}: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
async def send_media_message(
|
||||
self,
|
||||
chat_id: int,
|
||||
text: str | None = None,
|
||||
media_type: str | None = None,
|
||||
media_data: bytes | None = None,
|
||||
media_items: list[dict] | None = None,
|
||||
parse_mode: str = "HTML"
|
||||
) -> bool:
|
||||
"""
|
||||
Send a message with optional media.
|
||||
|
||||
For single media: use media_type and media_data
|
||||
For multiple media: use media_items list with dicts containing:
|
||||
- type: "photo" or "video"
|
||||
- data: bytes
|
||||
- filename: str (optional)
|
||||
- content_type: str (optional)
|
||||
"""
|
||||
# Multiple media - use media group
|
||||
if media_items and len(media_items) > 1:
|
||||
return await self.send_media_group(chat_id, media_items, text, parse_mode)
|
||||
|
||||
# Single media from media_items
|
||||
if media_items and len(media_items) == 1:
|
||||
item = media_items[0]
|
||||
if item["type"] == "photo":
|
||||
return await self.send_photo(
|
||||
chat_id, item["data"], text, parse_mode,
|
||||
item.get("filename", "photo.jpg"),
|
||||
item.get("content_type", "image/jpeg")
|
||||
)
|
||||
elif item["type"] == "video":
|
||||
return await self.send_video(
|
||||
chat_id, item["data"], text, parse_mode,
|
||||
item.get("filename", "video.mp4"),
|
||||
item.get("content_type", "video/mp4")
|
||||
)
|
||||
|
||||
# Legacy single media support
|
||||
if media_data and media_type:
|
||||
if media_type == "photo":
|
||||
return await self.send_photo(chat_id, media_data, text, parse_mode)
|
||||
elif media_type == "video":
|
||||
return await self.send_video(chat_id, media_data, text, parse_mode)
|
||||
|
||||
if text:
|
||||
return await self.send_message(chat_id, text, parse_mode)
|
||||
|
||||
return False
|
||||
|
||||
async def notify_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -83,9 +286,15 @@ class TelegramNotifier:
|
||||
db: AsyncSession,
|
||||
marathon_id: int,
|
||||
message: str,
|
||||
exclude_user_id: int | None = None
|
||||
exclude_user_id: int | None = None,
|
||||
check_setting: str | None = None
|
||||
) -> int:
|
||||
"""Send notification to all marathon participants with linked Telegram."""
|
||||
"""Send notification to all marathon participants with linked Telegram.
|
||||
|
||||
Args:
|
||||
check_setting: If provided, only send to users with this setting enabled.
|
||||
Options: 'notify_events', 'notify_disputes', 'notify_moderation'
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.join(Participant, Participant.user_id == User.id)
|
||||
@@ -100,6 +309,10 @@ class TelegramNotifier:
|
||||
for user in users:
|
||||
if exclude_user_id and user.id == exclude_user_id:
|
||||
continue
|
||||
# Check notification setting if specified
|
||||
if check_setting and not getattr(user, check_setting, True):
|
||||
logger.info(f"[Notify] Skipping user {user.nickname} - {check_setting} is disabled")
|
||||
continue
|
||||
if await self.send_message(user.telegram_id, message):
|
||||
sent_count += 1
|
||||
|
||||
@@ -113,7 +326,7 @@ class TelegramNotifier:
|
||||
event_type: str,
|
||||
marathon_title: str
|
||||
) -> int:
|
||||
"""Notify participants about event start."""
|
||||
"""Notify participants about event start (respects notify_events setting)."""
|
||||
event_messages = {
|
||||
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
|
||||
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
|
||||
@@ -128,7 +341,9 @@ class TelegramNotifier:
|
||||
f"📌 Новое событие в «{marathon_title}»!"
|
||||
)
|
||||
|
||||
return await self.notify_marathon_participants(db, marathon_id, message)
|
||||
return await self.notify_marathon_participants(
|
||||
db, marathon_id, message, check_setting='notify_events'
|
||||
)
|
||||
|
||||
async def notify_event_end(
|
||||
self,
|
||||
@@ -137,7 +352,7 @@ class TelegramNotifier:
|
||||
event_type: str,
|
||||
marathon_title: str
|
||||
) -> int:
|
||||
"""Notify participants about event end."""
|
||||
"""Notify participants about event end (respects notify_events setting)."""
|
||||
event_names = {
|
||||
"golden_hour": "Golden Hour",
|
||||
"jackpot": "Jackpot",
|
||||
@@ -150,7 +365,9 @@ class TelegramNotifier:
|
||||
event_name = event_names.get(event_type, "Событие")
|
||||
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
|
||||
|
||||
return await self.notify_marathon_participants(db, marathon_id, message)
|
||||
return await self.notify_marathon_participants(
|
||||
db, marathon_id, message, check_setting='notify_events'
|
||||
)
|
||||
|
||||
async def notify_marathon_start(
|
||||
self,
|
||||
@@ -186,7 +403,14 @@ class TelegramNotifier:
|
||||
challenge_title: str,
|
||||
assignment_id: int
|
||||
) -> bool:
|
||||
"""Notify user about dispute raised on their assignment."""
|
||||
"""Notify user about dispute raised on their assignment (respects notify_disputes setting)."""
|
||||
# Check user's notification settings
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user and not user.notify_disputes:
|
||||
logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled")
|
||||
return False
|
||||
|
||||
logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}")
|
||||
|
||||
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
|
||||
@@ -227,7 +451,14 @@ class TelegramNotifier:
|
||||
challenge_title: str,
|
||||
is_valid: bool
|
||||
) -> bool:
|
||||
"""Notify user about dispute resolution."""
|
||||
"""Notify user about dispute resolution (respects notify_disputes setting)."""
|
||||
# Check user's notification settings
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user and not user.notify_disputes:
|
||||
logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled")
|
||||
return False
|
||||
|
||||
if is_valid:
|
||||
message = (
|
||||
f"❌ <b>Спор признан обоснованным</b>\n\n"
|
||||
@@ -251,7 +482,14 @@ class TelegramNotifier:
|
||||
marathon_title: str,
|
||||
game_title: str
|
||||
) -> bool:
|
||||
"""Notify user that their proposed game was approved."""
|
||||
"""Notify user that their proposed game was approved (respects notify_moderation setting)."""
|
||||
# Check user's notification settings
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user and not user.notify_moderation:
|
||||
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||
return False
|
||||
|
||||
message = (
|
||||
f"✅ <b>Твоя игра одобрена!</b>\n\n"
|
||||
f"Марафон: {marathon_title}\n"
|
||||
@@ -267,7 +505,14 @@ class TelegramNotifier:
|
||||
marathon_title: str,
|
||||
game_title: str
|
||||
) -> bool:
|
||||
"""Notify user that their proposed game was rejected."""
|
||||
"""Notify user that their proposed game was rejected (respects notify_moderation setting)."""
|
||||
# Check user's notification settings
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user and not user.notify_moderation:
|
||||
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||
return False
|
||||
|
||||
message = (
|
||||
f"❌ <b>Твоя игра отклонена</b>\n\n"
|
||||
f"Марафон: {marathon_title}\n"
|
||||
@@ -284,7 +529,14 @@ class TelegramNotifier:
|
||||
game_title: str,
|
||||
challenge_title: str
|
||||
) -> bool:
|
||||
"""Notify user that their proposed challenge was approved."""
|
||||
"""Notify user that their proposed challenge was approved (respects notify_moderation setting)."""
|
||||
# Check user's notification settings
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user and not user.notify_moderation:
|
||||
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||
return False
|
||||
|
||||
message = (
|
||||
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
|
||||
f"Марафон: {marathon_title}\n"
|
||||
@@ -302,7 +554,14 @@ class TelegramNotifier:
|
||||
game_title: str,
|
||||
challenge_title: str
|
||||
) -> bool:
|
||||
"""Notify user that their proposed challenge was rejected."""
|
||||
"""Notify user that their proposed challenge was rejected (respects notify_moderation setting)."""
|
||||
# Check user's notification settings
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user and not user.notify_moderation:
|
||||
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||
return False
|
||||
|
||||
message = (
|
||||
f"❌ <b>Твой челлендж отклонён</b>\n\n"
|
||||
f"Марафон: {marathon_title}\n"
|
||||
@@ -312,6 +571,94 @@ class TelegramNotifier:
|
||||
)
|
||||
return await self.notify_user(db, user_id, message)
|
||||
|
||||
async def notify_admin_disputes_pending(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
count: int
|
||||
) -> bool:
|
||||
"""Notify admin about disputes waiting for decision."""
|
||||
if not settings.TELEGRAM_ADMIN_ID:
|
||||
logger.warning("[Notify] No TELEGRAM_ADMIN_ID configured")
|
||||
return False
|
||||
|
||||
admin_url = f"{settings.FRONTEND_URL}/admin/disputes"
|
||||
use_inline_button = admin_url.startswith("https://")
|
||||
|
||||
if use_inline_button:
|
||||
message = (
|
||||
f"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\n\n"
|
||||
f"Голосование завершено, требуется ваше решение."
|
||||
)
|
||||
reply_markup = {
|
||||
"inline_keyboard": [[
|
||||
{"text": "Открыть оспаривания", "url": admin_url}
|
||||
]]
|
||||
}
|
||||
else:
|
||||
message = (
|
||||
f"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\n\n"
|
||||
f"Голосование завершено, требуется ваше решение.\n\n"
|
||||
f"🔗 {admin_url}"
|
||||
)
|
||||
reply_markup = None
|
||||
|
||||
return await self.send_message(
|
||||
int(settings.TELEGRAM_ADMIN_ID),
|
||||
message,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
|
||||
async def notify_assignment_skipped_by_moderator(
|
||||
self,
|
||||
db,
|
||||
user,
|
||||
marathon_title: str,
|
||||
game_title: str,
|
||||
exiled: bool,
|
||||
reason: str | None,
|
||||
moderator_nickname: str,
|
||||
) -> bool:
|
||||
"""Notify participant that their assignment was skipped by organizer"""
|
||||
if not user.telegram_id or not user.notify_moderation:
|
||||
return False
|
||||
|
||||
exile_text = "\n🚫 Игра исключена из вашего пула" if exiled else ""
|
||||
reason_text = f"\n📝 Причина: {reason}" if reason else ""
|
||||
|
||||
message = (
|
||||
f"⏭️ <b>Задание пропущено</b>\n\n"
|
||||
f"Марафон: {marathon_title}\n"
|
||||
f"Игра: {game_title}\n"
|
||||
f"Организатор: {moderator_nickname}"
|
||||
f"{exile_text}"
|
||||
f"{reason_text}\n\n"
|
||||
f"Вы можете крутить колесо заново."
|
||||
)
|
||||
|
||||
return await self.send_message(user.telegram_id, message)
|
||||
|
||||
async def notify_item_granted(
|
||||
self,
|
||||
user,
|
||||
item_name: str,
|
||||
quantity: int,
|
||||
reason: str,
|
||||
admin_nickname: str,
|
||||
) -> bool:
|
||||
"""Notify user that they received an item from admin"""
|
||||
if not user.telegram_id:
|
||||
return False
|
||||
|
||||
message = (
|
||||
f"🎁 <b>Вы получили подарок!</b>\n\n"
|
||||
f"Предмет: {item_name}\n"
|
||||
f"Количество: {quantity}\n"
|
||||
f"От: {admin_nickname}\n"
|
||||
f"Причина: {reason}"
|
||||
)
|
||||
|
||||
return await self.send_message(user.telegram_id, message)
|
||||
|
||||
|
||||
# Global instance
|
||||
telegram_notifier = TelegramNotifier()
|
||||
|
||||
@@ -3,7 +3,7 @@ from aiogram.filters import Command
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
|
||||
from keyboards.main_menu import get_main_menu
|
||||
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard
|
||||
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard, get_settings_keyboard
|
||||
from services.api_client import api_client
|
||||
|
||||
router = Router()
|
||||
@@ -197,15 +197,66 @@ async def cmd_settings(message: Message):
|
||||
)
|
||||
return
|
||||
|
||||
# Get current notification settings
|
||||
settings = await api_client.get_notification_settings(message.from_user.id)
|
||||
if not settings:
|
||||
settings = {"notify_events": True, "notify_disputes": True, "notify_moderation": True}
|
||||
|
||||
await message.answer(
|
||||
"<b>⚙️ Настройки</b>\n\n"
|
||||
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
|
||||
"Сейчас ты получаешь все уведомления:\n"
|
||||
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
|
||||
"• 🚀 Старт/финиш марафонов\n"
|
||||
"• ⚠️ Споры по заданиям\n\n"
|
||||
"Команды:\n"
|
||||
"/unlink - Отвязать аккаунт\n"
|
||||
"/status - Проверить привязку",
|
||||
"<b>⚙️ Настройки уведомлений</b>\n\n"
|
||||
"Нажми на категорию, чтобы включить/выключить:\n\n"
|
||||
"✅ — уведомления включены\n"
|
||||
"❌ — уведомления выключены\n\n"
|
||||
"<i>Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.</i>",
|
||||
reply_markup=get_settings_keyboard(settings)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("toggle:"))
|
||||
async def toggle_notification(callback: CallbackQuery):
|
||||
"""Toggle notification setting."""
|
||||
setting_name = callback.data.split(":")[1]
|
||||
|
||||
# Get current settings
|
||||
current_settings = await api_client.get_notification_settings(callback.from_user.id)
|
||||
if not current_settings:
|
||||
await callback.answer("Не удалось загрузить настройки", show_alert=True)
|
||||
return
|
||||
|
||||
# Toggle the setting
|
||||
current_value = current_settings.get(setting_name, True)
|
||||
new_value = not current_value
|
||||
|
||||
# Update on backend
|
||||
result = await api_client.update_notification_settings(
|
||||
callback.from_user.id,
|
||||
{setting_name: new_value}
|
||||
)
|
||||
|
||||
if not result or result.get("error"):
|
||||
await callback.answer("Не удалось сохранить настройки", show_alert=True)
|
||||
return
|
||||
|
||||
# Update keyboard with new values
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=get_settings_keyboard(result)
|
||||
)
|
||||
|
||||
status = "включены" if new_value else "выключены"
|
||||
setting_names = {
|
||||
"notify_events": "События",
|
||||
"notify_disputes": "Споры",
|
||||
"notify_moderation": "Модерация"
|
||||
}
|
||||
await callback.answer(f"{setting_names.get(setting_name, setting_name)}: {status}")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "back_to_menu")
|
||||
async def back_to_menu(callback: CallbackQuery):
|
||||
"""Go back to main menu from settings."""
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
"Главное меню",
|
||||
reply_markup=get_main_menu()
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -40,3 +40,45 @@ def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_settings_keyboard(settings: dict) -> InlineKeyboardMarkup:
|
||||
"""Create keyboard for notification settings."""
|
||||
# Get current values with defaults
|
||||
notify_events = settings.get("notify_events", True)
|
||||
notify_disputes = settings.get("notify_disputes", True)
|
||||
notify_moderation = settings.get("notify_moderation", True)
|
||||
|
||||
# Status indicators
|
||||
events_status = "✅" if notify_events else "❌"
|
||||
disputes_status = "✅" if notify_disputes else "❌"
|
||||
moderation_status = "✅" if notify_moderation else "❌"
|
||||
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"{events_status} События (Golden Hour, Jackpot...)",
|
||||
callback_data="toggle:notify_events"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"{disputes_status} Споры",
|
||||
callback_data="toggle:notify_disputes"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"{moderation_status} Модерация (игры/челленджи)",
|
||||
callback_data="toggle:notify_moderation"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="◀️ Назад",
|
||||
callback_data="back_to_menu"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
@@ -124,6 +124,22 @@ class APIClient:
|
||||
"""Get user's overall statistics."""
|
||||
return await self._request("GET", f"/telegram/stats/{telegram_id}")
|
||||
|
||||
async def get_notification_settings(self, telegram_id: int) -> dict[str, Any] | None:
|
||||
"""Get user's notification settings."""
|
||||
return await self._request("GET", f"/telegram/notifications/{telegram_id}")
|
||||
|
||||
async def update_notification_settings(
|
||||
self,
|
||||
telegram_id: int,
|
||||
settings: dict[str, bool]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update user's notification settings."""
|
||||
return await self._request(
|
||||
"PATCH",
|
||||
f"/telegram/notifications/{telegram_id}",
|
||||
json=settings
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP session."""
|
||||
if self._session and not self._session.closed:
|
||||
|
||||
32
desktop/.gitignore
vendored
Normal file
32
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
release/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.claude/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Electron
|
||||
*.asar
|
||||
|
||||
# Lock files (optional - remove if you want to commit)
|
||||
package-lock.json
|
||||
6893
desktop/package-lock.json
generated
Normal file
6893
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
89
desktop/package.json
Normal file
89
desktop/package.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "game-marathon-tracker",
|
||||
"version": "1.0.1",
|
||||
"description": "Desktop app for tracking game time in Game Marathon",
|
||||
"main": "dist/main/main/index.js",
|
||||
"author": "Game Marathon",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"",
|
||||
"dev:main": "tsc -p tsconfig.main.json --watch",
|
||||
"dev:renderer": "vite",
|
||||
"dev:electron": "wait-on http://localhost:5173 && electron .",
|
||||
"build": "npm run build:main && npm run build:renderer",
|
||||
"build:main": "tsc -p tsconfig.main.json",
|
||||
"build:renderer": "vite build && node -e \"require('fs').copyFileSync('src/renderer/splash.html', 'dist/renderer/splash.html'); require('fs').copyFileSync('src/renderer/logo.jpg', 'dist/renderer/logo.jpg')\"",
|
||||
"start": "electron .",
|
||||
"pack": "electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder --win"
|
||||
},
|
||||
"dependencies": {
|
||||
"auto-launch": "^5.0.6",
|
||||
"axios": "^1.6.7",
|
||||
"clsx": "^2.1.0",
|
||||
"electron-store": "^8.1.0",
|
||||
"electron-updater": "^6.7.3",
|
||||
"lucide-react": "^0.323.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"vdf-parser": "^1.0.3",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/auto-launch": "^5.0.5",
|
||||
"@types/node": "^20.11.16",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^28.2.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.0",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.gamemarathon.tracker",
|
||||
"productName": "Game Marathon Tracker",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "resources",
|
||||
"to": "resources"
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"icon": "resources/icon.ico",
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"runAfterFinish": false,
|
||||
"artifactName": "Game-Marathon-Tracker-Setup-${version}.${ext}"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "Oronemu",
|
||||
"repo": "marathon_tracker"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
desktop/postcss.config.js
Normal file
6
desktop/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
2
desktop/resources/.gitkeep
Normal file
2
desktop/resources/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# Resources placeholder
|
||||
# Add icon.ico and tray-icon.png here
|
||||
BIN
desktop/resources/icon.ico
Normal file
BIN
desktop/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
BIN
desktop/resources/logo.jpg
Normal file
BIN
desktop/resources/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
145
desktop/src/main/apiClient.ts
Normal file
145
desktop/src/main/apiClient.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import https from 'https'
|
||||
import http from 'http'
|
||||
import { URL } from 'url'
|
||||
import type { StoreType } from './storeTypes'
|
||||
|
||||
interface ApiResponse<T = unknown> {
|
||||
data: T
|
||||
status: number
|
||||
}
|
||||
|
||||
interface ApiError {
|
||||
status: number
|
||||
message: string
|
||||
detail?: unknown
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private store: StoreType
|
||||
|
||||
constructor(store: StoreType) {
|
||||
this.store = store
|
||||
}
|
||||
|
||||
private getBaseUrl(): string {
|
||||
return this.store.get('settings').apiUrl || 'https://marathon.animeenigma.ru/api/v1'
|
||||
}
|
||||
|
||||
private getToken(): string | null {
|
||||
return this.store.get('token')
|
||||
}
|
||||
|
||||
async request<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||
endpoint: string,
|
||||
data?: unknown
|
||||
): Promise<ApiResponse<T>> {
|
||||
const baseUrl = this.getBaseUrl().replace(/\/$/, '') // Remove trailing slash
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
|
||||
const fullUrl = `${baseUrl}${cleanEndpoint}`
|
||||
const url = new URL(fullUrl)
|
||||
const token = this.getToken()
|
||||
|
||||
const isHttps = url.protocol === 'https:'
|
||||
const httpModule = isHttps ? https : http
|
||||
|
||||
const body = data ? JSON.stringify(data) : undefined
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...(body ? { 'Content-Length': Buffer.byteLength(body) } : {}),
|
||||
},
|
||||
}
|
||||
|
||||
console.log(`[ApiClient] ${method} ${url.href}`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpModule.request(options, (res) => {
|
||||
let responseData = ''
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
responseData += chunk
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
console.log(`[ApiClient] Response status: ${res.statusCode}`)
|
||||
console.log(`[ApiClient] Response body: ${responseData.substring(0, 500)}`)
|
||||
try {
|
||||
const parsed = responseData ? JSON.parse(responseData) : {}
|
||||
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({
|
||||
data: parsed as T,
|
||||
status: res.statusCode,
|
||||
})
|
||||
} else {
|
||||
const error: ApiError = {
|
||||
status: res.statusCode || 500,
|
||||
message: parsed.detail || 'Request failed',
|
||||
detail: parsed.detail,
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ApiClient] Parse error:', e)
|
||||
console.error('[ApiClient] Raw response:', responseData)
|
||||
reject({
|
||||
status: res.statusCode || 500,
|
||||
message: 'Failed to parse response',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', (e) => {
|
||||
console.error('[ApiClient] Request error:', e)
|
||||
reject({
|
||||
status: 0,
|
||||
message: e.message || 'Network error',
|
||||
})
|
||||
})
|
||||
|
||||
req.setTimeout(30000, () => {
|
||||
console.error('[ApiClient] Request timeout')
|
||||
req.destroy()
|
||||
reject({
|
||||
status: 0,
|
||||
message: 'Request timeout',
|
||||
})
|
||||
})
|
||||
|
||||
if (body) {
|
||||
req.write(body)
|
||||
}
|
||||
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('GET', endpoint)
|
||||
}
|
||||
|
||||
async post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('POST', endpoint, data)
|
||||
}
|
||||
|
||||
async put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('PUT', endpoint, data)
|
||||
}
|
||||
|
||||
async patch<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('PATCH', endpoint, data)
|
||||
}
|
||||
|
||||
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('DELETE', endpoint)
|
||||
}
|
||||
}
|
||||
42
desktop/src/main/autolaunch.ts
Normal file
42
desktop/src/main/autolaunch.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import AutoLaunch from 'auto-launch'
|
||||
import { app } from 'electron'
|
||||
|
||||
let autoLauncher: AutoLaunch | null = null
|
||||
|
||||
export async function setupAutoLaunch(enabled: boolean): Promise<void> {
|
||||
if (!autoLauncher) {
|
||||
autoLauncher = new AutoLaunch({
|
||||
name: 'Game Marathon Tracker',
|
||||
path: app.getPath('exe'),
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const isEnabled = await autoLauncher.isEnabled()
|
||||
|
||||
if (enabled && !isEnabled) {
|
||||
await autoLauncher.enable()
|
||||
console.log('Auto-launch enabled')
|
||||
} else if (!enabled && isEnabled) {
|
||||
await autoLauncher.disable()
|
||||
console.log('Auto-launch disabled')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to setup auto-launch:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function isAutoLaunchEnabled(): Promise<boolean> {
|
||||
if (!autoLauncher) {
|
||||
autoLauncher = new AutoLaunch({
|
||||
name: 'Game Marathon Tracker',
|
||||
path: app.getPath('exe'),
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
return await autoLauncher.isEnabled()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
197
desktop/src/main/index.ts
Normal file
197
desktop/src/main/index.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import * as path from 'path'
|
||||
import Store from 'electron-store'
|
||||
import { setupTray, destroyTray } from './tray'
|
||||
import { setupAutoLaunch } from './autolaunch'
|
||||
import { setupIpcHandlers } from './ipc'
|
||||
import { ProcessTracker } from './tracking/processTracker'
|
||||
import { createSplashWindow, setupAutoUpdater, setupUpdateIpcHandlers } from './updater'
|
||||
import type { StoreType } from './storeTypes'
|
||||
import './storeTypes' // Import for global type declarations
|
||||
|
||||
// Initialize electron store
|
||||
const store = new Store({
|
||||
defaults: {
|
||||
settings: {
|
||||
autoLaunch: false,
|
||||
minimizeToTray: true,
|
||||
trackingInterval: 5000,
|
||||
apiUrl: 'https://marathon.animeenigma.ru/api/v1',
|
||||
theme: 'dark',
|
||||
},
|
||||
token: null,
|
||||
trackedGames: {},
|
||||
trackingData: {},
|
||||
},
|
||||
}) as StoreType
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let processTracker: ProcessTracker | null = null
|
||||
let isMonitoringEnabled = false
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
|
||||
// Prevent multiple instances
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
if (!gotTheLock) {
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
// Someone tried to run a second instance, focus our window
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
function createWindow() {
|
||||
// In dev: use project resources folder, in prod: use app resources
|
||||
const iconPath = isDev
|
||||
? path.join(__dirname, '../../../resources/icon.ico')
|
||||
: path.join(process.resourcesPath, 'resources/icon.ico')
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 450,
|
||||
height: 750,
|
||||
resizable: false,
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
backgroundColor: '#0d0e14',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
icon: iconPath,
|
||||
})
|
||||
|
||||
// Load the app
|
||||
if (isDev) {
|
||||
mainWindow.loadURL('http://localhost:5173')
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' })
|
||||
} else {
|
||||
// In production: __dirname is dist/main/main/, so go up twice to dist/renderer/
|
||||
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'))
|
||||
}
|
||||
|
||||
// Handle close to tray
|
||||
mainWindow.on('close', (event) => {
|
||||
const settings = store.get('settings')
|
||||
if (settings.minimizeToTray && !app.isQuitting) {
|
||||
event.preventDefault()
|
||||
mainWindow?.hide()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
// Setup tray icon
|
||||
setupTray(mainWindow, store)
|
||||
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Setup IPC handlers
|
||||
setupIpcHandlers(store, () => mainWindow)
|
||||
setupUpdateIpcHandlers()
|
||||
|
||||
// Show splash screen and check for updates
|
||||
createSplashWindow()
|
||||
|
||||
setupAutoUpdater(async () => {
|
||||
// This runs after update check is complete (or skipped)
|
||||
|
||||
// Create the main window
|
||||
createWindow()
|
||||
|
||||
// Setup auto-launch
|
||||
const settings = store.get('settings')
|
||||
await setupAutoLaunch(settings.autoLaunch)
|
||||
|
||||
// Initialize process tracker (but don't start automatically)
|
||||
processTracker = new ProcessTracker(
|
||||
store,
|
||||
(stats) => {
|
||||
mainWindow?.webContents.send('tracking-update', stats)
|
||||
},
|
||||
(event) => {
|
||||
// Game started
|
||||
mainWindow?.webContents.send('game-started', event.gameName, event.gameId)
|
||||
},
|
||||
(event) => {
|
||||
// Game stopped
|
||||
mainWindow?.webContents.send('game-stopped', event.gameName, event.duration || 0)
|
||||
}
|
||||
)
|
||||
// Don't start automatically - user will start via button
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
// Don't quit on Windows if minimize to tray is enabled
|
||||
const settings = store.get('settings')
|
||||
if (!settings.minimizeToTray) {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
app.isQuitting = true
|
||||
processTracker?.stop()
|
||||
destroyTray()
|
||||
})
|
||||
|
||||
// Handle IPC for window controls
|
||||
ipcMain.on('minimize-to-tray', () => {
|
||||
mainWindow?.hide()
|
||||
})
|
||||
|
||||
ipcMain.on('close-window', () => {
|
||||
// This triggers the 'close' event handler which checks minimizeToTray setting
|
||||
mainWindow?.close()
|
||||
})
|
||||
|
||||
ipcMain.on('quit-app', () => {
|
||||
app.isQuitting = true
|
||||
app.quit()
|
||||
})
|
||||
|
||||
// Monitoring control
|
||||
ipcMain.handle('start-monitoring', () => {
|
||||
if (!isMonitoringEnabled && processTracker) {
|
||||
processTracker.start()
|
||||
isMonitoringEnabled = true
|
||||
console.log('Monitoring started')
|
||||
}
|
||||
return isMonitoringEnabled
|
||||
})
|
||||
|
||||
ipcMain.handle('stop-monitoring', () => {
|
||||
if (isMonitoringEnabled && processTracker) {
|
||||
processTracker.stop()
|
||||
isMonitoringEnabled = false
|
||||
console.log('Monitoring stopped')
|
||||
}
|
||||
return isMonitoringEnabled
|
||||
})
|
||||
|
||||
ipcMain.handle('get-monitoring-status', () => {
|
||||
return isMonitoringEnabled
|
||||
})
|
||||
|
||||
// Export for use in other modules
|
||||
export { store, mainWindow }
|
||||
174
desktop/src/main/ipc.ts
Normal file
174
desktop/src/main/ipc.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron'
|
||||
import { setupAutoLaunch } from './autolaunch'
|
||||
import { getRunningProcesses, getForegroundWindow } from './tracking/processTracker'
|
||||
import { getSteamGames, getSteamPath } from './tracking/steamIntegration'
|
||||
import { getTrackingStats, getTrackedGames, addTrackedGame, removeTrackedGame } from './tracking/timeStorage'
|
||||
import { ApiClient } from './apiClient'
|
||||
import type { TrackedGame, AppSettings, User, LoginResponse } from '../shared/types'
|
||||
import type { StoreType } from './storeTypes'
|
||||
|
||||
export function setupIpcHandlers(
|
||||
store: StoreType,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
) {
|
||||
const apiClient = new ApiClient(store)
|
||||
// Settings handlers
|
||||
ipcMain.handle('get-settings', () => {
|
||||
return store.get('settings')
|
||||
})
|
||||
|
||||
ipcMain.handle('save-settings', async (_event, settings: Partial<AppSettings>) => {
|
||||
const currentSettings = store.get('settings')
|
||||
const newSettings = { ...currentSettings, ...settings }
|
||||
store.set('settings', newSettings)
|
||||
|
||||
// Handle auto-launch setting change
|
||||
if (settings.autoLaunch !== undefined) {
|
||||
await setupAutoLaunch(settings.autoLaunch)
|
||||
}
|
||||
|
||||
return newSettings
|
||||
})
|
||||
|
||||
// Auth handlers
|
||||
ipcMain.handle('get-token', () => {
|
||||
return store.get('token')
|
||||
})
|
||||
|
||||
ipcMain.handle('save-token', (_event, token: string) => {
|
||||
store.set('token', token)
|
||||
})
|
||||
|
||||
ipcMain.handle('clear-token', () => {
|
||||
store.set('token', null)
|
||||
})
|
||||
|
||||
// Process tracking handlers
|
||||
ipcMain.handle('get-running-processes', async () => {
|
||||
return await getRunningProcesses()
|
||||
})
|
||||
|
||||
ipcMain.handle('get-foreground-window', async () => {
|
||||
return await getForegroundWindow()
|
||||
})
|
||||
|
||||
ipcMain.handle('get-tracking-stats', () => {
|
||||
return getTrackingStats(store)
|
||||
})
|
||||
|
||||
// Steam handlers
|
||||
ipcMain.handle('get-steam-games', async () => {
|
||||
return await getSteamGames()
|
||||
})
|
||||
|
||||
ipcMain.handle('get-steam-path', () => {
|
||||
return getSteamPath()
|
||||
})
|
||||
|
||||
// Tracked games handlers
|
||||
ipcMain.handle('get-tracked-games', () => {
|
||||
return getTrackedGames(store)
|
||||
})
|
||||
|
||||
ipcMain.handle('add-tracked-game', (_event, game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => {
|
||||
return addTrackedGame(store, game)
|
||||
})
|
||||
|
||||
ipcMain.handle('remove-tracked-game', (_event, gameId: string) => {
|
||||
removeTrackedGame(store, gameId)
|
||||
})
|
||||
|
||||
// API handlers - all requests go through main process (no CORS issues)
|
||||
ipcMain.handle('api-login', async (_event, login: string, password: string) => {
|
||||
console.log('[API] Login attempt for:', login)
|
||||
try {
|
||||
const response = await apiClient.post<LoginResponse>('/auth/login', { login, password })
|
||||
console.log('[API] Login response:', response.status)
|
||||
|
||||
// Save token if login successful
|
||||
if (response.data.access_token) {
|
||||
store.set('token', response.data.access_token)
|
||||
}
|
||||
|
||||
return { success: true, data: response.data }
|
||||
} catch (error: unknown) {
|
||||
console.error('[API] Login error:', error)
|
||||
const err = error as { status?: number; message?: string; detail?: unknown }
|
||||
return {
|
||||
success: false,
|
||||
error: err.detail || err.message || 'Login failed',
|
||||
status: err.status
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('api-get-me', async () => {
|
||||
try {
|
||||
const response = await apiClient.get<User>('/auth/me')
|
||||
return { success: true, data: response.data }
|
||||
} catch (error: unknown) {
|
||||
const err = error as { status?: number; message?: string; detail?: unknown }
|
||||
return {
|
||||
success: false,
|
||||
error: err.detail || err.message || 'Failed to get user',
|
||||
status: err.status
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('api-2fa-verify', async (_event, sessionId: number, code: string) => {
|
||||
console.log('[API] 2FA verify attempt')
|
||||
try {
|
||||
const response = await apiClient.post<LoginResponse>(`/auth/2fa/verify?session_id=${sessionId}&code=${code}`)
|
||||
console.log('[API] 2FA verify response:', response.status)
|
||||
|
||||
// Save token if verification successful
|
||||
if (response.data.access_token) {
|
||||
store.set('token', response.data.access_token)
|
||||
}
|
||||
|
||||
return { success: true, data: response.data }
|
||||
} catch (error: unknown) {
|
||||
console.error('[API] 2FA verify error:', error)
|
||||
const err = error as { status?: number; message?: string; detail?: unknown }
|
||||
return {
|
||||
success: false,
|
||||
error: err.detail || err.message || '2FA verification failed',
|
||||
status: err.status
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('api-request', async (_event, method: string, endpoint: string, data?: unknown) => {
|
||||
try {
|
||||
let response
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
response = await apiClient.get(endpoint)
|
||||
break
|
||||
case 'POST':
|
||||
response = await apiClient.post(endpoint, data)
|
||||
break
|
||||
case 'PUT':
|
||||
response = await apiClient.put(endpoint, data)
|
||||
break
|
||||
case 'PATCH':
|
||||
response = await apiClient.patch(endpoint, data)
|
||||
break
|
||||
case 'DELETE':
|
||||
response = await apiClient.delete(endpoint)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`)
|
||||
}
|
||||
return { success: true, data: response.data }
|
||||
} catch (error: unknown) {
|
||||
const err = error as { status?: number; message?: string; detail?: unknown }
|
||||
return {
|
||||
success: false,
|
||||
error: err.detail || err.message || 'Request failed',
|
||||
status: err.status
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
28
desktop/src/main/storeTypes.ts
Normal file
28
desktop/src/main/storeTypes.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import Store from 'electron-store'
|
||||
import type { AppSettings, TrackedGame } from '../shared/types'
|
||||
|
||||
export interface GameTrackingData {
|
||||
totalTime: number
|
||||
sessions: Array<{
|
||||
startTime: number
|
||||
endTime: number
|
||||
duration: number
|
||||
}>
|
||||
lastPlayed: number
|
||||
}
|
||||
|
||||
export type StoreType = Store<{
|
||||
settings: AppSettings
|
||||
token: string | null
|
||||
trackedGames: Record<string, TrackedGame>
|
||||
trackingData: Record<string, GameTrackingData>
|
||||
}>
|
||||
|
||||
// Extend Electron App type
|
||||
declare global {
|
||||
namespace Electron {
|
||||
interface App {
|
||||
isQuitting?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
284
desktop/src/main/tracking/processTracker.ts
Normal file
284
desktop/src/main/tracking/processTracker.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import type { TrackedProcess, TrackingStats, TrackedGame } from '../../shared/types'
|
||||
import type { StoreType } from '../storeTypes'
|
||||
import { updateGameTime, getTrackedGames } from './timeStorage'
|
||||
import { updateTrayMenu } from '../tray'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
interface ProcessInfo {
|
||||
ProcessName: string
|
||||
MainWindowTitle: string
|
||||
Id: number
|
||||
}
|
||||
|
||||
export async function getRunningProcesses(): Promise<TrackedProcess[]> {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
'powershell -NoProfile -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Process | Where-Object {$_.MainWindowTitle} | Select-Object ProcessName, MainWindowTitle, Id | ConvertTo-Json -Compress"',
|
||||
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
|
||||
)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
let processes: ProcessInfo[]
|
||||
try {
|
||||
const parsed = JSON.parse(stdout)
|
||||
processes = Array.isArray(parsed) ? parsed : [parsed]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return processes.map((proc) => ({
|
||||
id: proc.Id.toString(),
|
||||
name: proc.ProcessName,
|
||||
displayName: proc.MainWindowTitle || proc.ProcessName,
|
||||
windowTitle: proc.MainWindowTitle,
|
||||
isGame: isLikelyGame(proc.ProcessName, proc.MainWindowTitle),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to get running processes:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getForegroundWindow(): Promise<string | null> {
|
||||
try {
|
||||
// Use base64 encoded script to avoid escaping issues
|
||||
const script = `
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public class FGWindow {
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
}
|
||||
"@
|
||||
\$hwnd = [FGWindow]::GetForegroundWindow()
|
||||
\$processId = 0
|
||||
[void][FGWindow]::GetWindowThreadProcessId(\$hwnd, [ref]\$processId)
|
||||
\$proc = Get-Process -Id \$processId -ErrorAction SilentlyContinue
|
||||
if (\$proc) { Write-Output \$proc.ProcessName }
|
||||
`
|
||||
// Encode script as base64
|
||||
const base64Script = Buffer.from(script, 'utf16le').toString('base64')
|
||||
|
||||
const { stdout } = await execAsync(
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${base64Script}`,
|
||||
{ encoding: 'utf8', timeout: 5000 }
|
||||
)
|
||||
|
||||
const result = stdout.trim()
|
||||
return result || null
|
||||
} catch (error) {
|
||||
console.error('[getForegroundWindow] Error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyGame(processName: string, windowTitle: string): boolean {
|
||||
const gameIndicators = [
|
||||
'game', 'steam', 'epic', 'uplay', 'origin', 'battle.net',
|
||||
'unity', 'unreal', 'godot', 'ue4', 'ue5',
|
||||
]
|
||||
|
||||
const lowerName = processName.toLowerCase()
|
||||
const lowerTitle = (windowTitle || '').toLowerCase()
|
||||
|
||||
// Check for common game launchers/engines
|
||||
for (const indicator of gameIndicators) {
|
||||
if (lowerName.includes(indicator) || lowerTitle.includes(indicator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude common non-game processes
|
||||
const nonGameProcesses = [
|
||||
'explorer', 'chrome', 'firefox', 'edge', 'opera', 'brave',
|
||||
'code', 'idea', 'webstorm', 'pycharm', 'rider',
|
||||
'discord', 'slack', 'teams', 'zoom', 'telegram',
|
||||
'spotify', 'vlc', 'foobar', 'winamp',
|
||||
'notepad', 'word', 'excel', 'powerpoint', 'outlook',
|
||||
'cmd', 'powershell', 'terminal', 'windowsterminal',
|
||||
]
|
||||
|
||||
for (const nonGame of nonGameProcesses) {
|
||||
if (lowerName.includes(nonGame)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
interface GameEvent {
|
||||
gameName: string
|
||||
gameId: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export class ProcessTracker {
|
||||
private intervalId: NodeJS.Timeout | null = null
|
||||
private currentGame: string | null = null
|
||||
private currentGameName: string | null = null
|
||||
private sessionStart: number | null = null
|
||||
private store: StoreType
|
||||
private onUpdate: (stats: TrackingStats) => void
|
||||
private onGameStarted: (event: GameEvent) => void
|
||||
private onGameStopped: (event: GameEvent) => void
|
||||
|
||||
constructor(
|
||||
store: StoreType,
|
||||
onUpdate: (stats: TrackingStats) => void,
|
||||
onGameStarted?: (event: GameEvent) => void,
|
||||
onGameStopped?: (event: GameEvent) => void
|
||||
) {
|
||||
this.store = store
|
||||
this.onUpdate = onUpdate
|
||||
this.onGameStarted = onGameStarted || (() => {})
|
||||
this.onGameStopped = onGameStopped || (() => {})
|
||||
}
|
||||
|
||||
start() {
|
||||
const settings = this.store.get('settings')
|
||||
const interval = settings.trackingInterval || 5000
|
||||
|
||||
this.intervalId = setInterval(() => this.tick(), interval)
|
||||
console.log(`Process tracker started with ${interval}ms interval`)
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId)
|
||||
this.intervalId = null
|
||||
}
|
||||
|
||||
// End current session if any
|
||||
if (this.currentGame && this.sessionStart) {
|
||||
const duration = Date.now() - this.sessionStart
|
||||
updateGameTime(this.store, this.currentGame, duration)
|
||||
}
|
||||
|
||||
this.currentGame = null
|
||||
this.sessionStart = null
|
||||
console.log('Process tracker stopped')
|
||||
}
|
||||
|
||||
private async tick() {
|
||||
const foregroundProcess = await getForegroundWindow()
|
||||
const trackedGames = getTrackedGames(this.store)
|
||||
|
||||
// Debug logging - ALWAYS log
|
||||
console.log('[Tracker] Foreground:', foregroundProcess || 'NULL', '| Tracked:', trackedGames.length, 'games:', trackedGames.map(g => g.executableName).join(', ') || 'none')
|
||||
|
||||
// Find if foreground process matches any tracked game
|
||||
let matchedGame: TrackedGame | null = null
|
||||
if (foregroundProcess) {
|
||||
const lowerForeground = foregroundProcess.toLowerCase().replace('.exe', '')
|
||||
for (const game of trackedGames) {
|
||||
const lowerExe = game.executableName.toLowerCase().replace('.exe', '')
|
||||
// More flexible matching
|
||||
const matches = lowerForeground === lowerExe ||
|
||||
lowerForeground.includes(lowerExe) ||
|
||||
lowerExe.includes(lowerForeground)
|
||||
if (matches) {
|
||||
console.log('[Tracker] MATCH:', foregroundProcess, '===', game.executableName)
|
||||
matchedGame = game
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle game state changes
|
||||
if (matchedGame && matchedGame.id !== this.currentGame) {
|
||||
// New game started
|
||||
if (this.currentGame && this.sessionStart && this.currentGameName) {
|
||||
// End previous session
|
||||
const duration = Date.now() - this.sessionStart
|
||||
updateGameTime(this.store, this.currentGame, duration)
|
||||
// Emit game stopped event for previous game
|
||||
this.onGameStopped({
|
||||
gameName: this.currentGameName,
|
||||
gameId: this.currentGame,
|
||||
duration
|
||||
})
|
||||
}
|
||||
|
||||
this.currentGame = matchedGame.id
|
||||
this.currentGameName = matchedGame.name
|
||||
this.sessionStart = Date.now()
|
||||
console.log(`Started tracking: ${matchedGame.name}`)
|
||||
updateTrayMenu(null, true, matchedGame.name)
|
||||
// Emit game started event
|
||||
this.onGameStarted({
|
||||
gameName: matchedGame.name,
|
||||
gameId: matchedGame.id
|
||||
})
|
||||
} else if (!matchedGame && this.currentGame) {
|
||||
// Game stopped
|
||||
if (this.sessionStart) {
|
||||
const duration = Date.now() - this.sessionStart
|
||||
updateGameTime(this.store, this.currentGame, duration)
|
||||
// Emit game stopped event
|
||||
if (this.currentGameName) {
|
||||
this.onGameStopped({
|
||||
gameName: this.currentGameName,
|
||||
gameId: this.currentGame,
|
||||
duration
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Stopped tracking: ${this.currentGame}`)
|
||||
this.currentGame = null
|
||||
this.currentGameName = null
|
||||
this.sessionStart = null
|
||||
updateTrayMenu(null, false)
|
||||
}
|
||||
|
||||
// Emit update
|
||||
const stats = this.getStats()
|
||||
this.onUpdate(stats)
|
||||
}
|
||||
|
||||
private getStats(): TrackingStats {
|
||||
const trackedGames = getTrackedGames(this.store)
|
||||
const now = Date.now()
|
||||
const todayStart = new Date().setHours(0, 0, 0, 0)
|
||||
const weekStart = now - 7 * 24 * 60 * 60 * 1000
|
||||
const monthStart = now - 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
let totalTimeToday = 0
|
||||
let totalTimeWeek = 0
|
||||
let totalTimeMonth = 0
|
||||
|
||||
// Add current session time if active
|
||||
if (this.currentGame && this.sessionStart) {
|
||||
const currentSessionTime = now - this.sessionStart
|
||||
totalTimeToday += currentSessionTime
|
||||
totalTimeWeek += currentSessionTime
|
||||
totalTimeMonth += currentSessionTime
|
||||
}
|
||||
|
||||
// This is a simplified version - full implementation would track sessions with timestamps
|
||||
for (const game of trackedGames) {
|
||||
totalTimeMonth += game.totalTime
|
||||
// For simplicity, assume all recorded time is from this week/today
|
||||
// A full implementation would store session timestamps
|
||||
}
|
||||
|
||||
return {
|
||||
totalTimeToday,
|
||||
totalTimeWeek,
|
||||
totalTimeMonth,
|
||||
sessions: [],
|
||||
currentGame: this.currentGameName,
|
||||
currentSessionDuration: this.currentGame && this.sessionStart ? now - this.sessionStart : 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
215
desktop/src/main/tracking/steamIntegration.ts
Normal file
215
desktop/src/main/tracking/steamIntegration.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import type { SteamGame } from '../../shared/types'
|
||||
|
||||
// Common Steam installation paths on Windows
|
||||
const STEAM_PATHS = [
|
||||
'C:\\Program Files (x86)\\Steam',
|
||||
'C:\\Program Files\\Steam',
|
||||
'D:\\Steam',
|
||||
'D:\\SteamLibrary',
|
||||
'E:\\Steam',
|
||||
'E:\\SteamLibrary',
|
||||
]
|
||||
|
||||
let cachedSteamPath: string | null = null
|
||||
|
||||
export function getSteamPath(): string | null {
|
||||
if (cachedSteamPath) {
|
||||
return cachedSteamPath
|
||||
}
|
||||
|
||||
// Try common paths
|
||||
for (const steamPath of STEAM_PATHS) {
|
||||
if (fs.existsSync(path.join(steamPath, 'steam.exe')) ||
|
||||
fs.existsSync(path.join(steamPath, 'steamapps'))) {
|
||||
cachedSteamPath = steamPath
|
||||
return steamPath
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find via registry (would require node-winreg or similar)
|
||||
// For now, just check common paths
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getSteamGames(): Promise<SteamGame[]> {
|
||||
const steamPath = getSteamPath()
|
||||
if (!steamPath) {
|
||||
console.log('Steam not found')
|
||||
return []
|
||||
}
|
||||
|
||||
const games: SteamGame[] = []
|
||||
const libraryPaths = await getLibraryPaths(steamPath)
|
||||
|
||||
for (const libraryPath of libraryPaths) {
|
||||
const steamAppsPath = path.join(libraryPath, 'steamapps')
|
||||
if (!fs.existsSync(steamAppsPath)) continue
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(steamAppsPath)
|
||||
const manifests = files.filter((f) => f.startsWith('appmanifest_') && f.endsWith('.acf'))
|
||||
|
||||
for (const manifest of manifests) {
|
||||
const game = await parseAppManifest(path.join(steamAppsPath, manifest), libraryPath)
|
||||
if (game) {
|
||||
games.push(game)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading steam apps from ${steamAppsPath}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return games.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
async function getLibraryPaths(steamPath: string): Promise<string[]> {
|
||||
const paths: string[] = [steamPath]
|
||||
const libraryFoldersPath = path.join(steamPath, 'steamapps', 'libraryfolders.vdf')
|
||||
|
||||
if (!fs.existsSync(libraryFoldersPath)) {
|
||||
return paths
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(libraryFoldersPath, 'utf8')
|
||||
const libraryPaths = parseLibraryFolders(content)
|
||||
paths.push(...libraryPaths.filter((p) => !paths.includes(p)))
|
||||
} catch (error) {
|
||||
console.error('Error reading library folders:', error)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
function parseLibraryFolders(content: string): string[] {
|
||||
const paths: string[] = []
|
||||
|
||||
// Simple VDF parser for library folders
|
||||
// Format: "path" "C:\\SteamLibrary"
|
||||
const pathRegex = /"path"\s+"([^"]+)"/g
|
||||
let match
|
||||
|
||||
while ((match = pathRegex.exec(content)) !== null) {
|
||||
const libPath = match[1].replace(/\\\\/g, '\\')
|
||||
if (fs.existsSync(libPath)) {
|
||||
paths.push(libPath)
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
async function parseAppManifest(manifestPath: string, libraryPath: string): Promise<SteamGame | null> {
|
||||
try {
|
||||
const content = fs.readFileSync(manifestPath, 'utf8')
|
||||
|
||||
const appIdMatch = content.match(/"appid"\s+"(\d+)"/)
|
||||
const nameMatch = content.match(/"name"\s+"([^"]+)"/)
|
||||
const installDirMatch = content.match(/"installdir"\s+"([^"]+)"/)
|
||||
|
||||
if (!appIdMatch || !nameMatch || !installDirMatch) {
|
||||
return null
|
||||
}
|
||||
|
||||
const appId = appIdMatch[1]
|
||||
const name = nameMatch[1]
|
||||
const installDir = installDirMatch[1]
|
||||
|
||||
// Filter out tools, servers, etc.
|
||||
const skipTypes = ['Tool', 'Config', 'DLC', 'Music', 'Video']
|
||||
const typeMatch = content.match(/"type"\s+"([^"]+)"/)
|
||||
if (typeMatch && skipTypes.includes(typeMatch[1])) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fullInstallPath = path.join(libraryPath, 'steamapps', 'common', installDir)
|
||||
let executable: string | undefined
|
||||
|
||||
// Try to find main executable
|
||||
if (fs.existsSync(fullInstallPath)) {
|
||||
executable = findMainExecutable(fullInstallPath, name)
|
||||
}
|
||||
|
||||
return {
|
||||
appId,
|
||||
name,
|
||||
installDir: fullInstallPath,
|
||||
executable,
|
||||
iconPath: getGameIconPath(steamPath, appId),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing manifest ${manifestPath}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function findMainExecutable(installPath: string, gameName: string): string | undefined {
|
||||
try {
|
||||
const files = fs.readdirSync(installPath)
|
||||
const exeFiles = files.filter((f) => f.endsWith('.exe'))
|
||||
|
||||
if (exeFiles.length === 0) {
|
||||
// Check subdirectories (one level deep)
|
||||
for (const dir of files) {
|
||||
const subPath = path.join(installPath, dir)
|
||||
if (fs.statSync(subPath).isDirectory()) {
|
||||
const subFiles = fs.readdirSync(subPath)
|
||||
const subExe = subFiles.filter((f) => f.endsWith('.exe'))
|
||||
exeFiles.push(...subExe.map((f) => path.join(dir, f)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (exeFiles.length === 0) return undefined
|
||||
|
||||
// Try to find exe that matches game name
|
||||
const lowerName = gameName.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
for (const exe of exeFiles) {
|
||||
const lowerExe = exe.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
if (lowerExe.includes(lowerName) || lowerName.includes(lowerExe.replace('.exe', ''))) {
|
||||
return exe
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out common non-game executables
|
||||
const skipExes = [
|
||||
'unins', 'setup', 'install', 'config', 'crash', 'report',
|
||||
'launcher', 'updater', 'redistributable', 'vcredist', 'directx',
|
||||
'dxsetup', 'ue4prereqsetup', 'dotnet',
|
||||
]
|
||||
|
||||
const gameExes = exeFiles.filter((exe) => {
|
||||
const lower = exe.toLowerCase()
|
||||
return !skipExes.some((skip) => lower.includes(skip))
|
||||
})
|
||||
|
||||
return gameExes[0] || exeFiles[0]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getGameIconPath(steamPath: string | null, appId: string): string | undefined {
|
||||
if (!steamPath) return undefined
|
||||
|
||||
// Steam stores icons in appcache/librarycache
|
||||
const iconPath = path.join(steamPath, 'appcache', 'librarycache', `${appId}_icon.jpg`)
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return iconPath
|
||||
}
|
||||
|
||||
// Try header image
|
||||
const headerPath = path.join(steamPath, 'appcache', 'librarycache', `${appId}_header.jpg`)
|
||||
if (fs.existsSync(headerPath)) {
|
||||
return headerPath
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Re-export for use
|
||||
const steamPath = getSteamPath()
|
||||
155
desktop/src/main/tracking/timeStorage.ts
Normal file
155
desktop/src/main/tracking/timeStorage.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { TrackedGame, TrackingStats, GameSession } from '../../shared/types'
|
||||
import type { StoreType, GameTrackingData } from '../storeTypes'
|
||||
|
||||
export type { GameTrackingData }
|
||||
|
||||
export function getTrackedGames(store: StoreType): TrackedGame[] {
|
||||
const trackedGames = store.get('trackedGames') || {}
|
||||
return Object.values(trackedGames)
|
||||
}
|
||||
|
||||
export function addTrackedGame(
|
||||
store: StoreType,
|
||||
game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>
|
||||
): TrackedGame {
|
||||
const trackedGames = store.get('trackedGames') || {}
|
||||
|
||||
const newGame: TrackedGame = {
|
||||
...game,
|
||||
totalTime: 0,
|
||||
lastPlayed: undefined,
|
||||
}
|
||||
|
||||
trackedGames[game.id] = newGame
|
||||
store.set('trackedGames', trackedGames)
|
||||
|
||||
// Initialize tracking data
|
||||
const trackingData = store.get('trackingData') || {}
|
||||
trackingData[game.id] = {
|
||||
totalTime: 0,
|
||||
sessions: [],
|
||||
lastPlayed: 0,
|
||||
}
|
||||
store.set('trackingData', trackingData)
|
||||
|
||||
return newGame
|
||||
}
|
||||
|
||||
export function removeTrackedGame(store: StoreType, gameId: string): void {
|
||||
const trackedGames = store.get('trackedGames') || {}
|
||||
delete trackedGames[gameId]
|
||||
store.set('trackedGames', trackedGames)
|
||||
|
||||
const trackingData = store.get('trackingData') || {}
|
||||
delete trackingData[gameId]
|
||||
store.set('trackingData', trackingData)
|
||||
}
|
||||
|
||||
export function updateGameTime(store: StoreType, gameId: string, duration: number): void {
|
||||
// Update tracked games
|
||||
const trackedGames = store.get('trackedGames') || {}
|
||||
if (trackedGames[gameId]) {
|
||||
trackedGames[gameId].totalTime += duration
|
||||
trackedGames[gameId].lastPlayed = Date.now()
|
||||
store.set('trackedGames', trackedGames)
|
||||
}
|
||||
|
||||
// Update tracking data with session
|
||||
const trackingData = store.get('trackingData') || {}
|
||||
if (!trackingData[gameId]) {
|
||||
trackingData[gameId] = {
|
||||
totalTime: 0,
|
||||
sessions: [],
|
||||
lastPlayed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
trackingData[gameId].totalTime += duration
|
||||
trackingData[gameId].lastPlayed = now
|
||||
trackingData[gameId].sessions.push({
|
||||
startTime: now - duration,
|
||||
endTime: now,
|
||||
duration,
|
||||
})
|
||||
|
||||
// Keep only last 100 sessions to prevent data bloat
|
||||
if (trackingData[gameId].sessions.length > 100) {
|
||||
trackingData[gameId].sessions = trackingData[gameId].sessions.slice(-100)
|
||||
}
|
||||
|
||||
store.set('trackingData', trackingData)
|
||||
}
|
||||
|
||||
export function getTrackingStats(store: StoreType): TrackingStats {
|
||||
const trackingData = store.get('trackingData') || {}
|
||||
const now = Date.now()
|
||||
|
||||
const todayStart = new Date().setHours(0, 0, 0, 0)
|
||||
const weekStart = now - 7 * 24 * 60 * 60 * 1000
|
||||
const monthStart = now - 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
let totalTimeToday = 0
|
||||
let totalTimeWeek = 0
|
||||
let totalTimeMonth = 0
|
||||
const recentSessions: GameSession[] = []
|
||||
|
||||
for (const [gameId, data] of Object.entries(trackingData)) {
|
||||
for (const session of data.sessions) {
|
||||
if (session.endTime >= monthStart) {
|
||||
totalTimeMonth += session.duration
|
||||
|
||||
if (session.endTime >= weekStart) {
|
||||
totalTimeWeek += session.duration
|
||||
}
|
||||
|
||||
if (session.endTime >= todayStart) {
|
||||
totalTimeToday += session.duration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get last session for each game
|
||||
if (data.sessions.length > 0) {
|
||||
const lastSession = data.sessions[data.sessions.length - 1]
|
||||
const trackedGames = store.get('trackedGames') || {}
|
||||
const game = trackedGames[gameId]
|
||||
|
||||
if (game && lastSession.endTime >= weekStart) {
|
||||
recentSessions.push({
|
||||
gameId,
|
||||
gameName: game.name,
|
||||
startTime: lastSession.startTime,
|
||||
endTime: lastSession.endTime,
|
||||
duration: lastSession.duration,
|
||||
isActive: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recent
|
||||
recentSessions.sort((a, b) => (b.endTime || 0) - (a.endTime || 0))
|
||||
|
||||
return {
|
||||
totalTimeToday,
|
||||
totalTimeWeek,
|
||||
totalTimeMonth,
|
||||
sessions: recentSessions.slice(0, 10),
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTime(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
const remainingMinutes = minutes % 60
|
||||
return `${hours}ч ${remainingMinutes}м`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}м`
|
||||
} else {
|
||||
return `${seconds}с`
|
||||
}
|
||||
}
|
||||
115
desktop/src/main/tray.ts
Normal file
115
desktop/src/main/tray.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Tray, Menu, nativeImage, BrowserWindow, app, NativeImage } from 'electron'
|
||||
import * as path from 'path'
|
||||
import type { StoreType } from './storeTypes'
|
||||
|
||||
let tray: Tray | null = null
|
||||
|
||||
export function setupTray(
|
||||
mainWindow: BrowserWindow | null,
|
||||
store: StoreType
|
||||
) {
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
|
||||
// In dev: use project resources folder, in prod: use app resources
|
||||
const iconPath = isDev
|
||||
? path.join(__dirname, '../../../resources/icon.ico')
|
||||
: path.join(process.resourcesPath, 'resources/icon.ico')
|
||||
|
||||
// Create tray icon
|
||||
let trayIcon: NativeImage
|
||||
try {
|
||||
trayIcon = nativeImage.createFromPath(iconPath)
|
||||
if (trayIcon.isEmpty()) {
|
||||
trayIcon = nativeImage.createEmpty()
|
||||
}
|
||||
} catch {
|
||||
trayIcon = nativeImage.createEmpty()
|
||||
}
|
||||
|
||||
// Resize for tray (16x16 on Windows)
|
||||
if (!trayIcon.isEmpty()) {
|
||||
trayIcon = trayIcon.resize({ width: 16, height: 16 })
|
||||
}
|
||||
|
||||
tray = new Tray(trayIcon)
|
||||
tray.setToolTip('Game Marathon Tracker')
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Открыть',
|
||||
click: () => {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Статус: Отслеживание',
|
||||
enabled: false,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Выход',
|
||||
click: () => {
|
||||
app.isQuitting = true
|
||||
app.quit()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
tray.setContextMenu(contextMenu)
|
||||
|
||||
// Double-click to show window
|
||||
tray.on('double-click', () => {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
})
|
||||
|
||||
return tray
|
||||
}
|
||||
|
||||
export function updateTrayMenu(
|
||||
mainWindow: BrowserWindow | null,
|
||||
isTracking: boolean,
|
||||
currentGame?: string
|
||||
) {
|
||||
if (!tray) return
|
||||
|
||||
const statusLabel = isTracking
|
||||
? `Отслеживание: ${currentGame || 'Активно'}`
|
||||
: 'Отслеживание: Неактивно'
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Открыть',
|
||||
click: () => {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: statusLabel,
|
||||
enabled: false,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Выход',
|
||||
click: () => {
|
||||
app.isQuitting = true
|
||||
app.quit()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
tray.setContextMenu(contextMenu)
|
||||
}
|
||||
|
||||
export function destroyTray() {
|
||||
if (tray) {
|
||||
tray.destroy()
|
||||
tray = null
|
||||
}
|
||||
}
|
||||
|
||||
export { tray }
|
||||
184
desktop/src/main/updater.ts
Normal file
184
desktop/src/main/updater.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { BrowserWindow, ipcMain, app } from 'electron'
|
||||
import * as path from 'path'
|
||||
|
||||
let splashWindow: BrowserWindow | null = null
|
||||
|
||||
export function createSplashWindow(): BrowserWindow {
|
||||
splashWindow = new BrowserWindow({
|
||||
width: 350,
|
||||
height: 250,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
resizable: false,
|
||||
center: true,
|
||||
backgroundColor: '#0d0e14',
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
})
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
|
||||
if (isDev) {
|
||||
// In dev mode: __dirname is dist/main/main/, need to go up 3 levels to project root
|
||||
splashWindow.loadFile(path.join(__dirname, '../../../src/renderer/splash.html'))
|
||||
} else {
|
||||
// In production: __dirname is dist/main/main/, so go up twice to dist/renderer/
|
||||
splashWindow.loadFile(path.join(__dirname, '../../renderer/splash.html'))
|
||||
}
|
||||
|
||||
return splashWindow
|
||||
}
|
||||
|
||||
export function closeSplashWindow() {
|
||||
if (splashWindow) {
|
||||
splashWindow.close()
|
||||
splashWindow = null
|
||||
}
|
||||
}
|
||||
|
||||
function sendStatusToSplash(status: string) {
|
||||
if (splashWindow) {
|
||||
splashWindow.webContents.send('update-status', status)
|
||||
}
|
||||
}
|
||||
|
||||
function sendProgressToSplash(percent: number) {
|
||||
if (splashWindow) {
|
||||
splashWindow.webContents.send('update-progress', percent)
|
||||
}
|
||||
}
|
||||
|
||||
export function setupAutoUpdater(onComplete: () => void) {
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
let hasCompleted = false
|
||||
|
||||
const safeComplete = () => {
|
||||
if (hasCompleted) return
|
||||
hasCompleted = true
|
||||
closeSplashWindow()
|
||||
onComplete()
|
||||
}
|
||||
|
||||
// In development, skip update check
|
||||
if (isDev) {
|
||||
console.log('[Updater] Skipping update check in development mode')
|
||||
sendStatusToSplash('Режим разработки')
|
||||
setTimeout(safeComplete, 1500)
|
||||
return
|
||||
}
|
||||
|
||||
// Configure auto-updater
|
||||
autoUpdater.autoDownload = true
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
// Check for updates (use 'once' to prevent handlers from triggering on manual update checks)
|
||||
autoUpdater.once('checking-for-update', () => {
|
||||
console.log('[Updater] Checking for updates...')
|
||||
sendStatusToSplash('Проверка обновлений...')
|
||||
})
|
||||
|
||||
autoUpdater.once('update-available', (info) => {
|
||||
console.log('[Updater] Update available:', info.version)
|
||||
sendStatusToSplash(`Найдено обновление v${info.version}`)
|
||||
})
|
||||
|
||||
autoUpdater.once('update-not-available', () => {
|
||||
console.log('[Updater] No updates available')
|
||||
sendStatusToSplash('Актуальная версия')
|
||||
setTimeout(safeComplete, 1000)
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
const percent = Math.round(progress.percent)
|
||||
console.log(`[Updater] Download progress: ${percent}%`)
|
||||
sendStatusToSplash(`Загрузка обновления... ${percent}%`)
|
||||
sendProgressToSplash(percent)
|
||||
})
|
||||
|
||||
autoUpdater.once('update-downloaded', (info) => {
|
||||
console.log('[Updater] Update downloaded:', info.version)
|
||||
sendStatusToSplash('Установка обновления...')
|
||||
// Install and restart
|
||||
setTimeout(() => {
|
||||
autoUpdater.quitAndInstall(false, true)
|
||||
}, 1500)
|
||||
})
|
||||
|
||||
autoUpdater.once('error', (error) => {
|
||||
console.error('[Updater] Error:', error.message)
|
||||
console.error('[Updater] Error stack:', error.stack)
|
||||
sendStatusToSplash('Запуск...')
|
||||
setTimeout(safeComplete, 1500)
|
||||
})
|
||||
|
||||
// Start checking
|
||||
autoUpdater.checkForUpdates().catch((error) => {
|
||||
console.error('[Updater] Failed to check for updates:', error)
|
||||
sendStatusToSplash('Запуск...')
|
||||
setTimeout(safeComplete, 1500)
|
||||
})
|
||||
}
|
||||
|
||||
// Manual check for updates (from settings)
|
||||
export function checkForUpdatesManual(): Promise<{ available: boolean; version?: string; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
|
||||
if (isDev) {
|
||||
resolve({ available: false, error: 'В режиме разработки обновления недоступны' })
|
||||
return
|
||||
}
|
||||
|
||||
const onUpdateAvailable = (info: { version: string }) => {
|
||||
cleanup()
|
||||
resolve({ available: true, version: info.version })
|
||||
}
|
||||
|
||||
const onUpdateNotAvailable = () => {
|
||||
cleanup()
|
||||
resolve({ available: false })
|
||||
}
|
||||
|
||||
const onError = (error: Error) => {
|
||||
cleanup()
|
||||
resolve({ available: false, error: error.message })
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
autoUpdater.off('update-available', onUpdateAvailable)
|
||||
autoUpdater.off('update-not-available', onUpdateNotAvailable)
|
||||
autoUpdater.off('error', onError)
|
||||
}
|
||||
|
||||
autoUpdater.on('update-available', onUpdateAvailable)
|
||||
autoUpdater.on('update-not-available', onUpdateNotAvailable)
|
||||
autoUpdater.on('error', onError)
|
||||
|
||||
autoUpdater.checkForUpdates().catch((error) => {
|
||||
cleanup()
|
||||
resolve({ available: false, error: error.message })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Setup IPC handlers for updates
|
||||
export function setupUpdateIpcHandlers() {
|
||||
ipcMain.handle('get-app-version', () => {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
ipcMain.handle('check-for-updates', async () => {
|
||||
return await checkForUpdatesManual()
|
||||
})
|
||||
|
||||
ipcMain.handle('download-update', () => {
|
||||
autoUpdater.downloadUpdate()
|
||||
})
|
||||
|
||||
ipcMain.handle('install-update', () => {
|
||||
autoUpdater.quitAndInstall(false, true)
|
||||
})
|
||||
}
|
||||
97
desktop/src/preload/index.ts
Normal file
97
desktop/src/preload/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { AppSettings, TrackedProcess, SteamGame, TrackedGame, TrackingStats, User, LoginResponse } from '../shared/types'
|
||||
|
||||
interface ApiResult<T> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// ipcRenderer without exposing the entire object
|
||||
const electronAPI = {
|
||||
// Settings
|
||||
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('get-settings'),
|
||||
saveSettings: (settings: Partial<AppSettings>): Promise<void> =>
|
||||
ipcRenderer.invoke('save-settings', settings),
|
||||
|
||||
// Auth (local storage)
|
||||
getToken: (): Promise<string | null> => ipcRenderer.invoke('get-token'),
|
||||
saveToken: (token: string): Promise<void> => ipcRenderer.invoke('save-token', token),
|
||||
clearToken: (): Promise<void> => ipcRenderer.invoke('clear-token'),
|
||||
|
||||
// API calls (through main process - no CORS)
|
||||
apiLogin: (login: string, password: string): Promise<ApiResult<LoginResponse>> =>
|
||||
ipcRenderer.invoke('api-login', login, password),
|
||||
api2faVerify: (sessionId: number, code: string): Promise<ApiResult<LoginResponse>> =>
|
||||
ipcRenderer.invoke('api-2fa-verify', sessionId, code),
|
||||
apiGetMe: (): Promise<ApiResult<User>> =>
|
||||
ipcRenderer.invoke('api-get-me'),
|
||||
apiRequest: <T>(method: string, endpoint: string, data?: unknown): Promise<ApiResult<T>> =>
|
||||
ipcRenderer.invoke('api-request', method, endpoint, data),
|
||||
|
||||
// Process tracking
|
||||
getRunningProcesses: (): Promise<TrackedProcess[]> =>
|
||||
ipcRenderer.invoke('get-running-processes'),
|
||||
getForegroundWindow: (): Promise<string | null> =>
|
||||
ipcRenderer.invoke('get-foreground-window'),
|
||||
getTrackingStats: (): Promise<TrackingStats> =>
|
||||
ipcRenderer.invoke('get-tracking-stats'),
|
||||
|
||||
// Steam
|
||||
getSteamGames: (): Promise<SteamGame[]> => ipcRenderer.invoke('get-steam-games'),
|
||||
getSteamPath: (): Promise<string | null> => ipcRenderer.invoke('get-steam-path'),
|
||||
|
||||
// Tracked games
|
||||
getTrackedGames: (): Promise<TrackedGame[]> => ipcRenderer.invoke('get-tracked-games'),
|
||||
addTrackedGame: (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>): Promise<TrackedGame> =>
|
||||
ipcRenderer.invoke('add-tracked-game', game),
|
||||
removeTrackedGame: (gameId: string): Promise<void> =>
|
||||
ipcRenderer.invoke('remove-tracked-game', gameId),
|
||||
|
||||
// Window controls
|
||||
minimizeToTray: (): void => ipcRenderer.send('minimize-to-tray'),
|
||||
closeWindow: (): void => ipcRenderer.send('close-window'),
|
||||
quitApp: (): void => ipcRenderer.send('quit-app'),
|
||||
|
||||
// Monitoring control
|
||||
startMonitoring: (): Promise<boolean> => ipcRenderer.invoke('start-monitoring'),
|
||||
stopMonitoring: (): Promise<boolean> => ipcRenderer.invoke('stop-monitoring'),
|
||||
getMonitoringStatus: (): Promise<boolean> => ipcRenderer.invoke('get-monitoring-status'),
|
||||
|
||||
// Updates
|
||||
getAppVersion: (): Promise<string> => ipcRenderer.invoke('get-app-version'),
|
||||
checkForUpdates: (): Promise<{ available: boolean; version?: string; error?: string }> =>
|
||||
ipcRenderer.invoke('check-for-updates'),
|
||||
installUpdate: (): Promise<void> => ipcRenderer.invoke('install-update'),
|
||||
|
||||
// Events
|
||||
onTrackingUpdate: (callback: (stats: TrackingStats) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, stats: TrackingStats) => callback(stats)
|
||||
ipcRenderer.on('tracking-update', subscription)
|
||||
return () => ipcRenderer.removeListener('tracking-update', subscription)
|
||||
},
|
||||
|
||||
onGameStarted: (callback: (gameName: string, gameId: string) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, gameName: string, gameId: string) => callback(gameName, gameId)
|
||||
ipcRenderer.on('game-started', subscription)
|
||||
return () => ipcRenderer.removeListener('game-started', subscription)
|
||||
},
|
||||
|
||||
onGameStopped: (callback: (gameName: string, duration: number) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, gameName: string, duration: number) =>
|
||||
callback(gameName, duration)
|
||||
ipcRenderer.on('game-stopped', subscription)
|
||||
return () => ipcRenderer.removeListener('game-stopped', subscription)
|
||||
},
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
|
||||
|
||||
// Type declaration for renderer process
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: typeof electronAPI
|
||||
}
|
||||
}
|
||||
65
desktop/src/renderer/App.tsx
Normal file
65
desktop/src/renderer/App.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import { useAuthStore } from './store/auth'
|
||||
import { Layout } from './components/Layout'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { DashboardPage } from './pages/DashboardPage'
|
||||
import { SettingsPage } from './pages/SettingsPage'
|
||||
import { GamesPage } from './pages/GamesPage'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 flex items-center justify-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-neon-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <Layout>{children}</Layout>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { syncUser } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
syncUser()
|
||||
}, [syncUser])
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/games"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<GamesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
70
desktop/src/renderer/components/Layout.tsx
Normal file
70
desktop/src/renderer/components/Layout.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Gamepad2, Settings, LayoutDashboard, X, Minus } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation()
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: LayoutDashboard, label: 'Главная' },
|
||||
{ path: '/games', icon: Gamepad2, label: 'Игры' },
|
||||
{ path: '/settings', icon: Settings, label: 'Настройки' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-dark-900 flex flex-col overflow-hidden">
|
||||
{/* Custom title bar */}
|
||||
<div className="titlebar h-8 bg-dark-950 flex items-center justify-between px-2 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gamepad2 className="w-4 h-4 text-neon-500" />
|
||||
<span className="text-xs font-medium text-gray-400">Game Marathon Tracker</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => window.electronAPI.minimizeToTray()}
|
||||
className="w-8 h-8 flex items-center justify-center hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<Minus className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.electronAPI.closeWindow()}
|
||||
className="w-8 h-8 flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 overflow-auto p-4">{children}</div>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<nav className="bg-dark-800 border-t border-dark-700 px-2 py-2">
|
||||
<div className="flex justify-around">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex flex-col items-center gap-1 px-4 py-2 rounded-lg transition-all',
|
||||
isActive
|
||||
? 'text-neon-500 bg-neon-500/10'
|
||||
: 'text-gray-400 hover:text-gray-300 hover:bg-dark-700'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="text-xs">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
desktop/src/renderer/components/ui/GlassCard.tsx
Normal file
40
desktop/src/renderer/components/ui/GlassCard.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { type ReactNode, type HTMLAttributes } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
variant?: 'default' | 'dark' | 'neon'
|
||||
hover?: boolean
|
||||
glow?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GlassCard({
|
||||
children,
|
||||
variant = 'default',
|
||||
hover = false,
|
||||
glow = false,
|
||||
className,
|
||||
...props
|
||||
}: GlassCardProps) {
|
||||
const variantClasses = {
|
||||
default: 'glass',
|
||||
dark: 'glass-dark',
|
||||
neon: 'glass-neon',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-xl p-4',
|
||||
variantClasses[variant],
|
||||
hover && 'card-hover cursor-pointer',
|
||||
glow && 'neon-glow-pulse',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user