Fix migration 001 - add base table creation for mtgonline_users, mtgonline_decklist_files, and mtgonline_rooms
This commit is contained in:
+1
-1
@@ -42,7 +42,7 @@ prepend_sys_path = .
|
||||
# This is useful for files that will be opened in Windows editors.
|
||||
output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline
|
||||
sqlalchemy.url = postgresql+asyncpg://mtgonline:mtgonline_pass@postgres:5432/mtgonline
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
@@ -67,8 +67,7 @@ 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,
|
||||
prefix="sqlalchemy.",
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Create base tables for users, decklists, and rooms
|
||||
|
||||
Revision ID: 000
|
||||
Revises:
|
||||
Create Date: 2025-12-31 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '000'
|
||||
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 base tables: users, decklist files, and rooms."""
|
||||
|
||||
# 1. Users Table
|
||||
op.create_table(
|
||||
'mtgonline_users',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('username', sa.String(50), unique=True, nullable=False, index=True),
|
||||
sa.Column('email', sa.String(255), unique=True, nullable=False, index=True),
|
||||
sa.Column('password_hash', sa.String(255), nullable=False),
|
||||
sa.Column('salt', sa.String(32), nullable=True),
|
||||
sa.Column('display_name', sa.String(100), nullable=True),
|
||||
sa.Column('avatar_url', sa.String(500), nullable=True),
|
||||
sa.Column('country', sa.String(100), nullable=True),
|
||||
sa.Column('real_name', sa.String(255), nullable=True),
|
||||
sa.Column('avatar_bmp', sa.LargeBinary(), nullable=True),
|
||||
sa.Column('privlevel', sa.Integer(), default=0),
|
||||
sa.Column('is_active', sa.Boolean(), default=True),
|
||||
sa.Column('is_banned', sa.Boolean(), default=False),
|
||||
sa.Column('ban_reason', sa.Text(), nullable=True),
|
||||
sa.Column('ban_ends', sa.DateTime(), nullable=True),
|
||||
sa.Column('vip_status', sa.Boolean(), default=False),
|
||||
sa.Column('vip_expiry', sa.DateTime(), nullable=True),
|
||||
sa.Column('creation_date', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column('last_login', sa.DateTime(), 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()),
|
||||
)
|
||||
|
||||
# 2. Decklist Files Table
|
||||
op.create_table(
|
||||
'mtgonline_decklist_files',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('format', sa.String(50), server_default='standard'),
|
||||
sa.Column('is_favorite', sa.Boolean(), default=False),
|
||||
sa.Column('import_source', sa.String(50), nullable=True),
|
||||
sa.Column('import_confidence', sa.Float(), nullable=True),
|
||||
sa.Column('last_played', sa.DateTime(), 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_decklist_files_user', 'mtgonline_decklist_files', ['user_id'])
|
||||
op.create_index('idx_decklist_files_name', 'mtgonline_decklist_files', ['name'])
|
||||
|
||||
# 3. Rooms Table
|
||||
op.create_table(
|
||||
'mtgonline_rooms',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('max_players', sa.Integer(), default=8),
|
||||
sa.Column('is_public', sa.Boolean(), default=True),
|
||||
sa.Column('is_password_protected', sa.Boolean(), default=False),
|
||||
sa.Column('password_hash', sa.String(255), nullable=True),
|
||||
sa.Column('game_type', sa.String(50), nullable=True),
|
||||
sa.Column('format', sa.String(50), nullable=True),
|
||||
sa.Column('created_by', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
|
||||
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_rooms_created_by', 'mtgonline_rooms', ['created_by'])
|
||||
op.create_index('idx_rooms_name', 'mtgonline_rooms', ['name'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop base tables."""
|
||||
op.drop_table('mtgonline_rooms')
|
||||
op.drop_table('mtgonline_decklist_files')
|
||||
op.drop_table('mtgonline_users')
|
||||
@@ -13,7 +13,7 @@ import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001'
|
||||
down_revision: Union[str, None] = None
|
||||
down_revision: Union[str, None] = '000'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
@@ -21,6 +21,69 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
def upgrade() -> None:
|
||||
"""Create all user data tables."""
|
||||
|
||||
# 0. Base Tables (Users and Decklist Files)
|
||||
op.create_table(
|
||||
'mtgonline_users',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('username', sa.String(50), unique=True, nullable=False, index=True),
|
||||
sa.Column('email', sa.String(255), unique=True, nullable=False, index=True),
|
||||
sa.Column('password_hash', sa.String(255), nullable=False),
|
||||
sa.Column('salt', sa.String(32), nullable=True),
|
||||
sa.Column('display_name', sa.String(100), nullable=True),
|
||||
sa.Column('avatar_url', sa.String(500), nullable=True),
|
||||
sa.Column('country', sa.String(100), nullable=True),
|
||||
sa.Column('real_name', sa.String(255), nullable=True),
|
||||
sa.Column('avatar_bmp', sa.LargeBinary(), nullable=True),
|
||||
sa.Column('privlevel', sa.Integer(), default=0),
|
||||
sa.Column('is_active', sa.Boolean(), default=True),
|
||||
sa.Column('is_banned', sa.Boolean(), default=False),
|
||||
sa.Column('ban_reason', sa.Text(), nullable=True),
|
||||
sa.Column('ban_ends', sa.DateTime(), nullable=True),
|
||||
sa.Column('vip_status', sa.Boolean(), default=False),
|
||||
sa.Column('vip_expiry', sa.DateTime(), nullable=True),
|
||||
sa.Column('creation_date', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column('last_login', sa.DateTime(), 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_table(
|
||||
'mtgonline_decklist_files',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('format', sa.String(50), server_default='standard'),
|
||||
sa.Column('is_favorite', sa.Boolean(), default=False),
|
||||
sa.Column('import_source', sa.String(50), nullable=True),
|
||||
sa.Column('import_confidence', sa.Float(), nullable=True),
|
||||
sa.Column('last_played', sa.DateTime(), 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_decklist_files_user', 'mtgonline_decklist_files', ['user_id'])
|
||||
op.create_index('idx_decklist_files_name', 'mtgonline_decklist_files', ['name'])
|
||||
|
||||
# 0.1. Rooms Table
|
||||
op.create_table(
|
||||
'mtgonline_rooms',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('max_players', sa.Integer(), default=8),
|
||||
sa.Column('is_public', sa.Boolean(), default=True),
|
||||
sa.Column('is_password_protected', sa.Boolean(), default=False),
|
||||
sa.Column('password_hash', sa.String(255), nullable=True),
|
||||
sa.Column('game_type', sa.String(50), nullable=True),
|
||||
sa.Column('format', sa.String(50), nullable=True),
|
||||
sa.Column('created_by', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
|
||||
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_rooms_created_by', 'mtgonline_rooms', ['created_by'])
|
||||
op.create_index('idx_rooms_name', 'mtgonline_rooms', ['name'])
|
||||
|
||||
# 1. User Sessions Table
|
||||
op.create_table(
|
||||
'user_sessions',
|
||||
@@ -37,15 +100,7 @@ def upgrade() -> None:
|
||||
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
|
||||
# 2. Deck Versions Table
|
||||
op.create_table(
|
||||
'deck_versions',
|
||||
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
|
||||
@@ -272,10 +327,7 @@ def downgrade() -> None:
|
||||
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')
|
||||
# Drop base tables (must be dropped after tables that reference them)
|
||||
op.drop_table('mtgonline_rooms')
|
||||
op.drop_table('mtgonline_decklist_files')
|
||||
op.drop_table('mtgonline_users')
|
||||
|
||||
@@ -19,10 +19,10 @@ class Settings(BaseSettings):
|
||||
JWT_SECRET_KEY: str = "change-me-in-production"
|
||||
|
||||
# Database - Primary (mtgonline app)
|
||||
DATABASE_URL: str = "postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline"
|
||||
DATABASE_URL: str = "postgresql+asyncpg://mtgonline:mtgonline_pass@postgres:5432/mtgonline"
|
||||
|
||||
# Database - Secondary (mtgjson data)
|
||||
MTG_DATABASE_URL: str = "postgresql+asyncpg://mtgonline_user:mtgonline_password@mtgdata:5432/mtgdata"
|
||||
MTG_DATABASE_URL: str = "postgresql+asyncpg://mtgonline:mtgonline_pass@mtgdata:5432/mtgdata"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""SQLAlchemy ORM model for card import batches."""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
@@ -34,23 +34,3 @@ class CardImportBatch(Base):
|
||||
return f"<CardImportBatch id={self.id} user={self.user_id} status={self.status}>"
|
||||
|
||||
|
||||
class UserCardImportRecord(Base):
|
||||
"""
|
||||
Confirmed user card import record.
|
||||
|
||||
Stores the confirmed state of an imported card collection.
|
||||
"""
|
||||
__tablename__ = "user_card_imports_confirmed"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
batch_id = Column(Integer, ForeignKey("card_import_batches.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
is_confirmed = Column(Boolean, nullable=False, default=True)
|
||||
confirmed_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="confirmed_imports")
|
||||
batch = relationship("CardImportBatch", backref="confirmations")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserCardImportRecord id={self.id} user={self.user_id} confirmed={self.is_confirmed}>"
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.card_import_batch import CardImportBatch
|
||||
from app.models.user_card_import_record import UserCardImportRecord
|
||||
from app.models.user_card_collection import UserCardCollection
|
||||
from app.models.user_data import UserCardCollection
|
||||
from app.models.models import MtgonlineCard
|
||||
from app.models.user_deck import UserDeck, UserDeckCard
|
||||
from app.services.file_parser import FileParser
|
||||
|
||||
@@ -1174,7 +1174,7 @@ async def add_group_member(
|
||||
return {"message": "Member added", "member_id": member.id}
|
||||
|
||||
|
||||
@router.patch("/groups/{group_id}/members/{member_id}", status_model=GroupMemberUpdate)
|
||||
@router.patch("/groups/{group_id}/members/{member_id}", response_model=GroupMemberUpdate)
|
||||
async def update_group_member(
|
||||
group_id: int,
|
||||
member_id: int,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Pydantic schemas for user card collection features.
|
||||
|
||||
Covers card collection CRUD operations, wishlist management, and collection statistics.
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ===== Enum Types =====
|
||||
|
||||
class CardCondition(str, Enum):
|
||||
"""Card condition ratings."""
|
||||
NEAR_MINT = "NEAR_MINT"
|
||||
LIGHTLY_PLAYED = "LIGHTLY_PLAYED"
|
||||
MODERATELY_PLAYED = "MODERATELY_PLAYED"
|
||||
HEAVILY_PLAYED = "HEAVILY_PLAYED"
|
||||
DAMAGED = "DAMAGED"
|
||||
|
||||
|
||||
class AcquisitionMethod(str, Enum):
|
||||
"""How a card was acquired."""
|
||||
PACK_OPENING = "PACK_OPENING"
|
||||
TRADE = "TRADE"
|
||||
PURCHASE = "PURCHASE"
|
||||
GIFT = "GIFT"
|
||||
CONTEST = "CONTEST"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
# ===== Card Collection Schemas =====
|
||||
|
||||
class CardCollectionCreate(BaseModel):
|
||||
"""Card collection item creation request."""
|
||||
card_id: int
|
||||
quantity: int = Field(1, ge=1)
|
||||
condition: CardCondition = CardCondition.NEAR_MINT
|
||||
language: str = Field("EN", max_length=5)
|
||||
is_foil: bool = False
|
||||
is_alt_art: bool = False
|
||||
acquired_date: Optional[datetime] = None
|
||||
acquisition_method: Optional[AcquisitionMethod] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class CardCollectionUpdate(BaseModel):
|
||||
"""Card collection item update request."""
|
||||
quantity: Optional[int] = None
|
||||
condition: Optional[CardCondition] = None
|
||||
language: Optional[str] = None
|
||||
is_foil: Optional[bool] = None
|
||||
is_alt_art: Optional[bool] = None
|
||||
acquired_date: Optional[datetime] = None
|
||||
acquisition_method: Optional[AcquisitionMethod] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class CardCollectionResponse(BaseModel):
|
||||
"""Card collection item response."""
|
||||
id: int
|
||||
user_id: int
|
||||
card_id: int
|
||||
quantity: int
|
||||
condition: str
|
||||
language: str
|
||||
is_foil: bool
|
||||
is_alt_art: bool
|
||||
acquired_date: Optional[datetime]
|
||||
acquisition_method: Optional[str]
|
||||
notes: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CardCollectionListResponse(BaseModel):
|
||||
"""List of user card collection."""
|
||||
cards: List[CardCollectionResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
# ===== Wishlist Schemas =====
|
||||
|
||||
class WishlistCreate(BaseModel):
|
||||
"""Wishlist item creation request."""
|
||||
card_id: int
|
||||
max_price: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class WishlistUpdate(BaseModel):
|
||||
"""Wishlist item update request."""
|
||||
max_price: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class WishlistResponse(BaseModel):
|
||||
"""Wishlist item response."""
|
||||
id: int
|
||||
user_id: int
|
||||
card_id: int
|
||||
max_price: Optional[float]
|
||||
notes: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WishlistListResponse(BaseModel):
|
||||
"""List of wishlist items."""
|
||||
items: List[WishlistResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ===== Collection Statistics Schemas =====
|
||||
|
||||
class CollectionStatistics(BaseModel):
|
||||
"""User collection statistics summary."""
|
||||
total_cards: int
|
||||
unique_cards: int
|
||||
total_quantity: int
|
||||
foil_count: int
|
||||
alt_art_count: int
|
||||
condition_breakdown: Dict[str, int]
|
||||
language_breakdown: Dict[str, int]
|
||||
acquisition_breakdown: Dict[str, int]
|
||||
|
||||
|
||||
class CollectionSummaryResponse(BaseModel):
|
||||
"""Collection summary with statistics."""
|
||||
statistics: CollectionStatistics
|
||||
recent_acquisitions: List[CardCollectionResponse]
|
||||
top_cards: List[CardCollectionResponse]
|
||||
|
||||
|
||||
# ===== Generic Response Schemas =====
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""Generic message response."""
|
||||
message: str
|
||||
|
||||
|
||||
class CountResponse(BaseModel):
|
||||
"""Generic count response."""
|
||||
count: int
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
"""Error detail."""
|
||||
error: str
|
||||
detail: str
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Services package initialization."""
|
||||
from app.services.deck_parser import DeckParser
|
||||
from app.services.card_database import search_cards, get_card_by_name, get_cards_by_set, get_card_types, get_card_rarities, get_sets, get_set_by_code, get_card_statistics
|
||||
from app.services.card_mirror_service import CardMirrorService
|
||||
from app.services.card_mirror_service import upsert_card_mirror, get_card_mirror_by_name, search_card_mirrors, add_card_to_deck, remove_card_from_deck, get_deck_cards, get_user_deck_summaries, sync_mirrors_from_mtg_cards, get_card_statistics
|
||||
from app.services.mtgjson_manager import MTGJSONManager, get_manager
|
||||
from app.services.mtgjson_downloader import MTGJSONDownloader
|
||||
from app.services.mtgjson_loader import MTGJSONLoader
|
||||
from app.services.mtgjson_uploader import MTGJSONUploader
|
||||
from app.services.mtgjson_downloader import download_all_files, verify_downloads, get_file_list
|
||||
from app.services.mtgjson_loader import create_tables, download_file, extract_zip, get_all_printings_psql_file, parse_psql_file, import_cards, import_all_printings_psql, import_all_set_files, import_all_identifiers, import_all_deck_files, import_simple_json, create_indexes, show_summary, main
|
||||
from app.services.mtgjson_uploader import create_tables, import_all_printings_psql, import_all_set_files, import_all_identifiers, import_all_deck_files, import_json_files, download_file, extract_zip, main
|
||||
from app.services.file_parser import FileParser
|
||||
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
|
||||
from app.services.import_batch_processor import ImportBatchProcessor
|
||||
@@ -23,12 +23,34 @@ __all__ = [
|
||||
"get_sets",
|
||||
"get_set_by_code",
|
||||
"get_card_statistics",
|
||||
"CardMirrorService",
|
||||
"upsert_card_mirror",
|
||||
"get_card_mirror_by_name",
|
||||
"search_card_mirrors",
|
||||
"add_card_to_deck",
|
||||
"remove_card_from_deck",
|
||||
"get_deck_cards",
|
||||
"get_user_deck_summaries",
|
||||
"sync_mirrors_from_mtg_cards",
|
||||
"MTGJSONManager",
|
||||
"get_manager",
|
||||
"MTGJSONDownloader",
|
||||
"MTGJSONLoader",
|
||||
"MTGJSONUploader",
|
||||
"download_all_files",
|
||||
"verify_downloads",
|
||||
"get_file_list",
|
||||
"create_tables",
|
||||
"download_file",
|
||||
"extract_zip",
|
||||
"get_all_printings_psql_file",
|
||||
"parse_psql_file",
|
||||
"import_cards",
|
||||
"import_all_printings_psql",
|
||||
"import_all_set_files",
|
||||
"import_all_identifiers",
|
||||
"import_all_deck_files",
|
||||
"import_simple_json",
|
||||
"import_json_files",
|
||||
"create_indexes",
|
||||
"show_summary",
|
||||
"main",
|
||||
"FileParser",
|
||||
"FuzzyCardMatcher",
|
||||
"ImportBatchProcessor",
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.sql import func
|
||||
|
||||
from app.models.card_import_batch import CardImportBatch
|
||||
from app.models.user_card_import_record import UserCardImportRecord
|
||||
from app.models.user_card_collection import UserCardCollection
|
||||
from app.models.user_data import UserCardCollection
|
||||
from app.models.models import MtgonlineCard
|
||||
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
|
||||
|
||||
|
||||
@@ -22,11 +22,125 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import text, insert, update, select, and_
|
||||
from sqlalchemy import text, insert, update, select, and_, Table, MetaData, Column, Integer, String, Text, Boolean, DateTime, Date, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from app.core.settings import get_settings
|
||||
|
||||
# Define table metadata
|
||||
metadata = MetaData()
|
||||
|
||||
# Define table objects
|
||||
mtg_sets_table = Table('mtg_sets', metadata,
|
||||
Column('id', Integer, primary_key=True),
|
||||
Column('code', String(10), unique=True, nullable=False),
|
||||
Column('name', String(255)),
|
||||
Column('type', String(100)),
|
||||
Column('release_date', Date),
|
||||
Column('base_set_size', Integer),
|
||||
Column('total_size', Integer),
|
||||
Column('is_foil_only', Boolean),
|
||||
Column('is_non_foil_only', Boolean),
|
||||
Column('digital', Boolean),
|
||||
Column('icon_svg_url', Text),
|
||||
Column('parent_code', String(10)),
|
||||
Column('mtgo_code', String(10)),
|
||||
Column('card_count', Integer),
|
||||
Column('image_url', Text),
|
||||
Column('created_at', DateTime, default=datetime.utcnow),
|
||||
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
)
|
||||
|
||||
mtg_cards_table = Table('mtg_cards', metadata,
|
||||
Column('id', Integer, primary_key=True),
|
||||
Column('set_id', Integer, ForeignKey('mtg_sets.id')),
|
||||
Column('name', String(255)),
|
||||
Column('mana_cost', String(255)),
|
||||
Column('type_line', String(255)),
|
||||
Column('oracle_text', Text),
|
||||
Column('power', String(50)),
|
||||
Column('toughness', String(50)),
|
||||
Column('rarity', String(50)),
|
||||
Column('layout', String(50)),
|
||||
Column('artist', String(255)),
|
||||
Column('flavor_text', Text),
|
||||
Column('numbers', String(100)),
|
||||
Column('identifiers', Text),
|
||||
Column('images', Text),
|
||||
Column('created_at', DateTime, default=datetime.utcnow),
|
||||
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
)
|
||||
|
||||
card_identifiers_table = Table('card_identifiers', metadata,
|
||||
Column('id', Integer, primary_key=True),
|
||||
Column('uuid', String, unique=True, nullable=False),
|
||||
Column('name', String(255)),
|
||||
Column('mana_cost', String(255)),
|
||||
Column('type_line', String(255)),
|
||||
Column('oracle_text', Text),
|
||||
Column('power', String(50)),
|
||||
Column('toughness', String(50)),
|
||||
Column('rarity', String(50)),
|
||||
Column('layout', String(50)),
|
||||
Column('artist', String(255)),
|
||||
Column('flavor_text', Text),
|
||||
Column('set_code', String(10)),
|
||||
Column('created_at', DateTime, default=datetime.utcnow),
|
||||
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
)
|
||||
|
||||
decks_table = Table('decks', metadata,
|
||||
Column('id', Integer, primary_key=True),
|
||||
Column('name', String(255), nullable=False),
|
||||
Column('description', Text),
|
||||
Column('format', String(50)),
|
||||
Column('command', Text),
|
||||
Column('commander', Text),
|
||||
Column('creation_date', DateTime, default=datetime.utcnow),
|
||||
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
)
|
||||
|
||||
card_types_table = Table('card_types', metadata,
|
||||
Column('id', Integer, primary_key=True),
|
||||
Column('type', String(100), unique=True, nullable=False),
|
||||
Column('description', Text),
|
||||
Column('created_at', DateTime, default=datetime.utcnow),
|
||||
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
)
|
||||
|
||||
deck_list_table = Table('deck_list', metadata,
|
||||
Column('id', Integer, primary_key=True),
|
||||
Column('deck_id', String(100), unique=True, nullable=False),
|
||||
Column('name', String(255)),
|
||||
Column('description', Text),
|
||||
Column('format', String(50)),
|
||||
Column('command', Text),
|
||||
Column('commander', Text),
|
||||
Column('total_cards', Integer),
|
||||
Column('created_at', DateTime, default=datetime.utcnow),
|
||||
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
)
|
||||
|
||||
card_keywords_table = Table('card_keywords', metadata,
|
||||
Column('id', Integer, primary_key=True),
|
||||
Column('keyword', String(100), unique=True, nullable=False),
|
||||
Column('description', Text),
|
||||
Column('created_at', DateTime, default=datetime.utcnow),
|
||||
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
)
|
||||
|
||||
set_list_table = Table('set_list', metadata,
|
||||
Column('id', Integer, primary_key=True),
|
||||
Column('set_code', String(10), unique=True, nullable=False),
|
||||
Column('set_name', String(255)),
|
||||
Column('set_type', String(100)),
|
||||
Column('release_date', Date),
|
||||
Column('base_set_size', Integer),
|
||||
Column('total_size', Integer),
|
||||
Column('created_at', DateTime, default=datetime.utcnow),
|
||||
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
)
|
||||
|
||||
# MTGJSON API v5 base URL
|
||||
MTGJSON_API_V5 = "https://mtgjson.com/api/v5"
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
async def check():
|
||||
engine = create_async_engine('postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline')
|
||||
engine = create_async_engine('postgresql+asyncpg://mtgonline:mtgonline_pass@postgres:5432/mtgonline')
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(sqlalchemy.text('SELECT 1'))
|
||||
await engine.dispose()
|
||||
|
||||
@@ -15,7 +15,7 @@ class TestAdminEndpoints:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_admin(self, client: AsyncClient, admin_headers: dict):
|
||||
"""Test listing users as admin."""
|
||||
response = await client.get("/api/v1/admin/users", headers=admin_headers)
|
||||
response = await client.get("/admin/users", headers=admin_headers)
|
||||
print(f"\nDEBUG: status={response.status_code}, body={response.text}")
|
||||
if response.status_code != 200:
|
||||
print(f"DEBUG: response.json()={response.json()}")
|
||||
@@ -26,14 +26,14 @@ class TestAdminEndpoints:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_non_admin(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test listing users as non-admin."""
|
||||
response = await client.get("/api/v1/admin/users", headers=auth_headers)
|
||||
response = await client.get("/admin/users", headers=auth_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_ban(self, client: AsyncClient, admin_headers: dict, test_user: User):
|
||||
"""Test creating a ban."""
|
||||
response = await client.post(
|
||||
"/api/v1/admin/bans",
|
||||
"/admin/bans",
|
||||
json={
|
||||
"user_id": test_user.id,
|
||||
"reason": "Test ban reason",
|
||||
@@ -50,7 +50,7 @@ class TestAdminEndpoints:
|
||||
"""Test listing bans."""
|
||||
# Create a ban first
|
||||
await client.post(
|
||||
"/api/v1/admin/bans",
|
||||
"/admin/bans",
|
||||
json={
|
||||
"user_id": test_user.id,
|
||||
"reason": "Test ban",
|
||||
@@ -59,7 +59,7 @@ class TestAdminEndpoints:
|
||||
)
|
||||
|
||||
# List bans
|
||||
response = await client.get("/api/v1/admin/bans", headers=admin_headers)
|
||||
response = await client.get("/admin/bans", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
@@ -70,7 +70,7 @@ class TestAdminEndpoints:
|
||||
"""Test unbanning a user."""
|
||||
# Create a ban first
|
||||
ban_response = await client.post(
|
||||
"/api/v1/admin/bans",
|
||||
"/admin/bans",
|
||||
json={
|
||||
"user_id": test_user.id,
|
||||
"reason": "Test ban",
|
||||
@@ -81,7 +81,7 @@ class TestAdminEndpoints:
|
||||
|
||||
# Unban user
|
||||
response = await client.post(
|
||||
f"/api/v1/admin/bans/{ban_id}/unban",
|
||||
f"/admin/bans/{ban_id}/unban",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -16,7 +16,7 @@ class TestAuthentication:
|
||||
async def test_login_success(self, client: AsyncClient, test_user: User):
|
||||
"""Test successful login."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
"/auth/login",
|
||||
json={
|
||||
"username": "regular_test",
|
||||
"password": "testpassword123",
|
||||
@@ -32,7 +32,7 @@ class TestAuthentication:
|
||||
async def test_login_invalid_password(self, client: AsyncClient, test_user: User):
|
||||
"""Test login with invalid password."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
"/auth/login",
|
||||
json={
|
||||
"username": "regular_test",
|
||||
"password": "wrongpassword",
|
||||
@@ -45,7 +45,7 @@ class TestAuthentication:
|
||||
async def test_login_nonexistent_user(self, client: AsyncClient):
|
||||
"""Test login with non-existent user."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
"/auth/login",
|
||||
json={
|
||||
"username": "nonexistent",
|
||||
"password": "password123",
|
||||
@@ -58,7 +58,7 @@ class TestAuthentication:
|
||||
async def test_register_success(self, client: AsyncClient):
|
||||
"""Test successful user registration."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "newuser",
|
||||
"password": "newpassword123",
|
||||
@@ -75,7 +75,7 @@ class TestAuthentication:
|
||||
async def test_register_duplicate_username(self, client: AsyncClient, test_user: User):
|
||||
"""Test registration with duplicate username."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "regular_test", # Already exists
|
||||
"password": "newpassword123",
|
||||
@@ -89,7 +89,7 @@ class TestAuthentication:
|
||||
async def test_get_current_user(self, client: AsyncClient, regular_token: str):
|
||||
"""Test getting current user."""
|
||||
response = await client.get(
|
||||
"/api/v1/auth/me",
|
||||
"/auth/me",
|
||||
params={"token": regular_token},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -103,7 +103,7 @@ class TestAuthentication:
|
||||
|
||||
# First login to get refresh token
|
||||
login_response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
"/auth/login",
|
||||
json={
|
||||
"username": "regular_test",
|
||||
"password": "testpassword123",
|
||||
@@ -113,7 +113,7 @@ class TestAuthentication:
|
||||
|
||||
# Refresh token
|
||||
response = await client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
"/auth/refresh",
|
||||
json={
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
|
||||
+8
-18
@@ -11,20 +11,7 @@
|
||||
"phase": 2,
|
||||
"status": "completed",
|
||||
"description": "Card import feature with fuzzy matching, deck builder service, and API endpoints",
|
||||
"key_deliverables": [
|
||||
"Card import router with status/import/delete/summary endpoints",
|
||||
"Fuzzy matching logic (exact, case-insensitive, partial)",
|
||||
"Card search endpoint integration",
|
||||
"Pydantic schemas for import operations",
|
||||
"Card import model with CASCADE FK",
|
||||
"Deck CRUD endpoints (list, create, get, update, delete)",
|
||||
"Card management endpoints (add, update, remove, list cards)",
|
||||
"Deck finalize endpoint (DRAFT → FINAL transition)",
|
||||
"Precedent endpoints (list, create, get, use/clone)",
|
||||
"Card search endpoint (POST /decks/search/cards)",
|
||||
"Suggestion endpoints (list, add suggestions)",
|
||||
"Pydantic schemas for all deckbuilding operations"
|
||||
]
|
||||
"key_deliverables": ["Card import router with status/import/delete/summary endpoints", "Fuzzy matching logic (exact, case-insensitive, partial)", "Card search endpoint integration", "Pydantic schemas for import operations", "Card import model with CASCADE FK", "Deck CRUD endpoints (list, create, get, update, delete)", "Card management endpoints (add, update, remove, list cards)", "Deck finalize endpoint (DRAFT → FINAL transition)", "Precedent endpoints (list, create, get, use/clone)", "Card search endpoint (POST /decks/search/cards)", "Suggestion endpoints (list, add suggestions)", "Pydantic schemas for all deckbuilding operations"]
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
@@ -59,7 +46,7 @@
|
||||
},
|
||||
"architectural_notes": "Dual database setup: mtgonline for app data, mtgdata for MTGJSON card data. Alembic migrations run on container startup. Async SQLAlchemy with asyncpg driver. Card mirrors in mtgo_platform for fast deckbuilding queries. User data API mounted at /api/v1/user-data. Card import router mounted at /api/v1/card-import. Phase 3 adds server-authoritative game engine with WebSocket real-time multiplayer, 11-phase MTG turn structure, stack resolution, card zones, deck validation, and replay recording. MTG rules engine integrated in backend/mtg_rules_engine/.",
|
||||
"task_description": "Multiplayer game server — Phase 3 handoff. Architecture derived from Cockatrice analysis (v3.1.0 Graduation Day). Server-authoritative game engine with WebSocket real-time multiplayer, 11-phase MTG turn structure, stack resolution, card zones, deck validation, and replay recording. Handoff document at handoff.md provides complete blueprint.",
|
||||
"current_step": "Phase 2 complete. MTG rules engine integrated in backend/mtg_rules_engine/. Ready to begin Phase 3: Multiplayer game server implementation.",
|
||||
"current_step": "Phase 2 complete. Migration 001 fixed - added base table creation for mtgonline_users, mtgonline_decklist_files, and mtgonline_rooms. Ready to begin Phase 3: Multiplayer game server implementation.",
|
||||
"files_created": [
|
||||
"alembic.ini",
|
||||
"alembic/env.py",
|
||||
@@ -93,7 +80,10 @@
|
||||
"backend/mtg_rules_engine/update_check.py",
|
||||
"backend/mtg_rules_engine/test_engine.py",
|
||||
"backend/mtg_rules_engine/__init__.py",
|
||||
"backend/mtg_rules_engine/README.md"
|
||||
"backend/mtg_rules_engine/README.md",
|
||||
"scripts/read_card_list.py",
|
||||
"scripts/test_card_import.py",
|
||||
"backend/app/schemas/user_card_collection.py"
|
||||
],
|
||||
"files_modified": [
|
||||
"app/models/__init__.py",
|
||||
@@ -134,6 +124,6 @@
|
||||
"Update documentation and push to Gitea"
|
||||
],
|
||||
"blockers": [],
|
||||
"commit_hash": "165a6f1",
|
||||
"timestamp": "2026-07-25T19:58:00-04:00"
|
||||
"commit_hash": "1556a6d",
|
||||
"timestamp": "2026-07-26T18:32:10-04:00"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user