## Skip with Exile (новый расходник) - Новая модель ExiledGame для хранения изгнанных игр - Расходник skip_exile: пропуск без штрафа + игра исключается из пула навсегда - Фильтрация изгнанных игр при выдаче заданий - UI кнопка в PlayPage для использования skip_exile ## Модерация марафонов (для организаторов) - Эндпоинты: skip-assignment, exiled-games, restore-exiled-game - UI в LeaderboardPage: кнопка скипа у каждого участника - Выбор типа скипа (обычный/с изгнанием) + причина - Telegram уведомления о модерации ## Админская выдача предметов - Эндпоинты: admin grant/remove items, get user inventory - Новая страница AdminGrantItemPage (как магазин) - Telegram уведомление при получении подарка ## Исправления миграций - Миграции 029/030 теперь идемпотентны (проверка существования таблиц) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
85 lines
2.7 KiB
Python
85 lines
2.7 KiB
Python
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"
|
||
SKIP_EXILE = "skip_exile" # Скип с изгнанием игры из пула
|
||
BOOST = "boost"
|
||
WILD_CARD = "wild_card"
|
||
LUCKY_DICE = "lucky_dice"
|
||
COPYCAT = "copycat"
|
||
UNDO = "undo"
|
||
|
||
|
||
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
|