Common enemy rework

This commit is contained in:
2025-12-15 23:03:59 +07:00
parent 9a037cb34f
commit 07e02ce32d
11 changed files with 731 additions and 29 deletions

View 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')

View File

@@ -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
)

View File

@@ -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)")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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",

View File

@@ -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

View File

@@ -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: