feat: implement user data schema and API endpoints

- Add Alembic migration setup with async configuration
- Create 16 user data models (users, decks, cards, replays, etc.)
- Implement comprehensive API endpoints with JWT auth
- Add replay, card collection, group, network, preferences, and activity log routers
- Include API documentation and migration test plan
- Update Dockerfile to run migrations on startup
This commit is contained in:
2026-07-23 03:23:54 +00:00
parent 2c74a107bc
commit c42d7ca0e1
13 changed files with 4068 additions and 3 deletions
+86
View File
@@ -0,0 +1,86 @@
"""
Alembic environment configuration for async SQLAlchemy.
Supports async database operations for migrations.
"""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import all models so Alembic can detect changes
from app.core.database import Base
from app.models import (
User, DecklistFile, DecklistFolder, Room, RoomGameType,
Ban, GameLog, AuditLog, MtgCardMirror, DeckCardLink
)
# Import new models
from app.models.user_data import (
UserSession, DeckVersion, GameReplay, ReplayPlayer,
GameOutcome, UserStatistics, UserCardCollection, CardWishlist,
UserGroup, GroupMember, GroupChatMessage, UserNetwork,
NetworkMember, UserPreference, UserActivityLog
)
# this is the Alembic Config object
config = context.config
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine instance though.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Run migrations with proper async context."""
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async engine."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
options={"pool_pre_ping": True},
class_=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,281 @@
"""initial user schema
Revision ID: 001
Revises:
Create Date: 2026-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create all user data tables."""
# 1. User Sessions Table
op.create_table(
'user_sessions',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False),
sa.Column('session_token_hash', sa.String(255), unique=True, nullable=False),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), default=True),
)
op.create_index('idx_sessions_user', 'user_sessions', ['user_id'])
op.create_index('idx_sessions_token', 'user_sessions', ['session_token_hash'])
op.create_index('idx_sessions_expires', 'user_sessions', ['expires_at'])
# 2. Enhanced Decklist File columns
op.add_column('mtgonline_decklist_files', sa.Column('description', sa.Text(), nullable=True))
op.add_column('mtgonline_decklist_files', sa.Column('format', sa.String(50), server_default='standard'))
op.add_column('mtgonline_decklist_files', sa.Column('is_favorite', sa.Boolean(), default=False))
op.add_column('mtgonline_decklist_files', sa.Column('import_source', sa.String(50), nullable=True))
op.add_column('mtgonline_decklist_files', sa.Column('import_confidence', sa.Float(), nullable=True))
op.add_column('mtgonline_decklist_files', sa.Column('last_played', sa.DateTime(), nullable=True))
# 3. Deck Versions Table
op.create_table(
'deck_versions',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('deck_id', sa.Integer(), sa.ForeignKey('mtgonline_decklist_files.id', ondelete='CASCADE'), nullable=False),
sa.Column('version_number', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('status', sa.String(20), server_default='DRAFT'),
sa.Column('comment', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_deck_versions_deck', 'deck_versions', ['deck_id'])
# 4. Game Replays Table
op.create_table(
'game_replays',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('game_uuid', sa.String(36), unique=True, nullable=False),
sa.Column('room_id', sa.Integer(), sa.ForeignKey('mtgonline_rooms.id'), nullable=True),
sa.Column('game_type', sa.String(50), nullable=True),
sa.Column('format', sa.String(50), nullable=True),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(20), server_default='IN_PROGRESS'),
sa.Column('replay_data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index('idx_replays_room', 'game_replays', ['room_id'])
op.create_index('idx_replays_start', 'game_replays', ['start_time'])
op.create_index('idx_replays_status', 'game_replays', ['status'])
# 5. Replay Players Table
op.create_table(
'replay_players',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('replay_id', sa.BigInteger(), sa.ForeignKey('game_replays.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('position', sa.Integer(), nullable=True),
sa.Column('deck_id', sa.Integer(), sa.ForeignKey('mtgonline_decklist_files.id'), nullable=True),
sa.Column('won', sa.Boolean(), nullable=True),
sa.Column('lost', sa.Boolean(), nullable=True),
sa.Column('concession', sa.Boolean(), default=False),
sa.Column('turn_one', sa.Boolean(), default=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_replay_players_replay', 'replay_players', ['replay_id'])
op.create_index('idx_replay_players_user', 'replay_players', ['user_id'])
# 6. Game Outcomes Table
op.create_table(
'game_outcomes',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('game_uuid', sa.String(36), sa.ForeignKey('game_replays.game_uuid'), nullable=False),
sa.Column('outcome', sa.String(20), nullable=False),
sa.Column('opponent_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=True),
sa.Column('format', sa.String(50), nullable=True),
sa.Column('rating_before', sa.Integer(), nullable=True),
sa.Column('rating_after', sa.Integer(), nullable=True),
sa.Column('rating_change', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_outcomes_user', 'game_outcomes', ['user_id'])
op.create_index('idx_outcomes_game', 'game_outcomes', ['game_uuid'])
op.create_index('idx_outcomes_outcome', 'game_outcomes', ['outcome'])
# 7. User Statistics Table
op.create_table(
'user_statistics',
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), primary_key=True),
sa.Column('total_games', sa.Integer(), default=0),
sa.Column('total_wins', sa.Integer(), default=0),
sa.Column('total_losses', sa.Integer(), default=0),
sa.Column('total_concessions', sa.Integer(), default=0),
sa.Column('win_rate', sa.Float(), default=0.0),
sa.Column('current_streak', sa.Integer(), default=0),
sa.Column('best_streak', sa.Integer(), default=0),
sa.Column('average_rating', sa.Float(), default=0.0),
sa.Column('last_game_date', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
# 8. User Card Collection Table
op.create_table(
'user_card_collection',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False),
sa.Column('card_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), default=1),
sa.Column('condition', sa.String(20), server_default='NEAR_MINT'),
sa.Column('language', sa.String(5), server_default='EN'),
sa.Column('is_foil', sa.Boolean(), default=False),
sa.Column('is_alt_art', sa.Boolean(), default=False),
sa.Column('acquired_date', sa.DateTime(), server_default=sa.func.now()),
sa.Column('acquisition_method', sa.String(50), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index('idx_collection_user', 'user_card_collection', ['user_id'])
op.create_index('idx_collection_card', 'user_card_collection', ['card_id'])
op.create_unique_constraint('uq_collection_unique', 'user_card_collection', ['user_id', 'card_id', 'is_foil', 'is_alt_art'])
# 9. Card Wishlist Table
op.create_table(
'card_wishlist',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False),
sa.Column('card_id', sa.Integer(), nullable=False),
sa.Column('max_price', sa.Float(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_unique_constraint('uq_wishlist_user_card', 'card_wishlist', ['user_id', 'card_id'])
# 10. User Groups Table
op.create_table(
'user_groups',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('owner_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('is_public', sa.Boolean(), default=True),
sa.Column('max_members', sa.Integer(), default=50),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index('idx_groups_owner', 'user_groups', ['owner_id'])
# 11. Group Members Table
op.create_table(
'group_members',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('group_id', sa.BigInteger(), sa.ForeignKey('user_groups.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('role', sa.String(20), server_default='MEMBER'),
sa.Column('joined_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_members_group', 'group_members', ['group_id'])
op.create_index('idx_members_user', 'group_members', ['user_id'])
op.create_unique_constraint('uq_group_member', 'group_members', ['group_id', 'user_id'])
# 12. Group Chat Messages Table
op.create_table(
'group_chat_messages',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('group_id', sa.BigInteger(), sa.ForeignKey('user_groups.id', ondelete='CASCADE'), nullable=False),
sa.Column('sender_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_group_messages_group', 'group_chat_messages', ['group_id'])
op.create_index('idx_group_messages_sender', 'group_chat_messages', ['sender_id'])
op.create_index('idx_group_messages_created', 'group_chat_messages', ['created_at'])
# 13. User Networks Table
op.create_table(
'user_networks',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('creator_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('is_public', sa.Boolean(), default=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
# 14. Network Members Table
op.create_table(
'network_members',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('network_id', sa.BigInteger(), sa.ForeignKey('user_networks.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('role', sa.String(20), server_default='MEMBER'),
sa.Column('joined_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_network_members_network', 'network_members', ['network_id'])
op.create_index('idx_network_members_user', 'network_members', ['user_id'])
op.create_unique_constraint('uq_network_member', 'network_members', ['network_id', 'user_id'])
# 15. User Preferences Table
op.create_table(
'user_preferences',
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), primary_key=True),
sa.Column('theme', sa.String(20), server_default='light'),
sa.Column('notifications_enabled', sa.Boolean(), default=True),
sa.Column('email_notifications', sa.Boolean(), default=True),
sa.Column('auto_save_decks', sa.Boolean(), default=True),
sa.Column('default_format', sa.String(50), server_default='standard'),
sa.Column('language', sa.String(5), server_default='EN'),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
# 16. User Activity Log Table
op.create_table(
'user_activity_log',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('activity_type', sa.String(50), nullable=False),
sa.Column('activity_data', sa.JSON(), nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_activity_user', 'user_activity_log', ['user_id'])
op.create_index('idx_activity_type', 'user_activity_log', ['activity_type'])
op.create_index('idx_activity_created', 'user_activity_log', ['created_at'])
def downgrade() -> None:
"""Drop all user data tables."""
op.drop_table('user_activity_log')
op.drop_table('user_preferences')
op.drop_table('network_members')
op.drop_table('user_networks')
op.drop_table('group_chat_messages')
op.drop_table('group_members')
op.drop_table('user_groups')
op.drop_table('card_wishlist')
op.drop_table('user_card_collection')
op.drop_table('user_statistics')
op.drop_table('game_outcomes')
op.drop_table('replay_players')
op.drop_table('game_replays')
op.drop_table('deck_versions')
op.drop_table('user_sessions')
# Drop added columns from existing table
op.drop_column('mtgonline_decklist_files', 'last_played')
op.drop_column('mtgonline_decklist_files', 'import_confidence')
op.drop_column('mtgonline_decklist_files', 'import_source')
op.drop_column('mtgonline_decklist_files', 'is_favorite')
op.drop_column('mtgonline_decklist_files', 'format')
op.drop_column('mtgonline_decklist_files', 'description')
+2
View File
@@ -0,0 +1,2 @@
# Alembic migration scripts
# These files are generated by Alembic and should not be edited