Compare commits
14 Commits
4239ea8516
...
ca41c207b3
| Author | SHA1 | Date | |
|---|---|---|---|
| ca41c207b3 | |||
| 412de3bf05 | |||
| 9fd93a185c | |||
| fe6012b7a3 | |||
| a199952383 | |||
| e32df4d95e | |||
| f57a2ba9ea | |||
| d96f8de568 | |||
| 574140e67d | |||
| 87ecd9756c | |||
| c7966656d8 | |||
| 339a212e57 | |||
| 07e02ce32d | |||
| 9a037cb34f |
41
.dockerignore
Normal file
41
.dockerignore
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
*/node_modules
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
*.pyc
|
||||||
|
__pycache__
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Environment files (keep .env.example)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test & coverage
|
||||||
|
coverage
|
||||||
|
.pytest_cache
|
||||||
|
.coverage
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
@@ -11,5 +11,14 @@ OPENAI_API_KEY=sk-...
|
|||||||
# Telegram Bot
|
# Telegram Bot
|
||||||
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
||||||
|
|
||||||
|
# S3 Storage - FirstVDS (set S3_ENABLED=true to use)
|
||||||
|
S3_ENABLED=false
|
||||||
|
S3_BUCKET_NAME=your-bucket-name
|
||||||
|
S3_REGION=ru-1
|
||||||
|
S3_ACCESS_KEY_ID=your-access-key-id
|
||||||
|
S3_SECRET_ACCESS_KEY=your-secret-access-key
|
||||||
|
S3_ENDPOINT_URL=https://s3.firstvds.ru
|
||||||
|
S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru
|
||||||
|
|
||||||
# Frontend (for build)
|
# Frontend (for build)
|
||||||
VITE_API_URL=/api/v1
|
VITE_API_URL=/api/v1
|
||||||
|
|||||||
54
backend/alembic/versions/007_add_event_assignment_fields.py
Normal file
54
backend/alembic/versions/007_add_event_assignment_fields.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Add is_event_assignment and event_id to assignments for Common Enemy support
|
||||||
|
|
||||||
|
Revision ID: 007_add_event_assignment_fields
|
||||||
|
Revises: 006_add_swap_requests
|
||||||
|
Create Date: 2024-12-15
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '007_add_event_assignment_fields'
|
||||||
|
down_revision: Union[str, None] = '006_add_swap_requests'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add is_event_assignment column with default False
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('assignments')]
|
||||||
|
|
||||||
|
if 'is_event_assignment' not in columns:
|
||||||
|
op.add_column(
|
||||||
|
'assignments',
|
||||||
|
sa.Column('is_event_assignment', sa.Boolean(), nullable=False, server_default=sa.false())
|
||||||
|
)
|
||||||
|
op.create_index('ix_assignments_is_event_assignment', 'assignments', ['is_event_assignment'])
|
||||||
|
|
||||||
|
if 'event_id' not in columns:
|
||||||
|
op.add_column(
|
||||||
|
'assignments',
|
||||||
|
sa.Column('event_id', sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
'fk_assignments_event_id',
|
||||||
|
'assignments',
|
||||||
|
'events',
|
||||||
|
['event_id'],
|
||||||
|
['id'],
|
||||||
|
ondelete='SET NULL'
|
||||||
|
)
|
||||||
|
op.create_index('ix_assignments_event_id', 'assignments', ['event_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_assignments_event_id', table_name='assignments')
|
||||||
|
op.drop_constraint('fk_assignments_event_id', 'assignments', type_='foreignkey')
|
||||||
|
op.drop_column('assignments', 'event_id')
|
||||||
|
op.drop_index('ix_assignments_is_event_assignment', table_name='assignments')
|
||||||
|
op.drop_column('assignments', 'is_event_assignment')
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Rename rematch event type to game_choice
|
||||||
|
|
||||||
|
Revision ID: 008_rename_to_game_choice
|
||||||
|
Revises: 007_add_event_assignment_fields
|
||||||
|
Create Date: 2024-12-15
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "008_rename_to_game_choice"
|
||||||
|
down_revision = "007_add_event_assignment_fields"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Update event type from 'rematch' to 'game_choice' in events table
|
||||||
|
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
|
||||||
|
|
||||||
|
# Update event_type in assignments table
|
||||||
|
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
|
||||||
|
|
||||||
|
# Update activity data that references rematch event
|
||||||
|
op.execute("""
|
||||||
|
UPDATE activities
|
||||||
|
SET data = jsonb_set(data, '{event_type}', '"game_choice"')
|
||||||
|
WHERE data->>'event_type' = 'rematch'
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Revert event type from 'game_choice' to 'rematch'
|
||||||
|
op.execute("UPDATE events SET type = 'rematch' WHERE type = 'game_choice'")
|
||||||
|
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
|
||||||
|
op.execute("""
|
||||||
|
UPDATE activities
|
||||||
|
SET data = jsonb_set(data, '{event_type}', '"rematch"')
|
||||||
|
WHERE data->>'event_type' = 'game_choice'
|
||||||
|
""")
|
||||||
81
backend/alembic/versions/009_add_disputes.py
Normal file
81
backend/alembic/versions/009_add_disputes.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Add disputes tables for proof verification system
|
||||||
|
|
||||||
|
Revision ID: 009_add_disputes
|
||||||
|
Revises: 008_rename_to_game_choice
|
||||||
|
Create Date: 2024-12-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '009_add_disputes'
|
||||||
|
down_revision: Union[str, None] = '008_rename_to_game_choice'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
# Create disputes table
|
||||||
|
if 'disputes' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'disputes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('assignment_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('raised_by_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('reason', sa.Text(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(20), nullable=False, server_default='open'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('resolved_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['assignment_id'], ['assignments.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['raised_by_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.UniqueConstraint('assignment_id', name='uq_dispute_assignment'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_disputes_assignment_id', 'disputes', ['assignment_id'])
|
||||||
|
|
||||||
|
# Create dispute_comments table
|
||||||
|
if 'dispute_comments' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'dispute_comments',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('dispute_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('text', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_dispute_comments_dispute_id', 'dispute_comments', ['dispute_id'])
|
||||||
|
|
||||||
|
# Create dispute_votes table
|
||||||
|
if 'dispute_votes' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'dispute_votes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('dispute_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('vote', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.UniqueConstraint('dispute_id', 'user_id', name='uq_dispute_vote_user'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_dispute_votes_dispute_id', 'dispute_votes', ['dispute_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_dispute_votes_dispute_id', table_name='dispute_votes')
|
||||||
|
op.drop_table('dispute_votes')
|
||||||
|
op.drop_index('ix_dispute_comments_dispute_id', table_name='dispute_comments')
|
||||||
|
op.drop_table('dispute_comments')
|
||||||
|
op.drop_index('ix_disputes_assignment_id', table_name='disputes')
|
||||||
|
op.drop_table('disputes')
|
||||||
30
backend/alembic/versions/010_add_telegram_profile.py
Normal file
30
backend/alembic/versions/010_add_telegram_profile.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Add telegram profile fields to users
|
||||||
|
|
||||||
|
Revision ID: 010_add_telegram_profile
|
||||||
|
Revises: 009_add_disputes
|
||||||
|
Create Date: 2024-12-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '010_add_telegram_profile'
|
||||||
|
down_revision: Union[str, None] = '009_add_disputes'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'telegram_avatar_url')
|
||||||
|
op.drop_column('users', 'telegram_last_name')
|
||||||
|
op.drop_column('users', 'telegram_first_name')
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events
|
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -13,3 +13,5 @@ router.include_router(wheel.router)
|
|||||||
router.include_router(feed.router)
|
router.include_router(feed.router)
|
||||||
router.include_router(admin.router)
|
router.include_router(admin.router)
|
||||||
router.include_router(events.router)
|
router.include_router(events.router)
|
||||||
|
router.include_router(assignments.router)
|
||||||
|
router.include_router(telegram.router)
|
||||||
|
|||||||
555
backend/app/api/v1/assignments.py
Normal file
555
backend/app/api/v1/assignments.py
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
"""
|
||||||
|
Assignment details and dispute system endpoints.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, CurrentUser
|
||||||
|
from app.models import (
|
||||||
|
Assignment, AssignmentStatus, Participant, Challenge, User, Marathon,
|
||||||
|
Dispute, DisputeStatus, DisputeComment, DisputeVote,
|
||||||
|
)
|
||||||
|
from app.schemas import (
|
||||||
|
AssignmentDetailResponse, DisputeCreate, DisputeResponse,
|
||||||
|
DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate,
|
||||||
|
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.user import UserPublic
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
|
router = APIRouter(tags=["assignments"])
|
||||||
|
|
||||||
|
# Dispute window: 24 hours after completion
|
||||||
|
DISPUTE_WINDOW_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
|
def user_to_public(user: User) -> UserPublic:
|
||||||
|
"""Convert User model to UserPublic schema"""
|
||||||
|
return UserPublic(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
avatar_url=None,
|
||||||
|
role=user.role,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_dispute_response(dispute: Dispute, current_user_id: int) -> DisputeResponse:
|
||||||
|
"""Build DisputeResponse from Dispute model"""
|
||||||
|
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
||||||
|
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
||||||
|
|
||||||
|
my_vote = None
|
||||||
|
for v in dispute.votes:
|
||||||
|
if v.user_id == current_user_id:
|
||||||
|
my_vote = v.vote
|
||||||
|
break
|
||||||
|
|
||||||
|
# Ensure expires_at has UTC timezone info for correct frontend parsing
|
||||||
|
created_at_utc = dispute.created_at.replace(tzinfo=timezone.utc) if dispute.created_at.tzinfo is None else dispute.created_at
|
||||||
|
expires_at = created_at_utc + timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||||
|
|
||||||
|
return DisputeResponse(
|
||||||
|
id=dispute.id,
|
||||||
|
raised_by=user_to_public(dispute.raised_by),
|
||||||
|
reason=dispute.reason,
|
||||||
|
status=dispute.status,
|
||||||
|
comments=[
|
||||||
|
DisputeCommentResponse(
|
||||||
|
id=c.id,
|
||||||
|
user=user_to_public(c.user),
|
||||||
|
text=c.text,
|
||||||
|
created_at=c.created_at,
|
||||||
|
)
|
||||||
|
for c in sorted(dispute.comments, key=lambda x: x.created_at)
|
||||||
|
],
|
||||||
|
votes=[
|
||||||
|
{
|
||||||
|
"user": user_to_public(v.user),
|
||||||
|
"vote": v.vote,
|
||||||
|
"created_at": v.created_at,
|
||||||
|
}
|
||||||
|
for v in dispute.votes
|
||||||
|
],
|
||||||
|
votes_valid=votes_valid,
|
||||||
|
votes_invalid=votes_invalid,
|
||||||
|
my_vote=my_vote,
|
||||||
|
expires_at=expires_at,
|
||||||
|
created_at=dispute.created_at,
|
||||||
|
resolved_at=dispute.resolved_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assignments/{assignment_id}", response_model=AssignmentDetailResponse)
|
||||||
|
async def get_assignment_detail(
|
||||||
|
assignment_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get detailed information about an assignment including proofs and dispute"""
|
||||||
|
# Get assignment with all relationships
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.participant).selectinload(Participant.user),
|
||||||
|
selectinload(Assignment.dispute).selectinload(Dispute.raised_by),
|
||||||
|
selectinload(Assignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
|
||||||
|
selectinload(Assignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user),
|
||||||
|
)
|
||||||
|
.where(Assignment.id == assignment_id)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
|
# Check user is participant of the marathon
|
||||||
|
marathon_id = assignment.challenge.game.marathon_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
challenge = assignment.challenge
|
||||||
|
game = challenge.game
|
||||||
|
owner_user = assignment.participant.user
|
||||||
|
|
||||||
|
# Determine if user can dispute
|
||||||
|
can_dispute = False
|
||||||
|
if (
|
||||||
|
assignment.status == AssignmentStatus.COMPLETED.value
|
||||||
|
and assignment.completed_at
|
||||||
|
and assignment.participant.user_id != current_user.id
|
||||||
|
and assignment.dispute is None
|
||||||
|
):
|
||||||
|
time_since_completion = datetime.utcnow() - assignment.completed_at
|
||||||
|
can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||||
|
|
||||||
|
# Build proof URLs
|
||||||
|
proof_image_url = storage_service.get_url(assignment.proof_path, "proofs")
|
||||||
|
|
||||||
|
return AssignmentDetailResponse(
|
||||||
|
id=assignment.id,
|
||||||
|
challenge=ChallengeResponse(
|
||||||
|
id=challenge.id,
|
||||||
|
title=challenge.title,
|
||||||
|
description=challenge.description,
|
||||||
|
type=challenge.type,
|
||||||
|
difficulty=challenge.difficulty,
|
||||||
|
points=challenge.points,
|
||||||
|
estimated_time=challenge.estimated_time,
|
||||||
|
proof_type=challenge.proof_type,
|
||||||
|
proof_hint=challenge.proof_hint,
|
||||||
|
game=GameShort(
|
||||||
|
id=game.id,
|
||||||
|
title=game.title,
|
||||||
|
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||||
|
),
|
||||||
|
is_generated=challenge.is_generated,
|
||||||
|
created_at=challenge.created_at,
|
||||||
|
),
|
||||||
|
participant=user_to_public(owner_user),
|
||||||
|
status=assignment.status,
|
||||||
|
proof_url=assignment.proof_url,
|
||||||
|
proof_image_url=proof_image_url,
|
||||||
|
proof_comment=assignment.proof_comment,
|
||||||
|
points_earned=assignment.points_earned,
|
||||||
|
streak_at_completion=assignment.streak_at_completion,
|
||||||
|
started_at=assignment.started_at,
|
||||||
|
completed_at=assignment.completed_at,
|
||||||
|
can_dispute=can_dispute,
|
||||||
|
dispute=build_dispute_response(assignment.dispute, current_user.id) if assignment.dispute else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assignments/{assignment_id}/proof-media")
|
||||||
|
async def get_assignment_proof_media(
|
||||||
|
assignment_id: int,
|
||||||
|
request: Request,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Stream the proof media (image or video) for an assignment with Range support"""
|
||||||
|
# Get assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
)
|
||||||
|
.where(Assignment.id == assignment_id)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
|
# Check user is participant of the marathon
|
||||||
|
marathon_id = assignment.challenge.game.marathon_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Check if proof exists
|
||||||
|
if not assignment.proof_path:
|
||||||
|
raise HTTPException(status_code=404, detail="No proof media for this assignment")
|
||||||
|
|
||||||
|
# Get file from storage
|
||||||
|
file_data = await storage_service.get_file(assignment.proof_path, "proofs")
|
||||||
|
if not file_data:
|
||||||
|
raise HTTPException(status_code=404, detail="Proof media not found in storage")
|
||||||
|
|
||||||
|
content, content_type = file_data
|
||||||
|
file_size = len(content)
|
||||||
|
|
||||||
|
# Check if it's a video and handle Range requests
|
||||||
|
is_video = content_type.startswith("video/")
|
||||||
|
|
||||||
|
if is_video:
|
||||||
|
range_header = request.headers.get("range")
|
||||||
|
|
||||||
|
if range_header:
|
||||||
|
# Parse range header
|
||||||
|
range_match = range_header.replace("bytes=", "").split("-")
|
||||||
|
start = int(range_match[0]) if range_match[0] else 0
|
||||||
|
end = int(range_match[1]) if range_match[1] else file_size - 1
|
||||||
|
|
||||||
|
# Ensure valid range
|
||||||
|
if start >= file_size:
|
||||||
|
raise HTTPException(status_code=416, detail="Range not satisfiable")
|
||||||
|
|
||||||
|
end = min(end, file_size - 1)
|
||||||
|
chunk = content[start:end + 1]
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=chunk,
|
||||||
|
status_code=206,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": str(len(chunk)),
|
||||||
|
"Cache-Control": "public, max-age=31536000",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# No range header - return full video with Accept-Ranges
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": str(file_size),
|
||||||
|
"Cache-Control": "public, max-age=31536000",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# For images, just return the content
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "public, max-age=31536000",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Keep old endpoint for backwards compatibility
|
||||||
|
@router.get("/assignments/{assignment_id}/proof-image")
|
||||||
|
async def get_assignment_proof_image(
|
||||||
|
assignment_id: int,
|
||||||
|
request: Request,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Deprecated: Use proof-media instead. Redirects to proof-media."""
|
||||||
|
return await get_assignment_proof_media(assignment_id, request, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse)
|
||||||
|
async def create_dispute(
|
||||||
|
assignment_id: int,
|
||||||
|
data: DisputeCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Create a dispute against an assignment's proof"""
|
||||||
|
# Get assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.participant),
|
||||||
|
selectinload(Assignment.dispute),
|
||||||
|
)
|
||||||
|
.where(Assignment.id == assignment_id)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
|
# Check user is participant of the marathon
|
||||||
|
marathon_id = assignment.challenge.game.marathon_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if assignment.status != AssignmentStatus.COMPLETED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only dispute completed assignments")
|
||||||
|
|
||||||
|
if assignment.participant.user_id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot dispute your own assignment")
|
||||||
|
|
||||||
|
if assignment.dispute:
|
||||||
|
raise HTTPException(status_code=400, detail="A dispute already exists for this assignment")
|
||||||
|
|
||||||
|
if not assignment.completed_at:
|
||||||
|
raise HTTPException(status_code=400, detail="Assignment has no completion date")
|
||||||
|
|
||||||
|
time_since_completion = datetime.utcnow() - assignment.completed_at
|
||||||
|
if time_since_completion >= timedelta(hours=DISPUTE_WINDOW_HOURS):
|
||||||
|
raise HTTPException(status_code=400, detail="Dispute window has expired (24 hours)")
|
||||||
|
|
||||||
|
# Create dispute
|
||||||
|
dispute = Dispute(
|
||||||
|
assignment_id=assignment_id,
|
||||||
|
raised_by_id=current_user.id,
|
||||||
|
reason=data.reason,
|
||||||
|
status=DisputeStatus.OPEN.value,
|
||||||
|
)
|
||||||
|
db.add(dispute)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(dispute)
|
||||||
|
|
||||||
|
# Send notification to assignment owner
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if marathon:
|
||||||
|
await telegram_notifier.notify_dispute_raised(
|
||||||
|
db,
|
||||||
|
user_id=assignment.participant.user_id,
|
||||||
|
marathon_title=marathon.title,
|
||||||
|
challenge_title=assignment.challenge.title
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load relationships for response
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.raised_by),
|
||||||
|
selectinload(Dispute.comments).selectinload(DisputeComment.user),
|
||||||
|
selectinload(Dispute.votes).selectinload(DisputeVote.user),
|
||||||
|
)
|
||||||
|
.where(Dispute.id == dispute.id)
|
||||||
|
)
|
||||||
|
dispute = result.scalar_one()
|
||||||
|
|
||||||
|
return build_dispute_response(dispute, current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disputes/{dispute_id}/comments", response_model=DisputeCommentResponse)
|
||||||
|
async def add_dispute_comment(
|
||||||
|
dispute_id: int,
|
||||||
|
data: DisputeCommentCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Add a comment to a dispute discussion"""
|
||||||
|
# Get dispute with assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
)
|
||||||
|
.where(Dispute.id == dispute_id)
|
||||||
|
)
|
||||||
|
dispute = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not dispute:
|
||||||
|
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||||
|
|
||||||
|
if dispute.status != DisputeStatus.OPEN.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||||
|
|
||||||
|
# Check user is participant of the marathon
|
||||||
|
marathon_id = dispute.assignment.challenge.game.marathon_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Create comment
|
||||||
|
comment = DisputeComment(
|
||||||
|
dispute_id=dispute_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
text=data.text,
|
||||||
|
)
|
||||||
|
db.add(comment)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(comment)
|
||||||
|
|
||||||
|
# Get user for response
|
||||||
|
result = await db.execute(select(User).where(User.id == current_user.id))
|
||||||
|
user = result.scalar_one()
|
||||||
|
|
||||||
|
return DisputeCommentResponse(
|
||||||
|
id=comment.id,
|
||||||
|
user=user_to_public(user),
|
||||||
|
text=comment.text,
|
||||||
|
created_at=comment.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disputes/{dispute_id}/vote", response_model=MessageResponse)
|
||||||
|
async def vote_on_dispute(
|
||||||
|
dispute_id: int,
|
||||||
|
data: DisputeVoteCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Vote on a dispute (True = valid/proof is OK, False = invalid/proof is not OK)"""
|
||||||
|
# Get dispute with assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
)
|
||||||
|
.where(Dispute.id == dispute_id)
|
||||||
|
)
|
||||||
|
dispute = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not dispute:
|
||||||
|
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||||
|
|
||||||
|
if dispute.status != DisputeStatus.OPEN.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||||
|
|
||||||
|
# Check user is participant of the marathon
|
||||||
|
marathon_id = dispute.assignment.challenge.game.marathon_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Check if user already voted
|
||||||
|
result = await db.execute(
|
||||||
|
select(DisputeVote).where(
|
||||||
|
DisputeVote.dispute_id == dispute_id,
|
||||||
|
DisputeVote.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_vote = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_vote:
|
||||||
|
# Update existing vote
|
||||||
|
existing_vote.vote = data.vote
|
||||||
|
existing_vote.created_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
# Create new vote
|
||||||
|
vote = DisputeVote(
|
||||||
|
dispute_id=dispute_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
vote=data.vote,
|
||||||
|
)
|
||||||
|
db.add(vote)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
vote_text = "валидным" if data.vote else "невалидным"
|
||||||
|
return MessageResponse(message=f"Вы проголосовали: пруф {vote_text}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/returned-assignments", response_model=list[ReturnedAssignmentResponse])
|
||||||
|
async def get_returned_assignments(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get current user's returned assignments that need to be redone"""
|
||||||
|
# Check user is participant
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.user_id == current_user.id,
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Get returned assignments
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.dispute),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.status == AssignmentStatus.RETURNED.value,
|
||||||
|
)
|
||||||
|
.order_by(Assignment.completed_at.asc()) # Oldest first
|
||||||
|
)
|
||||||
|
assignments = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
ReturnedAssignmentResponse(
|
||||||
|
id=a.id,
|
||||||
|
challenge=ChallengeResponse(
|
||||||
|
id=a.challenge.id,
|
||||||
|
title=a.challenge.title,
|
||||||
|
description=a.challenge.description,
|
||||||
|
type=a.challenge.type,
|
||||||
|
difficulty=a.challenge.difficulty,
|
||||||
|
points=a.challenge.points,
|
||||||
|
estimated_time=a.challenge.estimated_time,
|
||||||
|
proof_type=a.challenge.proof_type,
|
||||||
|
proof_hint=a.challenge.proof_hint,
|
||||||
|
game=GameShort(
|
||||||
|
id=a.challenge.game.id,
|
||||||
|
title=a.challenge.game.title,
|
||||||
|
cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"),
|
||||||
|
),
|
||||||
|
is_generated=a.challenge.is_generated,
|
||||||
|
created_at=a.challenge.created_at,
|
||||||
|
),
|
||||||
|
original_completed_at=a.completed_at,
|
||||||
|
dispute_reason=a.dispute.reason if a.dispute else "Оспорено",
|
||||||
|
)
|
||||||
|
for a in assignments
|
||||||
|
]
|
||||||
@@ -10,15 +10,19 @@ from app.models import (
|
|||||||
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
|
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
|
||||||
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
|
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
|
||||||
)
|
)
|
||||||
|
from fastapi import UploadFile, File, Form
|
||||||
|
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
EventCreate, EventResponse, ActiveEventResponse, EventEffects,
|
EventCreate, EventResponse, ActiveEventResponse, EventEffects,
|
||||||
MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate,
|
MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate,
|
||||||
SwapRequestCreate, SwapRequestResponse, SwapRequestChallengeInfo, MySwapRequests,
|
SwapRequestCreate, SwapRequestResponse, SwapRequestChallengeInfo, MySwapRequests,
|
||||||
CommonEnemyLeaderboard,
|
CommonEnemyLeaderboard, EventAssignmentResponse, AssignmentResponse, CompleteResult,
|
||||||
)
|
)
|
||||||
|
from app.core.config import settings
|
||||||
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||||
from app.schemas.user import UserPublic
|
from app.schemas.user import UserPublic
|
||||||
from app.services.events import event_service
|
from app.services.events import event_service
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
|
||||||
router = APIRouter(tags=["events"])
|
router = APIRouter(tags=["events"])
|
||||||
|
|
||||||
@@ -635,129 +639,173 @@ async def cancel_swap_request(
|
|||||||
return MessageResponse(message="Swap request cancelled")
|
return MessageResponse(message="Swap request cancelled")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/rematch/{assignment_id}", response_model=MessageResponse)
|
# ==================== Game Choice Event Endpoints ====================
|
||||||
async def rematch_assignment(
|
|
||||||
|
|
||||||
|
class GameChoiceChallengeResponse(BaseModel):
|
||||||
|
"""Challenge option for game choice event"""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
difficulty: str
|
||||||
|
points: int
|
||||||
|
estimated_time: int | None
|
||||||
|
proof_type: str
|
||||||
|
proof_hint: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class GameChoiceChallengesResponse(BaseModel):
|
||||||
|
"""Response with available challenges for game choice"""
|
||||||
|
game_id: int
|
||||||
|
game_title: str
|
||||||
|
challenges: list[GameChoiceChallengeResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class GameChoiceSelectRequest(BaseModel):
|
||||||
|
"""Request to select a challenge during game choice event"""
|
||||||
|
challenge_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/game-choice/challenges", response_model=GameChoiceChallengesResponse)
|
||||||
|
async def get_game_choice_challenges(
|
||||||
marathon_id: int,
|
marathon_id: int,
|
||||||
assignment_id: int,
|
game_id: int,
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
):
|
):
|
||||||
"""Retry a dropped assignment (during rematch event)"""
|
"""Get 3 random challenges from a game for game choice event"""
|
||||||
|
from app.models import Game
|
||||||
|
from sqlalchemy.sql.expression import func
|
||||||
|
|
||||||
await get_marathon_or_404(db, marathon_id)
|
await get_marathon_or_404(db, marathon_id)
|
||||||
participant = await require_participant(db, current_user.id, marathon_id)
|
participant = await require_participant(db, current_user.id, marathon_id)
|
||||||
|
|
||||||
# Check active rematch event
|
# Check active game_choice event
|
||||||
event = await event_service.get_active_event(db, marathon_id)
|
event = await event_service.get_active_event(db, marathon_id)
|
||||||
if not event or event.type != EventType.REMATCH.value:
|
if not event or event.type != EventType.GAME_CHOICE.value:
|
||||||
raise HTTPException(status_code=400, detail="No active rematch event")
|
raise HTTPException(status_code=400, detail="No active game choice event")
|
||||||
|
|
||||||
# Check no current active assignment
|
# Get the game
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment).where(
|
select(Game).where(Game.id == game_id, Game.marathon_id == marathon_id)
|
||||||
Assignment.participant_id == participant.id,
|
|
||||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if result.scalar_one_or_none():
|
game = result.scalar_one_or_none()
|
||||||
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
if not game:
|
||||||
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
|
||||||
# Get the dropped assignment
|
# Get 3 random challenges from this game
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.where(Challenge.game_id == game_id)
|
||||||
|
.order_by(func.random())
|
||||||
|
.limit(3)
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
if not challenges:
|
||||||
|
raise HTTPException(status_code=400, detail="No challenges available for this game")
|
||||||
|
|
||||||
|
return GameChoiceChallengesResponse(
|
||||||
|
game_id=game.id,
|
||||||
|
game_title=game.title,
|
||||||
|
challenges=[
|
||||||
|
GameChoiceChallengeResponse(
|
||||||
|
id=c.id,
|
||||||
|
title=c.title,
|
||||||
|
description=c.description,
|
||||||
|
difficulty=c.difficulty,
|
||||||
|
points=c.points,
|
||||||
|
estimated_time=c.estimated_time,
|
||||||
|
proof_type=c.proof_type,
|
||||||
|
proof_hint=c.proof_hint,
|
||||||
|
)
|
||||||
|
for c in challenges
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/marathons/{marathon_id}/game-choice/select", response_model=MessageResponse)
|
||||||
|
async def select_game_choice_challenge(
|
||||||
|
marathon_id: int,
|
||||||
|
data: GameChoiceSelectRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Select a challenge during game choice event (replaces current assignment if any)"""
|
||||||
|
await get_marathon_or_404(db, marathon_id)
|
||||||
|
participant = await require_participant(db, current_user.id, marathon_id)
|
||||||
|
|
||||||
|
# Check active game_choice event
|
||||||
|
event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
if not event or event.type != EventType.GAME_CHOICE.value:
|
||||||
|
raise HTTPException(status_code=400, detail="No active game choice event")
|
||||||
|
|
||||||
|
# Get the challenge
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.options(selectinload(Challenge.game))
|
||||||
|
.where(Challenge.id == data.challenge_id)
|
||||||
|
)
|
||||||
|
challenge = result.scalar_one_or_none()
|
||||||
|
if not challenge:
|
||||||
|
raise HTTPException(status_code=404, detail="Challenge not found")
|
||||||
|
|
||||||
|
# Verify challenge belongs to this marathon
|
||||||
|
if challenge.game.marathon_id != marathon_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Challenge does not belong to this marathon")
|
||||||
|
|
||||||
|
# Check for current active assignment (non-event)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(selectinload(Assignment.challenge))
|
.options(selectinload(Assignment.challenge))
|
||||||
.where(
|
.where(
|
||||||
Assignment.id == assignment_id,
|
|
||||||
Assignment.participant_id == participant.id,
|
Assignment.participant_id == participant.id,
|
||||||
Assignment.status == AssignmentStatus.DROPPED.value,
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||||
|
Assignment.is_event_assignment == False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
dropped = result.scalar_one_or_none()
|
current_assignment = result.scalar_one_or_none()
|
||||||
if not dropped:
|
|
||||||
raise HTTPException(status_code=404, detail="Dropped assignment not found")
|
|
||||||
|
|
||||||
# Create new assignment for the same challenge (with rematch event_type for 50% points)
|
# If there's a current assignment, replace it (free drop during this event)
|
||||||
|
old_challenge_title = None
|
||||||
|
if current_assignment:
|
||||||
|
old_challenge_title = current_assignment.challenge.title
|
||||||
|
# Mark old assignment as dropped (no penalty during game_choice event)
|
||||||
|
current_assignment.status = AssignmentStatus.DROPPED.value
|
||||||
|
current_assignment.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Create new assignment with chosen challenge
|
||||||
new_assignment = Assignment(
|
new_assignment = Assignment(
|
||||||
participant_id=participant.id,
|
participant_id=participant.id,
|
||||||
challenge_id=dropped.challenge_id,
|
challenge_id=data.challenge_id,
|
||||||
status=AssignmentStatus.ACTIVE.value,
|
status=AssignmentStatus.ACTIVE.value,
|
||||||
event_type=EventType.REMATCH.value,
|
event_type=EventType.GAME_CHOICE.value,
|
||||||
)
|
)
|
||||||
db.add(new_assignment)
|
db.add(new_assignment)
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
|
activity_data = {
|
||||||
|
"game": challenge.game.title,
|
||||||
|
"challenge": challenge.title,
|
||||||
|
"event_type": EventType.GAME_CHOICE.value,
|
||||||
|
}
|
||||||
|
if old_challenge_title:
|
||||||
|
activity_data["replaced_challenge"] = old_challenge_title
|
||||||
|
|
||||||
activity = Activity(
|
activity = Activity(
|
||||||
marathon_id=marathon_id,
|
marathon_id=marathon_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
type=ActivityType.REMATCH.value,
|
type=ActivityType.SPIN.value, # Treat as a spin activity
|
||||||
data={
|
data=activity_data,
|
||||||
"challenge": dropped.challenge.title,
|
|
||||||
"original_assignment_id": assignment_id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
db.add(activity)
|
db.add(activity)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message="Rematch started! Complete for 50% points")
|
if old_challenge_title:
|
||||||
|
return MessageResponse(message=f"Задание заменено! Теперь у вас: {challenge.title}")
|
||||||
|
else:
|
||||||
class DroppedAssignmentResponse(BaseModel):
|
return MessageResponse(message=f"Задание выбрано: {challenge.title}")
|
||||||
id: int
|
|
||||||
challenge: ChallengeResponse
|
|
||||||
dropped_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/dropped-assignments", response_model=list[DroppedAssignmentResponse])
|
|
||||||
async def get_dropped_assignments(
|
|
||||||
marathon_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbSession,
|
|
||||||
):
|
|
||||||
"""Get dropped assignments that can be rematched"""
|
|
||||||
await get_marathon_or_404(db, marathon_id)
|
|
||||||
participant = await require_participant(db, current_user.id, marathon_id)
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(Assignment)
|
|
||||||
.options(
|
|
||||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
Assignment.participant_id == participant.id,
|
|
||||||
Assignment.status == AssignmentStatus.DROPPED.value,
|
|
||||||
)
|
|
||||||
.order_by(Assignment.started_at.desc())
|
|
||||||
)
|
|
||||||
dropped = result.scalars().all()
|
|
||||||
|
|
||||||
return [
|
|
||||||
DroppedAssignmentResponse(
|
|
||||||
id=a.id,
|
|
||||||
challenge=ChallengeResponse(
|
|
||||||
id=a.challenge.id,
|
|
||||||
title=a.challenge.title,
|
|
||||||
description=a.challenge.description,
|
|
||||||
type=a.challenge.type,
|
|
||||||
difficulty=a.challenge.difficulty,
|
|
||||||
points=a.challenge.points,
|
|
||||||
estimated_time=a.challenge.estimated_time,
|
|
||||||
proof_type=a.challenge.proof_type,
|
|
||||||
proof_hint=a.challenge.proof_hint,
|
|
||||||
game=GameShort(
|
|
||||||
id=a.challenge.game.id,
|
|
||||||
title=a.challenge.game.title,
|
|
||||||
cover_url=None,
|
|
||||||
),
|
|
||||||
is_generated=a.challenge.is_generated,
|
|
||||||
created_at=a.challenge.created_at,
|
|
||||||
),
|
|
||||||
dropped_at=a.completed_at or a.started_at,
|
|
||||||
)
|
|
||||||
for a in dropped
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/swap-candidates", response_model=list[SwapCandidate])
|
@router.get("/marathons/{marathon_id}/swap-candidates", response_model=list[SwapCandidate])
|
||||||
@@ -864,3 +912,262 @@ async def get_common_enemy_leaderboard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return leaderboard
|
return leaderboard
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Event Assignment Endpoints ====================
|
||||||
|
|
||||||
|
|
||||||
|
def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
|
||||||
|
"""Convert Assignment model to AssignmentResponse"""
|
||||||
|
challenge = assignment.challenge
|
||||||
|
game = challenge.game
|
||||||
|
return AssignmentResponse(
|
||||||
|
id=assignment.id,
|
||||||
|
challenge=ChallengeResponse(
|
||||||
|
id=challenge.id,
|
||||||
|
title=challenge.title,
|
||||||
|
description=challenge.description,
|
||||||
|
type=challenge.type,
|
||||||
|
difficulty=challenge.difficulty,
|
||||||
|
points=challenge.points,
|
||||||
|
estimated_time=challenge.estimated_time,
|
||||||
|
proof_type=challenge.proof_type,
|
||||||
|
proof_hint=challenge.proof_hint,
|
||||||
|
game=GameShort(
|
||||||
|
id=game.id,
|
||||||
|
title=game.title,
|
||||||
|
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||||
|
),
|
||||||
|
is_generated=challenge.is_generated,
|
||||||
|
created_at=challenge.created_at,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/event-assignment", response_model=EventAssignmentResponse)
|
||||||
|
async def get_event_assignment(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get current user's event assignment (Common Enemy)"""
|
||||||
|
await get_marathon_or_404(db, marathon_id)
|
||||||
|
participant = await require_participant(db, current_user.id, marathon_id)
|
||||||
|
|
||||||
|
# Get active common enemy event
|
||||||
|
event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
|
||||||
|
# Find event assignment for this participant
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.is_event_assignment == True,
|
||||||
|
)
|
||||||
|
.order_by(Assignment.started_at.desc())
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Check if completed
|
||||||
|
is_completed = assignment.status == AssignmentStatus.COMPLETED.value if assignment else False
|
||||||
|
|
||||||
|
# If no active event but we have an assignment, it might be from a past event
|
||||||
|
# Only return it if the event is still active
|
||||||
|
if not event or event.type != EventType.COMMON_ENEMY.value:
|
||||||
|
# Check if assignment belongs to an inactive event
|
||||||
|
if assignment and assignment.event_id:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Event).where(Event.id == assignment.event_id)
|
||||||
|
)
|
||||||
|
assignment_event = result.scalar_one_or_none()
|
||||||
|
if assignment_event and not assignment_event.is_active:
|
||||||
|
# Event ended, don't return the assignment
|
||||||
|
return EventAssignmentResponse(
|
||||||
|
assignment=None,
|
||||||
|
event_id=None,
|
||||||
|
challenge_id=None,
|
||||||
|
is_completed=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
is_completed=is_completed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/event-assignments/{assignment_id}/complete", response_model=CompleteResult)
|
||||||
|
async def complete_event_assignment(
|
||||||
|
assignment_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
proof_url: str | None = Form(None),
|
||||||
|
comment: str | None = Form(None),
|
||||||
|
proof_file: UploadFile | None = File(None),
|
||||||
|
):
|
||||||
|
"""Complete an event assignment (Common Enemy) with proof"""
|
||||||
|
from app.services.points import PointsService
|
||||||
|
points_service = PointsService()
|
||||||
|
|
||||||
|
# Get assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.participant),
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
)
|
||||||
|
.where(Assignment.id == assignment_id)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
|
if assignment.participant.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="This is not your assignment")
|
||||||
|
|
||||||
|
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Assignment is not active")
|
||||||
|
|
||||||
|
# Must be event assignment
|
||||||
|
if not assignment.is_event_assignment:
|
||||||
|
raise HTTPException(status_code=400, detail="This is not an event assignment")
|
||||||
|
|
||||||
|
# Need either file or URL
|
||||||
|
if not proof_file and not proof_url:
|
||||||
|
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
|
||||||
|
|
||||||
|
# Handle file upload
|
||||||
|
if proof_file:
|
||||||
|
contents = await proof_file.read()
|
||||||
|
if len(contents) > settings.MAX_UPLOAD_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
|
||||||
|
)
|
||||||
|
|
||||||
|
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
|
||||||
|
if ext not in settings.ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload file to storage
|
||||||
|
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
|
||||||
|
file_path = await storage_service.upload_file(
|
||||||
|
content=contents,
|
||||||
|
folder="proofs",
|
||||||
|
filename=filename,
|
||||||
|
content_type=proof_file.content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
assignment.proof_path = file_path
|
||||||
|
else:
|
||||||
|
assignment.proof_url = proof_url
|
||||||
|
|
||||||
|
assignment.proof_comment = comment
|
||||||
|
|
||||||
|
# Get marathon_id
|
||||||
|
marathon_id = assignment.challenge.game.marathon_id
|
||||||
|
|
||||||
|
# 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
|
||||||
|
common_enemy_winners = None
|
||||||
|
|
||||||
|
if active_event and active_event.type == EventType.COMMON_ENEMY.value:
|
||||||
|
common_enemy_bonus, common_enemy_closed, common_enemy_winners = await event_service.record_common_enemy_completion(
|
||||||
|
db, active_event, participant.id, current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
total_points = base_points + common_enemy_bonus
|
||||||
|
|
||||||
|
# Update assignment
|
||||||
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
|
assignment.points_earned = total_points
|
||||||
|
assignment.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Update participant points (event assignments add to total but don't affect streak)
|
||||||
|
participant.total_points += total_points
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
activity_data = {
|
||||||
|
"assignment_id": assignment.id,
|
||||||
|
"game": challenge.game.title,
|
||||||
|
"challenge": challenge.title,
|
||||||
|
"difficulty": challenge.difficulty,
|
||||||
|
"points": total_points,
|
||||||
|
"event_type": EventType.COMMON_ENEMY.value,
|
||||||
|
"is_event_assignment": True,
|
||||||
|
}
|
||||||
|
if common_enemy_bonus:
|
||||||
|
activity_data["common_enemy_bonus"] = common_enemy_bonus
|
||||||
|
|
||||||
|
activity = Activity(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
type=ActivityType.COMPLETE.value,
|
||||||
|
data=activity_data,
|
||||||
|
)
|
||||||
|
db.add(activity)
|
||||||
|
|
||||||
|
# If common enemy event auto-closed, log the event end with winners
|
||||||
|
if common_enemy_closed and common_enemy_winners:
|
||||||
|
# Load winner nicknames
|
||||||
|
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
|
||||||
|
users_result = await db.execute(
|
||||||
|
select(User).where(User.id.in_(winner_user_ids))
|
||||||
|
)
|
||||||
|
users_map = {u.id: u.nickname for u in users_result.scalars().all()}
|
||||||
|
|
||||||
|
winners_data = [
|
||||||
|
{
|
||||||
|
"user_id": w["user_id"],
|
||||||
|
"nickname": users_map.get(w["user_id"], "Unknown"),
|
||||||
|
"rank": w["rank"],
|
||||||
|
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
|
||||||
|
}
|
||||||
|
for w in common_enemy_winners
|
||||||
|
]
|
||||||
|
|
||||||
|
event_end_activity = Activity(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
type=ActivityType.EVENT_END.value,
|
||||||
|
data={
|
||||||
|
"event_type": EventType.COMMON_ENEMY.value,
|
||||||
|
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
|
||||||
|
"auto_closed": True,
|
||||||
|
"winners": winners_data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(event_end_activity)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return CompleteResult(
|
||||||
|
points_earned=total_points,
|
||||||
|
streak_bonus=0, # Event assignments don't give streak bonus
|
||||||
|
total_points=participant.total_points,
|
||||||
|
new_streak=participant.current_streak, # Streak unchanged
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from sqlalchemy import select, func
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser
|
||||||
from app.models import Activity, Participant
|
from app.models import Activity, Participant, Dispute, ActivityType
|
||||||
|
from app.models.dispute import DisputeStatus
|
||||||
from app.schemas import FeedResponse, ActivityResponse, UserPublic
|
from app.schemas import FeedResponse, ActivityResponse, UserPublic
|
||||||
|
|
||||||
router = APIRouter(tags=["feed"])
|
router = APIRouter(tags=["feed"])
|
||||||
@@ -44,16 +45,40 @@ async def get_feed(
|
|||||||
)
|
)
|
||||||
activities = result.scalars().all()
|
activities = result.scalars().all()
|
||||||
|
|
||||||
items = [
|
# Get assignment_ids from complete activities to check for disputes
|
||||||
ActivityResponse(
|
complete_assignment_ids = []
|
||||||
id=a.id,
|
for a in activities:
|
||||||
type=a.type,
|
if a.type == ActivityType.COMPLETE.value and a.data and a.data.get("assignment_id"):
|
||||||
user=UserPublic.model_validate(a.user),
|
complete_assignment_ids.append(a.data["assignment_id"])
|
||||||
data=a.data,
|
|
||||||
created_at=a.created_at,
|
# Get disputes for these assignments
|
||||||
|
disputes_map: dict[int, str] = {}
|
||||||
|
if complete_assignment_ids:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute).where(Dispute.assignment_id.in_(complete_assignment_ids))
|
||||||
|
)
|
||||||
|
for dispute in result.scalars().all():
|
||||||
|
disputes_map[dispute.assignment_id] = dispute.status
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for a in activities:
|
||||||
|
data = dict(a.data) if a.data else {}
|
||||||
|
|
||||||
|
# Add dispute status to complete activities
|
||||||
|
if a.type == ActivityType.COMPLETE.value and a.data and a.data.get("assignment_id"):
|
||||||
|
assignment_id = a.data["assignment_id"]
|
||||||
|
if assignment_id in disputes_map:
|
||||||
|
data["dispute_status"] = disputes_map[assignment_id]
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
ActivityResponse(
|
||||||
|
id=a.id,
|
||||||
|
type=a.type,
|
||||||
|
user=UserPublic.model_validate(a.user),
|
||||||
|
data=data if data else None,
|
||||||
|
created_at=a.created_at,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for a in activities
|
|
||||||
]
|
|
||||||
|
|
||||||
return FeedResponse(
|
return FeedResponse(
|
||||||
items=items,
|
items=items,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query
|
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
DbSession, CurrentUser,
|
DbSession, CurrentUser,
|
||||||
@@ -11,6 +9,7 @@ from app.api.deps import (
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
||||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
|
||||||
router = APIRouter(tags=["games"])
|
router = APIRouter(tags=["games"])
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
|
|||||||
return GameResponse(
|
return GameResponse(
|
||||||
id=game.id,
|
id=game.id,
|
||||||
title=game.title,
|
title=game.title,
|
||||||
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
|
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||||
download_url=game.download_url,
|
download_url=game.download_url,
|
||||||
genre=game.genre,
|
genre=game.genre,
|
||||||
status=game.status,
|
status=game.status,
|
||||||
@@ -354,15 +353,20 @@ async def upload_cover(
|
|||||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save file
|
# Delete old cover if exists
|
||||||
filename = f"{game_id}_{uuid.uuid4().hex}.{ext}"
|
if game.cover_path:
|
||||||
filepath = Path(settings.UPLOAD_DIR) / "covers" / filename
|
await storage_service.delete_file(game.cover_path)
|
||||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with open(filepath, "wb") as f:
|
# Upload file
|
||||||
f.write(contents)
|
filename = storage_service.generate_filename(game_id, file.filename)
|
||||||
|
file_path = await storage_service.upload_file(
|
||||||
|
content=contents,
|
||||||
|
folder="covers",
|
||||||
|
filename=filename,
|
||||||
|
content_type=file.content_type or "image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
game.cover_path = str(filepath)
|
game.cover_path = file_path
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return await get_game(game_id, current_user, db)
|
return await get_game(game_id, current_user, db)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.schemas import (
|
|||||||
UserPublic,
|
UserPublic,
|
||||||
SetParticipantRole,
|
SetParticipantRole,
|
||||||
)
|
)
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(prefix="/marathons", tags=["marathons"])
|
router = APIRouter(prefix="/marathons", tags=["marathons"])
|
||||||
|
|
||||||
@@ -294,6 +295,9 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Send Telegram notifications
|
||||||
|
await telegram_notifier.notify_marathon_start(db, marathon_id, marathon.title)
|
||||||
|
|
||||||
return await get_marathon(marathon_id, current_user, db)
|
return await get_marathon(marathon_id, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
@@ -319,6 +323,9 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Send Telegram notifications
|
||||||
|
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
|
||||||
|
|
||||||
return await get_marathon(marathon_id, current_user, db)
|
return await get_marathon(marathon_id, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
393
backend/app/api/v1/telegram.py
Normal file
393
backend/app/api/v1/telegram.py
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, CurrentUser
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_telegram_link_token, verify_telegram_link_token
|
||||||
|
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/telegram", tags=["telegram"])
|
||||||
|
|
||||||
|
|
||||||
|
# Schemas
|
||||||
|
class TelegramLinkToken(BaseModel):
|
||||||
|
token: str
|
||||||
|
bot_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfirmLink(BaseModel):
|
||||||
|
token: str
|
||||||
|
telegram_id: int
|
||||||
|
telegram_username: str | None = None
|
||||||
|
telegram_first_name: str | None = None
|
||||||
|
telegram_last_name: str | None = None
|
||||||
|
telegram_avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramLinkResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
nickname: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramUserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
nickname: str
|
||||||
|
login: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramMarathonResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
status: str
|
||||||
|
total_points: int
|
||||||
|
position: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramMarathonDetails(BaseModel):
|
||||||
|
marathon: dict
|
||||||
|
participant: dict
|
||||||
|
position: int
|
||||||
|
active_events: list[dict]
|
||||||
|
current_assignment: dict | None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramStatsResponse(BaseModel):
|
||||||
|
marathons_completed: int
|
||||||
|
marathons_active: int
|
||||||
|
challenges_completed: int
|
||||||
|
total_points: int
|
||||||
|
best_streak: int
|
||||||
|
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
@router.post("/generate-link-token", response_model=TelegramLinkToken)
|
||||||
|
async def generate_link_token(current_user: CurrentUser):
|
||||||
|
"""Generate a one-time token for Telegram account linking."""
|
||||||
|
logger.info(f"[TG_LINK] Generating link token for user {current_user.id} ({current_user.nickname})")
|
||||||
|
|
||||||
|
# Create a short token (≤64 chars) for Telegram deep link
|
||||||
|
token = create_telegram_link_token(
|
||||||
|
user_id=current_user.id,
|
||||||
|
expire_minutes=settings.TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES
|
||||||
|
)
|
||||||
|
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
|
||||||
|
|
||||||
|
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot"
|
||||||
|
bot_url = f"https://t.me/{bot_username}?start={token}"
|
||||||
|
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
|
||||||
|
|
||||||
|
return TelegramLinkToken(token=token, bot_url=bot_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/confirm-link", response_model=TelegramLinkResponse)
|
||||||
|
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
|
||||||
|
"""Confirm Telegram account linking (called by bot)."""
|
||||||
|
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
|
||||||
|
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
|
||||||
|
logger.info(f"[TG_CONFIRM] telegram_username: {data.telegram_username}")
|
||||||
|
logger.info(f"[TG_CONFIRM] token: {data.token}")
|
||||||
|
|
||||||
|
# Verify short token and extract user_id
|
||||||
|
user_id = verify_telegram_link_token(data.token)
|
||||||
|
logger.info(f"[TG_CONFIRM] Verified user_id: {user_id}")
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
logger.error(f"[TG_CONFIRM] FAILED: Token invalid or expired")
|
||||||
|
return TelegramLinkResponse(success=False, error="Ссылка недействительна или устарела")
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
logger.info(f"[TG_CONFIRM] Found user: {user.nickname if user else 'NOT FOUND'}")
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.error(f"[TG_CONFIRM] FAILED: User not found")
|
||||||
|
return TelegramLinkResponse(success=False, error="Пользователь не найден")
|
||||||
|
|
||||||
|
# Check if telegram_id already linked to another user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == data.telegram_id, User.id != user_id)
|
||||||
|
)
|
||||||
|
existing_user = result.scalar_one_or_none()
|
||||||
|
if existing_user:
|
||||||
|
logger.error(f"[TG_CONFIRM] FAILED: Telegram already linked to user {existing_user.id}")
|
||||||
|
return TelegramLinkResponse(
|
||||||
|
success=False,
|
||||||
|
error="Этот Telegram аккаунт уже привязан к другому пользователю"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Link account
|
||||||
|
logger.info(f"[TG_CONFIRM] Linking telegram_id={data.telegram_id} to user_id={user_id}")
|
||||||
|
user.telegram_id = data.telegram_id
|
||||||
|
user.telegram_username = data.telegram_username
|
||||||
|
user.telegram_first_name = data.telegram_first_name
|
||||||
|
user.telegram_last_name = data.telegram_last_name
|
||||||
|
user.telegram_avatar_url = data.telegram_avatar_url
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"[TG_CONFIRM] SUCCESS! User {user.nickname} linked to Telegram {data.telegram_id}")
|
||||||
|
|
||||||
|
return TelegramLinkResponse(success=True, nickname=user.nickname)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
|
||||||
|
async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
|
||||||
|
"""Get user by Telegram ID."""
|
||||||
|
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.info(f"[TG_USER] No user found for telegram_id={telegram_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(f"[TG_USER] Found user: {user.id} ({user.nickname})")
|
||||||
|
return TelegramUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
nickname=user.nickname,
|
||||||
|
login=user.login,
|
||||||
|
avatar_url=user.avatar_url
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
|
||||||
|
async def unlink_telegram(telegram_id: int, db: DbSession):
|
||||||
|
"""Unlink Telegram account."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return TelegramLinkResponse(success=False, error="Аккаунт не найден")
|
||||||
|
|
||||||
|
user.telegram_id = None
|
||||||
|
user.telegram_username = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return TelegramLinkResponse(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
|
||||||
|
async def get_user_marathons(telegram_id: int, db: DbSession):
|
||||||
|
"""Get user's marathons by Telegram ID."""
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get participations with marathons
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant, Marathon)
|
||||||
|
.join(Marathon, Participant.marathon_id == Marathon.id)
|
||||||
|
.where(Participant.user_id == user.id)
|
||||||
|
.order_by(Marathon.created_at.desc())
|
||||||
|
)
|
||||||
|
participations = result.all()
|
||||||
|
|
||||||
|
marathons = []
|
||||||
|
for participant, marathon in participations:
|
||||||
|
# Calculate position
|
||||||
|
position_result = await db.execute(
|
||||||
|
select(func.count(Participant.id) + 1)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon.id,
|
||||||
|
Participant.total_points > participant.total_points
|
||||||
|
)
|
||||||
|
)
|
||||||
|
position = position_result.scalar() or 1
|
||||||
|
|
||||||
|
marathons.append(TelegramMarathonResponse(
|
||||||
|
id=marathon.id,
|
||||||
|
title=marathon.title,
|
||||||
|
status=marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
|
||||||
|
total_points=participant.total_points,
|
||||||
|
position=position
|
||||||
|
))
|
||||||
|
|
||||||
|
return marathons
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
|
||||||
|
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession):
|
||||||
|
"""Get marathon details for user by Telegram ID."""
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get marathon
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon).where(Marathon.id == marathon_id)
|
||||||
|
)
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not marathon:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get participant
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant)
|
||||||
|
.where(Participant.marathon_id == marathon_id, Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not participant:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate position
|
||||||
|
position_result = await db.execute(
|
||||||
|
select(func.count(Participant.id) + 1)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
Participant.total_points > participant.total_points
|
||||||
|
)
|
||||||
|
)
|
||||||
|
position = position_result.scalar() or 1
|
||||||
|
|
||||||
|
# Get active events
|
||||||
|
result = await db.execute(
|
||||||
|
select(Event)
|
||||||
|
.where(Event.marathon_id == marathon_id, Event.is_active == True)
|
||||||
|
)
|
||||||
|
active_events = result.scalars().all()
|
||||||
|
|
||||||
|
events_data = [
|
||||||
|
{
|
||||||
|
"id": e.id,
|
||||||
|
"type": e.type.value if hasattr(e.type, 'value') else e.type,
|
||||||
|
"start_time": e.start_time.isoformat() if e.start_time else None,
|
||||||
|
"end_time": e.end_time.isoformat() if e.end_time else None
|
||||||
|
}
|
||||||
|
for e in active_events
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get current assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.status == "active"
|
||||||
|
)
|
||||||
|
.order_by(Assignment.started_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
assignment_data = None
|
||||||
|
if assignment:
|
||||||
|
challenge = assignment.challenge
|
||||||
|
game = challenge.game if challenge else None
|
||||||
|
assignment_data = {
|
||||||
|
"id": assignment.id,
|
||||||
|
"status": assignment.status.value if hasattr(assignment.status, 'value') else assignment.status,
|
||||||
|
"challenge": {
|
||||||
|
"id": challenge.id if challenge else None,
|
||||||
|
"title": challenge.title if challenge else None,
|
||||||
|
"difficulty": challenge.difficulty.value if challenge and hasattr(challenge.difficulty, 'value') else (challenge.difficulty if challenge else None),
|
||||||
|
"points": challenge.points if challenge else None,
|
||||||
|
"game": {
|
||||||
|
"id": game.id if game else None,
|
||||||
|
"title": game.title if game else None
|
||||||
|
}
|
||||||
|
} if challenge else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return TelegramMarathonDetails(
|
||||||
|
marathon={
|
||||||
|
"id": marathon.id,
|
||||||
|
"title": marathon.title,
|
||||||
|
"status": marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
|
||||||
|
"description": marathon.description
|
||||||
|
},
|
||||||
|
participant={
|
||||||
|
"total_points": participant.total_points,
|
||||||
|
"current_streak": participant.current_streak,
|
||||||
|
"drop_count": participant.drop_count
|
||||||
|
},
|
||||||
|
position=position,
|
||||||
|
active_events=events_data,
|
||||||
|
current_assignment=assignment_data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
|
||||||
|
async def get_user_stats(telegram_id: int, db: DbSession):
|
||||||
|
"""Get user's overall statistics by Telegram ID."""
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get participations
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant, Marathon)
|
||||||
|
.join(Marathon, Participant.marathon_id == Marathon.id)
|
||||||
|
.where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
participations = result.all()
|
||||||
|
|
||||||
|
marathons_completed = 0
|
||||||
|
marathons_active = 0
|
||||||
|
total_points = 0
|
||||||
|
best_streak = 0
|
||||||
|
|
||||||
|
for participant, marathon in participations:
|
||||||
|
status = marathon.status.value if hasattr(marathon.status, 'value') else marathon.status
|
||||||
|
if status == "finished":
|
||||||
|
marathons_completed += 1
|
||||||
|
elif status == "active":
|
||||||
|
marathons_active += 1
|
||||||
|
|
||||||
|
total_points += participant.total_points
|
||||||
|
if participant.current_streak > best_streak:
|
||||||
|
best_streak = participant.current_streak
|
||||||
|
|
||||||
|
# Count completed assignments
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(Assignment.id))
|
||||||
|
.join(Participant, Assignment.participant_id == Participant.id)
|
||||||
|
.where(Participant.user_id == user.id, Assignment.status == "completed")
|
||||||
|
)
|
||||||
|
challenges_completed = result.scalar() or 0
|
||||||
|
|
||||||
|
return TelegramStatsResponse(
|
||||||
|
marathons_completed=marathons_completed,
|
||||||
|
marathons_active=marathons_active,
|
||||||
|
challenges_completed=challenges_completed,
|
||||||
|
total_points=total_points,
|
||||||
|
best_streak=best_streak
|
||||||
|
)
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status, UploadFile, File
|
from fastapi import APIRouter, HTTPException, status, UploadFile, File
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse
|
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
@@ -64,16 +63,21 @@ async def upload_avatar(
|
|||||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save file
|
# Delete old avatar if exists
|
||||||
filename = f"{current_user.id}_{uuid.uuid4().hex}.{ext}"
|
if current_user.avatar_path:
|
||||||
filepath = Path(settings.UPLOAD_DIR) / "avatars" / filename
|
await storage_service.delete_file(current_user.avatar_path)
|
||||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with open(filepath, "wb") as f:
|
# Upload file
|
||||||
f.write(contents)
|
filename = storage_service.generate_filename(current_user.id, file.filename)
|
||||||
|
file_path = await storage_service.upload_file(
|
||||||
|
content=contents,
|
||||||
|
folder="avatars",
|
||||||
|
filename=filename,
|
||||||
|
content_type=file.content_type or "image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
# Update user
|
# Update user
|
||||||
current_user.avatar_path = str(filepath)
|
current_user.avatar_path = file_path
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
|
|
||||||
@@ -102,3 +106,22 @@ async def link_telegram(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message="Telegram account linked successfully")
|
return MessageResponse(message="Telegram account linked successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/telegram/unlink", response_model=MessageResponse)
|
||||||
|
async def unlink_telegram(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
if not current_user.telegram_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Telegram account is not linked",
|
||||||
|
)
|
||||||
|
|
||||||
|
current_user.telegram_id = None
|
||||||
|
current_user.telegram_username = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(message="Telegram account unlinked successfully")
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ from datetime import datetime
|
|||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Marathon, MarathonStatus, Game, Challenge, Participant,
|
Marathon, MarathonStatus, Game, Challenge, Participant,
|
||||||
Assignment, AssignmentStatus, Activity, ActivityType,
|
Assignment, AssignmentStatus, Activity, ActivityType,
|
||||||
EventType, Difficulty
|
EventType, Difficulty, User
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||||
@@ -19,6 +17,7 @@ from app.schemas import (
|
|||||||
)
|
)
|
||||||
from app.services.points import PointsService
|
from app.services.points import PointsService
|
||||||
from app.services.events import event_service
|
from app.services.events import event_service
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
|
||||||
router = APIRouter(tags=["wheel"])
|
router = APIRouter(tags=["wheel"])
|
||||||
|
|
||||||
@@ -38,7 +37,14 @@ async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Particip
|
|||||||
return participant
|
return participant
|
||||||
|
|
||||||
|
|
||||||
async def get_active_assignment(db, participant_id: int) -> Assignment | None:
|
async def get_active_assignment(db, participant_id: int, is_event: bool = False) -> Assignment | None:
|
||||||
|
"""Get active assignment for participant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
participant_id: Participant ID
|
||||||
|
is_event: If True, get event assignment (Common Enemy). If False, get regular assignment.
|
||||||
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
@@ -47,11 +53,45 @@ async def get_active_assignment(db, participant_id: int) -> Assignment | None:
|
|||||||
.where(
|
.where(
|
||||||
Assignment.participant_id == participant_id,
|
Assignment.participant_id == participant_id,
|
||||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||||
|
Assignment.is_event_assignment == is_event,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment | None:
|
||||||
|
"""Get the oldest returned assignment that needs to be redone."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant_id,
|
||||||
|
Assignment.status == AssignmentStatus.RETURNED.value,
|
||||||
|
Assignment.is_event_assignment == False,
|
||||||
|
)
|
||||||
|
.order_by(Assignment.completed_at.asc()) # Oldest first
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def activate_returned_assignment(db, returned_assignment: Assignment) -> None:
|
||||||
|
"""
|
||||||
|
Re-activate a returned assignment.
|
||||||
|
Simply changes the status back to ACTIVE.
|
||||||
|
"""
|
||||||
|
returned_assignment.status = AssignmentStatus.ACTIVE.value
|
||||||
|
returned_assignment.started_at = datetime.utcnow()
|
||||||
|
# Clear previous proof data for fresh attempt
|
||||||
|
returned_assignment.proof_path = None
|
||||||
|
returned_assignment.proof_url = None
|
||||||
|
returned_assignment.proof_comment = None
|
||||||
|
returned_assignment.completed_at = None
|
||||||
|
returned_assignment.points_earned = 0
|
||||||
|
|
||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
|
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
|
||||||
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Spin the wheel to get a random game and challenge"""
|
"""Spin the wheel to get a random game and challenge"""
|
||||||
@@ -64,10 +104,14 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
if marathon.status != MarathonStatus.ACTIVE.value:
|
if marathon.status != MarathonStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail="Marathon is not active")
|
raise HTTPException(status_code=400, detail="Marathon is not active")
|
||||||
|
|
||||||
|
# Check if marathon has expired by end_date
|
||||||
|
if marathon.end_date and datetime.utcnow() > marathon.end_date:
|
||||||
|
raise HTTPException(status_code=400, detail="Marathon has ended")
|
||||||
|
|
||||||
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
||||||
|
|
||||||
# Check no active assignment
|
# Check no active regular assignment (event assignments are separate)
|
||||||
active = await get_active_assignment(db, participant.id)
|
active = await get_active_assignment(db, participant.id, is_event=False)
|
||||||
if active:
|
if active:
|
||||||
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
||||||
|
|
||||||
@@ -77,7 +121,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
game = None
|
game = None
|
||||||
challenge = None
|
challenge = None
|
||||||
|
|
||||||
# Handle special event cases
|
# Handle special event cases (excluding Common Enemy - it has separate flow)
|
||||||
if active_event:
|
if active_event:
|
||||||
if active_event.type == EventType.JACKPOT.value:
|
if active_event.type == EventType.JACKPOT.value:
|
||||||
# Jackpot: Get hard challenge only
|
# Jackpot: Get hard challenge only
|
||||||
@@ -90,17 +134,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
game = result.scalar_one_or_none()
|
game = result.scalar_one_or_none()
|
||||||
# Consume jackpot (one-time use)
|
# Consume jackpot (one-time use)
|
||||||
await event_service.consume_jackpot(db, active_event.id)
|
await event_service.consume_jackpot(db, active_event.id)
|
||||||
|
# Note: Common Enemy is handled separately via event-assignment endpoints
|
||||||
elif active_event.type == EventType.COMMON_ENEMY.value:
|
|
||||||
# Common enemy: Everyone gets same challenge (if not already completed)
|
|
||||||
event_data = active_event.data or {}
|
|
||||||
completions = event_data.get("completions", [])
|
|
||||||
already_completed = any(c["participant_id"] == participant.id for c in completions)
|
|
||||||
|
|
||||||
if not already_completed:
|
|
||||||
challenge = await event_service.get_common_enemy_challenge(db, active_event)
|
|
||||||
if challenge:
|
|
||||||
game = challenge.game
|
|
||||||
|
|
||||||
# Normal random selection if no special event handling
|
# Normal random selection if no special event handling
|
||||||
if not game or not challenge:
|
if not game or not challenge:
|
||||||
@@ -130,6 +164,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
activity_data = {
|
activity_data = {
|
||||||
"game": game.title,
|
"game": game.title,
|
||||||
"challenge": challenge.title,
|
"challenge": challenge.title,
|
||||||
|
"difficulty": challenge.difficulty,
|
||||||
|
"points": challenge.points,
|
||||||
}
|
}
|
||||||
if active_event:
|
if active_event:
|
||||||
activity_data["event_type"] = active_event.type
|
activity_data["event_type"] = active_event.type
|
||||||
@@ -146,7 +182,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
await db.refresh(assignment)
|
await db.refresh(assignment)
|
||||||
|
|
||||||
# Calculate drop penalty (considers active event for double_risk)
|
# Calculate drop penalty (considers active event for double_risk)
|
||||||
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
|
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
|
||||||
|
|
||||||
# Get challenges count (avoid lazy loading in async context)
|
# Get challenges count (avoid lazy loading in async context)
|
||||||
challenges_count = 0
|
challenges_count = 0
|
||||||
@@ -162,7 +198,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
game=GameResponse(
|
game=GameResponse(
|
||||||
id=game.id,
|
id=game.id,
|
||||||
title=game.title,
|
title=game.title,
|
||||||
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
|
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||||
download_url=game.download_url,
|
download_url=game.download_url,
|
||||||
genre=game.genre,
|
genre=game.genre,
|
||||||
added_by=None,
|
added_by=None,
|
||||||
@@ -190,9 +226,9 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
|
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
|
||||||
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Get current active assignment"""
|
"""Get current active regular assignment (not event assignments)"""
|
||||||
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
||||||
assignment = await get_active_assignment(db, participant.id)
|
assignment = await get_active_assignment(db, participant.id, is_event=False)
|
||||||
|
|
||||||
if not assignment:
|
if not assignment:
|
||||||
return None
|
return None
|
||||||
@@ -200,6 +236,10 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
|||||||
challenge = assignment.challenge
|
challenge = assignment.challenge
|
||||||
game = challenge.game
|
game = challenge.game
|
||||||
|
|
||||||
|
# Calculate drop penalty (considers active event for double_risk)
|
||||||
|
active_event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
|
||||||
|
|
||||||
return AssignmentResponse(
|
return AssignmentResponse(
|
||||||
id=assignment.id,
|
id=assignment.id,
|
||||||
challenge=ChallengeResponse(
|
challenge=ChallengeResponse(
|
||||||
@@ -217,12 +257,13 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
|||||||
created_at=challenge.created_at,
|
created_at=challenge.created_at,
|
||||||
),
|
),
|
||||||
status=assignment.status,
|
status=assignment.status,
|
||||||
proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url,
|
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
|
||||||
proof_comment=assignment.proof_comment,
|
proof_comment=assignment.proof_comment,
|
||||||
points_earned=assignment.points_earned,
|
points_earned=assignment.points_earned,
|
||||||
streak_at_completion=assignment.streak_at_completion,
|
streak_at_completion=assignment.streak_at_completion,
|
||||||
started_at=assignment.started_at,
|
started_at=assignment.started_at,
|
||||||
completed_at=assignment.completed_at,
|
completed_at=assignment.completed_at,
|
||||||
|
drop_penalty=drop_penalty,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -235,7 +276,7 @@ async def complete_assignment(
|
|||||||
comment: str | None = Form(None),
|
comment: str | None = Form(None),
|
||||||
proof_file: UploadFile | None = File(None),
|
proof_file: UploadFile | None = File(None),
|
||||||
):
|
):
|
||||||
"""Complete an assignment with proof"""
|
"""Complete a regular assignment with proof (not event assignments)"""
|
||||||
# Get assignment
|
# Get assignment
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
@@ -256,6 +297,10 @@ async def complete_assignment(
|
|||||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail="Assignment is not active")
|
raise HTTPException(status_code=400, detail="Assignment is not active")
|
||||||
|
|
||||||
|
# Event assignments should be completed via /event-assignments/{id}/complete
|
||||||
|
if assignment.is_event_assignment:
|
||||||
|
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
|
||||||
|
|
||||||
# Need either file or URL
|
# Need either file or URL
|
||||||
if not proof_file and not proof_url:
|
if not proof_file and not proof_url:
|
||||||
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
|
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
|
||||||
@@ -276,14 +321,16 @@ async def complete_assignment(
|
|||||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
|
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
|
||||||
)
|
)
|
||||||
|
|
||||||
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}"
|
# Upload file to storage
|
||||||
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename
|
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
|
||||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
file_path = await storage_service.upload_file(
|
||||||
|
content=contents,
|
||||||
|
folder="proofs",
|
||||||
|
filename=filename,
|
||||||
|
content_type=proof_file.content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
|
||||||
with open(filepath, "wb") as f:
|
assignment.proof_path = file_path
|
||||||
f.write(contents)
|
|
||||||
|
|
||||||
assignment.proof_path = str(filepath)
|
|
||||||
else:
|
else:
|
||||||
assignment.proof_url = proof_url
|
assignment.proof_url = proof_url
|
||||||
|
|
||||||
@@ -303,12 +350,12 @@ async def complete_assignment(
|
|||||||
# Check active event for point multipliers
|
# Check active event for point multipliers
|
||||||
active_event = await event_service.get_active_event(db, marathon_id)
|
active_event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
|
||||||
# For jackpot/rematch: use the event_type stored in assignment (since event may be over)
|
# For jackpot: use the event_type stored in assignment (since event may be over)
|
||||||
# For other events: use the currently active event
|
# For other events: use the currently active event
|
||||||
effective_event = active_event
|
effective_event = active_event
|
||||||
|
|
||||||
# Handle assignment-level event types (jackpot, rematch)
|
# Handle assignment-level event types (jackpot)
|
||||||
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]:
|
if assignment.event_type == EventType.JACKPOT.value:
|
||||||
# Create a mock event object for point calculation
|
# Create a mock event object for point calculation
|
||||||
class MockEvent:
|
class MockEvent:
|
||||||
def __init__(self, event_type):
|
def __init__(self, event_type):
|
||||||
@@ -328,6 +375,7 @@ async def complete_assignment(
|
|||||||
db, active_event, participant.id, current_user.id
|
db, active_event, participant.id, current_user.id
|
||||||
)
|
)
|
||||||
total_points += common_enemy_bonus
|
total_points += common_enemy_bonus
|
||||||
|
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.COMPLETED.value
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
@@ -342,12 +390,15 @@ async def complete_assignment(
|
|||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
activity_data = {
|
activity_data = {
|
||||||
|
"assignment_id": assignment.id,
|
||||||
|
"game": full_challenge.game.title,
|
||||||
"challenge": challenge.title,
|
"challenge": challenge.title,
|
||||||
|
"difficulty": challenge.difficulty,
|
||||||
"points": total_points,
|
"points": total_points,
|
||||||
"streak": participant.current_streak,
|
"streak": participant.current_streak,
|
||||||
}
|
}
|
||||||
# Log event info (use assignment's event_type for jackpot/rematch, active_event for others)
|
# Log event info (use assignment's event_type for jackpot, active_event for others)
|
||||||
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]:
|
if assignment.event_type == EventType.JACKPOT.value:
|
||||||
activity_data["event_type"] = assignment.event_type
|
activity_data["event_type"] = assignment.event_type
|
||||||
activity_data["event_bonus"] = event_bonus
|
activity_data["event_bonus"] = event_bonus
|
||||||
elif active_event:
|
elif active_event:
|
||||||
@@ -367,6 +418,24 @@ async def complete_assignment(
|
|||||||
# If common enemy event auto-closed, log the event end with winners
|
# If common enemy event auto-closed, log the event end with winners
|
||||||
if common_enemy_closed and common_enemy_winners:
|
if common_enemy_closed and common_enemy_winners:
|
||||||
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||||
|
# Load winner nicknames
|
||||||
|
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
|
||||||
|
users_result = await db.execute(
|
||||||
|
select(User).where(User.id.in_(winner_user_ids))
|
||||||
|
)
|
||||||
|
users_map = {u.id: u.nickname for u in users_result.scalars().all()}
|
||||||
|
|
||||||
|
winners_data = [
|
||||||
|
{
|
||||||
|
"user_id": w["user_id"],
|
||||||
|
"nickname": users_map.get(w["user_id"], "Unknown"),
|
||||||
|
"rank": w["rank"],
|
||||||
|
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
|
||||||
|
}
|
||||||
|
for w in common_enemy_winners
|
||||||
|
]
|
||||||
|
print(f"[COMMON_ENEMY] Creating event_end activity with winners: {winners_data}")
|
||||||
|
|
||||||
event_end_activity = Activity(
|
event_end_activity = Activity(
|
||||||
marathon_id=marathon_id,
|
marathon_id=marathon_id,
|
||||||
user_id=current_user.id, # Last completer triggers the close
|
user_id=current_user.id, # Last completer triggers the close
|
||||||
@@ -375,20 +444,20 @@ async def complete_assignment(
|
|||||||
"event_type": EventType.COMMON_ENEMY.value,
|
"event_type": EventType.COMMON_ENEMY.value,
|
||||||
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
|
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
|
||||||
"auto_closed": True,
|
"auto_closed": True,
|
||||||
"winners": [
|
"winners": winners_data,
|
||||||
{
|
|
||||||
"user_id": w["user_id"],
|
|
||||||
"rank": w["rank"],
|
|
||||||
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
|
|
||||||
}
|
|
||||||
for w in common_enemy_winners
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.add(event_end_activity)
|
db.add(event_end_activity)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Check for returned assignments and activate the oldest one
|
||||||
|
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
|
||||||
|
if returned_assignment:
|
||||||
|
await activate_returned_assignment(db, returned_assignment)
|
||||||
|
await db.commit()
|
||||||
|
print(f"[WHEEL] Auto-activated returned assignment {returned_assignment.id} for participant {participant.id}")
|
||||||
|
|
||||||
return CompleteResult(
|
return CompleteResult(
|
||||||
points_earned=total_points,
|
points_earned=total_points,
|
||||||
streak_bonus=streak_bonus,
|
streak_bonus=streak_bonus,
|
||||||
@@ -427,7 +496,7 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
|||||||
active_event = await event_service.get_active_event(db, marathon_id)
|
active_event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
|
||||||
# Calculate penalty (0 if double_risk event is active)
|
# Calculate penalty (0 if double_risk event is active)
|
||||||
penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
|
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.DROPPED.value
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
@@ -440,7 +509,9 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
|||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
activity_data = {
|
activity_data = {
|
||||||
|
"game": assignment.challenge.game.title,
|
||||||
"challenge": assignment.challenge.title,
|
"challenge": assignment.challenge.title,
|
||||||
|
"difficulty": assignment.challenge.difficulty,
|
||||||
"penalty": penalty,
|
"penalty": penalty,
|
||||||
}
|
}
|
||||||
if active_event:
|
if active_event:
|
||||||
@@ -510,7 +581,7 @@ async def get_my_history(
|
|||||||
created_at=a.challenge.created_at,
|
created_at=a.challenge.created_at,
|
||||||
),
|
),
|
||||||
status=a.status,
|
status=a.status,
|
||||||
proof_url=f"/uploads/proofs/{a.proof_path.split('/')[-1]}" if a.proof_path else a.proof_url,
|
proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
|
||||||
proof_comment=a.proof_comment,
|
proof_comment=a.proof_comment,
|
||||||
points_earned=a.points_earned,
|
points_earned=a.points_earned,
|
||||||
streak_at_completion=a.streak_at_completion,
|
streak_at_completion=a.streak_at_completion,
|
||||||
|
|||||||
@@ -20,13 +20,25 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Telegram
|
# Telegram
|
||||||
TELEGRAM_BOT_TOKEN: str = ""
|
TELEGRAM_BOT_TOKEN: str = ""
|
||||||
|
TELEGRAM_BOT_USERNAME: str = ""
|
||||||
|
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
|
||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
UPLOAD_DIR: str = "uploads"
|
UPLOAD_DIR: str = "uploads"
|
||||||
MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 MB
|
MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB
|
||||||
|
MAX_VIDEO_SIZE: int = 30 * 1024 * 1024 # 30 MB
|
||||||
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}
|
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}
|
||||||
ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"}
|
ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"}
|
||||||
|
|
||||||
|
# S3 Storage (FirstVDS)
|
||||||
|
S3_ENABLED: bool = False
|
||||||
|
S3_BUCKET_NAME: str = ""
|
||||||
|
S3_REGION: str = "ru-1"
|
||||||
|
S3_ACCESS_KEY_ID: str = ""
|
||||||
|
S3_SECRET_ACCESS_KEY: str = ""
|
||||||
|
S3_ENDPOINT_URL: str = ""
|
||||||
|
S3_PUBLIC_URL: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ALLOWED_EXTENSIONS(self) -> set:
|
def ALLOWED_EXTENSIONS(self) -> set:
|
||||||
return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS
|
return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -35,3 +40,71 @@ def decode_access_token(token: str) -> dict | None:
|
|||||||
return payload
|
return payload
|
||||||
except jwt.JWTError:
|
except jwt.JWTError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_telegram_link_token(user_id: int, expire_minutes: int = 10) -> str:
|
||||||
|
"""
|
||||||
|
Create a short token for Telegram account linking.
|
||||||
|
Format: base64url encoded binary data (no separators).
|
||||||
|
Structure: user_id (4 bytes) + expire_at (4 bytes) + signature (8 bytes) = 16 bytes -> 22 chars base64url.
|
||||||
|
"""
|
||||||
|
expire_at = int(time.time()) + (expire_minutes * 60)
|
||||||
|
|
||||||
|
# Pack user_id and expire_at as unsigned 32-bit integers (8 bytes total)
|
||||||
|
data = struct.pack(">II", user_id, expire_at)
|
||||||
|
|
||||||
|
# Create HMAC signature (take first 8 bytes)
|
||||||
|
signature = hmac.new(
|
||||||
|
settings.SECRET_KEY.encode(),
|
||||||
|
data,
|
||||||
|
hashlib.sha256
|
||||||
|
).digest()[:8]
|
||||||
|
|
||||||
|
# Combine data + signature (16 bytes)
|
||||||
|
token_bytes = data + signature
|
||||||
|
|
||||||
|
# Encode as base64url without padding
|
||||||
|
token = base64.urlsafe_b64encode(token_bytes).decode().rstrip("=")
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def verify_telegram_link_token(token: str) -> int | None:
|
||||||
|
"""
|
||||||
|
Verify Telegram link token and return user_id if valid.
|
||||||
|
Returns None if token is invalid or expired.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Add padding if needed for base64 decoding
|
||||||
|
padding = 4 - (len(token) % 4)
|
||||||
|
if padding != 4:
|
||||||
|
token += "=" * padding
|
||||||
|
|
||||||
|
token_bytes = base64.urlsafe_b64decode(token)
|
||||||
|
|
||||||
|
if len(token_bytes) != 16:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Unpack data
|
||||||
|
data = token_bytes[:8]
|
||||||
|
provided_signature = token_bytes[8:]
|
||||||
|
|
||||||
|
user_id, expire_at = struct.unpack(">II", data)
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
if time.time() > expire_at:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify signature
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
settings.SECRET_KEY.encode(),
|
||||||
|
data,
|
||||||
|
hashlib.sha256
|
||||||
|
).digest()[:8]
|
||||||
|
|
||||||
|
if not hmac.compare_digest(provided_signature, expected_signature):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return user_id
|
||||||
|
except (ValueError, struct.error, Exception):
|
||||||
|
return None
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.core.config import settings
|
|||||||
from app.core.database import engine, Base, async_session_maker
|
from app.core.database import engine, Base, async_session_maker
|
||||||
from app.api.v1 import router as api_router
|
from app.api.v1 import router as api_router
|
||||||
from app.services.event_scheduler import event_scheduler
|
from app.services.event_scheduler import event_scheduler
|
||||||
|
from app.services.dispute_scheduler import dispute_scheduler
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -23,13 +24,15 @@ async def lifespan(app: FastAPI):
|
|||||||
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
|
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
|
||||||
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
|
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Start event scheduler
|
# Start schedulers
|
||||||
await event_scheduler.start(async_session_maker)
|
await event_scheduler.start(async_session_maker)
|
||||||
|
await dispute_scheduler.start(async_session_maker)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
await event_scheduler.stop()
|
await event_scheduler.stop()
|
||||||
|
await dispute_scheduler.stop()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.models.assignment import Assignment, AssignmentStatus
|
|||||||
from app.models.activity import Activity, ActivityType
|
from app.models.activity import Activity, ActivityType
|
||||||
from app.models.event import Event, EventType
|
from app.models.event import Event, EventType
|
||||||
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||||
|
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -30,4 +31,8 @@ __all__ = [
|
|||||||
"EventType",
|
"EventType",
|
||||||
"SwapRequest",
|
"SwapRequest",
|
||||||
"SwapRequestStatus",
|
"SwapRequestStatus",
|
||||||
|
"Dispute",
|
||||||
|
"DisputeStatus",
|
||||||
|
"DisputeComment",
|
||||||
|
"DisputeVote",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class ActivityType(str, Enum):
|
|||||||
EVENT_START = "event_start"
|
EVENT_START = "event_start"
|
||||||
EVENT_END = "event_end"
|
EVENT_END = "event_end"
|
||||||
SWAP = "swap"
|
SWAP = "swap"
|
||||||
REMATCH = "rematch"
|
GAME_CHOICE = "game_choice"
|
||||||
|
|
||||||
|
|
||||||
class Activity(Base):
|
class Activity(Base):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -10,6 +10,7 @@ class AssignmentStatus(str, Enum):
|
|||||||
ACTIVE = "active"
|
ACTIVE = "active"
|
||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
DROPPED = "dropped"
|
DROPPED = "dropped"
|
||||||
|
RETURNED = "returned" # Disputed and needs to be redone
|
||||||
|
|
||||||
|
|
||||||
class Assignment(Base):
|
class Assignment(Base):
|
||||||
@@ -20,6 +21,8 @@ class Assignment(Base):
|
|||||||
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
|
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
|
||||||
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
|
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
|
||||||
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
|
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
|
||||||
|
is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments
|
||||||
|
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
|
||||||
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
@@ -31,3 +34,5 @@ class Assignment(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
|
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
|
||||||
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
|
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
|
||||||
|
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
|
||||||
|
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False)
|
||||||
|
|||||||
66
backend/app/models/dispute.py
Normal file
66
backend/app/models/dispute.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeStatus(str, Enum):
|
||||||
|
OPEN = "open"
|
||||||
|
RESOLVED_VALID = "valid"
|
||||||
|
RESOLVED_INVALID = "invalid"
|
||||||
|
|
||||||
|
|
||||||
|
class Dispute(Base):
|
||||||
|
"""Dispute against a completed assignment's proof"""
|
||||||
|
__tablename__ = "disputes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True)
|
||||||
|
raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute")
|
||||||
|
raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id])
|
||||||
|
comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan")
|
||||||
|
votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeComment(Base):
|
||||||
|
"""Comment in a dispute discussion"""
|
||||||
|
__tablename__ = "dispute_comments"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="comments")
|
||||||
|
user: Mapped["User"] = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeVote(Base):
|
||||||
|
"""Vote in a dispute (valid or invalid)"""
|
||||||
|
__tablename__ = "dispute_votes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
vote: Mapped[bool] = mapped_column(Boolean, nullable=False) # True = valid, False = invalid
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Unique constraint: one vote per user per dispute
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("dispute_id", "user_id", name="uq_dispute_vote_user"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="votes")
|
||||||
|
user: Mapped["User"] = relationship("User")
|
||||||
@@ -12,7 +12,7 @@ class EventType(str, Enum):
|
|||||||
DOUBLE_RISK = "double_risk" # дропы бесплатны, x0.5 очков
|
DOUBLE_RISK = "double_risk" # дропы бесплатны, x0.5 очков
|
||||||
JACKPOT = "jackpot" # x3 за сложный челлендж
|
JACKPOT = "jackpot" # x3 за сложный челлендж
|
||||||
SWAP = "swap" # обмен заданиями
|
SWAP = "swap" # обмен заданиями
|
||||||
REMATCH = "rematch" # реванш проваленного
|
GAME_CHOICE = "game_choice" # выбор игры (2-3 челленджа на выбор)
|
||||||
|
|
||||||
|
|
||||||
class Event(Base):
|
class Event(Base):
|
||||||
@@ -37,3 +37,4 @@ class Event(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events")
|
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events")
|
||||||
created_by: Mapped["User | None"] = relationship("User")
|
created_by: Mapped["User | None"] = relationship("User")
|
||||||
|
assignments: Mapped[list["Assignment"]] = relationship("Assignment", back_populates="event")
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ class User(Base):
|
|||||||
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
|
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
|
||||||
telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
telegram_first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
telegram_last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
telegram_avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
|
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
@@ -52,5 +55,7 @@ class User(Base):
|
|||||||
@property
|
@property
|
||||||
def avatar_url(self) -> str | None:
|
def avatar_url(self) -> str | None:
|
||||||
if self.avatar_path:
|
if self.avatar_path:
|
||||||
return f"/uploads/avatars/{self.avatar_path.split('/')[-1]}"
|
# Lazy import to avoid circular dependency
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
return storage_service.get_url(self.avatar_path, "avatars")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from app.schemas.assignment import (
|
|||||||
SpinResult,
|
SpinResult,
|
||||||
CompleteResult,
|
CompleteResult,
|
||||||
DropResult,
|
DropResult,
|
||||||
|
EventAssignmentResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.activity import (
|
from app.schemas.activity import (
|
||||||
ActivityResponse,
|
ActivityResponse,
|
||||||
@@ -66,6 +67,16 @@ from app.schemas.common import (
|
|||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
PaginationParams,
|
PaginationParams,
|
||||||
)
|
)
|
||||||
|
from app.schemas.dispute import (
|
||||||
|
DisputeCreate,
|
||||||
|
DisputeCommentCreate,
|
||||||
|
DisputeVoteCreate,
|
||||||
|
DisputeCommentResponse,
|
||||||
|
DisputeVoteResponse,
|
||||||
|
DisputeResponse,
|
||||||
|
AssignmentDetailResponse,
|
||||||
|
ReturnedAssignmentResponse,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# User
|
# User
|
||||||
@@ -107,6 +118,7 @@ __all__ = [
|
|||||||
"SpinResult",
|
"SpinResult",
|
||||||
"CompleteResult",
|
"CompleteResult",
|
||||||
"DropResult",
|
"DropResult",
|
||||||
|
"EventAssignmentResponse",
|
||||||
# Activity
|
# Activity
|
||||||
"ActivityResponse",
|
"ActivityResponse",
|
||||||
"FeedResponse",
|
"FeedResponse",
|
||||||
@@ -128,4 +140,13 @@ __all__ = [
|
|||||||
"MessageResponse",
|
"MessageResponse",
|
||||||
"ErrorResponse",
|
"ErrorResponse",
|
||||||
"PaginationParams",
|
"PaginationParams",
|
||||||
|
# Dispute
|
||||||
|
"DisputeCreate",
|
||||||
|
"DisputeCommentCreate",
|
||||||
|
"DisputeVoteCreate",
|
||||||
|
"DisputeCommentResponse",
|
||||||
|
"DisputeVoteResponse",
|
||||||
|
"DisputeResponse",
|
||||||
|
"AssignmentDetailResponse",
|
||||||
|
"ReturnedAssignmentResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class AssignmentResponse(BaseModel):
|
|||||||
streak_at_completion: int | None = None
|
streak_at_completion: int | None = None
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
completed_at: datetime | None = None
|
completed_at: datetime | None = None
|
||||||
|
drop_penalty: int = 0 # Calculated penalty if dropped
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -48,3 +49,14 @@ class DropResult(BaseModel):
|
|||||||
penalty: int
|
penalty: int
|
||||||
total_points: int
|
total_points: int
|
||||||
new_drop_count: int
|
new_drop_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class EventAssignmentResponse(BaseModel):
|
||||||
|
"""Response for event-specific assignment (Common Enemy)"""
|
||||||
|
assignment: AssignmentResponse | None
|
||||||
|
event_id: int | None
|
||||||
|
challenge_id: int | None
|
||||||
|
is_completed: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|||||||
91
backend/app/schemas/dispute.py
Normal file
91
backend/app/schemas/dispute.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.user import UserPublic
|
||||||
|
from app.schemas.challenge import ChallengeResponse
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeCreate(BaseModel):
|
||||||
|
"""Request to create a dispute"""
|
||||||
|
reason: str = Field(..., min_length=10, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeCommentCreate(BaseModel):
|
||||||
|
"""Request to add a comment to a dispute"""
|
||||||
|
text: str = Field(..., min_length=1, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeVoteCreate(BaseModel):
|
||||||
|
"""Request to vote on a dispute"""
|
||||||
|
vote: bool # True = valid (proof is OK), False = invalid (proof is not OK)
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeCommentResponse(BaseModel):
|
||||||
|
"""Comment in a dispute discussion"""
|
||||||
|
id: int
|
||||||
|
user: UserPublic
|
||||||
|
text: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeVoteResponse(BaseModel):
|
||||||
|
"""Vote in a dispute"""
|
||||||
|
user: UserPublic
|
||||||
|
vote: bool # True = valid, False = invalid
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeResponse(BaseModel):
|
||||||
|
"""Full dispute information"""
|
||||||
|
id: int
|
||||||
|
raised_by: UserPublic
|
||||||
|
reason: str
|
||||||
|
status: str # "open", "valid", "invalid"
|
||||||
|
comments: list[DisputeCommentResponse]
|
||||||
|
votes: list[DisputeVoteResponse]
|
||||||
|
votes_valid: int
|
||||||
|
votes_invalid: int
|
||||||
|
my_vote: bool | None # Current user's vote, None if not voted
|
||||||
|
expires_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
resolved_at: datetime | None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentDetailResponse(BaseModel):
|
||||||
|
"""Detailed assignment information with proofs and dispute"""
|
||||||
|
id: int
|
||||||
|
challenge: ChallengeResponse
|
||||||
|
participant: UserPublic
|
||||||
|
status: str
|
||||||
|
proof_url: str | None # External URL (YouTube, etc.)
|
||||||
|
proof_image_url: str | None # Uploaded file URL
|
||||||
|
proof_comment: str | None
|
||||||
|
points_earned: int
|
||||||
|
streak_at_completion: int | None
|
||||||
|
started_at: datetime
|
||||||
|
completed_at: datetime | None
|
||||||
|
can_dispute: bool # True if <24h since completion and not own assignment
|
||||||
|
dispute: DisputeResponse | None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnedAssignmentResponse(BaseModel):
|
||||||
|
"""Returned assignment that needs to be redone"""
|
||||||
|
id: int
|
||||||
|
challenge: ChallengeResponse
|
||||||
|
original_completed_at: datetime
|
||||||
|
dispute_reason: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@@ -13,7 +13,7 @@ EventTypeLiteral = Literal[
|
|||||||
"double_risk",
|
"double_risk",
|
||||||
"jackpot",
|
"jackpot",
|
||||||
"swap",
|
"swap",
|
||||||
"rematch",
|
"game_choice",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ class EventCreate(BaseModel):
|
|||||||
class EventEffects(BaseModel):
|
class EventEffects(BaseModel):
|
||||||
points_multiplier: float = 1.0
|
points_multiplier: float = 1.0
|
||||||
drop_free: bool = False
|
drop_free: bool = False
|
||||||
special_action: str | None = None # "swap", "rematch"
|
special_action: str | None = None # "swap", "game_choice"
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ EVENT_INFO = {
|
|||||||
"drop_free": False,
|
"drop_free": False,
|
||||||
},
|
},
|
||||||
EventType.DOUBLE_RISK: {
|
EventType.DOUBLE_RISK: {
|
||||||
"name": "Двойной риск",
|
"name": "Безопасная игра",
|
||||||
"description": "Дропы бесплатны, но очки x0.5",
|
"description": "Дропы бесплатны, но очки x0.5",
|
||||||
"default_duration": 120,
|
"default_duration": 120,
|
||||||
"points_multiplier": 0.5,
|
"points_multiplier": 0.5,
|
||||||
@@ -106,13 +106,13 @@ EVENT_INFO = {
|
|||||||
"drop_free": False,
|
"drop_free": False,
|
||||||
"special_action": "swap",
|
"special_action": "swap",
|
||||||
},
|
},
|
||||||
EventType.REMATCH: {
|
EventType.GAME_CHOICE: {
|
||||||
"name": "Реванш",
|
"name": "Выбор игры",
|
||||||
"description": "Можно переделать проваленный челлендж за 50% очков",
|
"description": "Выбери игру и один из 3 челленджей. Можно заменить текущее задание без штрафа!",
|
||||||
"default_duration": 240,
|
"default_duration": 120,
|
||||||
"points_multiplier": 0.5,
|
"points_multiplier": 1.0,
|
||||||
"drop_free": False,
|
"drop_free": True, # Free replacement of current assignment
|
||||||
"special_action": "rematch",
|
"special_action": "game_choice",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ class UserPublic(UserBase):
|
|||||||
login: str
|
login: str
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
role: str = "user"
|
role: str = "user"
|
||||||
|
telegram_id: int | None = None
|
||||||
|
telegram_username: str | None = None
|
||||||
|
telegram_first_name: str | None = None
|
||||||
|
telegram_last_name: str | None = None
|
||||||
|
telegram_avatar_url: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
89
backend/app/services/dispute_scheduler.py
Normal file
89
backend/app/services/dispute_scheduler.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
Dispute Scheduler for automatic dispute resolution after 24 hours.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
|
||||||
|
from app.services.disputes import dispute_service
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
|
||||||
|
DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeScheduler:
|
||||||
|
"""Background scheduler for automatic dispute resolution."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._running = False
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def start(self, session_factory) -> None:
|
||||||
|
"""Start the scheduler background task."""
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._task = asyncio.create_task(self._run_loop(session_factory))
|
||||||
|
print("[DisputeScheduler] Started")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the scheduler."""
|
||||||
|
self._running = False
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
print("[DisputeScheduler] Stopped")
|
||||||
|
|
||||||
|
async def _run_loop(self, session_factory) -> None:
|
||||||
|
"""Main scheduler loop."""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
async with session_factory() as db:
|
||||||
|
await self._process_expired_disputes(db)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DisputeScheduler] Error in loop: {e}")
|
||||||
|
|
||||||
|
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
async def _process_expired_disputes(self, db: AsyncSession) -> None:
|
||||||
|
"""Process and resolve expired disputes."""
|
||||||
|
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||||
|
|
||||||
|
# Find all open disputes that have expired
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.votes),
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Dispute.status == DisputeStatus.OPEN.value,
|
||||||
|
Dispute.created_at < cutoff_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expired_disputes = result.scalars().all()
|
||||||
|
|
||||||
|
for dispute in expired_disputes:
|
||||||
|
try:
|
||||||
|
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute(
|
||||||
|
db, dispute.id
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: "
|
||||||
|
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Global scheduler instance
|
||||||
|
dispute_scheduler = DisputeScheduler()
|
||||||
149
backend/app/services/disputes.py
Normal file
149
backend/app/services/disputes.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Dispute resolution service.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
Dispute, DisputeStatus, DisputeVote,
|
||||||
|
Assignment, AssignmentStatus, Participant, Marathon, Challenge, Game,
|
||||||
|
)
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
|
|
||||||
|
class DisputeService:
|
||||||
|
"""Service for dispute resolution logic"""
|
||||||
|
|
||||||
|
async def resolve_dispute(self, db: AsyncSession, dispute_id: int) -> tuple[str, int, int]:
|
||||||
|
"""
|
||||||
|
Resolve a dispute based on votes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (result_status, votes_valid, votes_invalid)
|
||||||
|
"""
|
||||||
|
# Get dispute with votes and assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.votes),
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||||
|
)
|
||||||
|
.where(Dispute.id == dispute_id)
|
||||||
|
)
|
||||||
|
dispute = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not dispute:
|
||||||
|
raise ValueError(f"Dispute {dispute_id} not found")
|
||||||
|
|
||||||
|
if dispute.status != DisputeStatus.OPEN.value:
|
||||||
|
raise ValueError(f"Dispute {dispute_id} is already resolved")
|
||||||
|
|
||||||
|
# Count votes
|
||||||
|
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
||||||
|
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
||||||
|
|
||||||
|
# Determine result: tie goes to the accused (valid)
|
||||||
|
if votes_invalid > votes_valid:
|
||||||
|
# Proof is invalid - mark assignment as RETURNED
|
||||||
|
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||||
|
await self._handle_invalid_proof(db, dispute)
|
||||||
|
else:
|
||||||
|
# Proof is valid (or tie)
|
||||||
|
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||||
|
|
||||||
|
# Update dispute
|
||||||
|
dispute.status = result_status
|
||||||
|
dispute.resolved_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Send Telegram notification about dispute resolution
|
||||||
|
await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value)
|
||||||
|
|
||||||
|
return result_status, votes_valid, votes_invalid
|
||||||
|
|
||||||
|
async def _notify_dispute_resolved(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
dispute: Dispute,
|
||||||
|
is_valid: bool
|
||||||
|
) -> None:
|
||||||
|
"""Send notification about dispute resolution to the assignment owner."""
|
||||||
|
try:
|
||||||
|
# Get assignment with challenge and marathon info
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.participant),
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||||
|
)
|
||||||
|
.where(Assignment.id == dispute.assignment_id)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
if not assignment:
|
||||||
|
return
|
||||||
|
|
||||||
|
participant = assignment.participant
|
||||||
|
challenge = assignment.challenge
|
||||||
|
game = challenge.game if challenge else None
|
||||||
|
|
||||||
|
# Get marathon
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon).where(Marathon.id == game.marathon_id if game else 0)
|
||||||
|
)
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if marathon and participant:
|
||||||
|
await telegram_notifier.notify_dispute_resolved(
|
||||||
|
db,
|
||||||
|
user_id=participant.user_id,
|
||||||
|
marathon_title=marathon.title,
|
||||||
|
challenge_title=challenge.title if challenge else "Unknown",
|
||||||
|
is_valid=is_valid
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DisputeService] Failed to send notification: {e}")
|
||||||
|
|
||||||
|
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
|
||||||
|
"""
|
||||||
|
Handle the case when proof is determined to be invalid.
|
||||||
|
|
||||||
|
- Mark assignment as RETURNED
|
||||||
|
- Subtract points from participant
|
||||||
|
- Reset streak if it was affected
|
||||||
|
"""
|
||||||
|
assignment = dispute.assignment
|
||||||
|
participant = assignment.participant
|
||||||
|
|
||||||
|
# Subtract points that were earned
|
||||||
|
points_to_subtract = assignment.points_earned
|
||||||
|
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||||
|
|
||||||
|
# Reset assignment
|
||||||
|
assignment.status = AssignmentStatus.RETURNED.value
|
||||||
|
assignment.points_earned = 0
|
||||||
|
# Keep proof data so it can be reviewed
|
||||||
|
|
||||||
|
print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, "
|
||||||
|
f"subtracted {points_to_subtract} points from participant {participant.id}")
|
||||||
|
|
||||||
|
async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
|
||||||
|
"""Get all open disputes older than specified hours"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.where(
|
||||||
|
Dispute.status == DisputeStatus.OPEN.value,
|
||||||
|
Dispute.created_at < cutoff_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
# Global service instance
|
||||||
|
dispute_service = DisputeService()
|
||||||
@@ -21,7 +21,7 @@ AUTO_EVENT_TYPES = [
|
|||||||
EventType.GOLDEN_HOUR,
|
EventType.GOLDEN_HOUR,
|
||||||
EventType.DOUBLE_RISK,
|
EventType.DOUBLE_RISK,
|
||||||
EventType.JACKPOT,
|
EventType.JACKPOT,
|
||||||
EventType.REMATCH,
|
EventType.GAME_CHOICE,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ from sqlalchemy.orm import selectinload
|
|||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models import Event, EventType, Marathon, Challenge, Difficulty
|
from app.models import Event, EventType, Marathon, Challenge, Difficulty, Participant, Assignment, AssignmentStatus
|
||||||
from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES
|
from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
|
|
||||||
class EventService:
|
class EventService:
|
||||||
@@ -76,6 +77,12 @@ class EventService:
|
|||||||
data=data if data else None,
|
data=data if data else None,
|
||||||
)
|
)
|
||||||
db.add(event)
|
db.add(event)
|
||||||
|
await db.flush() # Get event.id before committing
|
||||||
|
|
||||||
|
# Auto-assign challenge to all participants for Common Enemy
|
||||||
|
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
|
||||||
|
await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(event)
|
await db.refresh(event)
|
||||||
|
|
||||||
@@ -83,18 +90,81 @@ class EventService:
|
|||||||
if created_by_id:
|
if created_by_id:
|
||||||
await db.refresh(event, ["created_by"])
|
await db.refresh(event, ["created_by"])
|
||||||
|
|
||||||
|
# Send Telegram notifications
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if marathon:
|
||||||
|
await telegram_notifier.notify_event_start(
|
||||||
|
db, marathon_id, event_type, marathon.title
|
||||||
|
)
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
async def _assign_common_enemy_to_all(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
event_id: int,
|
||||||
|
challenge_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Create event assignments for all participants in the marathon"""
|
||||||
|
# Get all participants
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(Participant.marathon_id == marathon_id)
|
||||||
|
)
|
||||||
|
participants = result.scalars().all()
|
||||||
|
|
||||||
|
# Create event assignment for each participant
|
||||||
|
for participant in participants:
|
||||||
|
assignment = Assignment(
|
||||||
|
participant_id=participant.id,
|
||||||
|
challenge_id=challenge_id,
|
||||||
|
status=AssignmentStatus.ACTIVE.value,
|
||||||
|
event_type=EventType.COMMON_ENEMY.value,
|
||||||
|
is_event_assignment=True,
|
||||||
|
event_id=event_id,
|
||||||
|
)
|
||||||
|
db.add(assignment)
|
||||||
|
|
||||||
async def end_event(self, db: AsyncSession, event_id: int) -> None:
|
async def end_event(self, db: AsyncSession, event_id: int) -> None:
|
||||||
"""End an event"""
|
"""End an event and mark incomplete event assignments as expired"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
result = await db.execute(select(Event).where(Event.id == event_id))
|
result = await db.execute(select(Event).where(Event.id == event_id))
|
||||||
event = result.scalar_one_or_none()
|
event = result.scalar_one_or_none()
|
||||||
if event:
|
if event:
|
||||||
|
event_type = event.type
|
||||||
|
marathon_id = event.marathon_id
|
||||||
|
|
||||||
event.is_active = False
|
event.is_active = False
|
||||||
if not event.end_time:
|
if not event.end_time:
|
||||||
event.end_time = datetime.utcnow()
|
event.end_time = datetime.utcnow()
|
||||||
|
|
||||||
|
# Mark all incomplete event assignments for this event as dropped
|
||||||
|
if event.type == EventType.COMMON_ENEMY.value:
|
||||||
|
await db.execute(
|
||||||
|
update(Assignment)
|
||||||
|
.where(
|
||||||
|
Assignment.event_id == event_id,
|
||||||
|
Assignment.is_event_assignment == True,
|
||||||
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
status=AssignmentStatus.DROPPED.value,
|
||||||
|
completed_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Send Telegram notifications about event end
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if marathon:
|
||||||
|
await telegram_notifier.notify_event_end(
|
||||||
|
db, marathon_id, event_type, marathon.title
|
||||||
|
)
|
||||||
|
|
||||||
async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None:
|
async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None:
|
||||||
"""Consume jackpot event after one spin"""
|
"""Consume jackpot event after one spin"""
|
||||||
await self.end_event(db, event_id)
|
await self.end_event(db, event_id)
|
||||||
@@ -157,13 +227,16 @@ class EventService:
|
|||||||
- winners_list: list of winners if event closed, None otherwise
|
- winners_list: list of winners if event closed, None otherwise
|
||||||
"""
|
"""
|
||||||
if event.type != EventType.COMMON_ENEMY.value:
|
if event.type != EventType.COMMON_ENEMY.value:
|
||||||
|
print(f"[COMMON_ENEMY] Event type mismatch: {event.type}")
|
||||||
return 0, False, None
|
return 0, False, None
|
||||||
|
|
||||||
data = event.data or {}
|
data = event.data or {}
|
||||||
completions = data.get("completions", [])
|
completions = data.get("completions", [])
|
||||||
|
print(f"[COMMON_ENEMY] Current completions count: {len(completions)}")
|
||||||
|
|
||||||
# Check if already completed
|
# Check if already completed
|
||||||
if any(c["participant_id"] == participant_id for c in completions):
|
if any(c["participant_id"] == participant_id for c in completions):
|
||||||
|
print(f"[COMMON_ENEMY] Participant {participant_id} already completed")
|
||||||
return 0, False, None
|
return 0, False, None
|
||||||
|
|
||||||
# Add completion
|
# Add completion
|
||||||
@@ -174,6 +247,7 @@ class EventService:
|
|||||||
"completed_at": datetime.utcnow().isoformat(),
|
"completed_at": datetime.utcnow().isoformat(),
|
||||||
"rank": rank,
|
"rank": rank,
|
||||||
})
|
})
|
||||||
|
print(f"[COMMON_ENEMY] Added completion for user {user_id}, rank={rank}")
|
||||||
|
|
||||||
# Update event data - need to flag_modified for SQLAlchemy to detect JSON changes
|
# Update event data - need to flag_modified for SQLAlchemy to detect JSON changes
|
||||||
event.data = {**data, "completions": completions}
|
event.data = {**data, "completions": completions}
|
||||||
@@ -189,6 +263,7 @@ class EventService:
|
|||||||
event.end_time = datetime.utcnow()
|
event.end_time = datetime.utcnow()
|
||||||
event_closed = True
|
event_closed = True
|
||||||
winners_list = completions[:3] # Top 3
|
winners_list = completions[:3] # Top 3
|
||||||
|
print(f"[COMMON_ENEMY] Event auto-closed! Winners: {winners_list}")
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -28,33 +28,43 @@ class GPTService:
|
|||||||
"""
|
"""
|
||||||
genre_text = f" (жанр: {game_genre})" if game_genre else ""
|
genre_text = f" (жанр: {game_genre})" if game_genre else ""
|
||||||
|
|
||||||
prompt = f"""Для видеоигры "{game_title}"{genre_text} сгенерируй 6 челленджей для игрового марафона.
|
prompt = f"""Ты — эксперт по видеоиграм. Сгенерируй 6 КОНКРЕТНЫХ челленджей для игры "{game_title}"{genre_text}.
|
||||||
|
|
||||||
Требования:
|
ВАЖНО: Челленджи должны быть СПЕЦИФИЧНЫМИ для этой игры!
|
||||||
- 2 лёгких челленджа (15-30 минут игры)
|
- Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры
|
||||||
- 2 средних челленджа (1-2 часа игры)
|
- Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре (спидраны, no-hit боссов, сбор коллекционных предметов и т.д.)
|
||||||
- 2 сложных челленджа (3+ часов или высокая сложность)
|
- НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов"
|
||||||
|
|
||||||
Для каждого челленджа укажи:
|
Примеры ХОРОШИХ челленджей:
|
||||||
- title: короткое название на русском (до 50 символов)
|
- Dark Souls: "Победи Орнштейна и Смоуга без призыва" / "Пройди Чумной город без отравления"
|
||||||
- description: что нужно сделать на русском (1-2 предложения)
|
- GTA V: "Получи золото в миссии «Ювелирное дело»" / "Выиграй уличную гонку на Vinewood"
|
||||||
- type: один из [completion, no_death, speedrun, collection, achievement, challenge_run]
|
- Hollow Knight: "Победи Хорнет без получения урона" / "Найди все грибные споры в Грибных пустошах"
|
||||||
- difficulty: easy/medium/hard
|
- Minecraft: "Убей Дракона Края за один визит в Энд" / "Построй работающую ферму железа"
|
||||||
- points: очки (easy: 30-50, medium: 60-100, hard: 120-200)
|
|
||||||
- estimated_time: примерное время в минутах
|
|
||||||
- proof_type: screenshot/video/steam (что лучше подойдёт для проверки)
|
|
||||||
- proof_hint: что должно быть на скриншоте/видео для подтверждения на русском
|
|
||||||
|
|
||||||
Ответь ТОЛЬКО валидным JSON объектом с ключом "challenges" содержащим массив челленджей.
|
Требования по сложности:
|
||||||
Пример формата:
|
- 2 лёгких (15-30 мин): простые задачи, знакомство с игрой
|
||||||
|
- 2 средних (1-2 часа): требуют навыка или исследования
|
||||||
|
- 2 сложных (3+ часа): серьёзный челлендж, достижения, полное прохождение
|
||||||
|
|
||||||
|
Формат ответа — JSON:
|
||||||
|
- title: название на русском (до 50 символов), конкретное и понятное
|
||||||
|
- description: что именно сделать (1-2 предложения), с деталями из игры
|
||||||
|
- type: completion | no_death | speedrun | collection | achievement | challenge_run
|
||||||
|
- difficulty: easy | medium | hard
|
||||||
|
- points: easy=20-40, medium=45-75, hard=90-150
|
||||||
|
- estimated_time: время в минутах
|
||||||
|
- proof_type: screenshot | video | steam
|
||||||
|
- proof_hint: ЧТО КОНКРЕТНО должно быть видно на скриншоте/видео (экран победы, достижение, локация и т.д.)
|
||||||
|
|
||||||
|
Ответь ТОЛЬКО JSON:
|
||||||
{{"challenges": [{{"title": "...", "description": "...", "type": "...", "difficulty": "...", "points": 50, "estimated_time": 30, "proof_type": "...", "proof_hint": "..."}}]}}"""
|
{{"challenges": [{{"title": "...", "description": "...", "type": "...", "difficulty": "...", "points": 50, "estimated_time": 30, "proof_type": "...", "proof_hint": "..."}}]}}"""
|
||||||
|
|
||||||
response = await self.client.chat.completions.create(
|
response = await self.client.chat.completions.create(
|
||||||
model="gpt-4o-mini",
|
model="gpt-5-mini",
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
response_format={"type": "json_object"},
|
response_format={"type": "json_object"},
|
||||||
temperature=0.7,
|
temperature=0.8,
|
||||||
max_tokens=2000,
|
max_tokens=2500,
|
||||||
)
|
)
|
||||||
|
|
||||||
content = response.choices[0].message.content
|
content = response.choices[0].message.content
|
||||||
@@ -77,10 +87,17 @@ class GPTService:
|
|||||||
if proof_type not in ["screenshot", "video", "steam"]:
|
if proof_type not in ["screenshot", "video", "steam"]:
|
||||||
proof_type = "screenshot"
|
proof_type = "screenshot"
|
||||||
|
|
||||||
# Validate points
|
# Validate points based on difficulty
|
||||||
points = ch.get("points", 50)
|
points = ch.get("points", 30)
|
||||||
if not isinstance(points, int) or points < 1:
|
if not isinstance(points, int) or points < 1:
|
||||||
points = 50
|
points = 30
|
||||||
|
# Clamp points to expected ranges
|
||||||
|
if difficulty == "easy":
|
||||||
|
points = max(20, min(40, points))
|
||||||
|
elif difficulty == "medium":
|
||||||
|
points = max(45, min(75, points))
|
||||||
|
elif difficulty == "hard":
|
||||||
|
points = max(90, min(150, points))
|
||||||
|
|
||||||
challenges.append(ChallengeGenerated(
|
challenges.append(ChallengeGenerated(
|
||||||
title=ch.get("title", "Unnamed Challenge")[:100],
|
title=ch.get("title", "Unnamed Challenge")[:100],
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ class PointsService:
|
|||||||
}
|
}
|
||||||
MAX_STREAK_MULTIPLIER = 0.4
|
MAX_STREAK_MULTIPLIER = 0.4
|
||||||
|
|
||||||
DROP_PENALTIES = {
|
# Drop penalty as percentage of challenge points
|
||||||
0: 0, # First drop is free
|
DROP_PENALTY_PERCENTAGES = {
|
||||||
1: 10,
|
0: 0.5, # 1st drop: 50%
|
||||||
2: 25,
|
1: 0.75, # 2nd drop: 75%
|
||||||
}
|
}
|
||||||
MAX_DROP_PENALTY = 50
|
MAX_DROP_PENALTY_PERCENTAGE = 1.0 # 3rd+ drop: 100%
|
||||||
|
|
||||||
# Event point multipliers
|
# Event point multipliers
|
||||||
EVENT_MULTIPLIERS = {
|
EVENT_MULTIPLIERS = {
|
||||||
EventType.GOLDEN_HOUR.value: 1.5,
|
EventType.GOLDEN_HOUR.value: 1.5,
|
||||||
EventType.DOUBLE_RISK.value: 0.5,
|
EventType.DOUBLE_RISK.value: 0.5,
|
||||||
EventType.JACKPOT.value: 3.0,
|
EventType.JACKPOT.value: 3.0,
|
||||||
EventType.REMATCH.value: 0.5,
|
# GAME_CHOICE uses 1.0 multiplier (default)
|
||||||
}
|
}
|
||||||
|
|
||||||
def calculate_completion_points(
|
def calculate_completion_points(
|
||||||
@@ -66,6 +66,7 @@ class PointsService:
|
|||||||
def calculate_drop_penalty(
|
def calculate_drop_penalty(
|
||||||
self,
|
self,
|
||||||
consecutive_drops: int,
|
consecutive_drops: int,
|
||||||
|
challenge_points: int,
|
||||||
event: Event | None = None
|
event: Event | None = None
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -73,6 +74,7 @@ class PointsService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
consecutive_drops: Number of drops since last completion
|
consecutive_drops: Number of drops since last completion
|
||||||
|
challenge_points: Base points of the challenge being dropped
|
||||||
event: Active event (optional)
|
event: Active event (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -82,10 +84,11 @@ class PointsService:
|
|||||||
if event and event.type == EventType.DOUBLE_RISK.value:
|
if event and event.type == EventType.DOUBLE_RISK.value:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
return self.DROP_PENALTIES.get(
|
penalty_percentage = self.DROP_PENALTY_PERCENTAGES.get(
|
||||||
consecutive_drops,
|
consecutive_drops,
|
||||||
self.MAX_DROP_PENALTY
|
self.MAX_DROP_PENALTY_PERCENTAGE
|
||||||
)
|
)
|
||||||
|
return int(challenge_points * penalty_percentage)
|
||||||
|
|
||||||
def apply_event_multiplier(self, base_points: int, event: Event | None) -> int:
|
def apply_event_multiplier(self, base_points: int, event: Event | None) -> int:
|
||||||
"""Apply event multiplier to points"""
|
"""Apply event multiplier to points"""
|
||||||
|
|||||||
269
backend/app/services/storage.py
Normal file
269
backend/app/services/storage.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Storage service for file uploads.
|
||||||
|
Supports both local filesystem and S3-compatible storage (FirstVDS).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError, BotoCoreError
|
||||||
|
from botocore.config import Config
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
StorageFolder = Literal["avatars", "covers", "proofs"]
|
||||||
|
|
||||||
|
|
||||||
|
class StorageService:
|
||||||
|
"""Unified storage service with S3 and local filesystem support."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._s3_client = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def s3_client(self):
|
||||||
|
"""Lazy initialization of S3 client."""
|
||||||
|
if self._s3_client is None and settings.S3_ENABLED:
|
||||||
|
logger.info(f"Initializing S3 client: endpoint={settings.S3_ENDPOINT_URL}, bucket={settings.S3_BUCKET_NAME}")
|
||||||
|
try:
|
||||||
|
# Use signature_version=s3v4 for S3-compatible storage
|
||||||
|
self._s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.S3_ENDPOINT_URL,
|
||||||
|
aws_access_key_id=settings.S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.S3_SECRET_ACCESS_KEY,
|
||||||
|
region_name=settings.S3_REGION or "us-east-1",
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
logger.info("S3 client initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize S3 client: {e}")
|
||||||
|
self._s3_client = None
|
||||||
|
return self._s3_client
|
||||||
|
|
||||||
|
def generate_filename(self, prefix: str | int, original_filename: str | None) -> str:
|
||||||
|
"""Generate unique filename with prefix."""
|
||||||
|
ext = "jpg"
|
||||||
|
if original_filename and "." in original_filename:
|
||||||
|
ext = original_filename.rsplit(".", 1)[-1].lower()
|
||||||
|
return f"{prefix}_{uuid.uuid4().hex}.{ext}"
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
folder: StorageFolder,
|
||||||
|
filename: str,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Upload file to storage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path/key to the uploaded file (relative path for local, S3 key for S3)
|
||||||
|
"""
|
||||||
|
if settings.S3_ENABLED:
|
||||||
|
try:
|
||||||
|
return await self._upload_to_s3(content, folder, filename, content_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"S3 upload failed, falling back to local: {e}")
|
||||||
|
return await self._upload_to_local(content, folder, filename)
|
||||||
|
else:
|
||||||
|
return await self._upload_to_local(content, folder, filename)
|
||||||
|
|
||||||
|
async def _upload_to_s3(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
folder: StorageFolder,
|
||||||
|
filename: str,
|
||||||
|
content_type: str,
|
||||||
|
) -> str:
|
||||||
|
"""Upload file to S3."""
|
||||||
|
key = f"{folder}/{filename}"
|
||||||
|
|
||||||
|
if not self.s3_client:
|
||||||
|
raise RuntimeError("S3 client not initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Uploading to S3: bucket={settings.S3_BUCKET_NAME}, key={key}")
|
||||||
|
self.s3_client.put_object(
|
||||||
|
Bucket=settings.S3_BUCKET_NAME,
|
||||||
|
Key=key,
|
||||||
|
Body=content,
|
||||||
|
ContentType=content_type,
|
||||||
|
)
|
||||||
|
logger.info(f"Successfully uploaded to S3: {key}")
|
||||||
|
return key
|
||||||
|
except (ClientError, BotoCoreError) as e:
|
||||||
|
logger.error(f"S3 upload error: {e}")
|
||||||
|
raise RuntimeError(f"Failed to upload to S3: {e}")
|
||||||
|
|
||||||
|
async def _upload_to_local(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
folder: StorageFolder,
|
||||||
|
filename: str,
|
||||||
|
) -> str:
|
||||||
|
"""Upload file to local filesystem."""
|
||||||
|
filepath = Path(settings.UPLOAD_DIR) / folder / filename
|
||||||
|
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
return str(filepath)
|
||||||
|
|
||||||
|
def get_url(self, path: str | None, folder: StorageFolder) -> str | None:
|
||||||
|
"""
|
||||||
|
Get public URL for a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File path/key (can be full path or just filename)
|
||||||
|
folder: Storage folder (avatars, covers, proofs)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Public URL or None if path is None
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract filename from path
|
||||||
|
filename = path.split("/")[-1]
|
||||||
|
|
||||||
|
if settings.S3_ENABLED:
|
||||||
|
# S3 URL
|
||||||
|
return f"{settings.S3_PUBLIC_URL}/{folder}/{filename}"
|
||||||
|
else:
|
||||||
|
# Local URL
|
||||||
|
return f"/uploads/{folder}/{filename}"
|
||||||
|
|
||||||
|
async def delete_file(self, path: str | None) -> bool:
|
||||||
|
"""
|
||||||
|
Delete file from storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File path/key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False otherwise
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if settings.S3_ENABLED:
|
||||||
|
return await self._delete_from_s3(path)
|
||||||
|
else:
|
||||||
|
return await self._delete_from_local(path)
|
||||||
|
|
||||||
|
async def _delete_from_s3(self, key: str) -> bool:
|
||||||
|
"""Delete file from S3."""
|
||||||
|
try:
|
||||||
|
self.s3_client.delete_object(
|
||||||
|
Bucket=settings.S3_BUCKET_NAME,
|
||||||
|
Key=key,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except ClientError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _delete_from_local(self, path: str) -> bool:
|
||||||
|
"""Delete file from local filesystem."""
|
||||||
|
try:
|
||||||
|
filepath = Path(path)
|
||||||
|
if filepath.exists():
|
||||||
|
filepath.unlink()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_file(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
folder: StorageFolder,
|
||||||
|
) -> tuple[bytes, str] | None:
|
||||||
|
"""
|
||||||
|
Get file content from storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File path/key (can be full path or just filename)
|
||||||
|
folder: Storage folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (content bytes, content_type) or None if not found
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract filename from path
|
||||||
|
filename = path.split("/")[-1]
|
||||||
|
|
||||||
|
if settings.S3_ENABLED:
|
||||||
|
return await self._get_from_s3(folder, filename)
|
||||||
|
else:
|
||||||
|
return await self._get_from_local(folder, filename)
|
||||||
|
|
||||||
|
async def _get_from_s3(
|
||||||
|
self,
|
||||||
|
folder: StorageFolder,
|
||||||
|
filename: str,
|
||||||
|
) -> tuple[bytes, str] | None:
|
||||||
|
"""Get file from S3."""
|
||||||
|
key = f"{folder}/{filename}"
|
||||||
|
|
||||||
|
if not self.s3_client:
|
||||||
|
logger.error("S3 client not initialized")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.s3_client.get_object(
|
||||||
|
Bucket=settings.S3_BUCKET_NAME,
|
||||||
|
Key=key,
|
||||||
|
)
|
||||||
|
content = response["Body"].read()
|
||||||
|
content_type = response.get("ContentType", "application/octet-stream")
|
||||||
|
return content, content_type
|
||||||
|
except ClientError as e:
|
||||||
|
logger.error(f"S3 get error for {key}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _get_from_local(
|
||||||
|
self,
|
||||||
|
folder: StorageFolder,
|
||||||
|
filename: str,
|
||||||
|
) -> tuple[bytes, str] | None:
|
||||||
|
"""Get file from local filesystem."""
|
||||||
|
filepath = Path(settings.UPLOAD_DIR) / folder / filename
|
||||||
|
|
||||||
|
if not filepath.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Determine content type from extension
|
||||||
|
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||||
|
content_types = {
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"png": "image/png",
|
||||||
|
"gif": "image/gif",
|
||||||
|
"webp": "image/webp",
|
||||||
|
"mp4": "video/mp4",
|
||||||
|
"webm": "video/webm",
|
||||||
|
"mov": "video/quicktime",
|
||||||
|
}
|
||||||
|
content_type = content_types.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
return content, content_type
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Local get error for {filepath}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
storage_service = StorageService()
|
||||||
212
backend/app/services/telegram_notifier.py
Normal file
212
backend/app/services/telegram_notifier.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models import User, Participant, Marathon
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramNotifier:
|
||||||
|
"""Service for sending Telegram notifications."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.bot_token = settings.TELEGRAM_BOT_TOKEN
|
||||||
|
self.api_url = f"https://api.telegram.org/bot{self.bot_token}"
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
text: str,
|
||||||
|
parse_mode: str = "HTML"
|
||||||
|
) -> bool:
|
||||||
|
"""Send a message to a Telegram chat."""
|
||||||
|
if not self.bot_token:
|
||||||
|
logger.warning("Telegram bot token not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.api_url}/sendMessage",
|
||||||
|
json={
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": parse_mode
|
||||||
|
},
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to send message: {response.text}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending Telegram message: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def notify_user(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
message: str
|
||||||
|
) -> bool:
|
||||||
|
"""Send notification to a user by user_id."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.id == user_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or not user.telegram_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await self.send_message(user.telegram_id, message)
|
||||||
|
|
||||||
|
async def notify_marathon_participants(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
message: str,
|
||||||
|
exclude_user_id: int | None = None
|
||||||
|
) -> int:
|
||||||
|
"""Send notification to all marathon participants with linked Telegram."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.join(Participant, Participant.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
User.telegram_id.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
for user in users:
|
||||||
|
if exclude_user_id and user.id == exclude_user_id:
|
||||||
|
continue
|
||||||
|
if await self.send_message(user.telegram_id, message):
|
||||||
|
sent_count += 1
|
||||||
|
|
||||||
|
return sent_count
|
||||||
|
|
||||||
|
# Notification templates
|
||||||
|
async def notify_event_start(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
event_type: str,
|
||||||
|
marathon_title: str
|
||||||
|
) -> int:
|
||||||
|
"""Notify participants about event start."""
|
||||||
|
event_messages = {
|
||||||
|
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
|
||||||
|
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
|
||||||
|
"double_risk": f"⚡ <b>Double Risk</b> в «{marathon_title}»!\n\nПоловина очков, но дропы бесплатны!",
|
||||||
|
"common_enemy": f"👥 <b>Common Enemy</b> в «{marathon_title}»!\n\nВсе получают одинаковый челлендж. Первые 3 — бонус!",
|
||||||
|
"swap": f"🔄 <b>Swap</b> в «{marathon_title}»!\n\nМожно поменяться заданием с другим участником!",
|
||||||
|
"game_choice": f"🎲 <b>Выбор игры</b> в «{marathon_title}»!\n\nВыбери игру и один из 3 челленджей!"
|
||||||
|
}
|
||||||
|
|
||||||
|
message = event_messages.get(
|
||||||
|
event_type,
|
||||||
|
f"📌 Новое событие в «{marathon_title}»!"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self.notify_marathon_participants(db, marathon_id, message)
|
||||||
|
|
||||||
|
async def notify_event_end(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
event_type: str,
|
||||||
|
marathon_title: str
|
||||||
|
) -> int:
|
||||||
|
"""Notify participants about event end."""
|
||||||
|
event_names = {
|
||||||
|
"golden_hour": "Golden Hour",
|
||||||
|
"jackpot": "Jackpot",
|
||||||
|
"double_risk": "Double Risk",
|
||||||
|
"common_enemy": "Common Enemy",
|
||||||
|
"swap": "Swap",
|
||||||
|
"game_choice": "Выбор игры"
|
||||||
|
}
|
||||||
|
|
||||||
|
event_name = event_names.get(event_type, "Событие")
|
||||||
|
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
|
||||||
|
|
||||||
|
return await self.notify_marathon_participants(db, marathon_id, message)
|
||||||
|
|
||||||
|
async def notify_marathon_start(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
marathon_title: str
|
||||||
|
) -> int:
|
||||||
|
"""Notify participants about marathon start."""
|
||||||
|
message = (
|
||||||
|
f"🚀 <b>Марафон «{marathon_title}» начался!</b>\n\n"
|
||||||
|
f"Время крутить колесо и получить первое задание!"
|
||||||
|
)
|
||||||
|
return await self.notify_marathon_participants(db, marathon_id, message)
|
||||||
|
|
||||||
|
async def notify_marathon_finish(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
marathon_id: int,
|
||||||
|
marathon_title: str
|
||||||
|
) -> int:
|
||||||
|
"""Notify participants about marathon finish."""
|
||||||
|
message = (
|
||||||
|
f"🏆 <b>Марафон «{marathon_title}» завершён!</b>\n\n"
|
||||||
|
f"Зайди на сайт, чтобы увидеть итоговую таблицу!"
|
||||||
|
)
|
||||||
|
return await self.notify_marathon_participants(db, marathon_id, message)
|
||||||
|
|
||||||
|
async def notify_dispute_raised(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
challenge_title: str
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user about dispute raised on their assignment."""
|
||||||
|
message = (
|
||||||
|
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"Зайди на сайт, чтобы ответить на спор."
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_dispute_resolved(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
challenge_title: str,
|
||||||
|
is_valid: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user about dispute resolution."""
|
||||||
|
if is_valid:
|
||||||
|
message = (
|
||||||
|
f"❌ <b>Спор признан обоснованным</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"Задание возвращено. Выполни его заново."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f"✅ <b>Спор отклонён</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"Твоё выполнение засчитано!"
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
telegram_notifier = TelegramNotifier()
|
||||||
@@ -28,5 +28,8 @@ httpx==0.26.0
|
|||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
python-magic==0.4.27
|
python-magic==0.4.27
|
||||||
|
|
||||||
|
# S3 Storage
|
||||||
|
boto3==1.34.0
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
|||||||
10
bot/Dockerfile
Normal file
10
bot/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["python", "main.py"]
|
||||||
14
bot/config.py
Normal file
14
bot/config.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
TELEGRAM_BOT_TOKEN: str
|
||||||
|
API_URL: str = "http://backend:8000"
|
||||||
|
BOT_USERNAME: str = "" # Will be set dynamically on startup
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
1
bot/handlers/__init__.py
Normal file
1
bot/handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot handlers
|
||||||
60
bot/handlers/link.py
Normal file
60
bot/handlers/link.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from keyboards.main_menu import get_main_menu
|
||||||
|
from services.api_client import api_client
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("unlink"))
|
||||||
|
async def cmd_unlink(message: Message):
|
||||||
|
"""Handle /unlink command to disconnect Telegram account."""
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
await message.answer(
|
||||||
|
"Твой аккаунт не привязан к Game Marathon.\n"
|
||||||
|
"Привяжи его через настройки профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await api_client.unlink_telegram(message.from_user.id)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
await message.answer(
|
||||||
|
"<b>Аккаунт отвязан</b>\n\n"
|
||||||
|
"Ты больше не будешь получать уведомления.\n"
|
||||||
|
"Чтобы привязать аккаунт снова, используй кнопку в настройках профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.answer(
|
||||||
|
"Произошла ошибка при отвязке аккаунта.\n"
|
||||||
|
"Попробуй позже или обратись к администратору.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("status"))
|
||||||
|
async def cmd_status(message: Message):
|
||||||
|
"""Check account link status."""
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
await message.answer(
|
||||||
|
f"<b>Статус аккаунта</b>\n\n"
|
||||||
|
f"✅ Аккаунт привязан\n"
|
||||||
|
f"👤 Никнейм: <b>{user.get('nickname', 'N/A')}</b>\n"
|
||||||
|
f"🆔 ID: {user.get('id', 'N/A')}",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.answer(
|
||||||
|
"<b>Статус аккаунта</b>\n\n"
|
||||||
|
"❌ Аккаунт не привязан\n\n"
|
||||||
|
"Привяжи его через настройки профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
211
bot/handlers/marathons.py
Normal file
211
bot/handlers/marathons.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
|
||||||
|
from keyboards.main_menu import get_main_menu
|
||||||
|
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard
|
||||||
|
from services.api_client import api_client
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("marathons"))
|
||||||
|
@router.message(F.text == "📊 Мои марафоны")
|
||||||
|
async def cmd_marathons(message: Message):
|
||||||
|
"""Show user's marathons."""
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
await message.answer(
|
||||||
|
"Сначала привяжи аккаунт через настройки профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
marathons = await api_client.get_user_marathons(message.from_user.id)
|
||||||
|
|
||||||
|
if not marathons:
|
||||||
|
await message.answer(
|
||||||
|
"<b>Мои марафоны</b>\n\n"
|
||||||
|
"У тебя пока нет активных марафонов.\n"
|
||||||
|
"Присоединись к марафону на сайте!",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "<b>📊 Мои марафоны</b>\n\n"
|
||||||
|
|
||||||
|
for m in marathons:
|
||||||
|
status_emoji = {
|
||||||
|
"preparing": "⏳",
|
||||||
|
"active": "🎮",
|
||||||
|
"finished": "🏁"
|
||||||
|
}.get(m.get("status"), "❓")
|
||||||
|
|
||||||
|
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
|
||||||
|
text += f" Очки: {m.get('total_points', 0)} | "
|
||||||
|
text += f"Место: #{m.get('position', '?')}\n\n"
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text,
|
||||||
|
reply_markup=get_marathons_keyboard(marathons)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("marathon:"))
|
||||||
|
async def marathon_details(callback: CallbackQuery):
|
||||||
|
"""Show marathon details."""
|
||||||
|
marathon_id = int(callback.data.split(":")[1])
|
||||||
|
|
||||||
|
details = await api_client.get_marathon_details(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
telegram_id=callback.from_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not details:
|
||||||
|
await callback.answer("Не удалось загрузить данные марафона", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
marathon = details.get("marathon", {})
|
||||||
|
participant = details.get("participant", {})
|
||||||
|
active_events = details.get("active_events", [])
|
||||||
|
current_assignment = details.get("current_assignment")
|
||||||
|
|
||||||
|
status_text = {
|
||||||
|
"preparing": "⏳ Подготовка",
|
||||||
|
"active": "🎮 Активен",
|
||||||
|
"finished": "🏁 Завершён"
|
||||||
|
}.get(marathon.get("status"), "❓")
|
||||||
|
|
||||||
|
text = f"<b>{marathon.get('title')}</b>\n"
|
||||||
|
text += f"Статус: {status_text}\n\n"
|
||||||
|
|
||||||
|
text += f"<b>📈 Твоя статистика:</b>\n"
|
||||||
|
text += f"• Очки: <b>{participant.get('total_points', 0)}</b>\n"
|
||||||
|
text += f"• Место: <b>#{details.get('position', '?')}</b>\n"
|
||||||
|
text += f"• Стрик: <b>{participant.get('current_streak', 0)}</b> 🔥\n"
|
||||||
|
text += f"• Дропов: <b>{participant.get('drop_count', 0)}</b>\n\n"
|
||||||
|
|
||||||
|
if active_events:
|
||||||
|
text += "<b>⚡ Активные события:</b>\n"
|
||||||
|
for event in active_events:
|
||||||
|
event_emoji = {
|
||||||
|
"golden_hour": "🌟",
|
||||||
|
"jackpot": "🎰",
|
||||||
|
"double_risk": "⚡",
|
||||||
|
"common_enemy": "👥",
|
||||||
|
"swap": "🔄",
|
||||||
|
"game_choice": "🎲"
|
||||||
|
}.get(event.get("type"), "📌")
|
||||||
|
text += f"{event_emoji} {event.get('type', '').replace('_', ' ').title()}\n"
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
if current_assignment:
|
||||||
|
challenge = current_assignment.get("challenge", {})
|
||||||
|
game = challenge.get("game", {})
|
||||||
|
text += f"<b>🎯 Текущее задание:</b>\n"
|
||||||
|
text += f"Игра: {game.get('title', 'N/A')}\n"
|
||||||
|
text += f"Задание: {challenge.get('title', 'N/A')}\n"
|
||||||
|
text += f"Сложность: {challenge.get('difficulty', 'N/A')}\n"
|
||||||
|
text += f"Очки: {challenge.get('points', 0)}\n"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=get_marathon_details_keyboard(marathon_id)
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "back_to_marathons")
|
||||||
|
async def back_to_marathons(callback: CallbackQuery):
|
||||||
|
"""Go back to marathons list."""
|
||||||
|
marathons = await api_client.get_user_marathons(callback.from_user.id)
|
||||||
|
|
||||||
|
if not marathons:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"<b>Мои марафоны</b>\n\n"
|
||||||
|
"У тебя пока нет активных марафонов."
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "<b>📊 Мои марафоны</b>\n\n"
|
||||||
|
|
||||||
|
for m in marathons:
|
||||||
|
status_emoji = {
|
||||||
|
"preparing": "⏳",
|
||||||
|
"active": "🎮",
|
||||||
|
"finished": "🏁"
|
||||||
|
}.get(m.get("status"), "❓")
|
||||||
|
|
||||||
|
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
|
||||||
|
text += f" Очки: {m.get('total_points', 0)} | "
|
||||||
|
text += f"Место: #{m.get('position', '?')}\n\n"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=get_marathons_keyboard(marathons)
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("stats"))
|
||||||
|
@router.message(F.text == "📈 Статистика")
|
||||||
|
async def cmd_stats(message: Message):
|
||||||
|
"""Show user's overall statistics."""
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
await message.answer(
|
||||||
|
"Сначала привяжи аккаунт через настройки профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = await api_client.get_user_stats(message.from_user.id)
|
||||||
|
|
||||||
|
if not stats:
|
||||||
|
await message.answer(
|
||||||
|
"<b>📈 Статистика</b>\n\n"
|
||||||
|
"Пока нет данных для отображения.\n"
|
||||||
|
"Начни участвовать в марафонах!",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = f"<b>📈 Общая статистика</b>\n\n"
|
||||||
|
text += f"👤 <b>{user.get('nickname', 'Игрок')}</b>\n\n"
|
||||||
|
text += f"🏆 Марафонов завершено: <b>{stats.get('marathons_completed', 0)}</b>\n"
|
||||||
|
text += f"🎮 Марафонов активно: <b>{stats.get('marathons_active', 0)}</b>\n"
|
||||||
|
text += f"✅ Заданий выполнено: <b>{stats.get('challenges_completed', 0)}</b>\n"
|
||||||
|
text += f"💰 Всего очков: <b>{stats.get('total_points', 0)}</b>\n"
|
||||||
|
text += f"🔥 Лучший стрик: <b>{stats.get('best_streak', 0)}</b>\n"
|
||||||
|
|
||||||
|
await message.answer(text, reply_markup=get_main_menu())
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("settings"))
|
||||||
|
@router.message(F.text == "⚙️ Настройки")
|
||||||
|
async def cmd_settings(message: Message):
|
||||||
|
"""Show notification settings."""
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
await message.answer(
|
||||||
|
"Сначала привяжи аккаунт через настройки профиля на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"<b>⚙️ Настройки</b>\n\n"
|
||||||
|
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
|
||||||
|
"Сейчас ты получаешь все уведомления:\n"
|
||||||
|
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
|
||||||
|
"• 🚀 Старт/финиш марафонов\n"
|
||||||
|
"• ⚠️ Споры по заданиям\n\n"
|
||||||
|
"Команды:\n"
|
||||||
|
"/unlink - Отвязать аккаунт\n"
|
||||||
|
"/status - Проверить привязку",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
146
bot/handlers/start.py
Normal file
146
bot/handlers/start.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Router, F, Bot
|
||||||
|
from aiogram.filters import CommandStart, Command, CommandObject
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from keyboards.main_menu import get_main_menu
|
||||||
|
from services.api_client import api_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_avatar_url(bot: Bot, user_id: int) -> str | None:
|
||||||
|
"""Get user's Telegram profile photo URL."""
|
||||||
|
try:
|
||||||
|
photos = await bot.get_user_profile_photos(user_id, limit=1)
|
||||||
|
if photos.total_count > 0 and photos.photos:
|
||||||
|
# Get the largest photo (last in the list)
|
||||||
|
photo = photos.photos[0][-1]
|
||||||
|
file = await bot.get_file(photo.file_id)
|
||||||
|
if file.file_path:
|
||||||
|
return f"https://api.telegram.org/file/bot{settings.TELEGRAM_BOT_TOKEN}/{file.file_path}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[START] Could not get user avatar: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
async def cmd_start(message: Message, command: CommandObject):
|
||||||
|
"""Handle /start command with or without deep link."""
|
||||||
|
logger.info(f"[START] ==================== START COMMAND ====================")
|
||||||
|
logger.info(f"[START] Telegram user: id={message.from_user.id}, username=@{message.from_user.username}")
|
||||||
|
logger.info(f"[START] Full message text: '{message.text}'")
|
||||||
|
logger.info(f"[START] Deep link args (command.args): '{command.args}'")
|
||||||
|
|
||||||
|
# Check if there's a deep link token (for account linking)
|
||||||
|
token = command.args
|
||||||
|
if token:
|
||||||
|
logger.info(f"[START] -------- TOKEN RECEIVED --------")
|
||||||
|
logger.info(f"[START] Token: {token}")
|
||||||
|
logger.info(f"[START] Token length: {len(token)} chars")
|
||||||
|
|
||||||
|
# Get user's avatar
|
||||||
|
avatar_url = await get_user_avatar_url(message.bot, message.from_user.id)
|
||||||
|
logger.info(f"[START] User avatar URL: {avatar_url}")
|
||||||
|
|
||||||
|
logger.info(f"[START] -------- CALLING API --------")
|
||||||
|
logger.info(f"[START] Sending to /telegram/confirm-link:")
|
||||||
|
logger.info(f"[START] - token: {token}")
|
||||||
|
logger.info(f"[START] - telegram_id: {message.from_user.id}")
|
||||||
|
logger.info(f"[START] - telegram_username: {message.from_user.username}")
|
||||||
|
logger.info(f"[START] - telegram_first_name: {message.from_user.first_name}")
|
||||||
|
logger.info(f"[START] - telegram_last_name: {message.from_user.last_name}")
|
||||||
|
logger.info(f"[START] - telegram_avatar_url: {avatar_url}")
|
||||||
|
|
||||||
|
result = await api_client.confirm_telegram_link(
|
||||||
|
token=token,
|
||||||
|
telegram_id=message.from_user.id,
|
||||||
|
telegram_username=message.from_user.username,
|
||||||
|
telegram_first_name=message.from_user.first_name,
|
||||||
|
telegram_last_name=message.from_user.last_name,
|
||||||
|
telegram_avatar_url=avatar_url
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[START] -------- API RESPONSE --------")
|
||||||
|
logger.info(f"[START] Response: {result}")
|
||||||
|
logger.info(f"[START] Success: {result.get('success')}")
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
user_nickname = result.get("nickname", "пользователь")
|
||||||
|
logger.info(f"[START] ✅ LINK SUCCESS! User '{user_nickname}' linked to telegram_id={message.from_user.id}")
|
||||||
|
await message.answer(
|
||||||
|
f"<b>Аккаунт успешно привязан!</b>\n\n"
|
||||||
|
f"Привет, <b>{user_nickname}</b>!\n\n"
|
||||||
|
f"Теперь ты будешь получать уведомления о:\n"
|
||||||
|
f"• Начале и окончании событий (Golden Hour, Jackpot и др.)\n"
|
||||||
|
f"• Старте и завершении марафонов\n"
|
||||||
|
f"• Спорах по твоим заданиям\n\n"
|
||||||
|
f"Используй меню ниже для навигации:",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
error = result.get("error", "Неизвестная ошибка")
|
||||||
|
logger.error(f"[START] ❌ LINK FAILED!")
|
||||||
|
logger.error(f"[START] Error: {error}")
|
||||||
|
logger.error(f"[START] Token was: {token}")
|
||||||
|
await message.answer(
|
||||||
|
f"<b>Ошибка привязки аккаунта</b>\n\n"
|
||||||
|
f"{error}\n\n"
|
||||||
|
f"Попробуй получить новую ссылку на сайте.",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# No token - regular start
|
||||||
|
logger.info(f"[START] No token, checking if user is already linked...")
|
||||||
|
user = await api_client.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
logger.info(f"[START] API response: {user}")
|
||||||
|
|
||||||
|
if user:
|
||||||
|
await message.answer(
|
||||||
|
f"<b>С возвращением, {user.get('nickname', 'игрок')}!</b>\n\n"
|
||||||
|
f"Твой аккаунт привязан. Используй меню для навигации:",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.answer(
|
||||||
|
"<b>Добро пожаловать в Game Marathon Bot!</b>\n\n"
|
||||||
|
"Этот бот поможет тебе следить за марафонами и "
|
||||||
|
"получать уведомления о важных событиях.\n\n"
|
||||||
|
"<b>Для начала работы:</b>\n"
|
||||||
|
"1. Зайди на сайт в настройки профиля\n"
|
||||||
|
"2. Нажми кнопку «Привязать Telegram»\n"
|
||||||
|
"3. Перейди по полученной ссылке\n\n"
|
||||||
|
"После привязки ты сможешь:\n"
|
||||||
|
"• Смотреть свои марафоны\n"
|
||||||
|
"• Получать уведомления о событиях\n"
|
||||||
|
"• Следить за статистикой",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("help"))
|
||||||
|
@router.message(F.text == "❓ Помощь")
|
||||||
|
async def cmd_help(message: Message):
|
||||||
|
"""Handle /help command."""
|
||||||
|
await message.answer(
|
||||||
|
"<b>Справка по командам:</b>\n\n"
|
||||||
|
"/start - Начать работу с ботом\n"
|
||||||
|
"/marathons - Мои марафоны\n"
|
||||||
|
"/stats - Моя статистика\n"
|
||||||
|
"/settings - Настройки уведомлений\n"
|
||||||
|
"/help - Эта справка\n\n"
|
||||||
|
"<b>Уведомления:</b>\n"
|
||||||
|
"Бот присылает уведомления о:\n"
|
||||||
|
"• 🌟 Golden Hour - очки x1.5\n"
|
||||||
|
"• 🎰 Jackpot - очки x3\n"
|
||||||
|
"• ⚡ Double Risk - половина очков, дропы бесплатны\n"
|
||||||
|
"• 👥 Common Enemy - общий челлендж\n"
|
||||||
|
"• 🚀 Старт/финиш марафонов\n"
|
||||||
|
"• ⚠️ Споры по заданиям",
|
||||||
|
reply_markup=get_main_menu()
|
||||||
|
)
|
||||||
1
bot/keyboards/__init__.py
Normal file
1
bot/keyboards/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot keyboards
|
||||||
42
bot/keyboards/inline.py
Normal file
42
bot/keyboards/inline.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
|
||||||
|
|
||||||
|
def get_marathons_keyboard(marathons: list) -> InlineKeyboardMarkup:
|
||||||
|
"""Create keyboard with marathon buttons."""
|
||||||
|
buttons = []
|
||||||
|
|
||||||
|
for marathon in marathons:
|
||||||
|
status_emoji = {
|
||||||
|
"preparing": "⏳",
|
||||||
|
"active": "🎮",
|
||||||
|
"finished": "🏁"
|
||||||
|
}.get(marathon.get("status"), "❓")
|
||||||
|
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{status_emoji} {marathon.get('title', 'Marathon')}",
|
||||||
|
callback_data=f"marathon:{marathon.get('id')}"
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
|
def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
|
||||||
|
"""Create keyboard for marathon details view."""
|
||||||
|
buttons = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔄 Обновить",
|
||||||
|
callback_data=f"marathon:{marathon_id}"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="◀️ Назад к списку",
|
||||||
|
callback_data="back_to_marathons"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
21
bot/keyboards/main_menu.py
Normal file
21
bot/keyboards/main_menu.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
|
||||||
|
|
||||||
|
|
||||||
|
def get_main_menu() -> ReplyKeyboardMarkup:
|
||||||
|
"""Create main menu keyboard."""
|
||||||
|
keyboard = [
|
||||||
|
[
|
||||||
|
KeyboardButton(text="📊 Мои марафоны"),
|
||||||
|
KeyboardButton(text="📈 Статистика")
|
||||||
|
],
|
||||||
|
[
|
||||||
|
KeyboardButton(text="⚙️ Настройки"),
|
||||||
|
KeyboardButton(text="❓ Помощь")
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
return ReplyKeyboardMarkup(
|
||||||
|
keyboard=keyboard,
|
||||||
|
resize_keyboard=True,
|
||||||
|
input_field_placeholder="Выбери действие..."
|
||||||
|
)
|
||||||
65
bot/main.py
Normal file
65
bot/main.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from handlers import start, marathons, link
|
||||||
|
from middlewares.logging import LoggingMiddleware
|
||||||
|
|
||||||
|
# Configure logging to stdout with DEBUG level
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Set aiogram logging level
|
||||||
|
logging.getLogger("aiogram").setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logger.info("="*50)
|
||||||
|
logger.info("Starting Game Marathon Bot...")
|
||||||
|
logger.info(f"API_URL: {settings.API_URL}")
|
||||||
|
logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...")
|
||||||
|
logger.info("="*50)
|
||||||
|
|
||||||
|
bot = Bot(
|
||||||
|
token=settings.TELEGRAM_BOT_TOKEN,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get bot username for deep links
|
||||||
|
bot_info = await bot.get_me()
|
||||||
|
settings.BOT_USERNAME = bot_info.username
|
||||||
|
logger.info(f"Bot info: @{settings.BOT_USERNAME} (id={bot_info.id})")
|
||||||
|
|
||||||
|
dp = Dispatcher()
|
||||||
|
|
||||||
|
# Register middleware
|
||||||
|
dp.message.middleware(LoggingMiddleware())
|
||||||
|
logger.info("Logging middleware registered")
|
||||||
|
|
||||||
|
# Register routers
|
||||||
|
logger.info("Registering routers...")
|
||||||
|
dp.include_router(start.router)
|
||||||
|
dp.include_router(link.router)
|
||||||
|
dp.include_router(marathons.router)
|
||||||
|
logger.info("Routers registered: start, link, marathons")
|
||||||
|
|
||||||
|
# Start polling
|
||||||
|
logger.info("Deleting webhook and starting polling...")
|
||||||
|
await bot.delete_webhook(drop_pending_updates=True)
|
||||||
|
logger.info("Polling started! Waiting for messages...")
|
||||||
|
await dp.start_polling(bot)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
1
bot/middlewares/__init__.py
Normal file
1
bot/middlewares/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot middlewares
|
||||||
28
bot/middlewares/logging.py
Normal file
28
bot/middlewares/logging.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Awaitable, Callable, Dict
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import Message, Update
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingMiddleware(BaseMiddleware):
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: Message,
|
||||||
|
data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
logger.info("="*60)
|
||||||
|
logger.info(f"[MIDDLEWARE] Incoming message from user {event.from_user.id}")
|
||||||
|
logger.info(f"[MIDDLEWARE] Username: @{event.from_user.username}")
|
||||||
|
logger.info(f"[MIDDLEWARE] Text: {event.text}")
|
||||||
|
logger.info(f"[MIDDLEWARE] Message ID: {event.message_id}")
|
||||||
|
logger.info(f"[MIDDLEWARE] Chat ID: {event.chat.id}")
|
||||||
|
logger.info("="*60)
|
||||||
|
|
||||||
|
result = await handler(event, data)
|
||||||
|
|
||||||
|
logger.info(f"[MIDDLEWARE] Handler completed for message {event.message_id}")
|
||||||
|
return result
|
||||||
5
bot/requirements.txt
Normal file
5
bot/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
aiogram==3.23.0
|
||||||
|
aiohttp==3.10.5
|
||||||
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
|
python-dotenv==1.0.1
|
||||||
1
bot/services/__init__.py
Normal file
1
bot/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot services
|
||||||
129
bot/services/api_client.py
Normal file
129
bot/services/api_client.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class APIClient:
|
||||||
|
"""HTTP client for backend API communication."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = settings.API_URL
|
||||||
|
self._session: aiohttp.ClientSession | None = None
|
||||||
|
logger.info(f"[APIClient] Initialized with base_url: {self.base_url}")
|
||||||
|
|
||||||
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
logger.info("[APIClient] Creating new aiohttp session")
|
||||||
|
self._session = aiohttp.ClientSession()
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
**kwargs
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Make HTTP request to backend API."""
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f"{self.base_url}/api/v1{endpoint}"
|
||||||
|
|
||||||
|
logger.info(f"[APIClient] {method} {url}")
|
||||||
|
if 'json' in kwargs:
|
||||||
|
logger.info(f"[APIClient] Request body: {kwargs['json']}")
|
||||||
|
if 'params' in kwargs:
|
||||||
|
logger.info(f"[APIClient] Request params: {kwargs['params']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.request(method, url, **kwargs) as response:
|
||||||
|
logger.info(f"[APIClient] Response status: {response.status}")
|
||||||
|
response_text = await response.text()
|
||||||
|
logger.info(f"[APIClient] Response body: {response_text[:500]}")
|
||||||
|
|
||||||
|
if response.status == 200:
|
||||||
|
import json
|
||||||
|
return json.loads(response_text)
|
||||||
|
elif response.status == 404:
|
||||||
|
logger.warning(f"[APIClient] 404 Not Found")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.error(f"[APIClient] API error {response.status}: {response_text}")
|
||||||
|
return {"error": response_text}
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.error(f"[APIClient] Request failed: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[APIClient] Unexpected error: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
async def confirm_telegram_link(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
telegram_id: int,
|
||||||
|
telegram_username: str | None,
|
||||||
|
telegram_first_name: str | None = None,
|
||||||
|
telegram_last_name: str | None = None,
|
||||||
|
telegram_avatar_url: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Confirm Telegram account linking."""
|
||||||
|
result = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/telegram/confirm-link",
|
||||||
|
json={
|
||||||
|
"token": token,
|
||||||
|
"telegram_id": telegram_id,
|
||||||
|
"telegram_username": telegram_username,
|
||||||
|
"telegram_first_name": telegram_first_name,
|
||||||
|
"telegram_last_name": telegram_last_name,
|
||||||
|
"telegram_avatar_url": telegram_avatar_url
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result or {"error": "Не удалось связаться с сервером"}
|
||||||
|
|
||||||
|
async def get_user_by_telegram_id(self, telegram_id: int) -> dict[str, Any] | None:
|
||||||
|
"""Get user by Telegram ID."""
|
||||||
|
return await self._request("GET", f"/telegram/user/{telegram_id}")
|
||||||
|
|
||||||
|
async def unlink_telegram(self, telegram_id: int) -> dict[str, Any]:
|
||||||
|
"""Unlink Telegram account."""
|
||||||
|
result = await self._request(
|
||||||
|
"POST",
|
||||||
|
f"/telegram/unlink/{telegram_id}"
|
||||||
|
)
|
||||||
|
return result or {"error": "Не удалось связаться с сервером"}
|
||||||
|
|
||||||
|
async def get_user_marathons(self, telegram_id: int) -> list[dict[str, Any]]:
|
||||||
|
"""Get user's marathons."""
|
||||||
|
result = await self._request("GET", f"/telegram/marathons/{telegram_id}")
|
||||||
|
if isinstance(result, list):
|
||||||
|
return result
|
||||||
|
return result.get("marathons", []) if result else []
|
||||||
|
|
||||||
|
async def get_marathon_details(
|
||||||
|
self,
|
||||||
|
marathon_id: int,
|
||||||
|
telegram_id: int
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Get marathon details for user."""
|
||||||
|
return await self._request(
|
||||||
|
"GET",
|
||||||
|
f"/telegram/marathon/{marathon_id}",
|
||||||
|
params={"telegram_id": telegram_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_user_stats(self, telegram_id: int) -> dict[str, Any] | None:
|
||||||
|
"""Get user's overall statistics."""
|
||||||
|
return await self._request("GET", f"/telegram/stats/{telegram_id}")
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the HTTP session."""
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Global API client instance
|
||||||
|
api_client = APIClient()
|
||||||
@@ -27,7 +27,16 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||||
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
|
||||||
DEBUG: ${DEBUG:-false}
|
DEBUG: ${DEBUG:-false}
|
||||||
|
# S3 Storage
|
||||||
|
S3_ENABLED: ${S3_ENABLED:-false}
|
||||||
|
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
|
||||||
|
S3_REGION: ${S3_REGION:-ru-1}
|
||||||
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||||
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||||
|
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
|
||||||
|
S3_PUBLIC_URL: ${S3_PUBLIC_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/uploads:/app/uploads
|
- ./backend/uploads:/app/uploads
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
@@ -64,5 +73,17 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: ./bot
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: marathon-bot
|
||||||
|
environment:
|
||||||
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||||
|
- API_URL=http://backend:8000
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
import { Layout } from '@/components/layout/Layout'
|
import { Layout } from '@/components/layout/Layout'
|
||||||
@@ -15,6 +16,7 @@ import { LobbyPage } from '@/pages/LobbyPage'
|
|||||||
import { PlayPage } from '@/pages/PlayPage'
|
import { PlayPage } from '@/pages/PlayPage'
|
||||||
import { LeaderboardPage } from '@/pages/LeaderboardPage'
|
import { LeaderboardPage } from '@/pages/LeaderboardPage'
|
||||||
import { InvitePage } from '@/pages/InvitePage'
|
import { InvitePage } from '@/pages/InvitePage'
|
||||||
|
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
|
||||||
|
|
||||||
// Protected route wrapper
|
// Protected route wrapper
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -40,6 +42,9 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ToastContainer />
|
||||||
|
<ConfirmModal />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
@@ -118,8 +123,18 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="assignments/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AssignmentDetailPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
frontend/src/api/assignments.ts
Normal file
47
frontend/src/api/assignments.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import client from './client'
|
||||||
|
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment } from '@/types'
|
||||||
|
|
||||||
|
export const assignmentsApi = {
|
||||||
|
// Get detailed assignment info with proofs and dispute
|
||||||
|
getDetail: async (assignmentId: number): Promise<AssignmentDetail> => {
|
||||||
|
const response = await client.get<AssignmentDetail>(`/assignments/${assignmentId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create a dispute against an assignment
|
||||||
|
createDispute: async (assignmentId: number, reason: string): Promise<Dispute> => {
|
||||||
|
const response = await client.post<Dispute>(`/assignments/${assignmentId}/dispute`, { reason })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add a comment to a dispute
|
||||||
|
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
|
||||||
|
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vote on a dispute (true = valid/proof is OK, false = invalid/proof is not OK)
|
||||||
|
vote: async (disputeId: number, vote: boolean): Promise<{ message: string }> => {
|
||||||
|
const response = await client.post<{ message: string }>(`/disputes/${disputeId}/vote`, { vote })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get current user's returned assignments
|
||||||
|
getReturnedAssignments: async (marathonId: number): Promise<ReturnedAssignment[]> => {
|
||||||
|
const response = await client.get<ReturnedAssignment[]>(`/marathons/${marathonId}/returned-assignments`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get proof media as blob URL (supports both images and videos)
|
||||||
|
getProofMediaUrl: async (assignmentId: number): Promise<{ url: string; type: 'image' | 'video' }> => {
|
||||||
|
const response = await client.get(`/assignments/${assignmentId}/proof-media`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
const contentType = response.headers['content-type'] || ''
|
||||||
|
const isVideo = contentType.startsWith('video/')
|
||||||
|
return {
|
||||||
|
url: URL.createObjectURL(response.data),
|
||||||
|
type: isVideo ? 'video' : 'image',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types'
|
import type { ActiveEvent, MarathonEvent, EventCreate, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult, GameChoiceChallenges } from '@/types'
|
||||||
|
|
||||||
export const eventsApi = {
|
export const eventsApi = {
|
||||||
getActive: async (marathonId: number): Promise<ActiveEvent> => {
|
getActive: async (marathonId: number): Promise<ActiveEvent> => {
|
||||||
@@ -46,12 +46,18 @@ export const eventsApi = {
|
|||||||
await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`)
|
await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
rematch: async (marathonId: number, assignmentId: number): Promise<void> => {
|
// Game Choice event
|
||||||
await client.post(`/marathons/${marathonId}/rematch/${assignmentId}`)
|
getGameChoiceChallenges: async (marathonId: number, gameId: number): Promise<GameChoiceChallenges> => {
|
||||||
|
const response = await client.get<GameChoiceChallenges>(`/marathons/${marathonId}/game-choice/challenges`, {
|
||||||
|
params: { game_id: gameId },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
getDroppedAssignments: async (marathonId: number): Promise<DroppedAssignment[]> => {
|
selectGameChoiceChallenge: async (marathonId: number, challengeId: number): Promise<{ message: string }> => {
|
||||||
const response = await client.get<DroppedAssignment[]>(`/marathons/${marathonId}/dropped-assignments`)
|
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/game-choice/select`, {
|
||||||
|
challenge_id: challengeId,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -64,4 +70,27 @@ export const eventsApi = {
|
|||||||
const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`)
|
const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Event Assignment (Common Enemy)
|
||||||
|
getEventAssignment: async (marathonId: number): Promise<EventAssignment> => {
|
||||||
|
const response = await client.get<EventAssignment>(`/marathons/${marathonId}/event-assignment`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
completeEventAssignment: async (
|
||||||
|
assignmentId: number,
|
||||||
|
data: { proof_url?: string; comment?: string; proof_file?: File }
|
||||||
|
): Promise<CompleteResult> => {
|
||||||
|
const formData = new FormData()
|
||||||
|
if (data.proof_url) formData.append('proof_url', data.proof_url)
|
||||||
|
if (data.comment) formData.append('comment', data.comment)
|
||||||
|
if (data.proof_file) formData.append('proof_file', data.proof_file)
|
||||||
|
|
||||||
|
const response = await client.post<CompleteResult>(
|
||||||
|
`/event-assignments/${assignmentId}/complete`,
|
||||||
|
formData,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export { feedApi } from './feed'
|
|||||||
export { adminApi } from './admin'
|
export { adminApi } from './admin'
|
||||||
export { eventsApi } from './events'
|
export { eventsApi } from './events'
|
||||||
export { challengesApi } from './challenges'
|
export { challengesApi } from './challenges'
|
||||||
|
export { assignmentsApi } from './assignments'
|
||||||
|
|||||||
22
frontend/src/api/telegram.ts
Normal file
22
frontend/src/api/telegram.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export interface TelegramLinkToken {
|
||||||
|
token: string
|
||||||
|
bot_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelegramStatus {
|
||||||
|
telegram_id: number | null
|
||||||
|
telegram_username: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const telegramApi = {
|
||||||
|
generateLinkToken: async (): Promise<TelegramLinkToken> => {
|
||||||
|
const response = await client.post<TelegramLinkToken>('/telegram/generate-link-token')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
unlinkTelegram: async (): Promise<void> => {
|
||||||
|
await client.post('/users/me/telegram/unlink')
|
||||||
|
},
|
||||||
|
}
|
||||||
282
frontend/src/components/ActivityFeed.tsx
Normal file
282
frontend/src/components/ActivityFeed.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { feedApi } from '@/api'
|
||||||
|
import type { Activity, ActivityType } from '@/types'
|
||||||
|
import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
formatRelativeTime,
|
||||||
|
getActivityIcon,
|
||||||
|
getActivityColor,
|
||||||
|
getActivityBgClass,
|
||||||
|
isEventActivity,
|
||||||
|
formatActivityMessage,
|
||||||
|
} from '@/utils/activity'
|
||||||
|
|
||||||
|
interface ActivityFeedProps {
|
||||||
|
marathonId: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityFeedRef {
|
||||||
|
refresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 20
|
||||||
|
const POLL_INTERVAL = 10000 // 10 seconds
|
||||||
|
|
||||||
|
// Важные типы активности для отображения
|
||||||
|
const IMPORTANT_ACTIVITY_TYPES: ActivityType[] = [
|
||||||
|
'spin',
|
||||||
|
'complete',
|
||||||
|
'drop',
|
||||||
|
'start_marathon',
|
||||||
|
'finish_marathon',
|
||||||
|
'event_start',
|
||||||
|
'event_end',
|
||||||
|
'swap',
|
||||||
|
'rematch',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ActivityFeed = forwardRef<ActivityFeedRef, ActivityFeedProps>(
|
||||||
|
({ marathonId, className = '' }, ref) => {
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const lastFetchRef = useRef<number>(0)
|
||||||
|
|
||||||
|
const loadActivities = useCallback(async (offset = 0, append = false) => {
|
||||||
|
try {
|
||||||
|
const response = await feedApi.get(marathonId, ITEMS_PER_PAGE * 2, offset)
|
||||||
|
|
||||||
|
// Фильтруем только важные события
|
||||||
|
const filtered = response.items.filter(item =>
|
||||||
|
IMPORTANT_ACTIVITY_TYPES.includes(item.type)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
setActivities(prev => [...prev, ...filtered])
|
||||||
|
} else {
|
||||||
|
setActivities(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasMore(response.has_more)
|
||||||
|
setTotal(filtered.length)
|
||||||
|
lastFetchRef.current = Date.now()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load activity feed:', error)
|
||||||
|
}
|
||||||
|
}, [marathonId])
|
||||||
|
|
||||||
|
// Expose refresh method
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
refresh: () => loadActivities()
|
||||||
|
}), [loadActivities])
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true)
|
||||||
|
loadActivities().finally(() => setIsLoading(false))
|
||||||
|
}, [loadActivities])
|
||||||
|
|
||||||
|
// Polling for new activities
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
loadActivities()
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [loadActivities])
|
||||||
|
|
||||||
|
const handleLoadMore = async () => {
|
||||||
|
setIsLoadingMore(true)
|
||||||
|
await loadActivities(activities.length, true)
|
||||||
|
setIsLoadingMore(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 p-4 flex flex-col ${className}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Bell className="w-5 h-5 text-primary-500" />
|
||||||
|
<h3 className="font-medium text-white">Активность</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 flex flex-col ${className}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700/50 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell className="w-5 h-5 text-primary-500" />
|
||||||
|
<h3 className="font-medium text-white">Активность</h3>
|
||||||
|
</div>
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="text-xs text-gray-500">{total}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity list */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-gray-500 text-sm">
|
||||||
|
Пока нет активности
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-700/30">
|
||||||
|
{activities.map((activity) => (
|
||||||
|
<ActivityItem key={activity.id} activity={activity} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Load more button */}
|
||||||
|
{hasMore && (
|
||||||
|
<div className="p-3 border-t border-gray-700/30">
|
||||||
|
<button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
className="w-full py-2 text-sm text-gray-400 hover:text-white transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
Загрузить ещё
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ActivityFeed.displayName = 'ActivityFeed'
|
||||||
|
|
||||||
|
interface ActivityItemProps {
|
||||||
|
activity: Activity
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityItem({ activity }: ActivityItemProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const Icon = getActivityIcon(activity.type)
|
||||||
|
const iconColor = getActivityColor(activity.type)
|
||||||
|
const bgClass = getActivityBgClass(activity.type)
|
||||||
|
const isEvent = isEventActivity(activity.type)
|
||||||
|
const { title, details, extra } = formatActivityMessage(activity)
|
||||||
|
|
||||||
|
// Get assignment_id and dispute status for complete activities
|
||||||
|
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null
|
||||||
|
const assignmentId = activity.type === 'complete' && activityData?.assignment_id
|
||||||
|
? activityData.assignment_id
|
||||||
|
: null
|
||||||
|
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
|
||||||
|
? activityData.dispute_status
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (isEvent) {
|
||||||
|
return (
|
||||||
|
<div className={`px-4 py-3 ${bgClass} border-l-2 ${activity.type === 'event_start' ? 'border-l-yellow-500' : 'border-l-gray-600'}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Icon className={`w-4 h-4 ${iconColor}`} />
|
||||||
|
<span className={`text-sm font-medium ${activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{details && (
|
||||||
|
<div className={`text-sm ${activity.type === 'event_start' ? 'text-yellow-200' : 'text-gray-500'}`}>
|
||||||
|
{details}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{formatRelativeTime(activity.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`px-4 py-3 hover:bg-gray-700/20 transition-colors ${bgClass}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{activity.user.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={activity.user.avatar_url}
|
||||||
|
alt={activity.user.nickname}
|
||||||
|
className="w-8 h-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center">
|
||||||
|
<span className="text-xs text-gray-400 font-medium">
|
||||||
|
{activity.user.nickname.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium text-white truncate">
|
||||||
|
{activity.user.nickname}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{formatRelativeTime(activity.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${iconColor}`} />
|
||||||
|
<span className="text-sm text-gray-300">{title}</span>
|
||||||
|
</div>
|
||||||
|
{details && (
|
||||||
|
<div className="text-sm text-gray-400 mt-1">
|
||||||
|
{details}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{extra && (
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Details button and dispute indicator for complete activities */}
|
||||||
|
{assignmentId && (
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/assignments/${assignmentId}`)}
|
||||||
|
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
Детали
|
||||||
|
</button>
|
||||||
|
{disputeStatus === 'open' && (
|
||||||
|
<span className="text-xs text-orange-400 flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Оспаривается
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{disputeStatus === 'valid' && (
|
||||||
|
<span className="text-xs text-red-400 flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Отклонено
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Clock } from 'lucide-react'
|
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock } from 'lucide-react'
|
||||||
import type { ActiveEvent, EventType } from '@/types'
|
import type { ActiveEvent, EventType } from '@/types'
|
||||||
import { EVENT_INFO } from '@/types'
|
import { EVENT_INFO } from '@/types'
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
|||||||
double_risk: <Shield className="w-5 h-5" />,
|
double_risk: <Shield className="w-5 h-5" />,
|
||||||
jackpot: <Gift className="w-5 h-5" />,
|
jackpot: <Gift className="w-5 h-5" />,
|
||||||
swap: <ArrowLeftRight className="w-5 h-5" />,
|
swap: <ArrowLeftRight className="w-5 h-5" />,
|
||||||
rematch: <RotateCcw className="w-5 h-5" />,
|
game_choice: <Gamepad2 className="w-5 h-5" />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_COLORS: Record<EventType, string> = {
|
const EVENT_COLORS: Record<EventType, string> = {
|
||||||
@@ -23,7 +23,7 @@ const EVENT_COLORS: Record<EventType, string> = {
|
|||||||
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400',
|
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400',
|
||||||
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400',
|
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400',
|
||||||
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400',
|
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400',
|
||||||
rematch: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
|
game_choice: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Play, Square } from 'lucide-react'
|
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui'
|
import { Button } from '@/components/ui'
|
||||||
import { eventsApi } from '@/api'
|
import { eventsApi } from '@/api'
|
||||||
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
||||||
import { EVENT_INFO } from '@/types'
|
import { EVENT_INFO } from '@/types'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
|
||||||
interface EventControlProps {
|
interface EventControlProps {
|
||||||
marathonId: number
|
marathonId: number
|
||||||
@@ -17,7 +19,7 @@ const EVENT_TYPES: EventType[] = [
|
|||||||
'double_risk',
|
'double_risk',
|
||||||
'jackpot',
|
'jackpot',
|
||||||
'swap',
|
'swap',
|
||||||
'rematch',
|
'game_choice',
|
||||||
'common_enemy',
|
'common_enemy',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -27,7 +29,17 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
|||||||
double_risk: <Shield className="w-4 h-4" />,
|
double_risk: <Shield className="w-4 h-4" />,
|
||||||
jackpot: <Gift className="w-4 h-4" />,
|
jackpot: <Gift className="w-4 h-4" />,
|
||||||
swap: <ArrowLeftRight className="w-4 h-4" />,
|
swap: <ArrowLeftRight className="w-4 h-4" />,
|
||||||
rematch: <RotateCcw className="w-4 h-4" />,
|
game_choice: <Gamepad2 className="w-4 h-4" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default durations for events (in minutes)
|
||||||
|
const DEFAULT_DURATIONS: Record<EventType, number | null> = {
|
||||||
|
golden_hour: 45,
|
||||||
|
common_enemy: null, // Until all complete
|
||||||
|
double_risk: 120,
|
||||||
|
jackpot: null, // 1 spin
|
||||||
|
swap: 60,
|
||||||
|
game_choice: 120,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventControl({
|
export function EventControl({
|
||||||
@@ -36,14 +48,24 @@ export function EventControl({
|
|||||||
challenges,
|
challenges,
|
||||||
onEventChange,
|
onEventChange,
|
||||||
}: EventControlProps) {
|
}: EventControlProps) {
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
|
const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
|
||||||
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
|
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
|
||||||
|
const [durationMinutes, setDurationMinutes] = useState<number | ''>(45)
|
||||||
const [isStarting, setIsStarting] = useState(false)
|
const [isStarting, setIsStarting] = useState(false)
|
||||||
const [isStopping, setIsStopping] = useState(false)
|
const [isStopping, setIsStopping] = useState(false)
|
||||||
|
|
||||||
|
// Update duration when event type changes
|
||||||
|
const handleTypeChange = (type: EventType) => {
|
||||||
|
setSelectedType(type)
|
||||||
|
const defaultDuration = DEFAULT_DURATIONS[type]
|
||||||
|
setDurationMinutes(defaultDuration ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
if (selectedType === 'common_enemy' && !selectedChallengeId) {
|
if (selectedType === 'common_enemy' && !selectedChallengeId) {
|
||||||
alert('Выберите челлендж для события "Общий враг"')
|
toast.warning('Выберите челлендж для события "Общий враг"')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,19 +73,27 @@ export function EventControl({
|
|||||||
try {
|
try {
|
||||||
await eventsApi.start(marathonId, {
|
await eventsApi.start(marathonId, {
|
||||||
type: selectedType,
|
type: selectedType,
|
||||||
|
duration_minutes: durationMinutes || undefined,
|
||||||
challenge_id: selectedType === 'common_enemy' ? selectedChallengeId ?? undefined : undefined,
|
challenge_id: selectedType === 'common_enemy' ? selectedChallengeId ?? undefined : undefined,
|
||||||
})
|
})
|
||||||
onEventChange()
|
onEventChange()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start event:', error)
|
console.error('Failed to start event:', error)
|
||||||
alert('Не удалось запустить событие')
|
toast.error('Не удалось запустить событие')
|
||||||
} finally {
|
} finally {
|
||||||
setIsStarting(false)
|
setIsStarting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStop = async () => {
|
const handleStop = async () => {
|
||||||
if (!confirm('Остановить событие досрочно?')) return
|
const confirmed = await confirm({
|
||||||
|
title: 'Остановить событие?',
|
||||||
|
message: 'Событие будет завершено досрочно.',
|
||||||
|
confirmText: 'Остановить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setIsStopping(true)
|
setIsStopping(true)
|
||||||
try {
|
try {
|
||||||
@@ -108,7 +138,7 @@ export function EventControl({
|
|||||||
{EVENT_TYPES.map((type) => (
|
{EVENT_TYPES.map((type) => (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
onClick={() => setSelectedType(type)}
|
onClick={() => handleTypeChange(type)}
|
||||||
className={`
|
className={`
|
||||||
p-3 rounded-lg border-2 transition-all text-left
|
p-3 rounded-lg border-2 transition-all text-left
|
||||||
${selectedType === type
|
${selectedType === type
|
||||||
@@ -127,6 +157,27 @@ export function EventControl({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Duration setting */}
|
||||||
|
{DEFAULT_DURATIONS[selectedType] !== null && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Длительность (минуты)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={durationMinutes}
|
||||||
|
onChange={(e) => setDurationMinutes(e.target.value ? parseInt(e.target.value) : '')}
|
||||||
|
min={1}
|
||||||
|
max={480}
|
||||||
|
placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`}
|
||||||
|
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedType === 'common_enemy' && challenges && challenges.length > 0 && (
|
{selectedType === 'common_enemy' && challenges && challenges.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
|||||||
325
frontend/src/components/TelegramLink.tsx
Normal file
325
frontend/src/components/TelegramLink.tsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { MessageCircle, ExternalLink, X, Loader2, RefreshCw, CheckCircle, User, Link2, Link2Off } from 'lucide-react'
|
||||||
|
import { telegramApi } from '@/api/telegram'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
|
||||||
|
export function TelegramLink() {
|
||||||
|
const { user, updateUser } = useAuthStore()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [botUrl, setBotUrl] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isPolling, setIsPolling] = useState(false)
|
||||||
|
const [linkSuccess, setLinkSuccess] = useState(false)
|
||||||
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const isLinked = !!user?.telegram_id
|
||||||
|
|
||||||
|
// Cleanup polling on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
setIsPolling(true)
|
||||||
|
let attempts = 0
|
||||||
|
const maxAttempts = 60 // 5 minutes (5 sec intervals)
|
||||||
|
|
||||||
|
pollingRef.current = setInterval(async () => {
|
||||||
|
attempts++
|
||||||
|
try {
|
||||||
|
const userData = await authApi.me()
|
||||||
|
if (userData.telegram_id) {
|
||||||
|
// Success! User linked their account
|
||||||
|
updateUser({
|
||||||
|
telegram_id: userData.telegram_id,
|
||||||
|
telegram_username: userData.telegram_username,
|
||||||
|
telegram_first_name: userData.telegram_first_name,
|
||||||
|
telegram_last_name: userData.telegram_last_name,
|
||||||
|
telegram_avatar_url: userData.telegram_avatar_url
|
||||||
|
})
|
||||||
|
setLinkSuccess(true)
|
||||||
|
setIsPolling(false)
|
||||||
|
setBotUrl(null)
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors, continue polling
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
setIsPolling(false)
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
setIsPolling(false)
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerateLink = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setLinkSuccess(false)
|
||||||
|
try {
|
||||||
|
const { bot_url } = await telegramApi.generateLinkToken()
|
||||||
|
setBotUrl(bot_url)
|
||||||
|
} catch {
|
||||||
|
setError('Не удалось сгенерировать ссылку')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnlink = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await telegramApi.unlinkTelegram()
|
||||||
|
updateUser({
|
||||||
|
telegram_id: null,
|
||||||
|
telegram_username: null,
|
||||||
|
telegram_first_name: null,
|
||||||
|
telegram_last_name: null,
|
||||||
|
telegram_avatar_url: null
|
||||||
|
})
|
||||||
|
setIsOpen(false)
|
||||||
|
} catch {
|
||||||
|
setError('Не удалось отвязать аккаунт')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenBot = () => {
|
||||||
|
if (botUrl) {
|
||||||
|
window.open(botUrl, '_blank')
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsOpen(false)
|
||||||
|
setBotUrl(null)
|
||||||
|
setError(null)
|
||||||
|
setLinkSuccess(false)
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
isLinked
|
||||||
|
? 'text-blue-400 hover:text-blue-300 hover:bg-gray-700'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'}
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-gray-800 rounded-xl max-w-md w-full p-6 relative">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute top-4 right-4 text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-12 h-12 bg-blue-500/20 rounded-full flex items-center justify-center">
|
||||||
|
<MessageCircle className="w-6 h-6 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">Telegram</h2>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{isLinked ? 'Аккаунт привязан' : 'Привяжи аккаунт'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLinked || linkSuccess ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{linkSuccess && (
|
||||||
|
<div className="p-4 bg-green-500/20 border border-green-500/50 rounded-lg flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-400 flex-shrink-0" />
|
||||||
|
<p className="text-green-400 font-medium">Аккаунт успешно привязан!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Profile Card */}
|
||||||
|
<div className="p-4 bg-gradient-to-br from-gray-700/50 to-gray-800/50 rounded-xl border border-gray-600/50">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Avatar - prefer Telegram avatar */}
|
||||||
|
<div className="relative">
|
||||||
|
{user?.telegram_avatar_url || user?.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={user.telegram_avatar_url || user.avatar_url || ''}
|
||||||
|
alt={user.nickname}
|
||||||
|
className="w-16 h-16 rounded-full object-cover border-2 border-blue-500/50"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center border-2 border-blue-500/50">
|
||||||
|
<User className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Link indicator */}
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center border-2 border-gray-800">
|
||||||
|
<Link2 className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-lg font-bold text-white truncate">
|
||||||
|
{[user?.telegram_first_name, user?.telegram_last_name].filter(Boolean).join(' ') || user?.nickname}
|
||||||
|
</p>
|
||||||
|
{user?.telegram_username && (
|
||||||
|
<p className="text-blue-400 font-medium truncate">@{user.telegram_username}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notifications Info */}
|
||||||
|
<div className="p-4 bg-gray-700/30 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<span className="text-yellow-400">🌟</span>
|
||||||
|
<span>События (Golden Hour, Jackpot)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<span className="text-green-400">🚀</span>
|
||||||
|
<span>Старт и финиш марафонов</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<span className="text-red-400">⚠️</span>
|
||||||
|
<span>Споры по заданиям</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleUnlink}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 px-4 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2 border border-red-500/30"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link2Off className="w-4 h-4" />
|
||||||
|
Отвязать аккаунт
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : botUrl ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isPolling ? (
|
||||||
|
<>
|
||||||
|
<div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<RefreshCw className="w-5 h-5 text-blue-400 animate-spin" />
|
||||||
|
<p className="text-blue-400 font-medium">Ожидание привязки...</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Открой бота в Telegram и нажми Start. Статус обновится автоматически.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleOpenBot}
|
||||||
|
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-5 h-5" />
|
||||||
|
Открыть Telegram снова
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-gray-300">
|
||||||
|
Нажми кнопку ниже, чтобы открыть бота и завершить привязку:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleOpenBot}
|
||||||
|
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-5 h-5" />
|
||||||
|
Открыть Telegram
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 text-center">
|
||||||
|
Ссылка действительна 10 минут
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-gray-300">
|
||||||
|
Привяжи Telegram, чтобы получать уведомления о важных событиях:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="text-sm text-gray-400 space-y-2">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="text-yellow-400">🌟</span>
|
||||||
|
Golden Hour - очки x1.5
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="text-yellow-400">🎰</span>
|
||||||
|
Jackpot - очки x3
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="text-yellow-400">⚡</span>
|
||||||
|
Double Risk и другие события
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateLink}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MessageCircle className="w-5 h-5" />
|
||||||
|
Привязать Telegram
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react'
|
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react'
|
||||||
|
import { TelegramLink } from '@/components/TelegramLink'
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { user, isAuthenticated, logout } = useAuthStore()
|
const { user, isAuthenticated, logout } = useAuthStore()
|
||||||
@@ -38,6 +39,8 @@ export function Layout() {
|
|||||||
<span>{user?.nickname}</span>
|
<span>{user?.nickname}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TelegramLink />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="p-2 text-gray-400 hover:text-white transition-colors"
|
className="p-2 text-gray-400 hover:text-white transition-colors"
|
||||||
|
|||||||
111
frontend/src/components/ui/ConfirmModal.tsx
Normal file
111
frontend/src/components/ui/ConfirmModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
|
||||||
|
import { Button } from './Button'
|
||||||
|
|
||||||
|
const icons: Record<ConfirmVariant, React.ReactNode> = {
|
||||||
|
danger: <Trash2 className="w-6 h-6" />,
|
||||||
|
warning: <AlertTriangle className="w-6 h-6" />,
|
||||||
|
info: <Info className="w-6 h-6" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconStyles: Record<ConfirmVariant, string> = {
|
||||||
|
danger: 'bg-red-500/20 text-red-500',
|
||||||
|
warning: 'bg-yellow-500/20 text-yellow-500',
|
||||||
|
info: 'bg-blue-500/20 text-blue-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonVariants: Record<ConfirmVariant, 'danger' | 'primary' | 'secondary'> = {
|
||||||
|
danger: 'danger',
|
||||||
|
warning: 'primary',
|
||||||
|
info: 'primary',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmModal() {
|
||||||
|
const { isOpen, options, handleConfirm, handleCancel } = useConfirmStore()
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
handleCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [isOpen, handleCancel])
|
||||||
|
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (!isOpen || !options) return null
|
||||||
|
|
||||||
|
const variant = options.variant || 'warning'
|
||||||
|
const Icon = icons[variant]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200"
|
||||||
|
onClick={handleCancel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative bg-gray-800 rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-gray-700">
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={clsx('w-12 h-12 rounded-full flex items-center justify-center mb-4', iconStyles[variant])}>
|
||||||
|
{Icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">
|
||||||
|
{options.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<p className="text-gray-400 mb-6 whitespace-pre-line">
|
||||||
|
{options.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
{options.cancelText || 'Отмена'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={buttonVariants[variant]}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{options.confirmText || 'Подтвердить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
frontend/src/components/ui/Toast.tsx
Normal file
83
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { X, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
import { useToastStore, type Toast as ToastType } from '@/store/toast'
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: CheckCircle,
|
||||||
|
error: XCircle,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
info: Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
success: 'bg-green-500/20 border-green-500/50 text-green-400',
|
||||||
|
error: 'bg-red-500/20 border-red-500/50 text-red-400',
|
||||||
|
warning: 'bg-yellow-500/20 border-yellow-500/50 text-yellow-400',
|
||||||
|
info: 'bg-blue-500/20 border-blue-500/50 text-blue-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconStyles = {
|
||||||
|
success: 'text-green-500',
|
||||||
|
error: 'text-red-500',
|
||||||
|
warning: 'text-yellow-500',
|
||||||
|
info: 'text-blue-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastItemProps {
|
||||||
|
toast: ToastType
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastItem({ toast, onRemove }: ToastItemProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
const [isLeaving, setIsLeaving] = useState(false)
|
||||||
|
const Icon = icons[toast.type]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Trigger enter animation
|
||||||
|
requestAnimationFrame(() => setIsVisible(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
setIsLeaving(true)
|
||||||
|
setTimeout(() => onRemove(toast.id), 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex items-start gap-3 p-4 rounded-lg border backdrop-blur-sm shadow-lg',
|
||||||
|
'transition-all duration-200 ease-out',
|
||||||
|
styles[toast.type],
|
||||||
|
isVisible && !isLeaving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={clsx('w-5 h-5 flex-shrink-0 mt-0.5', iconStyles[toast.type])} />
|
||||||
|
<p className="flex-1 text-sm text-white">{toast.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
className="flex-shrink-0 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastContainer() {
|
||||||
|
const toasts = useToastStore((state) => state.toasts)
|
||||||
|
const removeToast = useToastStore((state) => state.removeToast)
|
||||||
|
|
||||||
|
if (toasts.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div key={toast.id} className="pointer-events-auto">
|
||||||
|
<ToastItem toast={toast} onRemove={removeToast} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export { Button } from './Button'
|
export { Button } from './Button'
|
||||||
export { Input } from './Input'
|
export { Input } from './Input'
|
||||||
export { Card, CardHeader, CardTitle, CardContent } from './Card'
|
export { Card, CardHeader, CardTitle, CardContent } from './Card'
|
||||||
|
export { ToastContainer } from './Toast'
|
||||||
|
export { ConfirmModal } from './ConfirmModal'
|
||||||
|
|||||||
@@ -6,6 +6,30 @@ body {
|
|||||||
@apply bg-gray-900 text-gray-100 min-h-screen;
|
@apply bg-gray-900 text-gray-100 min-h-screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styles */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #4b5563;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
.custom-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #4b5563 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.btn {
|
.btn {
|
||||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
|||||||
515
frontend/src/pages/AssignmentDetailPage.tsx
Normal file
515
frontend/src/pages/AssignmentDetailPage.tsx
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { assignmentsApi } from '@/api'
|
||||||
|
import type { AssignmentDetail } from '@/types'
|
||||||
|
import { Card, CardContent, Button } from '@/components/ui'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import {
|
||||||
|
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
|
||||||
|
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
|
||||||
|
Send, Flag
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export function AssignmentDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const [assignment, setAssignment] = useState<AssignmentDetail | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
|
||||||
|
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
|
||||||
|
|
||||||
|
// Dispute creation
|
||||||
|
const [showDisputeForm, setShowDisputeForm] = useState(false)
|
||||||
|
const [disputeReason, setDisputeReason] = useState('')
|
||||||
|
const [isCreatingDispute, setIsCreatingDispute] = useState(false)
|
||||||
|
|
||||||
|
// Comment
|
||||||
|
const [commentText, setCommentText] = useState('')
|
||||||
|
const [isAddingComment, setIsAddingComment] = useState(false)
|
||||||
|
|
||||||
|
// Voting
|
||||||
|
const [isVoting, setIsVoting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAssignment()
|
||||||
|
return () => {
|
||||||
|
// Cleanup blob URL on unmount
|
||||||
|
if (proofMediaBlobUrl) {
|
||||||
|
URL.revokeObjectURL(proofMediaBlobUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
const loadAssignment = async () => {
|
||||||
|
if (!id) return
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await assignmentsApi.getDetail(parseInt(id))
|
||||||
|
setAssignment(data)
|
||||||
|
|
||||||
|
// Load proof media if exists
|
||||||
|
if (data.proof_image_url) {
|
||||||
|
try {
|
||||||
|
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
|
||||||
|
setProofMediaBlobUrl(url)
|
||||||
|
setProofMediaType(type)
|
||||||
|
} catch {
|
||||||
|
// Ignore error, media just won't show
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
setError(error.response?.data?.detail || 'Не удалось загрузить данные')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateDispute = async () => {
|
||||||
|
if (!id || !disputeReason.trim()) return
|
||||||
|
|
||||||
|
setIsCreatingDispute(true)
|
||||||
|
try {
|
||||||
|
await assignmentsApi.createDispute(parseInt(id), disputeReason)
|
||||||
|
setDisputeReason('')
|
||||||
|
setShowDisputeForm(false)
|
||||||
|
await loadAssignment()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание')
|
||||||
|
} finally {
|
||||||
|
setIsCreatingDispute(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVote = async (vote: boolean) => {
|
||||||
|
if (!assignment?.dispute) return
|
||||||
|
|
||||||
|
setIsVoting(true)
|
||||||
|
try {
|
||||||
|
await assignmentsApi.vote(assignment.dispute.id, vote)
|
||||||
|
await loadAssignment()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось проголосовать')
|
||||||
|
} finally {
|
||||||
|
setIsVoting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddComment = async () => {
|
||||||
|
if (!assignment?.dispute || !commentText.trim()) return
|
||||||
|
|
||||||
|
setIsAddingComment(true)
|
||||||
|
try {
|
||||||
|
await assignmentsApi.addComment(assignment.dispute.id, commentText)
|
||||||
|
setCommentText('')
|
||||||
|
await loadAssignment()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось добавить комментарий')
|
||||||
|
} finally {
|
||||||
|
setIsAddingComment(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimeRemaining = (expiresAt: string) => {
|
||||||
|
const now = new Date()
|
||||||
|
const expires = new Date(expiresAt)
|
||||||
|
const diff = expires.getTime() - now.getTime()
|
||||||
|
|
||||||
|
if (diff <= 0) return 'Истекло'
|
||||||
|
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||||
|
|
||||||
|
return `${hours}ч ${minutes}м`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return (
|
||||||
|
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-4 h-4" /> Выполнено
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
case 'dropped':
|
||||||
|
return (
|
||||||
|
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1">
|
||||||
|
<XCircle className="w-4 h-4" /> Пропущено
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
case 'returned':
|
||||||
|
return (
|
||||||
|
<span className="px-3 py-1 bg-orange-500/20 text-orange-400 rounded-full text-sm flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-4 h-4" /> Возвращено
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full text-sm">
|
||||||
|
Активно
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !assignment) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto text-center py-12">
|
||||||
|
<p className="text-red-400 mb-4">{error || 'Задание не найдено'}</p>
|
||||||
|
<Button onClick={() => navigate(-1)}>Назад</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispute = assignment.dispute
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<button onClick={() => navigate(-1)} className="text-gray-400 hover:text-white">
|
||||||
|
<ArrowLeft className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-bold text-white">Детали выполнения</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Challenge info */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
|
||||||
|
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(assignment.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm">
|
||||||
|
+{assignment.challenge.points} очков
|
||||||
|
</span>
|
||||||
|
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm">
|
||||||
|
{assignment.challenge.difficulty}
|
||||||
|
</span>
|
||||||
|
{assignment.challenge.estimated_time && (
|
||||||
|
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm">
|
||||||
|
~{assignment.challenge.estimated_time} мин
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-400 space-y-1">
|
||||||
|
<p>
|
||||||
|
<strong>Выполнил:</strong> {assignment.participant.nickname}
|
||||||
|
</p>
|
||||||
|
{assignment.completed_at && (
|
||||||
|
<p>
|
||||||
|
<strong>Дата:</strong> {formatDate(assignment.completed_at)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{assignment.points_earned > 0 && (
|
||||||
|
<p>
|
||||||
|
<strong>Получено очков:</strong> {assignment.points_earned}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Proof section */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent>
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Image className="w-5 h-5" />
|
||||||
|
Доказательство
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Proof media (image or video) */}
|
||||||
|
{assignment.proof_image_url && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{proofMediaBlobUrl ? (
|
||||||
|
proofMediaType === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={proofMediaBlobUrl}
|
||||||
|
controls
|
||||||
|
className="w-full rounded-lg max-h-96 bg-gray-900"
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={proofMediaBlobUrl}
|
||||||
|
alt="Proof"
|
||||||
|
className="w-full rounded-lg max-h-96 object-contain bg-gray-900"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-48 bg-gray-900 rounded-lg flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Proof URL */}
|
||||||
|
{assignment.proof_url && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<a
|
||||||
|
href={assignment.proof_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-primary-400 hover:text-primary-300"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
{assignment.proof_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Proof comment */}
|
||||||
|
{assignment.proof_comment && (
|
||||||
|
<div className="p-3 bg-gray-900 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Комментарий:</p>
|
||||||
|
<p className="text-white">{assignment.proof_comment}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!assignment.proof_image_url && !assignment.proof_url && (
|
||||||
|
<p className="text-gray-500 text-center py-4">Пруф не предоставлен</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dispute button */}
|
||||||
|
{assignment.can_dispute && !dispute && !showDisputeForm && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="w-full mb-6"
|
||||||
|
onClick={() => setShowDisputeForm(true)}
|
||||||
|
>
|
||||||
|
<Flag className="w-4 h-4 mr-2" />
|
||||||
|
Оспорить выполнение
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dispute creation form */}
|
||||||
|
{showDisputeForm && !dispute && (
|
||||||
|
<Card className="mb-6 border-red-500/50">
|
||||||
|
<CardContent>
|
||||||
|
<h3 className="text-lg font-bold text-red-400 mb-4 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Оспорить выполнение
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
Опишите причину оспаривания. После создания у участников будет 24 часа для голосования.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className="input w-full min-h-[100px] resize-none mb-4"
|
||||||
|
placeholder="Причина оспаривания (минимум 10 символов)..."
|
||||||
|
value={disputeReason}
|
||||||
|
onChange={(e) => setDisputeReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleCreateDispute}
|
||||||
|
isLoading={isCreatingDispute}
|
||||||
|
disabled={disputeReason.trim().length < 10}
|
||||||
|
>
|
||||||
|
Оспорить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDisputeForm(false)
|
||||||
|
setDisputeReason('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dispute section */}
|
||||||
|
{dispute && (
|
||||||
|
<Card className={`mb-6 ${dispute.status === 'open' ? 'border-yellow-500/50' : ''}`}>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-yellow-400 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Оспаривание
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{dispute.status === 'open' ? (
|
||||||
|
<span className="px-3 py-1 bg-yellow-500/20 text-yellow-400 rounded-full text-sm flex items-center gap-1">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{getTimeRemaining(dispute.expires_at)}
|
||||||
|
</span>
|
||||||
|
) : dispute.status === 'valid' ? (
|
||||||
|
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Пруф валиден
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
Пруф невалиден
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Открыл: <span className="text-white">{dispute.raised_by.nickname}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Дата: <span className="text-white">{formatDate(dispute.created_at)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-900 rounded-lg mb-4">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Причина:</p>
|
||||||
|
<p className="text-white">{dispute.reason}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voting section */}
|
||||||
|
{dispute.status === 'open' && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-300 mb-3">Голосование</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ThumbsUp className="w-5 h-5 text-green-500" />
|
||||||
|
<span className="text-green-400 font-medium">{dispute.votes_valid}</span>
|
||||||
|
<span className="text-gray-500 text-sm">валидно</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ThumbsDown className="w-5 h-5 text-red-500" />
|
||||||
|
<span className="text-red-400 font-medium">{dispute.votes_invalid}</span>
|
||||||
|
<span className="text-gray-500 text-sm">невалидно</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant={dispute.my_vote === true ? 'primary' : 'secondary'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => handleVote(true)}
|
||||||
|
isLoading={isVoting}
|
||||||
|
disabled={isVoting}
|
||||||
|
>
|
||||||
|
<ThumbsUp className="w-4 h-4 mr-2" />
|
||||||
|
Валидно
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={dispute.my_vote === false ? 'danger' : 'secondary'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => handleVote(false)}
|
||||||
|
isLoading={isVoting}
|
||||||
|
disabled={isVoting}
|
||||||
|
>
|
||||||
|
<ThumbsDown className="w-4 h-4 mr-2" />
|
||||||
|
Невалидно
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dispute.my_vote !== null && (
|
||||||
|
<p className="text-sm text-gray-500 mt-2 text-center">
|
||||||
|
Вы проголосовали: {dispute.my_vote ? 'валидно' : 'невалидно'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments section */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
Обсуждение ({dispute.comments.length})
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{dispute.comments.length > 0 && (
|
||||||
|
<div className="space-y-3 mb-4 max-h-60 overflow-y-auto">
|
||||||
|
{dispute.comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="p-3 bg-gray-900 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className={`font-medium ${comment.user.id === user?.id ? 'text-primary-400' : 'text-white'}`}>
|
||||||
|
{comment.user.nickname}
|
||||||
|
{comment.user.id === user?.id && ' (Вы)'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{formatDate(comment.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-300 text-sm">{comment.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add comment form */}
|
||||||
|
{dispute.status === 'open' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="Написать комментарий..."
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleAddComment()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddComment}
|
||||||
|
isLoading={isAddingComment}
|
||||||
|
disabled={!commentText.trim()}
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { marathonsApi, gamesApi } from '@/api'
|
|||||||
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
||||||
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
import {
|
import {
|
||||||
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
|
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
|
||||||
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
|
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
|
||||||
@@ -13,6 +15,8 @@ export function LobbyPage() {
|
|||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||||
const [games, setGames] = useState<Game[]>([])
|
const [games, setGames] = useState<Game[]>([])
|
||||||
@@ -41,6 +45,20 @@ export function LobbyPage() {
|
|||||||
const [gameChallenges, setGameChallenges] = useState<Record<number, Challenge[]>>({})
|
const [gameChallenges, setGameChallenges] = useState<Record<number, Challenge[]>>({})
|
||||||
const [loadingChallenges, setLoadingChallenges] = useState<number | null>(null)
|
const [loadingChallenges, setLoadingChallenges] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Manual challenge creation
|
||||||
|
const [addingChallengeToGameId, setAddingChallengeToGameId] = useState<number | null>(null)
|
||||||
|
const [newChallenge, setNewChallenge] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
type: 'completion',
|
||||||
|
difficulty: 'medium',
|
||||||
|
points: 50,
|
||||||
|
estimated_time: 30,
|
||||||
|
proof_type: 'screenshot',
|
||||||
|
proof_hint: '',
|
||||||
|
})
|
||||||
|
const [isCreatingChallenge, setIsCreatingChallenge] = useState(false)
|
||||||
|
|
||||||
// Start marathon
|
// Start marathon
|
||||||
const [isStarting, setIsStarting] = useState(false)
|
const [isStarting, setIsStarting] = useState(false)
|
||||||
|
|
||||||
@@ -99,7 +117,14 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteGame = async (gameId: number) => {
|
const handleDeleteGame = async (gameId: number) => {
|
||||||
if (!confirm('Удалить эту игру?')) return
|
const confirmed = await confirm({
|
||||||
|
title: 'Удалить игру?',
|
||||||
|
message: 'Игра и все её челленджи будут удалены.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gamesApi.delete(gameId)
|
await gamesApi.delete(gameId)
|
||||||
@@ -122,7 +147,14 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleRejectGame = async (gameId: number) => {
|
const handleRejectGame = async (gameId: number) => {
|
||||||
if (!confirm('Отклонить эту игру?')) return
|
const confirmed = await confirm({
|
||||||
|
title: 'Отклонить игру?',
|
||||||
|
message: 'Игра будет удалена из списка ожидающих.',
|
||||||
|
confirmText: 'Отклонить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setModeratingGameId(gameId)
|
setModeratingGameId(gameId)
|
||||||
try {
|
try {
|
||||||
@@ -143,6 +175,7 @@ export function LobbyPage() {
|
|||||||
|
|
||||||
setExpandedGameId(gameId)
|
setExpandedGameId(gameId)
|
||||||
|
|
||||||
|
// Load challenges if we haven't loaded them yet
|
||||||
if (!gameChallenges[gameId]) {
|
if (!gameChallenges[gameId]) {
|
||||||
setLoadingChallenges(gameId)
|
setLoadingChallenges(gameId)
|
||||||
try {
|
try {
|
||||||
@@ -150,14 +183,66 @@ export function LobbyPage() {
|
|||||||
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
|
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load challenges:', error)
|
console.error('Failed to load challenges:', error)
|
||||||
|
// Set empty array to prevent repeated attempts
|
||||||
|
setGameChallenges(prev => ({ ...prev, [gameId]: [] }))
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingChallenges(null)
|
setLoadingChallenges(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateChallenge = async (gameId: number) => {
|
||||||
|
if (!newChallenge.title.trim() || !newChallenge.description.trim()) {
|
||||||
|
toast.warning('Заполните название и описание')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreatingChallenge(true)
|
||||||
|
try {
|
||||||
|
await gamesApi.createChallenge(gameId, {
|
||||||
|
title: newChallenge.title.trim(),
|
||||||
|
description: newChallenge.description.trim(),
|
||||||
|
type: newChallenge.type,
|
||||||
|
difficulty: newChallenge.difficulty,
|
||||||
|
points: newChallenge.points,
|
||||||
|
estimated_time: newChallenge.estimated_time || undefined,
|
||||||
|
proof_type: newChallenge.proof_type,
|
||||||
|
proof_hint: newChallenge.proof_hint.trim() || undefined,
|
||||||
|
})
|
||||||
|
toast.success('Задание добавлено')
|
||||||
|
// Reset form
|
||||||
|
setNewChallenge({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
type: 'completion',
|
||||||
|
difficulty: 'medium',
|
||||||
|
points: 50,
|
||||||
|
estimated_time: 30,
|
||||||
|
proof_type: 'screenshot',
|
||||||
|
proof_hint: '',
|
||||||
|
})
|
||||||
|
setAddingChallengeToGameId(null)
|
||||||
|
// Refresh challenges
|
||||||
|
const challenges = await gamesApi.getChallenges(gameId)
|
||||||
|
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось добавить задание')
|
||||||
|
} finally {
|
||||||
|
setIsCreatingChallenge(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDeleteChallenge = async (challengeId: number, gameId: number) => {
|
const handleDeleteChallenge = async (challengeId: number, gameId: number) => {
|
||||||
if (!confirm('Удалить это задание?')) return
|
const confirmed = await confirm({
|
||||||
|
title: 'Удалить задание?',
|
||||||
|
message: 'Это действие нельзя отменить.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gamesApi.deleteChallenge(challengeId)
|
await gamesApi.deleteChallenge(challengeId)
|
||||||
@@ -227,7 +312,16 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleStartMarathon = async () => {
|
const handleStartMarathon = async () => {
|
||||||
if (!id || !confirm('Начать марафон? После этого нельзя будет добавить новые игры.')) return
|
if (!id) return
|
||||||
|
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Начать марафон?',
|
||||||
|
message: 'После старта нельзя будет добавить новые игры.',
|
||||||
|
confirmText: 'Начать',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setIsStarting(true)
|
setIsStarting(true)
|
||||||
try {
|
try {
|
||||||
@@ -235,7 +329,7 @@ export function LobbyPage() {
|
|||||||
navigate(`/marathons/${id}/play`)
|
navigate(`/marathons/${id}/play`)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось запустить марафон')
|
toast.error(error.response?.data?.detail || 'Не удалось запустить марафон')
|
||||||
} finally {
|
} finally {
|
||||||
setIsStarting(false)
|
setIsStarting(false)
|
||||||
}
|
}
|
||||||
@@ -286,12 +380,12 @@ export function LobbyPage() {
|
|||||||
{/* Game header */}
|
{/* Game header */}
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between p-4 ${
|
className={`flex items-center justify-between p-4 ${
|
||||||
game.challenges_count > 0 ? 'cursor-pointer hover:bg-gray-800/50' : ''
|
(game.status === 'approved') ? 'cursor-pointer hover:bg-gray-800/50' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => game.challenges_count > 0 && handleToggleGameChallenges(game.id)}
|
onClick={() => game.status === 'approved' && handleToggleGameChallenges(game.id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
{game.challenges_count > 0 && (
|
{game.status === 'approved' && (
|
||||||
<span className="text-gray-400 shrink-0">
|
<span className="text-gray-400 shrink-0">
|
||||||
{expandedGameId === game.id ? (
|
{expandedGameId === game.id ? (
|
||||||
<ChevronUp className="w-4 h-4" />
|
<ChevronUp className="w-4 h-4" />
|
||||||
@@ -364,50 +458,178 @@ export function LobbyPage() {
|
|||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
|
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
) : gameChallenges[game.id]?.length > 0 ? (
|
) : (
|
||||||
gameChallenges[game.id].map((challenge) => (
|
<>
|
||||||
<div
|
{gameChallenges[game.id]?.length > 0 ? (
|
||||||
key={challenge.id}
|
gameChallenges[game.id].map((challenge) => (
|
||||||
className="flex items-start justify-between gap-3 p-3 bg-gray-800 rounded-lg"
|
<div
|
||||||
>
|
key={challenge.id}
|
||||||
<div className="flex-1 min-w-0">
|
className="flex items-start justify-between gap-3 p-3 bg-gray-800 rounded-lg"
|
||||||
<div className="flex items-center gap-2 mb-1">
|
>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
<div className="flex-1 min-w-0">
|
||||||
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' :
|
<div className="flex items-center gap-2 mb-1">
|
||||||
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' :
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||||
'bg-red-900/50 text-red-400'
|
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' :
|
||||||
}`}>
|
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' :
|
||||||
{challenge.difficulty === 'easy' ? 'Легко' :
|
'bg-red-900/50 text-red-400'
|
||||||
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
}`}>
|
||||||
</span>
|
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||||
<span className="text-xs text-primary-400 font-medium">
|
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||||
+{challenge.points}
|
</span>
|
||||||
</span>
|
<span className="text-xs text-primary-400 font-medium">
|
||||||
{challenge.is_generated && (
|
+{challenge.points}
|
||||||
<span className="text-xs text-gray-500">
|
</span>
|
||||||
<Sparkles className="w-3 h-3 inline" /> ИИ
|
{challenge.is_generated && (
|
||||||
</span>
|
<span className="text-xs text-gray-500">
|
||||||
|
<Sparkles className="w-3 h-3 inline" /> ИИ
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
|
||||||
|
</div>
|
||||||
|
{isOrganizer && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
|
||||||
|
className="text-red-400 hover:text-red-300 shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
|
))
|
||||||
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
|
) : (
|
||||||
</div>
|
<p className="text-center text-gray-500 py-2 text-sm">
|
||||||
{isOrganizer && (
|
Нет заданий
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add challenge form */}
|
||||||
|
{isOrganizer && game.status === 'approved' && (
|
||||||
|
addingChallengeToGameId === game.id ? (
|
||||||
|
<div className="mt-4 p-4 bg-gray-800 rounded-lg space-y-3 border border-gray-700">
|
||||||
|
<h4 className="font-medium text-white text-sm">Новое задание</h4>
|
||||||
|
<Input
|
||||||
|
placeholder="Название задания"
|
||||||
|
value={newChallenge.title}
|
||||||
|
onChange={(e) => setNewChallenge(prev => ({ ...prev, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Описание (что нужно сделать)"
|
||||||
|
value={newChallenge.description}
|
||||||
|
onChange={(e) => setNewChallenge(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm resize-none"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип</label>
|
||||||
|
<select
|
||||||
|
value={newChallenge.type}
|
||||||
|
onChange={(e) => setNewChallenge(prev => ({ ...prev, type: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
|
||||||
|
>
|
||||||
|
<option value="completion">Прохождение</option>
|
||||||
|
<option value="no_death">Без смертей</option>
|
||||||
|
<option value="speedrun">Спидран</option>
|
||||||
|
<option value="collection">Коллекция</option>
|
||||||
|
<option value="achievement">Достижение</option>
|
||||||
|
<option value="challenge_run">Челлендж-ран</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
|
||||||
|
<select
|
||||||
|
value={newChallenge.difficulty}
|
||||||
|
onChange={(e) => setNewChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
|
||||||
|
>
|
||||||
|
<option value="easy">Легко (20-40 очков)</option>
|
||||||
|
<option value="medium">Средне (45-75 очков)</option>
|
||||||
|
<option value="hard">Сложно (90-150 очков)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={newChallenge.points}
|
||||||
|
onChange={(e) => setNewChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Время (мин)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={newChallenge.estimated_time}
|
||||||
|
onChange={(e) => setNewChallenge(prev => ({ ...prev, estimated_time: parseInt(e.target.value) || 0 }))}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип доказательства</label>
|
||||||
|
<select
|
||||||
|
value={newChallenge.proof_type}
|
||||||
|
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
|
||||||
|
>
|
||||||
|
<option value="screenshot">Скриншот</option>
|
||||||
|
<option value="video">Видео</option>
|
||||||
|
<option value="steam">Steam</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Подсказка</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Что должно быть на пруфе"
|
||||||
|
value={newChallenge.proof_hint}
|
||||||
|
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCreateChallenge(game.id)}
|
||||||
|
isLoading={isCreatingChallenge}
|
||||||
|
disabled={!newChallenge.title || !newChallenge.description}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAddingChallengeToGameId(null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
|
onClick={() => {
|
||||||
className="text-red-400 hover:text-red-300 shrink-0"
|
setAddingChallengeToGameId(game.id)
|
||||||
|
setExpandedGameId(game.id)
|
||||||
|
}}
|
||||||
|
className="w-full mt-2 border border-dashed border-gray-700 text-gray-400 hover:text-white hover:border-gray-600"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
Добавить задание вручную
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</div>
|
)}
|
||||||
))
|
</>
|
||||||
) : (
|
|
||||||
<p className="text-center text-gray-500 py-2 text-sm">
|
|
||||||
Нет заданий
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||||
import { Button, Card, CardContent } from '@/components/ui'
|
import { Button, Card, CardContent } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
import { EventBanner } from '@/components/EventBanner'
|
import { EventBanner } from '@/components/EventBanner'
|
||||||
import { EventControl } from '@/components/EventControl'
|
import { EventControl } from '@/components/EventControl'
|
||||||
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap } from 'lucide-react'
|
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
||||||
|
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag } from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
export function MarathonPage() {
|
export function MarathonPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||||
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
||||||
const [challenges, setChallenges] = useState<Challenge[]>([])
|
const [challenges, setChallenges] = useState<Challenge[]>([])
|
||||||
@@ -20,7 +25,9 @@ export function MarathonPage() {
|
|||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [isJoining, setIsJoining] = useState(false)
|
const [isJoining, setIsJoining] = useState(false)
|
||||||
|
const [isFinishing, setIsFinishing] = useState(false)
|
||||||
const [showEventControl, setShowEventControl] = useState(false)
|
const [showEventControl, setShowEventControl] = useState(false)
|
||||||
|
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMarathon()
|
loadMarathon()
|
||||||
@@ -60,6 +67,8 @@ export function MarathonPage() {
|
|||||||
try {
|
try {
|
||||||
const eventData = await eventsApi.getActive(parseInt(id))
|
const eventData = await eventsApi.getActive(parseInt(id))
|
||||||
setActiveEvent(eventData)
|
setActiveEvent(eventData)
|
||||||
|
// Refresh activity feed when event changes
|
||||||
|
activityFeedRef.current?.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh event:', error)
|
console.error('Failed to refresh event:', error)
|
||||||
}
|
}
|
||||||
@@ -79,7 +88,16 @@ export function MarathonPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!marathon || !confirm('Вы уверены, что хотите удалить этот марафон? Это действие нельзя отменить.')) return
|
if (!marathon) return
|
||||||
|
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Удалить марафон?',
|
||||||
|
message: 'Все данные марафона будут удалены безвозвратно.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
try {
|
try {
|
||||||
@@ -87,7 +105,7 @@ export function MarathonPage() {
|
|||||||
navigate('/marathons')
|
navigate('/marathons')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete marathon:', error)
|
console.error('Failed to delete marathon:', error)
|
||||||
alert('Не удалось удалить марафон')
|
toast.error('Не удалось удалить марафон')
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
}
|
}
|
||||||
@@ -102,12 +120,37 @@ export function MarathonPage() {
|
|||||||
setMarathon(updated)
|
setMarathon(updated)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось присоединиться')
|
toast.error(error.response?.data?.detail || 'Не удалось присоединиться')
|
||||||
} finally {
|
} finally {
|
||||||
setIsJoining(false)
|
setIsJoining(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFinish = async () => {
|
||||||
|
if (!marathon) return
|
||||||
|
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Завершить марафон?',
|
||||||
|
message: 'Марафон будет завершён досрочно. Участники больше не смогут выполнять задания.',
|
||||||
|
confirmText: 'Завершить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setIsFinishing(true)
|
||||||
|
try {
|
||||||
|
const updated = await marathonsApi.finish(marathon.id)
|
||||||
|
setMarathon(updated)
|
||||||
|
toast.success('Марафон завершён')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось завершить марафон')
|
||||||
|
} finally {
|
||||||
|
setIsFinishing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading || !marathon) {
|
if (isLoading || !marathon) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
@@ -122,243 +165,273 @@ export function MarathonPage() {
|
|||||||
const canDelete = isCreator || user?.role === 'admin'
|
const canDelete = isCreator || user?.role === 'admin'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
К списку марафонов
|
К списку марафонов
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Header */}
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
<div className="flex justify-between items-start mb-8">
|
{/* Main content */}
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
{/* Header */}
|
||||||
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
|
<div className="flex justify-between items-start mb-8">
|
||||||
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
<div>
|
||||||
marathon.is_public
|
<div className="flex items-center gap-3 mb-2">
|
||||||
? 'bg-green-900/50 text-green-400'
|
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
|
||||||
: 'bg-gray-700 text-gray-300'
|
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
||||||
}`}>
|
marathon.is_public
|
||||||
{marathon.is_public ? (
|
? 'bg-green-900/50 text-green-400'
|
||||||
<><Globe className="w-3 h-3" /> Открытый</>
|
: 'bg-gray-700 text-gray-300'
|
||||||
) : (
|
}`}>
|
||||||
<><Lock className="w-3 h-3" /> Закрытый</>
|
{marathon.is_public ? (
|
||||||
|
<><Globe className="w-3 h-3" /> Открытый</>
|
||||||
|
) : (
|
||||||
|
<><Lock className="w-3 h-3" /> Закрытый</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{marathon.description && (
|
||||||
|
<p className="text-gray-400">{marathon.description}</p>
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap justify-end">
|
||||||
|
{/* Кнопка присоединиться для открытых марафонов */}
|
||||||
|
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
|
||||||
|
<Button onClick={handleJoinPublic} isLoading={isJoining}>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Присоединиться
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Настройка для организаторов */}
|
||||||
|
{marathon.status === 'preparing' && isOrganizer && (
|
||||||
|
<Link to={`/marathons/${id}/lobby`}>
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Настройка
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Предложить игру для участников (не организаторов) если разрешено */}
|
||||||
|
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
|
||||||
|
<Link to={`/marathons/${id}/lobby`}>
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Gamepad2 className="w-4 h-4 mr-2" />
|
||||||
|
Предложить игру
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{marathon.status === 'active' && isParticipant && (
|
||||||
|
<Link to={`/marathons/${id}/play`}>
|
||||||
|
<Button>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Играть
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to={`/marathons/${id}/leaderboard`}>
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Trophy className="w-4 h-4 mr-2" />
|
||||||
|
Рейтинг
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{marathon.status === 'active' && isOrganizer && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleFinish}
|
||||||
|
isLoading={isFinishing}
|
||||||
|
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-900/20"
|
||||||
|
>
|
||||||
|
<Flag className="w-4 h-4 mr-2" />
|
||||||
|
Завершить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDelete && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDelete}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{marathon.description && (
|
|
||||||
<p className="text-gray-400">{marathon.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
{/* Stats */}
|
||||||
{/* Кнопка присоединиться для открытых марафонов */}
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||||
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
|
<Card>
|
||||||
<Button onClick={handleJoinPublic} isLoading={isJoining}>
|
<CardContent className="text-center py-4">
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
|
||||||
Присоединиться
|
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||||
</Button>
|
<Users className="w-4 h-4" />
|
||||||
|
Участников
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="text-center py-4">
|
||||||
|
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
|
||||||
|
<div className="text-sm text-gray-400">Игр</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="text-center py-4">
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
Начало
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="text-center py-4">
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||||
|
<CalendarCheck className="w-4 h-4" />
|
||||||
|
Конец
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="text-center py-4">
|
||||||
|
<div className={`text-2xl font-bold ${
|
||||||
|
marathon.status === 'active' ? 'text-green-500' :
|
||||||
|
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">Статус</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active event banner */}
|
||||||
|
{marathon.status === 'active' && activeEvent?.event && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Настройка для организаторов */}
|
{/* Event control for organizers */}
|
||||||
{marathon.status === 'preparing' && isOrganizer && (
|
{marathon.status === 'active' && isOrganizer && (
|
||||||
<Link to={`/marathons/${id}/lobby`}>
|
<Card className="mb-8">
|
||||||
<Button variant="secondary">
|
<CardContent>
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
<div className="flex items-center justify-between mb-4">
|
||||||
Настройка
|
<h3 className="font-medium text-white flex items-center gap-2">
|
||||||
</Button>
|
<Zap className="w-5 h-5 text-yellow-500" />
|
||||||
</Link>
|
Управление событиями
|
||||||
)}
|
</h3>
|
||||||
|
<Button
|
||||||
{/* Предложить игру для участников (не организаторов) если разрешено */}
|
variant="ghost"
|
||||||
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
|
size="sm"
|
||||||
<Link to={`/marathons/${id}/lobby`}>
|
onClick={() => setShowEventControl(!showEventControl)}
|
||||||
<Button variant="secondary">
|
>
|
||||||
<Gamepad2 className="w-4 h-4 mr-2" />
|
{showEventControl ? 'Скрыть' : 'Показать'}
|
||||||
Предложить игру
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</Link>
|
{showEventControl && activeEvent && (
|
||||||
)}
|
<EventControl
|
||||||
|
marathonId={marathon.id}
|
||||||
{marathon.status === 'active' && isParticipant && (
|
activeEvent={activeEvent}
|
||||||
<Link to={`/marathons/${id}/play`}>
|
challenges={challenges}
|
||||||
<Button>
|
onEventChange={refreshEvent}
|
||||||
<Play className="w-4 h-4 mr-2" />
|
/>
|
||||||
Играть
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link to={`/marathons/${id}/leaderboard`}>
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trophy className="w-4 h-4 mr-2" />
|
|
||||||
Рейтинг
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{canDelete && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleDelete}
|
|
||||||
isLoading={isDeleting}
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid md:grid-cols-5 gap-4 mb-8">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="text-center py-4">
|
|
||||||
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
|
|
||||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
|
||||||
<Users className="w-4 h-4" />
|
|
||||||
Участников
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="text-center py-4">
|
|
||||||
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
|
|
||||||
<div className="text-sm text-gray-400">Игр</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="text-center py-4">
|
|
||||||
<div className="text-2xl font-bold text-white">
|
|
||||||
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
|
||||||
<Calendar className="w-4 h-4" />
|
|
||||||
Начало
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="text-center py-4">
|
|
||||||
<div className="text-2xl font-bold text-white">
|
|
||||||
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
|
||||||
<CalendarCheck className="w-4 h-4" />
|
|
||||||
Конец
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="text-center py-4">
|
|
||||||
<div className={`text-2xl font-bold ${
|
|
||||||
marathon.status === 'active' ? 'text-green-500' :
|
|
||||||
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
|
|
||||||
}`}>
|
|
||||||
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">Статус</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active event banner */}
|
|
||||||
{marathon.status === 'active' && activeEvent?.event && (
|
|
||||||
<div className="mb-8">
|
|
||||||
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Event control for organizers */}
|
|
||||||
{marathon.status === 'active' && isOrganizer && (
|
|
||||||
<Card className="mb-8">
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="font-medium text-white flex items-center gap-2">
|
|
||||||
<Zap className="w-5 h-5 text-yellow-500" />
|
|
||||||
Управление событиями
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowEventControl(!showEventControl)}
|
|
||||||
>
|
|
||||||
{showEventControl ? 'Скрыть' : 'Показать'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{showEventControl && activeEvent && (
|
|
||||||
<EventControl
|
|
||||||
marathonId={marathon.id}
|
|
||||||
activeEvent={activeEvent}
|
|
||||||
challenges={challenges}
|
|
||||||
onEventChange={refreshEvent}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Invite link */}
|
|
||||||
{marathon.status !== 'finished' && (
|
|
||||||
<Card className="mb-8">
|
|
||||||
<CardContent>
|
|
||||||
<h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono text-sm overflow-hidden text-ellipsis">
|
|
||||||
{getInviteLink()}
|
|
||||||
</code>
|
|
||||||
<Button variant="secondary" onClick={copyInviteLink}>
|
|
||||||
{copied ? (
|
|
||||||
<>
|
|
||||||
<Check className="w-4 h-4 mr-2" />
|
|
||||||
Скопировано!
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy className="w-4 h-4 mr-2" />
|
|
||||||
Копировать
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
)}
|
||||||
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* My stats */}
|
{/* Invite link */}
|
||||||
{marathon.my_participation && (
|
{marathon.status !== 'finished' && (
|
||||||
<Card>
|
<Card className="mb-8">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
|
<h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3>
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono text-sm overflow-hidden text-ellipsis">
|
||||||
<div className="text-2xl font-bold text-primary-500">
|
{getInviteLink()}
|
||||||
{marathon.my_participation.total_points}
|
</code>
|
||||||
|
<Button variant="secondary" onClick={copyInviteLink}>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
Скопировано!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Копировать
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">Очков</div>
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
</div>
|
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
|
||||||
<div>
|
</p>
|
||||||
<div className="text-2xl font-bold text-yellow-500">
|
</CardContent>
|
||||||
{marathon.my_participation.current_streak}
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* My stats */}
|
||||||
|
{marathon.my_participation && (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-primary-500">
|
||||||
|
{marathon.my_participation.total_points}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">Очков</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-yellow-500">
|
||||||
|
{marathon.my_participation.current_streak}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">Серия</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-gray-400">
|
||||||
|
{marathon.my_participation.drop_count}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">Пропусков</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">Серия</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<div>
|
)}
|
||||||
<div className="text-2xl font-bold text-gray-400">
|
</div>
|
||||||
{marathon.my_participation.drop_count}
|
|
||||||
</div>
|
{/* Activity Feed - right sidebar */}
|
||||||
<div className="text-sm text-gray-400">Пропусков</div>
|
{isParticipant && (
|
||||||
</div>
|
<div className="lg:w-96 flex-shrink-0">
|
||||||
|
<div className="lg:sticky lg:top-4">
|
||||||
|
<ActivityFeed
|
||||||
|
ref={activityFeedRef}
|
||||||
|
marathonId={marathon.id}
|
||||||
|
className="lg:max-h-[calc(100vh-8rem)]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ interface AuthState {
|
|||||||
clearError: () => void
|
clearError: () => void
|
||||||
setPendingInviteCode: (code: string | null) => void
|
setPendingInviteCode: (code: string | null) => void
|
||||||
consumePendingInviteCode: () => string | null
|
consumePendingInviteCode: () => string | null
|
||||||
|
updateUser: (updates: Partial<User>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
@@ -89,6 +90,13 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({ pendingInviteCode: null })
|
set({ pendingInviteCode: null })
|
||||||
return code
|
return code
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateUser: (updates) => {
|
||||||
|
const currentUser = get().user
|
||||||
|
if (currentUser) {
|
||||||
|
set({ user: { ...currentUser, ...updates } })
|
||||||
|
}
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
|
|||||||
55
frontend/src/store/confirm.ts
Normal file
55
frontend/src/store/confirm.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export type ConfirmVariant = 'danger' | 'warning' | 'info'
|
||||||
|
|
||||||
|
interface ConfirmOptions {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
variant?: ConfirmVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmState {
|
||||||
|
isOpen: boolean
|
||||||
|
options: ConfirmOptions | null
|
||||||
|
resolve: ((value: boolean) => void) | null
|
||||||
|
|
||||||
|
confirm: (options: ConfirmOptions) => Promise<boolean>
|
||||||
|
handleConfirm: () => void
|
||||||
|
handleCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfirmStore = create<ConfirmState>((set, get) => ({
|
||||||
|
isOpen: false,
|
||||||
|
options: null,
|
||||||
|
resolve: null,
|
||||||
|
|
||||||
|
confirm: (options) => {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
set({
|
||||||
|
isOpen: true,
|
||||||
|
options,
|
||||||
|
resolve,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleConfirm: () => {
|
||||||
|
const { resolve } = get()
|
||||||
|
if (resolve) resolve(true)
|
||||||
|
set({ isOpen: false, options: null, resolve: null })
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCancel: () => {
|
||||||
|
const { resolve } = get()
|
||||||
|
if (resolve) resolve(false)
|
||||||
|
set({ isOpen: false, options: null, resolve: null })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Convenient hook
|
||||||
|
export const useConfirm = () => {
|
||||||
|
const confirm = useConfirmStore((state) => state.confirm)
|
||||||
|
return confirm
|
||||||
|
}
|
||||||
53
frontend/src/store/toast.ts
Normal file
53
frontend/src/store/toast.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string
|
||||||
|
type: ToastType
|
||||||
|
message: string
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastState {
|
||||||
|
toasts: Toast[]
|
||||||
|
addToast: (type: ToastType, message: string, duration?: number) => void
|
||||||
|
removeToast: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToastStore = create<ToastState>((set) => ({
|
||||||
|
toasts: [],
|
||||||
|
|
||||||
|
addToast: (type, message, duration = 4000) => {
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
set((state) => ({
|
||||||
|
toasts: [...state.toasts, { id, type, message, duration }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
set((state) => ({
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== id),
|
||||||
|
}))
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeToast: (id) => {
|
||||||
|
set((state) => ({
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== id),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Helper hooks for convenience
|
||||||
|
export const useToast = () => {
|
||||||
|
const addToast = useToastStore((state) => state.addToast)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: (message: string, duration?: number) => addToast('success', message, duration),
|
||||||
|
error: (message: string, duration?: number) => addToast('error', message, duration),
|
||||||
|
info: (message: string, duration?: number) => addToast('info', message, duration),
|
||||||
|
warning: (message: string, duration?: number) => addToast('warning', message, duration),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ export interface User {
|
|||||||
nickname: string
|
nickname: string
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
telegram_id: number | null
|
||||||
|
telegram_username: string | null
|
||||||
|
telegram_first_name: string | null
|
||||||
|
telegram_last_name: string | null
|
||||||
|
telegram_avatar_url: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +163,7 @@ export interface ChallengesPreviewResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Assignment types
|
// Assignment types
|
||||||
export type AssignmentStatus = 'active' | 'completed' | 'dropped'
|
export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned'
|
||||||
|
|
||||||
export interface Assignment {
|
export interface Assignment {
|
||||||
id: number
|
id: number
|
||||||
@@ -170,6 +175,7 @@ export interface Assignment {
|
|||||||
streak_at_completion: number | null
|
streak_at_completion: number | null
|
||||||
started_at: string
|
started_at: string
|
||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
|
drop_penalty: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpinResult {
|
export interface SpinResult {
|
||||||
@@ -245,6 +251,14 @@ export interface CommonEnemyLeaderboardEntry {
|
|||||||
bonus_points: number
|
bonus_points: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event Assignment (Common Enemy)
|
||||||
|
export interface EventAssignment {
|
||||||
|
assignment: Assignment | null
|
||||||
|
event_id: number | null
|
||||||
|
challenge_id: number | null
|
||||||
|
is_completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// Activity types
|
// Activity types
|
||||||
export type ActivityType =
|
export type ActivityType =
|
||||||
| 'join'
|
| 'join'
|
||||||
@@ -256,6 +270,10 @@ export type ActivityType =
|
|||||||
| 'add_game'
|
| 'add_game'
|
||||||
| 'approve_game'
|
| 'approve_game'
|
||||||
| 'reject_game'
|
| 'reject_game'
|
||||||
|
| 'event_start'
|
||||||
|
| 'event_end'
|
||||||
|
| 'swap'
|
||||||
|
| 'rematch'
|
||||||
|
|
||||||
export interface Activity {
|
export interface Activity {
|
||||||
id: number
|
id: number
|
||||||
@@ -278,7 +296,7 @@ export type EventType =
|
|||||||
| 'double_risk'
|
| 'double_risk'
|
||||||
| 'jackpot'
|
| 'jackpot'
|
||||||
| 'swap'
|
| 'swap'
|
||||||
| 'rematch'
|
| 'game_choice'
|
||||||
|
|
||||||
export interface MarathonEvent {
|
export interface MarathonEvent {
|
||||||
id: number
|
id: number
|
||||||
@@ -322,7 +340,7 @@ export const EVENT_INFO: Record<EventType, { name: string; description: string;
|
|||||||
color: 'red',
|
color: 'red',
|
||||||
},
|
},
|
||||||
double_risk: {
|
double_risk: {
|
||||||
name: 'Двойной риск',
|
name: 'Безопасная игра',
|
||||||
description: 'Дропы бесплатны, но очки x0.5',
|
description: 'Дропы бесплатны, но очки x0.5',
|
||||||
color: 'purple',
|
color: 'purple',
|
||||||
},
|
},
|
||||||
@@ -336,13 +354,31 @@ export const EVENT_INFO: Record<EventType, { name: string; description: string;
|
|||||||
description: 'Можно поменяться заданием с другим участником',
|
description: 'Можно поменяться заданием с другим участником',
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
},
|
},
|
||||||
rematch: {
|
game_choice: {
|
||||||
name: 'Реванш',
|
name: 'Выбор игры',
|
||||||
description: 'Можно переделать проваленный челлендж за 50% очков',
|
description: 'Выбери игру и один из 3 челленджей. Можно заменить задание без штрафа!',
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Game Choice types
|
||||||
|
export interface GameChoiceChallenge {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
difficulty: Difficulty
|
||||||
|
points: number
|
||||||
|
estimated_time: number | null
|
||||||
|
proof_type: ProofType
|
||||||
|
proof_hint: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameChoiceChallenges {
|
||||||
|
game_id: number
|
||||||
|
game_title: string
|
||||||
|
challenges: GameChoiceChallenge[]
|
||||||
|
}
|
||||||
|
|
||||||
// Admin types
|
// Admin types
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: number
|
id: number
|
||||||
@@ -374,3 +410,57 @@ export interface PlatformStats {
|
|||||||
games_count: number
|
games_count: number
|
||||||
total_participations: number
|
total_participations: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispute types
|
||||||
|
export type DisputeStatus = 'open' | 'valid' | 'invalid'
|
||||||
|
|
||||||
|
export interface DisputeComment {
|
||||||
|
id: number
|
||||||
|
user: User
|
||||||
|
text: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisputeVote {
|
||||||
|
user: User
|
||||||
|
vote: boolean // true = valid, false = invalid
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dispute {
|
||||||
|
id: number
|
||||||
|
raised_by: User
|
||||||
|
reason: string
|
||||||
|
status: DisputeStatus
|
||||||
|
comments: DisputeComment[]
|
||||||
|
votes: DisputeVote[]
|
||||||
|
votes_valid: number
|
||||||
|
votes_invalid: number
|
||||||
|
my_vote: boolean | null
|
||||||
|
expires_at: string
|
||||||
|
created_at: string
|
||||||
|
resolved_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignmentDetail {
|
||||||
|
id: number
|
||||||
|
challenge: Challenge
|
||||||
|
participant: User
|
||||||
|
status: AssignmentStatus
|
||||||
|
proof_url: string | null
|
||||||
|
proof_image_url: string | null
|
||||||
|
proof_comment: string | null
|
||||||
|
points_earned: number
|
||||||
|
streak_at_completion: number | null
|
||||||
|
started_at: string
|
||||||
|
completed_at: string | null
|
||||||
|
can_dispute: boolean
|
||||||
|
dispute: Dispute | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReturnedAssignment {
|
||||||
|
id: number
|
||||||
|
challenge: Challenge
|
||||||
|
original_completed_at: string
|
||||||
|
dispute_reason: string
|
||||||
|
}
|
||||||
|
|||||||
250
frontend/src/utils/activity.ts
Normal file
250
frontend/src/utils/activity.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import type { Activity, ActivityType, EventType } from '@/types'
|
||||||
|
import {
|
||||||
|
UserPlus,
|
||||||
|
RotateCcw,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Play,
|
||||||
|
Flag,
|
||||||
|
Plus,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
|
Zap,
|
||||||
|
ZapOff,
|
||||||
|
ArrowLeftRight,
|
||||||
|
RefreshCw,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
// Relative time formatting
|
||||||
|
export function formatRelativeTime(dateStr: string): string {
|
||||||
|
// Backend saves time in UTC, ensure we parse it correctly
|
||||||
|
// If the string doesn't end with 'Z', append it to indicate UTC
|
||||||
|
const utcDateStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'
|
||||||
|
const date = new Date(utcDateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffSec = Math.floor(diffMs / 1000)
|
||||||
|
const diffMin = Math.floor(diffSec / 60)
|
||||||
|
const diffHour = Math.floor(diffMin / 60)
|
||||||
|
const diffDay = Math.floor(diffHour / 24)
|
||||||
|
|
||||||
|
if (diffSec < 60) return 'только что'
|
||||||
|
if (diffMin < 60) {
|
||||||
|
if (diffMin === 1) return '1 мин назад'
|
||||||
|
if (diffMin < 5) return `${diffMin} мин назад`
|
||||||
|
if (diffMin < 21 && diffMin > 4) return `${diffMin} мин назад`
|
||||||
|
return `${diffMin} мин назад`
|
||||||
|
}
|
||||||
|
if (diffHour < 24) {
|
||||||
|
if (diffHour === 1) return '1 час назад'
|
||||||
|
if (diffHour < 5) return `${diffHour} часа назад`
|
||||||
|
return `${diffHour} часов назад`
|
||||||
|
}
|
||||||
|
if (diffDay === 1) return 'вчера'
|
||||||
|
if (diffDay < 7) return `${diffDay} дн назад`
|
||||||
|
|
||||||
|
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity icon mapping
|
||||||
|
export function getActivityIcon(type: ActivityType): LucideIcon {
|
||||||
|
const icons: Record<ActivityType, LucideIcon> = {
|
||||||
|
join: UserPlus,
|
||||||
|
spin: RotateCcw,
|
||||||
|
complete: CheckCircle,
|
||||||
|
drop: XCircle,
|
||||||
|
start_marathon: Play,
|
||||||
|
finish_marathon: Flag,
|
||||||
|
add_game: Plus,
|
||||||
|
approve_game: ThumbsUp,
|
||||||
|
reject_game: ThumbsDown,
|
||||||
|
event_start: Zap,
|
||||||
|
event_end: ZapOff,
|
||||||
|
swap: ArrowLeftRight,
|
||||||
|
rematch: RefreshCw,
|
||||||
|
}
|
||||||
|
return icons[type] || Zap
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity color mapping
|
||||||
|
export function getActivityColor(type: ActivityType): string {
|
||||||
|
const colors: Record<ActivityType, string> = {
|
||||||
|
join: 'text-green-400',
|
||||||
|
spin: 'text-blue-400',
|
||||||
|
complete: 'text-green-400',
|
||||||
|
drop: 'text-red-400',
|
||||||
|
start_marathon: 'text-green-400',
|
||||||
|
finish_marathon: 'text-gray-400',
|
||||||
|
add_game: 'text-blue-400',
|
||||||
|
approve_game: 'text-green-400',
|
||||||
|
reject_game: 'text-red-400',
|
||||||
|
event_start: 'text-yellow-400',
|
||||||
|
event_end: 'text-gray-400',
|
||||||
|
swap: 'text-purple-400',
|
||||||
|
rematch: 'text-orange-400',
|
||||||
|
}
|
||||||
|
return colors[type] || 'text-gray-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity background for special events
|
||||||
|
export function getActivityBgClass(type: ActivityType): string {
|
||||||
|
if (type === 'event_start') {
|
||||||
|
return 'bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border-yellow-700/50'
|
||||||
|
}
|
||||||
|
if (type === 'event_end') {
|
||||||
|
return 'bg-gray-800/50 border-gray-700/50'
|
||||||
|
}
|
||||||
|
return 'bg-gray-800/30 border-gray-700/30'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if activity is a special event
|
||||||
|
export function isEventActivity(type: ActivityType): boolean {
|
||||||
|
return type === 'event_start' || type === 'event_end'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event type to Russian name mapping
|
||||||
|
const EVENT_NAMES: Record<EventType, string> = {
|
||||||
|
golden_hour: 'Золотой час',
|
||||||
|
common_enemy: 'Общий враг',
|
||||||
|
double_risk: 'Безопасная игра',
|
||||||
|
jackpot: 'Джекпот',
|
||||||
|
swap: 'Обмен',
|
||||||
|
game_choice: 'Выбор игры',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Difficulty translation
|
||||||
|
const DIFFICULTY_NAMES: Record<string, string> = {
|
||||||
|
easy: 'Легкий',
|
||||||
|
medium: 'Средний',
|
||||||
|
hard: 'Сложный',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Winner {
|
||||||
|
nickname: string
|
||||||
|
rank: number
|
||||||
|
bonus_points: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format activity message
|
||||||
|
export function formatActivityMessage(activity: Activity): { title: string; details?: string; extra?: string } {
|
||||||
|
const data = activity.data || {}
|
||||||
|
|
||||||
|
switch (activity.type) {
|
||||||
|
case 'join':
|
||||||
|
return { title: 'присоединился к марафону' }
|
||||||
|
|
||||||
|
case 'spin': {
|
||||||
|
const game = (data.game as string) || ''
|
||||||
|
const challenge = (data.challenge as string) || ''
|
||||||
|
const difficulty = data.difficulty ? DIFFICULTY_NAMES[data.difficulty as string] || '' : ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: 'получил задание',
|
||||||
|
details: challenge || undefined,
|
||||||
|
extra: [game, difficulty].filter(Boolean).join(' • ') || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'complete': {
|
||||||
|
const game = (data.game as string) || ''
|
||||||
|
const challenge = (data.challenge as string) || ''
|
||||||
|
const points = data.points ? `+${data.points}` : ''
|
||||||
|
const streak = data.streak && (data.streak as number) > 1 ? `серия ${data.streak}` : ''
|
||||||
|
const bonus = data.common_enemy_bonus ? `+${data.common_enemy_bonus} бонус` : ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `завершил ${points}`,
|
||||||
|
details: challenge || undefined,
|
||||||
|
extra: [game, streak, bonus].filter(Boolean).join(' • ') || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'drop': {
|
||||||
|
const game = (data.game as string) || ''
|
||||||
|
const challenge = (data.challenge as string) || ''
|
||||||
|
const penalty = data.penalty ? `-${data.penalty}` : ''
|
||||||
|
const free = data.free_drop ? '(бесплатно)' : ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `пропустил ${penalty} ${free}`.trim(),
|
||||||
|
details: challenge || undefined,
|
||||||
|
extra: game || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'start_marathon':
|
||||||
|
return { title: 'Марафон начался!' }
|
||||||
|
|
||||||
|
case 'finish_marathon':
|
||||||
|
return { title: 'Марафон завершён!' }
|
||||||
|
|
||||||
|
case 'add_game':
|
||||||
|
return {
|
||||||
|
title: 'добавил игру',
|
||||||
|
details: (data.game as string) || (data.game_title as string) || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'approve_game':
|
||||||
|
return {
|
||||||
|
title: 'одобрил игру',
|
||||||
|
details: (data.game as string) || (data.game_title as string) || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'reject_game':
|
||||||
|
return {
|
||||||
|
title: 'отклонил игру',
|
||||||
|
details: (data.game as string) || (data.game_title as string) || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'event_start': {
|
||||||
|
const eventName = EVENT_NAMES[data.event_type as EventType] || (data.event_name as string) || 'Событие'
|
||||||
|
return {
|
||||||
|
title: 'СОБЫТИЕ НАЧАЛОСЬ',
|
||||||
|
details: eventName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'event_end': {
|
||||||
|
const eventName = EVENT_NAMES[data.event_type as EventType] || (data.event_name as string) || 'Событие'
|
||||||
|
const winners = data.winners as Winner[] | undefined
|
||||||
|
|
||||||
|
let winnersText = ''
|
||||||
|
if (winners && winners.length > 0) {
|
||||||
|
const medals = ['🥇', '🥈', '🥉']
|
||||||
|
winnersText = winners
|
||||||
|
.map((w) => `${medals[w.rank - 1] || ''} ${w.nickname} +${w.bonus_points}`)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: 'Событие завершено',
|
||||||
|
details: eventName,
|
||||||
|
extra: winnersText || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'swap': {
|
||||||
|
const challenge = (data.challenge as string) || ''
|
||||||
|
const withUser = (data.with_user as string) || ''
|
||||||
|
return {
|
||||||
|
title: 'обменялся заданиями',
|
||||||
|
details: withUser ? `с ${withUser}` : undefined,
|
||||||
|
extra: challenge || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'rematch': {
|
||||||
|
const game = (data.game as string) || ''
|
||||||
|
const challenge = (data.challenge as string) || ''
|
||||||
|
return {
|
||||||
|
title: 'взял реванш',
|
||||||
|
details: challenge || undefined,
|
||||||
|
extra: game || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { title: 'выполнил действие' }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user