635 lines
22 KiB
Python
635 lines
22 KiB
Python
"""
|
|
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 for next complete"
|
|
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 for all consumables
|
|
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
|
|
shields_available = await consumables_service.get_consumable_count(db, current_user.id, "shield")
|
|
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
|
|
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,
|
|
shields_available=shields_available,
|
|
has_shield=participant.has_shield,
|
|
boosts_available=boosts_available,
|
|
has_active_boost=participant.has_active_boost,
|
|
boost_multiplier=consumables_service.BOOST_MULTIPLIER 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,
|
|
)
|