Add shop
This commit is contained in:
@@ -4,6 +4,7 @@ from datetime import datetime
|
||||
from fastapi import Depends, HTTPException, status, Header
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -35,7 +36,16 @@ async def get_current_user(
|
||||
detail="Invalid token payload",
|
||||
)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == int(user_id))
|
||||
.options(
|
||||
selectinload(User.equipped_frame),
|
||||
selectinload(User.equipped_title),
|
||||
selectinload(User.equipped_name_color),
|
||||
selectinload(User.equipped_background),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -16,3 +16,4 @@ router.include_router(events.router)
|
||||
router.include_router(assignments.router)
|
||||
router.include_router(telegram.router)
|
||||
router.include_router(content.router)
|
||||
router.include_router(shop.router)
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||
from app.models import (
|
||||
User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
|
||||
User, UserRole, Marathon, MarathonStatus, CertificationStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
|
||||
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
|
||||
)
|
||||
from app.schemas import (
|
||||
@@ -37,6 +37,8 @@ class AdminMarathonResponse(BaseModel):
|
||||
start_date: str | None
|
||||
end_date: str | None
|
||||
created_at: str
|
||||
certification_status: str = "none"
|
||||
is_certified: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -219,7 +221,12 @@ async def list_marathons(
|
||||
|
||||
query = (
|
||||
select(Marathon)
|
||||
.options(selectinload(Marathon.creator))
|
||||
.options(
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_frame),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_title),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_background),
|
||||
)
|
||||
.order_by(Marathon.created_at.desc())
|
||||
)
|
||||
|
||||
@@ -248,6 +255,8 @@ async def list_marathons(
|
||||
start_date=marathon.start_date.isoformat() if marathon.start_date else None,
|
||||
end_date=marathon.end_date.isoformat() if marathon.end_date else None,
|
||||
created_at=marathon.created_at.isoformat(),
|
||||
certification_status=marathon.certification_status,
|
||||
is_certified=marathon.is_certified,
|
||||
))
|
||||
|
||||
return response
|
||||
@@ -1102,3 +1111,75 @@ async def resolve_dispute(
|
||||
return MessageResponse(
|
||||
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
||||
)
|
||||
|
||||
|
||||
# ============ Marathon Certification ============
|
||||
@router.post("/marathons/{marathon_id}/certify", response_model=MessageResponse)
|
||||
async def certify_marathon(
|
||||
request: Request,
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Certify (verify) a marathon. Admin only."""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
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")
|
||||
|
||||
if marathon.certification_status == CertificationStatus.CERTIFIED.value:
|
||||
raise HTTPException(status_code=400, detail="Marathon is already certified")
|
||||
|
||||
marathon.certification_status = CertificationStatus.CERTIFIED.value
|
||||
marathon.certified_at = datetime.utcnow()
|
||||
marathon.certified_by_id = current_user.id
|
||||
marathon.certification_rejection_reason = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, AdminActionType.MARATHON_CERTIFY.value,
|
||||
"marathon", marathon_id,
|
||||
{"title": marathon.title},
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return MessageResponse(message="Marathon certified successfully")
|
||||
|
||||
|
||||
@router.post("/marathons/{marathon_id}/revoke-certification", response_model=MessageResponse)
|
||||
async def revoke_marathon_certification(
|
||||
request: Request,
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Revoke certification from a marathon. Admin only."""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
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")
|
||||
|
||||
if marathon.certification_status != CertificationStatus.CERTIFIED.value:
|
||||
raise HTTPException(status_code=400, detail="Marathon is not certified")
|
||||
|
||||
marathon.certification_status = CertificationStatus.NONE.value
|
||||
marathon.certified_at = None
|
||||
marathon.certified_by_id = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, AdminActionType.MARATHON_REVOKE_CERTIFICATION.value,
|
||||
"marathon", marathon_id,
|
||||
{"title": marathon.title},
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return MessageResponse(message="Marathon certification revoked")
|
||||
|
||||
@@ -3,6 +3,7 @@ import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||
@@ -48,7 +49,16 @@ async def register(request: Request, data: UserRegister, db: DbSession):
|
||||
@limiter.limit("10/minute")
|
||||
async def login(request: Request, data: UserLogin, db: DbSession):
|
||||
# Find user
|
||||
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.login == data.login.lower())
|
||||
.options(
|
||||
selectinload(User.equipped_frame),
|
||||
selectinload(User.equipped_title),
|
||||
selectinload(User.equipped_name_color),
|
||||
selectinload(User.equipped_background),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(data.password, user.password_hash):
|
||||
@@ -147,7 +157,16 @@ async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession
|
||||
await db.commit()
|
||||
|
||||
# Get user
|
||||
result = await db.execute(select(User).where(User.id == session.user_id))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == session.user_id)
|
||||
.options(
|
||||
selectinload(User.equipped_frame),
|
||||
selectinload(User.equipped_title),
|
||||
selectinload(User.equipped_name_color),
|
||||
selectinload(User.equipped_background),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.models import Activity, Participant, Dispute, ActivityType
|
||||
from app.models import Activity, Participant, Dispute, ActivityType, User
|
||||
from app.models.dispute import DisputeStatus
|
||||
from app.schemas import FeedResponse, ActivityResponse, UserPublic
|
||||
|
||||
@@ -37,7 +37,12 @@ async def get_feed(
|
||||
# Get activities
|
||||
result = await db.execute(
|
||||
select(Activity)
|
||||
.options(selectinload(Activity.user))
|
||||
.options(
|
||||
selectinload(Activity.user).selectinload(User.equipped_frame),
|
||||
selectinload(Activity.user).selectinload(User.equipped_title),
|
||||
selectinload(Activity.user).selectinload(User.equipped_name_color),
|
||||
selectinload(Activity.user).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Activity.marathon_id == marathon_id)
|
||||
.order_by(Activity.created_at.desc())
|
||||
.limit(limit)
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.api.deps import (
|
||||
from app.core.config import settings
|
||||
from app.models import (
|
||||
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
|
||||
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant
|
||||
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User
|
||||
)
|
||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||
from app.schemas.assignment import AvailableGamesCount
|
||||
@@ -23,8 +23,14 @@ async def get_game_or_404(db, game_id: int) -> Game:
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(
|
||||
selectinload(Game.proposed_by),
|
||||
selectinload(Game.approved_by),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_background),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Game.id == game_id)
|
||||
)
|
||||
@@ -73,8 +79,14 @@ async def list_games(
|
||||
select(Game, func.count(Challenge.id).label("challenges_count"))
|
||||
.outerjoin(Challenge)
|
||||
.options(
|
||||
selectinload(Game.proposed_by),
|
||||
selectinload(Game.approved_by),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_background),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Game.marathon_id == marathon_id)
|
||||
.group_by(Game.id)
|
||||
@@ -106,8 +118,14 @@ async def list_pending_games(marathon_id: int, current_user: CurrentUser, db: Db
|
||||
select(Game, func.count(Challenge.id).label("challenges_count"))
|
||||
.outerjoin(Challenge)
|
||||
.options(
|
||||
selectinload(Game.proposed_by),
|
||||
selectinload(Game.approved_by),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_background),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_frame),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_title),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
|
||||
selectinload(Game.approved_by).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
|
||||
@@ -20,7 +20,7 @@ optional_auth = HTTPBearer(auto_error=False)
|
||||
from app.models import (
|
||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus,
|
||||
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
|
||||
)
|
||||
from app.schemas import (
|
||||
MarathonCreate,
|
||||
@@ -80,7 +80,12 @@ def generate_invite_code() -> str:
|
||||
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
||||
result = await db.execute(
|
||||
select(Marathon)
|
||||
.options(selectinload(Marathon.creator))
|
||||
.options(
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_frame),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_title),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
|
||||
selectinload(Marathon.creator).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Marathon.id == marathon_id)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
@@ -465,7 +470,12 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
|
||||
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.options(
|
||||
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||||
selectinload(Participant.user).selectinload(User.equipped_title),
|
||||
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||||
selectinload(Participant.user).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Participant.marathon_id == marathon_id)
|
||||
.order_by(Participant.joined_at)
|
||||
)
|
||||
@@ -504,7 +514,12 @@ async def set_participant_role(
|
||||
# Get participant
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.options(
|
||||
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||||
selectinload(Participant.user).selectinload(User.equipped_title),
|
||||
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||||
selectinload(Participant.user).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(
|
||||
Participant.marathon_id == marathon_id,
|
||||
Participant.user_id == user_id,
|
||||
@@ -569,7 +584,12 @@ async def get_leaderboard(
|
||||
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.options(
|
||||
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||||
selectinload(Participant.user).selectinload(User.equipped_title),
|
||||
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||||
selectinload(Participant.user).selectinload(User.equipped_background),
|
||||
)
|
||||
.where(Participant.marathon_id == marathon_id)
|
||||
.order_by(Participant.total_points.desc())
|
||||
)
|
||||
|
||||
631
backend/app/api/v1/shop.py
Normal file
631
backend/app/api/v1/shop.py
Normal file
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
Shop API endpoints - catalog, purchases, inventory, cosmetics, consumables
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession, require_participant, require_admin_with_2fa
|
||||
from app.models import (
|
||||
User, Marathon, Participant, Assignment, AssignmentStatus,
|
||||
ShopItem, UserInventory, CoinTransaction, ShopItemType,
|
||||
CertificationStatus,
|
||||
)
|
||||
from app.schemas import (
|
||||
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
|
||||
InventoryItemResponse, PurchaseRequest, PurchaseResponse,
|
||||
UseConsumableRequest, UseConsumableResponse,
|
||||
EquipItemRequest, EquipItemResponse,
|
||||
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
|
||||
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
|
||||
ConsumablesStatusResponse, MessageResponse,
|
||||
)
|
||||
from app.services.shop import shop_service
|
||||
from app.services.coins import coins_service
|
||||
from app.services.consumables import consumables_service
|
||||
|
||||
router = APIRouter(prefix="/shop", tags=["shop"])
|
||||
|
||||
|
||||
# === Catalog ===
|
||||
|
||||
@router.get("/items", response_model=list[ShopItemResponse])
|
||||
async def get_shop_items(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
item_type: str | None = None,
|
||||
include_unavailable: bool = False,
|
||||
):
|
||||
"""Get list of shop items"""
|
||||
items = await shop_service.get_available_items(db, item_type, include_unavailable)
|
||||
|
||||
# Get user's inventory to mark owned/equipped items
|
||||
user_inventory = await shop_service.get_user_inventory(db, current_user.id)
|
||||
owned_ids = {inv.item_id for inv in user_inventory}
|
||||
equipped_ids = {inv.item_id for inv in user_inventory if inv.equipped}
|
||||
|
||||
result = []
|
||||
for item in items:
|
||||
item_dict = ShopItemResponse.model_validate(item).model_dump()
|
||||
item_dict["is_owned"] = item.id in owned_ids
|
||||
item_dict["is_equipped"] = item.id in equipped_ids
|
||||
result.append(ShopItemResponse(**item_dict))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/items/{item_id}", response_model=ShopItemResponse)
|
||||
async def get_shop_item(
|
||||
item_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get single shop item by ID"""
|
||||
item = await shop_service.get_item_by_id(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
is_owned = await shop_service.check_user_owns_item(db, current_user.id, item_id)
|
||||
|
||||
# Check if equipped
|
||||
is_equipped = False
|
||||
if is_owned:
|
||||
inventory = await shop_service.get_user_inventory(db, current_user.id, item.item_type)
|
||||
is_equipped = any(inv.equipped and inv.item_id == item_id for inv in inventory)
|
||||
|
||||
response = ShopItemResponse.model_validate(item)
|
||||
response.is_owned = is_owned
|
||||
response.is_equipped = is_equipped
|
||||
return response
|
||||
|
||||
|
||||
# === Purchases ===
|
||||
|
||||
@router.post("/purchase", response_model=PurchaseResponse)
|
||||
async def purchase_item(
|
||||
data: PurchaseRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Purchase an item from the shop"""
|
||||
inv_item, total_cost = await shop_service.purchase_item(
|
||||
db, current_user, data.item_id, data.quantity
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
item = await shop_service.get_item_by_id(db, data.item_id)
|
||||
|
||||
return PurchaseResponse(
|
||||
success=True,
|
||||
item=ShopItemResponse.model_validate(item),
|
||||
quantity=data.quantity,
|
||||
total_cost=total_cost,
|
||||
new_balance=current_user.coins_balance,
|
||||
message=f"Successfully purchased {item.name} x{data.quantity}",
|
||||
)
|
||||
|
||||
|
||||
# === Inventory ===
|
||||
|
||||
@router.get("/inventory", response_model=list[InventoryItemResponse])
|
||||
async def get_my_inventory(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
item_type: str | None = None,
|
||||
):
|
||||
"""Get current user's inventory"""
|
||||
inventory = await shop_service.get_user_inventory(db, current_user.id, item_type)
|
||||
return [InventoryItemResponse.model_validate(inv) for inv in inventory]
|
||||
|
||||
|
||||
# === Equip/Unequip ===
|
||||
|
||||
@router.post("/equip", response_model=EquipItemResponse)
|
||||
async def equip_item(
|
||||
data: EquipItemRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Equip a cosmetic item from inventory"""
|
||||
item = await shop_service.equip_item(db, current_user, data.inventory_id)
|
||||
await db.commit()
|
||||
|
||||
return EquipItemResponse(
|
||||
success=True,
|
||||
item_type=item.item_type,
|
||||
equipped_item=ShopItemResponse.model_validate(item),
|
||||
message=f"Equipped {item.name}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/unequip/{item_type}", response_model=EquipItemResponse)
|
||||
async def unequip_item(
|
||||
item_type: str,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Unequip item of specified type"""
|
||||
valid_types = [ShopItemType.FRAME.value, ShopItemType.TITLE.value,
|
||||
ShopItemType.NAME_COLOR.value, ShopItemType.BACKGROUND.value]
|
||||
if item_type not in valid_types:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid item type: {item_type}")
|
||||
|
||||
await shop_service.unequip_item(db, current_user, item_type)
|
||||
await db.commit()
|
||||
|
||||
return EquipItemResponse(
|
||||
success=True,
|
||||
item_type=item_type,
|
||||
equipped_item=None,
|
||||
message=f"Unequipped {item_type}",
|
||||
)
|
||||
|
||||
|
||||
# === Consumables ===
|
||||
|
||||
@router.post("/use", response_model=UseConsumableResponse)
|
||||
async def use_consumable(
|
||||
data: UseConsumableRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Use a consumable item"""
|
||||
# Get marathon
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == data.marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
# Get participant
|
||||
participant = await require_participant(db, current_user.id, data.marathon_id)
|
||||
|
||||
# For skip and reroll, we need the assignment
|
||||
assignment = None
|
||||
if data.item_code in ["skip", "reroll"]:
|
||||
if not data.assignment_id:
|
||||
raise HTTPException(status_code=400, detail="assignment_id is required for skip/reroll")
|
||||
|
||||
result = await db.execute(
|
||||
select(Assignment).where(
|
||||
Assignment.id == data.assignment_id,
|
||||
Assignment.participant_id == participant.id,
|
||||
)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Use the consumable
|
||||
if data.item_code == "skip":
|
||||
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
|
||||
effect_description = "Assignment skipped without penalty"
|
||||
elif data.item_code == "shield":
|
||||
effect = await consumables_service.use_shield(db, current_user, participant, marathon)
|
||||
effect_description = "Shield activated - next drop will be free"
|
||||
elif data.item_code == "boost":
|
||||
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
|
||||
effect_description = f"Boost x{effect['multiplier']} activated until {effect['expires_at']}"
|
||||
elif data.item_code == "reroll":
|
||||
effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment)
|
||||
effect_description = "Assignment rerolled - you can spin again"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Get remaining quantity
|
||||
remaining = await consumables_service.get_consumable_count(db, current_user.id, data.item_code)
|
||||
|
||||
return UseConsumableResponse(
|
||||
success=True,
|
||||
item_code=data.item_code,
|
||||
remaining_quantity=remaining,
|
||||
effect_description=effect_description,
|
||||
effect_data=effect,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/consumables/{marathon_id}", response_model=ConsumablesStatusResponse)
|
||||
async def get_consumables_status(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get consumables status for participant in 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")
|
||||
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Get inventory counts
|
||||
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
|
||||
rerolls_available = await consumables_service.get_consumable_count(db, current_user.id, "reroll")
|
||||
|
||||
# Calculate remaining skips for this marathon
|
||||
skips_remaining = None
|
||||
if marathon.max_skips_per_participant is not None:
|
||||
skips_remaining = max(0, marathon.max_skips_per_participant - participant.skips_used)
|
||||
|
||||
return ConsumablesStatusResponse(
|
||||
skips_available=skips_available,
|
||||
skips_used=participant.skips_used,
|
||||
skips_remaining=skips_remaining,
|
||||
has_shield=participant.has_shield,
|
||||
has_active_boost=participant.has_active_boost,
|
||||
boost_multiplier=participant.active_boost_multiplier if participant.has_active_boost else None,
|
||||
boost_expires_at=participant.active_boost_expires_at if participant.has_active_boost else None,
|
||||
rerolls_available=rerolls_available,
|
||||
)
|
||||
|
||||
|
||||
# === Coins ===
|
||||
|
||||
@router.get("/balance", response_model=CoinsBalanceResponse)
|
||||
async def get_coins_balance(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get current user's coins balance with recent transactions"""
|
||||
result = await db.execute(
|
||||
select(CoinTransaction)
|
||||
.where(CoinTransaction.user_id == current_user.id)
|
||||
.order_by(CoinTransaction.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
transactions = result.scalars().all()
|
||||
|
||||
return CoinsBalanceResponse(
|
||||
balance=current_user.coins_balance,
|
||||
recent_transactions=[CoinTransactionResponse.model_validate(t) for t in transactions],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/transactions", response_model=list[CoinTransactionResponse])
|
||||
async def get_coin_transactions(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
):
|
||||
"""Get user's coin transaction history"""
|
||||
result = await db.execute(
|
||||
select(CoinTransaction)
|
||||
.where(CoinTransaction.user_id == current_user.id)
|
||||
.order_by(CoinTransaction.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(min(limit, 100))
|
||||
)
|
||||
transactions = result.scalars().all()
|
||||
return [CoinTransactionResponse.model_validate(t) for t in transactions]
|
||||
|
||||
|
||||
# === Certification (organizer endpoints) ===
|
||||
|
||||
@router.post("/certification/{marathon_id}/request", response_model=CertificationStatusResponse)
|
||||
async def request_certification(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Request certification for a marathon (organizer only)"""
|
||||
# Check user is organizer
|
||||
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")
|
||||
|
||||
if marathon.creator_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only the creator can request certification")
|
||||
|
||||
if marathon.certification_status != CertificationStatus.NONE.value:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Marathon already has certification status: {marathon.certification_status}"
|
||||
)
|
||||
|
||||
marathon.certification_status = CertificationStatus.PENDING.value
|
||||
marathon.certification_requested_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(marathon)
|
||||
|
||||
return CertificationStatusResponse(
|
||||
marathon_id=marathon.id,
|
||||
certification_status=marathon.certification_status,
|
||||
is_certified=marathon.is_certified,
|
||||
certification_requested_at=marathon.certification_requested_at,
|
||||
certified_at=marathon.certified_at,
|
||||
certified_by_nickname=None,
|
||||
rejection_reason=None,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/certification/{marathon_id}/request", response_model=MessageResponse)
|
||||
async def cancel_certification_request(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Cancel certification request (organizer only)"""
|
||||
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")
|
||||
|
||||
if marathon.creator_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only the creator can cancel certification request")
|
||||
|
||||
if marathon.certification_status != CertificationStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="No pending certification request to cancel")
|
||||
|
||||
marathon.certification_status = CertificationStatus.NONE.value
|
||||
marathon.certification_requested_at = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Certification request cancelled")
|
||||
|
||||
|
||||
@router.get("/certification/{marathon_id}", response_model=CertificationStatusResponse)
|
||||
async def get_certification_status(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get certification status of a marathon"""
|
||||
result = await db.execute(
|
||||
select(Marathon)
|
||||
.options(selectinload(Marathon.certified_by))
|
||||
.where(Marathon.id == marathon_id)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
if not marathon:
|
||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||
|
||||
return CertificationStatusResponse(
|
||||
marathon_id=marathon.id,
|
||||
certification_status=marathon.certification_status,
|
||||
is_certified=marathon.is_certified,
|
||||
certification_requested_at=marathon.certification_requested_at,
|
||||
certified_at=marathon.certified_at,
|
||||
certified_by_nickname=marathon.certified_by.nickname if marathon.certified_by else None,
|
||||
rejection_reason=marathon.certification_rejection_reason,
|
||||
)
|
||||
|
||||
|
||||
# === Admin endpoints ===
|
||||
|
||||
@router.get("/admin/items", response_model=list[ShopItemResponse])
|
||||
async def admin_get_all_items(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get all shop items including inactive (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
items = await shop_service.get_available_items(db, include_unavailable=True)
|
||||
return [ShopItemResponse.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.post("/admin/items", response_model=ShopItemResponse)
|
||||
async def admin_create_item(
|
||||
data: ShopItemCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Create a new shop item (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Check code uniqueness
|
||||
existing = await shop_service.get_item_by_code(db, data.code)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"Item with code '{data.code}' already exists")
|
||||
|
||||
item = ShopItem(
|
||||
item_type=data.item_type,
|
||||
code=data.code,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
price=data.price,
|
||||
rarity=data.rarity,
|
||||
asset_data=data.asset_data,
|
||||
is_active=data.is_active,
|
||||
available_from=data.available_from,
|
||||
available_until=data.available_until,
|
||||
stock_limit=data.stock_limit,
|
||||
stock_remaining=data.stock_limit, # Initialize remaining = limit
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return ShopItemResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.put("/admin/items/{item_id}", response_model=ShopItemResponse)
|
||||
async def admin_update_item(
|
||||
item_id: int,
|
||||
data: ShopItemUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Update a shop item (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
item = await shop_service.get_item_by_id(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
# Update fields
|
||||
if data.name is not None:
|
||||
item.name = data.name
|
||||
if data.description is not None:
|
||||
item.description = data.description
|
||||
if data.price is not None:
|
||||
item.price = data.price
|
||||
if data.rarity is not None:
|
||||
item.rarity = data.rarity
|
||||
if data.asset_data is not None:
|
||||
item.asset_data = data.asset_data
|
||||
if data.is_active is not None:
|
||||
item.is_active = data.is_active
|
||||
if data.available_from is not None:
|
||||
item.available_from = data.available_from
|
||||
if data.available_until is not None:
|
||||
item.available_until = data.available_until
|
||||
if data.stock_limit is not None:
|
||||
# If increasing limit, also increase remaining
|
||||
if item.stock_limit is not None and data.stock_limit > item.stock_limit:
|
||||
diff = data.stock_limit - item.stock_limit
|
||||
item.stock_remaining = (item.stock_remaining or 0) + diff
|
||||
item.stock_limit = data.stock_limit
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return ShopItemResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/admin/items/{item_id}", response_model=MessageResponse)
|
||||
async def admin_delete_item(
|
||||
item_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Delete a shop item (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
item = await shop_service.get_item_by_id(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message=f"Item '{item.name}' deleted")
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/coins/grant", response_model=MessageResponse)
|
||||
async def admin_grant_coins(
|
||||
user_id: int,
|
||||
data: AdminCoinsRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Grant coins to a user (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
await coins_service.admin_grant_coins(db, user, data.amount, data.reason, current_user.id)
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message=f"Granted {data.amount} coins to {user.nickname}")
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/coins/deduct", response_model=MessageResponse)
|
||||
async def admin_deduct_coins(
|
||||
user_id: int,
|
||||
data: AdminCoinsRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Deduct coins from a user (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
success = await coins_service.admin_deduct_coins(db, user, data.amount, data.reason, current_user.id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="User doesn't have enough coins")
|
||||
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message=f"Deducted {data.amount} coins from {user.nickname}")
|
||||
|
||||
|
||||
@router.get("/admin/certification/pending", response_model=list[dict])
|
||||
async def admin_get_pending_certifications(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get list of marathons pending certification (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
result = await db.execute(
|
||||
select(Marathon)
|
||||
.options(selectinload(Marathon.creator))
|
||||
.where(Marathon.certification_status == CertificationStatus.PENDING.value)
|
||||
.order_by(Marathon.certification_requested_at.asc())
|
||||
)
|
||||
marathons = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"title": m.title,
|
||||
"creator_nickname": m.creator.nickname,
|
||||
"status": m.status,
|
||||
"participants_count": len(m.participants) if m.participants else 0,
|
||||
"certification_requested_at": m.certification_requested_at,
|
||||
}
|
||||
for m in marathons
|
||||
]
|
||||
|
||||
|
||||
@router.post("/admin/certification/{marathon_id}/review", response_model=CertificationStatusResponse)
|
||||
async def admin_review_certification(
|
||||
marathon_id: int,
|
||||
data: CertificationReviewRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Approve or reject marathon certification (admin only)"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
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")
|
||||
|
||||
if marathon.certification_status != CertificationStatus.PENDING.value:
|
||||
raise HTTPException(status_code=400, detail="Marathon is not pending certification")
|
||||
|
||||
if data.approve:
|
||||
marathon.certification_status = CertificationStatus.CERTIFIED.value
|
||||
marathon.certified_at = datetime.utcnow()
|
||||
marathon.certified_by_id = current_user.id
|
||||
marathon.certification_rejection_reason = None
|
||||
else:
|
||||
if not data.rejection_reason:
|
||||
raise HTTPException(status_code=400, detail="Rejection reason is required")
|
||||
marathon.certification_status = CertificationStatus.REJECTED.value
|
||||
marathon.certification_rejection_reason = data.rejection_reason
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(marathon)
|
||||
|
||||
return CertificationStatusResponse(
|
||||
marathon_id=marathon.id,
|
||||
certification_status=marathon.certification_status,
|
||||
is_certified=marathon.is_certified,
|
||||
certification_requested_at=marathon.certification_requested_at,
|
||||
certified_at=marathon.certified_at,
|
||||
certified_by_nickname=current_user.nickname if data.approve else None,
|
||||
rejection_reason=marathon.certification_rejection_reason,
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.core.config import settings
|
||||
@@ -20,7 +21,16 @@ router = APIRouter(prefix="/users", tags=["users"])
|
||||
@router.get("/{user_id}", response_model=UserPublic)
|
||||
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||
"""Get user profile. Requires authentication."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user_id)
|
||||
.options(
|
||||
selectinload(User.equipped_frame),
|
||||
selectinload(User.equipped_title),
|
||||
selectinload(User.equipped_name_color),
|
||||
selectinload(User.equipped_background),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
@@ -239,7 +249,16 @@ async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser)
|
||||
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
|
||||
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||
"""Получить публичный профиль пользователя со статистикой. Requires authentication."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user_id)
|
||||
.options(
|
||||
selectinload(User.equipped_frame),
|
||||
selectinload(User.equipped_title),
|
||||
selectinload(User.equipped_name_color),
|
||||
selectinload(User.equipped_background),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
@@ -254,8 +273,14 @@ async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUse
|
||||
id=user.id,
|
||||
nickname=user.nickname,
|
||||
avatar_url=user.avatar_url,
|
||||
telegram_avatar_url=user.telegram_avatar_url,
|
||||
role=user.role,
|
||||
created_at=user.created_at,
|
||||
stats=stats,
|
||||
equipped_frame=user.equipped_frame,
|
||||
equipped_title=user.equipped_title,
|
||||
equipped_name_color=user.equipped_name_color,
|
||||
equipped_background=user.equipped_background,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ from app.schemas.game import PlaythroughInfo
|
||||
from app.services.points import PointsService
|
||||
from app.services.events import event_service
|
||||
from app.services.storage import storage_service
|
||||
from app.services.coins import coins_service
|
||||
from app.services.consumables import consumables_service
|
||||
from app.api.v1.games import get_available_games_for_participant
|
||||
|
||||
router = APIRouter(tags=["wheel"])
|
||||
@@ -584,6 +586,11 @@ async def complete_assignment(
|
||||
)
|
||||
total_points += bonus_points
|
||||
|
||||
# Apply boost multiplier from consumable
|
||||
boost_multiplier = consumables_service.get_active_boost_multiplier(participant)
|
||||
if boost_multiplier > 1.0:
|
||||
total_points = int(total_points * boost_multiplier)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.COMPLETED.value
|
||||
assignment.points_earned = total_points
|
||||
@@ -595,6 +602,15 @@ async def complete_assignment(
|
||||
participant.current_streak += 1
|
||||
participant.drop_count = 0
|
||||
|
||||
# Get marathon and award coins if certified
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one()
|
||||
coins_earned = 0
|
||||
if marathon.is_certified:
|
||||
coins_earned = await coins_service.award_playthrough_coins(
|
||||
db, current_user, participant, marathon, total_points, assignment.id
|
||||
)
|
||||
|
||||
# Check if this is a redo of a previously disputed assignment
|
||||
is_redo = (
|
||||
assignment.dispute is not None and
|
||||
@@ -613,6 +629,10 @@ async def complete_assignment(
|
||||
}
|
||||
if is_redo:
|
||||
activity_data["is_redo"] = True
|
||||
if boost_multiplier > 1.0:
|
||||
activity_data["boost_multiplier"] = boost_multiplier
|
||||
if coins_earned > 0:
|
||||
activity_data["coins_earned"] = coins_earned
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
@@ -635,6 +655,7 @@ async def complete_assignment(
|
||||
streak_bonus=streak_bonus,
|
||||
total_points=participant.total_points,
|
||||
new_streak=participant.current_streak,
|
||||
coins_earned=coins_earned,
|
||||
)
|
||||
|
||||
# Regular challenge completion
|
||||
@@ -669,6 +690,11 @@ async def complete_assignment(
|
||||
total_points += common_enemy_bonus
|
||||
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
|
||||
|
||||
# Apply boost multiplier from consumable
|
||||
boost_multiplier = consumables_service.get_active_boost_multiplier(participant)
|
||||
if boost_multiplier > 1.0:
|
||||
total_points = int(total_points * boost_multiplier)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.COMPLETED.value
|
||||
assignment.points_earned = total_points
|
||||
@@ -680,6 +706,15 @@ async def complete_assignment(
|
||||
participant.current_streak += 1
|
||||
participant.drop_count = 0
|
||||
|
||||
# Get marathon and award coins if certified
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one()
|
||||
coins_earned = 0
|
||||
if marathon.is_certified:
|
||||
coins_earned = await coins_service.award_challenge_coins(
|
||||
db, current_user, participant, marathon, challenge.difficulty, assignment.id
|
||||
)
|
||||
|
||||
# Check if this is a redo of a previously disputed assignment
|
||||
is_redo = (
|
||||
assignment.dispute is not None and
|
||||
@@ -697,6 +732,10 @@ async def complete_assignment(
|
||||
}
|
||||
if is_redo:
|
||||
activity_data["is_redo"] = True
|
||||
if boost_multiplier > 1.0:
|
||||
activity_data["boost_multiplier"] = boost_multiplier
|
||||
if coins_earned > 0:
|
||||
activity_data["coins_earned"] = coins_earned
|
||||
if assignment.event_type == EventType.JACKPOT.value:
|
||||
activity_data["event_type"] = assignment.event_type
|
||||
activity_data["event_bonus"] = event_bonus
|
||||
@@ -761,6 +800,7 @@ async def complete_assignment(
|
||||
streak_bonus=streak_bonus,
|
||||
total_points=participant.total_points,
|
||||
new_streak=participant.current_streak,
|
||||
coins_earned=coins_earned,
|
||||
)
|
||||
|
||||
|
||||
@@ -801,6 +841,12 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
participant.drop_count, game.playthrough_points, None
|
||||
)
|
||||
|
||||
# Check for shield - if active, no penalty
|
||||
shield_used = False
|
||||
if consumables_service.consume_shield(participant):
|
||||
penalty = 0
|
||||
shield_used = True
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
@@ -823,16 +869,20 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
participant.drop_count += 1
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"game": game.title,
|
||||
"is_playthrough": True,
|
||||
"penalty": penalty,
|
||||
"lost_bonuses": completed_bonuses_count,
|
||||
}
|
||||
if shield_used:
|
||||
activity_data["shield_used"] = True
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.DROP.value,
|
||||
data={
|
||||
"game": game.title,
|
||||
"is_playthrough": True,
|
||||
"penalty": penalty,
|
||||
"lost_bonuses": completed_bonuses_count,
|
||||
},
|
||||
data=activity_data,
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
@@ -842,6 +892,7 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
penalty=penalty,
|
||||
total_points=participant.total_points,
|
||||
new_drop_count=participant.drop_count,
|
||||
shield_used=shield_used,
|
||||
)
|
||||
|
||||
# Regular challenge drop
|
||||
@@ -853,6 +904,12 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
# Calculate penalty (0 if double_risk event is active)
|
||||
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
|
||||
|
||||
# Check for shield - if active, no penalty
|
||||
shield_used = False
|
||||
if consumables_service.consume_shield(participant):
|
||||
penalty = 0
|
||||
shield_used = True
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
@@ -869,6 +926,8 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
"difficulty": assignment.challenge.difficulty,
|
||||
"penalty": penalty,
|
||||
}
|
||||
if shield_used:
|
||||
activity_data["shield_used"] = True
|
||||
if active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
if active_event.type == EventType.DOUBLE_RISK.value:
|
||||
@@ -888,6 +947,7 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
penalty=penalty,
|
||||
total_points=participant.total_points,
|
||||
new_drop_count=participant.drop_count,
|
||||
shield_used=shield_used,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user