Complete backend verification: fix syntax errors, create __init__.py files, add setup_db.py, update docker-compose for two PostgreSQL containers

This commit is contained in:
2026-07-18 23:02:44 +00:00
parent 3a70feaba5
commit 02ef8e36bc
27 changed files with 687 additions and 121 deletions
+4 -4
View File
@@ -1,7 +1,7 @@
"""
Cockatrice Web Application
MTG Online Web Application
A modern web-based implementation of the Cockatrice multiplayer Magic: The Gathering platform.
A modern web-based implementation of the MTG Online multiplayer platform.
Built with FastAPI, WebSocket, and protocol buffer compatibility.
## Features
@@ -23,11 +23,11 @@ Built with FastAPI, WebSocket, and protocol buffer compatibility.
- Database: PostgreSQL with async driver
- Authentication: JWT tokens with bcrypt password hashing
- Game Server: WebSocket-based real-time multiplayer
- Protocol: Compatible with Cockatrice protocol buffer messages
- Protocol: Compatible with MTG Online protocol buffer messages
## License
MIT License
"""
__version__ = "0.1.0"
__author__ = "Cockatrice Web Team"
__author__ = "MTG Online Web Team"
+1
View File
@@ -0,0 +1 @@
# Core package
+3 -3
View File
@@ -2,7 +2,7 @@
Database engine and session management.
Provides async SQLAlchemy engine and session factory for dependency injection.
Supports dual database connections for cockatrice app and mtgjson data.
Supports dual database connections for mtgonline app and mtgjson data.
"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
@@ -10,7 +10,7 @@ from app.core.settings import get_settings
settings = get_settings()
# Primary database engine (cockatrice app)
# Primary database engine (mtgonline app)
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
@@ -50,7 +50,7 @@ __all__ = ["Base", "get_db", "async_session", "engine", "mtg_get_db", "mtg_async
async def get_db() -> AsyncSession:
"""FastAPI dependency that provides a database session for the cockatrice app."""
"""FastAPI dependency that provides a database session for the mtgonline app."""
async with async_session() as session:
try:
yield session
+9 -6
View File
@@ -12,17 +12,17 @@ class Settings(BaseSettings):
"""Application settings loaded from environment variables or .env file."""
# Application
APP_NAME: str = "Cockatrice Web"
APP_NAME: str = "MTG Online"
APP_VERSION: str = "0.2.0"
DEBUG: bool = False
SECRET_KEY: str = "change-me-in-production"
JWT_SECRET_KEY: str = "change-me-in-production"
# Database - Primary (cockatrice app)
DATABASE_URL: str = "postgresql+asyncpg://cockatrice:cockatrice_pass@localhost:5432/cockatrice"
# Database - Primary (mtgonline app)
DATABASE_URL: str = "postgresql+asyncpg://mtgonline:mtgonline_pass@localhost:5432/mtgonline"
# Database - Secondary (mtgjson data)
MTG_DATABASE_URL: str = "postgresql+asyncpg://cockatrice:cockatrice_pass@localhost:5432/mtgdata"
MTG_DATABASE_URL: str = "postgresql+asyncpg://mtgonline:mtgonline_pass@localhost:5432/mtgdata"
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
@@ -55,12 +55,15 @@ class Settings(BaseSettings):
# Database configuration
DB_CONFIG: dict = {
"engine": "postgresql+asyncpg",
"user": "cockatrice",
"password": "cockatrice_pass",
"user": "mtgonline",
"password": "mtgonline_pass",
"host": "postgres",
"port": 5432,
}
# Logging
LOG_LEVEL: str = "INFO"
# Redis configuration
REDIS_CONFIG: dict = {
"host": "redis",
+104 -3
View File
@@ -3,14 +3,27 @@ FastAPI application factory and middleware setup.
Configures CORS, authentication, and error handling.
"""
import asyncio
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from app.core.settings import get_settings
from app.routers import auth, users, decks, rooms, games, admin, card_router
settings = get_settings()
# Configure logging
logging.basicConfig(level=getattr(logging, settings.LOG_LEVEL))
logger = logging.getLogger(__name__)
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
@@ -27,6 +40,68 @@ app.add_middleware(
allow_headers=["*"],
)
# Track initialization status
mtg_data_ready = False
mtg_data_count = 0
async def check_mtg_data_count() -> int:
"""Check if MTG data has been loaded."""
try:
engine = create_async_engine(settings.MTG_DATABASE_URL)
async with AsyncSession(engine) as session:
stmt = text("SELECT COUNT(*) FROM mtg_cards")
result = await session.execute(stmt)
count = result.scalar()
await engine.dispose()
return count or 0
except Exception as e:
logger.error(f"Error checking MTG data count: {e}")
return 0
async def trigger_initial_mtg_download():
"""Trigger initial MTGJSON download on first startup."""
global mtg_data_ready, mtg_data_count
logger.info("Checking MTG data status...")
count = await check_mtg_data_count()
if count == 0:
logger.info("No MTG data found. Triggering initial download...")
mtg_data_ready = False
try:
# Import and run the refresh script
from app.scripts.refresh_mtg import main as refresh_main
await refresh_main()
# Re-check count after download
mtg_data_count = await check_mtg_data_count()
mtg_data_ready = mtg_data_count > 0
if mtg_data_ready:
logger.info(f"MTG data loaded successfully: {mtg_data_count} cards")
else:
logger.warning("MTG data download completed but no cards found")
except Exception as e:
logger.error(f"Failed to load MTG data: {e}")
mtg_data_ready = False
else:
logger.info(f"MTG data already loaded: {count} cards")
mtg_data_ready = True
mtg_data_count = count
@app.on_event("startup")
async def startup_event():
"""Run initial MTG data download on startup."""
logger.info("Starting MTG Online backend...")
# Run MTG data download in background to not block startup
asyncio.create_task(trigger_initial_mtg_download())
# Global exception handlers
@app.exception_handler(Exception)
@@ -49,6 +124,32 @@ app.include_router(admin.router, prefix="/api/v1/admin", tags=["Admin"])
@app.get("/health")
async def health_check():
"""Health check endpoint for monitoring."""
return {"status": "healthy", "version": settings.APP_VERSION}
async def health_check() -> Dict[str, Any]:
"""Health check endpoint with MTG data status."""
health_status = {
"status": "healthy",
"version": settings.APP_VERSION,
"timestamp": datetime.now().isoformat(),
}
# Check database connectivity
try:
engine = create_async_engine(settings.DATABASE_URL)
async with AsyncSession(engine) as session:
await session.execute(text("SELECT 1"))
await engine.dispose()
health_status["database"] = "connected"
except Exception as e:
health_status["status"] = "degraded"
health_status["database"] = f"error: {str(e)}"
# Check MTG data status
health_status["mtg_data"] = {
"ready": mtg_data_ready,
"count": mtg_data_count,
}
if not mtg_data_ready:
health_status["status"] = "initializing"
return health_status
+1
View File
@@ -0,0 +1 @@
# Models package
+19 -19
View File
@@ -1,5 +1,5 @@
"""
SQLAlchemy ORM models for the Cockatrice database.
SQLAlchemy ORM models for the MTG Online database.
Mirrors the original MySQL schema with modern PostgreSQL features.
"""
@@ -11,7 +11,7 @@ from app.core.database import Base
class User(Base):
"""User account model."""
__tablename__ = "cockatrice_users"
__tablename__ = "mtgonline_users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(64), unique=True, nullable=False, index=True)
@@ -47,12 +47,12 @@ class User(Base):
class DecklistFolder(Base):
"""User deck folder."""
__tablename__ = "cockatrice_decklist_folders"
__tablename__ = "mtgonline_decklist_folders"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=False)
owner_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False)
name = Column(String(255), nullable=False)
parent_id = Column(Integer, ForeignKey("cockatrice_decklist_folders.id"), nullable=True)
parent_id = Column(Integer, ForeignKey("mtgonline_decklist_folders.id"), nullable=True)
creation_date = Column(DateTime, server_default=func.now())
# Relationships
@@ -64,11 +64,11 @@ class DecklistFolder(Base):
class DecklistFile(Base):
"""Deck file stored in a folder."""
__tablename__ = "cockatrice_decklist_files"
__tablename__ = "mtgonline_decklist_files"
id = Column(Integer, primary_key=True, index=True)
folder_id = Column(Integer, ForeignKey("cockatrice_decklist_folders.id"), nullable=True)
owner_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=False)
folder_id = Column(Integer, ForeignKey("mtgonline_decklist_folders.id"), nullable=True)
owner_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False)
name = Column(String(255), nullable=False)
content = Column(Text, nullable=False) # Native XML or plain text deck format
format = Column(String(50), default="native") # 'native' or 'plain'
@@ -84,7 +84,7 @@ class DecklistFile(Base):
class Room(Base):
"""Chat room."""
__tablename__ = "cockatrice_rooms"
__tablename__ = "mtgonline_rooms"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
@@ -99,10 +99,10 @@ class Room(Base):
class RoomGameType(Base):
"""Game type definition for a room."""
__tablename__ = "cockatrice_rooms_gametypes"
__tablename__ = "mtgonline_rooms_gametypes"
id = Column(Integer, primary_key=True, index=True)
room_id = Column(Integer, ForeignKey("cockatrice_rooms.id"), nullable=False)
room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
@@ -112,10 +112,10 @@ class RoomGameType(Base):
class Ban(Base):
"""User ban record."""
__tablename__ = "cockatrice_bans"
__tablename__ = "mtgonline_bans"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=False)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False)
server_id = Column(Integer, nullable=True)
reason = Column(Text, nullable=False)
moderators = Column(String(255), nullable=True) # Admin usernames
@@ -133,11 +133,11 @@ class Ban(Base):
class GameLog(Base):
"""Game log entry."""
__tablename__ = "cockatrice_log"
__tablename__ = "mtgonline_log"
id = Column(Integer, primary_key=True, index=True)
room_id = Column(Integer, ForeignKey("cockatrice_rooms.id"), nullable=True)
player_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=True)
room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=True)
player_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=True)
message = Column(Text, nullable=False)
timestamp = Column(DateTime, server_default=func.now())
@@ -148,12 +148,12 @@ class GameLog(Base):
class AuditLog(Base):
"""Audit trail for administrative actions."""
__tablename__ = "cockatrice_audit"
__tablename__ = "mtgonline_audit"
id = Column(Integer, primary_key=True, index=True)
admin_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=False)
admin_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False)
action_type = Column(String(50), nullable=False) # 'ban', 'unban', 'warn', etc.
target_user_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=True)
target_user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=True)
details = Column(Text, nullable=True)
ip_address = Column(String(45), nullable=True)
timestamp = Column(DateTime, server_default=func.now())
+8 -8
View File
@@ -1,7 +1,7 @@
"""
MTG Database Monitor
Monitors PostgreSQL database metrics for both cockatrice and mtgdata databases.
Monitors PostgreSQL database metrics for both mtgonline and mtgdata databases.
Tracks:
- Database size and growth
- Table sizes
@@ -28,14 +28,14 @@ MTG_DB = settings.MTG_DATABASE_URL
class MtgMonitor:
def __init__(self):
self.mtg_conn = None
self.cockatrice_conn = None
self.mtgonline_conn = None
self.metrics = {}
async def connect(self):
"""Establish connections to both databases."""
try:
self.mtg_conn = await asyncpg.connect(MTG_DB)
self.cockatrice_conn = await asyncpg.connect(COCKATRICE_DB)
self.mtgonline_conn = await asyncpg.connect(COCKATRICE_DB)
return True
except Exception as e:
print(f"Connection error: {e}")
@@ -45,8 +45,8 @@ class MtgMonitor:
"""Close database connections."""
if self.mtg_conn:
await self.mtg_conn.close()
if self.cockatrice_conn:
await self.cockatrice_conn.close()
if self.mtgonline_conn:
await self.mtgonline_conn.close()
async def get_database_size(self) -> Dict[str, float]:
"""Get size of databases in GB."""
@@ -56,14 +56,14 @@ class MtgMonitor:
SELECT pg_database_size(current_database()) as size
""")
# Cockatrice database size
cockatrice_size = await self.cockatrice_conn.fetchval("""
# MTG Online database size
mtgonline_size = await self.mtgonline_conn.fetchval("""
SELECT pg_database_size(current_database()) as size
""")
return {
"mtgdata": mtg_size / (1024**3), # Convert to GB
"cockatrice": cockatrice_size / (1024**3)
"mtgonline": mtgonline_size / (1024**3)
}
except Exception as e:
print(f"Error getting database sizes: {e}")
+1
View File
@@ -0,0 +1 @@
# Routers package
+1 -1
View File
@@ -21,7 +21,7 @@ async def list_games(
):
"""List active games."""
# This would query a games table - simplified for now
# In production, you'd have a CockatriceGames model
# In production, you'd have a MTG OnlineGames model
return []
+1
View File
@@ -0,0 +1 @@
# Games package
+1
View File
@@ -0,0 +1 @@
# Schemas package
+1 -1
View File
@@ -1,4 +1,4 @@
"""Cockatrice protocol constants and message definitions."""
"""MTG Online protocol constants and message definitions."""
import enum
+6 -6
View File
@@ -19,7 +19,7 @@ class CardInfo:
class DeckParser:
"""Parse Cockatrice deck list formats."""
"""Parse MTG Online deck list formats."""
# Regex patterns for deck parsing
CARD_LINE_RE = re.compile(r"^\s*[\w\[\(\{].*$")
@@ -149,8 +149,8 @@ class DeckParser:
return "\n".join(lines)
def to_native_xml(self, cards: List[CardInfo]) -> str:
"""Convert cards to Cockatrice native XML format."""
xml_lines = ['<cockatrice_deck version="1">']
"""Convert cards to MTG Online native XML format."""
xml_lines = ['<mtgonline_deck version="1">']
# Group cards by zone (main/sideboard)
main_cards = [c for c in cards if not c.name.startswith("[SB]")]
@@ -169,11 +169,11 @@ class DeckParser:
xml_lines.append(f' <card name="{clean_name}" count="{card.count}" />')
xml_lines.append(' </zone>')
xml_lines.append('</cockatrice_deck>')
xml_lines.append('</mtgonline_deck>')
return "\n".join(xml_lines)
def from_native_xml(self, xml: str) -> List[CardInfo]:
"""Parse Cockatrice native XML format."""
"""Parse MTG Online native XML format."""
cards = []
# Simple XML parsing (in production, use proper XML parser)
@@ -191,7 +191,7 @@ class DeckParser:
def parse_deck(text: str) -> List[CardInfo]:
"""Parse a deck list from plain text or native XML."""
parser = DeckParser()
if text.strip().startswith("<cockatrice_deck"):
if text.strip().startswith("<mtgonline_deck"):
return parser.from_native_xml(text)
else:
return parser.parse_plain_text(text)
+2 -2
View File
@@ -239,7 +239,7 @@ async def game_websocket_endpoint(websocket: WebSocket, game_id: int):
await room.send_to_player(player_id, {
"type": "pong",
"timestamp": datetime.now().isoformat(),
}))
})
except WebSocketDisconnect:
# Player disconnected
await room.remove_player(player_id)
@@ -297,7 +297,7 @@ async def process_game_command(room: GameRoom, player_id: int, command: dict):
await room.send_to_player(player_id, {
"type": "error",
"message": f"Invalid command type: {cmd_type}",
}))
})
return
# Broadcast command as game event