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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user