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:
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Core package
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Models package
|
||||
@@ -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())
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Routers package
|
||||
@@ -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 []
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Games package
|
||||
@@ -0,0 +1 @@
|
||||
# Schemas package
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Cockatrice protocol constants and message definitions."""
|
||||
"""MTG Online protocol constants and message definitions."""
|
||||
import enum
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user