This commit is contained in:
2026-01-05 07:15:50 +07:00
parent 65b2512d8c
commit 6a7717a474
44 changed files with 5678 additions and 183 deletions

View File

@@ -0,0 +1,230 @@
"""Add shop system with coins, items, inventory, certification
Revision ID: 023_add_shop_system
Revises: 022_add_notification_settings
Create Date: 2025-01-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '023_add_shop_system'
down_revision: Union[str, None] = '022_add_notification_settings'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
# === 1. Создаём таблицу shop_items ===
if not table_exists('shop_items'):
op.create_table(
'shop_items',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('item_type', sa.String(30), nullable=False, index=True),
sa.Column('code', sa.String(50), nullable=False, unique=True, index=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('price', sa.Integer(), nullable=False),
sa.Column('rarity', sa.String(20), nullable=False, server_default='common'),
sa.Column('asset_data', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('available_from', sa.DateTime(), nullable=True),
sa.Column('available_until', sa.DateTime(), nullable=True),
sa.Column('stock_limit', sa.Integer(), nullable=True),
sa.Column('stock_remaining', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# === 2. Создаём таблицу user_inventory ===
if not table_exists('user_inventory'):
op.create_table(
'user_inventory',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('quantity', sa.Integer(), nullable=False, server_default='1'),
sa.Column('equipped', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('purchased_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('expires_at', sa.DateTime(), nullable=True),
)
# === 3. Создаём таблицу coin_transactions ===
if not table_exists('coin_transactions'):
op.create_table(
'coin_transactions',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('amount', sa.Integer(), nullable=False),
sa.Column('transaction_type', sa.String(30), nullable=False),
sa.Column('reference_type', sa.String(30), nullable=True),
sa.Column('reference_id', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# === 4. Создаём таблицу consumable_usages ===
if not table_exists('consumable_usages'):
op.create_table(
'consumable_usages',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False),
sa.Column('marathon_id', sa.Integer(), sa.ForeignKey('marathons.id', ondelete='CASCADE'), nullable=True),
sa.Column('assignment_id', sa.Integer(), sa.ForeignKey('assignments.id', ondelete='CASCADE'), nullable=True),
sa.Column('used_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('effect_data', sa.JSON(), nullable=True),
)
# === 5. Добавляем поля в users ===
# coins_balance - баланс монет
if not column_exists('users', 'coins_balance'):
op.add_column('users', sa.Column('coins_balance', sa.Integer(), nullable=False, server_default='0'))
# equipped_frame_id - экипированная рамка
if not column_exists('users', 'equipped_frame_id'):
op.add_column('users', sa.Column('equipped_frame_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_title_id - экипированный титул
if not column_exists('users', 'equipped_title_id'):
op.add_column('users', sa.Column('equipped_title_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_name_color_id - экипированный цвет ника
if not column_exists('users', 'equipped_name_color_id'):
op.add_column('users', sa.Column('equipped_name_color_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_background_id - экипированный фон
if not column_exists('users', 'equipped_background_id'):
op.add_column('users', sa.Column('equipped_background_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# === 6. Добавляем поля сертификации в marathons ===
# certification_status - статус сертификации
if not column_exists('marathons', 'certification_status'):
op.add_column('marathons', sa.Column('certification_status', sa.String(20), nullable=False, server_default='none'))
# certification_requested_at - когда подана заявка
if not column_exists('marathons', 'certification_requested_at'):
op.add_column('marathons', sa.Column('certification_requested_at', sa.DateTime(), nullable=True))
# certified_at - когда сертифицирован
if not column_exists('marathons', 'certified_at'):
op.add_column('marathons', sa.Column('certified_at', sa.DateTime(), nullable=True))
# certified_by_id - кем сертифицирован
if not column_exists('marathons', 'certified_by_id'):
op.add_column('marathons', sa.Column('certified_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True))
# certification_rejection_reason - причина отказа
if not column_exists('marathons', 'certification_rejection_reason'):
op.add_column('marathons', sa.Column('certification_rejection_reason', sa.Text(), nullable=True))
# === 7. Добавляем настройки consumables в marathons ===
# allow_skips - разрешены ли скипы
if not column_exists('marathons', 'allow_skips'):
op.add_column('marathons', sa.Column('allow_skips', sa.Boolean(), nullable=False, server_default='true'))
# max_skips_per_participant - лимит скипов на участника
if not column_exists('marathons', 'max_skips_per_participant'):
op.add_column('marathons', sa.Column('max_skips_per_participant', sa.Integer(), nullable=True))
# allow_consumables - разрешены ли расходуемые
if not column_exists('marathons', 'allow_consumables'):
op.add_column('marathons', sa.Column('allow_consumables', sa.Boolean(), nullable=False, server_default='true'))
# === 8. Добавляем поля в participants ===
# coins_earned - заработано монет в марафоне
if not column_exists('participants', 'coins_earned'):
op.add_column('participants', sa.Column('coins_earned', sa.Integer(), nullable=False, server_default='0'))
# skips_used - использовано скипов
if not column_exists('participants', 'skips_used'):
op.add_column('participants', sa.Column('skips_used', sa.Integer(), nullable=False, server_default='0'))
# active_boost_multiplier - активный множитель буста
if not column_exists('participants', 'active_boost_multiplier'):
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
# active_boost_expires_at - когда истекает буст
if not column_exists('participants', 'active_boost_expires_at'):
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
# has_shield - есть ли активный щит
if not column_exists('participants', 'has_shield'):
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
def downgrade() -> None:
# === Удаляем поля из participants ===
if column_exists('participants', 'has_shield'):
op.drop_column('participants', 'has_shield')
if column_exists('participants', 'active_boost_expires_at'):
op.drop_column('participants', 'active_boost_expires_at')
if column_exists('participants', 'active_boost_multiplier'):
op.drop_column('participants', 'active_boost_multiplier')
if column_exists('participants', 'skips_used'):
op.drop_column('participants', 'skips_used')
if column_exists('participants', 'coins_earned'):
op.drop_column('participants', 'coins_earned')
# === Удаляем поля consumables из marathons ===
if column_exists('marathons', 'allow_consumables'):
op.drop_column('marathons', 'allow_consumables')
if column_exists('marathons', 'max_skips_per_participant'):
op.drop_column('marathons', 'max_skips_per_participant')
if column_exists('marathons', 'allow_skips'):
op.drop_column('marathons', 'allow_skips')
# === Удаляем поля сертификации из marathons ===
if column_exists('marathons', 'certification_rejection_reason'):
op.drop_column('marathons', 'certification_rejection_reason')
if column_exists('marathons', 'certified_by_id'):
op.drop_column('marathons', 'certified_by_id')
if column_exists('marathons', 'certified_at'):
op.drop_column('marathons', 'certified_at')
if column_exists('marathons', 'certification_requested_at'):
op.drop_column('marathons', 'certification_requested_at')
if column_exists('marathons', 'certification_status'):
op.drop_column('marathons', 'certification_status')
# === Удаляем поля из users ===
if column_exists('users', 'equipped_background_id'):
op.drop_column('users', 'equipped_background_id')
if column_exists('users', 'equipped_name_color_id'):
op.drop_column('users', 'equipped_name_color_id')
if column_exists('users', 'equipped_title_id'):
op.drop_column('users', 'equipped_title_id')
if column_exists('users', 'equipped_frame_id'):
op.drop_column('users', 'equipped_frame_id')
if column_exists('users', 'coins_balance'):
op.drop_column('users', 'coins_balance')
# === Удаляем таблицы ===
if table_exists('consumable_usages'):
op.drop_table('consumable_usages')
if table_exists('coin_transactions'):
op.drop_table('coin_transactions')
if table_exists('user_inventory'):
op.drop_table('user_inventory')
if table_exists('shop_items'):
op.drop_table('shop_items')

View File

@@ -0,0 +1,495 @@
"""Seed shop items (frames, titles, consumables)
Revision ID: 024_seed_shop_items
Revises: 023_add_shop_system
Create Date: 2025-01-05
"""
from typing import Sequence, Union
from datetime import datetime
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '024_seed_shop_items'
down_revision: Union[str, None] = '023_add_shop_system'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not table_exists('shop_items'):
return
now = datetime.utcnow()
# Таблица shop_items
shop_items = sa.table(
'shop_items',
sa.column('id', sa.Integer),
sa.column('item_type', sa.String),
sa.column('code', sa.String),
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('price', sa.Integer),
sa.column('rarity', sa.String),
sa.column('asset_data', sa.JSON),
sa.column('is_active', sa.Boolean),
sa.column('created_at', sa.DateTime),
)
# === Рамки аватара ===
frames = [
{
'item_type': 'frame',
'code': 'frame_bronze',
'name': 'Бронзовая рамка',
'description': 'Простая бронзовая рамка для начинающих',
'price': 50,
'rarity': 'common',
'asset_data': {
'border_color': '#CD7F32',
'border_width': 3,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_silver',
'name': 'Серебряная рамка',
'description': 'Элегантная серебряная рамка',
'price': 100,
'rarity': 'uncommon',
'asset_data': {
'border_color': '#C0C0C0',
'border_width': 3,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_gold',
'name': 'Золотая рамка',
'description': 'Престижная золотая рамка',
'price': 200,
'rarity': 'rare',
'asset_data': {
'border_color': '#FFD700',
'border_width': 4,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_diamond',
'name': 'Бриллиантовая рамка',
'description': 'Сверкающая бриллиантовая рамка для истинных ценителей',
'price': 500,
'rarity': 'epic',
'asset_data': {
'border_color': '#B9F2FF',
'border_width': 4,
'border_style': 'double',
'glow': True,
'glow_color': '#B9F2FF'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_fire',
'name': 'Огненная рамка',
'description': 'Анимированная рамка с эффектом пламени',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'border_style': 'gradient',
'gradient': ['#FF4500', '#FF8C00', '#FFD700'],
'animated': True,
'animation': 'fire-pulse'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_neon',
'name': 'Неоновая рамка',
'description': 'Яркая неоновая рамка с свечением',
'price': 800,
'rarity': 'epic',
'asset_data': {
'border_color': '#00FF00',
'border_width': 3,
'glow': True,
'glow_color': '#00FF00',
'glow_intensity': 10
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_rainbow',
'name': 'Радужная рамка',
'description': 'Переливающаяся радужная рамка',
'price': 1500,
'rarity': 'legendary',
'asset_data': {
'border_style': 'gradient',
'gradient': ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#9400D3'],
'animated': True,
'animation': 'rainbow-rotate'
},
'is_active': True,
},
]
# === Титулы ===
titles = [
{
'item_type': 'title',
'code': 'title_newcomer',
'name': 'Новичок',
'description': 'Первый шаг в мир марафонов',
'price': 30,
'rarity': 'common',
'asset_data': {
'text': 'Новичок',
'color': '#808080'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_runner',
'name': 'Марафонец',
'description': 'Опытный участник марафонов',
'price': 100,
'rarity': 'uncommon',
'asset_data': {
'text': 'Марафонец',
'color': '#4169E1'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_hunter',
'name': 'Охотник за челленджами',
'description': 'Мастер выполнения сложных заданий',
'price': 200,
'rarity': 'rare',
'asset_data': {
'text': 'Охотник за челленджами',
'color': '#228B22'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_veteran',
'name': 'Ветеран',
'description': 'Закаленный в боях участник',
'price': 300,
'rarity': 'rare',
'asset_data': {
'text': 'Ветеран',
'color': '#8B4513'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_champion',
'name': 'Чемпион',
'description': 'Победитель марафонов',
'price': 500,
'rarity': 'epic',
'asset_data': {
'text': 'Чемпион',
'color': '#FFD700',
'icon': 'trophy'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_legend',
'name': 'Легенда',
'description': 'Легендарный участник марафонов',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'text': 'Легенда',
'color': '#FF4500',
'glow': True,
'icon': 'star'
},
'is_active': True,
},
]
# === Цвета никнейма ===
name_colors = [
{
'item_type': 'name_color',
'code': 'color_red',
'name': 'Красный ник',
'description': 'Яркий красный цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#FF4444'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_blue',
'name': 'Синий ник',
'description': 'Глубокий синий цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#4444FF'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_green',
'name': 'Зеленый ник',
'description': 'Сочный зеленый цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#44FF44'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_purple',
'name': 'Фиолетовый ник',
'description': 'Королевский фиолетовый цвет',
'price': 200,
'rarity': 'rare',
'asset_data': {
'style': 'solid',
'color': '#9932CC'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gold',
'name': 'Золотой ник',
'description': 'Престижный золотой цвет',
'price': 300,
'rarity': 'rare',
'asset_data': {
'style': 'solid',
'color': '#FFD700'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gradient_sunset',
'name': 'Закат',
'description': 'Красивый градиент заката',
'price': 500,
'rarity': 'epic',
'asset_data': {
'style': 'gradient',
'gradient': ['#FF6B6B', '#FFE66D']
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gradient_ocean',
'name': 'Океан',
'description': 'Градиент морских глубин',
'price': 500,
'rarity': 'epic',
'asset_data': {
'style': 'gradient',
'gradient': ['#4ECDC4', '#44A3FF']
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_rainbow',
'name': 'Радужный ник',
'description': 'Анимированный радужный цвет',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'style': 'animated',
'animation': 'rainbow-shift'
},
'is_active': True,
},
]
# === Фоны профиля ===
backgrounds = [
{
'item_type': 'background',
'code': 'bg_dark',
'name': 'Тёмный фон',
'description': 'Элегантный тёмный фон',
'price': 100,
'rarity': 'common',
'asset_data': {
'type': 'solid',
'color': '#1a1a2e'
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_gradient_purple',
'name': 'Фиолетовый градиент',
'description': 'Красивый фиолетовый градиент',
'price': 200,
'rarity': 'uncommon',
'asset_data': {
'type': 'gradient',
'gradient': ['#1a1a2e', '#4a0080']
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_stars',
'name': 'Звёздное небо',
'description': 'Фон с мерцающими звёздами',
'price': 400,
'rarity': 'rare',
'asset_data': {
'type': 'pattern',
'pattern': 'stars',
'animated': True
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_gaming',
'name': 'Игровой фон',
'description': 'Фон с игровыми элементами',
'price': 500,
'rarity': 'epic',
'asset_data': {
'type': 'pattern',
'pattern': 'gaming-icons'
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_fire',
'name': 'Огненный фон',
'description': 'Анимированный огненный фон',
'price': 800,
'rarity': 'legendary',
'asset_data': {
'type': 'animated',
'animation': 'fire-particles'
},
'is_active': True,
},
]
# === Расходуемые предметы ===
consumables = [
{
'item_type': 'consumable',
'code': 'skip',
'name': 'Пропуск',
'description': 'Пропустить текущее задание без штрафа и потери streak',
'price': 100,
'rarity': 'common',
'asset_data': {
'effect': 'skip',
'icon': 'skip-forward'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'shield',
'name': 'Щит',
'description': 'Защита от штрафа при следующем дропе. Streak сохраняется.',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'effect': 'shield',
'icon': 'shield'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'boost',
'name': 'Буст x1.5',
'description': 'Множитель очков x1.5 на следующие 2 часа',
'price': 200,
'rarity': 'rare',
'asset_data': {
'effect': 'boost',
'multiplier': 1.5,
'duration_hours': 2,
'icon': 'zap'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'reroll',
'name': 'Перекрут',
'description': 'Перекрутить колесо и получить новое задание',
'price': 80,
'rarity': 'common',
'asset_data': {
'effect': 'reroll',
'icon': 'refresh-cw'
},
'is_active': True,
},
]
# Вставляем все товары
all_items = frames + titles + name_colors + backgrounds + consumables
# Добавляем created_at ко всем товарам
for item in all_items:
item['created_at'] = now
op.bulk_insert(shop_items, all_items)
def downgrade() -> None:
# Удаляем все seed-товары по коду
op.execute("DELETE FROM shop_items WHERE code LIKE 'frame_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'title_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'color_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'bg_%'")
op.execute("DELETE FROM shop_items WHERE code IN ('skip', 'shield', 'boost', 'reroll')")