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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
|
||||
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode, CertificationStatus
|
||||
from app.models.participant import Participant, ParticipantRole
|
||||
from app.models.game import Game, GameStatus, GameType
|
||||
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
|
||||
@@ -13,6 +13,10 @@ from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVo
|
||||
from app.models.admin_log import AdminLog, AdminActionType
|
||||
from app.models.admin_2fa import Admin2FASession
|
||||
from app.models.static_content import StaticContent
|
||||
from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType
|
||||
from app.models.inventory import UserInventory
|
||||
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
|
||||
from app.models.consumable_usage import ConsumableUsage
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -20,6 +24,7 @@ __all__ = [
|
||||
"Marathon",
|
||||
"MarathonStatus",
|
||||
"GameProposalMode",
|
||||
"CertificationStatus",
|
||||
"Participant",
|
||||
"ParticipantRole",
|
||||
"Game",
|
||||
@@ -49,4 +54,12 @@ __all__ = [
|
||||
"AdminActionType",
|
||||
"Admin2FASession",
|
||||
"StaticContent",
|
||||
"ShopItem",
|
||||
"ShopItemType",
|
||||
"ItemRarity",
|
||||
"ConsumableType",
|
||||
"UserInventory",
|
||||
"CoinTransaction",
|
||||
"CoinTransactionType",
|
||||
"ConsumableUsage",
|
||||
]
|
||||
|
||||
@@ -17,6 +17,8 @@ class AdminActionType(str, Enum):
|
||||
# Marathon actions
|
||||
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
||||
MARATHON_DELETE = "marathon_delete"
|
||||
MARATHON_CERTIFY = "marathon_certify"
|
||||
MARATHON_REVOKE_CERTIFICATION = "marathon_revoke_certification"
|
||||
|
||||
# Content actions
|
||||
CONTENT_UPDATE = "content_update"
|
||||
|
||||
41
backend/app/models/coin_transaction.py
Normal file
41
backend/app/models/coin_transaction.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class CoinTransactionType(str, Enum):
|
||||
CHALLENGE_COMPLETE = "challenge_complete"
|
||||
PLAYTHROUGH_COMPLETE = "playthrough_complete"
|
||||
MARATHON_WIN = "marathon_win"
|
||||
MARATHON_PLACE = "marathon_place"
|
||||
COMMON_ENEMY_BONUS = "common_enemy_bonus"
|
||||
PURCHASE = "purchase"
|
||||
REFUND = "refund"
|
||||
ADMIN_GRANT = "admin_grant"
|
||||
ADMIN_DEDUCT = "admin_deduct"
|
||||
|
||||
|
||||
class CoinTransaction(Base):
|
||||
__tablename__ = "coin_transactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
amount: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
transaction_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
reference_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="coin_transactions"
|
||||
)
|
||||
30
backend/app/models/consumable_usage.py
Normal file
30
backend/app/models/consumable_usage.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.shop import ShopItem
|
||||
from app.models.marathon import Marathon
|
||||
from app.models.assignment import Assignment
|
||||
|
||||
|
||||
class ConsumableUsage(Base):
|
||||
__tablename__ = "consumable_usages"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False)
|
||||
marathon_id: Mapped[int | None] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), nullable=True)
|
||||
assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True)
|
||||
used_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
effect_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User")
|
||||
item: Mapped["ShopItem"] = relationship("ShopItem")
|
||||
marathon: Mapped["Marathon | None"] = relationship("Marathon")
|
||||
assignment: Mapped["Assignment | None"] = relationship("Assignment")
|
||||
39
backend/app/models/inventory.py
Normal file
39
backend/app/models/inventory.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.shop import ShopItem
|
||||
|
||||
|
||||
class UserInventory(Base):
|
||||
__tablename__ = "user_inventory"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
quantity: Mapped[int] = mapped_column(Integer, default=1)
|
||||
equipped: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
purchased_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="inventory"
|
||||
)
|
||||
item: Mapped["ShopItem"] = relationship(
|
||||
"ShopItem",
|
||||
back_populates="inventory_items"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if item has expired"""
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return datetime.utcnow() > self.expires_at
|
||||
@@ -17,6 +17,13 @@ class GameProposalMode(str, Enum):
|
||||
ORGANIZER_ONLY = "organizer_only"
|
||||
|
||||
|
||||
class CertificationStatus(str, Enum):
|
||||
NONE = "none"
|
||||
PENDING = "pending"
|
||||
CERTIFIED = "certified"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class Marathon(Base):
|
||||
__tablename__ = "marathons"
|
||||
|
||||
@@ -35,12 +42,28 @@ class Marathon(Base):
|
||||
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Certification fields
|
||||
certification_status: Mapped[str] = mapped_column(String(20), default=CertificationStatus.NONE.value)
|
||||
certification_requested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
certified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
certified_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
certification_rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Shop/Consumables settings
|
||||
allow_skips: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
max_skips_per_participant: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
allow_consumables: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
creator: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="created_marathons",
|
||||
foreign_keys=[creator_id]
|
||||
)
|
||||
certified_by: Mapped["User | None"] = relationship(
|
||||
"User",
|
||||
foreign_keys=[certified_by_id]
|
||||
)
|
||||
participants: Mapped[list["Participant"]] = relationship(
|
||||
"Participant",
|
||||
back_populates="marathon",
|
||||
@@ -61,3 +84,7 @@ class Marathon(Base):
|
||||
back_populates="marathon",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_certified(self) -> bool:
|
||||
return self.certification_status == CertificationStatus.CERTIFIED.value
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean, Float
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -26,6 +26,15 @@ class Participant(Base):
|
||||
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
|
||||
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Shop: coins earned in this marathon
|
||||
coins_earned: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Shop: consumables state
|
||||
skips_used: Mapped[int] = mapped_column(Integer, default=0)
|
||||
active_boost_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
active_boost_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", back_populates="participations")
|
||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")
|
||||
@@ -38,3 +47,16 @@ class Participant(Base):
|
||||
@property
|
||||
def is_organizer(self) -> bool:
|
||||
return self.role == ParticipantRole.ORGANIZER.value
|
||||
|
||||
@property
|
||||
def has_active_boost(self) -> bool:
|
||||
"""Check if participant has an active boost"""
|
||||
if self.active_boost_multiplier is None or self.active_boost_expires_at is None:
|
||||
return False
|
||||
return datetime.utcnow() < self.active_boost_expires_at
|
||||
|
||||
def get_boost_multiplier(self) -> float:
|
||||
"""Get current boost multiplier (1.0 if no active boost)"""
|
||||
if self.has_active_boost:
|
||||
return self.active_boost_multiplier or 1.0
|
||||
return 1.0
|
||||
|
||||
81
backend/app/models/shop.py
Normal file
81
backend/app/models/shop.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, Text, DateTime, Integer, Boolean, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.inventory import UserInventory
|
||||
|
||||
|
||||
class ShopItemType(str, Enum):
|
||||
FRAME = "frame"
|
||||
TITLE = "title"
|
||||
NAME_COLOR = "name_color"
|
||||
BACKGROUND = "background"
|
||||
CONSUMABLE = "consumable"
|
||||
|
||||
|
||||
class ItemRarity(str, Enum):
|
||||
COMMON = "common"
|
||||
UNCOMMON = "uncommon"
|
||||
RARE = "rare"
|
||||
EPIC = "epic"
|
||||
LEGENDARY = "legendary"
|
||||
|
||||
|
||||
class ConsumableType(str, Enum):
|
||||
SKIP = "skip"
|
||||
SHIELD = "shield"
|
||||
BOOST = "boost"
|
||||
REROLL = "reroll"
|
||||
|
||||
|
||||
class ShopItem(Base):
|
||||
__tablename__ = "shop_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
item_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
price: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
rarity: Mapped[str] = mapped_column(String(20), default=ItemRarity.COMMON.value)
|
||||
asset_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
available_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
available_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
stock_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
stock_remaining: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
inventory_items: Mapped[list["UserInventory"]] = relationship(
|
||||
"UserInventory",
|
||||
back_populates="item"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""Check if item is currently available for purchase"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if self.available_from and self.available_from > now:
|
||||
return False
|
||||
|
||||
if self.available_until and self.available_until < now:
|
||||
return False
|
||||
|
||||
if self.stock_remaining is not None and self.stock_remaining <= 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_consumable(self) -> bool:
|
||||
return self.item_type == ShopItemType.CONSUMABLE.value
|
||||
@@ -2,9 +2,15 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.shop import ShopItem
|
||||
from app.models.inventory import UserInventory
|
||||
from app.models.coin_transaction import CoinTransaction
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
USER = "user"
|
||||
@@ -39,6 +45,15 @@ class User(Base):
|
||||
notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Shop: coins balance
|
||||
coins_balance: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Shop: equipped cosmetics
|
||||
equipped_frame_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||
equipped_title_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||
equipped_name_color_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||
equipped_background_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||
"Marathon",
|
||||
@@ -65,6 +80,32 @@ class User(Base):
|
||||
foreign_keys=[banned_by_id]
|
||||
)
|
||||
|
||||
# Shop relationships
|
||||
inventory: Mapped[list["UserInventory"]] = relationship(
|
||||
"UserInventory",
|
||||
back_populates="user"
|
||||
)
|
||||
coin_transactions: Mapped[list["CoinTransaction"]] = relationship(
|
||||
"CoinTransaction",
|
||||
back_populates="user"
|
||||
)
|
||||
equipped_frame: Mapped["ShopItem | None"] = relationship(
|
||||
"ShopItem",
|
||||
foreign_keys=[equipped_frame_id]
|
||||
)
|
||||
equipped_title: Mapped["ShopItem | None"] = relationship(
|
||||
"ShopItem",
|
||||
foreign_keys=[equipped_title_id]
|
||||
)
|
||||
equipped_name_color: Mapped["ShopItem | None"] = relationship(
|
||||
"ShopItem",
|
||||
foreign_keys=[equipped_name_color_id]
|
||||
)
|
||||
equipped_background: Mapped["ShopItem | None"] = relationship(
|
||||
"ShopItem",
|
||||
foreign_keys=[equipped_background_id]
|
||||
)
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
return self.role == UserRole.ADMIN.value
|
||||
|
||||
@@ -104,6 +104,27 @@ from app.schemas.admin import (
|
||||
LoginResponse,
|
||||
DashboardStats,
|
||||
)
|
||||
from app.schemas.shop import (
|
||||
ShopItemCreate,
|
||||
ShopItemUpdate,
|
||||
ShopItemResponse,
|
||||
InventoryItemResponse,
|
||||
PurchaseRequest,
|
||||
PurchaseResponse,
|
||||
UseConsumableRequest,
|
||||
UseConsumableResponse,
|
||||
EquipItemRequest,
|
||||
EquipItemResponse,
|
||||
CoinTransactionResponse,
|
||||
CoinsBalanceResponse,
|
||||
AdminCoinsRequest,
|
||||
UserCosmeticsResponse,
|
||||
CertificationRequestSchema,
|
||||
CertificationReviewRequest,
|
||||
CertificationStatusResponse,
|
||||
ConsumablesStatusResponse,
|
||||
)
|
||||
from app.schemas.user import ShopItemPublic
|
||||
|
||||
__all__ = [
|
||||
# User
|
||||
@@ -202,4 +223,24 @@ __all__ = [
|
||||
"TwoFactorVerifyRequest",
|
||||
"LoginResponse",
|
||||
"DashboardStats",
|
||||
# Shop
|
||||
"ShopItemCreate",
|
||||
"ShopItemUpdate",
|
||||
"ShopItemResponse",
|
||||
"ShopItemPublic",
|
||||
"InventoryItemResponse",
|
||||
"PurchaseRequest",
|
||||
"PurchaseResponse",
|
||||
"UseConsumableRequest",
|
||||
"UseConsumableResponse",
|
||||
"EquipItemRequest",
|
||||
"EquipItemResponse",
|
||||
"CoinTransactionResponse",
|
||||
"CoinsBalanceResponse",
|
||||
"AdminCoinsRequest",
|
||||
"UserCosmeticsResponse",
|
||||
"CertificationRequestSchema",
|
||||
"CertificationReviewRequest",
|
||||
"CertificationStatusResponse",
|
||||
"ConsumablesStatusResponse",
|
||||
]
|
||||
|
||||
@@ -77,12 +77,14 @@ class CompleteResult(BaseModel):
|
||||
streak_bonus: int
|
||||
total_points: int
|
||||
new_streak: int
|
||||
coins_earned: int = 0 # Coins earned (only in certified marathons)
|
||||
|
||||
|
||||
class DropResult(BaseModel):
|
||||
penalty: int
|
||||
total_points: int
|
||||
new_drop_count: int
|
||||
shield_used: bool = False # Whether shield consumable was used to prevent penalty
|
||||
|
||||
|
||||
class EventAssignmentResponse(BaseModel):
|
||||
|
||||
@@ -14,6 +14,10 @@ class MarathonCreate(MarathonBase):
|
||||
duration_days: int = Field(default=30, ge=1, le=365)
|
||||
is_public: bool = False
|
||||
game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$")
|
||||
# Shop/Consumables settings
|
||||
allow_skips: bool = True
|
||||
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
|
||||
allow_consumables: bool = True
|
||||
|
||||
|
||||
class MarathonUpdate(BaseModel):
|
||||
@@ -23,6 +27,10 @@ class MarathonUpdate(BaseModel):
|
||||
is_public: bool | None = None
|
||||
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
|
||||
auto_events_enabled: bool | None = None
|
||||
# Shop/Consumables settings
|
||||
allow_skips: bool | None = None
|
||||
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
|
||||
allow_consumables: bool | None = None
|
||||
|
||||
|
||||
class ParticipantInfo(BaseModel):
|
||||
@@ -32,6 +40,13 @@ class ParticipantInfo(BaseModel):
|
||||
current_streak: int
|
||||
drop_count: int
|
||||
joined_at: datetime
|
||||
# Shop: coins and consumables status
|
||||
coins_earned: int = 0
|
||||
skips_used: int = 0
|
||||
has_shield: bool = False
|
||||
has_active_boost: bool = False
|
||||
boost_multiplier: float | None = None
|
||||
boost_expires_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -56,6 +71,13 @@ class MarathonResponse(MarathonBase):
|
||||
games_count: int
|
||||
created_at: datetime
|
||||
my_participation: ParticipantInfo | None = None
|
||||
# Certification
|
||||
certification_status: str = "none"
|
||||
is_certified: bool = False
|
||||
# Shop/Consumables settings
|
||||
allow_skips: bool = True
|
||||
max_skips_per_participant: int | None = None
|
||||
allow_consumables: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -74,6 +96,8 @@ class MarathonListItem(BaseModel):
|
||||
participants_count: int
|
||||
start_date: datetime | None
|
||||
end_date: datetime | None
|
||||
# Certification badge
|
||||
is_certified: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
199
backend/app/schemas/shop.py
Normal file
199
backend/app/schemas/shop.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Pydantic schemas for Shop system
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any
|
||||
|
||||
|
||||
# === Shop Items ===
|
||||
|
||||
class ShopItemBase(BaseModel):
|
||||
"""Base schema for shop items"""
|
||||
item_type: str
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
price: int
|
||||
rarity: str = "common"
|
||||
asset_data: dict | None = None
|
||||
|
||||
|
||||
class ShopItemCreate(ShopItemBase):
|
||||
"""Schema for creating a shop item (admin)"""
|
||||
is_active: bool = True
|
||||
available_from: datetime | None = None
|
||||
available_until: datetime | None = None
|
||||
stock_limit: int | None = None
|
||||
|
||||
|
||||
class ShopItemUpdate(BaseModel):
|
||||
"""Schema for updating a shop item (admin)"""
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
price: int | None = Field(None, ge=1)
|
||||
rarity: str | None = None
|
||||
asset_data: dict | None = None
|
||||
is_active: bool | None = None
|
||||
available_from: datetime | None = None
|
||||
available_until: datetime | None = None
|
||||
stock_limit: int | None = None
|
||||
|
||||
|
||||
class ShopItemResponse(ShopItemBase):
|
||||
"""Schema for shop item response"""
|
||||
id: int
|
||||
is_active: bool
|
||||
available_from: datetime | None
|
||||
available_until: datetime | None
|
||||
stock_limit: int | None
|
||||
stock_remaining: int | None
|
||||
created_at: datetime
|
||||
is_available: bool # Computed property
|
||||
is_owned: bool = False # Set by API based on user
|
||||
is_equipped: bool = False # Set by API based on user
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# === Inventory ===
|
||||
|
||||
class InventoryItemResponse(BaseModel):
|
||||
"""Schema for user inventory item"""
|
||||
id: int
|
||||
item: ShopItemResponse
|
||||
quantity: int
|
||||
equipped: bool
|
||||
purchased_at: datetime
|
||||
expires_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# === Purchases ===
|
||||
|
||||
class PurchaseRequest(BaseModel):
|
||||
"""Schema for purchase request"""
|
||||
item_id: int
|
||||
quantity: int = Field(default=1, ge=1, le=10)
|
||||
|
||||
|
||||
class PurchaseResponse(BaseModel):
|
||||
"""Schema for purchase response"""
|
||||
success: bool
|
||||
item: ShopItemResponse
|
||||
quantity: int
|
||||
total_cost: int
|
||||
new_balance: int
|
||||
message: str
|
||||
|
||||
|
||||
# === Consumables ===
|
||||
|
||||
class UseConsumableRequest(BaseModel):
|
||||
"""Schema for using a consumable"""
|
||||
item_code: str # 'skip', 'shield', 'boost', 'reroll'
|
||||
marathon_id: int
|
||||
assignment_id: int | None = None # Required for skip and reroll
|
||||
|
||||
|
||||
class UseConsumableResponse(BaseModel):
|
||||
"""Schema for consumable use response"""
|
||||
success: bool
|
||||
item_code: str
|
||||
remaining_quantity: int
|
||||
effect_description: str
|
||||
effect_data: dict | None = None
|
||||
|
||||
|
||||
# === Equipment ===
|
||||
|
||||
class EquipItemRequest(BaseModel):
|
||||
"""Schema for equipping an item"""
|
||||
inventory_id: int
|
||||
|
||||
|
||||
class EquipItemResponse(BaseModel):
|
||||
"""Schema for equip response"""
|
||||
success: bool
|
||||
item_type: str
|
||||
equipped_item: ShopItemResponse | None
|
||||
message: str
|
||||
|
||||
|
||||
# === Coins ===
|
||||
|
||||
class CoinTransactionResponse(BaseModel):
|
||||
"""Schema for coin transaction"""
|
||||
id: int
|
||||
amount: int
|
||||
transaction_type: str
|
||||
description: str | None
|
||||
reference_type: str | None
|
||||
reference_id: int | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CoinsBalanceResponse(BaseModel):
|
||||
"""Schema for coins balance with recent transactions"""
|
||||
balance: int
|
||||
recent_transactions: list[CoinTransactionResponse]
|
||||
|
||||
|
||||
class AdminCoinsRequest(BaseModel):
|
||||
"""Schema for admin coin operations"""
|
||||
amount: int = Field(..., ge=1)
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
# === User Cosmetics ===
|
||||
|
||||
class UserCosmeticsResponse(BaseModel):
|
||||
"""Schema for user's equipped cosmetics"""
|
||||
frame: ShopItemResponse | None = None
|
||||
title: ShopItemResponse | None = None
|
||||
name_color: ShopItemResponse | None = None
|
||||
background: ShopItemResponse | None = None
|
||||
|
||||
|
||||
# === Certification ===
|
||||
|
||||
class CertificationRequestSchema(BaseModel):
|
||||
"""Schema for requesting marathon certification"""
|
||||
pass # No fields needed for now
|
||||
|
||||
|
||||
class CertificationReviewRequest(BaseModel):
|
||||
"""Schema for admin reviewing certification"""
|
||||
approve: bool
|
||||
rejection_reason: str | None = Field(None, max_length=1000)
|
||||
|
||||
|
||||
class CertificationStatusResponse(BaseModel):
|
||||
"""Schema for certification status"""
|
||||
marathon_id: int
|
||||
certification_status: str
|
||||
is_certified: bool
|
||||
certification_requested_at: datetime | None
|
||||
certified_at: datetime | None
|
||||
certified_by_nickname: str | None = None
|
||||
rejection_reason: str | None = None
|
||||
|
||||
|
||||
# === Consumables Status ===
|
||||
|
||||
class ConsumablesStatusResponse(BaseModel):
|
||||
"""Schema for participant's consumables status in a marathon"""
|
||||
skips_available: int # From inventory
|
||||
skips_used: int # In this marathon
|
||||
skips_remaining: int | None # Based on marathon limit
|
||||
has_shield: bool
|
||||
has_active_boost: bool
|
||||
boost_multiplier: float | None
|
||||
boost_expires_at: datetime | None
|
||||
rerolls_available: int # From inventory
|
||||
@@ -28,6 +28,19 @@ class UserUpdate(BaseModel):
|
||||
nickname: str | None = Field(None, min_length=2, max_length=50)
|
||||
|
||||
|
||||
class ShopItemPublic(BaseModel):
|
||||
"""Minimal shop item info for public display"""
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
item_type: str
|
||||
rarity: str
|
||||
asset_data: dict | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserPublic(UserBase):
|
||||
"""Public user info visible to other users - minimal data"""
|
||||
id: int
|
||||
@@ -35,6 +48,11 @@ class UserPublic(UserBase):
|
||||
role: str = "user"
|
||||
telegram_avatar_url: str | None = None # Only TG avatar is public
|
||||
created_at: datetime
|
||||
# Shop: equipped cosmetics (visible to others)
|
||||
equipped_frame: ShopItemPublic | None = None
|
||||
equipped_title: ShopItemPublic | None = None
|
||||
equipped_name_color: ShopItemPublic | None = None
|
||||
equipped_background: ShopItemPublic | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -51,6 +69,8 @@ class UserPrivate(UserPublic):
|
||||
notify_events: bool = True
|
||||
notify_disputes: bool = True
|
||||
notify_moderation: bool = True
|
||||
# Shop: coins balance (only visible to self)
|
||||
coins_balance: int = 0
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
@@ -82,8 +102,15 @@ class UserProfilePublic(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
avatar_url: str | None = None
|
||||
telegram_avatar_url: str | None = None
|
||||
role: str = "user"
|
||||
created_at: datetime
|
||||
stats: UserStats
|
||||
# Equipped cosmetics
|
||||
equipped_frame: ShopItemPublic | None = None
|
||||
equipped_title: ShopItemPublic | None = None
|
||||
equipped_name_color: ShopItemPublic | None = None
|
||||
equipped_background: ShopItemPublic | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
288
backend/app/services/coins.py
Normal file
288
backend/app/services/coins.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Coins Service - handles all coin-related operations
|
||||
|
||||
Coins are earned only in certified marathons and can be spent in the shop.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import User, Participant, Marathon, CoinTransaction, CoinTransactionType
|
||||
from app.models.challenge import Difficulty
|
||||
|
||||
|
||||
class CoinsService:
|
||||
"""Service for managing coin transactions and balances"""
|
||||
|
||||
# Coins awarded per challenge difficulty (only in certified marathons)
|
||||
CHALLENGE_COINS = {
|
||||
Difficulty.EASY.value: 5,
|
||||
Difficulty.MEDIUM.value: 12,
|
||||
Difficulty.HARD.value: 25,
|
||||
}
|
||||
|
||||
# Coins for playthrough = points * this ratio
|
||||
PLAYTHROUGH_COIN_RATIO = 0.05 # 5% of points
|
||||
|
||||
# Coins awarded for marathon placements
|
||||
MARATHON_PLACE_COINS = {
|
||||
1: 100, # 1st place
|
||||
2: 50, # 2nd place
|
||||
3: 30, # 3rd place
|
||||
}
|
||||
|
||||
# Bonus coins for Common Enemy event winners
|
||||
COMMON_ENEMY_BONUS_COINS = {
|
||||
1: 15, # First to complete
|
||||
2: 10, # Second
|
||||
3: 5, # Third
|
||||
}
|
||||
|
||||
async def award_challenge_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
difficulty: str,
|
||||
assignment_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award coins for completing a challenge.
|
||||
Only awards coins if marathon is certified.
|
||||
|
||||
Returns: number of coins awarded (0 if marathon not certified)
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = self.CHALLENGE_COINS.get(difficulty, 0)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
# Create transaction
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.CHALLENGE_COMPLETE.value,
|
||||
reference_type="assignment",
|
||||
reference_id=assignment_id,
|
||||
description=f"Challenge completion ({difficulty})",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
# Update balances
|
||||
user.coins_balance += coins
|
||||
participant.coins_earned += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def award_playthrough_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
points: int,
|
||||
assignment_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award coins for completing a playthrough.
|
||||
Coins = points * PLAYTHROUGH_COIN_RATIO
|
||||
|
||||
Returns: number of coins awarded (0 if marathon not certified)
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = int(points * self.PLAYTHROUGH_COIN_RATIO)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.PLAYTHROUGH_COMPLETE.value,
|
||||
reference_type="assignment",
|
||||
reference_id=assignment_id,
|
||||
description=f"Playthrough completion ({points} points)",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += coins
|
||||
participant.coins_earned += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def award_marathon_place(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
marathon: Marathon,
|
||||
place: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award coins for placing in a marathon (1st, 2nd, 3rd).
|
||||
|
||||
Returns: number of coins awarded (0 if not top 3 or not certified)
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = self.MARATHON_PLACE_COINS.get(place, 0)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.MARATHON_PLACE.value,
|
||||
reference_type="marathon",
|
||||
reference_id=marathon.id,
|
||||
description=f"Marathon #{place} place: {marathon.title}",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def award_common_enemy_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
rank: int,
|
||||
event_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Award bonus coins for Common Enemy event completion.
|
||||
|
||||
Returns: number of bonus coins awarded
|
||||
"""
|
||||
if not marathon.is_certified:
|
||||
return 0
|
||||
|
||||
coins = self.COMMON_ENEMY_BONUS_COINS.get(rank, 0)
|
||||
if coins <= 0:
|
||||
return 0
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=coins,
|
||||
transaction_type=CoinTransactionType.COMMON_ENEMY_BONUS.value,
|
||||
reference_type="event",
|
||||
reference_id=event_id,
|
||||
description=f"Common Enemy #{rank} place",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += coins
|
||||
participant.coins_earned += coins
|
||||
|
||||
return coins
|
||||
|
||||
async def spend_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
description: str,
|
||||
reference_type: str | None = None,
|
||||
reference_id: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Spend coins (for purchases).
|
||||
|
||||
Returns: True if successful, False if insufficient balance
|
||||
"""
|
||||
if user.coins_balance < amount:
|
||||
return False
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=-amount, # Negative for spending
|
||||
transaction_type=CoinTransactionType.PURCHASE.value,
|
||||
reference_type=reference_type,
|
||||
reference_id=reference_id,
|
||||
description=description,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance -= amount
|
||||
return True
|
||||
|
||||
async def refund_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
description: str,
|
||||
reference_type: str | None = None,
|
||||
reference_id: int | None = None,
|
||||
) -> None:
|
||||
"""Refund coins to user (for failed purchases, etc.)"""
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=amount,
|
||||
transaction_type=CoinTransactionType.REFUND.value,
|
||||
reference_type=reference_type,
|
||||
reference_id=reference_id,
|
||||
description=description,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += amount
|
||||
|
||||
async def admin_grant_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
reason: str,
|
||||
admin_id: int,
|
||||
) -> None:
|
||||
"""Admin grants coins to user"""
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=amount,
|
||||
transaction_type=CoinTransactionType.ADMIN_GRANT.value,
|
||||
reference_type="admin",
|
||||
reference_id=admin_id,
|
||||
description=f"Admin grant: {reason}",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance += amount
|
||||
|
||||
async def admin_deduct_coins(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount: int,
|
||||
reason: str,
|
||||
admin_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Admin deducts coins from user.
|
||||
|
||||
Returns: True if successful, False if insufficient balance
|
||||
"""
|
||||
if user.coins_balance < amount:
|
||||
return False
|
||||
|
||||
transaction = CoinTransaction(
|
||||
user_id=user.id,
|
||||
amount=-amount,
|
||||
transaction_type=CoinTransactionType.ADMIN_DEDUCT.value,
|
||||
reference_type="admin",
|
||||
reference_id=admin_id,
|
||||
description=f"Admin deduction: {reason}",
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user.coins_balance -= amount
|
||||
return True
|
||||
|
||||
|
||||
# Singleton instance
|
||||
coins_service = CoinsService()
|
||||
323
backend/app/services/consumables.py
Normal file
323
backend/app/services/consumables.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import (
|
||||
User, Participant, Marathon, Assignment, AssignmentStatus,
|
||||
ShopItem, UserInventory, ConsumableUsage, ConsumableType
|
||||
)
|
||||
|
||||
|
||||
class ConsumablesService:
|
||||
"""Service for consumable items"""
|
||||
|
||||
# Boost settings
|
||||
BOOST_DURATION_HOURS = 2
|
||||
BOOST_MULTIPLIER = 1.5
|
||||
|
||||
async def use_skip(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
) -> dict:
|
||||
"""
|
||||
Use a Skip to bypass current assignment without penalty.
|
||||
|
||||
- No streak loss
|
||||
- No drop penalty
|
||||
- Assignment marked as dropped but without negative effects
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If skips not allowed or limit reached
|
||||
"""
|
||||
# Check marathon settings
|
||||
if not marathon.allow_skips:
|
||||
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
|
||||
|
||||
if marathon.max_skips_per_participant is not None:
|
||||
if participant.skips_used >= marathon.max_skips_per_participant:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)"
|
||||
)
|
||||
|
||||
# Check assignment is active
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Can only skip active assignments")
|
||||
|
||||
# Consume skip from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.SKIP.value)
|
||||
|
||||
# Mark assignment as dropped (but without penalty)
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
# Note: We do NOT increase drop_count or reset streak
|
||||
|
||||
# Track skip usage
|
||||
participant.skips_used += 1
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "skip",
|
||||
"skipped_without_penalty": True,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"skipped": True,
|
||||
"penalty": 0,
|
||||
"streak_preserved": True,
|
||||
}
|
||||
|
||||
async def use_shield(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Activate a Shield - protects from next drop penalty.
|
||||
|
||||
- Next drop will not cause point penalty
|
||||
- Streak is preserved on next drop
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or shield already active
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if participant.has_shield:
|
||||
raise HTTPException(status_code=400, detail="Shield is already active")
|
||||
|
||||
# Consume shield from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.SHIELD.value)
|
||||
|
||||
# Activate shield
|
||||
participant.has_shield = True
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "shield",
|
||||
"activated": True,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"shield_activated": True,
|
||||
}
|
||||
|
||||
async def use_boost(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Activate a Boost - multiplies points for next 2 hours.
|
||||
|
||||
- Points for completed challenges are multiplied by BOOST_MULTIPLIER
|
||||
- Duration: BOOST_DURATION_HOURS
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or boost already active
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if participant.has_active_boost:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Boost already active until {participant.active_boost_expires_at}"
|
||||
)
|
||||
|
||||
# Consume boost from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.BOOST.value)
|
||||
|
||||
# Activate boost
|
||||
participant.active_boost_multiplier = self.BOOST_MULTIPLIER
|
||||
participant.active_boost_expires_at = datetime.utcnow() + timedelta(hours=self.BOOST_DURATION_HOURS)
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "boost",
|
||||
"multiplier": self.BOOST_MULTIPLIER,
|
||||
"duration_hours": self.BOOST_DURATION_HOURS,
|
||||
"expires_at": participant.active_boost_expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"boost_activated": True,
|
||||
"multiplier": self.BOOST_MULTIPLIER,
|
||||
"expires_at": participant.active_boost_expires_at,
|
||||
}
|
||||
|
||||
async def use_reroll(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
) -> dict:
|
||||
"""
|
||||
Use a Reroll - discard current assignment and spin again.
|
||||
|
||||
- Current assignment is cancelled (not dropped)
|
||||
- User can spin the wheel again
|
||||
- No penalty
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or assignment not active
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Can only reroll active assignments")
|
||||
|
||||
# Consume reroll from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.REROLL.value)
|
||||
|
||||
# Cancel current assignment
|
||||
old_challenge_id = assignment.challenge_id
|
||||
old_game_id = assignment.game_id
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
# Note: We do NOT increase drop_count (this is a reroll, not a real drop)
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "reroll",
|
||||
"rerolled_from_challenge_id": old_challenge_id,
|
||||
"rerolled_from_game_id": old_game_id,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"rerolled": True,
|
||||
"can_spin_again": True,
|
||||
}
|
||||
|
||||
async def _consume_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
item_code: str,
|
||||
) -> ShopItem:
|
||||
"""
|
||||
Consume 1 unit of a consumable from user's inventory.
|
||||
|
||||
Returns: The consumed ShopItem
|
||||
|
||||
Raises:
|
||||
HTTPException: If user doesn't have the item
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(UserInventory)
|
||||
.options(selectinload(UserInventory.item))
|
||||
.join(ShopItem)
|
||||
.where(
|
||||
UserInventory.user_id == user.id,
|
||||
ShopItem.code == item_code,
|
||||
UserInventory.quantity > 0,
|
||||
)
|
||||
)
|
||||
inv_item = result.scalar_one_or_none()
|
||||
|
||||
if not inv_item:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"You don't have any {item_code} in your inventory"
|
||||
)
|
||||
|
||||
# Decrease quantity
|
||||
inv_item.quantity -= 1
|
||||
|
||||
return inv_item.item
|
||||
|
||||
async def get_consumable_count(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
item_code: str,
|
||||
) -> int:
|
||||
"""Get how many of a consumable user has"""
|
||||
result = await db.execute(
|
||||
select(UserInventory.quantity)
|
||||
.join(ShopItem)
|
||||
.where(
|
||||
UserInventory.user_id == user_id,
|
||||
ShopItem.code == item_code,
|
||||
)
|
||||
)
|
||||
quantity = result.scalar_one_or_none()
|
||||
return quantity or 0
|
||||
|
||||
def consume_shield_on_drop(self, participant: Participant) -> bool:
|
||||
"""
|
||||
Consume shield when dropping (called from wheel.py).
|
||||
|
||||
Returns: True if shield was consumed, False otherwise
|
||||
"""
|
||||
if participant.has_shield:
|
||||
participant.has_shield = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_active_boost_multiplier(self, participant: Participant) -> float:
|
||||
"""
|
||||
Get current boost multiplier for participant.
|
||||
|
||||
Returns: Multiplier value (1.0 if no active boost)
|
||||
"""
|
||||
return participant.get_boost_multiplier()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
consumables_service = ConsumablesService()
|
||||
297
backend/app/services/shop.py
Normal file
297
backend/app/services/shop.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Shop Service - handles shop items, purchases, and inventory management
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import User, ShopItem, UserInventory, ShopItemType
|
||||
from app.services.coins import coins_service
|
||||
|
||||
|
||||
class ShopService:
|
||||
"""Service for shop operations"""
|
||||
|
||||
async def get_available_items(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
item_type: str | None = None,
|
||||
include_unavailable: bool = False,
|
||||
) -> list[ShopItem]:
|
||||
"""
|
||||
Get list of shop items.
|
||||
|
||||
Args:
|
||||
item_type: Filter by item type (frame, title, etc.)
|
||||
include_unavailable: Include inactive/out of stock items
|
||||
"""
|
||||
query = select(ShopItem)
|
||||
|
||||
if item_type:
|
||||
query = query.where(ShopItem.item_type == item_type)
|
||||
|
||||
if not include_unavailable:
|
||||
now = datetime.utcnow()
|
||||
query = query.where(
|
||||
ShopItem.is_active == True,
|
||||
(ShopItem.available_from.is_(None)) | (ShopItem.available_from <= now),
|
||||
(ShopItem.available_until.is_(None)) | (ShopItem.available_until >= now),
|
||||
(ShopItem.stock_remaining.is_(None)) | (ShopItem.stock_remaining > 0),
|
||||
)
|
||||
|
||||
query = query.order_by(ShopItem.price.asc())
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_item_by_id(self, db: AsyncSession, item_id: int) -> ShopItem | None:
|
||||
"""Get shop item by ID"""
|
||||
result = await db.execute(select(ShopItem).where(ShopItem.id == item_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_item_by_code(self, db: AsyncSession, code: str) -> ShopItem | None:
|
||||
"""Get shop item by code"""
|
||||
result = await db.execute(select(ShopItem).where(ShopItem.code == code))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def purchase_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
item_id: int,
|
||||
quantity: int = 1,
|
||||
) -> tuple[UserInventory, int]:
|
||||
"""
|
||||
Purchase an item from the shop.
|
||||
|
||||
Args:
|
||||
user: The purchasing user
|
||||
item_id: ID of item to purchase
|
||||
quantity: Number to purchase (only for consumables)
|
||||
|
||||
Returns:
|
||||
Tuple of (inventory item, total cost)
|
||||
|
||||
Raises:
|
||||
HTTPException: If item not found, not available, or insufficient funds
|
||||
"""
|
||||
# Get item
|
||||
item = await self.get_item_by_id(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
# Check availability
|
||||
if not item.is_available:
|
||||
raise HTTPException(status_code=400, detail="Item is not available")
|
||||
|
||||
# For non-consumables, quantity is always 1
|
||||
if item.item_type != ShopItemType.CONSUMABLE.value:
|
||||
quantity = 1
|
||||
|
||||
# Check if already owned
|
||||
existing = await db.execute(
|
||||
select(UserInventory).where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.item_id == item.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="You already own this item")
|
||||
|
||||
# Check stock
|
||||
if item.stock_remaining is not None and item.stock_remaining < quantity:
|
||||
raise HTTPException(status_code=400, detail="Not enough stock available")
|
||||
|
||||
# Calculate total cost
|
||||
total_cost = item.price * quantity
|
||||
|
||||
# Check balance
|
||||
if user.coins_balance < total_cost:
|
||||
raise HTTPException(status_code=400, detail="Not enough coins")
|
||||
|
||||
# Deduct coins
|
||||
success = await coins_service.spend_coins(
|
||||
db, user, total_cost,
|
||||
f"Purchase: {item.name} x{quantity}",
|
||||
"shop_item", item.id,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Payment failed")
|
||||
|
||||
# Add to inventory
|
||||
if item.item_type == ShopItemType.CONSUMABLE.value:
|
||||
# For consumables, increase quantity if already exists
|
||||
existing_result = await db.execute(
|
||||
select(UserInventory).where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.item_id == item.id,
|
||||
)
|
||||
)
|
||||
inv_item = existing_result.scalar_one_or_none()
|
||||
|
||||
if inv_item:
|
||||
inv_item.quantity += quantity
|
||||
else:
|
||||
inv_item = UserInventory(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
quantity=quantity,
|
||||
)
|
||||
db.add(inv_item)
|
||||
else:
|
||||
# For cosmetics, create new inventory entry
|
||||
inv_item = UserInventory(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
quantity=1,
|
||||
)
|
||||
db.add(inv_item)
|
||||
|
||||
# Decrease stock if limited
|
||||
if item.stock_remaining is not None:
|
||||
item.stock_remaining -= quantity
|
||||
|
||||
await db.flush()
|
||||
return inv_item, total_cost
|
||||
|
||||
async def get_user_inventory(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
item_type: str | None = None,
|
||||
) -> list[UserInventory]:
|
||||
"""Get user's inventory"""
|
||||
query = (
|
||||
select(UserInventory)
|
||||
.options(selectinload(UserInventory.item))
|
||||
.where(UserInventory.user_id == user_id)
|
||||
)
|
||||
|
||||
if item_type:
|
||||
query = query.join(ShopItem).where(ShopItem.item_type == item_type)
|
||||
|
||||
# Exclude empty consumables
|
||||
query = query.where(UserInventory.quantity > 0)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_inventory_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
inventory_id: int,
|
||||
) -> UserInventory | None:
|
||||
"""Get specific inventory item"""
|
||||
result = await db.execute(
|
||||
select(UserInventory)
|
||||
.options(selectinload(UserInventory.item))
|
||||
.where(
|
||||
UserInventory.id == inventory_id,
|
||||
UserInventory.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def equip_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
inventory_id: int,
|
||||
) -> ShopItem:
|
||||
"""
|
||||
Equip a cosmetic item from inventory.
|
||||
|
||||
Returns: The equipped item
|
||||
|
||||
Raises:
|
||||
HTTPException: If item not found or is a consumable
|
||||
"""
|
||||
# Get inventory item
|
||||
inv_item = await self.get_inventory_item(db, user.id, inventory_id)
|
||||
if not inv_item:
|
||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||
|
||||
item = inv_item.item
|
||||
|
||||
if item.item_type == ShopItemType.CONSUMABLE.value:
|
||||
raise HTTPException(status_code=400, detail="Cannot equip consumables")
|
||||
|
||||
# Unequip current item of same type
|
||||
await db.execute(
|
||||
update(UserInventory)
|
||||
.where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.equipped == True,
|
||||
UserInventory.item_id.in_(
|
||||
select(ShopItem.id).where(ShopItem.item_type == item.item_type)
|
||||
),
|
||||
)
|
||||
.values(equipped=False)
|
||||
)
|
||||
|
||||
# Equip new item
|
||||
inv_item.equipped = True
|
||||
|
||||
# Update user's equipped_*_id
|
||||
if item.item_type == ShopItemType.FRAME.value:
|
||||
user.equipped_frame_id = item.id
|
||||
elif item.item_type == ShopItemType.TITLE.value:
|
||||
user.equipped_title_id = item.id
|
||||
elif item.item_type == ShopItemType.NAME_COLOR.value:
|
||||
user.equipped_name_color_id = item.id
|
||||
elif item.item_type == ShopItemType.BACKGROUND.value:
|
||||
user.equipped_background_id = item.id
|
||||
|
||||
return item
|
||||
|
||||
async def unequip_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
item_type: str,
|
||||
) -> None:
|
||||
"""Unequip item of specified type"""
|
||||
# Unequip from inventory
|
||||
await db.execute(
|
||||
update(UserInventory)
|
||||
.where(
|
||||
UserInventory.user_id == user.id,
|
||||
UserInventory.equipped == True,
|
||||
UserInventory.item_id.in_(
|
||||
select(ShopItem.id).where(ShopItem.item_type == item_type)
|
||||
),
|
||||
)
|
||||
.values(equipped=False)
|
||||
)
|
||||
|
||||
# Clear user's equipped_*_id
|
||||
if item_type == ShopItemType.FRAME.value:
|
||||
user.equipped_frame_id = None
|
||||
elif item_type == ShopItemType.TITLE.value:
|
||||
user.equipped_title_id = None
|
||||
elif item_type == ShopItemType.NAME_COLOR.value:
|
||||
user.equipped_name_color_id = None
|
||||
elif item_type == ShopItemType.BACKGROUND.value:
|
||||
user.equipped_background_id = None
|
||||
|
||||
async def check_user_owns_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
item_id: int,
|
||||
) -> bool:
|
||||
"""Check if user owns an item"""
|
||||
result = await db.execute(
|
||||
select(UserInventory).where(
|
||||
UserInventory.user_id == user_id,
|
||||
UserInventory.item_id == item_id,
|
||||
UserInventory.quantity > 0,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
shop_service = ShopService()
|
||||
Reference in New Issue
Block a user