Common enemy rework
This commit is contained in:
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')
|
||||
@@ -10,12 +10,17 @@ from app.models import (
|
||||
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
|
||||
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
|
||||
)
|
||||
from fastapi import UploadFile, File, Form
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from app.schemas import (
|
||||
EventCreate, EventResponse, ActiveEventResponse, EventEffects,
|
||||
MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate,
|
||||
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.user import UserPublic
|
||||
from app.services.events import event_service
|
||||
@@ -864,3 +869,259 @@ async def get_common_enemy_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=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
|
||||
),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
),
|
||||
status=assignment.status,
|
||||
proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" 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}",
|
||||
)
|
||||
|
||||
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}"
|
||||
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
assignment.proof_path = str(filepath)
|
||||
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 = {
|
||||
"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
|
||||
)
|
||||
|
||||
@@ -38,7 +38,14 @@ async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Particip
|
||||
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(
|
||||
select(Assignment)
|
||||
.options(
|
||||
@@ -47,6 +54,7 @@ async def get_active_assignment(db, participant_id: int) -> Assignment | None:
|
||||
.where(
|
||||
Assignment.participant_id == participant_id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
Assignment.is_event_assignment == is_event,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -66,8 +74,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
|
||||
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
||||
|
||||
# Check no active assignment
|
||||
active = await get_active_assignment(db, participant.id)
|
||||
# Check no active regular assignment (event assignments are separate)
|
||||
active = await get_active_assignment(db, participant.id, is_event=False)
|
||||
if active:
|
||||
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
||||
|
||||
@@ -77,7 +85,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
game = None
|
||||
challenge = None
|
||||
|
||||
# Handle special event cases
|
||||
# Handle special event cases (excluding Common Enemy - it has separate flow)
|
||||
if active_event:
|
||||
if active_event.type == EventType.JACKPOT.value:
|
||||
# Jackpot: Get hard challenge only
|
||||
@@ -90,17 +98,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
game = result.scalar_one_or_none()
|
||||
# Consume jackpot (one-time use)
|
||||
await event_service.consume_jackpot(db, active_event.id)
|
||||
|
||||
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
|
||||
# Note: Common Enemy is handled separately via event-assignment endpoints
|
||||
|
||||
# Normal random selection if no special event handling
|
||||
if not game or not challenge:
|
||||
@@ -192,9 +190,9 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
|
||||
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
|
||||
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)
|
||||
assignment = await get_active_assignment(db, participant.id)
|
||||
assignment = await get_active_assignment(db, participant.id, is_event=False)
|
||||
|
||||
if not assignment:
|
||||
return None
|
||||
@@ -237,7 +235,7 @@ async def complete_assignment(
|
||||
comment: str | None = Form(None),
|
||||
proof_file: UploadFile | None = File(None),
|
||||
):
|
||||
"""Complete an assignment with proof"""
|
||||
"""Complete a regular assignment with proof (not event assignments)"""
|
||||
# Get assignment
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
@@ -258,6 +256,10 @@ async def complete_assignment(
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
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
|
||||
if not proof_file and not proof_url:
|
||||
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
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 app.core.database import Base
|
||||
@@ -20,6 +20,8 @@ class Assignment(Base):
|
||||
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
|
||||
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
|
||||
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
|
||||
is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments
|
||||
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
|
||||
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -31,3 +33,4 @@ class Assignment(Base):
|
||||
# Relationships
|
||||
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
|
||||
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
|
||||
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
|
||||
|
||||
@@ -37,3 +37,4 @@ class Event(Base):
|
||||
# Relationships
|
||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events")
|
||||
created_by: Mapped["User | None"] = relationship("User")
|
||||
assignments: Mapped[list["Assignment"]] = relationship("Assignment", back_populates="event")
|
||||
|
||||
@@ -41,6 +41,7 @@ from app.schemas.assignment import (
|
||||
SpinResult,
|
||||
CompleteResult,
|
||||
DropResult,
|
||||
EventAssignmentResponse,
|
||||
)
|
||||
from app.schemas.activity import (
|
||||
ActivityResponse,
|
||||
@@ -107,6 +108,7 @@ __all__ = [
|
||||
"SpinResult",
|
||||
"CompleteResult",
|
||||
"DropResult",
|
||||
"EventAssignmentResponse",
|
||||
# Activity
|
||||
"ActivityResponse",
|
||||
"FeedResponse",
|
||||
|
||||
@@ -48,3 +48,14 @@ class DropResult(BaseModel):
|
||||
penalty: int
|
||||
total_points: 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
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Event, EventType, Marathon, Challenge, Difficulty
|
||||
from app.models import Event, EventType, Marathon, Challenge, Difficulty, Participant, Assignment, AssignmentStatus
|
||||
from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||
|
||||
|
||||
@@ -76,6 +76,12 @@ class EventService:
|
||||
data=data if data else None,
|
||||
)
|
||||
db.add(event)
|
||||
await db.flush() # Get event.id before committing
|
||||
|
||||
# Auto-assign challenge to all participants for Common Enemy
|
||||
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
|
||||
await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
@@ -85,14 +91,58 @@ class EventService:
|
||||
|
||||
return event
|
||||
|
||||
async def _assign_common_enemy_to_all(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
marathon_id: int,
|
||||
event_id: int,
|
||||
challenge_id: int,
|
||||
) -> None:
|
||||
"""Create event assignments for all participants in the marathon"""
|
||||
# Get all participants
|
||||
result = await db.execute(
|
||||
select(Participant).where(Participant.marathon_id == marathon_id)
|
||||
)
|
||||
participants = result.scalars().all()
|
||||
|
||||
# Create event assignment for each participant
|
||||
for participant in participants:
|
||||
assignment = Assignment(
|
||||
participant_id=participant.id,
|
||||
challenge_id=challenge_id,
|
||||
status=AssignmentStatus.ACTIVE.value,
|
||||
event_type=EventType.COMMON_ENEMY.value,
|
||||
is_event_assignment=True,
|
||||
event_id=event_id,
|
||||
)
|
||||
db.add(assignment)
|
||||
|
||||
async def end_event(self, db: AsyncSession, event_id: int) -> None:
|
||||
"""End an event"""
|
||||
"""End an event and mark incomplete event assignments as expired"""
|
||||
from sqlalchemy import update
|
||||
|
||||
result = await db.execute(select(Event).where(Event.id == event_id))
|
||||
event = result.scalar_one_or_none()
|
||||
if event:
|
||||
event.is_active = False
|
||||
if not event.end_time:
|
||||
event.end_time = datetime.utcnow()
|
||||
|
||||
# Mark all incomplete event assignments for this event as dropped
|
||||
if event.type == EventType.COMMON_ENEMY.value:
|
||||
await db.execute(
|
||||
update(Assignment)
|
||||
.where(
|
||||
Assignment.event_id == event_id,
|
||||
Assignment.is_event_assignment == True,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
)
|
||||
.values(
|
||||
status=AssignmentStatus.DROPPED.value,
|
||||
completed_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import client from './client'
|
||||
import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types'
|
||||
import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult } from '@/types'
|
||||
|
||||
export const eventsApi = {
|
||||
getActive: async (marathonId: number): Promise<ActiveEvent> => {
|
||||
@@ -64,4 +64,27 @@ export const eventsApi = {
|
||||
const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`)
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api'
|
||||
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, DroppedAssignment, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types'
|
||||
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, DroppedAssignment, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment } from '@/types'
|
||||
import { Button, Card, CardContent } from '@/components/ui'
|
||||
import { SpinWheel } from '@/components/SpinWheel'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
@@ -41,7 +41,19 @@ export function PlayPage() {
|
||||
// Common Enemy leaderboard state
|
||||
const [commonEnemyLeaderboard, setCommonEnemyLeaderboard] = useState<CommonEnemyLeaderboardEntry[]>([])
|
||||
|
||||
// Tab state for Common Enemy
|
||||
type PlayTab = 'spin' | 'event'
|
||||
const [activeTab, setActiveTab] = useState<PlayTab>('spin')
|
||||
|
||||
// Event assignment state (Common Enemy)
|
||||
const [eventAssignment, setEventAssignment] = useState<EventAssignment | null>(null)
|
||||
const [eventProofFile, setEventProofFile] = useState<File | null>(null)
|
||||
const [eventProofUrl, setEventProofUrl] = useState('')
|
||||
const [eventComment, setEventComment] = useState('')
|
||||
const [isEventCompleting, setIsEventCompleting] = useState(false)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const eventFileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
@@ -123,16 +135,18 @@ export function PlayPage() {
|
||||
const loadData = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const [marathonData, assignment, gamesData, eventData] = await Promise.all([
|
||||
const [marathonData, assignment, gamesData, eventData, eventAssignmentData] = await Promise.all([
|
||||
marathonsApi.get(parseInt(id)),
|
||||
wheelApi.getCurrentAssignment(parseInt(id)),
|
||||
gamesApi.list(parseInt(id), 'approved'),
|
||||
eventsApi.getActive(parseInt(id)),
|
||||
eventsApi.getEventAssignment(parseInt(id)),
|
||||
])
|
||||
setMarathon(marathonData)
|
||||
setCurrentAssignment(assignment)
|
||||
setGames(gamesData)
|
||||
setActiveEvent(eventData)
|
||||
setEventAssignment(eventAssignmentData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
} finally {
|
||||
@@ -224,6 +238,37 @@ export function PlayPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEventComplete = async () => {
|
||||
if (!eventAssignment?.assignment) return
|
||||
if (!eventProofFile && !eventProofUrl) {
|
||||
alert('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
||||
return
|
||||
}
|
||||
|
||||
setIsEventCompleting(true)
|
||||
try {
|
||||
const result = await eventsApi.completeEventAssignment(eventAssignment.assignment.id, {
|
||||
proof_file: eventProofFile || undefined,
|
||||
proof_url: eventProofUrl || undefined,
|
||||
comment: eventComment || undefined,
|
||||
})
|
||||
|
||||
alert(`Выполнено! +${result.points_earned} очков`)
|
||||
|
||||
// Reset form
|
||||
setEventProofFile(null)
|
||||
setEventProofUrl('')
|
||||
setEventComment('')
|
||||
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось выполнить')
|
||||
} finally {
|
||||
setIsEventCompleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRematch = async (assignmentId: number) => {
|
||||
if (!id) return
|
||||
|
||||
@@ -367,8 +412,248 @@ export function PlayPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Common Enemy Leaderboard */}
|
||||
{/* Tabs for Common Enemy event */}
|
||||
{activeEvent?.event?.type === 'common_enemy' && (
|
||||
<div className="flex gap-2 mb-6">
|
||||
<Button
|
||||
variant={activeTab === 'spin' ? 'primary' : 'secondary'}
|
||||
onClick={() => setActiveTab('spin')}
|
||||
className="flex-1"
|
||||
>
|
||||
Мой прокрут
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'event' ? 'primary' : 'secondary'}
|
||||
onClick={() => setActiveTab('event')}
|
||||
className="flex-1 relative"
|
||||
>
|
||||
Общий враг
|
||||
{eventAssignment?.assignment && !eventAssignment.is_completed && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event tab content (Common Enemy) */}
|
||||
{activeTab === 'event' && activeEvent?.event?.type === 'common_enemy' && (
|
||||
<>
|
||||
{/* Common Enemy Leaderboard */}
|
||||
<Card className="mb-6">
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Users className="w-5 h-5 text-red-500" />
|
||||
<h3 className="text-lg font-bold text-white">Выполнили челлендж</h3>
|
||||
{commonEnemyLeaderboard.length > 0 && (
|
||||
<span className="ml-auto text-gray-400 text-sm">
|
||||
{commonEnemyLeaderboard.length} чел.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{commonEnemyLeaderboard.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
Пока никто не выполнил. Будь первым!
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{commonEnemyLeaderboard.map((entry) => (
|
||||
<div
|
||||
key={entry.participant_id}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-lg
|
||||
${entry.rank === 1 ? 'bg-yellow-500/20 border border-yellow-500/30' :
|
||||
entry.rank === 2 ? 'bg-gray-400/20 border border-gray-400/30' :
|
||||
entry.rank === 3 ? 'bg-orange-600/20 border border-orange-600/30' :
|
||||
'bg-gray-800'}
|
||||
`}
|
||||
>
|
||||
<div className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm
|
||||
${entry.rank === 1 ? 'bg-yellow-500 text-black' :
|
||||
entry.rank === 2 ? 'bg-gray-400 text-black' :
|
||||
entry.rank === 3 ? 'bg-orange-600 text-white' :
|
||||
'bg-gray-700 text-gray-300'}
|
||||
`}>
|
||||
{entry.rank && entry.rank <= 3 ? (
|
||||
<Trophy className="w-4 h-4" />
|
||||
) : (
|
||||
entry.rank
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium">{entry.user.nickname}</p>
|
||||
</div>
|
||||
{entry.bonus_points > 0 && (
|
||||
<span className="text-green-400 text-sm font-medium">
|
||||
+{entry.bonus_points} бонус
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Event Assignment Card */}
|
||||
{eventAssignment?.assignment && !eventAssignment.is_completed ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-center mb-6">
|
||||
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm">
|
||||
Задание события "Общий враг"
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Game */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-400 mb-1">Игра</h3>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{eventAssignment.assignment.challenge.game.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Challenge */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-400 mb-1">Задание</h3>
|
||||
<p className="text-xl font-bold text-white mb-2">
|
||||
{eventAssignment.assignment.challenge.title}
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
{eventAssignment.assignment.challenge.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Points */}
|
||||
<div className="flex items-center gap-4 mb-6 text-sm">
|
||||
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full">
|
||||
+{eventAssignment.assignment.challenge.points} очков
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full">
|
||||
{eventAssignment.assignment.challenge.difficulty}
|
||||
</span>
|
||||
{eventAssignment.assignment.challenge.estimated_time && (
|
||||
<span className="text-gray-400">
|
||||
~{eventAssignment.assignment.challenge.estimated_time} мин
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Proof hint */}
|
||||
{eventAssignment.assignment.challenge.proof_hint && (
|
||||
<div className="mb-6 p-3 bg-gray-900 rounded-lg">
|
||||
<p className="text-sm text-gray-400">
|
||||
<strong>Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proof upload */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Загрузить доказательство ({eventAssignment.assignment.challenge.proof_type})
|
||||
</label>
|
||||
|
||||
{/* File upload */}
|
||||
<input
|
||||
ref={eventFileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => setEventProofFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
|
||||
{eventProofFile ? (
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-900 rounded-lg">
|
||||
<span className="text-white flex-1 truncate">{eventProofFile.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEventProofFile(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => eventFileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Выбрать файл
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center text-gray-500">или</div>
|
||||
|
||||
{/* URL input */}
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Вставьте ссылку (YouTube, профиль Steam и т.д.)"
|
||||
value={eventProofUrl}
|
||||
onChange={(e) => setEventProofUrl(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Comment */}
|
||||
<textarea
|
||||
className="input min-h-[80px] resize-none"
|
||||
placeholder="Комментарий (необязательно)"
|
||||
value={eventComment}
|
||||
onChange={(e) => setEventComment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleEventComplete}
|
||||
isLoading={isEventCompleting}
|
||||
disabled={!eventProofFile && !eventProofUrl}
|
||||
>
|
||||
Выполнено
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : eventAssignment?.is_completed ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Check className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Задание выполнено!</h3>
|
||||
<p className="text-gray-400">
|
||||
Вы уже завершили челлендж события "Общий враг"
|
||||
</p>
|
||||
{eventAssignment.assignment && (
|
||||
<p className="text-green-400 mt-2">
|
||||
+{eventAssignment.assignment.points_earned} очков
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-500 mx-auto mb-4" />
|
||||
<p className="text-gray-400">Загрузка задания события...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spin tab content - only show when spin tab is active or no common_enemy event */}
|
||||
{(activeTab === 'spin' || activeEvent?.event?.type !== 'common_enemy') && (
|
||||
<>
|
||||
{/* Common Enemy Leaderboard - show on spin tab too for context */}
|
||||
{activeEvent?.event?.type === 'common_enemy' && activeTab === 'spin' && commonEnemyLeaderboard.length > 0 && (
|
||||
<Card className="mb-6">
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
@@ -807,6 +1092,8 @@ export function PlayPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,6 +245,14 @@ export interface CommonEnemyLeaderboardEntry {
|
||||
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
|
||||
export type ActivityType =
|
||||
| 'join'
|
||||
|
||||
Reference in New Issue
Block a user