Complete architecture simplification: 54+ files → 37 files

- Removed 11 duplicate router files (auth.py, users.py, decks.py, card_router.py, interactions.py, user_data.py, rooms.py, games/router.py, refresh.py, card_import.py, ws.py)
- Removed duplicate core files (logging_config.py, settings.py)
- Removed unused services (card_mirror_service.py, mtgjson_loader.py, mtgjson_uploader.py)
- Removed unused schemas (proto_messages.py, protocol_constants.py)
- Created run.py startup script for simple execution
- Created app/core/config.py for unified configuration management
- Created app/core/logging.py for logging configuration
- Consolidated routers into 6 focused files (auth_router.py, users_router.py, decks_router.py, cards_router.py, user_data_router.py, games_router.py)
- Updated all imports to use consolidated model paths
- Added backward compatibility aliases in database.py
- Created SIMPLIFICATION_SUMMARY.md documentation

Architecture improvements:
- Single unified database engine with auto-detection (SQLite/PostgreSQL)
- Consolidated models into 2 files (models.py, mtg_models.py)
- Clean router structure with one file per domain
- Simplified configuration with sensible defaults
- SQLite by default for development, PostgreSQL for production

Testing:
- All imports verified working
- App loads successfully
- Ready for development and testing
This commit is contained in:
2026-08-26 15:15:36 +00:00
parent 03f5433583
commit 900f51af3a
45 changed files with 5823 additions and 5460 deletions
+173 -632
View File
@@ -1,662 +1,203 @@
# MTG Online Backend - Handoff Document
## MTG Online Project - Architecture Simplification Request
## Project Overview
### Context
Project: MTG Online at `/home/wall-o/projects/mtgonline/`
Current State: Backend API with FastAPI, SQLAlchemy, Alembic, Docker Compose, PostgreSQL, Redis
Goal: Simplify the entire architecture so an LLM can better handle writing and debugging code
**Project Name**: MTG Online Backend
**Location**: `/home/wall-o/projects/mtgonline`
**Purpose**: Python FastAPI application that processes Magic: The Gathering card data from MTGJSON and stores it in PostgreSQL databases.
### What We Have
## Vision Statement (from STATEMENT_OF_INTENT.md)
**MTG Online Web** — A modern, web-based implementation of the MTG Online multiplayer Magic: The Gathering platform.
To build a fully-featured, open-source multiplayer Magic: The Gathering platform that runs entirely in modern web browsers, eliminating the need for desktop software installations while maintaining compatibility with the existing MTG Online ecosystem.
## Core Objectives
### User Experience
- Intuitive, modern interface that rivals native desktop applications
- Real-time multiplayer gameplay with minimal latency
- Seamless deck building with import/export from MTG Online
- Responsive design that works across all screen sizes
### Technical Excellence
- **Backend**: Python 3.12 + FastAPI with async architecture
- **Database**: PostgreSQL with async SQLAlchemy ORM
- **Real-time**: WebSocket-based game server for live multiplayer
- **Protocol**: Full compatibility with MTG Online protocol buffer messages
### Feature Parity with Desktop
- User authentication and account management
- Deck creation, editing, and storage (per-user)
- Multiplayer game rooms with real-time state sync
- Card game mechanics (mana, phases, priority, stack)
- Admin/moderation tools (ban, warn, log viewing)
- Card database integration with comprehensive card data
### Performance Requirements
- API response times < 100ms for 95% of requests
- WebSocket latency < 50ms for game state updates
- Support 1000+ concurrent users
- Sub-second page loads with proper caching
### Success Criteria
- [ ] Users can create accounts and authenticate securely
- [ ] Users can create, edit, and manage decks (per-user)
- [ ] Users can join and play multiplayer games in real-time
- [ ] Game state syncs correctly across all connected players
- [ ] Admin users can manage accounts and moderate games
- [ ] Deck formats are compatible with MTG Online desktop client
## Current Status
All planned tasks have been completed:
- ✅ Documentation created (root README.md + backend/README.md)
- ✅ State.json consolidated to project root
- ✅ Commit pushed to Gitea (commit `c42d7ca`)
- ✅ Docker cleanup completed (all containers, images, volumes removed)
- ✅ **User data schema implemented** (16 tables with Alembic migrations)
- ✅ **Comprehensive API endpoints created** (7 routers covering all user data features)
## Architecture Summary
### Tech Stack
- **Backend**: Python 3.12, FastAPI, SQLAlchemy (async), asyncpg
- **Databases**: Dual PostgreSQL (14-alpine)
- Primary: `mtgonline` database (users, decks, auth)
- MTG Data: `mtgdata` database (card data, sets)
- **Cache**: Redis 7-alpine
- **Protocol**: Protocol buffer message compatibility
### Service Architecture
**Backend Structure:**
```
mtgonline/
├── backend/ # FastAPI application
│ ├── mtg_rules_engine/ # MTG rules engine (rules enforcement)
│ │ ├── engine.py # Core game engine
│ │ ├── rules_engine.py # Rules engine core
│ │ ├── keywords.py # Card keywords
│ │ ├── keywords_db.py # Keywords database
│ │ ├── keyword_validator.py # Keyword validation
│ │ ├── validator.py # Card validation
│ │ ├── updater.py # Engine updater
│ │ ├── update_check.py # Update checker
│ │ ├── test_engine.py # Engine tests
│ │ └── README.md # Rules engine docs
│ ├── app/
│ │ ├── core/ # Settings, database engines, Redis client
│ │ ├── models/ # SQLAlchemy ORM models (user_data.py - 16 models)
│ │ ├── routers/ # API route modules
│ │ ├── schemas/ # Pydantic request/response schemas
│ │ ├── services/ # Business logic (MTGJSON manager, card DB, game server, deck parser)
│ │ └── main.py # FastAPI app entry point
│ ├── alembic/ # Database migrations
│ ├── scripts/ # Utility scripts
│ ├── Dockerfile
│ ├── requirements.txt
│ └── .env.example
├── docker-compose.dev.yml # Development stack
├── docker-compose.yml # Production stack
└── README.md # Project documentation
backend/
├── app/
│ ├── core/ # Settings, database engines, Redis client, logging
│ ├── models/ # 10+ SQLAlchemy model files (models.py, user_data.py, user_deck.py, etc.)
│ ├── routers/ # 10+ router files (auth, users, decks, games, admin, card_router, etc.)
│ ├── schemas/ # 13+ Pydantic schema files
│ ├── services/ # 15+ service files (mtgjson_manager.py is 53KB, mtgjson_loader.py is 32KB)
│ └── main.py # FastAPI app with 10+ router mounts
├── mtg_rules_engine/ # 10+ files for game rules engine
├── alembic/ # Database migrations
├── Dockerfile
├── docker-compose.yml
└── requirements.txt
```
### Data Flow
1. Backend connects to PostgreSQL (both instances) and Redis on startup
2. Checks `mtg_refresh_log` in `mtgdata` database for existing data
3. If no data exists, downloads MTGJSON files from `https://mtgjson.com/api/v5/`
4. Upserts data into `mtgdata` tables (`mtg_sets`, `mtg_cards`, etc.)
5. Data available via REST endpoints
**Key Issues:**
1. **Fragmented models** - 10+ separate model files that should be consolidated
2. **Massive services** - `mtgjson_manager.py` is 53KB, `mtgjson_loader.py` is 32KB
3. **Docker complexity** - Multi-database setup (PostgreSQL x2, Redis) with Docker Compose
4. **Async everywhere** - Async SQLAlchemy, asyncpg, async Redis - adds complexity for development
5. **Redundant schemas** - 13+ schema files with potential duplication
6. **Empty frontend** - Frontend directory exists but is empty
7. **Multiple entry points** - Scripts, migrations, Docker, direct Python execution
### Key Database Connections
- **Primary DB**: `postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline`
- **MTG DB**: `postgresql+asyncpg://mtgonline_user:mtgonline_password@mtgdata:5432/mtgdata`
- **Redis**: `redis://redis:6379`
### What We Want
## Environment Configuration
Simplify the architecture to be **LLM-friendly** with these principles:
### Docker Compose Dev Environment Variables
```yaml
environment:
DATABASE_URL: "postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline"
MTG_DATABASE_URL: "postgresql+asyncpg://mtgonline_user:mtgonline_password@mtgdata:5432/mtgdata"
REDIS_URL: "redis://redis:6379"
```
1. **Single database** - Use SQLite for development/local, PostgreSQL for production
2. **Consolidated models** - Merge related models into fewer files
3. **Simplified services** - Break large services into focused, smaller modules
4. **No Docker for development** - Run directly with Python/uvicorn
5. **Linear structure** - Reduce nesting and indirection
6. **Clear separation** - Each file has one clear responsibility
7. **Easy startup** - `python run.py` starts everything
### Service Ports (Host to Container)
- PostgreSQL: `5432:5432`
- MTG Data PostgreSQL: `5433:5432`
- Redis: `6379:6379`
- Backend: `5555:8000`
### Specific Simplifications
## API Endpoints
#### 1. Database Layer
- **Current**: Dual PostgreSQL databases (mtgonline, mtgdata) + Redis
- **Simplified**: Single SQLite database for development, optional PostgreSQL for production
- **Why**: SQLite eliminates connection pooling, async drivers, migration complexity
### Health & Status
- `GET /health` - Health check with MTGJSON status
- `GET /` - API info
#### 2. Models Layer
- **Current**: 10+ model files (models.py, user_data.py, user_deck.py, mirror_models.py, etc.)
- **Simplified**: 2-3 consolidated model files
- `models.py` - All application models
- `mtg_models.py` - All MTG card data models
- **Why**: Reduces import complexity, makes relationships clearer
### Authentication
- `POST /auth/login` - User login
- `POST /auth/register` - User registration
- `POST /auth/refresh` - Refresh JWT
- `GET /auth/me` - Current user
#### 3. Services Layer
- **Current**: 15+ service files, some massive (mtgjson_manager.py = 53KB)
- **Simplified**: 5-7 focused service files
- `card_service.py` - Card search and lookup
- `deck_service.py` - Deck CRUD and validation
- `user_service.py` - User management
- `mtgjson_service.py` - MTGJSON data management (split from current 53KB file)
- `game_service.py` - Game state management
- **Why**: Each file < 1000 lines, clear single responsibility
### Users
- `GET /users/{user_id}` - Get user
- `PATCH /users/{user_id}` - Update user
- `POST /users/{user_id}/ban` - Ban user (admin)
#### 4. Routers Layer
- **Current**: 10+ router files
- **Simplified**: 5-6 router files
- `auth_router.py` - Authentication
- `user_router.py` - User data
- `deck_router.py` - Deck management
- `card_router.py` - Card search and import
- `game_router.py` - Game management
- `admin_router.py` - Admin functions
- **Why**: One router per domain, easier to navigate
### Decks
- `GET /decks/` - List decks
- `POST /decks/` - Create deck
- `GET /decks/{deck_id}` - Get deck
- `PATCH /decks/{deck_id}` - Update deck
- `DELETE /decks/{deck_id}` - Delete deck
#### 5. Schemas Layer
- **Current**: 13+ schema files
- **Simplified**: 5-6 schema files matching routers
- `auth_schemas.py` - Auth request/response
- `user_schemas.py` - User data schemas
- `deck_schemas.py` - Deck schemas
- `card_schemas.py` - Card schemas
- `game_schemas.py` - Game schemas
- **Why**: Schemas co-located with their router domain
### MTG Cards
- `GET /api/cards/` - Search cards
- `GET /api/cards/{card_id}` - Get card
- `GET /api/sets/` - List sets
#### 6. Startup Script
- **Current**: Docker Compose, entrypoint scripts, migration scripts
- **Simplified**: Single `run.py` script
```python
# run.py
import uvicorn
from app.core.database import init_db
from app.main import app
### Admin
- `GET /admin/users` - List all users
- `GET /admin/bans` - List bans
- `POST /admin/bans` - Create ban
if __name__ == "__main__":
init_db() # Create tables if needed
uvicorn.run(app, host="0.0.0.0", port=8000)
```
- **Why**: One command to start everything
### Data Management
- `POST /refresh` - Trigger MTGJSON refresh
#### 7. Configuration
- **Current**: pydantic-settings with 20+ env vars, Docker-specific defaults
- **Simplified**: Simple config with sensible defaults
```python
# config.py
DATABASE_URL = "sqlite:///mtgonline.db" # or postgresql://...
DEBUG = True
SECRET_KEY = "dev-secret-key"
```
- **Why**: Works out of the box, easy to override
### WebSocket
- `WS /ws/{room_id}` - Real-time game communication
### Files to Review
## MTGJSON Data Pipeline
**Backend:**
- `/home/wall-o/projects/mtgonline/backend/app/main.py` - Application entry
- `/home/wall-o/projects/mtgonline/backend/app/core/` - Settings, database, Redis
- `/home/wall-o/projects/mtgonline/backend/app/models/` - All model files
- `/home/wall-o/projects/mtgonline/backend/app/routers/` - All router files
- `/home/wall-o/projects/mtgonline/backend/app/schemas/` - All schema files
- `/home/wall-o/projects/mtgonline/backend/app/services/` - All service files
- `/home/wall-o/projects/mtgonline/backend/mtg_rules_engine/` - Game rules engine
- `/home/wall-o/projects/mtgonline/backend/alembic/` - Migrations
- `/home/wall-o/projects/mtgonline/backend/requirements.txt` - Dependencies
### Downloaded Files
- `AllPrintings.psql` - Main card data (PostgreSQL format)
- `AllIdentifiers.json` - Card identifiers
- `Keywords.json` - Card keywords
- `CardTypes.json` - Card type definitions
- `AllDeckFiles.zip` - Deck files (unzipped on load)
**Project Root:**
- `/home/wall-o/projects/mtgonline/README.md`
- `/home/wall-o/projects/mtgonline/ROADMAP.md`
- `/home/wall-o/projects/mtgonline/state.json`
- `/home/wall-o/projects/mtgonline/docker-compose.yml`
- `/home/wall-o/projects/mtgonline/scripts/`
### Refresh Logic
1. **On Startup**: Checks `mtg_refresh_log` for existing data
2. **If No Data**: Downloads and loads all MTGJSON files (may take minutes)
3. **Manual Refresh**: `POST /refresh` triggers immediate reload
4. **Logging**: All refreshes logged to `mtg_refresh_log` with status, timing, and counts
### Important Considerations
## Git Repository
1. **Preserve functionality** - All API endpoints must work
2. **Keep tests passing** - Don't break existing tests
3. **Maintain data integrity** - Database migrations must work
4. **Support both SQLite and PostgreSQL** - Easy switch for production
5. **Document changes** - Update README with new setup instructions
6. **Remove Docker for dev** - Keep it optional for production only
7. **Reduce file count** - Target 50% reduction in total files
8. **Reduce code lines** - Target 30% reduction through consolidation
**Gitea Repository**: `https://git.optimex.systems/admin/mtgonline.git`
**Credentials**: Located at `/home/wall-o/projects/gitea_credentials.txt`
**Current Branch**: `main`
**Last Commit**: `c42d7ca` - "feat: implement user data schema and API endpoints"
### Expected Outcome
### Commit History
```
c42d7ca - feat: implement user data schema and API endpoints
- Add Alembic migration setup with async configuration
- Create 16 user data models (users, decks, cards, replays, etc.)
- Implement comprehensive API endpoints with JWT auth
- Add replay, card collection, group, network, preferences, and activity log routers
- Include API documentation and migration test plan
46abfe5 - Add comprehensive documentation for MTG Online Backend
6f01e2d - Initial project setup
```
After simplification:
- **Backend structure**:
```
backend/
├── app/
│ ├── core/
│ │ ├── config.py # Simple configuration
│ │ ├── database.py # Single database engine
│ │ └── logging.py # Logging setup
│ ├── models/
│ │ ├── models.py # All app models
│ │ └── mtg_models.py # All MTG models
│ ├── routers/
│ │ ├── auth_router.py
│ │ ├── user_router.py
│ │ ├── deck_router.py
│ │ ├── card_router.py
│ │ ├── game_router.py
│ │ └── admin_router.py
│ ├── schemas/
│ │ ├── auth_schemas.py
│ │ ├── user_schemas.py
│ │ ├── deck_schemas.py
│ │ ├── card_schemas.py
│ │ └── game_schemas.py
│ ├── services/
│ │ ├── card_service.py
│ │ ├── deck_service.py
│ │ ├── user_service.py
│ │ ├── mtgjson_service.py
│ │ └── game_service.py
│ └── main.py
├── mtg_rules_engine/ # Keep as-is (specialized)
├── run.py # Simple startup script
└── requirements.txt
```
## Running the Project
### Start Services
```bash
cd /home/wall-o/projects/mtgonline
docker compose -f docker-compose.dev.yml up -d
```
### Check Services
```bash
docker compose -f docker-compose.dev.yml ps
docker compose -f docker-compose.dev.yml logs -f backend
```
### Stop Services
```bash
docker compose -f docker-compose.dev.yml down
```
### Full Cleanup
```bash
# Stop and remove all containers
docker compose -f docker-compose.dev.yml down
# Remove images
docker images rm mtgonline-backend:latest postgres:14-alpine redis:7-alpine
# Clear build cache and prune
docker builder prune -af
docker system prune -af --volumes
```
## Testing
### Run Tests
```bash
cd /home/wall-o/projects/mtgonline/backend
docker exec -it <backend_container_id> pytest
# OR
cd /home/wall-o/projects/mtgonline/backend
pytest
```
### Database Verification
```bash
# Check MTG data tables
docker exec <mtgdata_container_id> psql -U mtgonline_user mtgdata -c "\dt"
# Check refresh log
docker exec <mtgdata_container_id> psql -U mtgonline_user mtgdata -c "SELECT * FROM mtg_refresh_log ORDER BY refresh_time DESC LIMIT 5;"
```
## Troubleshooting
### Backend can't connect to databases
- Verify all services are running: `docker compose -f docker-compose.dev.yml ps`
- Check logs: `docker compose -f docker-compose.dev.yml logs backend`
- Ensure environment variables match docker-compose.dev.yml
### MTGJSON download fails
- Check network connectivity to mtgjson.com
- Verify DATA_DIR has write permissions
- Check disk space: `df -h`
- Manual download: `python scripts/download_mtgjson.py`
### Database tables missing
- Run initialization: `docker exec -i <mtgdata_container_id> psql -U mtgonline_user mtgdata < /path/to/scripts/init-mtgdata.sql`
- Check tables: `docker exec <mtgdata_container_id> psql -U mtgonline_user mtgdata -c "\dt"`
### CORS errors
- Check CORS_ORIGINS setting in app/core/settings.py
- Ensure frontend URL matches allowed origins
## Phase 2 Complete: Deck Building & Card Management
The v1 backend work has been completed. The following features were implemented:
### Completed Features
#### 1. Per-User Deck Building
- **`user_decks` table** with DRAFT/FINAL status tracking
- **API endpoints**: Create, list, get, update, finalize, delete decks
- **Card search** integrated with MTG card database
- **Deck precedents** and card suggestion features
#### 2. Card Import from Files
- **Supported formats**: XLSX, CSV, JSON, ODS
- **Fuzzy matching** against MTG card database
- **Import flow**: Upload → Parse → Match → Confirm → Save
- **API endpoints**: Upload, status check, results, confirm, list user cards
#### 3. Database Schema
- **16 user data tables** with Alembic migrations
- **Async SQLAlchemy** with PostgreSQL
- **JSONB columns** for flexible data storage
#### 4. API Endpoints
- **Authentication**: Login, register, refresh, current user
- **Users**: Get, update, ban (admin)
- **Decks**: CRUD operations with status management
- **Cards**: Search, import, user card management
- **Admin**: User list, ban management
- **Data Management**: MTGJSON refresh
#### 5. MTG Rules Engine
- **Integrated in `backend/mtg_rules_engine/`**
- **Core modules**: `engine.py`, `rules_engine.py`, `keywords.py`, `validator.py`
- **Supporting modules**: `keywords_db.py`, `keyword_validator.py`, `updater.py`, `update_check.py`
- **Test suite**: `test_engine.py`
- **Purpose**: Enforces MTG game rules (mana, phases, priority, stack resolution, combat) for multiplayer server
### Testing & Verification
- ✅ All API endpoints tested and working
- ✅ Database migrations applied successfully
- ✅ Docker deployment verified
- ✅ Integration tests passing
- ✅ Rules engine source files extracted and integrated
---
## Session Summary: Phase 3 Architecture Planning
### Work Completed
This session focused on planning Phase 3 (Multiplayer Game Server) architecture and updating documentation to reflect key decisions:
#### 1. Architecture Decisions Documented
- **Chat System**: Pre-built Docker container (Tinode recommended)
- Option A architecture (separate container)
- WebSocket-based, frontend connects directly
- No custom codebase needed
- **Audio System**: Pre-built Docker container (Kurento or Janus)
- Option A architecture (separate container)
- WebRTC-based, frontend connects directly
- No custom codebase needed
- **Game Engine**: Python-based (not C++)
- Codebase being developed separately
- Will be integrated during Phase 3.1 (Core Game Server)
- Python module with volume mount
#### 2. Documentation Created
- **PHASE_3_PLANNING.md**: Comprehensive planning document with:
- Architecture overview with modular design
- Chat server solutions (Tinode, SimpleWebSocketChat, Zitadel)
- Audio server solutions (Kurento, Janus)
- Python game engine integration approach
- Development timeline (9 weeks across 4 phases)
- Docker configuration examples
- Risk assessment
- Frontend integration examples
#### 3. HANDOFF.md Updates
- Removed "Next Phase: V1 Backend" section (work completed)
- Added "Phase 2 Complete" section summarizing completed work
- Updated Phase 3 section with new architecture decisions
- Added architecture diagram showing modular design
- Documented integration points for chat and audio servers
- Updated summary table to reflect pre-built solutions
### Key Takeaways
1. **No custom chat/audio codebase needed** - using pre-built Docker solutions
2. **Python game engine** will be integrated as a module (not C++)
3. **Modular architecture** allows independent deployment of chat/audio
4. **Phase 3.1** will focus on core game server with Python engine integration
- **Startup**: `cd backend && python run.py`
- **Database**: SQLite by default, PostgreSQL optional
- **No Docker required** for development
### Next Steps
1. Await Python game engine codebase delivery
2. Deploy pre-built chat server (Tinode)
3. Deploy pre-built audio server (Kurento or Janus)
4. Begin Phase 3.1: Core Game Server implementation
---
1. **Review entire codebase** - Understand all files and their relationships
2. **Identify consolidation opportunities** - Which files can be merged?
3. **Create simplified structure** - Build new architecture incrementally
4. **Migrate functionality** - Move code to new structure
5. **Test everything** - Ensure all endpoints work
6. **Update documentation** - README, setup instructions
7. **Remove Docker complexity** - Keep only for production if needed
## Phase 3: Multiplayer Game Server (In Progress)
### 3.1 Overview
The multiplayer game server provides a **high-end graphic gameplay option** for players within the same group (groups are set by admins). This is the core real-time gameplay system that replaces the legacy desktop client.
**Architecture Decision**: The multiplayer server uses a **Python-based game engine** (not C++). The game engine codebase is being developed separately and will be integrated during Phase 3.
### 3.2 Architecture
The multiplayer server follows a **modular architecture** with pre-built Docker containers for chat and audio:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │────▶│ Game Server │────▶│ Card Backend │
│ (React/TS) │ │ (Python) │ │ (FastAPI) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Chat Server │ │ Audio Server │
│ (Pre-built │ │ (Pre-built │
│ Docker) │ │ Docker) │
└─────────────────┘ └─────────────────┘
```
**Key Decisions:**
- **Chat**: Pre-built Docker container (Tinode recommended) - Option A architecture
- **Audio**: Pre-built Docker container (Kurento or Janus) - Option A architecture
- **Game Engine**: Python-based, codebase being developed separately
### 3.3 Game Engine
A **Python-based game engine** codes all Magic: The Gathering rules directly into the multiplayer system. This means:
- Cards being played **automatically have the appropriate rules applied** as they are played
- The engine enforces game mechanics (mana, phases, priority, stack resolution, combat)
- No manual rule implementation per card — the engine handles it all
**Status**: The game engine codebase is **being developed separately** and will be integrated during Phase 3.1 (Core Game Server).
**Integration Approach:**
```python
# game_engine/engine.py
from game_engine.engine import GameEngine
engine = GameEngine()
engine.load_card('lightning-bolt')
result = engine.resolve_effect('lightning-bolt', target='player')
```
**Docker Volume Mount:**
```yaml
volumes:
- ./game-engine:/app/game_engine
```
### 3.4 Graphics
Graphics are handled in the **front-end development** (React/TypeScript with game board visualization). The multiplayer server provides:
- Game state synchronization
- Card data and metadata
- Real-time updates via WebSocket
The server does not handle rendering — that's the frontend's responsibility.
### 3.5 Chat System
The multiplayer server uses **pre-built Docker containers** for chat functionality:
#### Text-based Chat
- **Solution**: Tinode (recommended) or SimpleWebSocketChat
- **Architecture**: Option A (separate container)
- **Integration**: WebSocket-based, frontend connects directly
- **Features**: Room management, message persistence, moderation
**Docker Deployment (Tinode):**
```bash
docker run -d \
--name tinode \
-p 8080:8080 \
-v ./tinode-data:/data \
--restart unless-stopped \
tinode/tinode
```
**Frontend Integration:**
```javascript
import Tinode from 'tinode-sdk';
const tinode = new Tinode({ socket: 'ws://chat-server:8080' });
await tinode.connect();
await tinode.subscribe('game-room-123');
tinode.on('message', (topic, msg) => console.log('Chat:', msg));
tinode.sendMessage('game-room-123', { text: 'Hello game!' });
```
#### Audio-based Chat
- **Solution**: Kurento Media Server (recommended) or Janus Gateway
- **Architecture**: Option A (separate container)
- **Integration**: WebRTC-based, frontend connects directly
- **Features**: Low latency, scalable, open source
**Docker Deployment (Kurento):**
```bash
docker run -d \
--name kurento \
-p 8888:8888 \
-p 8443:8443 \
--restart unless-stopped \
kurento/kurento-media-server:latest
```
**No custom chat/audio codebase needed** — both are pre-built solutions.
### 3.6 Integration with Card Backend
The multiplayer server communicates with the card backend (current FastAPI app) via:
- **API Calls**: For authentication, user data, deck retrieval, card lookups
- **Direct PSQL Queries**: For card data and user deck data
#### Integration Points
- **Card Backend API** (`http://backend:8000`):
- `GET /api/cards/{card_id}` — Get card details for in-game display
- `GET /api/cards/search?q=...` — Search cards during gameplay
- `GET /api/sets/` — List available sets for game formatting
- **PSQL Direct Access** (via shared connection string):
- `mtgdata` database — Read card data (cards, sets, etc.)
- `mtgonline` database — Read user decks (for deck validation, game setup)
#### API Endpoints for Play Backend
- `POST /play/decks/{deck_id}/validate` — Validate deck against card database
- `GET /play/users/{user_id}/decks` — Get user's FINAL decks for selection
- `GET /play/cards/{card_id}` — Get card details for game board display
- `POST /play/chat/text` — Send text message in game/room (via Tinode)
- `POST /play/chat/audio` — Manage audio chat sessions (via Kurento)
### Summary of Work Required in Phase 3: Multiplayer Game Server
| Feature | Database | API | Notes |
|---------|----------|-----|-------|
| Game engine integration | N/A (Python codebase) | N/A | Being developed separately, integrate in Phase 3.1 |
| Multiplayer WebSocket server | `mtgonline` (rooms, games) | WebSocket hub | Python/FastAPI |
| Game state management | In-memory + DB persistence | State sync | Server-authoritative |
| MTG rule enforcement | N/A (game engine) | Automatic | Cards auto-apply rules |
| Text chat backend | N/A (Tinode) | WebSocket | Pre-built Docker container |
| Audio chat backend | N/A (Kurento) | WebRTC | Pre-built Docker container |
| Group-based access | `mtgonline` (groups) | Group validation | Admin-configured groups |
| Deck validation | `mtgdata` + `mtgonline` | Validation endpoint | Against card database |
| Card backend integration | Read-only | API consumer | Shared DB + REST API |
See **PHASE_3_PLANNING.md** for detailed task breakdown.
## User Data Schema & API
### Database Models (16 Tables)
- **`users`** - User accounts with authentication
- **`decks`** - User decks (DRAFT/FINAL status)
- **`cards`** - User card collections
- **`card_ownership`** - Card ownership tracking
- **`win_streaks`** - Win/loss statistics
- **`game_replays`** - Saved game replays (JSONB)
- **`groups`** - User groups
- **`group_members`** - Group membership
- **`networks`** - Network accounts (Twitch, X, YouTube)
- **`network_credentials`** - Network login info
- **`preferences`** - User preferences (JSONB)
- **`activity_log`** - User activity tracking (JSONB)
- **`suggested_cards`** - Card suggestions
- **`folders`** - Deck organization
- **`game_logs`** - Game audit trail
- **`user_decks`** - User deck storage (DRAFT/FINAL)
### API Endpoints
#### Replays (`/api/v1/user-data/replays`)
- `POST /api/v1/user-data/replays/` - Save replay
- `GET /api/v1/user-data/replays/{replay_id}` - Get replay
- `DELETE /api/v1/user-data/replays/{replay_id}` - Delete replay
#### Card Collection (`/api/v1/user-data/cards`)
- `GET /api/v1/user-data/cards/` - List user's cards
- `POST /api/v1/user-data/cards/` - Add card to collection
- `DELETE /api/v1/user-data/cards/{card_id}` - Remove card
#### Groups (`/api/v1/user-data/groups`)
- `GET /api/v1/user-data/groups/` - List user's groups
- `POST /api/v1/user-data/groups/` - Create group
- `PATCH /api/v1/user-data/groups/{group_id}` - Update group
- `DELETE /api/v1/user-data/groups/{group_id}` - Delete group
#### Networks (`/api/v1/user-data/networks`)
- `GET /api/v1/user-data/networks/` - List network accounts
- `POST /api/v1/user-data/networks/` - Add network
- `PATCH /api/v1/user-data/networks/{network_id}` - Update network
- `DELETE /api/v1/user-data/networks/{network_id}` - Remove network
#### Preferences (`/api/v1/user-data/preferences`)
- `GET /api/v1/user-data/preferences/` - Get preferences
- `PATCH /api/v1/user-data/preferences/` - Update preferences
#### Activity Log (`/api/v1/user-data/activity`)
- `GET /api/v1/user-data/activity/` - List activity
- `POST /api/v1/user-data/activity/` - Add activity entry
### Alembic Migrations
- **Async configuration** with `run_sync` for database operations
- **Initial migration**: `001_initial_user_schema.py` creates all 16 tables
- **Migration script**: `scripts/run_migrations.sh` runs on container startup
- **JSONB columns** used for flexible data storage (replay_data, activity_data, preferences)
### Architecture Decisions
- **CASCADE deletes** for data integrity in related tables
- **Composite unique constraints** for card collection uniqueness
- **RESTful API design** with pagination support
- **JWT authentication** for all endpoints
- **Permission checks** for group/network management
## State File
Current state saved at: `/home/wall-o/projects/mtgonline/state.json`
```json
{
"project_summary": "MTG Online Backend API with PostgreSQL database. Implements card game platform with deck management, card import, and user data features. Phase 3 (multiplayer game server) is in progress with game engine code incoming.",
"task_description": "Phase 3: Multiplayer game server with Cockatrice-inspired architecture, game engine integration, text/audio chat, and group-based gameplay",
"current_step": "Preparation phase - reviewing Cockatrice architecture analysis, awaiting game engine code delivery, planning server scaffolding",
"commit_hash": "1e7c762",
"timestamp": "2026-07-24T04:35:00-04:00"
}
```
## Access Information
- **Backend API**: `http://localhost:5555`
- **User Data API**: `http://localhost:5555/api/v1/user-data`
- **Swagger Docs**: `http://localhost:5555/docs`
- **Health Check**: `http://localhost:5555/health`
- **Gitea**: `https://git.optimex.systems/admin/mtgonline`
### Key Files to Review
#### Current State
1. `/home/wall-o/projects/mtgonline/state.json` - Current project state
2. `/home/wall-o/projects/mtgonline/README.md` - Project documentation
3. `/home/wall-o/projects/mtgonline/backend/README.md` - Backend documentation
#### Architecture & Configuration
4. `/home/wall-o/projects/mtgonline/docker-compose.dev.yml` - Docker configuration
5. `/home/wall-o/projects/mtgonline/backend/app/core/settings.py` - Application settings
6. `/home/wall-o/projects/mtgonline/backend/app/main.py` - FastAPI entry point (user-data mounted at /api/v1/user-data)
#### User Data Models
7. `/home/wall-o/projects/mtgonline/backend/app/models/user_data.py` - All user data models (16 tables)
8. `/home/wall-o/projects/mtgonline/backend/alembic/versions/001_initial_user_schema.py` - Migration script
#### API Endpoints
9. `/home/wall-o/projects/mtgonline/backend/app/routers/user_data.py` - User data API endpoints
10. `/home/wall-o/projects/mtgonline/backend/app/schemas/user_data_schemas.py` - Pydantic schemas for user data
#### Documentation
11. `/home/wall-o/projects/mtgonline/backend/API_DOCUMENTATION.md` - Comprehensive API documentation
12. `/home/wall-o/projects/mtgonline/backend/TEST_PLAN.md` - Migration test plan
13. `/home/wall-o/projects/mtgonline/ROADMAP.md` - Complete feature roadmap and timeline
14. `/home/wall-o/projects/mtgonline/STATEMENT_OF_INTENT.md` - Project vision and objectives
## Environment
- **OS**: Linux 6.8.0-136-generic (x86_64)
- **Docker**: Available
- **Python**: 3.12.3
- **Working Directory**: `/home/wall-o/projects/mtgonline`
---
**Last Updated**: 2026-07-25T19:58:00-04:00
**Status**: Phase 1-2 Complete. Phase 3 (Multiplayer Game Server) in progress. Rules engine integrated in `backend/mtg_rules_engine/`.
**Next Action**: Begin Phase 3 — set up multiplayer server scaffolding, integrate rules engine, implement WebSocket hub with chat support.
**Timeline**:
| Phase | Duration | Status |
|-------|----------|--------|
| Phase 1: Backend Foundation | 2 weeks | ✅ Complete |
| Phase 2: Card Import & Deck Building | 2 weeks | ✅ Complete |
| Phase 3: Multiplayer Game Server | TBD | 🔄 In Progress |
| Phase 4: Frontend Integration | TBD | Pending |
| Phase 5: Advanced Features | Ongoing | Future |
Please start by exploring the project structure and understanding the current architecture before making changes. Focus on understanding what each file does and how they interact.
+200
View File
@@ -0,0 +1,200 @@
# MTG Online Architecture Simplification Plan
## Current State Analysis
### Backend Structure
```
backend/
├── app/
│ ├── core/ # Settings, database engines, Redis client, logging
│ ├── models/ # 10+ SQLAlchemy model files
│ ├── routers/ # 10+ router files
│ ├── schemas/ # 13+ Pydantic schema files
│ ├── services/ # 15+ service files (some massive)
│ └── main.py # FastAPI app with 10+ router mounts
├── mtg_rules_engine/ # 10+ files for game rules engine
├── alembic/ # Database migrations
├── Dockerfile
├── docker-compose.yml
└── requirements.txt
```
### Key Issues Identified
1. **Fragmented models** - 10+ separate model files that should be consolidated
2. **Massive services** - `mtgjson_manager.py` is 53KB, `mtgjson_loader.py` is 32KB
3. **Docker complexity** - Multi-database setup (PostgreSQL x2, Redis) with Docker Compose
4. **Async everywhere** - Async SQLAlchemy, asyncpg, async Redis - adds complexity
5. **Redundant schemas** - 13+ schema files with potential duplication
6. **Empty frontend** - Frontend directory exists but is empty
7. **Multiple entry points** - Scripts, migrations, Docker, direct Python execution
### Router Analysis
- **auth.py** (162 lines) - Authentication endpoints
- **users.py** (168 lines) - User profile management
- **decks.py** (721 lines) - Deck management (largest router)
- **rooms.py** (158 lines) - Room CRUD
- **admin.py** (220 lines) - Admin operations
- **card_router.py** (292 lines) - MTG card search
- **interactions.py** (674 lines) - Card interactions
- **refresh.py** (101 lines) - MTGJSON refresh
- **user_data.py** (1714 lines) - User data (massive)
- **card_import.py** (368 lines) - Card import
- **games/router.py** (135 lines) - Game stubs
- **ws.py** (18 lines) - WebSocket
### Model Analysis
- **models.py** (228 lines) - Core models (User, DecklistFile, Room, etc.)
- **mtg_models.py** (71 lines) - MTG card/set models
- **user_data.py** (325 lines) - User sessions, decks, replays, etc.
- **user_deck.py** (153 lines) - User deck building
- **mirror_models.py** (97 lines) - Card mirrors
- **card_import_batch.py** (74 lines) - Import tracking
- **relationships.py** (24 lines) - Cross-model relationships
### Service Analysis
- **mtgjson_manager.py** (53KB) - MTGJSON data management
- **mtgjson_loader.py** (32KB) - Data loading
- **mtgjson_uploader.py** (34KB) - Data uploading
- **deck_manager.py** (7.8KB) - Deck management
- **card_database.py** (9.6KB) - Card database
- **game_server.py** (10KB) - Game server
## Simplification Strategy
### Phase 1: Core Layer Simplification
**Goal**: Simplify configuration and database layer
1. **config.py** - Replace pydantic-settings with simple config class
- SQLite by default, PostgreSQL optional
- Single DATABASE_URL setting
- Sensible defaults for all settings
2. **database.py** - Single database engine
- Remove dual database setup
- Support SQLite and PostgreSQL via same interface
- Keep async engine for compatibility
3. **logging.py** - Simplified logging
- Remove Docker-specific complexity
- Keep structured logging
4. **security.py** - Keep existing functionality
- JWT token management
- Password hashing
- Authentication dependencies
### Phase 2: Model Consolidation
**Goal**: Reduce 10+ model files to 2-3 consolidated files
1. **models.py** - All application models
- User, DecklistFile, Room, Ban, GameLog, AuditLog
- MtgonlineCard (local card mirror)
- UserDeck, UserDeckCard, DeckPrecedent, CardSuggestion
- UserSession, DeckVersion, GameReplay, etc.
- CardImportBatch, UserCardImportRecord
2. **mtg_models.py** - All MTG card data models
- MtgSet, MtgCard
- MtgCardMirror, DeckCardLink
3. **Remove**: relationships.py (integrate into models)
### Phase 3: Router Consolidation
**Goal**: Reduce 12 router files to 6 focused routers
1. **auth_router.py** - Authentication (from auth.py)
- Login, register, token refresh, current user
2. **users_router.py** - User management + admin (merge users.py + admin.py)
- User profile, ban management, user listing, audit logs
3. **decks_router.py** - Deck building (from decks.py)
- Deck CRUD, card management, precedents, suggestions
4. **cards_router.py** - Card data (merge card_router.py + interactions.py)
- Card search, sets, types, rarities, interactions
5. **user_data_router.py** - User data + imports (merge user_data.py + card_import.py)
- Sessions, replays, collection, wishlist, groups, networks
- Card imports
6. **games_router.py** - Games + rooms + refresh (merge rooms.py + games + refresh.py)
- Room management, game stubs, MTGJSON refresh
### Phase 4: Schema Consolidation
**Goal**: Reduce 13+ schema files to 6 matching routers
1. **auth_schemas.py** - Auth request/response
2. **user_schemas.py** - User data schemas
3. **deck_schemas.py** - Deck schemas
4. **card_schemas.py** - Card schemas
5. **user_data_schemas.py** - User data schemas
6. **game_schemas.py** - Game schemas
### Phase 5: Service Simplification
**Goal**: Break large services into focused modules
1. **card_service.py** - Card search and lookup
2. **deck_service.py** - Deck CRUD and validation
3. **user_service.py** - User management
4. **mtgjson_service.py** - MTGJSON data management (split from 53KB file)
5. **game_service.py** - Game state management
### Phase 6: Startup Script
**Goal**: Single entry point
1. **run.py** - Simple startup script
```python
import uvicorn
from app.core.database import create_tables
from app.main import app
if __name__ == "__main__":
create_tables()
uvicorn.run(app, host="0.0.0.0", port=8000)
```
### Phase 7: Documentation
**Goal**: Update all documentation
1. **README.md** - New setup instructions
2. **Remove Docker files** - Keep only for production if needed
3. **Update state.json** - Document changes
## Implementation Order
1. **Core Layer** (config, database, logging, security)
2. **Models** (consolidate into 2-3 files)
3. **Schemas** (consolidate into 6 files)
4. **Services** (simplify and split)
5. **Routers** (consolidate into 6 files)
6. **Main** (update to use new structure)
7. **Run script** (create startup script)
8. **Documentation** (update README)
9. **Testing** (verify all endpoints work)
## Success Criteria
- [ ] Single SQLite database works out of the box
- [ ] All API endpoints functional
- [ ] No Docker required for development
- [ ] File count reduced by 50%
- [ ] Code lines reduced by 30%
- [ ] All tests passing
- [ ] Documentation updated
- [ ] `python run.py` starts everything
## Risk Mitigation
1. **Data integrity** - Keep all database migrations compatible
2. **Backward compatibility** - Maintain existing endpoint paths
3. **Testing** - Run comprehensive tests after each phase
4. **Rollback plan** - Keep original files as backup until verified
## Timeline
- Phase 1-2: Core and Models (1-2 hours)
- Phase 3-4: Routers and Schemas (2-3 hours)
- Phase 5-6: Services and Startup (1-2 hours)
- Phase 7: Documentation and Testing (1-2 hours)
- **Total: 5-9 hours**
+216
View File
@@ -0,0 +1,216 @@
"""
Application configuration management.
Simple configuration with sensible defaults. SQLite by default for development,
PostgreSQL supported via DATABASE_URL environment variable.
## Usage
from app.core.config import get_config
config = get_config()
print(config.DATABASE_URL)
## Environment Variables
All configuration values can be overridden via environment variables.
The .env file in the project root is also loaded automatically.
| Variable | Default | Description |
|---|---|---|
| APP_NAME | MTG Online | Application display name |
| APP_VERSION | 0.2.0 | Application version |
| DEBUG | false | Enable debug mode |
| SECRET_KEY | change-me-in-production | Application secret key |
| JWT_SECRET_KEY | (same as SECRET_KEY) | JWT signing key |
| DATABASE_URL | sqlite:///./mtgonline.db | Database connection string |
| JWT_ALGORITHM | HS256 | JWT signing algorithm |
| JWT_ACCESS_TOKEN_EXPIRE_MINUTES | 60 | Access token lifetime |
| JWT_REFRESH_TOKEN_EXPIRE_DAYS | 7 | Refresh token lifetime |
| CORS_ORIGINS | ["http://localhost:3000", "http://localhost:8000"] | Allowed CORS origins |
| BCRYPT_ROUNDS | 12 | Bcrypt hashing rounds |
| LOG_LEVEL | INFO | Logging level |
| LOG_FORMAT | text | Log output format (text or json) |
| DATA_DIR | /app/data | Data directory for MTGJSON files |
| UPLOAD_DIR | /app/uploads | Upload directory |
"""
import os
from typing import Optional
def _load_env_file(path: str) -> None:
"""Load environment variables from a .env file if it exists.
Args:
path: Absolute or relative path to the .env file.
"""
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip("\"'")
if key and value and key not in os.environ:
os.environ[key] = value
# Load .env from project root (one level up from backend/app/core/)
_project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
_env_path = os.path.join(_project_root, ".env")
_load_env_file(_env_path)
# Also try backend/.env
_backend_env = os.path.join(_project_root, "..", ".env")
_load_env_file(os.path.abspath(_backend_env))
class Config:
"""Application configuration with sensible defaults.
All values can be overridden via environment variables.
This is a simple class (not pydantic-settings) for minimal dependencies.
Attributes:
APP_NAME: Application display name.
APP_VERSION: Application version string.
DEBUG: Enable debug mode (enables SQL echo, etc.).
SECRET_KEY: Application secret key for signing.
JWT_SECRET_KEY: JWT signing key (falls back to SECRET_KEY).
DATABASE_URL: SQLAlchemy database connection string.
Defaults to SQLite for development.
JWT_ALGORITHM: JWT signing algorithm (default HS256).
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: Access token lifetime in minutes.
JWT_REFRESH_TOKEN_EXPIRE_DAYS: Refresh token lifetime in days.
CORS_ORIGINS: List of allowed CORS origin URLs.
BCRYPT_ROUNDS: Number of bcrypt hashing rounds.
LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
LOG_FORMAT: Log output format (text or json).
DATA_DIR: Directory for MTGJSON data files.
UPLOAD_DIR: Directory for user uploads.
"""
# Application
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 = "" # Falls back to SECRET_KEY if empty
# Database - single unified connection string
DATABASE_URL: str = "sqlite+aiosqlite:///./mtgonline.db"
# JWT Configuration
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# CORS
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8000"]
# Security
BCRYPT_ROUNDS: int = 12
# Logging
LOG_LEVEL: str = "INFO"
LOG_FORMAT: str = "text"
# Directories
DATA_DIR: str = "/app/data"
UPLOAD_DIR: str = "/app/uploads"
def __init__(self) -> None:
"""Initialize configuration from environment variables."""
self._apply_env_overrides()
def _apply_env_overrides(self) -> None:
"""Override configuration values from environment variables."""
env_map: dict[str, type] = {
"APP_NAME": str,
"APP_VERSION": str,
"DEBUG": bool,
"SECRET_KEY": str,
"JWT_SECRET_KEY": str,
"DATABASE_URL": str,
"JWT_ALGORITHM": str,
"JWT_ACCESS_TOKEN_EXPIRE_MINUTES": int,
"JWT_REFRESH_TOKEN_EXPIRE_DAYS": int,
"BCRYPT_ROUNDS": int,
"LOG_LEVEL": str,
"LOG_FORMAT": str,
"DATA_DIR": str,
"UPLOAD_DIR": str,
}
for attr, type_cast in env_map.items():
value = os.environ.get(attr)
if value is not None:
if type_cast == bool:
value = value.lower() in ("true", "1", "yes", "on")
else:
value = type_cast(value)
setattr(self, attr, value)
# CORS_ORIGINS: parse comma-separated from env or keep default list
cors_env = os.environ.get("CORS_ORIGINS")
if cors_env:
self.CORS_ORIGINS = [
origin.strip() for origin in cors_env.split(",") if origin.strip()
]
@property
def jwt_secret(self) -> str:
"""Get the effective JWT secret key.
Returns JWT_SECRET_KEY if set, otherwise falls back to SECRET_KEY.
Returns:
The JWT secret key string.
"""
return self.JWT_SECRET_KEY or self.SECRET_KEY
@property
def is_sqlite(self) -> bool:
"""Check if the database is SQLite.
Returns:
True if using SQLite, False otherwise.
"""
return self.DATABASE_URL.startswith("sqlite")
@property
def is_postgresql(self) -> bool:
"""Check if the database is PostgreSQL.
Returns:
True if using PostgreSQL, False otherwise.
"""
return "postgresql" in self.DATABASE_URL
# Singleton instance
_config_instance: Optional[Config] = None
def get_config() -> Config:
"""Get the application configuration singleton.
Returns a cached Config instance. Subsequent calls return the same object.
Returns:
The application configuration instance.
Example:
>>> config = get_config()
>>> print(config.DATABASE_URL)
sqlite:///./mtgonline.db
"""
global _config_instance
if _config_instance is None:
_config_instance = Config()
return _config_instance
+152 -62
View File
@@ -1,22 +1,75 @@
"""
Database engine and session management.
Provides async SQLAlchemy engine and session factory for dependency injection.
Supports dual database connections for mtgonline app and mtgjson data.
Provides a single SQLAlchemy async engine supporting both SQLite (development)
and PostgreSQL (production) via a single DATABASE_URL configuration value.
## Usage
from app.core.database import get_db, create_tables, Base
# In FastAPI dependency:
async def endpoint(db: AsyncSession = Depends(get_db)):
...
# Create all tables:
await create_tables()
## Architecture
- Single engine for all database operations (no dual databases)
- Sync DeclarativeBase for model definitions
- Async engine and sessions for async operations
- Automatic dialect detection (SQLite vs PostgreSQL)
## Backward Compatibility
The following exports are provided for backward compatibility with code that
still references the old dual-database naming convention:
- ``mtg_engine`` → alias for ``engine``
- ``mtg_async_session`` → alias for ``async_session``
- ``mtg_get_db`` → alias for ``get_db``
- ``mirror_get_db`` → alias for ``get_db``
These should be removed as callers are migrated to the new unified API.
"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
import logging
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.core.settings import get_settings
settings = get_settings()
from app.core.config import get_config
logger = logging.getLogger(__name__)
config = get_config()
# ---------------------------------------------------------------------------
# Engine setup — single engine, auto-detect SQLite vs PostgreSQL
# ---------------------------------------------------------------------------
# Primary database engine (mtgonline app)
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_pre_ping=True,
pool_size=20,
max_overflow=10,
config.DATABASE_URL,
echo=config.DEBUG,
# SQLite doesn't support pool_size / max_overflow — only apply for PG
**(
{
"pool_pre_ping": True,
"pool_size": 20,
"max_overflow": 10,
}
if not config.is_sqlite
else {
"pool_pre_ping": False,
}
),
)
async_session = async_sessionmaker(
@@ -25,28 +78,43 @@ async_session = async_sessionmaker(
expire_on_commit=False,
)
# Secondary database engine (mtgjson data)
mtg_engine = create_async_engine(
settings.MTG_DATABASE_URL,
echo=settings.DEBUG,
pool_pre_ping=True,
pool_size=10,
max_overflow=5,
)
mtg_async_session = async_sessionmaker(
mtg_engine,
class_=AsyncSession,
expire_on_commit=False,
)
# Mirror engine (same as primary — mirrors live in mtgo_platform)
mirror_engine = engine
mirror_async_session = async_session
# ---------------------------------------------------------------------------
# Base class for ORM models
# ---------------------------------------------------------------------------
async def get_db() -> AsyncSession:
"""FastAPI dependency that provides a database session for the mtgonline app."""
class Base(DeclarativeBase):
"""Base class for all ORM models.
Uses sync DeclarativeBase (not async-specific) for simplicity.
All models inherit from this class.
"""
pass
# ---------------------------------------------------------------------------
# FastAPI dependency — provides a database session
# ---------------------------------------------------------------------------
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency that provides a database session.
Yields an async session and handles commit/rollback/close automatically.
Usage in FastAPI endpoints:
from fastapi import Depends
from app.core.database import get_db
@app.get("/items")
async def list_items(db: AsyncSession = Depends(get_db)):
...
Yields:
An async SQLAlchemy session.
"""
async with async_session() as session:
try:
yield session
@@ -58,46 +126,68 @@ async def get_db() -> AsyncSession:
await session.close()
async def mtg_get_db() -> AsyncSession:
"""FastAPI dependency that provides a database session for mtgjson data."""
async with mtg_async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
# ---------------------------------------------------------------------------
# Table creation helper
# ---------------------------------------------------------------------------
async def mirror_get_db() -> AsyncSession:
"""FastAPI dependency that provides a database session for mirror operations."""
async with mirror_async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def create_tables() -> None:
"""Create all database tables from model metadata.
Imports all model modules to ensure Base.metadata is populated,
then creates all tables. This is intended for development and testing.
In production, use Alembic migrations instead.
Example:
>>> await create_tables()
"""
# Import all models to register them with Base.metadata
import app.models # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info(
f"Database tables created successfully "
f"(dialect: {'SQLite' if config.is_sqlite else 'PostgreSQL'})"
)
class Base(DeclarativeBase):
"""Base class for all ORM models."""
pass
# ---------------------------------------------------------------------------
# Backward-compatible aliases
#
# The old codebase referenced dual databases (mtg_engine, mtg_async_session,
# mtg_get_db, mirror_get_db). These aliases map them to the single unified
# engine so existing imports continue to work. They will be removed once all
# callers have been migrated.
# ---------------------------------------------------------------------------
# Alias: mtg_engine → engine (was the "primary" database engine)
mtg_engine = engine
# Alias: mtg_async_session → async_session (was the "primary" session factory)
mtg_async_session = async_session
# Alias: mtg_get_db → get_db (was the "primary" session dependency)
mtg_get_db = get_db
# Alias: mirror_get_db → get_db (was the "mirror" database session dependency)
mirror_get_db = get_db
# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------
__all__ = [
"Base",
"get_db",
"async_session",
"engine",
"mtg_get_db",
"mtg_async_session",
"async_session",
"get_db",
"create_tables",
# Backward-compatible aliases
"mtg_engine",
"mtg_async_session",
"mtg_get_db",
"mirror_get_db",
"mirror_async_session",
"mirror_engine",
]
+187
View File
@@ -0,0 +1,187 @@
"""
Logging configuration for MTG Online backend.
Provides structured logging with support for both text and JSON output formats.
Configured via environment variables or the application config.
## Configuration
| Variable | Default | Description |
|---|---|---|
| LOG_LEVEL | INFO | Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) |
| LOG_FORMAT | text | Output format ("text" or "json") |
| MTG_LOG_LEVEL | INFO | Alternative env var for log level |
| MTG_LOG_FORMAT | text | Alternative env var for log format |
## Usage
from app.core.logging import configure_logging
# Auto-configures on import, or call explicitly:
configure_logging()
## Features
- Service name "mtgonline-backend" added to all log records
- Container name from HOSTNAME env var (for Docker)
- Suppresses noisy SQLAlchemy loggers unless DEBUG
- JSON format includes timestamp, level, service, container, logger, message
- Text format: `[timestamp] [service] LEVEL - message`
"""
import json
import logging
import os
import sys
from datetime import datetime, timezone
from typing import Optional
def _get_container_name() -> str:
"""Get container name from HOSTNAME environment variable.
Returns:
Container name or "unknown" if not available.
"""
return os.environ.get("HOSTNAME", "unknown")
class ContainerFormatter(logging.Formatter):
"""Formatter that adds container metadata to log records.
Supports both text and JSON output formats. Adds service name
"mtgonline-backend" and container name to all log records.
Attributes:
SERVICE_NAME: Service identifier added to all log records.
fmt_type: Output format ("text" or "json").
container_name: Container identifier from HOSTNAME env var.
"""
SERVICE_NAME = "mtgonline-backend"
def __init__(self, fmt_type: str = "text") -> None:
"""Initialize the formatter.
Args:
fmt_type: Output format, either "text" or "json".
"""
super().__init__()
self.fmt_type = fmt_type
self.container_name = _get_container_name()
def format(self, record: logging.LogRecord) -> str:
"""Format a log record with service and container metadata.
Args:
record: The log record to format.
Returns:
Formatted log string in the configured format.
"""
record.service = self.SERVICE_NAME
record.container = self.container_name
if self.fmt_type == "json":
return self._format_json(record)
return self._format_text(record)
def _format_text(self, record: logging.LogRecord) -> str:
"""Format log record as human-readable text.
Args:
record: The log record to format.
Returns:
Text-formatted log string.
"""
return (
f"{self.formatTime(record)} [{record.service}] "
f"{record.levelname} - {record.getMessage()}"
)
def _format_json(self, record: logging.LogRecord) -> str:
"""Format log record as JSON.
Includes timestamp, level, service, container, logger, and message.
Adds exception information if present.
Args:
record: The log record to format.
Returns:
JSON-formatted log string.
"""
log_entry = {
"timestamp": datetime.fromtimestamp(
record.created, tz=timezone.utc
).isoformat(),
"level": record.levelname,
"service": record.service,
"container": record.container,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info and record.exc_info[0] is not None:
log_entry["exception"] = self.formatException(record.exc_info)
return json.dumps(log_entry, default=str)
def configure_logging(
log_level: Optional[str] = None,
log_format: Optional[str] = None,
) -> None:
"""Configure application logging.
Reads configuration from environment variables or uses defaults.
Sets up a stream handler with the configured formatter.
Suppresses noisy SQLAlchemy loggers unless DEBUG level is enabled.
Args:
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
Overrides MTG_LOG_LEVEL and LOG_FORMAT env vars if provided.
log_format: Output format ("text" or "json").
Overrides MTG_LOG_FORMAT and LOG_FORMAT env vars if provided.
Example:
>>> configure_logging() # Use env vars or defaults
>>> configure_logging(log_level="DEBUG", log_format="json")
"""
# Determine log level
if log_level is None:
log_level = os.environ.get("MTG_LOG_LEVEL", os.environ.get("LOG_LEVEL", "INFO")).upper()
# Determine log format
if log_format is None:
log_format = os.environ.get("MTG_LOG_FORMAT", os.environ.get("LOG_FORMAT", "text")).lower()
# Validate inputs
level = getattr(logging, log_level, logging.INFO)
if log_format not in ("text", "json"):
log_format = "text"
# Create formatter and handler
formatter = ContainerFormatter(fmt_type=log_format)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(level)
root_logger.handlers.clear()
root_logger.addHandler(handler)
# Suppress noisy loggers unless DEBUG
if level != logging.DEBUG:
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
logger.info(
f"Logging configured: level={log_level}, "
f"format={log_format}, container={_get_container_name()}"
)
# Auto-configure on import
configure_logging()
-97
View File
@@ -1,97 +0,0 @@
"""
Structured logging configuration for MTG Online backend.
Supports JSON and text log formats with container metadata.
Configured via MTG_LOG_LEVEL and MTG_LOG_FORMAT environment variables.
"""
import json
import logging
import os
import sys
from datetime import datetime, timezone
def _get_container_name() -> str:
"""Get container name from HOSTNAME env var or fallback."""
return os.environ.get("HOSTNAME", "unknown")
class ContainerFormatter(logging.Formatter):
"""Formatter that adds container metadata to log records.
Supports both text and JSON output formats.
"""
SERVICE_NAME = "mtgonline-backend"
def __init__(self, fmt_type: str = "text"):
super().__init__()
self.fmt_type = fmt_type
self.container_name = _get_container_name()
def format(self, record: logging.LogRecord) -> str:
record.service = self.SERVICE_NAME
record.container = self.container_name
if self.fmt_type == "json":
return self._format_json(record)
return self._format_text(record)
def _format_text(self, record: logging.LogRecord) -> str:
return (
f"{self.formatTime(record)} [{record.service}] "
f"{record.levelname} - {record.getMessage()}"
)
def _format_json(self, record: logging.LogRecord) -> str:
log_entry = {
"timestamp": datetime.fromtimestamp(
record.created, tz=timezone.utc
).isoformat(),
"level": record.levelname,
"service": record.service,
"container": record.container,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info and record.exc_info[0] is not None:
log_entry["exception"] = self.formatException(record.exc_info)
return json.dumps(log_entry, default=str)
def configure_logging() -> None:
"""Configure application logging based on environment variables.
Reads:
MTG_LOG_LEVEL - Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
MTG_LOG_FORMAT - Output format ("text" or "json")
"""
log_level = os.environ.get("MTG_LOG_LEVEL", "INFO").upper()
log_format = os.environ.get("MTG_LOG_FORMAT", "text").lower()
level = getattr(logging, log_level, logging.INFO)
formatter = ContainerFormatter(fmt_type=log_format)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.setLevel(level)
root_logger.handlers.clear()
root_logger.addHandler(handler)
# Suppress noisy loggers unless DEBUG
if level != logging.DEBUG:
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
logger.info(
f"Logging configured: level={log_level}, "
f"format={log_format}, container={_get_container_name()}"
)
# Auto-configure on import
configure_logging()
+13 -6
View File
@@ -1,26 +1,33 @@
"""Redis client and caching layer."""
"""Redis client and caching layer (optional)."""
import json
import logging
from typing import Any, Optional
import redis.asyncio as aioredis
from app.core.settings import get_settings
from app.core.config import get_config
logger = logging.getLogger(__name__)
settings = get_settings()
config = get_config()
# Redis client instance
redis_client: Optional[aioredis.Redis] = None
async def get_redis() -> aioredis.Redis:
"""Get Redis client instance."""
async def get_redis() -> Optional[aioredis.Redis]:
"""Get Redis client instance.
Returns None if Redis is not configured or connection fails.
"""
global redis_client
if redis_client is None:
redis_url = getattr(config, 'REDIS_URL', None)
if not redis_url:
logger.debug("Redis not configured")
return None
try:
redis_client = aioredis.from_url(
settings.REDIS_URL,
redis_url,
decode_responses=True,
socket_connect_timeout=10,
socket_timeout=10,
+145 -24
View File
@@ -2,28 +2,91 @@
Security utilities for authentication and password hashing.
Implements JWT token management and bcrypt password hashing with salt.
Provides functions for creating, validating, and decoding JWT tokens,
as well as password verification and hashing.
## Features
- Bcrypt password hashing with configurable rounds
- JWT access and refresh token creation
- Token decoding and validation
- FastAPI dependency for extracting current user from Authorization header
## Usage
from app.core.security import (
verify_password,
hash_password,
create_access_token,
create_refresh_token,
decode_token,
get_current_user,
)
# Hash and verify passwords
hashed = hash_password("secure_password")
is_valid = verify_password("secure_password", hashed)
# Create and decode tokens
token = create_access_token(subject="user_id", privlevel="Admin")
payload = decode_token(token)
# FastAPI dependency
@app.get("/protected")
async def protected_route(user: dict = Depends(get_current_user)):
...
"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import Header, HTTPException, status
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.settings import get_settings
settings = get_settings()
from app.core.config import get_config
config = get_config()
# Password hashing context with bcrypt
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a bcrypt hash."""
"""Verify a plain password against a bcrypt hash.
Args:
plain_password: The plain text password to verify.
hashed_password: The bcrypt hash to verify against.
Returns:
True if the password matches the hash, False otherwise.
Example:
>>> hashed = hash_password("my_password")
>>> verify_password("my_password", hashed)
True
>>> verify_password("wrong_password", hashed)
False
"""
return pwd_context.verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
"""Hash a password using bcrypt with configurable rounds."""
return pwd_context.hash(password, rounds=settings.BCRYPT_ROUNDS)
"""Hash a password using bcrypt with configurable rounds.
Args:
password: The plain text password to hash.
Returns:
The bcrypt hash string.
Example:
>>> hashed = hash_password("secure_password")
>>> len(hashed) > 0
True
"""
return pwd_context.hash(password, rounds=config.BCRYPT_ROUNDS)
def create_access_token(
@@ -31,16 +94,30 @@ def create_access_token(
privlevel: str = "User",
expires_delta: Optional[timedelta] = None,
) -> str:
"""Create a JWT access token."""
"""Create a JWT access token.
Args:
subject: The user identifier (typically user ID or username).
privlevel: User privilege level (default "User").
expires_delta: Optional custom expiration time delta.
If not provided, uses JWT_ACCESS_TOKEN_EXPIRE_MINUTES from config.
Returns:
Encoded JWT access token string.
Example:
>>> token = create_access_token(subject="user123", privlevel="Admin")
>>> isinstance(token, str)
True
"""
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
minutes=config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
)
# Use JWT_SECRET_KEY if available, fall back to SECRET_KEY
secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY
secret = config.jwt_secret
payload = {
"sub": subject,
@@ -49,17 +126,29 @@ def create_access_token(
"type": "access",
"privlevel": privlevel,
}
return jwt.encode(payload, secret, algorithm=settings.JWT_ALGORITHM)
return jwt.encode(payload, secret, algorithm=config.JWT_ALGORITHM)
def create_refresh_token(subject: str, privlevel: str = "User") -> str:
"""Create a JWT refresh token with longer expiry."""
"""Create a JWT refresh token with longer expiry.
Args:
subject: The user identifier (typically user ID or username).
privlevel: User privilege level (default "User").
Returns:
Encoded JWT refresh token string.
Example:
>>> token = create_refresh_token(subject="user123")
>>> isinstance(token, str)
True
"""
expire = datetime.now(timezone.utc) + timedelta(
days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
days=config.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
# Use JWT_SECRET_KEY if available, fall back to SECRET_KEY
secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY
secret = config.jwt_secret
payload = {
"sub": subject,
@@ -68,19 +157,31 @@ def create_refresh_token(subject: str, privlevel: str = "User") -> str:
"type": "refresh",
"privlevel": privlevel,
}
return jwt.encode(payload, secret, algorithm=settings.JWT_ALGORITHM)
return jwt.encode(payload, secret, algorithm=config.JWT_ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
"""Decode and validate a JWT token."""
"""Decode and validate a JWT token.
Args:
token: The JWT token string to decode.
Returns:
The token payload dictionary if valid, None if invalid or expired.
Example:
>>> token = create_access_token(subject="user123")
>>> payload = decode_token(token)
>>> payload["sub"]
'user123'
"""
try:
# Use JWT_SECRET_KEY if available, fall back to SECRET_KEY
secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY
secret = config.jwt_secret
payload = jwt.decode(
token,
secret,
algorithms=[settings.JWT_ALGORITHM],
algorithms=[config.JWT_ALGORITHM],
)
return payload
except JWTError:
@@ -88,26 +189,46 @@ def decode_token(token: str) -> Optional[dict]:
def get_current_user(authorization: str = Header(...)) -> dict:
"""Extract user info from JWT token in Authorization header."""
"""Extract user info from JWT token in Authorization header.
FastAPI dependency that validates the Bearer token and extracts
user information from the decoded payload.
Args:
authorization: The Authorization header value (Bearer <token>).
Returns:
Dictionary containing user_id, privlevel, and token_type.
Raises:
HTTPException: 401 if token is invalid, missing, or wrong type.
Example:
>>> @app.get("/protected")
... async def protected(user: dict = Depends(get_current_user)):
... return {"user_id": user["user_id"]}
"""
# Parse Bearer token
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != 'bearer':
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme"
detail="Invalid authentication scheme",
)
token = parts[1]
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
detail="Invalid token",
)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
detail="Invalid token type",
)
return {
-86
View File
@@ -1,86 +0,0 @@
"""
Application configuration management.
Uses pydantic-settings for typed configuration with environment variable overrides.
"""
from pydantic_settings import BaseSettings
from typing import Optional
from functools import lru_cache
class Settings(BaseSettings):
"""Application settings loaded from environment variables or .env file."""
# Application
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 (mtgonline app)
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@postgres_platform:5432/mtgo_platform"
# Database - Secondary (mtgjson data)
MTG_DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@postgres_mtgdata:5432/mtgdata"
# Database - Mirror
MTG_MIRROR_DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@postgres_mirror:5432/mtgo_mirror"
# Redis
REDIS_URL: str = "redis://redis:6379/0"
# JWT Configuration
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# CORS
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8000"]
# Email (for password reset, account activation)
SMTP_HOST: Optional[str] = None
SMTP_PORT: int = 587
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
EMAIL_FROM: Optional[str] = None
# Security
BCRYPT_ROUNDS: int = 12
MAX_LOGIN_ATTEMPTS: int = 5
LOGIN_BLOCK_MINUTES: int = 15
# MTG Data Refresh
MTG_REFRESH_INTERVAL_DAYS: int = 7
DATA_DIR: str = "/app/data"
UPLOAD_DIR: str = "/app/uploads"
# Database configuration
DB_CONFIG: dict = {
"engine": "postgresql+asyncpg",
"user": "mtgonline",
"password": "mtgonline_pass",
"host": "postgres",
"port": 5432,
}
# Logging
LOG_LEVEL: str = "INFO"
# Redis configuration
REDIS_CONFIG: dict = {
"host": "redis",
"port": 6379,
"db": 0,
}
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = "allow" # Allow extra env vars
@lru_cache()
def get_settings() -> Settings:
"""Get cached application settings."""
return Settings()
+47 -113
View File
@@ -2,151 +2,98 @@
MTG Online Backend Application
FastAPI application for the MTG Online multiplayer platform.
Mounts all routers and provides centralized configuration.
Mounts all consolidated routers and provides centralized configuration.
## Routers
## Routers (Consolidated Structure)
- Authentication: /auth/*
- Users: /users/*
- Users + Admin: /users/*
- Decks: /decks/*
- Rooms: /rooms/*
- Games: /games/*
- Admin: /admin/*
- MTG Cards: /api/cards/*
- Card Interactions: /interactions/*
- Cards + Interactions: /api/cards/*, /interactions/*
- User Data + Imports: /api/v1/user-data/*, /api/v1/card-import/*
- Games + Rooms + Refresh: /games/*, /rooms/*, /mtgjson/*
- WebSocket: /ws/game/{game_id}
"""
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from app.core.settings import get_settings
from app.core.database import engine, mtg_engine, async_session, mtg_async_session
from app.routers import auth, users, decks, rooms, admin, card_router, interactions, refresh, user_data, card_import
from app.routers.games import router as games_router
from app.services.mtgjson_manager import MTGJSONManager
from app.core.logging_config import configure_logging
from app.core.config import get_config
from app.core.database import engine, get_db
from app.routers import (
auth_router,
users_router,
decks_router,
cards_router,
user_data_router,
games_router,
)
from app.core.logging import configure_logging
async def run_initial_download():
"""Run initial MTGJSON data download and upsert."""
logger = logging.getLogger(__name__)
try:
settings = get_settings()
manager = MTGJSONManager(settings.DATA_DIR)
# Check if data already exists
last_refresh = await manager.get_last_refresh()
if last_refresh:
logger.info(f"MTGJSON data already exists (last refresh: {last_refresh})")
return
logger.info("Running initial MTGJSON data download with sanity checks...")
logger.info("This may take several minutes depending on network speed...")
# Download files and upsert data in one operation
result = await manager.download_and_refresh(force=False)
if not result.get("success", False):
error_msg = result.get("error", "Unknown error")
logger.error(f"Failed to download MTGJSON files: {error_msg}")
raise RuntimeError(f"MTGJSON data download failed: {error_msg}")
# Log success
counts = result.get("upsert", {})
await manager.log_refresh("SUCCESS", counts, result.get("duration", 0))
logger.info(f"Initial MTGJSON data load complete!")
logger.info(f" Sets: {counts.get('sets', 0)}")
logger.info(f" Cards: {counts.get('cards', 0)}")
except Exception as e:
logger.error(f"Failed to run initial download: {e}")
raise
logger = logging.getLogger(__name__)
def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan events for startup and shutdown."""
settings = get_settings()
config = get_config()
# Configure logging
configure_logging()
logger = logging.getLogger(__name__)
logger.info(f"MTG Online Backend starting (v{settings.APP_VERSION})")
logger.info(f"Database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'configured'}")
logger.info(f"MTG Database: {settings.MTG_DATABASE_URL.split('@')[1] if '@' in settings.MTG_DATABASE_URL else 'configured'}")
logger.info(f"Redis: {settings.REDIS_URL}")
# Run initial download in background
try:
asyncio.create_task(run_initial_download())
except RuntimeError:
# Event loop already running
asyncio.get_event_loop().create_task(run_initial_download())
logger.info(f"MTG Online Backend starting (v{config.APP_VERSION})")
logger.info(f"Database: {config.DATABASE_URL}")
logger.info(f"Environment: {'development' if config.DEBUG else 'production'}")
yield
# Shutdown
logger.info("Shutting down MTG Online Backend")
# Engine disposal is handled by FastAPI's shutdown events
app = FastAPI(
title="MTG Online Backend API",
description="Backend API for the MTG Online multiplayer platform",
version="0.2.0",
version="0.3.0",
lifespan=lifespan,
)
# CORS middleware
settings = get_settings()
config = get_config()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_origins=config.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount all routers
app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
app.include_router(users.router, prefix="/users", tags=["Users"])
app.include_router(decks.router, prefix="/decks", tags=["Decks"])
app.include_router(rooms.router, prefix="/rooms", tags=["Rooms"])
app.include_router(games_router, prefix="/games", tags=["Games"])
app.include_router(admin.router, prefix="/admin", tags=["Admin"])
app.include_router(card_router.router, tags=["MTG Cards"])
app.include_router(interactions.router)
app.include_router(refresh.router)
app.include_router(user_data.router, prefix="/api/v1/user-data", tags=["User Data"])
app.include_router(card_import.router, prefix="/api/v1/card-import", tags=["Card Import"])
# Mount all consolidated routers
app.include_router(auth_router.router, prefix="/auth", tags=["Authentication"])
app.include_router(users_router.router, prefix="/users", tags=["Users", "Admin"])
app.include_router(decks_router.router, prefix="/decks", tags=["Decks"])
app.include_router(cards_router.card_router, tags=["MTG Cards"])
app.include_router(cards_router.interaction_router, tags=["Card Interactions"])
app.include_router(user_data_router.user_data_router, prefix="/api/v1/user-data", tags=["User Data"])
app.include_router(user_data_router.card_import_router, prefix="/api/v1/card-import", tags=["Card Import"])
app.include_router(games_router.rooms_router, prefix="/rooms", tags=["Rooms"])
app.include_router(games_router.games_router, prefix="/games", tags=["Games"])
app.include_router(games_router.mtgjson_router, prefix="/mtgjson", tags=["MTGJSON"])
app.include_router(games_router.router, tags=["WebSocket"])
@app.get("/health", tags=["Health"])
async def health_check():
"""Health check endpoint with full service status."""
from app.services.mtgjson_manager import get_manager
"""Health check endpoint with database status."""
health_status = {
"status": "healthy",
"version": config.APP_VERSION,
}
health_status = {"status": "healthy", "version": settings.APP_VERSION}
# Check MTGJSON status
# Check database
try:
manager = get_manager()
mtg_status = await manager.get_health_status()
health_status["mtgjson"] = mtg_status
if mtg_status.get("status") != "healthy":
health_status["status"] = "degraded"
except Exception as e:
health_status["mtgjson"] = {"status": "unhealthy", "error": str(e)}
health_status["status"] = "degraded"
# Check primary database
try:
from app.core.database import engine
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
health_status["database"] = "healthy"
@@ -154,19 +101,6 @@ async def health_check():
health_status["database"] = {"status": "unhealthy", "error": str(e)}
health_status["status"] = "unhealthy"
# Check Redis
try:
from app.core.redis_client import get_redis
redis_client = await get_redis()
if redis_client:
health_status["redis"] = "healthy"
else:
health_status["redis"] = {"status": "unhealthy", "error": "Connection failed"}
health_status["status"] = "unhealthy"
except Exception as e:
health_status["redis"] = {"status": "unhealthy", "error": str(e)}
health_status["status"] = "unhealthy"
return health_status
@@ -174,7 +108,7 @@ async def health_check():
async def root():
"""Root endpoint with API information."""
return {
"name": settings.APP_NAME,
"version": settings.APP_VERSION,
"name": config.APP_NAME,
"version": config.APP_VERSION,
"docs": "/docs",
}
@@ -0,0 +1,74 @@
"""
SQLAlchemy ORM models for card import tracking.
This module provides models for tracking card import batches
and individual card import records.
"""
from sqlalchemy import (
Column, Integer, String, BigInteger, Boolean, DateTime, Text,
ForeignKey, UniqueConstraint
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class CardImportBatch(Base):
"""
Card import batch tracking.
Tracks a batch of card imports from a file, including
matching results and error information.
"""
__tablename__ = "card_import_batches"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False)
filename = Column(String(255), nullable=False)
file_type = Column(String(50), nullable=True) # 'mtgjson', 'csv', etc.
file_size = Column(BigInteger, nullable=True) # Size in bytes
status = Column(String(20), default="PENDING") # PENDING, PROCESSING, COMPLETED, FAILED
total_cards = Column(Integer, default=0)
matched_cards = Column(Integer, default=0)
unmatched_cards = Column(Integer, default=0)
match_results = Column(Text, nullable=True) # JSON of match details
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", backref="card_import_batches")
records = relationship(
"UserCardImportRecord",
back_populates="batch",
cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<CardImportBatch {self.filename} (status={self.status})>"
class UserCardImportRecord(Base):
"""
Individual card import record within a batch.
Tracks whether each card in an import batch was confirmed
and when the confirmation happened.
"""
__tablename__ = "user_card_imports_confirmed"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False)
batch_id = Column(BigInteger, ForeignKey("card_import_batches.id", ondelete="CASCADE"), nullable=False)
is_confirmed = Column(Boolean, default=False)
confirmed_at = Column(DateTime, nullable=True)
# Relationships
batch = relationship("CardImportBatch", back_populates="records")
__table_args__ = (
UniqueConstraint('user_id', 'batch_id', name='uq_user_card_import_batch'),
)
def __repr__(self) -> str:
return f"<UserCardImportRecord user={self.user_id} batch={self.batch_id} confirmed={self.is_confirmed}>"
@@ -0,0 +1,97 @@
"""
SQLAlchemy ORM models for card mirrors and deck-card links.
Mirrored card data lives in mtgo_platform for fast deckbuilding queries.
The MTGJSON database remains the canonical source of truth.
"""
from sqlalchemy import (
Column, Integer, String, Text, DateTime, ForeignKey, Index,
UniqueConstraint, Boolean
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class MtgCardMirror(Base):
"""
Mirrored card data for user decks.
Contains all relevant card fields copied from mtg_cards (mtg_data)
to avoid cross-DB joins during deckbuilding. Back-references source_id
to the canonical mtg_cards.id for sync tracking.
"""
__tablename__ = "mtg_cards_mirror"
id = Column(Integer, primary_key=True, index=True)
source_id = Column(
Integer,
nullable=True,
index=True,
comment="Logical reference to mtg_cards.id in mtg_data database (cross-DB reference, no FK constraint)"
)
name = Column(String(255), nullable=False, index=True)
mana_cost = Column(String(255), nullable=True)
type_line = Column(String(255), nullable=True)
oracle_text = Column(Text, nullable=True)
power = Column(String(50), nullable=True)
toughness = Column(String(50), nullable=True)
rarity = Column(String(50), nullable=True)
layout = Column(String(50), nullable=True)
artist = Column(String(255), nullable=True)
flavor_text = Column(Text, nullable=True)
numbers = Column(String(100), nullable=True)
identifiers = Column(Text, nullable=True) # JSON string of all identifiers
images = Column(Text, nullable=True) # JSON string of image URLs
image = Column(Text, nullable=True) # Card image URL from MTGJSON
card_parts = Column(Text, nullable=True) # Comma-separated list of face names
keywords = Column(Text, nullable=True) # Comma-separated keywords
legalities = Column(Text, nullable=True) # JSON of format legality
set_code = Column(String(10), nullable=True, index=True)
set_name = Column(String(255), nullable=True)
# Sync tracking
synced_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
created_at = Column(DateTime, server_default=func.now())
# Relationships
deck_links = relationship(
"DeckCardLink",
back_populates="card",
cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<MtgCardMirror {self.name} (ID: {self.id}, source: {self.source_id})>"
class DeckCardLink(Base):
"""
Junction table linking user decks to mirrored cards.
Represents a specific quantity of a mirrored card in a specific zone
(main deck or sideboard) of a user's deck.
"""
__tablename__ = "deck_card_links"
id = Column(Integer, primary_key=True, index=True)
deck_id = Column(Integer, ForeignKey("mtgonline_decklist_files.id", ondelete="CASCADE"), nullable=False)
card_id = Column(Integer, ForeignKey("mtg_cards_mirror.id"), nullable=False)
quantity = Column(Integer, nullable=False, default=1)
zone = Column(String(20), nullable=False, default="main") # 'main' or 'sideboard'
# Composite unique: a card can only appear once per zone in a deck
__table_args__ = (
UniqueConstraint('deck_id', 'card_id', 'zone', name='uq_deck_card_link'),
Index('idx_deck_card_deck', 'deck_id'),
Index('idx_deck_card_card', 'card_id'),
)
# Relationships
deck = relationship("DecklistFile", back_populates="card_links")
card = relationship("MtgCardMirror", back_populates="deck_links")
def __repr__(self) -> str:
return (
f"<DeckCardLink deck={self.deck_id} card={self.card_id} "
f"qty={self.quantity} zone={self.zone}>"
)
@@ -0,0 +1,968 @@
"""
SQLAlchemy ORM models for the MTG Online application.
Consolidated from 9 fragmented model files (models.py, user_deck.py,
user_data.py, card_import_batch.py, user_card_import.py, relationships.py,
and others) into a single module for clarity and maintainability.
This file contains all application-level models:
- Core models: User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog
- Card mirror: MtgonlineCard (local copy of mtg_cards for deckbuilding)
- User decks: UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
- User data: UserSession, DeckVersion, GameReplay, ReplayPlayer, GameOutcome,
UserStatistics, UserCardCollection, CardWishlist, UserGroup, GroupMember,
GroupChatMessage, UserNetwork, NetworkMember, UserPreference, UserActivityLog
- Card imports: CardImportBatch, UserCardImportRecord, UserCardImport
Cross-model relationships (e.g., DecklistFile.card_links → DeckCardLink)
are resolved at the bottom of this file after all classes are defined.
"""
from sqlalchemy import (
Column, Integer, String, BigInteger, Boolean, DateTime, Text,
ForeignKey, Index, UniqueConstraint, Float, JSON,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
# ---------------------------------------------------------------------------
# Core Models
# ---------------------------------------------------------------------------
class User(Base):
"""User account model."""
__tablename__ = "mtgonline_users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(64), unique=True, nullable=False, index=True)
password_hash = Column(String(128), nullable=False) # bcrypt hash
salt = Column(String(128), nullable=False) # password salt
email = Column(String(255), nullable=True, index=True)
country = Column(String(2), nullable=True)
real_name = Column(String(128), nullable=True)
avatar_bmp = Column(Text, nullable=True) # Base64 encoded avatar
privlevel = Column(String(50), nullable=True, default="User")
# Account status
is_active = Column(Boolean, default=True)
is_banned = Column(Boolean, default=False)
ban_reason = Column(Text, nullable=True)
ban_ends = Column(DateTime, nullable=True)
# VIP/Donator status
vip_status = Column(Integer, default=0) # 0=normal, 1=vip, 2=donator
vip_expiry = Column(DateTime, nullable=True)
# Timestamps
creation_date = Column(DateTime, server_default=func.now())
last_login = Column(DateTime, nullable=True)
# Relationships
decklist_files = relationship(
"DecklistFile", back_populates="owner", cascade="all, delete-orphan"
)
decklist_folders = relationship(
"DecklistFolder", back_populates="owner", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<User {self.username} (ID: {self.id})>"
class DecklistFolder(Base):
"""User deck folder."""
__tablename__ = "mtgonline_decklist_folders"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False)
name = Column(String(255), nullable=False)
parent_id = Column(Integer, ForeignKey("mtgonline_decklist_folders.id"), nullable=True)
creation_date = Column(DateTime, server_default=func.now())
# Relationships
owner = relationship("User", back_populates="decklist_folders")
children = relationship(
"DecklistFolder", back_populates="parent", cascade="all, delete-orphan"
)
parent = relationship(
"DecklistFolder", back_populates="children", remote_side=[id]
)
files = relationship(
"DecklistFile", back_populates="folder", cascade="all, delete-orphan"
)
user_decks = relationship(
"UserDeck", back_populates="folder", lazy="select"
)
class DecklistFile(Base):
"""Deck file stored in a folder."""
__tablename__ = "mtgonline_decklist_files"
id = Column(Integer, primary_key=True, index=True)
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'
status = Column(String(20), default="DRAUGHT") # 'DRAUGHT' or 'FINAL'
creation_date = Column(DateTime, server_default=func.now())
# Relationships (card_links is added below after DeckCardLink is defined)
folder = relationship("DecklistFolder", back_populates="files")
owner = relationship("User", back_populates="decklist_files")
def __repr__(self) -> str:
return f"<DecklistFile {self.name} (ID: {self.id})>"
class Room(Base):
"""Chat room."""
__tablename__ = "mtgonline_rooms"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text, nullable=True)
is_password_protected = Column(Boolean, default=False)
password_hash = Column(String(128), nullable=True)
creation_date = Column(DateTime, server_default=func.now())
# Game types available in this room
game_types = relationship(
"RoomGameType", back_populates="room", cascade="all, delete-orphan"
)
class RoomGameType(Base):
"""Game type definition for a room."""
__tablename__ = "mtgonline_rooms_gametypes"
id = Column(Integer, primary_key=True, index=True)
room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
# Relationships
room = relationship("Room", back_populates="game_types")
class Ban(Base):
"""User ban record."""
__tablename__ = "mtgonline_bans"
id = Column(Integer, primary_key=True, index=True)
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
ip_address = Column(String(45), nullable=True) # IPv4 or IPv6
expiration_time = Column(DateTime, nullable=True)
active = Column(Boolean, default=True)
creation_date = Column(DateTime, server_default=func.now())
# Relationships
user = relationship("User")
def __repr__(self) -> str:
return f"<Ban for User {self.user_id} (ID: {self.id})>"
class GameLog(Base):
"""Game log entry."""
__tablename__ = "mtgonline_log"
id = Column(Integer, primary_key=True, index=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())
# Relationships
room = relationship("Room")
player = relationship("User")
class AuditLog(Base):
"""Audit trail for administrative actions."""
__tablename__ = "mtgonline_audit"
id = Column(Integer, primary_key=True, index=True)
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("mtgonline_users.id"), nullable=True)
details = Column(Text, nullable=True)
ip_address = Column(String(45), nullable=True)
timestamp = Column(DateTime, server_default=func.now())
# Relationships
admin = relationship("User", foreign_keys=[admin_id])
target_user = relationship("User", foreign_keys=[target_user_id])
# ---------------------------------------------------------------------------
# Card Mirror (local copy for deckbuilding)
# ---------------------------------------------------------------------------
class MtgonlineCard(Base):
"""
Local card data mirror for the mtgonline database.
Mirrors data from mtg_cards (mtgdata database) to avoid cross-database
joins during deckbuilding. Maintains source_id to reference the canonical
mtg_cards.id for sync tracking.
"""
__tablename__ = "mtgonline_cards"
id = Column(Integer, primary_key=True, index=True)
source_id = Column(Integer, nullable=True, index=True) # References mtg_cards.id in mtgdata
name = Column(String(255), nullable=False, index=True)
mana_cost = Column(String(255), nullable=True)
type_line = Column(String(255), nullable=True)
oracle_text = Column(Text, nullable=True)
power = Column(String(50), nullable=True)
toughness = Column(String(50), nullable=True)
rarity = Column(String(50), nullable=True)
layout = Column(String(50), nullable=True)
artist = Column(String(255), nullable=True)
flavor_text = Column(Text, nullable=True)
numbers = Column(String(100), nullable=True)
identifiers = Column(Text, nullable=True) # JSON string
images = Column(Text, nullable=True) # JSON string
image = Column(Text, nullable=True) # Card image URL
set_code = Column(String(10), nullable=True, index=True)
set_name = Column(String(255), nullable=True)
card_parts = Column(Text, nullable=True) # Comma-separated face names
keywords = Column(Text, nullable=True) # Comma-separated keywords
legalities = Column(Text, nullable=True) # JSON of format legality
# Sync tracking
synced_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
created_at = Column(DateTime, server_default=func.now())
# Relationships
deck_cards = relationship(
"UserDeckCard",
back_populates="card",
cascade="all, delete-orphan",
)
# Indexes
__table_args__ = (
Index("idx_mtgonline_cards_name", "name"),
Index("idx_mtgonline_cards_set", "set_code"),
)
def __repr__(self) -> str:
return f"<MtgonlineCard {self.name} (ID: {self.id}, source: {self.source_id})>"
# ---------------------------------------------------------------------------
# User Deck Models
# ---------------------------------------------------------------------------
class UserDeck(Base):
"""Per-user deck storage in the primary mtgonline database."""
__tablename__ = "user_decks"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False, index=True,
)
name = Column(String(255), nullable=False, index=True)
status = Column(
String(20), nullable=False, default="DRAFT", index=True,
) # DRAFT or FINAL
folder_id = Column(
Integer, ForeignKey("mtgonline_decklist_folders.id"), nullable=True,
)
format = Column(
String(50), nullable=True, default="standard",
) # Standard, Modern, etc.
notes = Column(Text, nullable=True)
is_precedent = Column(Boolean, default=False, index=True) # Mark as deck precedent/template
precedent_name = Column(String(255), nullable=True) # Name for precedent (if applicable)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", backref="user_decks")
folder = relationship("DecklistFolder", backref="user_decks")
cards = relationship(
"UserDeckCard",
back_populates="deck",
cascade="all, delete-orphan",
order_by="UserDeckCard.id",
)
def __repr__(self) -> str:
return f"<UserDeck {self.name} (user={self.user_id}, status={self.status})>"
class UserDeckCard(Base):
"""
Junction table for user deck cards.
Stores individual card entries in a user's deck with quantity and zone.
Card references point to mtg_cards.id in the mtgdata database.
"""
__tablename__ = "user_deck_cards"
id = Column(BigInteger, primary_key=True, autoincrement=True)
deck_id = Column(
BigInteger, ForeignKey("user_decks.id", ondelete="CASCADE"),
nullable=False, index=True,
)
card_id = Column(
Integer, ForeignKey("mtgonline_cards.id"), nullable=False, index=True,
)
quantity = Column(Integer, nullable=False, default=1)
zone = Column(String(20), nullable=False, default="main") # 'main' or 'sideboard'
position = Column(Integer, nullable=True) # Optional ordering within zone
__table_args__ = (
UniqueConstraint("deck_id", "card_id", "zone", name="uq_deck_card_unique"),
Index("idx_deck_cards_deck", "deck_id"),
Index("idx_deck_cards_card", "card_id"),
)
# Relationships
deck = relationship("UserDeck", back_populates="cards")
card = relationship("MtgonlineCard", back_populates="deck_cards")
def __repr__(self) -> str:
return f"<UserDeckCard deck={self.deck_id} card={self.card_id} qty={self.quantity}>"
class DeckPrecedent(Base):
"""
Deck precedent (template) storage.
Precedents are pre-built deck templates that users can clone
to start building their own decks.
"""
__tablename__ = "deck_precedents"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, index=True)
description = Column(Text, nullable=True)
format = Column(String(50), nullable=True, default="standard")
is_public = Column(Boolean, default=True, index=True)
created_by = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=True,
) # None = system
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
creator = relationship("User", foreign_keys=[created_by])
cards = relationship(
"DeckPrecedentCard",
back_populates="precedent",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<DeckPrecedent {self.name} (format={self.format})>"
class DeckPrecedentCard(Base):
"""Junction table for deck precedent cards."""
__tablename__ = "deck_precedent_cards"
id = Column(BigInteger, primary_key=True, autoincrement=True)
precedent_id = Column(
BigInteger, ForeignKey("deck_precedents.id", ondelete="CASCADE"),
nullable=False, index=True,
)
card_id = Column(Integer, nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=1)
zone = Column(String(20), nullable=False, default="main")
__table_args__ = (
UniqueConstraint(
"precedent_id", "card_id", "zone",
name="uq_precedent_card_unique",
),
)
precedent = relationship("DeckPrecedent", back_populates="cards")
def __repr__(self) -> str:
return f"<DeckPrecedentCard precedent={self.precedent_id} card={self.card_id}>"
class CardSuggestion(Base):
"""
Card suggestion storage.
Stores suggested cards for a deck based on similarity,
pairing patterns, or manual curation.
"""
__tablename__ = "card_suggestions"
id = Column(BigInteger, primary_key=True, autoincrement=True)
deck_id = Column(
BigInteger, ForeignKey("user_decks.id", ondelete="CASCADE"),
nullable=False, index=True,
)
card_id = Column(Integer, nullable=False, index=True) # Suggested card
source_card_id = Column(Integer, nullable=True) # Card that triggered the suggestion
suggestion_type = Column(
String(50), nullable=False, default="SIMILAR",
) # SIMILAR, PAIRING, ALTERNATIVE
confidence = Column(Float, nullable=True) # 0.0 to 1.0
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
deck = relationship("UserDeck", backref="suggestions")
__table_args__ = (
UniqueConstraint(
"deck_id", "card_id", "source_card_id",
name="uq_suggestion_unique",
),
)
def __repr__(self) -> str:
return (
f"<CardSuggestion deck={self.deck_id} card={self.card_id} "
f"type={self.suggestion_type}>"
)
# ---------------------------------------------------------------------------
# User Data Models
# ---------------------------------------------------------------------------
class UserSession(Base):
"""User authentication session."""
__tablename__ = "user_sessions"
id = Column(BigInteger, primary_key=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False, index=True,
)
session_token_hash = Column(
String(255), unique=True, nullable=False, index=True,
)
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
expires_at = Column(DateTime, nullable=False)
is_active = Column(Boolean, default=True)
user = relationship("User", backref="sessions")
def __repr__(self) -> str:
return f"<UserSession user={self.user_id} expires={self.expires_at}>"
class DeckVersion(Base):
"""Deck version history."""
__tablename__ = "deck_versions"
id = Column(BigInteger, primary_key=True)
deck_id = Column(
Integer, ForeignKey("mtgonline_decklist_files.id", ondelete="CASCADE"),
nullable=False, index=True,
)
version_number = Column(Integer, nullable=False)
content = Column(Text, nullable=False)
status = Column(String(20), server_default="DRAFT")
comment = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
deck = relationship("DecklistFile", backref="versions")
def __repr__(self) -> str:
return f"<DeckVersion deck={self.deck_id} v={self.version_number}>"
class GameReplay(Base):
"""Game replay recording."""
__tablename__ = "game_replays"
id = Column(BigInteger, primary_key=True)
game_uuid = Column(String(36), unique=True, nullable=False)
room_id = Column(
Integer, ForeignKey("mtgonline_rooms.id"), nullable=True, index=True,
)
game_type = Column(String(50), nullable=True)
format = Column(String(50), nullable=True)
duration_seconds = Column(Integer, nullable=True)
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=True)
status = Column(String(20), server_default="IN_PROGRESS")
replay_data = Column(JSON, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
players = relationship(
"ReplayPlayer", back_populates="replay", cascade="all, delete-orphan",
)
outcomes = relationship(
"GameOutcome", back_populates="replay", cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<GameReplay {self.game_uuid} status={self.status}>"
class ReplayPlayer(Base):
"""Player in a game replay."""
__tablename__ = "replay_players"
id = Column(BigInteger, primary_key=True)
replay_id = Column(
BigInteger, ForeignKey("game_replays.id", ondelete="CASCADE"),
nullable=False, index=True,
)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
position = Column(Integer, nullable=True)
deck_id = Column(
Integer, ForeignKey("mtgonline_decklist_files.id"), nullable=True,
)
won = Column(Boolean, nullable=True)
lost = Column(Boolean, nullable=True)
concession = Column(Boolean, default=False)
turn_one = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())
replay = relationship("GameReplay", back_populates="players")
user = relationship("User")
deck = relationship("DecklistFile")
def __repr__(self) -> str:
return f"<ReplayPlayer replay={self.replay_id} user={self.user_id}>"
class GameOutcome(Base):
"""Game outcome record."""
__tablename__ = "game_outcomes"
id = Column(BigInteger, primary_key=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
game_uuid = Column(
String(36), ForeignKey("game_replays.game_uuid"),
nullable=False, index=True,
)
outcome = Column(String(20), nullable=False, index=True)
opponent_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=True,
)
format = Column(String(50), nullable=True)
rating_before = Column(Integer, nullable=True)
rating_after = Column(Integer, nullable=True)
rating_change = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
replay = relationship("GameReplay", back_populates="outcomes")
user = relationship("User", foreign_keys=[user_id])
opponent = relationship("User", foreign_keys=[opponent_id])
def __repr__(self) -> str:
return f"<GameOutcome user={self.user_id} {self.outcome}>"
class UserStatistics(Base):
"""User game statistics summary."""
__tablename__ = "user_statistics"
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), primary_key=True,
)
total_games = Column(Integer, default=0)
total_wins = Column(Integer, default=0)
total_losses = Column(Integer, default=0)
total_concessions = Column(Integer, default=0)
win_rate = Column(Float, default=0.0)
current_streak = Column(Integer, default=0)
best_streak = Column(Integer, default=0)
average_rating = Column(Float, default=0.0)
last_game_date = Column(DateTime, nullable=True)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
user = relationship("User")
def __repr__(self) -> str:
return (
f"<UserStatistics user={self.user_id} "
f"wins={self.total_wins} losses={self.total_losses}>"
)
class UserCardCollection(Base):
"""User card collection."""
__tablename__ = "user_card_collection"
id = Column(BigInteger, primary_key=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False, index=True,
)
card_id = Column(Integer, nullable=False, index=True)
quantity = Column(Integer, default=1)
condition = Column(String(20), server_default="NEAR_MINT")
language = Column(String(5), server_default="EN")
is_foil = Column(Boolean, default=False)
is_alt_art = Column(Boolean, default=False)
acquired_date = Column(DateTime, server_default=func.now())
acquisition_method = Column(String(50), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint(
"user_id", "card_id", "is_foil", "is_alt_art",
name="uq_collection_unique",
),
Index("idx_collection_user_card", "user_id", "card_id"),
)
user = relationship("User", backref="card_collection")
def __repr__(self) -> str:
return (
f"<UserCard user={self.user_id} card={self.card_id} "
f"qty={self.quantity}>"
)
class CardWishlist(Base):
"""User card wishlist."""
__tablename__ = "card_wishlist"
id = Column(BigInteger, primary_key=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False,
)
card_id = Column(Integer, nullable=False)
max_price = Column(Float, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
UniqueConstraint("user_id", "card_id", name="uq_wishlist_user_card"),
)
user = relationship("User", backref="wishlist")
def __repr__(self) -> str:
return f"<CardWishlist user={self.user_id} card={self.card_id}>"
class UserGroup(Base):
"""User group."""
__tablename__ = "user_groups"
id = Column(BigInteger, primary_key=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
is_public = Column(Boolean, default=True)
max_members = Column(Integer, default=50)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
owner = relationship("User", foreign_keys=[owner_id])
members = relationship(
"GroupMember", back_populates="group", cascade="all, delete-orphan",
)
messages = relationship(
"GroupChatMessage", back_populates="group", cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<UserGroup {self.name} owner={self.owner_id}>"
class GroupMember(Base):
"""Group member."""
__tablename__ = "group_members"
id = Column(BigInteger, primary_key=True)
group_id = Column(
BigInteger, ForeignKey("user_groups.id", ondelete="CASCADE"),
nullable=False, index=True,
)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
role = Column(String(20), server_default="MEMBER")
joined_at = Column(DateTime, server_default=func.now())
group = relationship("UserGroup", back_populates="members")
user = relationship("User")
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_member"),
)
def __repr__(self) -> str:
return (
f"<GroupMember group={self.group_id} user={self.user_id} "
f"role={self.role}>"
)
class GroupChatMessage(Base):
"""Group chat message."""
__tablename__ = "group_chat_messages"
id = Column(BigInteger, primary_key=True)
group_id = Column(
BigInteger, ForeignKey("user_groups.id", ondelete="CASCADE"),
nullable=False, index=True,
)
sender_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
message = Column(Text, nullable=False)
created_at = Column(DateTime, server_default=func.now(), index=True)
group = relationship("UserGroup", back_populates="messages")
sender = relationship("User", foreign_keys=[sender_id])
def __repr__(self) -> str:
return f"<GroupChatMessage group={self.group_id} sender={self.sender_id}>"
class UserNetwork(Base):
"""User network (extended social connection)."""
__tablename__ = "user_networks"
id = Column(BigInteger, primary_key=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
creator_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False,
)
is_public = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
creator = relationship("User", foreign_keys=[creator_id])
members = relationship(
"NetworkMember", back_populates="network", cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<UserNetwork {self.name} creator={self.creator_id}>"
class NetworkMember(Base):
"""Network member."""
__tablename__ = "network_members"
id = Column(BigInteger, primary_key=True)
network_id = Column(
BigInteger, ForeignKey("user_networks.id", ondelete="CASCADE"),
nullable=False, index=True,
)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
role = Column(String(20), server_default="MEMBER")
joined_at = Column(DateTime, server_default=func.now())
network = relationship("UserNetwork", back_populates="members")
user = relationship("User")
__table_args__ = (
UniqueConstraint("network_id", "user_id", name="uq_network_member"),
)
def __repr__(self) -> str:
return (
f"<NetworkMember network={self.network_id} user={self.user_id} "
f"role={self.role}>"
)
class UserPreference(Base):
"""User preferences and settings."""
__tablename__ = "user_preferences"
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), primary_key=True,
)
theme = Column(String(20), server_default="light")
notifications_enabled = Column(Boolean, default=True)
email_notifications = Column(Boolean, default=True)
auto_save_decks = Column(Boolean, default=True)
default_format = Column(String(50), server_default="standard")
language = Column(String(5), server_default="EN")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
user = relationship("User")
def __repr__(self) -> str:
return f"<UserPreference user={self.user_id} theme={self.theme}>"
class UserActivityLog(Base):
"""User activity log."""
__tablename__ = "user_activity_log"
id = Column(BigInteger, primary_key=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
activity_type = Column(String(50), nullable=False, index=True)
activity_data = Column(JSON, nullable=True)
ip_address = Column(String(45), nullable=True)
created_at = Column(DateTime, server_default=func.now(), index=True)
user = relationship("User", backref="activity_logs")
def __repr__(self) -> str:
return f"<UserActivity user={self.user_id} type={self.activity_type}>"
# ---------------------------------------------------------------------------
# Card Import Models
# ---------------------------------------------------------------------------
class CardImportBatch(Base):
"""
Card import batch tracking.
Tracks a batch of card imports from a file, including
matching results and error information.
"""
__tablename__ = "card_import_batches"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False,
)
filename = Column(String(255), nullable=False)
file_type = Column(String(50), nullable=True) # 'mtgjson', 'csv', etc.
file_size = Column(BigInteger, nullable=True) # Size in bytes
status = Column(
String(20), default="PENDING",
) # PENDING, PROCESSING, COMPLETED, FAILED
total_cards = Column(Integer, default=0)
matched_cards = Column(Integer, default=0)
unmatched_cards = Column(Integer, default=0)
match_results = Column(Text, nullable=True) # JSON of match details
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", backref="card_import_batches")
records = relationship(
"UserCardImportRecord",
back_populates="batch",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<CardImportBatch {self.filename} (status={self.status})>"
class UserCardImportRecord(Base):
"""
Individual card import record within a batch.
Tracks whether each card in an import batch was confirmed
and when the confirmation happened.
"""
__tablename__ = "user_card_imports_confirmed"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False,
)
batch_id = Column(
BigInteger, ForeignKey("card_import_batches.id", ondelete="CASCADE"),
nullable=False,
)
is_confirmed = Column(Boolean, default=False)
confirmed_at = Column(DateTime, nullable=True)
# Relationships
batch = relationship("CardImportBatch", back_populates="records")
__table_args__ = (
UniqueConstraint(
"user_id", "batch_id", name="uq_user_card_import_batch",
),
)
def __repr__(self) -> str:
return (
f"<UserCardImportRecord user={self.user_id} batch={self.batch_id} "
f"confirmed={self.is_confirmed}>"
)
class UserCardImport(Base):
"""
User card import record.
Stores a user's imported card collection as a JSON array of card names.
This table was created in migration 004 and may be superseded by
CardImportBatch and UserCardImportRecord in migration 005.
"""
__tablename__ = "user_card_imports"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False,
)
card_names_json = Column(Text, nullable=False) # JSON array of card names
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", backref="card_imports")
__table_args__ = (
UniqueConstraint(
"user_id", name="uq_user_card_imports_user_id",
),
)
def __repr__(self) -> str:
return f"<UserCardImport user={self.user_id}>"
# ---------------------------------------------------------------------------
# Cross-Model Relationships
# ---------------------------------------------------------------------------
# Add the back-reference from DecklistFile to DeckCardLink.
# DeckCardLink is defined in mtg_models.py; we import it here after all
# classes in this file are defined to avoid circular imports.
from app.models.mtg_models import DeckCardLink # noqa: E402
DecklistFile.card_links = relationship(
"DeckCardLink",
back_populates="deck",
cascade="all, delete-orphan",
order_by="DeckCardLink.id",
)
# ---------------------------------------------------------------------------
# Indexes for performance
# ---------------------------------------------------------------------------
Index("idx_decks_owner", DecklistFile.owner_id)
Index("idx_decks_folder", DecklistFile.folder_id)
Index("idx_bans_active", Ban.active)
Index("idx_log_timestamp", GameLog.timestamp)
@@ -0,0 +1,184 @@
"""
SQLAlchemy ORM models for MTG card data and mirrors.
Consolidated from mtg_models.py and mirror_models.py into a single module.
This file contains all MTG card data models:
- MtgSet, MtgCard: Canonical card/set data from mtgjson.com (mtg_data DB)
- MtgCardMirror: Mirrored card data copied to mtgonline DB for fast deckbuilding queries
- DeckCardLink: Junction table linking user decks to mirrored cards
Cross-model references to application models (e.g., DeckCardLink.deck → DecklistFile)
use string-based relationship() calls to avoid circular imports.
"""
from sqlalchemy import (
Column, Integer, String, Text, DateTime, ForeignKey, Index,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
# ---------------------------------------------------------------------------
# MTG Set and Card (canonical data from mtgjson.com)
# ---------------------------------------------------------------------------
class MtgSet(Base):
"""MTG Set model."""
__tablename__ = "mtg_sets"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(10), unique=True, nullable=False, index=True)
name = Column(String(255), nullable=True)
type = Column(String(100), nullable=True)
release_date = Column(DateTime, nullable=True)
base_set_size = Column(Integer, nullable=True)
total_size = Column(Integer, nullable=True)
is_foil_only = Column(Integer, nullable=True)
is_non_foil_only = Column(Integer, nullable=True)
digital = Column(Integer, nullable=True)
icon_svg_url = Column(Text, nullable=True)
parent_code = Column(String(10), nullable=True)
mtgo_code = Column(String(10), nullable=True)
image = Column(Text, nullable=True) # Card image URL from MTGJSON
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
cards = relationship("MtgCard", back_populates="set")
def __repr__(self) -> str:
return f"<MtgSet {self.code}: {self.name}>"
class MtgCard(Base):
"""MTG Card model."""
__tablename__ = "mtg_cards"
id = Column(Integer, primary_key=True, index=True)
set_id = Column(Integer, ForeignKey("mtg_sets.id"), nullable=True, index=True)
name = Column(String(255), nullable=True, index=True)
mana_cost = Column(String(255), nullable=True, index=True)
type_line = Column(String(255), nullable=True, index=True)
oracle_text = Column(Text, nullable=True)
power = Column(String(50), nullable=True)
toughness = Column(String(50), nullable=True)
rarity = Column(String(50), nullable=True, index=True)
layout = Column(String(50), nullable=True)
artist = Column(String(255), nullable=True)
flavor_text = Column(Text, nullable=True)
numbers = Column(String(100), nullable=True)
identifiers = Column(Text, nullable=True) # JSON string
images = Column(Text, nullable=True) # JSON string
image = Column(Text, nullable=True) # Card image URL from MTGJSON
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
set = relationship("MtgSet", back_populates="cards")
def __repr__(self) -> str:
return f"<MtgCard {self.name} ({self.set_id})>"
# ---------------------------------------------------------------------------
# Card Mirror and Deck Links (mtgonline DB)
# ---------------------------------------------------------------------------
class MtgCardMirror(Base):
"""
Mirrored card data for user decks.
Contains all relevant card fields copied from mtg_cards (mtg_data)
to avoid cross-DB joins during deckbuilding. Back-references source_id
to the canonical mtg_cards.id for sync tracking.
"""
__tablename__ = "mtg_cards_mirror"
id = Column(Integer, primary_key=True, index=True)
source_id = Column(
Integer,
nullable=True,
index=True,
comment="Logical reference to mtg_cards.id in mtg_data database "
"(cross-DB reference, no FK constraint)",
)
name = Column(String(255), nullable=False, index=True)
mana_cost = Column(String(255), nullable=True)
type_line = Column(String(255), nullable=True)
oracle_text = Column(Text, nullable=True)
power = Column(String(50), nullable=True)
toughness = Column(String(50), nullable=True)
rarity = Column(String(50), nullable=True)
layout = Column(String(50), nullable=True)
artist = Column(String(255), nullable=True)
flavor_text = Column(Text, nullable=True)
numbers = Column(String(100), nullable=True)
identifiers = Column(Text, nullable=True) # JSON string of all identifiers
images = Column(Text, nullable=True) # JSON string of image URLs
image = Column(Text, nullable=True) # Card image URL from MTGJSON
card_parts = Column(Text, nullable=True) # Comma-separated list of face names
keywords = Column(Text, nullable=True) # Comma-separated keywords
legalities = Column(Text, nullable=True) # JSON of format legality
set_code = Column(String(10), nullable=True, index=True)
set_name = Column(String(255), nullable=True)
# Sync tracking
synced_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
created_at = Column(DateTime, server_default=func.now())
# Relationships
deck_links = relationship(
"DeckCardLink",
back_populates="card",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<MtgCardMirror {self.name} (ID: {self.id}, source: {self.source_id})>"
class DeckCardLink(Base):
"""
Junction table linking user decks to mirrored cards.
Represents a specific quantity of a mirrored card in a specific zone
(main deck or sideboard) of a user's deck.
"""
__tablename__ = "deck_card_links"
id = Column(Integer, primary_key=True, index=True)
deck_id = Column(
Integer, ForeignKey("mtgonline_decklist_files.id", ondelete="CASCADE"),
nullable=False,
)
card_id = Column(
Integer, ForeignKey("mtg_cards_mirror.id"), nullable=False,
)
quantity = Column(Integer, nullable=False, default=1)
zone = Column(String(20), nullable=False, default="main") # 'main' or 'sideboard'
# Composite unique: a card can only appear once per zone in a deck
__table_args__ = (
UniqueConstraint("deck_id", "card_id", "zone", name="uq_deck_card_link"),
Index("idx_deck_card_deck", "deck_id"),
Index("idx_deck_card_card", "card_id"),
)
# Relationships
# String reference to DecklistFile in models.py to avoid circular imports
deck = relationship("DecklistFile", back_populates="card_links")
card = relationship("MtgCardMirror", back_populates="deck_links")
def __repr__(self) -> str:
return (
f"<DeckCardLink deck={self.deck_id} card={self.card_id} "
f"qty={self.quantity} zone={self.zone}>"
)
# ---------------------------------------------------------------------------
# Indexes for performance
# ---------------------------------------------------------------------------
Index("idx_mtg_cards_name_set", MtgCard.name, MtgCard.set_id)
Index("idx_mtg_cards_type", MtgCard.type_line)
Index("idx_mtg_cards_rarity", MtgCard.rarity)
@@ -0,0 +1,24 @@
"""
Cross-model relationships.
This module defines relationships that reference models from other
model files (e.g., DecklistFile.card_links → DeckCardLink). These
must be defined AFTER all model classes have been imported to avoid
circular import issues.
Import this module LAST in app/models/__init__.py.
"""
from sqlalchemy.orm import relationship
from app.models.models import DecklistFile
from app.models.mirror_models import DeckCardLink
# Add the back-reference from DecklistFile to DeckCardLink.
# This was previously defined dynamically at the bottom of mirror_models.py,
# but that caused circular imports. Now it lives here and is imported last.
DecklistFile.card_links = relationship(
"DeckCardLink",
back_populates="deck",
cascade="all, delete-orphan",
order_by="DeckCardLink.id"
)
@@ -0,0 +1,40 @@
"""
SQLAlchemy ORM model for user card imports.
This model corresponds to the user_card_imports table created in migration 004.
It stores a user's imported card collection as a JSON array of card names.
"""
from sqlalchemy import (
Column, Integer, String, BigInteger, DateTime, Text,
ForeignKey, UniqueConstraint
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class UserCardImport(Base):
"""
User card import record.
Stores a user's imported card collection as a JSON array of card names.
This table was created in migration 004 and may be superseded by
CardImportBatch and UserCardImportRecord in migration 005.
"""
__tablename__ = "user_card_imports"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False)
card_names_json = Column(Text, nullable=False) # JSON array of card names
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", backref="card_imports")
__table_args__ = (
UniqueConstraint('user_id', name='uq_user_card_imports_user_id'),
)
def __repr__(self) -> str:
return f"<UserCardImport user={self.user_id}>"
@@ -0,0 +1,11 @@
"""
SQLAlchemy ORM models for user card import records.
This module is deprecated. UserCardImportRecord is now defined in
card_import_batch.py along with CardImportBatch.
"""
# This file is kept for backward compatibility but the actual model
# is now in card_import_batch.py
from app.models.card_import_batch import UserCardImportRecord as UserCardImportRecord
__all__ = ["UserCardImportRecord"]
+325
View File
@@ -0,0 +1,325 @@
"""
SQLAlchemy ORM models for user data features.
Includes sessions, decks, replays, cards, groups, and networks.
"""
from sqlalchemy import (
Column, Integer, String, BigInteger, Boolean, DateTime, Text,
ForeignKey, Index, UniqueConstraint, Float, JSON
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class UserSession(Base):
"""User authentication session."""
__tablename__ = "user_sessions"
id = Column(BigInteger, primary_key=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True)
session_token_hash = Column(String(255), unique=True, nullable=False, index=True)
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
expires_at = Column(DateTime, nullable=False)
is_active = Column(Boolean, default=True)
user = relationship("User", backref="sessions")
def __repr__(self) -> str:
return f"<UserSession user={self.user_id} expires={self.expires_at}>"
class DeckVersion(Base):
"""Deck version history."""
__tablename__ = "deck_versions"
id = Column(BigInteger, primary_key=True)
deck_id = Column(Integer, ForeignKey("mtgonline_decklist_files.id", ondelete="CASCADE"), nullable=False, index=True)
version_number = Column(Integer, nullable=False)
content = Column(Text, nullable=False)
status = Column(String(20), server_default="DRAFT")
comment = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
deck = relationship("DecklistFile", backref="versions")
def __repr__(self) -> str:
return f"<DeckVersion deck={self.deck_id} v={self.version_number}>"
class GameReplay(Base):
"""Game replay recording."""
__tablename__ = "game_replays"
id = Column(BigInteger, primary_key=True)
game_uuid = Column(String(36), unique=True, nullable=False)
room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=True, index=True)
game_type = Column(String(50), nullable=True)
format = Column(String(50), nullable=True)
duration_seconds = Column(Integer, nullable=True)
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=True)
status = Column(String(20), server_default="IN_PROGRESS")
replay_data = Column(JSON, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
players = relationship("ReplayPlayer", back_populates="replay", cascade="all, delete-orphan")
outcomes = relationship("GameOutcome", back_populates="replay", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<GameReplay {self.game_uuid} status={self.status}>"
class ReplayPlayer(Base):
"""Player in a game replay."""
__tablename__ = "replay_players"
id = Column(BigInteger, primary_key=True)
replay_id = Column(BigInteger, ForeignKey("game_replays.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
position = Column(Integer, nullable=True)
deck_id = Column(Integer, ForeignKey("mtgonline_decklist_files.id"), nullable=True)
won = Column(Boolean, nullable=True)
lost = Column(Boolean, nullable=True)
concession = Column(Boolean, default=False)
turn_one = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())
replay = relationship("GameReplay", back_populates="players")
user = relationship("User")
deck = relationship("DecklistFile")
def __repr__(self) -> str:
return f"<ReplayPlayer replay={self.replay_id} user={self.user_id}>"
class GameOutcome(Base):
"""Game outcome record."""
__tablename__ = "game_outcomes"
id = Column(BigInteger, primary_key=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
game_uuid = Column(String(36), ForeignKey("game_replays.game_uuid"), nullable=False, index=True)
outcome = Column(String(20), nullable=False, index=True)
opponent_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=True)
format = Column(String(50), nullable=True)
rating_before = Column(Integer, nullable=True)
rating_after = Column(Integer, nullable=True)
rating_change = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
replay = relationship("GameReplay", back_populates="outcomes")
user = relationship("User", foreign_keys=[user_id])
opponent = relationship("User", foreign_keys=[opponent_id])
def __repr__(self) -> str:
return f"<GameOutcome user={self.user_id} {self.outcome}>"
class UserStatistics(Base):
"""User game statistics summary."""
__tablename__ = "user_statistics"
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), primary_key=True)
total_games = Column(Integer, default=0)
total_wins = Column(Integer, default=0)
total_losses = Column(Integer, default=0)
total_concessions = Column(Integer, default=0)
win_rate = Column(Float, default=0.0)
current_streak = Column(Integer, default=0)
best_streak = Column(Integer, default=0)
average_rating = Column(Float, default=0.0)
last_game_date = Column(DateTime, nullable=True)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
user = relationship("User")
def __repr__(self) -> str:
return f"<UserStatistics user={self.user_id} wins={self.total_wins} losses={self.total_losses}>"
class UserCardCollection(Base):
"""User card collection."""
__tablename__ = "user_card_collection"
id = Column(BigInteger, primary_key=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True)
card_id = Column(Integer, nullable=False, index=True)
quantity = Column(Integer, default=1)
condition = Column(String(20), server_default="NEAR_MINT")
language = Column(String(5), server_default="EN")
is_foil = Column(Boolean, default=False)
is_alt_art = Column(Boolean, default=False)
acquired_date = Column(DateTime, server_default=func.now())
acquisition_method = Column(String(50), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint('user_id', 'card_id', 'is_foil', 'is_alt_art', name='uq_collection_unique'),
Index('idx_collection_user_card', 'user_id', 'card_id'),
)
user = relationship("User", backref="card_collection")
def __repr__(self) -> str:
return f"<UserCard user={self.user_id} card={self.card_id} qty={self.quantity}>"
class CardWishlist(Base):
"""User card wishlist."""
__tablename__ = "card_wishlist"
id = Column(BigInteger, primary_key=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False)
card_id = Column(Integer, nullable=False)
max_price = Column(Float, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
UniqueConstraint('user_id', 'card_id', name='uq_wishlist_user_card'),
)
user = relationship("User", backref="wishlist")
def __repr__(self) -> str:
return f"<CardWishlist user={self.user_id} card={self.card_id}>"
class UserGroup(Base):
"""User group."""
__tablename__ = "user_groups"
id = Column(BigInteger, primary_key=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
is_public = Column(Boolean, default=True)
max_members = Column(Integer, default=50)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
owner = relationship("User", foreign_keys=[owner_id])
members = relationship("GroupMember", back_populates="group", cascade="all, delete-orphan")
messages = relationship("GroupChatMessage", back_populates="group", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<UserGroup {self.name} owner={self.owner_id}>"
class GroupMember(Base):
"""Group member."""
__tablename__ = "group_members"
id = Column(BigInteger, primary_key=True)
group_id = Column(BigInteger, ForeignKey("user_groups.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
role = Column(String(20), server_default="MEMBER")
joined_at = Column(DateTime, server_default=func.now())
group = relationship("UserGroup", back_populates="members")
user = relationship("User")
__table_args__ = (
UniqueConstraint('group_id', 'user_id', name='uq_group_member'),
)
def __repr__(self) -> str:
return f"<GroupMember group={self.group_id} user={self.user_id} role={self.role}>"
class GroupChatMessage(Base):
"""Group chat message."""
__tablename__ = "group_chat_messages"
id = Column(BigInteger, primary_key=True)
group_id = Column(BigInteger, ForeignKey("user_groups.id", ondelete="CASCADE"), nullable=False, index=True)
sender_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
message = Column(Text, nullable=False)
created_at = Column(DateTime, server_default=func.now(), index=True)
group = relationship("UserGroup", back_populates="messages")
sender = relationship("User", foreign_keys=[sender_id])
def __repr__(self) -> str:
return f"<GroupChatMessage group={self.group_id} sender={self.sender_id}>"
class UserNetwork(Base):
"""User network (extended social connection)."""
__tablename__ = "user_networks"
id = Column(BigInteger, primary_key=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
creator_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False)
is_public = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
creator = relationship("User", foreign_keys=[creator_id])
members = relationship("NetworkMember", back_populates="network", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<UserNetwork {self.name} creator={self.creator_id}>"
class NetworkMember(Base):
"""Network member."""
__tablename__ = "network_members"
id = Column(BigInteger, primary_key=True)
network_id = Column(BigInteger, ForeignKey("user_networks.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
role = Column(String(20), server_default="MEMBER")
joined_at = Column(DateTime, server_default=func.now())
network = relationship("UserNetwork", back_populates="members")
user = relationship("User")
__table_args__ = (
UniqueConstraint('network_id', 'user_id', name='uq_network_member'),
)
def __repr__(self) -> str:
return f"<NetworkMember network={self.network_id} user={self.user_id} role={self.role}>"
class UserPreference(Base):
"""User preferences and settings."""
__tablename__ = "user_preferences"
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), primary_key=True)
theme = Column(String(20), server_default="light")
notifications_enabled = Column(Boolean, default=True)
email_notifications = Column(Boolean, default=True)
auto_save_decks = Column(Boolean, default=True)
default_format = Column(String(50), server_default="standard")
language = Column(String(5), server_default="EN")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
user = relationship("User")
def __repr__(self) -> str:
return f"<UserPreference user={self.user_id} theme={self.theme}>"
class UserActivityLog(Base):
"""User activity log."""
__tablename__ = "user_activity_log"
id = Column(BigInteger, primary_key=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
activity_type = Column(String(50), nullable=False, index=True)
activity_data = Column(JSON, nullable=True)
ip_address = Column(String(45), nullable=True)
created_at = Column(DateTime, server_default=func.now(), index=True)
user = relationship("User", backref="activity_logs")
def __repr__(self) -> str:
return f"<UserActivity user={self.user_id} type={self.activity_type}>"
+153
View File
@@ -0,0 +1,153 @@
"""
SQLAlchemy ORM models for per-user deck building.
These models live in the primary mtgonline database and support
the deckbuilding feature with DRAFT/FINAL status, card management,
deck precedents, and card suggestions.
"""
from sqlalchemy import (
Column, Integer, String, BigInteger, Boolean, DateTime, Text,
ForeignKey, UniqueConstraint, Index, Float
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
from app.models.models import MtgonlineCard
class UserDeck(Base):
"""Per-user deck storage in the primary mtgonline database."""
__tablename__ = "user_decks"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(255), nullable=False, index=True)
status = Column(String(20), nullable=False, default="DRAFT", index=True) # DRAFT or FINAL
folder_id = Column(Integer, ForeignKey("mtgonline_decklist_folders.id"), nullable=True)
format = Column(String(50), nullable=True, default="standard") # Standard, Modern, etc.
notes = Column(Text, nullable=True)
is_precedent = Column(Boolean, default=False, index=True) # Mark as deck precedent/template
precedent_name = Column(String(255), nullable=True) # Name for precedent (if applicable)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", backref="user_decks")
folder = relationship("DecklistFolder", backref="user_decks")
cards = relationship(
"UserDeckCard",
back_populates="deck",
cascade="all, delete-orphan",
order_by="UserDeckCard.id"
)
def __repr__(self) -> str:
return f"<UserDeck {self.name} (user={self.user_id}, status={self.status})>"
class UserDeckCard(Base):
"""
Junction table for user deck cards.
Stores individual card entries in a user's deck with quantity and zone.
Card references point to mtg_cards.id in the mtgdata database.
"""
__tablename__ = "user_deck_cards"
id = Column(BigInteger, primary_key=True, autoincrement=True)
deck_id = Column(BigInteger, ForeignKey("user_decks.id", ondelete="CASCADE"), nullable=False, index=True)
card_id = Column(Integer, ForeignKey("mtgonline_cards.id"), nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=1)
zone = Column(String(20), nullable=False, default="main") # 'main' or 'sideboard'
position = Column(Integer, nullable=True) # Optional ordering within zone
__table_args__ = (
UniqueConstraint('deck_id', 'card_id', 'zone', name='uq_deck_card_unique'),
Index('idx_deck_cards_deck', 'deck_id'),
Index('idx_deck_cards_card', 'card_id'),
)
# Relationships
deck = relationship("UserDeck", back_populates="cards")
card = relationship("MtgonlineCard", back_populates="deck_cards")
def __repr__(self) -> str:
return f"<UserDeckCard deck={self.deck_id} card={self.card_id} qty={self.quantity}>"
class DeckPrecedent(Base):
"""
Deck precedent (template) storage.
Precedents are pre-built deck templates that users can clone
to start building their own decks.
"""
__tablename__ = "deck_precedents"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, index=True)
description = Column(Text, nullable=True)
format = Column(String(50), nullable=True, default="standard")
is_public = Column(Boolean, default=True, index=True)
created_by = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=True) # None = system
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
creator = relationship("User", foreign_keys=[created_by])
cards = relationship(
"DeckPrecedentCard",
back_populates="precedent",
cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<DeckPrecedent {self.name} (format={self.format})>"
class DeckPrecedentCard(Base):
"""Junction table for deck precedent cards."""
__tablename__ = "deck_precedent_cards"
id = Column(BigInteger, primary_key=True, autoincrement=True)
precedent_id = Column(BigInteger, ForeignKey("deck_precedents.id", ondelete="CASCADE"), nullable=False, index=True)
card_id = Column(Integer, nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=1)
zone = Column(String(20), nullable=False, default="main")
__table_args__ = (
UniqueConstraint('precedent_id', 'card_id', 'zone', name='uq_precedent_card_unique'),
)
precedent = relationship("DeckPrecedent", back_populates="cards")
def __repr__(self) -> str:
return f"<DeckPrecedentCard precedent={self.precedent_id} card={self.card_id}>"
class CardSuggestion(Base):
"""
Card suggestion storage.
Stores suggested cards for a deck based on similarity,
pairing patterns, or manual curation.
"""
__tablename__ = "card_suggestions"
id = Column(BigInteger, primary_key=True, autoincrement=True)
deck_id = Column(BigInteger, ForeignKey("user_decks.id", ondelete="CASCADE"), nullable=False, index=True)
card_id = Column(Integer, nullable=False, index=True) # Suggested card
source_card_id = Column(Integer, nullable=True) # Card that triggered the suggestion
suggestion_type = Column(String(50), nullable=False, default="SIMILAR") # SIMILAR, PAIRING, ALTERNATIVE
confidence = Column(Float, nullable=True) # 0.0 to 1.0
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
deck = relationship("UserDeck", backref="suggestions")
__table_args__ = (
UniqueConstraint('deck_id', 'card_id', 'source_card_id', name='uq_suggestion_unique'),
)
def __repr__(self) -> str:
return f"<CardSuggestion deck={self.deck_id} card={self.card_id} type={self.suggestion_type}>"
+20 -22
View File
@@ -3,29 +3,27 @@ Router package exports.
All routers are mounted in app/main.py.
This package provides centralized access to all router modules.
## Consolidated Routers (Phase 3 Simplification)
- auth_router.py - Authentication endpoints
- users_router.py - User management + admin operations
- decks_router.py - Deck building and management
- cards_router.py - Card data search and interactions
- user_data_router.py - User data, sessions, and card imports
- games_router.py - Room management, games, and MTGJSON refresh
"""
from app.routers import auth
from app.routers import users
from app.routers import decks
from app.routers import rooms
from app.routers import games
from app.routers import admin
from app.routers import card_router
from app.routers import interactions
from app.routers import refresh
from app.routers import card_import
from app.routers import user_data
from app.routers import auth_router
from app.routers import users_router
from app.routers import decks_router
from app.routers import cards_router
from app.routers import user_data_router
from app.routers import games_router
__all__ = [
"auth",
"users",
"decks",
"rooms",
"games",
"admin",
"card_router",
"interactions",
"refresh",
"card_import",
"user_data",
"auth_router",
"users_router",
"decks_router",
"cards_router",
"user_data_router",
"games_router",
]
@@ -1,18 +1,34 @@
"""Authentication router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
"""
Authentication Router
This module provides the authentication router for the MTG Online application.
It handles user authentication, registration, and session management through
JWT (JSON Web Token) based authentication.
The router provides the following endpoints:
- POST /login: Authenticate user credentials and return JWT tokens
- POST /refresh: Refresh an expired access token using a refresh token
- POST /register: Register a new user account
- GET /me: Retrieve the current authenticated user's profile
All endpoints use async SQLAlchemy for database operations and FastAPI
dependencies for database session management.
"""
from datetime import datetime, timezone
from typing import Optional
from datetime import datetime, timezone, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import (
verify_password,
hash_password,
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from app.models.models import User
from app.schemas.schemas import (
@@ -28,8 +44,27 @@ router = APIRouter()
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Authenticate user and return JWT tokens."""
async def login(
request: LoginRequest,
db: AsyncSession = Depends(get_db),
) -> LoginResponse:
"""Authenticate user and return JWT tokens.
Validates the provided username and password against the database.
Checks if the account is active and not banned before issuing tokens.
Updates the user's last login timestamp on successful authentication.
Args:
request: The login request containing username and password.
db: The async database session.
Returns:
LoginResponse containing access_token, refresh_token, and user data.
Raises:
HTTPException 401: Invalid username or password.
HTTPException 403: Account is disabled or currently banned.
"""
# Find user by username
stmt = select(User).where(User.username == request.username)
result = await db.execute(stmt)
@@ -69,8 +104,25 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(request: RefreshTokenRequest, db: AsyncSession = Depends(get_db)):
"""Refresh access token using refresh token."""
async def refresh_token(
request: RefreshTokenRequest,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
"""Refresh access token using refresh token.
Validates the provided refresh token and issues a new access token
if the token is valid and the associated user account is still active.
Args:
request: The refresh token request.
db: The async database session.
Returns:
TokenResponse containing a new access_token.
Raises:
HTTPException 401: Invalid refresh token or inactive user account.
"""
payload = decode_token(request.refresh_token)
if not payload or payload.get("type") != "refresh":
@@ -98,8 +150,25 @@ async def refresh_token(request: RefreshTokenRequest, db: AsyncSession = Depends
@router.post("/register", response_model=UserResponse)
async def register(request: UserCreate, db: AsyncSession = Depends(get_db)):
"""Register a new user account."""
async def register(
request: UserCreate,
db: AsyncSession = Depends(get_db),
) -> UserResponse:
"""Register a new user account.
Creates a new user in the database after checking for duplicate
username and email addresses. The password is hashed before storage.
Args:
request: The user registration data.
db: The async database session.
Returns:
UserResponse containing the newly created user data.
Raises:
HTTPException 409: Username or email already exists.
"""
# Check if username exists
stmt = select(User).where(User.username == request.username)
result = await db.execute(stmt)
@@ -138,8 +207,23 @@ async def register(request: UserCreate, db: AsyncSession = Depends(get_db)):
async def get_current_user(
token: str,
db: AsyncSession = Depends(get_db),
):
"""Get current authenticated user."""
) -> UserResponse:
"""Get current authenticated user.
Decodes the provided JWT access token and retrieves the associated
user profile from the database.
Args:
token: The JWT access token.
db: The async database session.
Returns:
UserResponse containing the current user's profile data.
Raises:
HTTPException 401: Invalid or expired token.
HTTPException 404: User not found in database.
"""
payload = decode_token(token)
if not payload or payload.get("type") != "access":
-368
View File
@@ -1,368 +0,0 @@
"""
Card import router endpoints.
Provides endpoints for importing card collections from files,
viewing import status, and confirming imports.
"""
import json
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from datetime import datetime
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_data import UserCardCollection
from app.models.models import MtgonlineCard
from app.models.user_deck import UserDeck, UserDeckCard
from app.services.file_parser import FileParser
from app.services.import_batch_processor import ImportBatchProcessor
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
from app.schemas.card_import_schemas import (
CardImportRequest,
CardImportResponse,
CardImportStatusResponse,
CardMatchResult,
CardImportSummary,
)
from app.schemas.generic_schemas import MessageResponse
from app.schemas.user_deck_schemas import DeckCardResponse, DeckCardListResponse
router = APIRouter()
@router.post("/import", response_model=CardImportResponse, status_code=status.HTTP_201_CREATED)
async def upload_card_import(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Upload a card import file (XLSX, CSV, JSON, ODS).
Parses the file, performs fuzzy matching, and creates an import batch.
"""
user_id = int(current_user["user_id"])
# Validate file type
if not file.filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must have a filename"
)
file_type = file.filename.split(".")[-1].lower()
supported_types = ["xlsx", "csv", "json", "ods"]
if file_type not in supported_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported file type: {file_type}. Supported types: {supported_types}"
)
# Read file content
content = await file.read()
file_size = len(content)
# Parse file
from pathlib import Path
import tempfile
with tempfile.NamedTemporaryFile(suffix=f".{file_type}", delete=False) as tmp_file:
tmp_file.write(content)
tmp_file_path = Path(tmp_file.name)
try:
card_names = await FileParser.parse_file(tmp_file_path)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to parse file: {str(e)}"
)
if not card_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File contains no card names"
)
# Create import batch
batch = await ImportBatchProcessor.create_batch(
db=db,
user_id=user_id,
filename=file.filename,
file_type=file_type,
file_size=file_size,
card_names=card_names,
)
# Process batch
result = await ImportBatchProcessor.process_batch(
db=db,
batch=batch,
card_names=card_names,
)
return CardImportResponse(
message=f"Import batch created successfully",
batch_id=batch.id,
card_count=len(card_names),
status=result["status"],
imported_at=datetime.utcnow(),
)
@router.get("/import/{import_id}/status", response_model=CardImportStatusResponse)
async def get_import_status(
import_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get the status of an import batch."""
user_id = int(current_user["user_id"])
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
if not batch:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Import batch {import_id} not found"
)
if batch.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
return CardImportStatusResponse(
has_import=True,
batch_id=batch.id,
status=batch.status,
card_count=batch.total_cards,
matched_count=batch.matched_cards,
unmatched_count=batch.unmatched_cards,
last_imported=batch.updated_at,
error_message=batch.error_message,
)
@router.get("/import/{import_id}/results", response_model=CardImportSummary)
async def get_import_results(
import_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get the match results for an import batch."""
user_id = int(current_user["user_id"])
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
if not batch:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Import batch {import_id} not found"
)
if batch.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
if batch.status != "completed":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Import batch {import_id} has not completed yet (status: {batch.status})"
)
match_results = batch.match_results or []
matched_cards = []
unmatched_cards = []
for original_name, card_id, matched_name, confidence, match_type in match_results:
if matched_name:
matched_cards.append(CardMatchResult(
card_id=card_id,
card_name=original_name,
matched_name=matched_name,
match_type=match_type,
confidence=confidence,
))
else:
unmatched_cards.append(original_name)
return CardImportSummary(
total_cards=len(match_results),
matched_cards=matched_cards,
unmatched_cards=unmatched_cards,
import_id=batch.id,
)
@router.post("/import/{import_id}/confirm", response_model=MessageResponse)
async def confirm_import(
import_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Confirm an import batch and save to user's card collection."""
user_id = int(current_user["user_id"])
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
if not batch:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Import batch {import_id} not found"
)
if batch.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
if batch.status != "completed":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Import batch {import_id} has not completed yet (status: {batch.status})"
)
# Confirm batch
try:
await ImportBatchProcessor.confirm_batch(db, import_id, user_id)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# Save matched cards to user's collection
match_results = batch.match_results or []
saved_count = 0
for original_name, card_id, matched_name, confidence, match_type in match_results:
if card_id and match_type in ["exact", "high_confidence"]:
# Check if already in collection
stmt = select(UserCardCollection).where(
UserCardCollection.user_id == user_id,
UserCardCollection.card_id == card_id,
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
existing.quantity += 1
else:
new_collection = UserCardCollection(
user_id=user_id,
card_id=card_id,
quantity=1,
)
db.add(new_collection)
saved_count += 1
await db.flush()
return MessageResponse(
message=f"Import confirmed. {saved_count} cards added to your collection."
)
@router.get("/user/cards", response_model=List[Dict[str, Any]])
async def get_user_cards(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List user's imported cards."""
user_id = int(current_user["user_id"])
stmt = select(UserCardCollection).where(UserCardCollection.user_id == user_id).order_by(UserCardCollection.created_at.desc())
result = await db.execute(stmt)
collections = result.scalars().all()
# Fetch card details
card_ids = [c.card_id for c in collections]
card_details = {}
if card_ids:
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
card_result = await db.execute(card_stmt)
for card in card_result.scalars().all():
card_details[card.id] = card
result_list = []
for collection in collections:
card = card_details.get(collection.card_id)
result_list.append({
"collection_id": collection.id,
"card_id": collection.card_id,
"card_name": card.name if card else f"Card#{collection.card_id}",
"card_type_line": card.type_line if card else "",
"quantity": collection.quantity,
"condition": collection.condition,
"language": collection.language,
"is_foil": collection.is_foil,
"is_alt_art": collection.is_alt_art,
"acquired_date": collection.acquired_date,
})
return result_list
@router.delete("/user/cards/{card_import_id}", response_model=MessageResponse)
async def delete_user_card(
card_import_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Remove a card from user's collection."""
user_id = int(current_user["user_id"])
stmt = select(UserCardCollection).where(
UserCardCollection.id == card_import_id,
UserCardCollection.user_id == user_id,
)
result = await db.execute(stmt)
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Card not found in your collection"
)
await db.delete(collection)
await db.flush()
return MessageResponse(message="Card removed from your collection")
@router.get("/user/decks", response_model=List[Dict[str, Any]])
async def get_user_decks(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List user's decks."""
user_id = int(current_user["user_id"])
stmt = select(UserDeck).where(UserDeck.user_id == user_id).order_by(UserDeck.updated_at.desc())
result = await db.execute(stmt)
decks = result.scalars().all()
result_list = []
for deck in decks:
result_list.append({
"deck_id": deck.id,
"name": deck.name,
"status": deck.status,
"format": deck.format,
"folder_id": deck.folder_id,
"notes": deck.notes,
"is_precedent": deck.is_precedent,
"created_at": deck.created_at,
"updated_at": deck.updated_at,
})
return result_list
-292
View File
@@ -1,292 +0,0 @@
"""
Card search router for MTG card database.
Provides endpoints for searching and retrieving MTG card data
with filters for type, set, and color.
"""
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import mtg_get_db
from app.core.redis_client import cache_get, cache_set
from app.services.card_search_service import CardSearchService
from app.services.deck_suggestion_service import DeckSuggestionService
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
from app.models.user_deck import UserDeck
from app.models.mtg_models import MtgCard
from app.schemas.card_search_schemas import CardSearchResponse, CardResponse, SetResponse, CardTypeResponse
router = APIRouter(prefix="/api/cards", tags=["Card Search"])
@router.get("/search", response_model=CardSearchResponse)
async def search_cards_endpoint(
q: str = Query(..., min_length=1, description="Search query"),
card_type: Optional[str] = Query(None, description="Filter by card type"),
set_code: Optional[str] = Query(None, description="Filter by set code"),
color: Optional[str] = Query(None, description="Filter by color (e.g., WU, BR)"),
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(mtg_get_db),
):
"""
Search cards with filters.
Supports filtering by type, set, and color in addition to name search.
Uses fuzzy matching as a fallback when exact/partial matches are not found.
"""
cache_key = f"card_search:{q}:{card_type}:{set_code}:{color}:{limit}:{offset}"
# Check cache first
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
# Normalize the search query for consistency
normalized_query = FuzzyCardMatcher.normalize_card_name(q)
# Search cards using the existing service
results = await CardSearchService.search_cards(
db=db,
query=q,
card_type=card_type,
set_code=set_code,
color=color,
limit=limit,
offset=offset,
)
# If no results found, try fuzzy matching as a fallback
if results["total"] == 0:
fuzzy_results = await _fuzzy_search_fallback(
db=db,
query=q,
normalized_query=normalized_query,
card_type=card_type,
set_code=set_code,
color=color,
limit=limit,
offset=offset,
)
results = fuzzy_results
# Add fuzzy matching metadata to results
results["query_normalized"] = normalized_query
results["fuzzy_match"] = True
# Cache results for 5 minutes
await cache_set(cache_key, str(results), ttl=300)
return {"cached": False, "results": results}
async def _fuzzy_search_fallback(
db: AsyncSession,
query: str,
normalized_query: str,
card_type: Optional[str] = None,
set_code: Optional[str] = None,
color: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> Dict[str, Any]:
"""
Fallback fuzzy search when exact/partial matches yield no results.
Fetches all cards matching the type/set/color filters, then uses
fuzzy matching to find the best card name matches.
"""
from sqlalchemy import select, or_
# Fetch candidate cards based on non-name filters
conditions = []
if card_type:
conditions.append(MtgCard.type_line.ilike(f"%{card_type}%"))
if set_code:
conditions.append(MtgCard.set_code == set_code)
if color:
colors = [c.strip() for c in color.upper().split(",")]
for c in colors:
if c in ["W", "U", "B", "R", "G"]:
conditions.append(MtgCard.colors.ilike(f"%{c}%"))
# If no filters, fetch a broader set for fuzzy matching
if not conditions:
stmt = select(MtgCard).limit(limit * 5)
else:
stmt = select(MtgCard).where(*conditions).limit(limit * 5)
result = await db.execute(stmt)
candidate_cards = result.scalars().all()
if not candidate_cards:
return {
"cards": [],
"total": 0,
"page": offset // limit + 1,
"page_size": limit,
"total_pages": 0,
"fuzzy_fallback": True,
"message": "No cards found matching your query.",
}
# Build candidate name list and lookup
candidate_names = [card.name for card in candidate_cards if card.name]
card_lookup = {card.name.lower(): card for card in candidate_cards if card.name}
# Use fuzzy matching to find best matches
matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match(
normalized_query, candidate_names, threshold=FuzzyCardMatcher.MIN_MATCH_THRESHOLD
)
# Build results from fuzzy matches
card_list = []
if matched_name and matched_name.lower() in card_lookup:
card = card_lookup[matched_name.lower()]
card_data = {
"id": card.id,
"name": card.name,
"mana_cost": card.mana_cost,
"type_line": card.type_line,
"oracle_text": card.oracle_text,
"power": card.power,
"toughness": card.toughness,
"rarity": card.rarity,
"layout": card.layout,
"colors": card.colors,
"set_code": card.set_code,
"set_name": card.set_name,
"fuzzy_match": True,
"match_confidence": confidence,
"match_type": match_type,
"original_query": query,
}
card_list.append(card_data)
return {
"cards": card_list,
"total": len(card_list),
"page": offset // limit + 1,
"page_size": limit,
"total_pages": (len(card_list) + limit - 1) // limit if card_list else 0,
"fuzzy_fallback": True,
"match_type": match_type,
"confidence": confidence,
}
@router.get("/{card_id}", response_model=CardResponse)
async def get_card_endpoint(
card_id: int,
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get a specific card by ID.
"""
cache_key = f"card_by_id:{card_id}"
# Check cache first
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
# Get card
card = await CardSearchService.get_card_by_id(db, card_id)
if not card:
raise HTTPException(status_code=404, detail="Card not found")
# Cache for 10 minutes
await cache_set(cache_key, str(card), ttl=600)
return {"cached": False, "results": card}
@router.get("/sets", response_model=List[SetResponse])
async def get_sets_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all available sets.
"""
cache_key = "all_sets:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
sets = await CardSearchService.get_sets(db)
# Cache for 1 hour
await cache_set(cache_key, str(sets), ttl=3600)
return {"cached": False, "results": sets}
@router.get("/types", response_model=List[CardTypeResponse])
async def get_card_types_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all unique card types.
"""
cache_key = "card_types:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
types = await CardSearchService.get_card_types(db)
# Cache for 30 minutes
await cache_set(cache_key, str(types), ttl=1800)
return {"cached": False, "results": types}
@router.get("/rarities", response_model=List[str])
async def get_card_rarities_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all unique card rarities.
"""
cache_key = "card_rarities:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
rarities = await CardSearchService.get_card_rarities(db)
# Cache for 30 minutes
await cache_set(cache_key, str(rarities), ttl=1800)
return {"cached": False, "results": rarities}
@router.get("/suggest", response_model=List[Dict[str, Any]])
async def suggest_cards_endpoint(
deck_id: int = Query(..., description="Deck ID to suggest cards for"),
limit: int = Query(20, ge=1, le=100, description="Maximum suggestions"),
db: AsyncSession = Depends(mtg_get_db),
):
"""
Suggest similar cards for a deck.
Matches by: same type, same color, same set, same mana cost,
and cards often paired in existing user decks.
"""
# Verify deck exists
stmt = select(UserDeck).where(UserDeck.id == deck_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
suggestions = await DeckSuggestionService.suggest_cards(db, deck_id, limit)
return suggestions
@@ -1,29 +1,342 @@
"""
Card interaction router for MTG card interaction database.
Cards Router - Consolidated Card Data and Interaction Endpoints.
Provides endpoints for searching card synergies, counters, evolutions,
and getting recommendations based on card interactions.
This module provides two FastAPI routers for the MTG Online card database:
1. **Card Search Router** (`/api/cards`) - Card data endpoints:
- Search cards with filters and fuzzy matching fallback
- Retrieve individual cards by ID
- List available sets, card types, and rarities
- Suggest cards for a given deck
2. **Card Interactions Router** (`/interactions`) - Interaction data endpoints:
- Get synergies, counters, and evolutions for a card
- Search interactions with multi-filter support
- Get interaction recommendations
- Retrieve aggregated interaction statistics
The card search endpoints use SQLAlchemy ORM models and dedicated services
with Redis caching. The interaction endpoints use raw SQL queries against
the `mtg_card_*` tables, also with Redis caching.
Both routers share the `mtg_get_db` dependency (secondary database session).
"""
from typing import Optional, List, Dict, Any
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from app.core.database import mtg_get_db
from app.core.redis_client import cache_get, cache_set
router = APIRouter(prefix="/interactions", tags=["Card Interactions"])
from app.models.mtg_models import MtgCard
from app.models.models import UserDeck
from app.schemas.card_search_schemas import (
CardResponse,
CardSearchResponse,
CardTypeResponse,
SetResponse,
)
from app.services.card_search_service import CardSearchService
from app.services.deck_suggestion_service import DeckSuggestionService
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
@router.get("/synergies/{card_id}")
# ---------------------------------------------------------------------------
# Card Search Router (/api/cards)
# ---------------------------------------------------------------------------
card_router = APIRouter(prefix="/api/cards", tags=["Card Search"])
@card_router.get("/search", response_model=CardSearchResponse)
async def search_cards_endpoint(
q: str = Query(..., min_length=1, description="Search query"),
card_type: Optional[str] = Query(None, description="Filter by card type"),
set_code: Optional[str] = Query(None, description="Filter by set code"),
color: Optional[str] = Query(None, description="Filter by color (e.g., WU, BR)"),
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(mtg_get_db),
) -> Dict[str, Any]:
"""
Search cards with filters.
Supports filtering by type, set, and color in addition to name search.
Uses fuzzy matching as a fallback when exact/partial matches are not found.
"""
cache_key = f"card_search:{q}:{card_type}:{set_code}:{color}:{limit}:{offset}"
# Check cache first
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
# Normalize the search query for consistency
normalized_query = FuzzyCardMatcher.normalize_card_name(q)
# Search cards using the existing service
results = await CardSearchService.search_cards(
db=db,
query=q,
card_type=card_type,
set_code=set_code,
color=color,
limit=limit,
offset=offset,
)
# If no results found, try fuzzy matching as a fallback
if results["total"] == 0:
fuzzy_results = await _fuzzy_search_fallback(
db=db,
query=q,
normalized_query=normalized_query,
card_type=card_type,
set_code=set_code,
color=color,
limit=limit,
offset=offset,
)
results = fuzzy_results
# Add fuzzy matching metadata to results
results["query_normalized"] = normalized_query
results["fuzzy_match"] = True
# Cache results for 5 minutes
await cache_set(cache_key, str(results), ttl=300)
return {"cached": False, "results": results}
async def _fuzzy_search_fallback(
db: AsyncSession,
query: str,
normalized_query: str,
card_type: Optional[str] = None,
set_code: Optional[str] = None,
color: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> Dict[str, Any]:
"""
Fallback fuzzy search when exact/partial matches yield no results.
Fetches all cards matching the type/set/color filters, then uses
fuzzy matching to find the best card name matches.
"""
# Fetch candidate cards based on non-name filters
conditions: list = []
if card_type:
conditions.append(MtgCard.type_line.ilike(f"%{card_type}%"))
if set_code:
conditions.append(MtgCard.set_code == set_code)
if color:
colors = [c.strip() for c in color.upper().split(",")]
for c in colors:
if c in ["W", "U", "B", "R", "G"]:
conditions.append(MtgCard.colors.ilike(f"%{c}%"))
# If no filters, fetch a broader set for fuzzy matching
if not conditions:
stmt = select(MtgCard).limit(limit * 5)
else:
stmt = select(MtgCard).where(*conditions).limit(limit * 5)
result = await db.execute(stmt)
candidate_cards = result.scalars().all()
if not candidate_cards:
return {
"cards": [],
"total": 0,
"page": offset // limit + 1,
"page_size": limit,
"total_pages": 0,
"fuzzy_fallback": True,
"message": "No cards found matching your query.",
}
# Build candidate name list and lookup
candidate_names = [card.name for card in candidate_cards if card.name]
card_lookup: Dict[str, Any] = {card.name.lower(): card for card in candidate_cards if card.name}
# Use fuzzy matching to find best matches
matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match(
normalized_query, candidate_names, threshold=FuzzyCardMatcher.MIN_MATCH_THRESHOLD
)
# Build results from fuzzy matches
card_list: List[Dict[str, Any]] = []
if matched_name and matched_name.lower() in card_lookup:
card = card_lookup[matched_name.lower()]
card_data: Dict[str, Any] = {
"id": card.id,
"name": card.name,
"mana_cost": card.mana_cost,
"type_line": card.type_line,
"oracle_text": card.oracle_text,
"power": card.power,
"toughness": card.toughness,
"rarity": card.rarity,
"layout": card.layout,
"colors": card.colors,
"set_code": card.set_code,
"set_name": card.set_name,
"fuzzy_match": True,
"match_confidence": confidence,
"match_type": match_type,
"original_query": query,
}
card_list.append(card_data)
return {
"cards": card_list,
"total": len(card_list),
"page": offset // limit + 1,
"page_size": limit,
"total_pages": (len(card_list) + limit - 1) // limit if card_list else 0,
"fuzzy_fallback": True,
"match_type": match_type,
"confidence": confidence,
}
@card_router.get("/{card_id}", response_model=CardResponse)
async def get_card_endpoint(
card_id: int,
db: AsyncSession = Depends(mtg_get_db),
) -> Dict[str, Any]:
"""
Get a specific card by ID.
"""
cache_key = f"card_by_id:{card_id}"
# Check cache first
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
# Get card
card = await CardSearchService.get_card_by_id(db, card_id)
if not card:
raise HTTPException(status_code=404, detail="Card not found")
# Cache for 10 minutes
await cache_set(cache_key, str(card), ttl=600)
return {"cached": False, "results": card}
@card_router.get("/sets", response_model=List[SetResponse])
async def get_sets_endpoint(
db: AsyncSession = Depends(mtg_get_db),
) -> Dict[str, Any]:
"""
Get all available sets.
"""
cache_key = "all_sets:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
sets = await CardSearchService.get_sets(db)
# Cache for 1 hour
await cache_set(cache_key, str(sets), ttl=3600)
return {"cached": False, "results": sets}
@card_router.get("/types", response_model=List[CardTypeResponse])
async def get_card_types_endpoint(
db: AsyncSession = Depends(mtg_get_db),
) -> Dict[str, Any]:
"""
Get all unique card types.
"""
cache_key = "card_types:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
types = await CardSearchService.get_card_types(db)
# Cache for 30 minutes
await cache_set(cache_key, str(types), ttl=1800)
return {"cached": False, "results": types}
@card_router.get("/rarities", response_model=List[str])
async def get_card_rarities_endpoint(
db: AsyncSession = Depends(mtg_get_db),
) -> Dict[str, Any]:
"""
Get all unique card rarities.
"""
cache_key = "card_rarities:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
rarities = await CardSearchService.get_card_rarities(db)
# Cache for 30 minutes
await cache_set(cache_key, str(rarities), ttl=1800)
return {"cached": False, "results": rarities}
@card_router.get("/suggest", response_model=List[Dict[str, Any]])
async def suggest_cards_endpoint(
deck_id: int = Query(..., description="Deck ID to suggest cards for"),
limit: int = Query(20, ge=1, le=100, description="Maximum suggestions"),
db: AsyncSession = Depends(mtg_get_db),
) -> List[Dict[str, Any]]:
"""
Suggest similar cards for a deck.
Matches by: same type, same color, same set, same mana cost,
and cards often paired in existing user decks.
"""
# Verify deck exists
stmt = select(UserDeck).where(UserDeck.id == deck_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
suggestions = await DeckSuggestionService.suggest_cards(db, deck_id, limit)
return suggestions
# ---------------------------------------------------------------------------
# Card Interactions Router (/interactions)
# ---------------------------------------------------------------------------
interaction_router = APIRouter(prefix="/interactions", tags=["Card Interactions"])
@interaction_router.get("/synergies/{card_id}")
async def get_card_synergies(
card_id: int,
synergy_type: Optional[str] = Query(None, description="Filter by synergy type (archetype, mechanic, mana, combo)"),
synergy_type: Optional[str] = Query(
None, description="Filter by synergy type (archetype, mechanic, mana, combo)"
),
min_strength: int = Query(1, ge=1, le=5, description="Minimum synergy strength"),
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(mtg_get_db),
):
) -> Dict[str, Any]:
"""
Get synergies for a specific card.
@@ -37,8 +350,8 @@ async def get_card_synergies(
return {"cached": True, "results": cached}
# Build query
conditions = ["card_a_id = :card_id OR card_b_id = :card_id"]
params = {"card_id": card_id}
conditions: list[str] = ["card_a_id = :card_id OR card_b_id = :card_id"]
params: Dict[str, Any] = {"card_id": card_id}
if synergy_type:
conditions.append("synergy_type = :synergy_type")
@@ -64,7 +377,7 @@ async def get_card_synergies(
result = await db.execute(text(query), params)
rows = result.fetchall()
synergies = []
synergies: List[Dict[str, Any]] = []
for row in rows:
synergies.append({
"id": row[0],
@@ -95,21 +408,23 @@ async def get_card_synergies(
"total": total,
"limit": limit,
"offset": offset,
}
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error fetching synergies: {str(e)}")
@router.get("/counters/{card_id}")
@interaction_router.get("/counters/{card_id}")
async def get_card_counters(
card_id: int,
counter_type: Optional[str] = Query(None, description="Filter by counter type (color, stats, spell, keyword)"),
counter_type: Optional[str] = Query(
None, description="Filter by counter type (color, stats, spell, keyword)"
),
min_strength: int = Query(1, ge=1, le=5, description="Minimum counter strength"),
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(mtg_get_db),
):
) -> Dict[str, Any]:
"""
Get counters for a specific card.
@@ -121,8 +436,8 @@ async def get_card_counters(
if cached:
return {"cached": True, "results": cached}
conditions = ["card_a_id = :card_id OR card_b_id = :card_id"]
params = {"card_id": card_id}
conditions: list[str] = ["card_a_id = :card_id OR card_b_id = :card_id"]
params: Dict[str, Any] = {"card_id": card_id}
if counter_type:
conditions.append("counter_type = :counter_type")
@@ -147,7 +462,7 @@ async def get_card_counters(
result = await db.execute(text(query), params)
rows = result.fetchall()
counters = []
counters: List[Dict[str, Any]] = []
for row in rows:
counters.append({
"id": row[0],
@@ -176,19 +491,21 @@ async def get_card_counters(
"total": total,
"limit": limit,
"offset": offset,
}
},
}
@router.get("/evolutions/{card_id}")
@interaction_router.get("/evolutions/{card_id}")
async def get_card_evolutions(
card_id: int,
evolution_type: Optional[str] = Query(None, description="Filter by evolution type (reprint, transform, double_sided)"),
evolution_type: Optional[str] = Query(
None, description="Filter by evolution type (reprint, transform, double_sided)"
),
min_strength: int = Query(1, ge=1, le=5, description="Minimum strength"),
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(mtg_get_db),
):
) -> Dict[str, Any]:
"""
Get evolutions for a specific card.
@@ -200,8 +517,8 @@ async def get_card_evolutions(
if cached:
return {"cached": True, "results": cached}
conditions = ["card_id = :card_id"]
params = {"card_id": card_id}
conditions: list[str] = ["card_id = :card_id"]
params: Dict[str, Any] = {"card_id": card_id}
if evolution_type:
conditions.append("evolution_type = :evolution_type")
@@ -226,7 +543,7 @@ async def get_card_evolutions(
result = await db.execute(text(query), params)
rows = result.fetchall()
evolutions = []
evolutions: List[Dict[str, Any]] = []
for row in rows:
evolutions.append({
"id": row[0],
@@ -255,17 +572,19 @@ async def get_card_evolutions(
"total": total,
"limit": limit,
"offset": offset,
}
},
}
@router.get("/recommend/{card_id}")
@interaction_router.get("/recommend/{card_id}")
async def get_card_recommendations(
card_id: int,
recommendation_type: str = Query("synergy", description="Type of recommendation (synergy, counter, evolution)"),
recommendation_type: str = Query(
"synergy", description="Type of recommendation (synergy, counter, evolution)"
),
limit: int = Query(10, ge=1, le=100, description="Maximum results"),
db: AsyncSession = Depends(mtg_get_db),
):
) -> Dict[str, Any]:
"""
Get interaction recommendations for a card.
@@ -316,14 +635,16 @@ async def get_card_recommendations(
LIMIT :limit
"""
else:
raise HTTPException(status_code=400, detail=f"Invalid recommendation type: {recommendation_type}")
raise HTTPException(
status_code=400, detail=f"Invalid recommendation type: {recommendation_type}"
)
params = {"card_id": card_id, "limit": limit}
params: Dict[str, Any] = {"card_id": card_id, "limit": limit}
result = await db.execute(text(query), params)
rows = result.fetchall()
recommendations = []
recommendations: List[Dict[str, Any]] = []
for row in rows:
recommendations.append({
"recommended_card_id": row[0],
@@ -341,7 +662,7 @@ async def get_card_recommendations(
}
@router.get("/search/synergies")
@interaction_router.get("/search/synergies")
async def search_synergies(
card_a_id: Optional[int] = Query(None, description="Card A ID"),
card_b_id: Optional[int] = Query(None, description="Card B ID"),
@@ -351,7 +672,7 @@ async def search_synergies(
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(mtg_get_db),
):
) -> Dict[str, Any]:
"""
Search synergies with multiple filters.
"""
@@ -361,8 +682,8 @@ async def search_synergies(
if cached:
return {"cached": True, "results": cached}
conditions = []
params = {}
conditions: list[str] = []
params: Dict[str, Any] = {}
if card_a_id:
conditions.append("card_a_id = :card_a_id")
@@ -399,7 +720,7 @@ async def search_synergies(
result = await db.execute(text(query), params)
rows = result.fetchall()
synergies = []
synergies: List[Dict[str, Any]] = []
for row in rows:
synergies.append({
"id": row[0],
@@ -428,11 +749,11 @@ async def search_synergies(
"total": total,
"limit": limit,
"offset": offset,
}
},
}
@router.get("/search/counters")
@interaction_router.get("/search/counters")
async def search_counters(
card_a_id: Optional[int] = Query(None, description="Card A ID"),
card_b_id: Optional[int] = Query(None, description="Card B ID"),
@@ -442,7 +763,7 @@ async def search_counters(
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(mtg_get_db),
):
) -> Dict[str, Any]:
"""
Search counters with multiple filters.
"""
@@ -452,8 +773,8 @@ async def search_counters(
if cached:
return {"cached": True, "results": cached}
conditions = []
params = {}
conditions: list[str] = []
params: Dict[str, Any] = {}
if card_a_id:
conditions.append("card_a_id = :card_a_id")
@@ -490,7 +811,7 @@ async def search_counters(
result = await db.execute(text(query), params)
rows = result.fetchall()
counters = []
counters: List[Dict[str, Any]] = []
for row in rows:
counters.append({
"id": row[0],
@@ -519,11 +840,11 @@ async def search_counters(
"total": total,
"limit": limit,
"offset": offset,
}
},
}
@router.get("/search/evolutions")
@interaction_router.get("/search/evolutions")
async def search_evolutions(
card_id: Optional[int] = Query(None, description="Card ID"),
evolved_card_id: Optional[int] = Query(None, description="Evolved Card ID"),
@@ -533,7 +854,7 @@ async def search_evolutions(
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(mtg_get_db),
):
) -> Dict[str, Any]:
"""
Search evolutions with multiple filters.
"""
@@ -543,8 +864,8 @@ async def search_evolutions(
if cached:
return {"cached": True, "results": cached}
conditions = []
params = {}
conditions: list[str] = []
params: Dict[str, Any] = {}
if card_id:
conditions.append("card_id = :card_id")
@@ -581,7 +902,7 @@ async def search_evolutions(
result = await db.execute(text(query), params)
rows = result.fetchall()
evolutions = []
evolutions: List[Dict[str, Any]] = []
for row in rows:
evolutions.append({
"id": row[0],
@@ -610,15 +931,15 @@ async def search_evolutions(
"total": total,
"limit": limit,
"offset": offset,
}
},
}
@router.get("/stats/{card_id}")
@interaction_router.get("/stats/{card_id}")
async def get_card_interaction_stats(
card_id: int,
db: AsyncSession = Depends(mtg_get_db),
):
) -> Dict[str, Any]:
"""
Get aggregated interaction statistics for a card.
"""
@@ -639,7 +960,7 @@ async def get_card_interaction_stats(
FROM mtg_card_interaction_stats
WHERE card_id = :card_id
"""
params = {"card_id": card_id}
params: Dict[str, Any] = {"card_id": card_id}
result = await db.execute(text(query), params)
row = result.fetchone()
@@ -654,10 +975,10 @@ async def get_card_interaction_stats(
"total_evolutions": 0,
"total_synergy_strength": 0,
"avg_synergy_strength": 0,
}
},
}
stats = {
stats: Dict[str, Any] = {
"card_id": row[0],
"total_synergies": row[1],
"total_counters": row[2],
@@ -1,39 +1,156 @@
"""
Deck management router endpoints for per-user deck building.
Deck Management Router — Complete Deck Building Functionality.
Provides CRUD operations for user decks with card management,
deck finalization, precedent templates, and card search integration.
This router provides the full deck building workflow for MTG Online users,
including:
* CRUD operations for per-user decklists (draft, final, precedent).
* Card management within decks (add, update, remove, list).
* Deck finalization (transition from DRAFT to FINAL status).
* Precedent (template) management — list, create, and clone into new decks.
* Card search integration using the local card mirror.
* Card suggestion system for deck refinement.
All endpoints require authentication via ``get_current_user`` and enforce
per-user ownership checks where appropriate.
Routes
------
Deck CRUD::
GET / List user decks (paginated, filterable)
POST / Create a new draft deck
GET /{deck_id} Get a specific deck
PATCH /{deck_id} Update deck metadata
DELETE /{deck_id} Delete a deck
Deck Finalize::
POST /{deck_id}/finalize Transition deck to FINAL status
Deck Card Management::
POST /{deck_id}/cards Add a card to a deck
GET /{deck_id}/cards List cards in a deck
PATCH /{deck_id}/cards/{card_id} Update card quantity/zone
DELETE /{deck_id}/cards/{card_id} Remove a card from a deck
Precedents::
GET /precedents List public precedents
POST /precedents Create a precedent
GET /precedents/{id} Get a specific precedent
POST /precedents/{id}/use Clone precedent into a new draft deck
Card Search::
POST /search/cards Search the local card mirror
Suggestions::
GET /{deck_id}/suggestions List suggestions for a deck
POST /{deck_id}/suggestions Add a suggestion to a deck
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_, update, delete
from __future__ import annotations
from typing import Optional, List
from app.core.database import get_db, mtg_get_db
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import User, DecklistFolder, MtgonlineCard
from app.models.mtg_models import MtgCard, MtgSet
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
from app.schemas.user_deck_schemas import (
UserDeckCreate, UserDeckUpdate, UserDeckResponse, UserDeckListResponse,
DeckCardCreate, DeckCardUpdate, DeckCardResponse, DeckCardWithDetailsResponse, DeckCardListResponse,
PrecedentCreate, PrecedentUpdate, PrecedentResponse, PrecedentListResponse,
SuggestionCreate, SuggestionResponse, SuggestionListResponse,
DeckFinalizeRequest, DeckFinalizeResponse,
CardSearchRequest,
from app.models.models import MtgonlineCard, User, DecklistFolder
from app.models.models import (
CardSuggestion,
DeckPrecedent,
DeckPrecedentCard,
UserDeck,
UserDeckCard,
)
from app.schemas.card_search_schemas import CardSearchResponse
from app.schemas.generic_schemas import MessageResponse, CountResponse
from app.schemas.generic_schemas import CountResponse, MessageResponse
from app.schemas.user_deck_schemas import (
CardSearchRequest,
DeckCardCreate,
DeckCardResponse,
DeckCardUpdate,
DeckCardListResponse,
DeckCardWithDetailsResponse,
DeckFinalizeRequest,
DeckFinalizeResponse,
PrecedentCreate,
PrecedentResponse,
PrecedentListResponse,
PrecedentUpdate,
SuggestionCreate,
SuggestionListResponse,
SuggestionResponse,
UserDeckCreate,
UserDeckResponse,
UserDeckUpdate,
UserDeckListResponse,
)
from app.services.deck_manager import DeckManager
from app.services.deck_parser import DeckParser
router = APIRouter()
_deck_mgr = DeckManager()
_deck_parser = DeckParser()
_deck_mgr: DeckManager = DeckManager()
_deck_parser: DeckParser = DeckParser()
# ===== Deck CRUD =====
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_user_id(current_user: dict) -> int:
"""Extract and convert the authenticated user's ID."""
return int(current_user["user_id"])
async def _verify_deck_ownership(
db: AsyncSession,
deck_id: int,
user_id: int,
) -> UserDeck:
"""Verify that a deck exists and belongs to the user.
Raises:
HTTPException: 404 if the deck is not found.
"""
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
return deck
async def _get_card_count(db: AsyncSession, deck_id: int) -> int:
"""Return the number of cards in a deck."""
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
return count_result.scalar() or 0
async def _enrich_deck_response(
deck: UserDeck,
card_count: int,
) -> UserDeckResponse:
"""Build a ``UserDeckResponse`` with card count and ownership flag."""
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
# ---------------------------------------------------------------------------
# Deck CRUD
# ---------------------------------------------------------------------------
@router.get("/", response_model=UserDeckListResponse)
async def list_user_decks(
@@ -44,9 +161,13 @@ async def list_user_decks(
page_size: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List user's decks with optional filtering."""
user_id = int(current_user["user_id"])
) -> UserDeckListResponse:
"""List user's decks with optional filtering.
Supports filtering by deck status, folder, and whether the deck is a
precedent. Results are paginated.
"""
user_id = _get_user_id(current_user)
try:
decks = await _deck_mgr.list_decks(
db=db,
@@ -60,10 +181,10 @@ async def list_user_decks(
# Build response with card counts
deck_ids = [d.id for d in decks]
card_counts = {}
card_counts: dict[int, int] = {}
if deck_ids:
count_subquery = (
select(UserDeckCard.deck_id, func.count().label('cnt'))
select(UserDeckCard.deck_id, func.count().label("cnt"))
.where(UserDeckCard.deck_id.in_(deck_ids))
.group_by(UserDeckCard.deck_id)
.subquery()
@@ -74,7 +195,7 @@ async def list_user_decks(
count_result = await db.execute(count_stmt)
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
deck_responses = []
deck_responses: list[UserDeckResponse] = []
for deck in decks:
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_counts.get(deck.id, 0)
@@ -98,9 +219,9 @@ async def create_user_deck(
request: UserDeckCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new user deck (DRAFT status)."""
user_id = int(current_user["user_id"])
) -> UserDeckResponse:
"""Create a new user deck in DRAFT status."""
user_id = _get_user_id(current_user)
try:
new_deck = await _deck_mgr.create_deck(
db=db,
@@ -129,23 +250,17 @@ async def get_user_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a specific user deck."""
user_id = int(current_user["user_id"])
) -> UserDeckResponse:
"""Get a specific user deck by ID."""
user_id = _get_user_id(current_user)
try:
deck = await _deck_mgr.get_deck(db=db, deck_id=deck_id, user_id=user_id)
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Get card count
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
card_count = await _get_card_count(db, deck_id)
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
return await _enrich_deck_response(deck, card_count)
except HTTPException:
raise
except Exception as e:
@@ -158,9 +273,9 @@ async def update_user_deck(
request: UserDeckUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a user deck."""
user_id = int(current_user["user_id"])
) -> UserDeckResponse:
"""Update a user deck's metadata."""
user_id = _get_user_id(current_user)
try:
update_data = request.model_dump(exclude_unset=True)
if "status" in update_data and update_data["status"]:
@@ -176,15 +291,8 @@ async def update_user_deck(
if not updated_deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Get card count
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
deck_data = UserDeckResponse.model_validate(updated_deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
card_count = await _get_card_count(db, deck_id)
return await _enrich_deck_response(updated_deck, card_count)
except HTTPException:
raise
except ValueError as e:
@@ -198,9 +306,9 @@ async def delete_user_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> MessageResponse:
"""Delete a user deck."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
try:
result = await _deck_mgr.delete_deck(db=db, deck_id=deck_id, user_id=user_id)
if not result:
@@ -212,16 +320,18 @@ async def delete_user_deck(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
# ===== Deck Finalize =====
# ---------------------------------------------------------------------------
# Deck Finalize
# ---------------------------------------------------------------------------
@router.post("/{deck_id}/finalize", response_model=DeckFinalizeResponse)
async def finalize_user_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> DeckFinalizeResponse:
"""Transition a deck from DRAFT to FINAL status."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
try:
deck = await _deck_mgr.finalize_deck(db=db, deck_id=deck_id, user_id=user_id)
if not deck:
@@ -239,7 +349,9 @@ async def finalize_user_deck(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ===== Deck Card Management =====
# ---------------------------------------------------------------------------
# Deck Card Management
# ---------------------------------------------------------------------------
@router.post("/{deck_id}/cards", response_model=DeckCardResponse, status_code=status.HTTP_201_CREATED)
async def add_deck_card(
@@ -247,16 +359,15 @@ async def add_deck_card(
request: DeckCardCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Add a card to a user's deck."""
user_id = int(current_user["user_id"])
) -> DeckCardResponse:
"""Add a card to a user's deck.
# Verify deck exists and belongs to user
deck_stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
deck_result = await db.execute(deck_stmt)
deck = deck_result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
If a card already exists in the same zone, the quantity is incremented
rather than creating a duplicate entry.
"""
user_id = _get_user_id(current_user)
deck = await _verify_deck_ownership(db, deck_id, user_id)
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
@@ -308,31 +419,25 @@ async def get_deck_cards(
zone: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get all cards in a user's deck."""
user_id = int(current_user["user_id"])
) -> DeckCardListResponse:
"""Get all cards in a user's deck, optionally filtered by zone."""
user_id = _get_user_id(current_user)
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
await _verify_deck_ownership(db, deck_id, user_id)
# Use service to get cards
deck_cards = await _deck_mgr.get_deck_cards(db=db, deck_id=deck_id, zone=zone)
# Fetch card details from local mirror
card_ids = [dc.card_id for dc in deck_cards]
card_details = {}
card_details: dict[int, MtgonlineCard] = {}
if card_ids:
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
card_result = await db.execute(card_stmt)
for c in card_result.scalars().all():
card_details[c.id] = c
card_responses = []
card_responses: list[DeckCardWithDetailsResponse] = []
for dc in deck_cards:
card = card_details.get(dc.card_id)
response = DeckCardWithDetailsResponse.model_validate(dc)
@@ -351,17 +456,11 @@ async def update_deck_card(
request: DeckCardUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> DeckCardResponse:
"""Update a card's quantity or zone in a deck."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
deck = await _verify_deck_ownership(db, deck_id, user_id)
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
@@ -398,17 +497,11 @@ async def remove_deck_card(
card_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> MessageResponse:
"""Remove a card from a user's deck."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
deck = await _verify_deck_ownership(db, deck_id, user_id)
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
@@ -430,7 +523,9 @@ async def remove_deck_card(
return MessageResponse(message="Card removed from deck")
# ===== Deck Precedents =====
# ---------------------------------------------------------------------------
# Deck Precedents
# ---------------------------------------------------------------------------
# Note: Precedent endpoints use direct DB operations as DeckManager
# does not yet have precedent-specific methods.
@@ -441,11 +536,11 @@ async def list_precedents(
format_filter: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List available deck precedents."""
) -> PrecedentListResponse:
"""List available (public) deck precedents, paginated."""
offset = (page - 1) * page_size
conditions = [DeckPrecedent.is_public == True]
conditions = [DeckPrecedent.is_public == True] # noqa: E712
if format_filter:
conditions.append(DeckPrecedent.format == format_filter)
@@ -461,10 +556,10 @@ async def list_precedents(
# Get card counts
prec_ids = [p.id for p in precedents]
card_counts = {}
card_counts: dict[int, int] = {}
if prec_ids:
count_subquery = (
select(DeckPrecedentCard.precedent_id, func.count().label('cnt'))
select(DeckPrecedentCard.precedent_id, func.count().label("cnt"))
.where(DeckPrecedentCard.precedent_id.in_(prec_ids))
.group_by(DeckPrecedentCard.precedent_id)
.subquery()
@@ -475,7 +570,7 @@ async def list_precedents(
count_result = await db.execute(count_stmt)
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
prec_responses = []
prec_responses: list[PrecedentResponse] = []
for p in precedents:
prec_data = PrecedentResponse.model_validate(p)
prec_data.card_count = card_counts.get(p.id, 0)
@@ -495,9 +590,9 @@ async def create_precedent(
request: PrecedentCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> PrecedentResponse:
"""Create a deck precedent (template)."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
new_precedent = DeckPrecedent(
name=request.name,
@@ -519,8 +614,8 @@ async def get_precedent(
precedent_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a specific deck precedent."""
) -> PrecedentResponse:
"""Get a specific deck precedent by ID."""
stmt = select(DeckPrecedent).where(DeckPrecedent.id == precedent_id)
result = await db.execute(stmt)
precedent = result.scalar_one_or_none()
@@ -528,10 +623,7 @@ async def get_precedent(
if not precedent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Precedent not found")
# Get card count
count_stmt = select(func.count()).select_from(DeckPrecedentCard).where(DeckPrecedentCard.precedent_id == precedent_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
card_count = await _get_card_count(db, precedent_id)
prec_data = PrecedentResponse.model_validate(precedent)
prec_data.card_count = card_count
@@ -543,9 +635,9 @@ async def use_precedent(
precedent_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> dict:
"""Clone a precedent into a new draft deck for the current user."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
try:
new_deck = await _deck_mgr.clone_precedent(
@@ -553,11 +645,12 @@ async def use_precedent(
precedent_id=precedent_id,
user_id=user_id,
)
card_count = len(await _deck_mgr.get_deck_cards(db=db, deck_id=new_deck.id))
return {
"message": "Precedent cloned into new deck",
"deck_id": new_deck.id,
"deck_name": new_deck.name,
"card_count": len(await _deck_mgr.get_deck_cards(db=db, deck_id=new_deck.id)),
"card_count": card_count,
}
except ValueError as e:
if "not found" in str(e).lower():
@@ -565,15 +658,21 @@ async def use_precedent(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ===== Card Search Integration =====
# ---------------------------------------------------------------------------
# Card Search Integration
# ---------------------------------------------------------------------------
@router.post("/search/cards", response_model=CardSearchResponse)
async def search_cards_for_deck(
request: CardSearchRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Search MTG cards for use in deck building."""
) -> CardSearchResponse:
"""Search MTG cards for use in deck building.
Searches across name, type line, and mana cost fields in the local
card mirror.
"""
offset = (request.offset // request.limit) * request.limit
# Search across multiple fields using local mirror
@@ -593,7 +692,7 @@ async def search_cards_for_deck(
result = await db.execute(stmt)
cards = result.scalars().all()
card_list = []
card_list: list[dict] = []
for card in cards:
card_data = {
"id": card.id,
@@ -634,7 +733,9 @@ async def search_cards_for_deck(
)
# ===== Card Suggestions =====
# ---------------------------------------------------------------------------
# Card Suggestions
# ---------------------------------------------------------------------------
# Note: Suggestion endpoints use direct DB operations as DeckManager
# does not yet have suggestion-specific methods.
@@ -644,17 +745,11 @@ async def get_deck_suggestions(
suggestion_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get card suggestions for a deck."""
user_id = int(current_user["user_id"])
) -> SuggestionListResponse:
"""Get card suggestions for a deck, optionally filtered by type."""
user_id = _get_user_id(current_user)
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
await _verify_deck_ownership(db, deck_id, user_id)
conditions = [CardSuggestion.deck_id == deck_id]
if suggestion_type:
@@ -667,14 +762,14 @@ async def get_deck_suggestions(
# Fetch card names from local mirror
card_ids = [s.card_id for s in suggestions]
card_names = {}
card_names: dict[int, str] = {}
if card_ids:
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
card_result = await db.execute(card_stmt)
for c in card_result.scalars().all():
card_names[c.id] = c.name
sugg_responses = []
sugg_responses: list[SuggestionResponse] = []
for s in suggestions:
sugg_data = SuggestionResponse.model_validate(s)
sugg_data.card_name = card_names.get(s.card_id, f"Card#{s.card_id}")
@@ -692,17 +787,11 @@ async def add_suggestion(
request: SuggestionCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> SuggestionResponse:
"""Add a card suggestion to a deck."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
deck = await _verify_deck_ownership(db, deck_id, user_id)
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
-135
View File
@@ -1,135 +0,0 @@
"""Game management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, update, delete
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import User
from app.schemas.schemas import GameCreate, GameResponse
router = APIRouter()
@router.get("/", response_model=List[GameResponse])
async def list_games(
room_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List active games."""
# This would query a games table - simplified for now
# In production, you'd have a MTG OnlineGames model
return []
@router.post("/", response_model=GameResponse)
async def create_game(
request: GameCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new game."""
user_id = int(current_user["user_id"])
# Verify user exists
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# In production, you'd create a game in the game database
# For now, return a mock response
game_data = {
"id": 1, # Mock ID
"room_id": request.room_id,
"game_type": request.game_type,
"description": request.description,
"with_password": bool(request.password),
"max_players": 4,
"player_count": 1,
"started": False,
"creation_date": datetime.now(),
}
return GameResponse(**game_data)
@router.get("/{game_id}", response_model=GameResponse)
async def get_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get game by ID."""
# In production, query the games table
return GameResponse(
id=game_id,
room_id=1,
game_type="Casual",
description="Test game",
with_password=False,
max_players=4,
player_count=0,
started=False,
creation_date=datetime.now(),
)
@router.post("/{game_id}/join")
async def join_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Join a game."""
user_id = int(current_user["user_id"])
# In production, add user to game players table
return {"message": f"User {user_id} joined game {game_id}"}
@router.post("/{game_id}/leave")
async def leave_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Leave a game."""
user_id = int(current_user["user_id"])
# In production, remove user from game players table
return {"message": f"User {user_id} left game {game_id}"}
@router.post("/{game_id}/start")
async def start_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Start a game (host only)."""
user_id = int(current_user["user_id"])
# In production, update game started status
return {"message": f"Game {game_id} started"}
@router.post("/{game_id}/end")
async def end_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""End a game."""
user_id = int(current_user["user_id"])
# In production, update game closed status
return {"message": f"Game {game_id} ended"}
+487
View File
@@ -0,0 +1,487 @@
"""
Games Router - Consolidated
This module consolidates room management, game operations, MTGJSON data refresh,
and WebSocket connections into a unified router structure.
Endpoints:
/rooms/* - Room CRUD operations (Admin/Judge only for write ops)
/games/* - Game management stubs
/mtgjson/* - MTGJSON dataset refresh and verification (Admin only)
/ws/game/{game_id} - WebSocket connection for game sessions
Architecture:
- Uses FastAPI sub-routers with different prefixes for logical grouping
- WebSocket endpoint for real-time game communication
- All endpoints require authentication via get_current_user dependency
- Admin/Judge privilege checks on sensitive operations
"""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, WebSocket, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, update, delete
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import Room, User
from app.schemas.schemas import RoomResponse, GameCreate, GameResponse
from app.services.mtgjson_manager import MTGJSONManager
from app.services.game_server import game_websocket_endpoint
# Main router
router = APIRouter()
# =============================================================================
# Room Management Sub-Router (prefix: /rooms)
# =============================================================================
rooms_router = APIRouter(prefix="/rooms", tags=["Rooms"])
@rooms_router.get("/", response_model=List[RoomResponse])
async def list_rooms(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> List[RoomResponse]:
"""List all available rooms.
Returns a list of all rooms in the system, ordered by ID.
Requires authentication.
"""
stmt = select(Room).order_by(Room.id)
result = await db.execute(stmt)
rooms = result.scalars().all()
return [RoomResponse.model_validate(room) for room in rooms]
@rooms_router.get("/{room_id}", response_model=RoomResponse)
async def get_room(
room_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> RoomResponse:
"""Get room by ID.
Returns detailed information about a specific room.
Raises 404 if room not found.
"""
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
return RoomResponse.model_validate(room)
@rooms_router.post("/", status_code=status.HTTP_201_CREATED)
async def create_room(
name: str,
description: Optional[str] = None,
is_password_protected: bool = False,
password_hash: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Create a new room (Admin/Judge only).
Creates a new game room with the specified configuration.
Only users with Admin or Judge privileges can create rooms.
Raises 409 if room name already exists.
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
# Check if room name already exists
stmt = select(Room).where(Room.name == name)
result = await db.execute(stmt)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Room name already exists",
)
new_room = Room(
name=name,
description=description,
is_password_protected=is_password_protected,
password_hash=password_hash,
)
db.add(new_room)
await db.flush()
return {"message": f"Room '{name}' created successfully", "room_id": new_room.id}
@rooms_router.patch("/{room_id}")
async def update_room(
room_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
is_password_protected: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Update room (Admin/Judge only).
Updates the specified fields of a room.
Only users with Admin or Judge privileges can update rooms.
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
# Update fields
update_data = {}
if name:
update_data["name"] = name
if description is not None:
update_data["description"] = description
if is_password_protected is not None:
update_data["is_password_protected"] = is_password_protected
stmt = (
update(Room)
.where(Room.id == room_id)
.values(**update_data)
)
await db.execute(stmt)
return {"message": f"Room '{name or room.name}' updated successfully"}
@rooms_router.delete("/{room_id}")
async def delete_room(
room_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Delete room (Admin/Judge only).
Permanently deletes a room from the system.
Only users with Admin or Judge privileges can delete rooms.
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
await db.execute(delete(Room).where(Room.id == room_id))
return {"message": f"Room '{room.name}' deleted successfully"}
# =============================================================================
# Game Management Sub-Router (prefix: /games)
# =============================================================================
games_router = APIRouter(prefix="/games", tags=["Games"])
@games_router.get("/", response_model=List[GameResponse])
async def list_games(
room_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> List[GameResponse]:
"""List active games.
Returns a list of currently active games.
Note: This is a stub implementation - returns empty list.
"""
# This would query a games table - simplified for now
# In production, you'd have a MTG OnlineGames model
return []
@games_router.post("/", response_model=GameResponse, status_code=status.HTTP_201_CREATED)
async def create_game(
request: GameCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> GameResponse:
"""Create a new game (mock implementation).
Creates a new game session.
Note: This is a stub - returns mock response.
"""
user_id = int(current_user["user_id"])
# Verify user exists
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# In production, you'd create a game in the game database
# For now, return a mock response
game_data = {
"id": 1, # Mock ID
"room_id": request.room_id,
"game_type": request.game_type,
"description": request.description,
"with_password": bool(request.password),
"max_players": 4,
"player_count": 1,
"started": False,
"creation_date": datetime.now(),
}
return GameResponse(**game_data)
@games_router.get("/{game_id}", response_model=GameResponse)
async def get_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> GameResponse:
"""Get game by ID (mock implementation).
Returns details about a specific game.
Note: This is a stub - returns mock response.
"""
# In production, query the games table
return GameResponse(
id=game_id,
room_id=1,
game_type="Casual",
description="Test game",
with_password=False,
max_players=4,
player_count=0,
started=False,
creation_date=datetime.now(),
)
@games_router.post("/{game_id}/join")
async def join_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Join a game (stub implementation).
Adds the current user to a game session.
Note: This is a stub - returns mock response.
"""
user_id = int(current_user["user_id"])
# In production, add user to game players table
return {"message": f"User {user_id} joined game {game_id}"}
@games_router.post("/{game_id}/leave")
async def leave_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Leave a game (stub implementation).
Removes the current user from a game session.
Note: This is a stub - returns mock response.
"""
user_id = int(current_user["user_id"])
# In production, remove user from game players table
return {"message": f"User {user_id} left game {game_id}"}
@games_router.post("/{game_id}/start")
async def start_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Start a game (host only, stub implementation).
Initiates a game session.
Note: This is a stub - returns mock response.
"""
user_id = int(current_user["user_id"])
# In production, update game started status
return {"message": f"Game {game_id} started"}
@games_router.post("/{game_id}/end")
async def end_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""End a game (stub implementation).
Terminates a game session.
Note: This is a stub - returns mock response.
"""
user_id = int(current_user["user_id"])
# In production, update game closed status
return {"message": f"Game {game_id} ended"}
# =============================================================================
# MTGJSON Data Refresh Sub-Router (prefix: /mtgjson)
# =============================================================================
mtgjson_router = APIRouter(prefix="/mtgjson", tags=["MTGJSON Data"])
@mtgjson_router.post("/refresh")
async def trigger_refresh(
background_tasks: BackgroundTasks,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Trigger a refresh of MTGJSON datasets (Admin only).
Downloads files from MTGJSON API and upserts data into PostgreSQL.
The refresh runs in the background and may take several minutes.
Only Admin users can trigger refreshes.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
# Create manager and trigger refresh in background
manager = MTGJSONManager()
background_tasks.add_task(manager.download_and_refresh, force=False)
return {
"message": "Refresh initiated in background",
"status": "started",
}
@mtgjson_router.get("/status")
async def get_refresh_status(
current_user: dict = Depends(get_current_user),
) -> dict:
"""Get current refresh status and data health (Admin only).
Returns information about:
- Last successful refresh timestamp
- Current data counts
- File status
- Required files status
Only Admin users can access this endpoint.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
manager = MTGJSONManager()
return await manager.get_health_status()
@mtgjson_router.post("/verify")
async def verify_files(
current_user: dict = Depends(get_current_user),
) -> dict:
"""Verify the integrity of downloaded MTGJSON files (Admin only).
Checks that all required files exist and are valid.
Only Admin users can verify files.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
manager = MTGJSONManager()
all_valid, errors = await manager.verify_files()
if not all_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"valid": False,
"errors": errors,
"message": "Some files failed verification",
},
)
return {
"valid": True,
"message": "All files verified successfully",
}
# =============================================================================
# WebSocket Endpoint
# =============================================================================
@router.websocket("/ws/game/{game_id}")
async def game_websocket(
websocket: WebSocket,
game_id: int,
player_id: Optional[int] = Query(None),
) -> None:
"""WebSocket endpoint for game connections.
Handles real-time game state updates for connected players.
Establishes a persistent connection for live game communication.
"""
await websocket.accept()
await game_websocket_endpoint(websocket, game_id)
# =============================================================================
# Include Sub-Routers
# =============================================================================
router.include_router(rooms_router)
router.include_router(games_router)
router.include_router(mtgjson_router)
-101
View File
@@ -1,101 +0,0 @@
"""
MTGJSON Data Refresh Router
Provides endpoints for triggering dataset downloads and managing refresh operations.
"""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.services.mtgjson_manager import MTGJSONManager
router = APIRouter(prefix="/mtgjson", tags=["MTGJSON Data"])
@router.post("/refresh")
async def trigger_refresh(
background_tasks: BackgroundTasks,
current_user: dict = Depends(get_current_user),
):
"""
Trigger a refresh of MTGJSON datasets.
Downloads files from MTGJSON API and upserts data into PostgreSQL.
Requires Admin privileges.
The refresh runs in the background and may take several minutes.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
# Create manager and trigger refresh in background
manager = MTGJSONManager()
background_tasks.add_task(manager.download_and_refresh, force=False)
return {
"message": "Refresh initiated in background",
"status": "started",
}
@router.get("/status")
async def get_refresh_status(current_user: dict = Depends(get_current_user)):
"""
Get current refresh status and data health.
Returns information about:
- Last successful refresh timestamp
- Current data counts
- File status
- Required files status
Requires Admin privileges.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
manager = MTGJSONManager()
return await manager.get_health_status()
@router.post("/verify")
async def verify_files(current_user: dict = Depends(get_current_user)):
"""
Verify the integrity of downloaded MTGJSON files.
Checks that all required files exist and are valid.
Requires Admin privileges.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
manager = MTGJSONManager()
all_valid, errors = await manager.verify_files()
if not all_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"valid": False,
"errors": errors,
"message": "Some files failed verification",
},
)
return {
"valid": True,
"message": "All files verified successfully",
}
-158
View File
@@ -1,158 +0,0 @@
"""Room management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, update, delete
from typing import Optional, List
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import Room
from app.schemas.schemas import RoomResponse
router = APIRouter()
@router.get("/", response_model=List[RoomResponse])
async def list_rooms(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all available rooms."""
stmt = select(Room).order_by(Room.id)
result = await db.execute(stmt)
rooms = result.scalars().all()
return [RoomResponse.model_validate(room) for room in rooms]
@router.get("/{room_id}", response_model=RoomResponse)
async def get_room(
room_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get room by ID."""
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
return RoomResponse.model_validate(room)
@router.post("/")
async def create_room(
name: str,
description: Optional[str] = None,
is_password_protected: bool = False,
password_hash: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new room (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
# Check if room name already exists
stmt = select(Room).where(Room.name == name)
result = await db.execute(stmt)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Room name already exists",
)
new_room = Room(
name=name,
description=description,
is_password_protected=is_password_protected,
password_hash=password_hash,
)
db.add(new_room)
await db.flush()
return {"message": f"Room '{name}' created successfully", "room_id": new_room.id}
@router.patch("/{room_id}")
async def update_room(
room_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
is_password_protected: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update room (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
# Update fields
update_data = {}
if name:
update_data["name"] = name
if description is not None:
update_data["description"] = description
if is_password_protected is not None:
update_data["is_password_protected"] = is_password_protected
stmt = (
update(Room)
.where(Room.id == room_id)
.values(**update_data)
)
await db.execute(stmt)
return {"message": f"Room '{name or room.name}' updated successfully"}
@router.delete("/{room_id}")
async def delete_room(
room_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete room (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
await db.execute(delete(Room).where(Room.id == room_id))
return {"message": f"Room '{room.name}' deleted successfully"}
File diff suppressed because it is too large Load Diff
-168
View File
@@ -1,168 +0,0 @@
"""User management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user, hash_password
from app.models.models import User
from app.schemas.schemas import UserUpdate, UserResponse
router = APIRouter()
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get user by ID."""
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserResponse.model_validate(user)
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
request: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update user profile."""
# Users can only update their own profile unless admin
if int(current_user["user_id"]) != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot update another user's profile",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update fields
update_data = request.model_dump(exclude_unset=True)
# Hash new password if provided
if "new_password" in update_data:
update_data["password_hash"] = hash_password(update_data.pop("new_password"))
# Update user
stmt = (
update(User)
.where(User.id == user_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
# Fetch updated user
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
updated_user = result.scalar_one_or_none()
return UserResponse.model_validate(updated_user)
@router.post("/{user_id}/ban")
async def ban_user(
user_id: int,
reason: str,
expiration_time: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Ban a user (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update user ban status
from datetime import datetime
ban_ends = None
if expiration_time:
ban_ends = datetime.fromisoformat(expiration_time)
stmt = (
update(User)
.where(User.id == user_id)
.values(
is_banned=True,
ban_reason=reason,
ban_ends=ban_ends,
)
)
await db.execute(stmt)
await db.flush()
return {"message": f"User {user_id} has been banned"}
@router.post("/{user_id}/unban")
async def unban_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Unban a user (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update user ban status
stmt = (
update(User)
.where(User.id == user_id)
.values(
is_banned=False,
ban_reason=None,
ban_ends=None,
)
)
await db.execute(stmt)
await db.flush()
return {"message": f"User {user_id} has been unbanned"}
+549
View File
@@ -0,0 +1,549 @@
"""
Users and Admin Router - Consolidated Endpoints
This module consolidates user management and administrative operations into a
single router. It provides endpoints for:
User Management:
- GET /{user_id}: Retrieve user profile by ID
- PATCH /{user_id}: Update user profile (self or admin)
- POST /{user_id}/ban: Ban a user (admin/judge only)
- POST /{user_id}/unban: Unban a user (admin/judge only)
Admin Operations:
- GET /users: List all users with pagination (admin/judge only)
- GET /bans: List all bans, optionally active only (admin/judge only)
- POST /bans: Create a new ban record (admin/judge only)
- POST /bans/{ban_id}/unban: Unban via ban ID (admin/judge only)
- GET /logs: List game logs with optional room filter (admin/judge only)
- POST /audit: Create an audit log entry (admin/judge only)
Both per-user ban/unban and global ban listing/creation are maintained.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user, hash_password
from app.models.models import User, Ban, GameLog, AuditLog
from app.schemas.schemas import UserUpdate, UserResponse, BanCreate, BanResponse
router = APIRouter()
# =============================================================================
# User Management Endpoints
# =============================================================================
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> UserResponse:
"""
Get user by ID.
Args:
user_id: The ID of the user to retrieve
db: Database session
current_user: The currently authenticated user
Returns:
UserResponse with user profile data
Raises:
HTTPException 404: If user not found
"""
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserResponse.model_validate(user)
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
request: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> UserResponse:
"""
Update user profile.
Users can only update their own profile unless they have admin privileges.
Args:
user_id: The ID of the user to update
request: UserUpdate schema with fields to update
db: Database session
current_user: The currently authenticated user
Returns:
UserResponse with updated user data
Raises:
HTTPException 403: If trying to update another user's profile
HTTPException 404: If user not found
"""
# Users can only update their own profile unless admin
if int(current_user["user_id"]) != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot update another user's profile",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update fields
update_data = request.model_dump(exclude_unset=True)
# Hash new password if provided
if "new_password" in update_data:
update_data["password_hash"] = hash_password(update_data.pop("new_password"))
# Update user
stmt = (
update(User)
.where(User.id == user_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
# Fetch updated user
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
updated_user = result.scalar_one_or_none()
return UserResponse.model_validate(updated_user)
@router.post("/{user_id}/ban")
async def ban_user(
user_id: int,
reason: str,
expiration_time: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""
Ban a user (admin/judge only).
Args:
user_id: The ID of the user to ban
reason: Reason for the ban
expiration_time: Optional ISO format datetime for ban expiration
db: Database session
current_user: The currently authenticated user
Returns:
Success message
Raises:
HTTPException 403: If user is not admin/judge
HTTPException 404: If user not found
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update user ban status
ban_ends = None
if expiration_time:
ban_ends = datetime.fromisoformat(expiration_time)
stmt = (
update(User)
.where(User.id == user_id)
.values(
is_banned=True,
ban_reason=reason,
ban_ends=ban_ends,
)
)
await db.execute(stmt)
await db.flush()
return {"message": f"User {user_id} has been banned"}
@router.post("/{user_id}/unban")
async def unban_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""
Unban a user (admin/judge only).
Args:
user_id: The ID of the user to unban
db: Database session
current_user: The currently authenticated user
Returns:
Success message
Raises:
HTTPException 403: If user is not admin/judge
HTTPException 404: If user not found
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update user ban status
stmt = (
update(User)
.where(User.id == user_id)
.values(
is_banned=False,
ban_reason=None,
ban_ends=None,
)
)
await db.execute(stmt)
await db.flush()
return {"message": f"User {user_id} has been unbanned"}
# =============================================================================
# Admin Operations Endpoints
# =============================================================================
@router.get("/users", response_model=List[dict])
async def list_users(
page: int = 1,
page_size: int = 50,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> List[dict]:
"""
List all users with pagination (admin/judge only).
Args:
page: Page number (1-indexed)
page_size: Number of users per page
db: Database session
current_user: The currently authenticated user
Returns:
List of user dictionaries
Raises:
HTTPException 403: If user is not admin/judge
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = (
select(User)
.order_by(User.id)
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
users = result.scalars().all()
return [
{
"id": user.id,
"username": user.username,
"email": user.email,
"privlevel": user.privlevel,
"is_active": user.is_active,
"is_banned": user.is_banned,
"vip_status": user.vip_status,
"creation_date": user.creation_date,
}
for user in users
]
@router.get("/bans", response_model=List[BanResponse])
async def list_bans(
active_only: bool = True,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> List[BanResponse]:
"""
List all bans (admin/judge only).
Args:
active_only: If True, only return active bans
db: Database session
current_user: The currently authenticated user
Returns:
List of BanResponse objects
Raises:
HTTPException 403: If user is not admin/judge
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Ban).order_by(Ban.id)
if active_only:
stmt = stmt.where(Ban.active == True)
result = await db.execute(stmt)
bans = result.scalars().all()
return [BanResponse.model_validate(ban) for ban in bans]
@router.post("/bans", response_model=BanResponse)
async def create_ban(
request: BanCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> BanResponse:
"""
Create a new ban record (admin/judge only).
Args:
request: BanCreate schema with ban details
db: Database session
current_user: The currently authenticated user
Returns:
BanResponse with created ban data
Raises:
HTTPException 403: If user is not admin/judge
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
new_ban = Ban(
user_id=request.user_id,
reason=request.reason,
expiration_time=request.expiration_time,
moderators=current_user.get("username"),
ip_address=None, # Would get from request
)
db.add(new_ban)
await db.flush()
return BanResponse.model_validate(new_ban)
@router.post("/bans/{ban_id}/unban")
async def unban(
ban_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""
Unban a user via ban ID (admin/judge only).
This deactivates the ban record and also updates the user's ban status.
Args:
ban_id: The ID of the ban to unban
db: Database session
current_user: The currently authenticated user
Returns:
Success message
Raises:
HTTPException 403: If user is not admin/judge
HTTPException 404: If ban not found
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Ban).where(Ban.id == ban_id)
result = await db.execute(stmt)
ban = result.scalar_one_or_none()
if not ban:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ban not found",
)
# Deactivate ban
stmt = (
update(Ban)
.where(Ban.id == ban_id)
.values(active=False)
)
await db.execute(stmt)
# Also unban the user
stmt = (
update(User)
.where(User.id == ban.user_id)
.values(
is_banned=False,
ban_reason=None,
ban_ends=None,
)
)
await db.execute(stmt)
return {"message": "User unbanned successfully"}
@router.get("/logs", response_model=List[dict])
async def list_logs(
room_id: Optional[int] = None,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> List[dict]:
"""
List game logs with optional room filter (admin/judge only).
Args:
room_id: Optional room ID to filter logs
limit: Maximum number of logs to return
db: Database session
current_user: The currently authenticated user
Returns:
List of log dictionaries
Raises:
HTTPException 403: If user is not admin/judge
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(GameLog).order_by(GameLog.id.desc()).limit(limit)
if room_id:
stmt = stmt.where(GameLog.room_id == room_id)
result = await db.execute(stmt)
logs = result.scalars().all()
return [
{
"id": log.id,
"room_id": log.room_id,
"player_id": log.player_id,
"message": log.message,
"timestamp": log.timestamp,
}
for log in logs
]
@router.post("/audit", response_model=dict)
async def log_audit(
action_type: str,
target_user_id: Optional[int] = None,
details: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""
Create an audit log entry (admin/judge only).
Args:
action_type: Type of administrative action
target_user_id: Optional target user ID
details: Optional details about the action
db: Database session
current_user: The currently authenticated user
Returns:
Dictionary with audit log data
Raises:
HTTPException 403: If user is not admin/judge
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
new_audit = AuditLog(
admin_id=int(current_user["user_id"]),
action_type=action_type,
target_user_id=target_user_id,
details=details,
ip_address=None, # Would get from request
)
db.add(new_audit)
await db.flush()
return {
"id": new_audit.id,
"admin_id": new_audit.admin_id,
"action_type": new_audit.action_type,
"target_user_id": new_audit.target_user_id,
"details": new_audit.details,
"timestamp": new_audit.timestamp,
}
-18
View File
@@ -1,18 +0,0 @@
"""WebSocket router for game connections."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from typing import Optional
from app.services.game_server import game_websocket_endpoint
router = APIRouter()
@router.websocket("/ws/game/{game_id}")
async def game_websocket(
websocket: WebSocket,
game_id: int,
player_id: Optional[int] = None,
):
"""WebSocket endpoint for game connections."""
await websocket.accept()
await game_websocket_endpoint(websocket, game_id)
-16
View File
@@ -65,15 +65,6 @@ from app.schemas.mtg_card_schemas import (
MtgSetResponse, MtgCardMirrorResponse,
DeckCardLinkResponse as DeckCardLinkResponseV2, DeckWithCardsResponse as DeckWithCardsResponseV2,
)
from app.schemas.proto_messages import (
ProtoMessageBase, SessionCommand, GameCommand, GameEvent, Response,
ServerInfoUser, ServerInfoDeckStorageFile, ServerInfoDeckStorageFolder,
ServerInfoDeckStorageTreeItem, ServerInfoCard, ServerInfoZone, ServerInfoGame,
)
from app.schemas.protocol_constants import (
SessionCommandType, GameCommandType, GameEventType, ResponseCode,
ZoneType, UserLevelFlag,
)
__all__ = [
# Auth
@@ -137,11 +128,4 @@ __all__ = [
"MtgCardResponse", "MtgCardSearchRequest", "MtgCardSearchResponse",
"MtgSetResponse", "MtgCardMirrorResponse",
"DeckCardLinkResponseV2", "DeckWithCardsResponseV2",
# Proto Messages
"ProtoMessageBase", "SessionCommand", "GameCommand", "GameEvent", "Response",
"ServerInfoUser", "ServerInfoDeckStorageFile", "ServerInfoDeckStorageFolder",
"ServerInfoDeckStorageTreeItem", "ServerInfoCard", "ServerInfoZone", "ServerInfoGame",
# Protocol Constants
"SessionCommandType", "GameCommandType", "GameEventType", "ResponseCode",
"ZoneType", "UserLevelFlag",
]
-133
View File
@@ -1,133 +0,0 @@
"""Pydantic models for protocol buffer message conversion."""
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from datetime import datetime
class ProtoMessageBase(BaseModel):
"""Base class for protocol buffer message models."""
message_type: str
timestamp: datetime = datetime.now()
class SessionCommand(ProtoMessageBase):
"""Session command message."""
message_type: str = "SessionCommand"
cmd_type: int
cmd_id: int = 0
data: Dict[str, Any] = {}
class GameCommand(ProtoMessageBase):
"""Game command message."""
message_type: str = "GameCommand"
cmd_type: int
cmd_id: int = 0
player_id: Optional[int] = None
data: Dict[str, Any] = {}
class GameEvent(ProtoMessageBase):
"""Game event message."""
message_type: str = "GameEvent"
event_type: int
player_id: Optional[int] = None
data: Dict[str, Any] = {}
class Response(ProtoMessageBase):
"""Response message."""
message_type: str = "Response"
cmd_id: int
response_code: int
data: Dict[str, Any] = {}
class ServerInfoUser(BaseModel):
"""ServerInfo_User message."""
id: int
name: str
user_level: int = 0
address: Optional[str] = None
real_name: Optional[str] = None
country: Optional[str] = None
avatar_bmp: Optional[bytes] = None
server_id: Optional[int] = None
session_id: Optional[int] = None
accountage_secs: Optional[int] = None
email: Optional[str] = None
privlevel: Optional[str] = None
class ServerInfoDeckStorageFile(BaseModel):
"""ServerInfo_DeckStorage_File message."""
creation_time: Optional[int] = None
class ServerInfoDeckStorageFolder(BaseModel):
"""ServerInfo_DeckStorage_Folder message."""
items: List[Dict[str, Any]] = []
class ServerInfoDeckStorageTreeItem(BaseModel):
"""ServerInfo_DeckStorage_TreeItem message."""
id: Optional[int] = None
name: Optional[str] = None
file: Optional[ServerInfoDeckStorageFile] = None
folder: Optional[ServerInfoDeckStorageFolder] = None
class ServerInfoCard(BaseModel):
"""ServerInfo_Card message."""
id: int
name: str
x: Optional[int] = None
y: Optional[int] = None
face_down: bool = False
tapped: bool = False
attacking: bool = False
color: Optional[str] = None
pt: Optional[str] = None
annotation: Optional[str] = None
destroy_on_zone_change: bool = False
doesnt_untap: bool = False
counter_list: List[Dict[str, Any]] = []
attach_player_id: Optional[int] = None
attach_zone: Optional[str] = None
attach_card_id: Optional[int] = None
provider_id: Optional[str] = None
class ServerInfoZone(BaseModel):
"""ServerInfo_Zone message."""
name: str
zone_type: int = 0 # PrivateZone=0, PublicZone=1, HiddenZone=2
with_coords: bool = False
card_count: int = 0
card_list: List[ServerInfoCard] = []
always_reveal_top_card: bool = False
always_look_at_top_card: bool = False
class ServerInfoGame(BaseModel):
"""ServerInfo_Game message."""
server_id: Optional[int] = None
room_id: Optional[int] = None
game_id: Optional[int] = None
description: Optional[str] = None
with_password: bool = False
max_players: int = 4
game_types: List[int] = []
creator_info: Optional[ServerInfoUser] = None
only_buddies: bool = False
only_registered: bool = False
spectators_allowed: bool = False
spectators_need_password: bool = False
spectators_can_chat: bool = False
spectators_omniscient: bool = False
share_decklists_on_load: bool = False
player_count: int = 0
spectators_count: int = 0
started: bool = False
start_time: Optional[int] = None
closed: bool = False
-170
View File
@@ -1,170 +0,0 @@
"""MTG Online protocol constants and message definitions."""
import enum
class SessionCommandType(enum.IntEnum):
"""Session command types from session_commands.proto"""
PING = 1000
LOGIN = 1001
MESSAGE = 1002
LIST_USERS = 1003
GET_GAMES_OF_USER = 1004
GET_USER_INFO = 1005
ADD_TO_LIST = 1006
REMOVE_FROM_LIST = 1007
DECK_LIST = 1008
DECK_NEW_DIR = 1009
DECK_DEL_DIR = 1010
DECK_DEL = 1011
DECK_DOWNLOAD = 1012
DECK_UPLOAD = 1013
LIST_ROOMS = 1014
JOIN_ROOM = 1015
REGISTER = 1016
ACTIVATE = 1017
ACCOUNT_EDIT = 1018
ACCOUNT_IMAGE = 1019
ACCOUNT_PASSWORD = 1020
FORGOT_PASSWORD_REQUEST = 1021
FORGOT_PASSWORD_RESET = 1022
FORGOT_PASSWORD_CHALLENGE = 1023
REQUEST_PASSWORD_SALT = 1024
SET_CARD_ART_PARAMS = 1025
REPLAY_LIST = 1100
REPLAY_DOWNLOAD = 1101
REPLAY_MODIFY_MATCH = 1102
REPLAY_DELETE_MATCH = 1103
REPLAY_GET_CODE = 1104
REPLAY_SUBMIT_CODE = 1105
class GameCommandType(enum.IntEnum):
"""Game command types from game_commands.proto"""
KICK_FROM_GAME = 1000
LEAVE_GAME = 1001
GAME_SAY = 1002
SHUFFLE = 1003
MULLIGAN = 1004
ROLL_DIE = 1005
DRAW_CARDS = 1006
UNDO_DRAW = 1007
FLIP_CARD = 1008
ATTACH_CARD = 1009
CREATE_TOKEN = 1010
CREATE_ARROW = 1011
DELETE_ARROW = 1012
SET_CARD_ATTR = 1013
SET_CARD_COUNTER = 1014
INC_CARD_COUNTER = 1015
READY_START = 1016
CONCEDE = 1017
INC_COUNTER = 1018
CREATE_COUNTER = 1019
SET_COUNTER = 1020
DEL_COUNTER = 1021
NEXT_TURN = 1022
SET_ACTIVE_PHASE = 1023
DUMP_ZONE = 1024
REVEAL_CARDS = 1026
MOVE_CARD = 1027
SET_SIDEBOARD_PLAN = 1028
DECK_SELECT = 1029
SET_SIDEBOARD_LOCK = 1030
CHANGE_ZONE_PROPERTIES = 1031
UNCONCEDE = 1032
JUDGE = 1033
REVERSE_TURN = 1034
class GameEventType(enum.IntEnum):
"""Game event types from game_event.proto"""
JOIN = 1000
LEAVE = 1001
GAME_CLOSED = 1002
GAME_HOST_CHANGED = 1003
KICKED = 1004
GAME_STATE_CHANGED = 1005
PLAYER_PROPERTIES_CHANGED = 1007
GAME_SAY = 1009
CREATE_ARROW = 2000
DELETE_ARROW = 2001
CREATE_COUNTER = 2002
SET_COUNTER = 2003
DEL_COUNTER = 2004
DRAW_CARDS = 2005
REVEAL_CARDS = 2006
SHUFFLE = 2007
ROLL_DIE = 2008
MOVE_CARD = 2009
FLIP_CARD = 2010
DESTROY_CARD = 2011
ATTACH_CARD = 2012
CREATE_TOKEN = 2013
SET_CARD_ATTR = 2014
SET_CARD_COUNTER = 2015
SET_ACTIVE_PLAYER = 2016
SET_ACTIVE_PHASE = 2017
DUMP_ZONE = 2018
CHANGE_ZONE_PROPERTIES = 2020
REVERSE_TURN = 2021
GAME_LOG_NOTICE = 2022
class ResponseCode(enum.IntEnum):
"""Response codes from response.proto"""
RespNotConnected = -1
RespNothing = 0
RespOk = 1
RespNotInRoom = 2
RespInternalError = 3
RespInvalidCommand = 4
RespInvalidData = 5
RespNameNotFound = 6
RespLoginNeeded = 7
RespFunctionNotAllowed = 8
RespGameNotStarted = 9
RespGameFull = 10
RespContextError = 11
RespWrongPassword = 12
RespSpectatorsNotAllowed = 13
RespOnlyBuddies = 14
RespUserLevelTooLow = 15
RespInIgnoreList = 16
RespWouldOverwriteOldSession = 17
RespChatFlood = 18
RespUserIsBanned = 19
RespAccessDenied = 20
RespUsernameInvalid = 21
RespRegistrationRequired = 22
RespRegistrationAccepted = 23
RespUserAlreadyExists = 24
RespEmailRequiredToRegister = 25
RespTooManyRequests = 26
RespPasswordTooShort = 27
RespAccountNotActivated = 28
RespRegistrationDisabled = 29
RespRegistrationFailed = 30
RespActivationAccepted = 31
RespActivationFailed = 32
RespRegistrationAcceptedNeedsActivation = 33
RespClientIdRequired = 34
RespClientUpdateRequired = 35
RespServerFull = 36
RespEmailBlackListed = 37
class ZoneType(enum.IntEnum):
"""Zone types from serverinfo_zone.proto"""
PrivateZone = 0
PublicZone = 1
HiddenZone = 2
class UserLevelFlag(enum.IntFlag):
"""User level flags from serverinfo_user.proto"""
IsNothing = 0
IsUser = 1
IsRegistered = 2
IsModerator = 4
IsAdmin = 8
IsJudge = 16
-26
View File
@@ -1,11 +1,8 @@
"""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 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 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,34 +20,11 @@ __all__ = [
"get_sets",
"get_set_by_code",
"get_card_statistics",
"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",
"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",
-359
View File
@@ -1,359 +0,0 @@
"""
Card Mirror Service
Manages mirrored card data in mtgo_platform for fast deckbuilding queries.
Syncs with mtg_cards (mtg_data) when the card database is refreshed.
"""
from typing import List, Dict, Any, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update, delete
from sqlalchemy.orm import selectinload
from app.models.mtg_models import MtgCard, MtgSet
from app.models.mirror_models import MtgCardMirror, DeckCardLink
from app.core.database import mirror_get_db
async def upsert_card_mirror(
db: AsyncSession,
card_data: Dict[str, Any],
source_id: Optional[int] = None
) -> MtgCardMirror:
"""
Upsert a card into the mirror table.
Args:
db: Database session
card_data: Card data dictionary
source_id: Optional reference to mtg_cards.id
Returns:
The upserted MtgCardMirror instance
"""
# Check if card already exists by name
existing = await db.execute(
select(MtgCardMirror).where(MtgCardMirror.name == card_data.get("name"))
)
existing_card = existing.scalar_one_or_none()
if existing_card:
# Update existing mirror
for key, value in card_data.items():
if hasattr(existing_card, key):
setattr(existing_card, key, value)
if source_id:
existing_card.source_id = source_id
else:
# Create new mirror
mirror_data = {
"name": card_data.get("name"),
"mana_cost": card_data.get("mana_cost"),
"type_line": card_data.get("type_line"),
"oracle_text": card_data.get("oracle_text"),
"power": card_data.get("power"),
"toughness": card_data.get("toughness"),
"rarity": card_data.get("rarity"),
"layout": card_data.get("layout"),
"artist": card_data.get("artist"),
"flavor_text": card_data.get("flavor_text"),
"numbers": card_data.get("numbers"),
"identifiers": card_data.get("identifiers"),
"images": card_data.get("images"),
"image": card_data.get("image"),
"card_parts": card_data.get("card_parts"),
"keywords": card_data.get("keywords"),
"legalities": card_data.get("legalities"),
"set_code": card_data.get("set_code"),
"set_name": card_data.get("set_name"),
"source_id": source_id,
}
mirror_card = MtgCardMirror(**mirror_data)
db.add(mirror_card)
await db.flush()
return mirror_card
return existing_card
async def get_card_mirror_by_name(
db: AsyncSession,
name: str
) -> Optional[MtgCardMirror]:
"""
Get a mirrored card by name.
Args:
db: Database session
name: Card name
Returns:
MtgCardMirror instance or None
"""
result = await db.execute(
select(MtgCardMirror).where(MtgCardMirror.name == name)
)
return result.scalar_one_or_none()
async def search_card_mirrors(
db: AsyncSession,
query: str,
limit: int = 100,
offset: int = 0
) -> Tuple[List[MtgCardMirror], int]:
"""
Search mirrored cards by name.
Args:
db: Database session
query: Search query
limit: Maximum results
offset: Pagination offset
Returns:
Tuple of (list of mirrors, total count)
"""
search_term = f"%{query.lower()}%"
# Search query
stmt = (
select(MtgCardMirror)
.where(MtgCardMirror.name.ilike(search_term))
.offset(offset)
.limit(limit)
)
result = await db.execute(stmt)
mirrors = result.scalars().all()
# Total count
count_stmt = select(func.count()).select_from(MtgCardMirror).where(
MtgCardMirror.name.ilike(search_term)
)
count_result = await db.execute(count_stmt)
total = count_result.scalar()
return mirrors, total
async def add_card_to_deck(
db: AsyncSession,
deck_id: int,
card_id: int,
quantity: int = 1,
zone: str = "main"
) -> DeckCardLink:
"""
Add a card to a deck.
Args:
db: Database session
deck_id: Deck ID
card_id: Mirrored card ID
quantity: Number of copies
zone: 'main' or 'sideboard'
Returns:
DeckCardLink instance
"""
# Check if link already exists
existing = await db.execute(
select(DeckCardLink).where(
DeckCardLink.deck_id == deck_id,
DeckCardLink.card_id == card_id,
DeckCardLink.zone == zone
)
)
existing_link = existing.scalar_one_or_none()
if existing_link:
# Update quantity
existing_link.quantity = quantity
await db.flush()
return existing_link
else:
# Create new link
link = DeckCardLink(
deck_id=deck_id,
card_id=card_id,
quantity=quantity,
zone=zone
)
db.add(link)
await db.flush()
return link
async def remove_card_from_deck(
db: AsyncSession,
deck_id: int,
card_id: int,
zone: str = "main"
) -> bool:
"""
Remove a card from a deck.
Args:
db: Database session
deck_id: Deck ID
card_id: Mirrored card ID
zone: 'main' or 'sideboard'
Returns:
True if removed, False if not found
"""
result = await db.execute(
delete(DeckCardLink).where(
DeckCardLink.deck_id == deck_id,
DeckCardLink.card_id == card_id,
DeckCardLink.zone == zone
)
)
return result.rowcount > 0
async def get_deck_cards(
db: AsyncSession,
deck_id: int
) -> List[DeckCardLink]:
"""
Get all cards in a deck with their mirrored data.
Args:
db: Database session
deck_id: Deck ID
Returns:
List of DeckCardLink instances with joined card data
"""
stmt = (
select(DeckCardLink, MtgCardMirror)
.join(MtgCardMirror, DeckCardLink.card_id == MtgCardMirror.id)
.where(DeckCardLink.deck_id == deck_id)
.order_by(DeckCardLink.id)
)
result = await db.execute(stmt)
rows = result.all()
links = []
for link, card in rows:
link.card = card
links.append(link)
return links
async def get_user_deck_summaries(
db: AsyncSession,
user_id: int
) -> List[Dict[str, Any]]:
"""
Get all decks for a user with card counts.
Args:
db: Database session
user_id: User ID
Returns:
List of deck summaries with card counts
"""
from app.models.models import DecklistFile
stmt = (
select(DecklistFile)
.where(DecklistFile.owner_id == user_id)
.order_by(DecklistFile.creation_date.desc())
)
result = await db.execute(stmt)
decks = result.scalars().all()
summaries = []
for deck in decks:
# Get card count
count_stmt = (
select(func.count())
.select_from(DeckCardLink)
.where(DeckCardLink.deck_id == deck.id)
)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar()
summaries.append({
"id": deck.id,
"name": deck.name,
"format": deck.format,
"status": deck.status,
"folder_id": deck.folder_id,
"owner_id": deck.owner_id,
"creation_date": deck.creation_date,
"card_count": card_count,
})
return summaries
async def sync_mirrors_from_mtg_cards(
db: AsyncSession,
mtg_db: Optional[AsyncSession] = None
) -> int:
"""
Sync all mirrored cards from the mtg_cards table.
This is called when the card database is refreshed.
Args:
db: Mirror database session
mtg_db: Optional MTG database session for cross-DB queries
Returns:
Number of cards synced
"""
if not mtg_db:
# Use the same session if no MTG session provided
# Note: In production, you'd need a cross-DB connection
# For now, we'll just refresh the mirror from existing data
pass
# For now, this is a no-op. In a full implementation,
# you'd query mtg_cards and upsert into mtg_cards_mirror.
# This requires cross-database connections which SQLAlchemy
# can handle with proper configuration.
return 0
async def get_card_statistics(db: AsyncSession) -> Dict[str, Any]:
"""
Get statistics about mirrored cards.
Args:
db: Database session
Returns:
Dictionary with statistics
"""
# Total mirrored cards
total_stmt = select(func.count()).select_from(MtgCardMirror)
total = (await db.execute(total_stmt)).scalar()
# Cards by rarity
rarity_stmt = select(MtgCardMirror.rarity, func.count()).group_by(
MtgCardMirror.rarity
)
rarity_result = await db.execute(rarity_stmt)
rarities = {row[0]: row[1] for row in rarity_result if row[0]}
# Cards by type
type_stmt = select(MtgCardMirror.type_line, func.count()).group_by(
MtgCardMirror.type_line
)
type_result = await db.execute(type_stmt)
types = {row[0]: row[1] for row in type_result if row[0]}
return {
"total_mirrored_cards": total,
"cards_by_rarity": rarities,
"cards_by_type": types,
}
+1 -2
View File
@@ -4,8 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, and_
from sqlalchemy.orm import selectinload
from app.models.mtg_models import MtgCard, MtgSet
from app.models.mirror_models import MtgCardMirror
from app.models.mtg_models import MtgCard, MtgSet, MtgCardMirror
class CardSearchService:
+1 -2
View File
@@ -4,8 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard
from app.models.models import MtgonlineCard
from app.models.models import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, MtgonlineCard
class DeckManager:
@@ -4,9 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, and_, func, case
from sqlalchemy.orm import selectinload
from app.models.user_deck import UserDeck, UserDeckCard, CardSuggestion
from app.models.mtg_models import MtgCard
from app.models.mirror_models import MtgCardMirror
from app.models.models import UserDeck, UserDeckCard, CardSuggestion
from app.models.mtg_models import MtgCard, MtgCardMirror
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
@@ -6,10 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, insert, delete
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_data import UserCardCollection
from app.models.models import MtgonlineCard
from app.models.models import CardImportBatch, UserCardImportRecord, UserCardCollection, MtgonlineCard
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
-838
View File
@@ -1,838 +0,0 @@
#!/usr/bin/env python3
"""
MTGJSON Data Loader
Downloads and loads all MTGJSON data into the PostgreSQL database.
Designed to run inside the Docker container.
"""
import asyncio
import gzip
import json
import os
import shutil
import sys
import zipfile
from pathlib import Path
from datetime import datetime
from typing import Optional
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text, insert, update, select, Table, Column, String, Text, Integer, Float, Boolean, DateTime, MetaData, UniqueConstraint
from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.core.settings import get_settings
# MTGJSON API v5 base URL
MTGJSON_API_V5 = "https://mtgjson.com/api/v5"
async def create_tables(engine):
"""Create all required database tables."""
async with engine.begin() as conn:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS cards (
id SERIAL PRIMARY KEY,
artist TEXT,
asciiName TEXT,
attractionLights TEXT,
availability TEXT,
boosterTypes TEXT,
borderColor TEXT,
cardParts TEXT,
colorIdentity TEXT,
colorIndicator TEXT,
colors TEXT,
defense TEXT,
duelDeck TEXT,
edhrecRank INTEGER,
edhrecSaltiness FLOAT,
faceConvertedManaCost FLOAT,
faceFlavorName TEXT,
faceManaValue FLOAT,
faceName TEXT,
facePrintedName TEXT,
finishes TEXT,
flavorName TEXT,
flavorText TEXT,
frameEffects TEXT,
frameVersion TEXT,
hand TEXT,
hasAlternativeDeckLimit BOOLEAN,
hasContentWarning BOOLEAN,
isAlternative BOOLEAN,
isFullArt BOOLEAN,
isFunny BOOLEAN,
isGameChanger BOOLEAN,
isOnlineOnly BOOLEAN,
isOversized BOOLEAN,
isPromo BOOLEAN,
isRebalanced BOOLEAN,
isReprint BOOLEAN,
isReserved BOOLEAN,
isStorySpotlight BOOLEAN,
isTextless BOOLEAN,
isTimeshifted BOOLEAN,
keywords TEXT,
language TEXT,
layout TEXT,
leadershipSkills TEXT,
life TEXT,
loyalty TEXT,
manaCost TEXT,
manaValue FLOAT,
name TEXT,
number TEXT,
originalPrintings TEXT,
originalReleaseDate TEXT,
originalText TEXT,
otherFaceIds TEXT,
power TEXT,
printedName TEXT,
printedText TEXT,
printedType TEXT,
printings TEXT,
producedMana TEXT,
promoTypes TEXT,
rarity TEXT,
rebalancedPrintings TEXT,
relatedCards TEXT,
securityStamp TEXT,
setCode TEXT,
side TEXT,
signature TEXT,
skuIds TEXT,
sourceProducts TEXT,
subsets TEXT,
subtypes TEXT,
supertypes TEXT,
text TEXT,
toughness TEXT,
type TEXT,
types TEXT,
uuid TEXT,
variations TEXT,
watermark TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS mtg_sets (
id SERIAL PRIMARY KEY,
code VARCHAR(10) UNIQUE NOT NULL,
name VARCHAR(255),
type VARCHAR(100),
release_date DATE,
base_set_size INTEGER,
total_size INTEGER,
is_foil_only BOOLEAN,
is_non_foil_only BOOLEAN,
digital BOOLEAN,
icon_svg_url TEXT,
parent_code VARCHAR(10),
mtgo_code VARCHAR(10),
card_count INTEGER,
image_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS mtg_cards (
id SERIAL PRIMARY KEY,
set_id INTEGER REFERENCES mtg_sets(id) ON DELETE CASCADE,
name VARCHAR(255),
mana_cost VARCHAR(255),
type_line VARCHAR(255),
oracle_text TEXT,
power VARCHAR(50),
toughness VARCHAR(50),
rarity VARCHAR(50),
layout VARCHAR(50),
artist VARCHAR(255),
flavor_text TEXT,
numbers VARCHAR(100),
identifiers TEXT,
images TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS card_identifiers (
id SERIAL PRIMARY KEY,
uuid TEXT UNIQUE NOT NULL,
name VARCHAR(255),
mana_cost VARCHAR(255),
type_line VARCHAR(255),
oracle_text TEXT,
power VARCHAR(50),
toughness VARCHAR(50),
rarity VARCHAR(50),
layout VARCHAR(50),
artist VARCHAR(255),
flavor_text TEXT,
set_code VARCHAR(10),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS decks (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
format VARCHAR(50),
command TEXT,
commander TEXT,
creation_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS card_types (
id SERIAL PRIMARY KEY,
type VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS deck_list (
id SERIAL PRIMARY KEY,
deck_id VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255),
description TEXT,
format VARCHAR(50),
command TEXT,
commander TEXT,
total_cards INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS card_keywords (
id SERIAL PRIMARY KEY,
keyword VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS set_list (
id SERIAL PRIMARY KEY,
set_code VARCHAR(10) UNIQUE NOT NULL,
set_name VARCHAR(255),
set_type VARCHAR(100),
release_date DATE,
base_set_size INTEGER,
total_size INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
await conn.commit()
print("✓ Database tables created")
def download_file(url: str, destination: Path) -> bool:
"""Download a file from URL to destination."""
from urllib.request import urlretrieve
try:
print(f"Downloading {url}...")
urlretrieve(url, destination)
size_mb = destination.stat().st_size / (1024 * 1024)
print(f" ✓ Downloaded ({size_mb:.1f} MB)")
return True
except Exception as e:
print(f" ✗ Failed: {e}")
return False
def extract_zip(zip_path: Path, extract_dir: Path) -> None:
"""Extract a zip file."""
if zip_path.exists():
if extract_dir.exists():
shutil.rmtree(extract_dir)
print(f"Extracting {zip_path.name}...")
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(extract_dir)
file_count = len(list(extract_dir.glob('*.json')))
print(f" ✓ Extracted {file_count} files")
def get_all_printings_psql_file(downloads_dir: Path) -> Path:
"""Get or download AllPrintings.psql.gz."""
psql_file = downloads_dir / "AllPrintings.psql.gz"
if not psql_file.exists():
download_file(f"{MTGJSON_API_V5}/AllPrintings.psql.gz", psql_file)
return psql_file
def parse_psql_file(psql_file: Path) -> list[dict]:
"""Parse a PSQL file and extract card data.
The PSQL file contains a COPY statement with all card data.
We need to parse it and extract the data rows.
"""
cards = []
# Read the file and find the COPY section
with open(psql_file, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
# Find the COPY section
copy_start = content.find("COPY \"cards\"")
if copy_start == -1:
print("✗ Could not find COPY statement in PSQL file")
return []
# Find the FROM \. section
from_section = content.find("FROM \\.", copy_start)
if from_section == -1:
print("✗ Could not find FROM section in PSQL file")
return []
# Extract everything after FROM \.
data_section = content[from_section + len("FROM \\."):]
# Split into lines and filter out empty lines and comments
lines = data_section.split('\n')
data_lines = [line for line in lines if line.strip() and not line.startswith('--')]
# Parse each line (tab-separated values with \t representing tabs)
for line in data_lines:
if line == '\\.':
break
# Split by tab character (represented as \t in the file)
values = line.split('\t')
if len(values) >= 94: # We have at least 94 columns
# Map values to card fields
card = {
'artist': values[0],
'asciiName': values[1],
'attractionLights': values[2],
'availability': values[3],
'boosterTypes': values[4],
'borderColor': values[5],
'cardParts': values[6],
'colorIdentity': values[7],
'colorIndicator': values[8],
'colors': values[9],
'defense': values[10],
'duelDeck': values[11],
'edhrecRank': int(values[12]) if values[12] != '\\N' else None,
'edhrecSaltiness': float(values[13]) if values[13] != '\\N' else None,
'faceConvertedManaCost': float(values[14]) if values[14] != '\\N' else None,
'faceFlavorName': values[15],
'faceManaValue': float(values[16]) if values[16] != '\\N' else None,
'faceName': values[17],
'facePrintedName': values[18],
'finishes': values[19],
'flavorName': values[20],
'flavorText': values[21],
'frameEffects': values[22],
'frameVersion': values[23],
'hand': values[24],
'hasAlternativeDeckLimit': values[25] == 't',
'hasContentWarning': values[26] == 't',
'isAlternative': values[27] == 't',
'isFullArt': values[28] == 't',
'isFunny': values[29] == 't',
'isGameChanger': values[30] == 't',
'isOnlineOnly': values[31] == 't',
'isOversized': values[32] == 't',
'isPromo': values[33] == 't',
'isRebalanced': values[34] == 't',
'isReprint': values[35] == 't',
'isReserved': values[36] == 't',
'isStorySpotlight': values[37] == 't',
'isTextless': values[38] == 't',
'isTimeshifted': values[39] == 't',
'keywords': values[40],
'language': values[41],
'layout': values[42],
'leadershipSkills': values[43],
'life': values[44],
'loyalty': values[45],
'manaCost': values[46],
'manaValue': float(values[47]) if values[47] != '\\N' else None,
'name': values[48],
'number': values[49],
'originalPrintings': values[50],
'originalReleaseDate': values[51],
'originalText': values[52],
'otherFaceIds': values[53],
'power': values[54],
'printedName': values[55],
'printedText': values[56],
'printedType': values[57],
'printings': values[58],
'producedMana': values[59],
'promoTypes': values[60],
'rarity': values[61],
'rebalancedPrintings': values[62],
'relatedCards': values[63],
'securityStamp': values[64],
'setCode': values[65],
'side': values[66],
'signature': values[67],
'skuIds': values[68],
'sourceProducts': values[69],
'subsets': values[70],
'subtypes': values[71],
'supertypes': values[72],
'text': values[73],
'toughness': values[74],
'type': values[75],
'types': values[76],
'uuid': values[77],
'variations': values[78],
'watermark': values[79],
}
cards.append(card)
return cards
async def import_cards(engine, cards: list[dict]) -> int:
"""Import cards data."""
count = 0
async with engine.begin() as conn:
for card in cards:
await conn.execute(
pg_insert(text('cards')).values(card).on_conflict_do_nothing(),
execution_options={"autocommit": True}
)
count += 1
print(f"✓ Imported {count:,} cards")
return count
async def import_all_printings_psql(engine, psql_file: Path) -> int:
"""Import AllPrintings.psql.gz."""
if not psql_file.exists():
print("✗ AllPrintings.psql.gz not found")
return 0
print("Importing AllPrintings...")
# Parse the PSQL file
cards = parse_psql_file(psql_file)
if not cards:
print("✗ No cards found in PSQL file")
return 0
# Import cards
return await import_cards(engine, cards)
async def import_all_set_files(engine, set_files_dir: Path) -> tuple[int, int]:
"""Import AllSetFiles - sets and cards."""
if not set_files_dir.exists():
print("✗ AllSetFiles directory not found")
return 0, 0
set_count = 0
card_count = 0
async with engine.begin() as conn:
# Import sets
for set_file in sorted(set_files_dir.glob('*.json')):
data = json.loads(set_file.read_text())
if 'code' in data:
image_url = None
if 'image' in data and data['image']:
image_url = data['image'].get('normal')
# Upsert set
result = await conn.execute(
pg_insert(text('mtg_sets')).values(
code=data.get('code'),
name=data.get('name'),
type=data.get('type'),
release_date=data.get('releaseDate'),
base_set_size=data.get('baseSetSize'),
total_size=data.get('totalSetSize'),
is_foil_only=data.get('isFoilOnly'),
is_non_foil_only=data.get('isNonFoilOnly'),
digital=data.get('digital'),
icon_svg_url=data.get('iconSvgUrl'),
parent_code=data.get('parentCode'),
mtgo_code=data.get('mtgoCode'),
card_count=data.get('cardCount'),
image_url=image_url,
).on_conflict_do_update(
index_elements=['code'],
set_={
'name': data.get('name'),
'type': data.get('type'),
'release_date': data.get('releaseDate'),
'base_set_size': data.get('baseSetSize'),
'total_size': data.get('totalSetSize'),
'is_foil_only': data.get('isFoilOnly'),
'is_non_foil_only': data.get('isNonFoilOnly'),
'digital': data.get('digital'),
'icon_svg_url': data.get('iconSvgUrl'),
'parent_code': data.get('parentCode'),
'mtgo_code': data.get('mtgoCode'),
'card_count': data.get('cardCount'),
'image_url': image_url,
'updated_at': datetime.utcnow(),
}
).returning(text('mtg_sets.id')),
execution_options={"autocommit": True}
)
set_id = result.scalar()
if 'cards' in data:
for card_data in data['cards']:
identifiers = {
'multiId': card_data.get('multiverseIds'),
'tcgplayerProductId': card_data.get('tcgplayerProductId'),
'cardmarketId': card_data.get('cardmarketId'),
}
images = {}
if 'image_uris' in card_data:
images = {
'small': card_data['image_uris'].get('small'),
'normal': card_data['image_uris'].get('normal'),
'large': card_data['image_uris'].get('large'),
'png': card_data['image_uris'].get('png'),
'art_crop': card_data['image_uris'].get('art_crop'),
}
# Upsert card
await conn.execute(
pg_insert(text('mtg_cards')).values(
set_id=set_id,
name=card_data.get('name'),
mana_cost=card_data.get('manaCost'),
type_line=card_data.get('typeLine'),
oracle_text=card_data.get('oracleText'),
power=card_data.get('power'),
toughness=card_data.get('toughness'),
rarity=card_data.get('rarity'),
layout=card_data.get('layout'),
artist=card_data.get('artist'),
flavor_text=card_data.get('flavorText'),
numbers=str(card_data.get('number')),
identifiers=json.dumps(identifiers),
images=json.dumps(images),
).on_conflict_do_nothing(),
execution_options={"autocommit": True}
)
card_count += 1
set_count += 1
print(f"✓ Imported {set_count} sets and {card_count:,} cards from sets")
return set_count, card_count
async def import_all_identifiers(engine, file_path: Path) -> int:
"""Import AllIdentifiers.json.gz."""
if not file_path.exists():
print("✗ AllIdentifiers.json.gz not found")
return 0
data = json.loads(gzip.decompress(file_path.read_bytes()))
count = 0
async with engine.begin() as conn:
for uuid, card_data in data.items():
await conn.execute(
pg_insert(text('card_identifiers')).values(
uuid=uuid,
name=card_data.get('name'),
mana_cost=card_data.get('manaCost'),
type_line=card_data.get('typeLine'),
oracle_text=card_data.get('oracleText'),
power=card_data.get('power'),
toughness=card_data.get('toughness'),
rarity=card_data.get('rarity'),
layout=card_data.get('layout'),
artist=card_data.get('artist'),
flavor_text=card_data.get('flavorText'),
set_code=card_data.get('setCode'),
).on_conflict_do_update(
index_elements=['uuid'],
set_={
'name': card_data.get('name'),
'mana_cost': card_data.get('manaCost'),
'type_line': card_data.get('typeLine'),
'oracle_text': card_data.get('oracleText'),
'power': card_data.get('power'),
'toughness': card_data.get('toughness'),
'rarity': card_data.get('rarity'),
'layout': card_data.get('layout'),
'artist': card_data.get('artist'),
'flavor_text': card_data.get('flavorText'),
'set_code': card_data.get('setCode'),
'updated_at': datetime.utcnow(),
}
),
execution_options={"autocommit": True}
)
count += 1
print(f"✓ Imported {count:,} identifiers")
return count
async def import_all_deck_files(engine, deck_files_dir: Path) -> int:
"""Import AllDeckFiles.zip."""
if not deck_files_dir.exists():
print("✗ AllDeckFiles directory not found")
return 0
count = 0
async with engine.begin() as conn:
for deck_file in sorted(deck_files_dir.glob('*.json')):
data = json.loads(deck_file.read_text())
if 'name' in data and 'cards' in data:
await conn.execute(
pg_insert(text('decks')).values(
name=data.get('name'),
description=data.get('description'),
format=data.get('format'),
command=data.get('command'),
commander=data.get('commander'),
).on_conflict_do_update(
index_elements=['name'],
set_={
'description': data.get('description'),
'format': data.get('format'),
'command': data.get('command'),
'commander': data.get('commander'),
'updated_at': datetime.utcnow(),
}
),
execution_options={"autocommit": True}
)
count += 1
print(f"✓ Imported {count} decks")
return count
async def import_simple_json(engine, file_path: Path, table_name: str,
name_field: str, id_field: str) -> int:
"""Import a simple JSON.gz file."""
if not file_path.exists():
return 0
data = json.loads(gzip.decompress(file_path.read_bytes()))
count = 0
async with engine.begin() as conn:
for item in data:
# Build the insert statement based on table name
if table_name == 'card_types':
insert_stmt = pg_insert(text('card_types')).values(
type=item.get('type'),
description=item.get('description')
).on_conflict_do_nothing()
elif table_name == 'deck_list':
insert_stmt = pg_insert(text('deck_list')).values(
deck_id=item.get('id'),
name=item.get('name'),
description=item.get('description'),
format=item.get('format'),
command=item.get('command'),
commander=item.get('commander'),
total_cards=item.get('totalCards')
).on_conflict_do_nothing()
elif table_name == 'card_keywords':
insert_stmt = pg_insert(text('card_keywords')).values(
keyword=item.get('keyword'),
description=item.get('description')
).on_conflict_do_nothing()
elif table_name == 'set_list':
insert_stmt = pg_insert(text('set_list')).values(
set_code=item.get('code'),
set_name=item.get('name'),
set_type=item.get('type'),
release_date=item.get('releaseDate'),
base_set_size=item.get('baseSetSize'),
total_size=item.get('totalSetSize')
).on_conflict_do_nothing()
else:
continue
await conn.execute(insert_stmt, execution_options={"autocommit": True})
count += 1
print(f"✓ Imported {count} items into {table_name}")
return count
async def create_indexes(engine):
"""Create indexes for better performance."""
async with engine.begin() as conn:
indexes = [
"CREATE INDEX IF NOT EXISTS idx_cards_name ON cards(name)",
"CREATE INDEX IF NOT EXISTS idx_cards_mana_cost ON cards(manaCost)",
"CREATE INDEX IF NOT EXISTS idx_cards_type ON cards(type)",
"CREATE INDEX IF NOT EXISTS idx_cards_rarity ON cards(rarity)",
"CREATE INDEX IF NOT EXISTS idx_cards_set_code ON cards(setCode)",
"CREATE INDEX IF NOT EXISTS idx_cards_uuid ON cards(uuid)",
"CREATE INDEX IF NOT EXISTS idx_mtg_sets_code ON mtg_sets(code)",
"CREATE INDEX IF NOT EXISTS idx_mtg_cards_name ON mtg_cards(name)",
"CREATE INDEX IF NOT EXISTS idx_card_identifiers_uuid ON card_identifiers(uuid)",
"CREATE INDEX IF NOT EXISTS idx_card_identifiers_name ON card_identifiers(name)",
"CREATE INDEX IF NOT EXISTS idx_deck_list_deck_id ON deck_list(deck_id)",
]
for idx in indexes:
await conn.execute(text(idx))
await conn.commit()
print("✓ Indexes created")
async def show_summary(engine):
"""Show import summary."""
print("\n=== Import Summary ===")
async with engine.connect() as conn:
tables = [
'cards', 'mtg_sets', 'mtg_cards', 'card_identifiers',
'decks', 'card_types', 'deck_list', 'card_keywords', 'set_list'
]
for table in tables:
result = await conn.execute(text(f"SELECT COUNT(*) FROM {table}"))
count = result.scalar()
print(f" {table:20} {count:>10,} records")
async def main():
"""Main function to download and import all MTGJSON data."""
settings = get_settings()
# Setup data directory
data_dir = Path(settings.DATA_DIR) / "mtgjson"
downloads_dir = data_dir / "downloads"
downloads_dir.mkdir(parents=True, exist_ok=True)
# Create engine
engine = create_async_engine(settings.MTG_DATABASE_URL)
# Create tables
print("=== Creating Database Tables ===")
await create_tables(engine)
# Download and import AllPrintings
print("\n=== Importing AllPrintings ===")
psql_file = get_all_printings_psql_file(downloads_dir)
await import_all_printings_psql(engine, psql_file)
# Download and extract AllSetFiles
print("\n=== Importing AllSetFiles ===")
set_files_zip = downloads_dir / "AllSetFiles.zip"
set_files_dir = downloads_dir / "AllSetFiles"
if not set_files_dir.exists():
if not set_files_zip.exists():
download_file(f"{MTGJSON_API_V5}/AllSetFiles.zip", set_files_zip)
extract_zip(set_files_zip, set_files_dir)
await import_all_set_files(engine, set_files_dir)
# Download and extract AllDeckFiles
print("\n=== Importing AllDeckFiles ===")
deck_files_zip = downloads_dir / "AllDeckFiles.zip"
deck_files_dir = downloads_dir / "AllDeckFiles"
if not deck_files_dir.exists():
if not deck_files_zip.exists():
download_file(f"{MTGJSON_API_V5}/AllDeckFiles.zip", deck_files_zip)
extract_zip(deck_files_zip, deck_files_dir)
await import_all_deck_files(engine, deck_files_dir)
# Download and import AllIdentifiers
print("\n=== Importing AllIdentifiers ===")
identifiers_file = downloads_dir / "AllIdentifiers.json.gz"
if not identifiers_file.exists():
download_file(f"{MTGJSON_API_V5}/AllIdentifiers.json.gz", identifiers_file)
await import_all_identifiers(engine, identifiers_file)
# Download and import CardTypes
print("\n=== Importing CardTypes ===")
card_types_file = downloads_dir / "CardTypes.json.gz"
if not card_types_file.exists():
download_file(f"{MTGJSON_API_V5}/CardTypes.json.gz", card_types_file)
if card_types_file.exists():
await import_simple_json(engine, card_types_file, 'card_types', 'type', 'id')
# Download and import DeckList
print("\n=== Importing DeckList ===")
deck_list_file = downloads_dir / "DeckList.json.gz"
if not deck_list_file.exists():
download_file(f"{MTGJSON_API_V5}/DeckList.json.gz", deck_list_file)
if deck_list_file.exists():
await import_simple_json(engine, deck_list_file, 'deck_list', 'name', 'deck_id')
# Download and import Keywords
print("\n=== Importing Keywords ===")
keywords_file = downloads_dir / "Keywords.json.gz"
if not keywords_file.exists():
download_file(f"{MTGJSON_API_V5}/Keywords.json.gz", keywords_file)
if keywords_file.exists():
await import_simple_json(engine, keywords_file, 'card_keywords', 'keyword', 'id')
# Download and import SetList
print("\n=== Importing SetList ===")
set_list_file = downloads_dir / "SetList.json.gz"
if not set_list_file.exists():
download_file(f"{MTGJSON_API_V5}/SetList.json.gz", set_list_file)
if set_list_file.exists():
await import_simple_json(engine, set_list_file, 'set_list', 'set_name', 'set_code')
# Create indexes
print("\n=== Creating Indexes ===")
await create_indexes(engine)
# Show summary
await show_summary(engine)
await engine.dispose()
print("\n✓ All MTGJSON data imported successfully!")
if __name__ == "__main__":
asyncio.run(main())
-883
View File
@@ -1,883 +0,0 @@
#!/usr/bin/env python3
"""
MTGJSON Data Uploader
Downloads all MTGJSON data and upserts it into the PostgreSQL database.
This script is designed to run inside the Docker container.
"""
import asyncio
import gzip
import json
import os
import sys
import zipfile
from pathlib import Path
from datetime import datetime
from typing import Any
from urllib.request import urlretrieve
# Add parent directory to path for imports
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_, 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"
# Files to download
MTGJSON_FILES = [
"AllPrintings.psql.gz",
"AllSetFiles.zip",
"AllDeckFiles.zip",
"AllIdentifiers.json.gz",
"CardTypes.json.gz",
"DeckList.json.gz",
"Keywords.json.gz",
"SetList.json.gz",
]
async def create_tables(engine: create_async_engine) -> None:
"""Create all required tables."""
async with engine.begin() as conn:
# Cards table (from AllPrintings)
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS cards (
id SERIAL PRIMARY KEY,
artist TEXT,
asciiName TEXT,
attractionLights TEXT,
availability TEXT,
boosterTypes TEXT,
borderColor TEXT,
cardParts TEXT,
colorIdentity TEXT,
colorIndicator TEXT,
colors TEXT,
defense TEXT,
duelDeck TEXT,
edhrecRank INTEGER,
edhrecSaltiness FLOAT,
faceConvertedManaCost FLOAT,
faceFlavorName TEXT,
faceManaValue FLOAT,
faceName TEXT,
facePrintedName TEXT,
finishes TEXT,
flavorName TEXT,
flavorText TEXT,
frameEffects TEXT,
frameVersion TEXT,
hand TEXT,
hasAlternativeDeckLimit BOOLEAN,
hasContentWarning BOOLEAN,
isAlternative BOOLEAN,
isFullArt BOOLEAN,
isFunny BOOLEAN,
isGameChanger BOOLEAN,
isOnlineOnly BOOLEAN,
isOversized BOOLEAN,
isPromo BOOLEAN,
isRebalanced BOOLEAN,
isReprint BOOLEAN,
isReserved BOOLEAN,
isStorySpotlight BOOLEAN,
isTextless BOOLEAN,
isTimeshifted BOOLEAN,
keywords TEXT,
language TEXT,
layout TEXT,
leadershipSkills TEXT,
life TEXT,
loyalty TEXT,
manaCost TEXT,
manaValue FLOAT,
name TEXT,
number TEXT,
originalPrintings TEXT,
originalReleaseDate TEXT,
originalText TEXT,
otherFaceIds TEXT,
power TEXT,
printedName TEXT,
printedText TEXT,
printedType TEXT,
printings TEXT,
producedMana TEXT,
promoTypes TEXT,
rarity TEXT,
rebalancedPrintings TEXT,
relatedCards TEXT,
securityStamp TEXT,
setCode TEXT,
side TEXT,
signature TEXT,
skuIds TEXT,
sourceProducts TEXT,
subsets TEXT,
subtypes TEXT,
supertypes TEXT,
text TEXT,
toughness TEXT,
type TEXT,
types TEXT,
uuid TEXT,
variations TEXT,
watermark TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
# MTG Sets table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS mtg_sets (
id SERIAL PRIMARY KEY,
code VARCHAR(10) UNIQUE NOT NULL,
name VARCHAR(255),
type VARCHAR(100),
release_date DATE,
base_set_size INTEGER,
total_size INTEGER,
is_foil_only BOOLEAN,
is_non_foil_only BOOLEAN,
digital BOOLEAN,
icon_svg_url TEXT,
parent_code VARCHAR(10),
mtgo_code VARCHAR(10),
card_count INTEGER,
image_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
# MTG Cards table (from set files)
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS mtg_cards (
id SERIAL PRIMARY KEY,
set_id INTEGER REFERENCES mtg_sets(id) ON DELETE CASCADE,
name VARCHAR(255),
mana_cost VARCHAR(255),
type_line VARCHAR(255),
oracle_text TEXT,
power VARCHAR(50),
toughness VARCHAR(50),
rarity VARCHAR(50),
layout VARCHAR(50),
artist VARCHAR(255),
flavor_text TEXT,
numbers VARCHAR(100),
identifiers TEXT,
images TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
# Card Identifiers table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS card_identifiers (
id SERIAL PRIMARY KEY,
uuid TEXT UNIQUE NOT NULL,
name VARCHAR(255),
mana_cost VARCHAR(255),
type_line VARCHAR(255),
oracle_text TEXT,
power VARCHAR(50),
toughness VARCHAR(50),
rarity VARCHAR(50),
layout VARCHAR(50),
artist VARCHAR(255),
flavor_text TEXT,
set_code VARCHAR(10),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
# Decks table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS decks (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
format VARCHAR(50),
command TEXT,
commander TEXT,
creation_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
# Card Types table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS card_types (
id SERIAL PRIMARY KEY,
type VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
# Deck List table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS deck_list (
id SERIAL PRIMARY KEY,
deck_id VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255),
description TEXT,
format VARCHAR(50),
command TEXT,
commander TEXT,
total_cards INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
# Card Keywords table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS card_keywords (
id SERIAL PRIMARY KEY,
keyword VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
# Set List table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS set_list (
id SERIAL PRIMARY KEY,
set_code VARCHAR(10) UNIQUE NOT NULL,
set_name VARCHAR(255),
set_type VARCHAR(100),
release_date DATE,
base_set_size INTEGER,
total_size INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
await conn.commit()
print("✓ Database tables created")
async def import_all_printings_psql(engine: create_async_engine, psql_file: Path) -> int:
"""Import AllPrintings.psql.gz file."""
if not psql_file.exists():
print("✗ AllPrintings.psql.gz not found")
return 0
print(f"Importing {psql_file.name}...")
# Decompress
psql_content = gzip.decompress(psql_file.read_bytes())
psql_path = psql_file.with_suffix('.psql')
psql_path.write_bytes(psql_content)
# Use psql command to import
import subprocess
settings = get_settings()
db_url = settings.MTG_DATABASE_URL
# Parse database URL to extract connection details
# Format: postgresql+asyncpg://user:pass@host:port/database
url_parts = db_url.replace('postgresql+asyncpg://', '').split('@')
user_pass = url_parts[0].split('//')[1]
host_db = url_parts[1]
user, password = user_pass.split(':')
host, port_db = host_db.split(':')
database = port_db.split('/')[1]
cmd = [
'psql', '-h', host, '-p', port_db.split('/')[0],
'-U', user, '-d', database,
'-f', str(psql_path)
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"✗ Import failed: {result.stderr[:500]}")
psql_path.unlink()
return 0
# Count records
async with engine.connect() as conn:
result = await conn.execute(text("SELECT COUNT(*) FROM cards"))
count = result.scalar()
print(f"✓ Imported {count:,} cards")
# Clean up
psql_path.unlink()
return count
async def import_all_set_files(engine: create_async_engine, set_files_dir: Path) -> tuple[int, int]:
"""Import AllSetFiles.zip - sets and cards."""
if not set_files_dir.exists():
print("✗ AllSetFiles directory not found")
return 0, 0
set_count = 0
card_count = 0
async with engine.begin() as conn:
# Import sets
for set_file in sorted(set_files_dir.glob('*.json')):
data = json.loads(set_file.read_text())
if 'code' in data:
image_url = None
if 'image' in data and data['image']:
image_url = data['image'].get('normal')
# Upsert set
result = await conn.execute(
pg_insert(mtg_sets_table).values(
code=data.get('code'),
name=data.get('name'),
type=data.get('type'),
release_date=data.get('releaseDate'),
base_set_size=data.get('baseSetSize'),
total_size=data.get('totalSetSize'),
is_foil_only=data.get('isFoilOnly'),
is_non_foil_only=data.get('isNonFoilOnly'),
digital=data.get('digital'),
icon_svg_url=data.get('iconSvgUrl'),
parent_code=data.get('parentCode'),
mtgo_code=data.get('mtgoCode'),
card_count=data.get('cardCount'),
image_url=image_url,
).on_conflict_do_update(
index_elements=['code'],
set_={
'name': data.get('name'),
'type': data.get('type'),
'release_date': data.get('releaseDate'),
'base_set_size': data.get('baseSetSize'),
'total_size': data.get('totalSetSize'),
'is_foil_only': data.get('isFoilOnly'),
'is_non_foil_only': data.get('isNonFoilOnly'),
'digital': data.get('digital'),
'icon_svg_url': data.get('iconSvgUrl'),
'parent_code': data.get('parentCode'),
'mtgo_code': data.get('mtgoCode'),
'card_count': data.get('cardCount'),
'image_url': image_url,
'updated_at': datetime.utcnow(),
}
).returning(mtg_sets_table.id),
execution_options={"autocommit": True}
)
set_id = result.scalar()
if 'cards' in data:
for card_data in data['cards']:
identifiers = {
'multiId': card_data.get('multiverseIds'),
'tcgplayerProductId': card_data.get('tcgplayerProductId'),
'cardmarketId': card_data.get('cardmarketId'),
}
images = {}
if 'image_uris' in card_data:
images = {
'small': card_data['image_uris'].get('small'),
'normal': card_data['image_uris'].get('normal'),
'large': card_data['image_uris'].get('large'),
'png': card_data['image_uris'].get('png'),
'art_crop': card_data['image_uris'].get('art_crop'),
}
# Upsert card
await conn.execute(
pg_insert(mtg_cards_table).values(
set_id=set_id,
name=card_data.get('name'),
mana_cost=card_data.get('manaCost'),
type_line=card_data.get('typeLine'),
oracle_text=card_data.get('oracleText'),
power=card_data.get('power'),
toughness=card_data.get('toughness'),
rarity=card_data.get('rarity'),
layout=card_data.get('layout'),
artist=card_data.get('artist'),
flavor_text=card_data.get('flavorText'),
numbers=str(card_data.get('number')),
identifiers=json.dumps(identifiers),
images=json.dumps(images),
).on_conflict_do_nothing(),
execution_options={"autocommit": True}
)
card_count += 1
set_count += 1
print(f"✓ Imported {set_count} sets and {card_count} cards from sets")
return set_count, card_count
async def import_all_identifiers(engine: create_async_engine, file_path: Path) -> int:
"""Import AllIdentifiers.json.gz."""
if not file_path.exists():
print("✗ AllIdentifiers.json.gz not found")
return 0
data = json.loads(gzip.decompress(file_path.read_bytes()))
count = 0
async with engine.begin() as conn:
for uuid, card_data in data.items():
await conn.execute(
pg_insert(card_identifiers_table).values(
uuid=uuid,
name=card_data.get('name'),
mana_cost=card_data.get('manaCost'),
type_line=card_data.get('typeLine'),
oracle_text=card_data.get('oracleText'),
power=card_data.get('power'),
toughness=card_data.get('toughness'),
rarity=card_data.get('rarity'),
layout=card_data.get('layout'),
artist=card_data.get('artist'),
flavor_text=card_data.get('flavorText'),
set_code=card_data.get('setCode'),
).on_conflict_do_update(
index_elements=['uuid'],
set_={
'name': card_data.get('name'),
'mana_cost': card_data.get('manaCost'),
'type_line': card_data.get('typeLine'),
'oracle_text': card_data.get('oracleText'),
'power': card_data.get('power'),
'toughness': card_data.get('toughness'),
'rarity': card_data.get('rarity'),
'layout': card_data.get('layout'),
'artist': card_data.get('artist'),
'flavor_text': card_data.get('flavorText'),
'set_code': card_data.get('setCode'),
'updated_at': datetime.utcnow(),
}
),
execution_options={"autocommit": True}
)
count += 1
print(f"✓ Imported {count} identifiers")
return count
async def import_all_deck_files(engine: create_async_engine, deck_files_dir: Path) -> int:
"""Import AllDeckFiles.zip."""
if not deck_files_dir.exists():
print("✗ AllDeckFiles directory not found")
return 0
count = 0
async with engine.begin() as conn:
for deck_file in sorted(deck_files_dir.glob('*.json')):
data = json.loads(deck_file.read_text())
if 'name' in data and 'cards' in data:
await conn.execute(
pg_insert(decks_table).values(
name=data.get('name'),
description=data.get('description'),
format=data.get('format'),
command=data.get('command'),
commander=data.get('commander'),
).on_conflict_do_update(
index_elements=['name'],
set_={
'description': data.get('description'),
'format': data.get('format'),
'command': data.get('command'),
'commander': data.get('commander'),
'updated_at': datetime.utcnow(),
}
),
execution_options={"autocommit": True}
)
count += 1
print(f"✓ Imported {count} decks")
return count
async def import_json_files(engine: create_async_engine, file_path: Path,
table_name: str, name_field: str, id_field: str) -> int:
"""Import a JSON.gz file into a table."""
if not file_path.exists():
return 0
data = json.loads(gzip.decompress(file_path.read_bytes()))
count = 0
async with engine.begin() as conn:
for item in data:
values = {k: v for k, v in item.items() if k != id_field}
await conn.execute(
pg_insert(text(f'{table_name}_table')).values(values),
execution_options={"autocommit": True}
)
count += 1
print(f"✓ Imported {count} items into {table_name}")
return count
def download_file(url: str, destination: Path) -> bool:
"""Download a file from URL to destination."""
try:
print(f"Downloading {url}...")
urlretrieve(url, destination)
size_mb = destination.stat().st_size / (1024 * 1024)
print(f" ✓ Downloaded to {destination} ({size_mb:.1f} MB)")
return True
except Exception as e:
print(f" ✗ Failed to download {url}: {e}")
return False
def extract_zip(zip_file: Path, extract_dir: Path) -> None:
"""Extract a zip file."""
if not zip_file.exists():
print(f"✗ {zip_file.name} not found")
return
if extract_dir.exists():
import shutil
shutil.rmtree(extract_dir)
print(f"Extracting {zip_file.name}...")
with zipfile.ZipFile(zip_file, 'r') as zip_ref:
zip_ref.extractall(extract_dir)
file_count = len(list(extract_dir.glob('*.json')))
print(f"✓ Extracted to {extract_dir} ({file_count} files)")
async def main():
"""Main function to download and import all MTGJSON data."""
settings = get_settings()
# Setup data directory
data_dir = Path(settings.DATA_DIR) / "mtgjson"
downloads_dir = data_dir / "downloads"
downloads_dir.mkdir(parents=True, exist_ok=True)
# Create engine
engine = create_async_engine(settings.MTG_DATABASE_URL)
# Create tables
print("=== Creating Database Tables ===")
await create_tables(engine)
# Download AllPrintings.psql.gz
print("\n=== Downloading AllPrintings ===")
psql_file = downloads_dir / "AllPrintings.psql.gz"
if not psql_file.exists():
download_file(f"{MTGJSON_API_V5}/AllPrintings.psql.gz", psql_file)
# Import AllPrintings
await import_all_printings_psql(engine, psql_file)
# Download and extract AllSetFiles
print("\n=== Importing AllSetFiles ===")
set_files_zip = downloads_dir / "AllSetFiles.zip"
set_files_dir = downloads_dir / "AllSetFiles"
if not set_files_dir.exists():
if not set_files_zip.exists():
download_file(f"{MTGJSON_API_V5}/AllSetFiles.zip", set_files_zip)
extract_zip(set_files_zip, set_files_dir)
await import_all_set_files(engine, set_files_dir)
# Download and extract AllDeckFiles
print("\n=== Importing AllDeckFiles ===")
deck_files_zip = downloads_dir / "AllDeckFiles.zip"
deck_files_dir = downloads_dir / "AllDeckFiles"
if not deck_files_dir.exists():
if not deck_files_zip.exists():
download_file(f"{MTGJSON_API_V5}/AllDeckFiles.zip", deck_files_zip)
extract_zip(deck_files_zip, deck_files_dir)
await import_all_deck_files(engine, deck_files_dir)
# Download and import AllIdentifiers
print("\n=== Importing AllIdentifiers ===")
identifiers_file = downloads_dir / "AllIdentifiers.json.gz"
if not identifiers_file.exists():
download_file(f"{MTGJSON_API_V5}/AllIdentifiers.json.gz", identifiers_file)
await import_all_identifiers(engine, identifiers_file)
# Download and import CardTypes
print("\n=== Importing CardTypes ===")
card_types_file = downloads_dir / "CardTypes.json.gz"
if not card_types_file.exists():
download_file(f"{MTGJSON_API_V5}/CardTypes.json.gz", card_types_file)
# Import CardTypes (simplified)
if card_types_file.exists():
data = json.loads(gzip.decompress(card_types_file.read_bytes()))
async with engine.begin() as conn:
count = 0
for card_type in data:
await conn.execute(
pg_insert(card_types_table).values(
type=card_type.get('type'),
description=card_type.get('description'),
).on_conflict_do_nothing(),
execution_options={"autocommit": True}
)
count += 1
print(f"✓ Imported {count} card types")
# Download and import DeckList
print("\n=== Importing DeckList ===")
deck_list_file = downloads_dir / "DeckList.json.gz"
if not deck_list_file.exists():
download_file(f"{MTGJSON_API_V5}/DeckList.json.gz", deck_list_file)
# Import DeckList (simplified)
if deck_list_file.exists():
data = json.loads(gzip.decompress(deck_list_file.read_bytes()))
async with engine.begin() as conn:
count = 0
for deck in data:
await conn.execute(
pg_insert(deck_list_table).values(
deck_id=deck.get('id'),
name=deck.get('name'),
description=deck.get('description'),
format=deck.get('format'),
command=deck.get('command'),
commander=deck.get('commander'),
total_cards=deck.get('totalCards'),
).on_conflict_do_nothing(),
execution_options={"autocommit": True}
)
count += 1
print(f"✓ Imported {count} deck list entries")
# Download and import Keywords
print("\n=== Importing Keywords ===")
keywords_file = downloads_dir / "Keywords.json.gz"
if not keywords_file.exists():
download_file(f"{MTGJSON_API_V5}/Keywords.json.gz", keywords_file)
# Import Keywords (simplified)
if keywords_file.exists():
data = json.loads(gzip.decompress(keywords_file.read_bytes()))
async with engine.begin() as conn:
count = 0
for keyword in data:
await conn.execute(
pg_insert(card_keywords_table).values(
keyword=keyword.get('keyword'),
description=keyword.get('description'),
).on_conflict_do_nothing(),
execution_options={"autocommit": True}
)
count += 1
print(f"✓ Imported {count} keywords")
# Download and import SetList
print("\n=== Importing SetList ===")
set_list_file = downloads_dir / "SetList.json.gz"
if not set_list_file.exists():
download_file(f"{MTGJSON_API_V5}/SetList.json.gz", set_list_file)
# Import SetList (simplified)
if set_list_file.exists():
data = json.loads(gzip.decompress(set_list_file.read_bytes()))
async with engine.begin() as conn:
count = 0
for set_data in data:
await conn.execute(
pg_insert(set_list_table).values(
set_code=set_data.get('code'),
set_name=set_data.get('name'),
set_type=set_data.get('type'),
release_date=set_data.get('releaseDate'),
base_set_size=set_data.get('baseSetSize'),
total_size=set_data.get('totalSetSize'),
).on_conflict_do_nothing(),
execution_options={"autocommit": True}
)
count += 1
print(f"✓ Imported {count} set list entries")
# Create indexes
print("\n=== Creating Indexes ===")
async with engine.begin() as conn:
indexes = [
"CREATE INDEX IF NOT EXISTS idx_cards_name ON cards(name)",
"CREATE INDEX IF NOT EXISTS idx_cards_mana_cost ON cards(manaCost)",
"CREATE INDEX IF NOT EXISTS idx_cards_type ON cards(type)",
"CREATE INDEX IF NOT EXISTS idx_cards_rarity ON cards(rarity)",
"CREATE INDEX IF NOT EXISTS idx_cards_set_code ON cards(setCode)",
"CREATE INDEX IF NOT EXISTS idx_cards_uuid ON cards(uuid)",
"CREATE INDEX IF NOT EXISTS idx_mtg_sets_code ON mtg_sets(code)",
"CREATE INDEX IF NOT EXISTS idx_mtg_cards_name ON mtg_cards(name)",
"CREATE INDEX IF NOT EXISTS idx_card_identifiers_uuid ON card_identifiers(uuid)",
"CREATE INDEX IF NOT EXISTS idx_card_identifiers_name ON card_identifiers(name)",
"CREATE INDEX IF NOT EXISTS idx_deck_list_deck_id ON deck_list(deck_id)",
]
for idx in indexes:
await conn.execute(text(idx))
await conn.commit()
print("✓ Indexes created")
# Show summary
print("\n=== Import Summary ===")
async with engine.connect() as conn:
tables = [
'cards', 'mtg_sets', 'mtg_cards', 'card_identifiers',
'decks', 'card_types', 'deck_list', 'card_keywords', 'set_list'
]
for table in tables:
result = await conn.execute(text(f"SELECT COUNT(*) FROM {table}"))
count = result.scalar()
print(f" {table:20} {count:>10,} records")
await engine.dispose()
print("\n✓ All MTGJSON data imported successfully!")
if __name__ == "__main__":
asyncio.run(main())
+69 -75
View File
@@ -1,88 +1,82 @@
{
"project_summary": "MTG Online backend REST API with FastAPI, SQLAlchemy, and Alembic migrations. Provides user authentication, deck management, card collection tracking, game rooms, and protocol message handling for MTG Online client compatibility.",
"roadmap": [
{"phase": 1, "name": "Core Infrastructure", "status": "completed", "description": "Project setup, database, authentication, basic models"},
{"phase": 2, "name": "Model Layer", "status": "completed", "description": "All SQLAlchemy models with proper relationships and constraints"},
{"phase": 3, "name": "Schema Layer", "status": "completed", "description": "Pydantic v2 schemas matching models, deduplication, validation"},
{"phase": 4, "name": "Router Layer", "status": "completed", "description": "FastAPI routes with schema validation and error handling"},
{"phase": 5, "name": "Service Layer", "status": "completed", "description": "Business logic, card matching, deck validation"},
{"phase": 6, "name": "Integration Testing", "status": "completed", "description": "Router-service integration, schema validation, database connection flows"},
{"phase": 7, "name": "End-to-End API Testing", "status": "completed", "description": "Database setup, migrations, application startup, API endpoint verification"},
{"phase": 8, "name": "Performance Testing", "status": "pending", "description": "Load testing, stress testing, optimization"},
{"phase": 9, "name": "Docker Deployment", "status": "completed", "description": "Docker Compose with PostgreSQL, Redis, structured logging, health checks, resource limits, entrypoint script"}
],
"tech_stack": {
"language": "Python 3.12",
"framework": "FastAPI",
"orm": "SQLAlchemy 2.0",
"migration": "Alembic 1.16",
"validation": "Pydantic v2",
"auth": "bcrypt, JWT",
"database": "PostgreSQL 17",
"cache": "Redis",
"async_driver": "asyncpg",
"docker": "Docker Compose V2",
"logging": "Structured logging with JSON/text format support"
"context": {
"project_summary": "MTG Online backend API — a FastAPI/SQLAlchemy application for managing MTG card decks, user accounts, game replays, and social features. Uses PostgreSQL with a separate mtg_data database for canonical card/set data.",
"roadmap": [
{"phase": "1", "status": "completed", "description": "Architecture simplification: unified database config, security, settings"},
{"phase": "2", "status": "completed", "description": "Model consolidation: 10+ files → 2 (models.py, mtg_models.py)"},
{"phase": "3", "status": "completed", "description": "Router consolidation and cleanup: 54+ files → 37 files"},
{"phase": "4", "status": "pending", "description": "Docker packaging"}
],
"tech_stack": ["Python 3.12", "FastAPI", "SQLAlchemy 2.0", "PostgreSQL", "bcrypt", "PyJWT"],
"architectural_notes": "Two databases: mtgonline (primary app) and mtg_data (canonical card data). Models use string-based back_populates for cross-file relationships. Cross-model refs (DecklistFile↔DeckCardLink) resolved via late import pattern. Single unified database engine with auto-detection for SQLite/PostgreSQL."
},
"architectural_notes": "Multi-database architecture: primary mtgo_platform DB for user data, mtgdata DB for canonical card data. Card mirrors in mtgo_platform for fast deckbuilding queries. Pydantic v2 schemas with model_config for from_attributes. Protocol constants and message models for MTG Online client compatibility. Service layer pattern with DeckManager, CardSearchService, FuzzyCardMatcher, etc. Docker deployment with health checks, resource limits, and structured logging.",
"working": {
"task_description": "Phase 9: Docker Deployment - Enhanced Docker Compose setup with structured logging, health checks, resource limits, and entrypoint script. Fixed settings.py defaults for Docker service names.",
"current_step": "Fixed settings.py defaults to use Docker service names (postgres_platform, postgres_mtgdata, postgres_mirror, redis) instead of localhost. Committed and pushed to Gitea. Ready for comprehensive API testing with Docker deployment.",
"task_description": "Phase 3: Complete architecture simplification - consolidate routers, remove duplicates, create startup script",
"current_step": "All phases complete. Backend simplified from 54+ files to 37 files with clean architecture.",
"files_created": [
"app/schemas/generic_schemas.py",
"test_routers.py",
"test_schema_validation.py",
".env",
"app/core/logging_config.py",
"scripts/docker-entrypoint.sh"
"backend/run.py (simple startup script)",
"backend/app/core/config.py (configuration management)",
"backend/app/core/logging.py (logging configuration)",
"backend/app/routers/auth_router.py (consolidated auth)",
"backend/app/routers/users_router.py (consolidated users)",
"backend/app/routers/decks_router.py (consolidated decks)",
"backend/app/routers/cards_router.py (consolidated cards)",
"backend/app/routers/user_data_router.py (consolidated user data)",
"backend/app/routers/games_router.py (consolidated games/rooms/websocket)",
"backend/app/routers/admin.py (admin functions)",
"backend/SIMPLIFICATION_SUMMARY.md (documentation)"
],
"files_modified": [
"alembic.ini",
"app/core/settings.py",
"app/main.py",
"app/routers/__init__.py",
"app/routers/card_import.py",
"app/routers/card_router.py",
"app/routers/decks.py",
"app/routers/user_data.py",
"app/schemas/__init__.py",
"app/schemas/card_import_schemas.py",
"app/schemas/card_search_schemas.py",
"app/schemas/game_schemas.py",
"app/schemas/mtg_card_schemas.py",
"app/schemas/schemas.py",
"app/schemas/user_card_collection.py",
"app/schemas/user_data_schemas.py",
"app/schemas/user_deck_schemas.py",
"Dockerfile",
"docker-compose.yml"
"backend/app/main.py (updated router registrations)",
"backend/app/core/database.py (unified engine, backward compat aliases)",
"backend/app/core/redis_client.py (use config instead of settings)",
"backend/app/core/security.py (updated imports)",
"backend/app/routers/__init__.py (updated exports)",
"backend/app/schemas/__init__.py (removed stale imports)",
"backend/app/services/__init__.py (removed stale imports)",
"backend/app/services/card_search_service.py (updated model imports)",
"backend/app/services/deck_manager.py (updated model imports)",
"backend/app/services/deck_suggestion_service.py (updated model imports)",
"backend/app/services/import_batch_processor.py (updated model imports)"
],
"files_deleted": [
"backend/app/routers/auth.py",
"backend/app/routers/users.py",
"backend/app/routers/decks.py",
"backend/app/routers/card_router.py",
"backend/app/routers/interactions.py",
"backend/app/routers/user_data.py",
"backend/app/routers/rooms.py",
"backend/app/routers/refresh.py",
"backend/app/routers/card_import.py",
"backend/app/routers/ws.py",
"backend/app/routers/games/router.py",
"backend/app/core/logging_config.py",
"backend/app/core/settings.py",
"backend/app/schemas/proto_messages.py",
"backend/app/schemas/protocol_constants.py",
"backend/app/services/card_mirror_service.py",
"backend/app/services/mtgjson_loader.py",
"backend/app/services/mtgjson_uploader.py"
],
"decisions": [
"Switched from psycopg2 to asyncpg for async SQLAlchemy support",
"Fixed router registration in main.py - removed duplicate prefixes",
"Added user_data export to routers/__init__.py",
"Refactored decks router to use DeckManager service layer",
"Integrated FuzzyCardMatcher into card_router search endpoints",
"Made WishlistCreate.card_id optional for proper schema validation",
"Set PostgreSQL password and configured scram-sha-256 auth",
"Updated alembic.ini to use local PostgreSQL instead of Docker hostname",
"Added structured logging with JSON/text format support",
"Created docker-entrypoint.sh for dependency health checks before startup",
"Enhanced /health endpoint to check DB and Redis connectivity",
"Added resource limits (CPU/memory) for all Docker services",
"Added network aliases for service discovery",
"Reduced health check timeout from 20s to 5s",
"Added build metadata labels to Dockerfile",
"Fixed settings.py defaults to use Docker service names instead of localhost"
"All 11 old router files removed, replaced with 6 consolidated routers",
"Duplicate core files (logging_config.py, settings.py) removed",
"Unused services removed (card_mirror_service, mtgjson_loader, mtgjson_uploader)",
"Unused schemas removed (proto_messages, protocol_constants)",
"Created run.py for simple startup: python3 run.py",
"SQLite by default for development, PostgreSQL via DATABASE_URL",
"All model imports updated to use consolidated app.models.models",
"Backward compatibility aliases added to database.py for old import paths"
],
"next_steps": [
"Rebuild and restart Docker containers with fixed settings.py",
"Test all API endpoints with authenticated users",
"Phase 8: Performance Testing - Load testing, stress testing, optimization",
"Deploy to production environment"
"Phase 4: Docker packaging (Dockerfile, docker-compose.yml)",
"Add comprehensive tests",
"Implement remaining game endpoints",
"Add WebSocket game server integration"
],
"blockers": [],
"commit_hash": "d8f9526",
"timestamp": "2026-08-26T03:07:30Z"
"blockers": null,
"commit_hash": null,
"timestamp": "2025-08-26T15:15:00Z"
}
}