Add shop
This commit is contained in:
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,
|
||||
)
|
||||
Reference in New Issue
Block a user