Add events
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -12,3 +12,4 @@ router.include_router(challenges.router)
|
||||
router.include_router(wheel.router)
|
||||
router.include_router(feed.router)
|
||||
router.include_router(admin.router)
|
||||
router.include_router(events.router)
|
||||
|
||||
@@ -81,6 +81,52 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
|
||||
]
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
||||
async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""List all challenges for a marathon (from all approved games). Participants only."""
|
||||
# Check marathon exists
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
# Check user is participant or admin
|
||||
participant = await get_participant(db, current_user.id, marathon_id)
|
||||
if not current_user.is_admin and not participant:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
# Get all challenges from approved games in this marathon
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.join(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Challenge.game))
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.status == GameStatus.APPROVED.value,
|
||||
)
|
||||
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
|
||||
)
|
||||
challenges = result.scalars().all()
|
||||
|
||||
return [
|
||||
ChallengeResponse(
|
||||
id=c.id,
|
||||
title=c.title,
|
||||
description=c.description,
|
||||
type=c.type,
|
||||
difficulty=c.difficulty,
|
||||
points=c.points,
|
||||
estimated_time=c.estimated_time,
|
||||
proof_type=c.proof_type,
|
||||
proof_hint=c.proof_hint,
|
||||
game=GameShort(id=c.game.id, title=c.game.title, cover_url=None),
|
||||
is_generated=c.is_generated,
|
||||
created_at=c.created_at,
|
||||
)
|
||||
for c in challenges
|
||||
]
|
||||
|
||||
|
||||
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
||||
async def create_challenge(
|
||||
game_id: int,
|
||||
|
||||
866
backend/app/api/v1/events.py
Normal file
866
backend/app/api/v1/events.py
Normal file
@@ -0,0 +1,866 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.models import (
|
||||
Marathon, MarathonStatus, Participant, ParticipantRole,
|
||||
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
|
||||
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
|
||||
)
|
||||
from app.schemas import (
|
||||
EventCreate, EventResponse, ActiveEventResponse, EventEffects,
|
||||
MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate,
|
||||
SwapRequestCreate, SwapRequestResponse, SwapRequestChallengeInfo, MySwapRequests,
|
||||
CommonEnemyLeaderboard,
|
||||
)
|
||||
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||
from app.schemas.user import UserPublic
|
||||
from app.services.events import event_service
|
||||
|
||||
router = APIRouter(tags=["events"])
|
||||
|
||||
|
||||
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
return marathon
|
||||
|
||||
|
||||
async def require_organizer(db, user_id: int, marathon_id: int) -> Participant:
|
||||
result = await db.execute(
|
||||
select(Participant).where(
|
||||
Participant.user_id == user_id,
|
||||
Participant.marathon_id == marathon_id,
|
||||
)
|
||||
)
|
||||
participant = result.scalar_one_or_none()
|
||||
if not participant:
|
||||
raise HTTPException(status_code=403, detail="Not a participant")
|
||||
if participant.role != ParticipantRole.ORGANIZER.value:
|
||||
raise HTTPException(status_code=403, detail="Only organizers can manage events")
|
||||
return participant
|
||||
|
||||
|
||||
async def require_participant(db, user_id: int, marathon_id: int) -> Participant:
|
||||
result = await db.execute(
|
||||
select(Participant).where(
|
||||
Participant.user_id == user_id,
|
||||
Participant.marathon_id == marathon_id,
|
||||
)
|
||||
)
|
||||
participant = result.scalar_one_or_none()
|
||||
if not participant:
|
||||
raise HTTPException(status_code=403, detail="Not a participant")
|
||||
return participant
|
||||
|
||||
|
||||
def event_to_response(event: Event) -> EventResponse:
|
||||
return EventResponse(
|
||||
id=event.id,
|
||||
type=event.type,
|
||||
start_time=event.start_time,
|
||||
end_time=event.end_time,
|
||||
is_active=event.is_active,
|
||||
created_by=UserPublic(
|
||||
id=event.created_by.id,
|
||||
login=event.created_by.login,
|
||||
nickname=event.created_by.nickname,
|
||||
avatar_url=None,
|
||||
role=event.created_by.role,
|
||||
created_at=event.created_by.created_at,
|
||||
) if event.created_by else None,
|
||||
data=event.data,
|
||||
created_at=event.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/event", response_model=ActiveEventResponse)
|
||||
async def get_active_event(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession
|
||||
):
|
||||
"""Get currently active event for marathon"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
effects = event_service.get_event_effects(event)
|
||||
time_remaining = event_service.get_time_remaining(event)
|
||||
|
||||
return ActiveEventResponse(
|
||||
event=event_to_response(event) if event else None,
|
||||
effects=effects,
|
||||
time_remaining_seconds=time_remaining,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/events", response_model=list[EventResponse])
|
||||
async def list_events(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
):
|
||||
"""Get event history for marathon"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(Event)
|
||||
.options(selectinload(Event.created_by))
|
||||
.where(Event.marathon_id == marathon_id)
|
||||
.order_by(Event.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
|
||||
return [event_to_response(e) for e in events]
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/events", response_model=EventResponse)
|
||||
async def start_event(
|
||||
marathon_id: int,
|
||||
data: EventCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Start a new event (organizer only)"""
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
await require_organizer(db, current_user.id, marathon_id)
|
||||
|
||||
if marathon.status != MarathonStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Marathon is not active")
|
||||
|
||||
# Validate common_enemy requires challenge_id
|
||||
if data.type == EventType.COMMON_ENEMY.value and not data.challenge_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Common enemy event requires challenge_id"
|
||||
)
|
||||
|
||||
try:
|
||||
event = await event_service.start_event(
|
||||
db=db,
|
||||
marathon_id=marathon_id,
|
||||
event_type=data.type,
|
||||
created_by_id=current_user.id,
|
||||
duration_minutes=data.duration_minutes,
|
||||
challenge_id=data.challenge_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Log activity
|
||||
event_info = EVENT_INFO.get(EventType(data.type), {})
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.EVENT_START.value,
|
||||
data={
|
||||
"event_type": data.type,
|
||||
"event_name": event_info.get("name", data.type),
|
||||
},
|
||||
)
|
||||
db.add(activity)
|
||||
await db.commit()
|
||||
|
||||
# Reload with relationship
|
||||
await db.refresh(event, ["created_by"])
|
||||
|
||||
return event_to_response(event)
|
||||
|
||||
|
||||
@router.delete("/marathons/{marathon_id}/event", response_model=MessageResponse)
|
||||
async def stop_event(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Stop active event (organizer only)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
await require_organizer(db, current_user.id, marathon_id)
|
||||
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="No active event")
|
||||
|
||||
# Build activity data before ending event
|
||||
event_info = EVENT_INFO.get(EventType(event.type), {})
|
||||
activity_data = {
|
||||
"event_type": event.type,
|
||||
"event_name": event_info.get("name", event.type),
|
||||
"auto_closed": False,
|
||||
}
|
||||
|
||||
# For common_enemy, include winners in activity
|
||||
if event.type == EventType.COMMON_ENEMY.value:
|
||||
event_data = event.data or {}
|
||||
completions = event_data.get("completions", [])
|
||||
if completions:
|
||||
activity_data["winners"] = [
|
||||
{
|
||||
"user_id": c["user_id"],
|
||||
"rank": c["rank"],
|
||||
"bonus_points": COMMON_ENEMY_BONUSES.get(c["rank"], 0),
|
||||
}
|
||||
for c in completions[:3] # Top 3
|
||||
]
|
||||
|
||||
await event_service.end_event(db, event.id)
|
||||
|
||||
# Log activity
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.EVENT_END.value,
|
||||
data=activity_data,
|
||||
)
|
||||
db.add(activity)
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Event stopped")
|
||||
|
||||
|
||||
def build_swap_request_response(
|
||||
swap_req: SwapRequestModel,
|
||||
) -> SwapRequestResponse:
|
||||
"""Build SwapRequestResponse from model with loaded relationships"""
|
||||
return SwapRequestResponse(
|
||||
id=swap_req.id,
|
||||
status=swap_req.status,
|
||||
from_user=UserPublic(
|
||||
id=swap_req.from_participant.user.id,
|
||||
login=swap_req.from_participant.user.login,
|
||||
nickname=swap_req.from_participant.user.nickname,
|
||||
avatar_url=None,
|
||||
role=swap_req.from_participant.user.role,
|
||||
created_at=swap_req.from_participant.user.created_at,
|
||||
),
|
||||
to_user=UserPublic(
|
||||
id=swap_req.to_participant.user.id,
|
||||
login=swap_req.to_participant.user.login,
|
||||
nickname=swap_req.to_participant.user.nickname,
|
||||
avatar_url=None,
|
||||
role=swap_req.to_participant.user.role,
|
||||
created_at=swap_req.to_participant.user.created_at,
|
||||
),
|
||||
from_challenge=SwapRequestChallengeInfo(
|
||||
title=swap_req.from_assignment.challenge.title,
|
||||
description=swap_req.from_assignment.challenge.description,
|
||||
points=swap_req.from_assignment.challenge.points,
|
||||
difficulty=swap_req.from_assignment.challenge.difficulty,
|
||||
game_title=swap_req.from_assignment.challenge.game.title,
|
||||
),
|
||||
to_challenge=SwapRequestChallengeInfo(
|
||||
title=swap_req.to_assignment.challenge.title,
|
||||
description=swap_req.to_assignment.challenge.description,
|
||||
points=swap_req.to_assignment.challenge.points,
|
||||
difficulty=swap_req.to_assignment.challenge.difficulty,
|
||||
game_title=swap_req.to_assignment.challenge.game.title,
|
||||
),
|
||||
created_at=swap_req.created_at,
|
||||
responded_at=swap_req.responded_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/swap-requests", response_model=SwapRequestResponse)
|
||||
async def create_swap_request(
|
||||
marathon_id: int,
|
||||
data: SwapRequestCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Create a swap request to another participant (requires their confirmation)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active swap event
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event or event.type != EventType.SWAP.value:
|
||||
raise HTTPException(status_code=400, detail="No active swap event")
|
||||
|
||||
# Get target participant
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(
|
||||
Participant.id == data.target_participant_id,
|
||||
Participant.marathon_id == marathon_id,
|
||||
)
|
||||
)
|
||||
target = result.scalar_one_or_none()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target participant not found")
|
||||
|
||||
if target.id == participant.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot swap with yourself")
|
||||
|
||||
# Get both active assignments
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
my_assignment = result.scalar_one_or_none()
|
||||
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == target.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
target_assignment = result.scalar_one_or_none()
|
||||
|
||||
if not my_assignment or not target_assignment:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Both participants must have active assignments to swap"
|
||||
)
|
||||
|
||||
# Check if there's already a pending request between these participants
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel).where(
|
||||
SwapRequestModel.event_id == event.id,
|
||||
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
|
||||
or_(
|
||||
and_(
|
||||
SwapRequestModel.from_participant_id == participant.id,
|
||||
SwapRequestModel.to_participant_id == target.id,
|
||||
),
|
||||
and_(
|
||||
SwapRequestModel.from_participant_id == target.id,
|
||||
SwapRequestModel.to_participant_id == participant.id,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A pending swap request already exists between you and this participant"
|
||||
)
|
||||
|
||||
# Create swap request
|
||||
swap_request = SwapRequestModel(
|
||||
event_id=event.id,
|
||||
from_participant_id=participant.id,
|
||||
to_participant_id=target.id,
|
||||
from_assignment_id=my_assignment.id,
|
||||
to_assignment_id=target_assignment.id,
|
||||
status=SwapRequestStatus.PENDING.value,
|
||||
)
|
||||
db.add(swap_request)
|
||||
await db.commit()
|
||||
await db.refresh(swap_request)
|
||||
|
||||
# Load relationships for response
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel)
|
||||
.options(
|
||||
selectinload(SwapRequestModel.from_participant).selectinload(Participant.user),
|
||||
selectinload(SwapRequestModel.to_participant).selectinload(Participant.user),
|
||||
selectinload(SwapRequestModel.from_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
selectinload(SwapRequestModel.to_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
)
|
||||
.where(SwapRequestModel.id == swap_request.id)
|
||||
)
|
||||
swap_request = result.scalar_one()
|
||||
|
||||
return build_swap_request_response(swap_request)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/swap-requests", response_model=MySwapRequests)
|
||||
async def get_my_swap_requests(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get current user's incoming and outgoing swap requests"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active swap event
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event or event.type != EventType.SWAP.value:
|
||||
return MySwapRequests(incoming=[], outgoing=[])
|
||||
|
||||
# Get all pending requests for this event involving this participant
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel)
|
||||
.options(
|
||||
selectinload(SwapRequestModel.from_participant).selectinload(Participant.user),
|
||||
selectinload(SwapRequestModel.to_participant).selectinload(Participant.user),
|
||||
selectinload(SwapRequestModel.from_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
selectinload(SwapRequestModel.to_assignment)
|
||||
.selectinload(Assignment.challenge)
|
||||
.selectinload(Challenge.game),
|
||||
)
|
||||
.where(
|
||||
SwapRequestModel.event_id == event.id,
|
||||
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
|
||||
or_(
|
||||
SwapRequestModel.from_participant_id == participant.id,
|
||||
SwapRequestModel.to_participant_id == participant.id,
|
||||
)
|
||||
)
|
||||
.order_by(SwapRequestModel.created_at.desc())
|
||||
)
|
||||
requests = result.scalars().all()
|
||||
|
||||
incoming = []
|
||||
outgoing = []
|
||||
for req in requests:
|
||||
response = build_swap_request_response(req)
|
||||
if req.to_participant_id == participant.id:
|
||||
incoming.append(response)
|
||||
else:
|
||||
outgoing.append(response)
|
||||
|
||||
return MySwapRequests(incoming=incoming, outgoing=outgoing)
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/swap-requests/{request_id}/accept", response_model=MessageResponse)
|
||||
async def accept_swap_request(
|
||||
marathon_id: int,
|
||||
request_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Accept a swap request (performs the actual swap)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active swap event
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event or event.type != EventType.SWAP.value:
|
||||
raise HTTPException(status_code=400, detail="No active swap event")
|
||||
|
||||
# Get the swap request
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel)
|
||||
.options(
|
||||
selectinload(SwapRequestModel.from_participant).selectinload(Participant.user),
|
||||
selectinload(SwapRequestModel.to_participant),
|
||||
selectinload(SwapRequestModel.from_assignment),
|
||||
selectinload(SwapRequestModel.to_assignment),
|
||||
)
|
||||
.where(
|
||||
SwapRequestModel.id == request_id,
|
||||
SwapRequestModel.event_id == event.id,
|
||||
)
|
||||
)
|
||||
swap_request = result.scalar_one_or_none()
|
||||
if not swap_request:
|
||||
raise HTTPException(status_code=404, detail="Swap request not found")
|
||||
|
||||
# Check that current user is the target
|
||||
if swap_request.to_participant_id != participant.id:
|
||||
raise HTTPException(status_code=403, detail="You can only accept requests sent to you")
|
||||
|
||||
if swap_request.status != SwapRequestStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="This request is no longer pending")
|
||||
|
||||
# Verify both assignments are still active
|
||||
result = await db.execute(
|
||||
select(Assignment).where(Assignment.id == swap_request.from_assignment_id)
|
||||
)
|
||||
from_assignment = result.scalar_one_or_none()
|
||||
|
||||
result = await db.execute(
|
||||
select(Assignment).where(Assignment.id == swap_request.to_assignment_id)
|
||||
)
|
||||
to_assignment = result.scalar_one_or_none()
|
||||
|
||||
if not from_assignment or not to_assignment:
|
||||
swap_request.status = SwapRequestStatus.CANCELLED.value
|
||||
swap_request.responded_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=400, detail="One or both assignments no longer exist")
|
||||
|
||||
if from_assignment.status != AssignmentStatus.ACTIVE.value or to_assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
swap_request.status = SwapRequestStatus.CANCELLED.value
|
||||
swap_request.responded_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=400, detail="One or both assignments are no longer active")
|
||||
|
||||
# Perform the swap
|
||||
from_challenge_id = from_assignment.challenge_id
|
||||
from_assignment.challenge_id = to_assignment.challenge_id
|
||||
to_assignment.challenge_id = from_challenge_id
|
||||
|
||||
# Update request status
|
||||
swap_request.status = SwapRequestStatus.ACCEPTED.value
|
||||
swap_request.responded_at = datetime.utcnow()
|
||||
|
||||
# Cancel any other pending requests involving these participants in this event
|
||||
await db.execute(
|
||||
select(SwapRequestModel)
|
||||
.where(
|
||||
SwapRequestModel.event_id == event.id,
|
||||
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
|
||||
SwapRequestModel.id != request_id,
|
||||
or_(
|
||||
SwapRequestModel.from_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
|
||||
SwapRequestModel.to_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
|
||||
)
|
||||
)
|
||||
)
|
||||
# Update those to cancelled
|
||||
from sqlalchemy import update
|
||||
await db.execute(
|
||||
update(SwapRequestModel)
|
||||
.where(
|
||||
SwapRequestModel.event_id == event.id,
|
||||
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
|
||||
SwapRequestModel.id != request_id,
|
||||
or_(
|
||||
SwapRequestModel.from_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
|
||||
SwapRequestModel.to_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
|
||||
)
|
||||
)
|
||||
.values(status=SwapRequestStatus.CANCELLED.value, responded_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
# Log activity
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.SWAP.value,
|
||||
data={
|
||||
"swapped_with_user_id": swap_request.from_participant.user_id,
|
||||
"swapped_with_nickname": swap_request.from_participant.user.nickname,
|
||||
},
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Swap completed successfully!")
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/swap-requests/{request_id}/decline", response_model=MessageResponse)
|
||||
async def decline_swap_request(
|
||||
marathon_id: int,
|
||||
request_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Decline a swap request"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active swap event (allow declining even if event ended)
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
|
||||
# Get the swap request
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel).where(SwapRequestModel.id == request_id)
|
||||
)
|
||||
swap_request = result.scalar_one_or_none()
|
||||
if not swap_request:
|
||||
raise HTTPException(status_code=404, detail="Swap request not found")
|
||||
|
||||
# Check that current user is the target
|
||||
if swap_request.to_participant_id != participant.id:
|
||||
raise HTTPException(status_code=403, detail="You can only decline requests sent to you")
|
||||
|
||||
if swap_request.status != SwapRequestStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="This request is no longer pending")
|
||||
|
||||
# Update status
|
||||
swap_request.status = SwapRequestStatus.DECLINED.value
|
||||
swap_request.responded_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Swap request declined")
|
||||
|
||||
|
||||
@router.delete("/marathons/{marathon_id}/swap-requests/{request_id}", response_model=MessageResponse)
|
||||
async def cancel_swap_request(
|
||||
marathon_id: int,
|
||||
request_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Cancel your own outgoing swap request"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Get the swap request
|
||||
result = await db.execute(
|
||||
select(SwapRequestModel).where(SwapRequestModel.id == request_id)
|
||||
)
|
||||
swap_request = result.scalar_one_or_none()
|
||||
if not swap_request:
|
||||
raise HTTPException(status_code=404, detail="Swap request not found")
|
||||
|
||||
# Check that current user is the sender
|
||||
if swap_request.from_participant_id != participant.id:
|
||||
raise HTTPException(status_code=403, detail="You can only cancel your own requests")
|
||||
|
||||
if swap_request.status != SwapRequestStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="This request is no longer pending")
|
||||
|
||||
# Update status
|
||||
swap_request.status = SwapRequestStatus.CANCELLED.value
|
||||
swap_request.responded_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Swap request cancelled")
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/rematch/{assignment_id}", response_model=MessageResponse)
|
||||
async def rematch_assignment(
|
||||
marathon_id: int,
|
||||
assignment_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Retry a dropped assignment (during rematch event)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active rematch event
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event or event.type != EventType.REMATCH.value:
|
||||
raise HTTPException(status_code=400, detail="No active rematch event")
|
||||
|
||||
# Check no current active assignment
|
||||
result = await db.execute(
|
||||
select(Assignment).where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
||||
|
||||
# Get the dropped assignment
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(selectinload(Assignment.challenge))
|
||||
.where(
|
||||
Assignment.id == assignment_id,
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.DROPPED.value,
|
||||
)
|
||||
)
|
||||
dropped = 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)
|
||||
new_assignment = Assignment(
|
||||
participant_id=participant.id,
|
||||
challenge_id=dropped.challenge_id,
|
||||
status=AssignmentStatus.ACTIVE.value,
|
||||
event_type=EventType.REMATCH.value,
|
||||
)
|
||||
db.add(new_assignment)
|
||||
|
||||
# Log activity
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.REMATCH.value,
|
||||
data={
|
||||
"challenge": dropped.challenge.title,
|
||||
"original_assignment_id": assignment_id,
|
||||
},
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Rematch started! Complete for 50% points")
|
||||
|
||||
|
||||
class DroppedAssignmentResponse(BaseModel):
|
||||
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])
|
||||
async def get_swap_candidates(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get participants with active assignments available for swap (during swap event)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check active swap event
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event or event.type != EventType.SWAP.value:
|
||||
raise HTTPException(status_code=400, detail="No active swap event")
|
||||
|
||||
# Get all participants except current user with active assignments
|
||||
from app.models import Game
|
||||
result = await db.execute(
|
||||
select(Participant, Assignment, Challenge, Game)
|
||||
.join(Assignment, Assignment.participant_id == Participant.id)
|
||||
.join(Challenge, Assignment.challenge_id == Challenge.id)
|
||||
.join(Game, Challenge.game_id == Game.id)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(
|
||||
Participant.marathon_id == marathon_id,
|
||||
Participant.id != participant.id,
|
||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
SwapCandidate(
|
||||
participant_id=p.id,
|
||||
user=UserPublic(
|
||||
id=p.user.id,
|
||||
login=p.user.login,
|
||||
nickname=p.user.nickname,
|
||||
avatar_url=None,
|
||||
role=p.user.role,
|
||||
created_at=p.user.created_at,
|
||||
),
|
||||
challenge_title=challenge.title,
|
||||
challenge_description=challenge.description,
|
||||
challenge_points=challenge.points,
|
||||
challenge_difficulty=challenge.difficulty,
|
||||
game_title=game.title,
|
||||
)
|
||||
for p, assignment, challenge, game in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/common-enemy-leaderboard", response_model=list[CommonEnemyLeaderboard])
|
||||
async def get_common_enemy_leaderboard(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get leaderboard for common enemy event (who completed the challenge)"""
|
||||
await get_marathon_or_404(db, marathon_id)
|
||||
await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Get active common enemy event
|
||||
event = await event_service.get_active_event(db, marathon_id)
|
||||
if not event or event.type != EventType.COMMON_ENEMY.value:
|
||||
return []
|
||||
|
||||
# Get completions from event data
|
||||
data = event.data or {}
|
||||
completions = data.get("completions", [])
|
||||
|
||||
if not completions:
|
||||
return []
|
||||
|
||||
# Get user info for all participants who completed
|
||||
user_ids = [c["user_id"] for c in completions]
|
||||
result = await db.execute(
|
||||
select(User).where(User.id.in_(user_ids))
|
||||
)
|
||||
users_by_id = {u.id: u for u in result.scalars().all()}
|
||||
|
||||
# Build leaderboard
|
||||
leaderboard = []
|
||||
for completion in completions:
|
||||
user = users_by_id.get(completion["user_id"])
|
||||
if user:
|
||||
leaderboard.append(
|
||||
CommonEnemyLeaderboard(
|
||||
participant_id=completion["participant_id"],
|
||||
user=UserPublic(
|
||||
id=user.id,
|
||||
login=user.login,
|
||||
nickname=user.nickname,
|
||||
avatar_url=None,
|
||||
role=user.role,
|
||||
created_at=user.created_at,
|
||||
),
|
||||
completed_at=completion.get("completed_at"),
|
||||
rank=completion.get("rank"),
|
||||
bonus_points=COMMON_ENEMY_BONUSES.get(completion.get("rank", 0), 0),
|
||||
)
|
||||
)
|
||||
|
||||
return leaderboard
|
||||
@@ -170,6 +170,7 @@ async def create_marathon(
|
||||
invite_code=marathon.invite_code,
|
||||
is_public=marathon.is_public,
|
||||
game_proposal_mode=marathon.game_proposal_mode,
|
||||
auto_events_enabled=marathon.auto_events_enabled,
|
||||
start_date=marathon.start_date,
|
||||
end_date=marathon.end_date,
|
||||
participants_count=1,
|
||||
@@ -206,6 +207,7 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
|
||||
invite_code=marathon.invite_code,
|
||||
is_public=marathon.is_public,
|
||||
game_proposal_mode=marathon.game_proposal_mode,
|
||||
auto_events_enabled=marathon.auto_events_enabled,
|
||||
start_date=marathon.start_date,
|
||||
end_date=marathon.end_date,
|
||||
participants_count=participants_count,
|
||||
@@ -240,6 +242,8 @@ async def update_marathon(
|
||||
marathon.is_public = data.is_public
|
||||
if data.game_proposal_mode is not None:
|
||||
marathon.game_proposal_mode = data.game_proposal_mode
|
||||
if data.auto_events_enabled is not None:
|
||||
marathon.auto_events_enabled = data.auto_events_enabled
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -10,13 +10,15 @@ from app.api.deps import DbSession, CurrentUser
|
||||
from app.core.config import settings
|
||||
from app.models import (
|
||||
Marathon, MarathonStatus, Game, Challenge, Participant,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType
|
||||
Assignment, AssignmentStatus, Activity, ActivityType,
|
||||
EventType, Difficulty
|
||||
)
|
||||
from app.schemas import (
|
||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
|
||||
)
|
||||
from app.services.points import PointsService
|
||||
from app.services.events import event_service
|
||||
|
||||
router = APIRouter(tags=["wheel"])
|
||||
|
||||
@@ -69,46 +71,91 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
if active:
|
||||
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
||||
|
||||
# Get all games with challenges
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(selectinload(Game.challenges))
|
||||
.where(Game.marathon_id == marathon_id)
|
||||
)
|
||||
games = [g for g in result.scalars().all() if g.challenges]
|
||||
# Check active event
|
||||
active_event = await event_service.get_active_event(db, marathon_id)
|
||||
|
||||
if not games:
|
||||
raise HTTPException(status_code=400, detail="No games with challenges available")
|
||||
game = None
|
||||
challenge = None
|
||||
|
||||
# Random selection
|
||||
game = random.choice(games)
|
||||
challenge = random.choice(game.challenges)
|
||||
# Handle special event cases
|
||||
if active_event:
|
||||
if active_event.type == EventType.JACKPOT.value:
|
||||
# Jackpot: Get hard challenge only
|
||||
challenge = await event_service.get_random_hard_challenge(db, marathon_id)
|
||||
if challenge:
|
||||
# Load game for challenge
|
||||
result = await db.execute(
|
||||
select(Game).where(Game.id == challenge.game_id)
|
||||
)
|
||||
game = result.scalar_one_or_none()
|
||||
# Consume jackpot (one-time use)
|
||||
await event_service.consume_jackpot(db, active_event.id)
|
||||
|
||||
# Create assignment
|
||||
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
|
||||
if not game or not challenge:
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(selectinload(Game.challenges))
|
||||
.where(Game.marathon_id == marathon_id)
|
||||
)
|
||||
games = [g for g in result.scalars().all() if g.challenges]
|
||||
|
||||
if not games:
|
||||
raise HTTPException(status_code=400, detail="No games with challenges available")
|
||||
|
||||
game = random.choice(games)
|
||||
challenge = random.choice(game.challenges)
|
||||
|
||||
# Create assignment (store event_type for jackpot multiplier on completion)
|
||||
assignment = Assignment(
|
||||
participant_id=participant.id,
|
||||
challenge_id=challenge.id,
|
||||
status=AssignmentStatus.ACTIVE.value,
|
||||
event_type=active_event.type if active_event else None,
|
||||
)
|
||||
db.add(assignment)
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"game": game.title,
|
||||
"challenge": challenge.title,
|
||||
}
|
||||
if active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.SPIN.value,
|
||||
data={
|
||||
"game": game.title,
|
||||
"challenge": challenge.title,
|
||||
},
|
||||
data=activity_data,
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(assignment)
|
||||
|
||||
# Calculate drop penalty
|
||||
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count)
|
||||
# Calculate drop penalty (considers active event for double_risk)
|
||||
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
|
||||
|
||||
# Get challenges count (avoid lazy loading in async context)
|
||||
challenges_count = 0
|
||||
if 'challenges' in game.__dict__:
|
||||
challenges_count = len(game.challenges)
|
||||
else:
|
||||
challenges_count = await db.scalar(
|
||||
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
||||
)
|
||||
|
||||
return SpinResult(
|
||||
assignment_id=assignment.id,
|
||||
@@ -119,7 +166,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
download_url=game.download_url,
|
||||
genre=game.genre,
|
||||
added_by=None,
|
||||
challenges_count=len(game.challenges),
|
||||
challenges_count=challenges_count,
|
||||
created_at=game.created_at,
|
||||
),
|
||||
challenge=ChallengeResponse(
|
||||
@@ -246,9 +293,41 @@ async def complete_assignment(
|
||||
participant = assignment.participant
|
||||
challenge = assignment.challenge
|
||||
|
||||
total_points, streak_bonus = points_service.calculate_completion_points(
|
||||
challenge.points, participant.current_streak
|
||||
# Get marathon_id for activity and event check
|
||||
result = await db.execute(
|
||||
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
|
||||
)
|
||||
full_challenge = result.scalar_one()
|
||||
marathon_id = full_challenge.game.marathon_id
|
||||
|
||||
# Check active event for point multipliers
|
||||
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 other events: use the currently active event
|
||||
effective_event = active_event
|
||||
|
||||
# Handle assignment-level event types (jackpot, rematch)
|
||||
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]:
|
||||
# Create a mock event object for point calculation
|
||||
class MockEvent:
|
||||
def __init__(self, event_type):
|
||||
self.type = event_type
|
||||
effective_event = MockEvent(assignment.event_type)
|
||||
|
||||
total_points, streak_bonus, event_bonus = points_service.calculate_completion_points(
|
||||
challenge.points, participant.current_streak, effective_event
|
||||
)
|
||||
|
||||
# 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 += common_enemy_bonus
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.COMPLETED.value
|
||||
@@ -261,25 +340,53 @@ async def complete_assignment(
|
||||
participant.current_streak += 1
|
||||
participant.drop_count = 0 # Reset drop counter on success
|
||||
|
||||
# Get marathon_id for activity
|
||||
result = await db.execute(
|
||||
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
|
||||
)
|
||||
full_challenge = result.scalar_one()
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"challenge": challenge.title,
|
||||
"points": total_points,
|
||||
"streak": participant.current_streak,
|
||||
}
|
||||
# Log event info (use assignment's event_type for jackpot/rematch, active_event for others)
|
||||
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]:
|
||||
activity_data["event_type"] = assignment.event_type
|
||||
activity_data["event_bonus"] = event_bonus
|
||||
elif active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
activity_data["event_bonus"] = event_bonus
|
||||
if common_enemy_bonus:
|
||||
activity_data["common_enemy_bonus"] = common_enemy_bonus
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=full_challenge.game.marathon_id,
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.COMPLETE.value,
|
||||
data={
|
||||
"challenge": challenge.title,
|
||||
"points": total_points,
|
||||
"streak": participant.current_streak,
|
||||
},
|
||||
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:
|
||||
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||
event_end_activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id, # Last completer triggers the close
|
||||
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": [
|
||||
{
|
||||
"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)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return CompleteResult(
|
||||
@@ -314,9 +421,13 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
raise HTTPException(status_code=400, detail="Assignment is not active")
|
||||
|
||||
participant = assignment.participant
|
||||
marathon_id = assignment.challenge.game.marathon_id
|
||||
|
||||
# Calculate penalty
|
||||
penalty = points_service.calculate_drop_penalty(participant.drop_count)
|
||||
# Check active event for free drops (double_risk)
|
||||
active_event = await event_service.get_active_event(db, marathon_id)
|
||||
|
||||
# Calculate penalty (0 if double_risk event is active)
|
||||
penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
@@ -328,14 +439,20 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
participant.drop_count += 1
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"challenge": assignment.challenge.title,
|
||||
"penalty": penalty,
|
||||
}
|
||||
if active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
if active_event.type == EventType.DOUBLE_RISK.value:
|
||||
activity_data["free_drop"] = True
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=assignment.challenge.game.marathon_id,
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.DROP.value,
|
||||
data={
|
||||
"challenge": assignment.challenge.title,
|
||||
"penalty": penalty,
|
||||
},
|
||||
data=activity_data,
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user