Улучшение системы оспариваний и исправления

- Оспаривания теперь требуют решения админа после 24ч голосования
  - Можно повторно оспаривать после разрешённых споров
  - Исправлены бонусные очки при перепрохождении после оспаривания
  - Сброс серии при невалидном пруфе
  - Колесо показывает только доступные игры
  - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
2025-12-29 22:23:34 +03:00
parent 1cedfeb3ee
commit 89dbe2c018
42 changed files with 5426 additions and 313 deletions

View File

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

View File

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

View File

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