Promocode system

This commit is contained in:
2026-01-08 10:02:15 +07:00
parent 1751c4dd4c
commit e63d6c8489
19 changed files with 1443 additions and 7 deletions

View File

@@ -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, shop
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo
router = APIRouter(prefix="/api/v1")
@@ -17,3 +17,4 @@ router.include_router(assignments.router)
router.include_router(telegram.router)
router.include_router(content.router)
router.include_router(shop.router)
router.include_router(promo.router)

299
backend/app/api/v1/promo.py Normal file
View File

@@ -0,0 +1,299 @@
"""
Promo Code API endpoints - user redemption and admin management
"""
import secrets
import string
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_admin_with_2fa
from app.models import User, CoinTransaction, CoinTransactionType
from app.models.promo_code import PromoCode, PromoCodeRedemption
from app.schemas.promo_code import (
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeResponse,
PromoCodeRedeemRequest,
PromoCodeRedeemResponse,
PromoCodeRedemptionResponse,
PromoCodeRedemptionUser,
)
from app.schemas.common import MessageResponse
router = APIRouter(prefix="/promo", tags=["promo"])
def generate_promo_code(length: int = 8) -> str:
"""Generate a random promo code"""
chars = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(chars) for _ in range(length))
# === User endpoints ===
@router.post("/redeem", response_model=PromoCodeRedeemResponse)
async def redeem_promo_code(
data: PromoCodeRedeemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Redeem a promo code to receive coins"""
# Find promo code
result = await db.execute(
select(PromoCode).where(PromoCode.code == data.code.upper().strip())
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
# Check if valid
if not promo.is_active:
raise HTTPException(status_code=400, detail="Промокод деактивирован")
now = datetime.utcnow()
if promo.valid_from and now < promo.valid_from:
raise HTTPException(status_code=400, detail="Промокод ещё не активен")
if promo.valid_until and now > promo.valid_until:
raise HTTPException(status_code=400, detail="Промокод истёк")
if promo.max_uses is not None and promo.uses_count >= promo.max_uses:
raise HTTPException(status_code=400, detail="Лимит использований исчерпан")
# Check if user already redeemed
result = await db.execute(
select(PromoCodeRedemption).where(
PromoCodeRedemption.promo_code_id == promo.id,
PromoCodeRedemption.user_id == current_user.id,
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Вы уже использовали этот промокод")
# Create redemption record
redemption = PromoCodeRedemption(
promo_code_id=promo.id,
user_id=current_user.id,
coins_awarded=promo.coins_amount,
)
db.add(redemption)
# Update uses count
promo.uses_count += 1
# Award coins
transaction = CoinTransaction(
user_id=current_user.id,
amount=promo.coins_amount,
transaction_type=CoinTransactionType.PROMO_CODE.value,
reference_type="promo_code",
reference_id=promo.id,
description=f"Промокод: {promo.code}",
)
db.add(transaction)
current_user.coins_balance += promo.coins_amount
await db.commit()
await db.refresh(current_user)
return PromoCodeRedeemResponse(
success=True,
coins_awarded=promo.coins_amount,
new_balance=current_user.coins_balance,
message=f"Вы получили {promo.coins_amount} монет!",
)
# === Admin endpoints ===
@router.get("/admin/list", response_model=list[PromoCodeResponse])
async def admin_list_promo_codes(
current_user: CurrentUser,
db: DbSession,
include_inactive: bool = False,
):
"""Get all promo codes (admin only)"""
require_admin_with_2fa(current_user)
query = select(PromoCode).options(selectinload(PromoCode.created_by))
if not include_inactive:
query = query.where(PromoCode.is_active == True)
query = query.order_by(PromoCode.created_at.desc())
result = await db.execute(query)
promos = result.scalars().all()
return [
PromoCodeResponse(
id=p.id,
code=p.code,
coins_amount=p.coins_amount,
max_uses=p.max_uses,
uses_count=p.uses_count,
is_active=p.is_active,
valid_from=p.valid_from,
valid_until=p.valid_until,
created_at=p.created_at,
created_by_nickname=p.created_by.nickname if p.created_by else None,
)
for p in promos
]
@router.post("/admin/create", response_model=PromoCodeResponse)
async def admin_create_promo_code(
data: PromoCodeCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a new promo code (admin only)"""
require_admin_with_2fa(current_user)
# Generate or use provided code
code = data.code.upper().strip() if data.code else generate_promo_code()
# Check uniqueness
result = await db.execute(
select(PromoCode).where(PromoCode.code == code)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"Промокод '{code}' уже существует")
promo = PromoCode(
code=code,
coins_amount=data.coins_amount,
max_uses=data.max_uses,
valid_from=data.valid_from,
valid_until=data.valid_until,
created_by_id=current_user.id,
)
db.add(promo)
await db.commit()
await db.refresh(promo)
return PromoCodeResponse(
id=promo.id,
code=promo.code,
coins_amount=promo.coins_amount,
max_uses=promo.max_uses,
uses_count=promo.uses_count,
is_active=promo.is_active,
valid_from=promo.valid_from,
valid_until=promo.valid_until,
created_at=promo.created_at,
created_by_nickname=current_user.nickname,
)
@router.put("/admin/{promo_id}", response_model=PromoCodeResponse)
async def admin_update_promo_code(
promo_id: int,
data: PromoCodeUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update a promo code (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(PromoCode)
.options(selectinload(PromoCode.created_by))
.where(PromoCode.id == promo_id)
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
if data.is_active is not None:
promo.is_active = data.is_active
if data.max_uses is not None:
promo.max_uses = data.max_uses
if data.valid_until is not None:
promo.valid_until = data.valid_until
await db.commit()
await db.refresh(promo)
return PromoCodeResponse(
id=promo.id,
code=promo.code,
coins_amount=promo.coins_amount,
max_uses=promo.max_uses,
uses_count=promo.uses_count,
is_active=promo.is_active,
valid_from=promo.valid_from,
valid_until=promo.valid_until,
created_at=promo.created_at,
created_by_nickname=promo.created_by.nickname if promo.created_by else None,
)
@router.delete("/admin/{promo_id}", response_model=MessageResponse)
async def admin_delete_promo_code(
promo_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete a promo code (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(PromoCode).where(PromoCode.id == promo_id)
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
await db.delete(promo)
await db.commit()
return MessageResponse(message=f"Промокод '{promo.code}' удалён")
@router.get("/admin/{promo_id}/redemptions", response_model=list[PromoCodeRedemptionResponse])
async def admin_get_promo_redemptions(
promo_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get list of users who redeemed a promo code (admin only)"""
require_admin_with_2fa(current_user)
# Check promo exists
result = await db.execute(
select(PromoCode).where(PromoCode.id == promo_id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Промокод не найден")
# Get redemptions
result = await db.execute(
select(PromoCodeRedemption)
.options(selectinload(PromoCodeRedemption.user))
.where(PromoCodeRedemption.promo_code_id == promo_id)
.order_by(PromoCodeRedemption.redeemed_at.desc())
)
redemptions = result.scalars().all()
return [
PromoCodeRedemptionResponse(
id=r.id,
user=PromoCodeRedemptionUser(
id=r.user.id,
nickname=r.user.nickname,
),
coins_awarded=r.coins_awarded,
redeemed_at=r.redeemed_at,
)
for r in redemptions
]

View File

@@ -10,7 +10,7 @@ from app.api.deps import CurrentUser, DbSession, require_participant, require_ad
from app.models import (
User, Marathon, Participant, Assignment, AssignmentStatus,
ShopItem, UserInventory, CoinTransaction, ShopItemType,
CertificationStatus,
CertificationStatus, Challenge, Game,
)
from app.schemas import (
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
@@ -19,8 +19,9 @@ from app.schemas import (
EquipItemRequest, EquipItemResponse,
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
ConsumablesStatusResponse, MessageResponse,
ConsumablesStatusResponse, MessageResponse, SwapCandidate,
)
from app.schemas.user import UserPublic
from app.services.shop import shop_service
from app.services.coins import coins_service
from app.services.consumables import consumables_service
@@ -296,6 +297,91 @@ async def get_consumables_status(
)
@router.get("/copycat-candidates/{marathon_id}", response_model=list[SwapCandidate])
async def get_copycat_candidates(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get participants with active assignments available for copycat (no event required)"""
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 all participants except current user with active assignments
# Support both challenge assignments and playthrough assignments
result = await db.execute(
select(Participant, Assignment, Challenge, Game)
.join(Assignment, Assignment.participant_id == Participant.id)
.outerjoin(Challenge, Assignment.challenge_id == Challenge.id)
.outerjoin(Game, Challenge.game_id == Game.id)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
rows = result.all()
candidates = []
for p, assignment, challenge, game in rows:
# For playthrough assignments, challenge is None
if assignment.is_playthrough:
# Need to get game info for playthrough
game_result = await db.execute(
select(Game).where(Game.id == assignment.game_id)
)
playthrough_game = game_result.scalar_one_or_none()
if playthrough_game:
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
nickname=p.user.nickname,
avatar_url=p.user.avatar_url,
role=p.user.role,
telegram_avatar_url=p.user.telegram_avatar_url,
created_at=p.user.created_at,
equipped_frame=None,
equipped_title=None,
equipped_name_color=None,
equipped_background=None,
),
challenge_title=f"Прохождение: {playthrough_game.title}",
challenge_description=playthrough_game.playthrough_description or "Прохождение игры",
challenge_points=playthrough_game.playthrough_points or 0,
challenge_difficulty="medium",
game_title=playthrough_game.title,
))
elif challenge and game:
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
nickname=p.user.nickname,
avatar_url=p.user.avatar_url,
role=p.user.role,
telegram_avatar_url=p.user.telegram_avatar_url,
created_at=p.user.created_at,
equipped_frame=None,
equipped_title=None,
equipped_name_color=None,
equipped_background=None,
),
challenge_title=challenge.title,
challenge_description=challenge.description,
challenge_points=challenge.points,
challenge_difficulty=challenge.difficulty,
game_title=game.title,
))
return candidates
# === Coins ===
@router.get("/balance", response_model=CoinsBalanceResponse)