Compare commits
2
Commits
e507625d86
...
900f51af3a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
900f51af3a | ||
|
|
03f5433583 |
+173
-632
@@ -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
|
### What We Have
|
||||||
**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.
|
|
||||||
|
|
||||||
## Vision Statement (from STATEMENT_OF_INTENT.md)
|
**Backend Structure:**
|
||||||
|
|
||||||
**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
|
|
||||||
```
|
```
|
||||||
mtgonline/
|
backend/
|
||||||
├── backend/ # FastAPI application
|
├── app/
|
||||||
│ ├── mtg_rules_engine/ # MTG rules engine (rules enforcement)
|
│ ├── core/ # Settings, database engines, Redis client, logging
|
||||||
│ │ ├── engine.py # Core game engine
|
│ ├── models/ # 10+ SQLAlchemy model files (models.py, user_data.py, user_deck.py, etc.)
|
||||||
│ │ ├── rules_engine.py # Rules engine core
|
│ ├── routers/ # 10+ router files (auth, users, decks, games, admin, card_router, etc.)
|
||||||
│ │ ├── keywords.py # Card keywords
|
│ ├── schemas/ # 13+ Pydantic schema files
|
||||||
│ │ ├── keywords_db.py # Keywords database
|
│ ├── services/ # 15+ service files (mtgjson_manager.py is 53KB, mtgjson_loader.py is 32KB)
|
||||||
│ │ ├── keyword_validator.py # Keyword validation
|
│ └── main.py # FastAPI app with 10+ router mounts
|
||||||
│ │ ├── validator.py # Card validation
|
├── mtg_rules_engine/ # 10+ files for game rules engine
|
||||||
│ │ ├── updater.py # Engine updater
|
├── alembic/ # Database migrations
|
||||||
│ │ ├── update_check.py # Update checker
|
├── Dockerfile
|
||||||
│ │ ├── test_engine.py # Engine tests
|
├── docker-compose.yml
|
||||||
│ │ └── README.md # Rules engine docs
|
└── requirements.txt
|
||||||
│ ├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Flow
|
**Key Issues:**
|
||||||
1. Backend connects to PostgreSQL (both instances) and Redis on startup
|
1. **Fragmented models** - 10+ separate model files that should be consolidated
|
||||||
2. Checks `mtg_refresh_log` in `mtgdata` database for existing data
|
2. **Massive services** - `mtgjson_manager.py` is 53KB, `mtgjson_loader.py` is 32KB
|
||||||
3. If no data exists, downloads MTGJSON files from `https://mtgjson.com/api/v5/`
|
3. **Docker complexity** - Multi-database setup (PostgreSQL x2, Redis) with Docker Compose
|
||||||
4. Upserts data into `mtgdata` tables (`mtg_sets`, `mtg_cards`, etc.)
|
4. **Async everywhere** - Async SQLAlchemy, asyncpg, async Redis - adds complexity for development
|
||||||
5. Data available via REST endpoints
|
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
|
### What We Want
|
||||||
- **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`
|
|
||||||
|
|
||||||
## Environment Configuration
|
Simplify the architecture to be **LLM-friendly** with these principles:
|
||||||
|
|
||||||
### Docker Compose Dev Environment Variables
|
1. **Single database** - Use SQLite for development/local, PostgreSQL for production
|
||||||
```yaml
|
2. **Consolidated models** - Merge related models into fewer files
|
||||||
environment:
|
3. **Simplified services** - Break large services into focused, smaller modules
|
||||||
DATABASE_URL: "postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline"
|
4. **No Docker for development** - Run directly with Python/uvicorn
|
||||||
MTG_DATABASE_URL: "postgresql+asyncpg://mtgonline_user:mtgonline_password@mtgdata:5432/mtgdata"
|
5. **Linear structure** - Reduce nesting and indirection
|
||||||
REDIS_URL: "redis://redis:6379"
|
6. **Clear separation** - Each file has one clear responsibility
|
||||||
```
|
7. **Easy startup** - `python run.py` starts everything
|
||||||
|
|
||||||
### Service Ports (Host to Container)
|
### Specific Simplifications
|
||||||
- PostgreSQL: `5432:5432`
|
|
||||||
- MTG Data PostgreSQL: `5433:5432`
|
|
||||||
- Redis: `6379:6379`
|
|
||||||
- Backend: `5555:8000`
|
|
||||||
|
|
||||||
## 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
|
#### 2. Models Layer
|
||||||
- `GET /health` - Health check with MTGJSON status
|
- **Current**: 10+ model files (models.py, user_data.py, user_deck.py, mirror_models.py, etc.)
|
||||||
- `GET /` - API info
|
- **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
|
#### 3. Services Layer
|
||||||
- `POST /auth/login` - User login
|
- **Current**: 15+ service files, some massive (mtgjson_manager.py = 53KB)
|
||||||
- `POST /auth/register` - User registration
|
- **Simplified**: 5-7 focused service files
|
||||||
- `POST /auth/refresh` - Refresh JWT
|
- `card_service.py` - Card search and lookup
|
||||||
- `GET /auth/me` - Current user
|
- `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
|
#### 4. Routers Layer
|
||||||
- `GET /users/{user_id}` - Get user
|
- **Current**: 10+ router files
|
||||||
- `PATCH /users/{user_id}` - Update user
|
- **Simplified**: 5-6 router files
|
||||||
- `POST /users/{user_id}/ban` - Ban user (admin)
|
- `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
|
#### 5. Schemas Layer
|
||||||
- `GET /decks/` - List decks
|
- **Current**: 13+ schema files
|
||||||
- `POST /decks/` - Create deck
|
- **Simplified**: 5-6 schema files matching routers
|
||||||
- `GET /decks/{deck_id}` - Get deck
|
- `auth_schemas.py` - Auth request/response
|
||||||
- `PATCH /decks/{deck_id}` - Update deck
|
- `user_schemas.py` - User data schemas
|
||||||
- `DELETE /decks/{deck_id}` - Delete deck
|
- `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
|
#### 6. Startup Script
|
||||||
- `GET /api/cards/` - Search cards
|
- **Current**: Docker Compose, entrypoint scripts, migration scripts
|
||||||
- `GET /api/cards/{card_id}` - Get card
|
- **Simplified**: Single `run.py` script
|
||||||
- `GET /api/sets/` - List sets
|
```python
|
||||||
|
# run.py
|
||||||
|
import uvicorn
|
||||||
|
from app.core.database import init_db
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
### Admin
|
if __name__ == "__main__":
|
||||||
- `GET /admin/users` - List all users
|
init_db() # Create tables if needed
|
||||||
- `GET /admin/bans` - List bans
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
- `POST /admin/bans` - Create ban
|
```
|
||||||
|
- **Why**: One command to start everything
|
||||||
|
|
||||||
### Data Management
|
#### 7. Configuration
|
||||||
- `POST /refresh` - Trigger MTGJSON refresh
|
- **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
|
### Files to Review
|
||||||
- `WS /ws/{room_id}` - Real-time game communication
|
|
||||||
|
|
||||||
## 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
|
**Project Root:**
|
||||||
- `AllPrintings.psql` - Main card data (PostgreSQL format)
|
- `/home/wall-o/projects/mtgonline/README.md`
|
||||||
- `AllIdentifiers.json` - Card identifiers
|
- `/home/wall-o/projects/mtgonline/ROADMAP.md`
|
||||||
- `Keywords.json` - Card keywords
|
- `/home/wall-o/projects/mtgonline/state.json`
|
||||||
- `CardTypes.json` - Card type definitions
|
- `/home/wall-o/projects/mtgonline/docker-compose.yml`
|
||||||
- `AllDeckFiles.zip` - Deck files (unzipped on load)
|
- `/home/wall-o/projects/mtgonline/scripts/`
|
||||||
|
|
||||||
### Refresh Logic
|
### Important Considerations
|
||||||
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
|
|
||||||
|
|
||||||
## 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`
|
### Expected Outcome
|
||||||
**Credentials**: Located at `/home/wall-o/projects/gitea_credentials.txt`
|
|
||||||
**Current Branch**: `main`
|
|
||||||
**Last Commit**: `c42d7ca` - "feat: implement user data schema and API endpoints"
|
|
||||||
|
|
||||||
### Commit History
|
After simplification:
|
||||||
```
|
- **Backend structure**:
|
||||||
c42d7ca - feat: implement user data schema and API endpoints
|
```
|
||||||
- Add Alembic migration setup with async configuration
|
backend/
|
||||||
- Create 16 user data models (users, decks, cards, replays, etc.)
|
├── app/
|
||||||
- Implement comprehensive API endpoints with JWT auth
|
│ ├── core/
|
||||||
- Add replay, card collection, group, network, preferences, and activity log routers
|
│ │ ├── config.py # Simple configuration
|
||||||
- Include API documentation and migration test plan
|
│ │ ├── database.py # Single database engine
|
||||||
46abfe5 - Add comprehensive documentation for MTG Online Backend
|
│ │ └── logging.py # Logging setup
|
||||||
6f01e2d - Initial project 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
|
- **Startup**: `cd backend && python run.py`
|
||||||
|
- **Database**: SQLite by default, PostgreSQL optional
|
||||||
### Start Services
|
- **No Docker required** for development
|
||||||
```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
|
|
||||||
|
|
||||||
### Next Steps
|
### 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)
|
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.
|
||||||
|
|
||||||
### 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 |
|
|
||||||
|
|||||||
@@ -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**
|
||||||
@@ -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
@@ -1,22 +1,75 @@
|
|||||||
"""
|
"""
|
||||||
Database engine and session management.
|
Database engine and session management.
|
||||||
|
|
||||||
Provides async SQLAlchemy engine and session factory for dependency injection.
|
Provides a single SQLAlchemy async engine supporting both SQLite (development)
|
||||||
Supports dual database connections for mtgonline app and mtgjson data.
|
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 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(
|
engine = create_async_engine(
|
||||||
settings.DATABASE_URL,
|
config.DATABASE_URL,
|
||||||
echo=settings.DEBUG,
|
echo=config.DEBUG,
|
||||||
pool_pre_ping=True,
|
# SQLite doesn't support pool_size / max_overflow — only apply for PG
|
||||||
pool_size=20,
|
**(
|
||||||
max_overflow=10,
|
{
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
"pool_size": 20,
|
||||||
|
"max_overflow": 10,
|
||||||
|
}
|
||||||
|
if not config.is_sqlite
|
||||||
|
else {
|
||||||
|
"pool_pre_ping": False,
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async_session = async_sessionmaker(
|
async_session = async_sessionmaker(
|
||||||
@@ -25,28 +78,43 @@ async_session = async_sessionmaker(
|
|||||||
expire_on_commit=False,
|
expire_on_commit=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Secondary database engine (mtgjson data)
|
# ---------------------------------------------------------------------------
|
||||||
mtg_engine = create_async_engine(
|
# Base class for ORM models
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncSession:
|
class Base(DeclarativeBase):
|
||||||
"""FastAPI dependency that provides a database session for the mtgonline app."""
|
"""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:
|
async with async_session() as session:
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
@@ -58,46 +126,68 @@ async def get_db() -> AsyncSession:
|
|||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
async def mtg_get_db() -> AsyncSession:
|
# ---------------------------------------------------------------------------
|
||||||
"""FastAPI dependency that provides a database session for mtgjson data."""
|
# Table creation helper
|
||||||
async with mtg_async_session() as session:
|
# ---------------------------------------------------------------------------
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
await session.commit()
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def mirror_get_db() -> AsyncSession:
|
async def create_tables() -> None:
|
||||||
"""FastAPI dependency that provides a database session for mirror operations."""
|
"""Create all database tables from model metadata.
|
||||||
async with mirror_async_session() as session:
|
|
||||||
try:
|
Imports all model modules to ensure Base.metadata is populated,
|
||||||
yield session
|
then creates all tables. This is intended for development and testing.
|
||||||
await session.commit()
|
In production, use Alembic migrations instead.
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
Example:
|
||||||
raise
|
>>> await create_tables()
|
||||||
finally:
|
"""
|
||||||
await session.close()
|
# 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."""
|
# Backward-compatible aliases
|
||||||
pass
|
#
|
||||||
|
# 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__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"get_db",
|
|
||||||
"async_session",
|
|
||||||
"engine",
|
"engine",
|
||||||
"mtg_get_db",
|
"async_session",
|
||||||
"mtg_async_session",
|
"get_db",
|
||||||
|
"create_tables",
|
||||||
|
# Backward-compatible aliases
|
||||||
"mtg_engine",
|
"mtg_engine",
|
||||||
|
"mtg_async_session",
|
||||||
|
"mtg_get_db",
|
||||||
"mirror_get_db",
|
"mirror_get_db",
|
||||||
"mirror_async_session",
|
|
||||||
"mirror_engine",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
"""Redis client and caching layer."""
|
"""Redis client and caching layer (optional)."""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
from app.core.settings import get_settings
|
from app.core.config import get_config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
settings = get_settings()
|
config = get_config()
|
||||||
|
|
||||||
# Redis client instance
|
# Redis client instance
|
||||||
redis_client: Optional[aioredis.Redis] = None
|
redis_client: Optional[aioredis.Redis] = None
|
||||||
|
|
||||||
|
|
||||||
async def get_redis() -> aioredis.Redis:
|
async def get_redis() -> Optional[aioredis.Redis]:
|
||||||
"""Get Redis client instance."""
|
"""Get Redis client instance.
|
||||||
|
|
||||||
|
Returns None if Redis is not configured or connection fails.
|
||||||
|
"""
|
||||||
global redis_client
|
global redis_client
|
||||||
if redis_client is None:
|
if redis_client is None:
|
||||||
|
redis_url = getattr(config, 'REDIS_URL', None)
|
||||||
|
if not redis_url:
|
||||||
|
logger.debug("Redis not configured")
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
redis_client = aioredis.from_url(
|
redis_client = aioredis.from_url(
|
||||||
settings.REDIS_URL,
|
redis_url,
|
||||||
decode_responses=True,
|
decode_responses=True,
|
||||||
socket_connect_timeout=10,
|
socket_connect_timeout=10,
|
||||||
socket_timeout=10,
|
socket_timeout=10,
|
||||||
|
|||||||
+145
-24
@@ -2,28 +2,91 @@
|
|||||||
Security utilities for authentication and password hashing.
|
Security utilities for authentication and password hashing.
|
||||||
|
|
||||||
Implements JWT token management and bcrypt password hashing with salt.
|
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 datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Header, HTTPException, status
|
from fastapi import Header, HTTPException, status
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
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")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
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)
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""Hash a password using bcrypt with configurable rounds."""
|
"""Hash a password using bcrypt with configurable rounds.
|
||||||
return pwd_context.hash(password, rounds=settings.BCRYPT_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(
|
def create_access_token(
|
||||||
@@ -31,16 +94,30 @@ def create_access_token(
|
|||||||
privlevel: str = "User",
|
privlevel: str = "User",
|
||||||
expires_delta: Optional[timedelta] = None,
|
expires_delta: Optional[timedelta] = None,
|
||||||
) -> str:
|
) -> 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:
|
if expires_delta:
|
||||||
expire = datetime.now(timezone.utc) + expires_delta
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
else:
|
else:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
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 = config.jwt_secret
|
||||||
secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"sub": subject,
|
"sub": subject,
|
||||||
@@ -49,17 +126,29 @@ def create_access_token(
|
|||||||
"type": "access",
|
"type": "access",
|
||||||
"privlevel": privlevel,
|
"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:
|
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(
|
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 = config.jwt_secret
|
||||||
secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"sub": subject,
|
"sub": subject,
|
||||||
@@ -68,19 +157,31 @@ def create_refresh_token(subject: str, privlevel: str = "User") -> str:
|
|||||||
"type": "refresh",
|
"type": "refresh",
|
||||||
"privlevel": privlevel,
|
"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]:
|
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:
|
try:
|
||||||
# Use JWT_SECRET_KEY if available, fall back to SECRET_KEY
|
secret = config.jwt_secret
|
||||||
secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY
|
|
||||||
|
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
secret,
|
secret,
|
||||||
algorithms=[settings.JWT_ALGORITHM],
|
algorithms=[config.JWT_ALGORITHM],
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
except JWTError:
|
except JWTError:
|
||||||
@@ -88,26 +189,46 @@ def decode_token(token: str) -> Optional[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def get_current_user(authorization: str = Header(...)) -> 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
|
# Parse Bearer token
|
||||||
parts = authorization.split()
|
parts = authorization.split()
|
||||||
if len(parts) != 2 or parts[0].lower() != 'bearer':
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid authentication scheme"
|
detail="Invalid authentication scheme",
|
||||||
)
|
)
|
||||||
|
|
||||||
token = parts[1]
|
token = parts[1]
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
|
|
||||||
if not payload:
|
if not payload:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid token"
|
detail="Invalid token",
|
||||||
)
|
)
|
||||||
|
|
||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid token type"
|
detail="Invalid token type",
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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
@@ -2,151 +2,98 @@
|
|||||||
MTG Online Backend Application
|
MTG Online Backend Application
|
||||||
|
|
||||||
FastAPI application for the MTG Online multiplayer platform.
|
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/*
|
- Authentication: /auth/*
|
||||||
- Users: /users/*
|
- Users + Admin: /users/*
|
||||||
- Decks: /decks/*
|
- Decks: /decks/*
|
||||||
- Rooms: /rooms/*
|
- Cards + Interactions: /api/cards/*, /interactions/*
|
||||||
- Games: /games/*
|
- User Data + Imports: /api/v1/user-data/*, /api/v1/card-import/*
|
||||||
- Admin: /admin/*
|
- Games + Rooms + Refresh: /games/*, /rooms/*, /mtgjson/*
|
||||||
- MTG Cards: /api/cards/*
|
- WebSocket: /ws/game/{game_id}
|
||||||
- Card Interactions: /interactions/*
|
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.core.settings import get_settings
|
from app.core.config import get_config
|
||||||
from app.core.database import engine, mtg_engine, async_session, mtg_async_session
|
from app.core.database import engine, get_db
|
||||||
from app.routers import auth, users, decks, rooms, admin, card_router, interactions, refresh, user_data, card_import
|
from app.routers import (
|
||||||
from app.routers.games import router as games_router
|
auth_router,
|
||||||
from app.services.mtgjson_manager import MTGJSONManager
|
users_router,
|
||||||
from app.core.logging_config import configure_logging
|
decks_router,
|
||||||
|
cards_router,
|
||||||
|
user_data_router,
|
||||||
|
games_router,
|
||||||
|
)
|
||||||
|
from app.core.logging import configure_logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Application lifespan events for startup and shutdown."""
|
"""Application lifespan events for startup and shutdown."""
|
||||||
settings = get_settings()
|
config = get_config()
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
configure_logging()
|
configure_logging()
|
||||||
logger = logging.getLogger(__name__)
|
logger.info(f"MTG Online Backend starting (v{config.APP_VERSION})")
|
||||||
logger.info(f"MTG Online Backend starting (v{settings.APP_VERSION})")
|
logger.info(f"Database: {config.DATABASE_URL}")
|
||||||
logger.info(f"Database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'configured'}")
|
logger.info(f"Environment: {'development' if config.DEBUG else 'production'}")
|
||||||
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())
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down MTG Online Backend")
|
logger.info("Shutting down MTG Online Backend")
|
||||||
# Engine disposal is handled by FastAPI's shutdown events
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="MTG Online Backend API",
|
title="MTG Online Backend API",
|
||||||
description="Backend API for the MTG Online multiplayer platform",
|
description="Backend API for the MTG Online multiplayer platform",
|
||||||
version="0.2.0",
|
version="0.3.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS middleware
|
# CORS middleware
|
||||||
settings = get_settings()
|
config = get_config()
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.CORS_ORIGINS,
|
allow_origins=config.CORS_ORIGINS,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Mount all routers
|
# Mount all consolidated routers
|
||||||
app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
app.include_router(auth_router.router, prefix="/auth", tags=["Authentication"])
|
||||||
app.include_router(users.router, prefix="/users", tags=["Users"])
|
app.include_router(users_router.router, prefix="/users", tags=["Users", "Admin"])
|
||||||
app.include_router(decks.router, prefix="/decks", tags=["Decks"])
|
app.include_router(decks_router.router, prefix="/decks", tags=["Decks"])
|
||||||
app.include_router(rooms.router, prefix="/rooms", tags=["Rooms"])
|
app.include_router(cards_router.card_router, tags=["MTG Cards"])
|
||||||
app.include_router(games_router, prefix="/games", tags=["Games"])
|
app.include_router(cards_router.interaction_router, tags=["Card Interactions"])
|
||||||
app.include_router(admin.router, prefix="/admin", tags=["Admin"])
|
app.include_router(user_data_router.user_data_router, prefix="/api/v1/user-data", tags=["User Data"])
|
||||||
app.include_router(card_router.router, tags=["MTG Cards"])
|
app.include_router(user_data_router.card_import_router, prefix="/api/v1/card-import", tags=["Card Import"])
|
||||||
app.include_router(interactions.router)
|
app.include_router(games_router.rooms_router, prefix="/rooms", tags=["Rooms"])
|
||||||
app.include_router(refresh.router)
|
app.include_router(games_router.games_router, prefix="/games", tags=["Games"])
|
||||||
app.include_router(user_data.router, prefix="/api/v1/user-data", tags=["User Data"])
|
app.include_router(games_router.mtgjson_router, prefix="/mtgjson", tags=["MTGJSON"])
|
||||||
app.include_router(card_import.router, prefix="/api/v1/card-import", tags=["Card Import"])
|
app.include_router(games_router.router, tags=["WebSocket"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", tags=["Health"])
|
@app.get("/health", tags=["Health"])
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint with full service status."""
|
"""Health check endpoint with database status."""
|
||||||
from app.services.mtgjson_manager import get_manager
|
health_status = {
|
||||||
|
"status": "healthy",
|
||||||
|
"version": config.APP_VERSION,
|
||||||
|
}
|
||||||
|
|
||||||
health_status = {"status": "healthy", "version": settings.APP_VERSION}
|
# Check database
|
||||||
|
|
||||||
# Check MTGJSON status
|
|
||||||
try:
|
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:
|
async with engine.connect() as conn:
|
||||||
await conn.execute(text("SELECT 1"))
|
await conn.execute(text("SELECT 1"))
|
||||||
health_status["database"] = "healthy"
|
health_status["database"] = "healthy"
|
||||||
@@ -154,19 +101,6 @@ async def health_check():
|
|||||||
health_status["database"] = {"status": "unhealthy", "error": str(e)}
|
health_status["database"] = {"status": "unhealthy", "error": str(e)}
|
||||||
health_status["status"] = "unhealthy"
|
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
|
return health_status
|
||||||
|
|
||||||
|
|
||||||
@@ -174,7 +108,7 @@ async def health_check():
|
|||||||
async def root():
|
async def root():
|
||||||
"""Root endpoint with API information."""
|
"""Root endpoint with API information."""
|
||||||
return {
|
return {
|
||||||
"name": settings.APP_NAME,
|
"name": config.APP_NAME,
|
||||||
"version": settings.APP_VERSION,
|
"version": config.APP_VERSION,
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
@@ -1,56 +1,30 @@
|
|||||||
"""Models package initialization."""
|
"""Models package initialization.
|
||||||
from app.models.models import User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog
|
|
||||||
from app.models.mtg_models import MtgSet, MtgCard
|
|
||||||
from app.models.mirror_models import MtgCardMirror, DeckCardLink
|
|
||||||
from app.models.user_data import (
|
|
||||||
UserSession, DeckVersion, GameReplay, ReplayPlayer,
|
|
||||||
GameOutcome, UserStatistics, UserCardCollection, CardWishlist,
|
|
||||||
UserGroup, GroupMember, GroupChatMessage, UserNetwork,
|
|
||||||
NetworkMember, UserPreference, UserActivityLog
|
|
||||||
)
|
|
||||||
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
|
|
||||||
from app.models.card_import_batch import CardImportBatch
|
|
||||||
from app.models.user_card_import_record import UserCardImportRecord
|
|
||||||
from app.models.user_card_import import UserCardImport
|
|
||||||
|
|
||||||
# Import relationships LAST to avoid circular imports
|
Consolidated from 10+ fragmented model files into 2 main modules:
|
||||||
# This defines cross-model references (e.g., DecklistFile.card_links)
|
- models.py: Application models (users, decks, sessions, groups, etc.)
|
||||||
from app.models import relationships
|
- mtg_models.py: MTG card data models (sets, cards, mirrors, links)
|
||||||
|
"""
|
||||||
|
from app.models.models import (
|
||||||
|
User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog,
|
||||||
|
MtgonlineCard,
|
||||||
|
UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion,
|
||||||
|
UserSession, DeckVersion, GameReplay, ReplayPlayer, GameOutcome,
|
||||||
|
UserStatistics, UserCardCollection, CardWishlist,
|
||||||
|
UserGroup, GroupMember, GroupChatMessage,
|
||||||
|
UserNetwork, NetworkMember,
|
||||||
|
UserPreference, UserActivityLog,
|
||||||
|
CardImportBatch, UserCardImportRecord, UserCardImport,
|
||||||
|
)
|
||||||
|
from app.models.mtg_models import MtgSet, MtgCard, MtgCardMirror, DeckCardLink
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User", "DecklistFile", "DecklistFolder", "Room", "RoomGameType",
|
||||||
"DecklistFile",
|
"Ban", "GameLog", "AuditLog", "MtgSet", "MtgCard", "MtgCardMirror",
|
||||||
"DecklistFolder",
|
"DeckCardLink", "MtgonlineCard", "UserSession", "DeckVersion",
|
||||||
"Room",
|
"GameReplay", "ReplayPlayer", "GameOutcome", "UserStatistics",
|
||||||
"RoomGameType",
|
"UserCardCollection", "CardWishlist", "UserGroup", "GroupMember",
|
||||||
"Ban",
|
"GroupChatMessage", "UserNetwork", "NetworkMember", "UserPreference",
|
||||||
"GameLog",
|
"UserActivityLog", "UserDeck", "UserDeckCard", "DeckPrecedent",
|
||||||
"AuditLog",
|
"DeckPrecedentCard", "CardSuggestion", "CardImportBatch",
|
||||||
"MtgSet",
|
"UserCardImportRecord", "UserCardImport",
|
||||||
"MtgCard",
|
|
||||||
"MtgCardMirror",
|
|
||||||
"DeckCardLink",
|
|
||||||
"UserSession",
|
|
||||||
"DeckVersion",
|
|
||||||
"GameReplay",
|
|
||||||
"ReplayPlayer",
|
|
||||||
"GameOutcome",
|
|
||||||
"UserStatistics",
|
|
||||||
"UserCardCollection",
|
|
||||||
"CardWishlist",
|
|
||||||
"UserGroup",
|
|
||||||
"GroupMember",
|
|
||||||
"GroupChatMessage",
|
|
||||||
"UserNetwork",
|
|
||||||
"NetworkMember",
|
|
||||||
"UserPreference",
|
|
||||||
"UserActivityLog",
|
|
||||||
"UserDeck",
|
|
||||||
"UserDeckCard",
|
|
||||||
"DeckPrecedent",
|
|
||||||
"DeckPrecedentCard",
|
|
||||||
"CardSuggestion",
|
|
||||||
"CardImportBatch",
|
|
||||||
"UserCardImportRecord",
|
|
||||||
"UserCardImport",
|
|
||||||
]
|
]
|
||||||
|
|||||||
+805
-65
@@ -1,14 +1,35 @@
|
|||||||
"""
|
"""
|
||||||
SQLAlchemy ORM models for the MTG Online database.
|
SQLAlchemy ORM models for the MTG Online application.
|
||||||
|
|
||||||
Mirrors the original MySQL schema with modern PostgreSQL features.
|
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
|
from sqlalchemy import (
|
||||||
|
Column, Integer, String, BigInteger, Boolean, DateTime, Text,
|
||||||
|
ForeignKey, Index, UniqueConstraint, Float, JSON,
|
||||||
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core Models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""User account model."""
|
"""User account model."""
|
||||||
__tablename__ = "mtgonline_users"
|
__tablename__ = "mtgonline_users"
|
||||||
@@ -38,65 +59,17 @@ class User(Base):
|
|||||||
last_login = Column(DateTime, nullable=True)
|
last_login = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
decklist_files = relationship("DecklistFile", back_populates="owner", cascade="all, delete-orphan")
|
decklist_files = relationship(
|
||||||
decklist_folders = relationship("DecklistFolder", back_populates="owner", cascade="all, delete-orphan")
|
"DecklistFile", back_populates="owner", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
decklist_folders = relationship(
|
||||||
|
"DecklistFolder", back_populates="owner", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<User {self.username} (ID: {self.id})>"
|
return f"<User {self.username} (ID: {self.id})>"
|
||||||
|
|
||||||
|
|
||||||
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})>"
|
|
||||||
|
|
||||||
|
|
||||||
class DecklistFolder(Base):
|
class DecklistFolder(Base):
|
||||||
"""User deck folder."""
|
"""User deck folder."""
|
||||||
__tablename__ = "mtgonline_decklist_folders"
|
__tablename__ = "mtgonline_decklist_folders"
|
||||||
@@ -109,13 +82,17 @@ class DecklistFolder(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
owner = relationship("User", back_populates="decklist_folders")
|
owner = relationship("User", back_populates="decklist_folders")
|
||||||
children = relationship("DecklistFolder", back_populates="parent", cascade="all, delete-orphan")
|
children = relationship(
|
||||||
parent = relationship("DecklistFolder", back_populates="children", remote_side=[id])
|
"DecklistFolder", back_populates="parent", cascade="all, delete-orphan"
|
||||||
files = relationship("DecklistFile", back_populates="folder", 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(
|
user_decks = relationship(
|
||||||
"UserDeck",
|
"UserDeck", back_populates="folder", lazy="select"
|
||||||
back_populates="folder",
|
|
||||||
lazy="select"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,7 +109,7 @@ class DecklistFile(Base):
|
|||||||
status = Column(String(20), default="DRAUGHT") # 'DRAUGHT' or 'FINAL'
|
status = Column(String(20), default="DRAUGHT") # 'DRAUGHT' or 'FINAL'
|
||||||
creation_date = Column(DateTime, server_default=func.now())
|
creation_date = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
# Relationships
|
# Relationships (card_links is added below after DeckCardLink is defined)
|
||||||
folder = relationship("DecklistFolder", back_populates="files")
|
folder = relationship("DecklistFolder", back_populates="files")
|
||||||
owner = relationship("User", back_populates="decklist_files")
|
owner = relationship("User", back_populates="decklist_files")
|
||||||
|
|
||||||
@@ -152,7 +129,9 @@ class Room(Base):
|
|||||||
creation_date = Column(DateTime, server_default=func.now())
|
creation_date = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
# Game types available in this room
|
# Game types available in this room
|
||||||
game_types = relationship("RoomGameType", back_populates="room", cascade="all, delete-orphan")
|
game_types = relationship(
|
||||||
|
"RoomGameType", back_populates="room", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RoomGameType(Base):
|
class RoomGameType(Base):
|
||||||
@@ -221,7 +200,768 @@ class AuditLog(Base):
|
|||||||
target_user = relationship("User", foreign_keys=[target_user_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
|
# Indexes for performance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Index("idx_decks_owner", DecklistFile.owner_id)
|
Index("idx_decks_owner", DecklistFile.owner_id)
|
||||||
Index("idx_decks_folder", DecklistFile.folder_id)
|
Index("idx_decks_folder", DecklistFile.folder_id)
|
||||||
Index("idx_bans_active", Ban.active)
|
Index("idx_bans_active", Ban.active)
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
"""
|
"""
|
||||||
SQLAlchemy ORM models for the MTG database (mtgjson.com data).
|
SQLAlchemy ORM models for MTG card data and mirrors.
|
||||||
|
|
||||||
Models mirror the mtg_cards and mtg_sets tables in the MTG PostgreSQL database.
|
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
|
from sqlalchemy import (
|
||||||
|
Column, Integer, String, Text, DateTime, ForeignKey, Index,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MTG Set and Card (canonical data from mtgjson.com)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class MtgSet(Base):
|
class MtgSet(Base):
|
||||||
"""MTG Set model."""
|
"""MTG Set model."""
|
||||||
__tablename__ = "mtg_sets"
|
__tablename__ = "mtg_sets"
|
||||||
@@ -65,7 +80,105 @@ class MtgCard(Base):
|
|||||||
return f"<MtgCard {self.name} ({self.set_id})>"
|
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
|
# Indexes for performance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Index("idx_mtg_cards_name_set", MtgCard.name, MtgCard.set_id)
|
Index("idx_mtg_cards_name_set", MtgCard.name, MtgCard.set_id)
|
||||||
Index("idx_mtg_cards_type", MtgCard.type_line)
|
Index("idx_mtg_cards_type", MtgCard.type_line)
|
||||||
Index("idx_mtg_cards_rarity", MtgCard.rarity)
|
Index("idx_mtg_cards_rarity", MtgCard.rarity)
|
||||||
|
|||||||
@@ -3,29 +3,27 @@ Router package exports.
|
|||||||
|
|
||||||
All routers are mounted in app/main.py.
|
All routers are mounted in app/main.py.
|
||||||
This package provides centralized access to all router modules.
|
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 auth_router
|
||||||
from app.routers import users
|
from app.routers import users_router
|
||||||
from app.routers import decks
|
from app.routers import decks_router
|
||||||
from app.routers import rooms
|
from app.routers import cards_router
|
||||||
from app.routers import games
|
from app.routers import user_data_router
|
||||||
from app.routers import admin
|
from app.routers import games_router
|
||||||
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
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"auth",
|
"auth_router",
|
||||||
"users",
|
"users_router",
|
||||||
"decks",
|
"decks_router",
|
||||||
"rooms",
|
"cards_router",
|
||||||
"games",
|
"user_data_router",
|
||||||
"admin",
|
"games_router",
|
||||||
"card_router",
|
|
||||||
"interactions",
|
|
||||||
"refresh",
|
|
||||||
"card_import",
|
|
||||||
"user_data",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
"""Authentication router endpoints."""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
Authentication Router
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select
|
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 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.database import get_db
|
||||||
from app.core.security import (
|
from app.core.security import (
|
||||||
verify_password,
|
|
||||||
hash_password,
|
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
decode_token,
|
decode_token,
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
)
|
)
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
from app.schemas.schemas import (
|
from app.schemas.schemas import (
|
||||||
@@ -28,8 +44,27 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
|
async def login(
|
||||||
"""Authenticate user and return JWT tokens."""
|
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
|
# Find user by username
|
||||||
stmt = select(User).where(User.username == request.username)
|
stmt = select(User).where(User.username == request.username)
|
||||||
result = await db.execute(stmt)
|
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)
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
async def refresh_token(request: RefreshTokenRequest, db: AsyncSession = Depends(get_db)):
|
async def refresh_token(
|
||||||
"""Refresh access token using 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)
|
payload = decode_token(request.refresh_token)
|
||||||
|
|
||||||
if not payload or payload.get("type") != "refresh":
|
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)
|
@router.post("/register", response_model=UserResponse)
|
||||||
async def register(request: UserCreate, db: AsyncSession = Depends(get_db)):
|
async def register(
|
||||||
"""Register a new user account."""
|
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
|
# Check if username exists
|
||||||
stmt = select(User).where(User.username == request.username)
|
stmt = select(User).where(User.username == request.username)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
@@ -138,8 +207,23 @@ async def register(request: UserCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
token: str,
|
token: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
) -> UserResponse:
|
||||||
"""Get current authenticated user."""
|
"""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)
|
payload = decode_token(token)
|
||||||
|
|
||||||
if not payload or payload.get("type") != "access":
|
if not payload or payload.get("type") != "access":
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
This module provides two FastAPI routers for the MTG Online card database:
|
||||||
and getting recommendations based on card interactions.
|
|
||||||
|
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 fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from app.core.database import mtg_get_db
|
from app.core.database import mtg_get_db
|
||||||
from app.core.redis_client import cache_get, cache_set
|
from app.core.redis_client import cache_get, cache_set
|
||||||
|
from app.models.mtg_models import MtgCard
|
||||||
router = APIRouter(prefix="/interactions", tags=["Card Interactions"])
|
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(
|
async def get_card_synergies(
|
||||||
card_id: int,
|
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"),
|
min_strength: int = Query(1, ge=1, le=5, description="Minimum synergy strength"),
|
||||||
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
|
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
|
||||||
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
||||||
db: AsyncSession = Depends(mtg_get_db),
|
db: AsyncSession = Depends(mtg_get_db),
|
||||||
):
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get synergies for a specific card.
|
Get synergies for a specific card.
|
||||||
|
|
||||||
@@ -37,8 +350,8 @@ async def get_card_synergies(
|
|||||||
return {"cached": True, "results": cached}
|
return {"cached": True, "results": cached}
|
||||||
|
|
||||||
# Build query
|
# Build query
|
||||||
conditions = ["card_a_id = :card_id OR card_b_id = :card_id"]
|
conditions: list[str] = ["card_a_id = :card_id OR card_b_id = :card_id"]
|
||||||
params = {"card_id": card_id}
|
params: Dict[str, Any] = {"card_id": card_id}
|
||||||
|
|
||||||
if synergy_type:
|
if synergy_type:
|
||||||
conditions.append("synergy_type = :synergy_type")
|
conditions.append("synergy_type = :synergy_type")
|
||||||
@@ -64,7 +377,7 @@ async def get_card_synergies(
|
|||||||
result = await db.execute(text(query), params)
|
result = await db.execute(text(query), params)
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
|
|
||||||
synergies = []
|
synergies: List[Dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
synergies.append({
|
synergies.append({
|
||||||
"id": row[0],
|
"id": row[0],
|
||||||
@@ -95,21 +408,23 @@ async def get_card_synergies(
|
|||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Error fetching synergies: {str(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(
|
async def get_card_counters(
|
||||||
card_id: int,
|
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"),
|
min_strength: int = Query(1, ge=1, le=5, description="Minimum counter strength"),
|
||||||
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
|
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
|
||||||
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
||||||
db: AsyncSession = Depends(mtg_get_db),
|
db: AsyncSession = Depends(mtg_get_db),
|
||||||
):
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get counters for a specific card.
|
Get counters for a specific card.
|
||||||
|
|
||||||
@@ -121,8 +436,8 @@ async def get_card_counters(
|
|||||||
if cached:
|
if cached:
|
||||||
return {"cached": True, "results": cached}
|
return {"cached": True, "results": cached}
|
||||||
|
|
||||||
conditions = ["card_a_id = :card_id OR card_b_id = :card_id"]
|
conditions: list[str] = ["card_a_id = :card_id OR card_b_id = :card_id"]
|
||||||
params = {"card_id": card_id}
|
params: Dict[str, Any] = {"card_id": card_id}
|
||||||
|
|
||||||
if counter_type:
|
if counter_type:
|
||||||
conditions.append("counter_type = :counter_type")
|
conditions.append("counter_type = :counter_type")
|
||||||
@@ -147,7 +462,7 @@ async def get_card_counters(
|
|||||||
result = await db.execute(text(query), params)
|
result = await db.execute(text(query), params)
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
|
|
||||||
counters = []
|
counters: List[Dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
counters.append({
|
counters.append({
|
||||||
"id": row[0],
|
"id": row[0],
|
||||||
@@ -176,19 +491,21 @@ async def get_card_counters(
|
|||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/evolutions/{card_id}")
|
@interaction_router.get("/evolutions/{card_id}")
|
||||||
async def get_card_evolutions(
|
async def get_card_evolutions(
|
||||||
card_id: int,
|
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"),
|
min_strength: int = Query(1, ge=1, le=5, description="Minimum strength"),
|
||||||
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
|
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
|
||||||
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
||||||
db: AsyncSession = Depends(mtg_get_db),
|
db: AsyncSession = Depends(mtg_get_db),
|
||||||
):
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get evolutions for a specific card.
|
Get evolutions for a specific card.
|
||||||
|
|
||||||
@@ -200,8 +517,8 @@ async def get_card_evolutions(
|
|||||||
if cached:
|
if cached:
|
||||||
return {"cached": True, "results": cached}
|
return {"cached": True, "results": cached}
|
||||||
|
|
||||||
conditions = ["card_id = :card_id"]
|
conditions: list[str] = ["card_id = :card_id"]
|
||||||
params = {"card_id": card_id}
|
params: Dict[str, Any] = {"card_id": card_id}
|
||||||
|
|
||||||
if evolution_type:
|
if evolution_type:
|
||||||
conditions.append("evolution_type = :evolution_type")
|
conditions.append("evolution_type = :evolution_type")
|
||||||
@@ -226,7 +543,7 @@ async def get_card_evolutions(
|
|||||||
result = await db.execute(text(query), params)
|
result = await db.execute(text(query), params)
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
|
|
||||||
evolutions = []
|
evolutions: List[Dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
evolutions.append({
|
evolutions.append({
|
||||||
"id": row[0],
|
"id": row[0],
|
||||||
@@ -255,17 +572,19 @@ async def get_card_evolutions(
|
|||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recommend/{card_id}")
|
@interaction_router.get("/recommend/{card_id}")
|
||||||
async def get_card_recommendations(
|
async def get_card_recommendations(
|
||||||
card_id: int,
|
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"),
|
limit: int = Query(10, ge=1, le=100, description="Maximum results"),
|
||||||
db: AsyncSession = Depends(mtg_get_db),
|
db: AsyncSession = Depends(mtg_get_db),
|
||||||
):
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get interaction recommendations for a card.
|
Get interaction recommendations for a card.
|
||||||
|
|
||||||
@@ -316,14 +635,16 @@ async def get_card_recommendations(
|
|||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
else:
|
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)
|
result = await db.execute(text(query), params)
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
|
|
||||||
recommendations = []
|
recommendations: List[Dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
recommendations.append({
|
recommendations.append({
|
||||||
"recommended_card_id": row[0],
|
"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(
|
async def search_synergies(
|
||||||
card_a_id: Optional[int] = Query(None, description="Card A ID"),
|
card_a_id: Optional[int] = Query(None, description="Card A ID"),
|
||||||
card_b_id: Optional[int] = Query(None, description="Card B 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"),
|
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
|
||||||
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
||||||
db: AsyncSession = Depends(mtg_get_db),
|
db: AsyncSession = Depends(mtg_get_db),
|
||||||
):
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Search synergies with multiple filters.
|
Search synergies with multiple filters.
|
||||||
"""
|
"""
|
||||||
@@ -361,8 +682,8 @@ async def search_synergies(
|
|||||||
if cached:
|
if cached:
|
||||||
return {"cached": True, "results": cached}
|
return {"cached": True, "results": cached}
|
||||||
|
|
||||||
conditions = []
|
conditions: list[str] = []
|
||||||
params = {}
|
params: Dict[str, Any] = {}
|
||||||
|
|
||||||
if card_a_id:
|
if card_a_id:
|
||||||
conditions.append("card_a_id = :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)
|
result = await db.execute(text(query), params)
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
|
|
||||||
synergies = []
|
synergies: List[Dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
synergies.append({
|
synergies.append({
|
||||||
"id": row[0],
|
"id": row[0],
|
||||||
@@ -428,11 +749,11 @@ async def search_synergies(
|
|||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search/counters")
|
@interaction_router.get("/search/counters")
|
||||||
async def search_counters(
|
async def search_counters(
|
||||||
card_a_id: Optional[int] = Query(None, description="Card A ID"),
|
card_a_id: Optional[int] = Query(None, description="Card A ID"),
|
||||||
card_b_id: Optional[int] = Query(None, description="Card B 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"),
|
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
|
||||||
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
||||||
db: AsyncSession = Depends(mtg_get_db),
|
db: AsyncSession = Depends(mtg_get_db),
|
||||||
):
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Search counters with multiple filters.
|
Search counters with multiple filters.
|
||||||
"""
|
"""
|
||||||
@@ -452,8 +773,8 @@ async def search_counters(
|
|||||||
if cached:
|
if cached:
|
||||||
return {"cached": True, "results": cached}
|
return {"cached": True, "results": cached}
|
||||||
|
|
||||||
conditions = []
|
conditions: list[str] = []
|
||||||
params = {}
|
params: Dict[str, Any] = {}
|
||||||
|
|
||||||
if card_a_id:
|
if card_a_id:
|
||||||
conditions.append("card_a_id = :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)
|
result = await db.execute(text(query), params)
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
|
|
||||||
counters = []
|
counters: List[Dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
counters.append({
|
counters.append({
|
||||||
"id": row[0],
|
"id": row[0],
|
||||||
@@ -519,11 +840,11 @@ async def search_counters(
|
|||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search/evolutions")
|
@interaction_router.get("/search/evolutions")
|
||||||
async def search_evolutions(
|
async def search_evolutions(
|
||||||
card_id: Optional[int] = Query(None, description="Card ID"),
|
card_id: Optional[int] = Query(None, description="Card ID"),
|
||||||
evolved_card_id: Optional[int] = Query(None, description="Evolved 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"),
|
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
|
||||||
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
||||||
db: AsyncSession = Depends(mtg_get_db),
|
db: AsyncSession = Depends(mtg_get_db),
|
||||||
):
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Search evolutions with multiple filters.
|
Search evolutions with multiple filters.
|
||||||
"""
|
"""
|
||||||
@@ -543,8 +864,8 @@ async def search_evolutions(
|
|||||||
if cached:
|
if cached:
|
||||||
return {"cached": True, "results": cached}
|
return {"cached": True, "results": cached}
|
||||||
|
|
||||||
conditions = []
|
conditions: list[str] = []
|
||||||
params = {}
|
params: Dict[str, Any] = {}
|
||||||
|
|
||||||
if card_id:
|
if card_id:
|
||||||
conditions.append("card_id = :card_id")
|
conditions.append("card_id = :card_id")
|
||||||
@@ -581,7 +902,7 @@ async def search_evolutions(
|
|||||||
result = await db.execute(text(query), params)
|
result = await db.execute(text(query), params)
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
|
|
||||||
evolutions = []
|
evolutions: List[Dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
evolutions.append({
|
evolutions.append({
|
||||||
"id": row[0],
|
"id": row[0],
|
||||||
@@ -610,15 +931,15 @@ async def search_evolutions(
|
|||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats/{card_id}")
|
@interaction_router.get("/stats/{card_id}")
|
||||||
async def get_card_interaction_stats(
|
async def get_card_interaction_stats(
|
||||||
card_id: int,
|
card_id: int,
|
||||||
db: AsyncSession = Depends(mtg_get_db),
|
db: AsyncSession = Depends(mtg_get_db),
|
||||||
):
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get aggregated interaction statistics for a card.
|
Get aggregated interaction statistics for a card.
|
||||||
"""
|
"""
|
||||||
@@ -639,7 +960,7 @@ async def get_card_interaction_stats(
|
|||||||
FROM mtg_card_interaction_stats
|
FROM mtg_card_interaction_stats
|
||||||
WHERE card_id = :card_id
|
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)
|
result = await db.execute(text(query), params)
|
||||||
row = result.fetchone()
|
row = result.fetchone()
|
||||||
@@ -654,10 +975,10 @@ async def get_card_interaction_stats(
|
|||||||
"total_evolutions": 0,
|
"total_evolutions": 0,
|
||||||
"total_synergy_strength": 0,
|
"total_synergy_strength": 0,
|
||||||
"avg_synergy_strength": 0,
|
"avg_synergy_strength": 0,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
stats = {
|
stats: Dict[str, Any] = {
|
||||||
"card_id": row[0],
|
"card_id": row[0],
|
||||||
"total_synergies": row[1],
|
"total_synergies": row[1],
|
||||||
"total_counters": row[2],
|
"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,
|
This router provides the full deck building workflow for MTG Online users,
|
||||||
deck finalization, precedent templates, and card search integration.
|
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 __future__ import annotations
|
||||||
from sqlalchemy import select, func, or_, update, delete
|
|
||||||
from typing import Optional, List
|
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.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.mtg_models import MtgCard, MtgSet
|
||||||
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
|
from app.models.models import MtgonlineCard, User, DecklistFolder
|
||||||
from app.schemas.user_deck_schemas import (
|
from app.models.models import (
|
||||||
UserDeckCreate, UserDeckUpdate, UserDeckResponse, UserDeckListResponse,
|
CardSuggestion,
|
||||||
DeckCardCreate, DeckCardUpdate, DeckCardResponse, DeckCardWithDetailsResponse, DeckCardListResponse,
|
DeckPrecedent,
|
||||||
PrecedentCreate, PrecedentUpdate, PrecedentResponse, PrecedentListResponse,
|
DeckPrecedentCard,
|
||||||
SuggestionCreate, SuggestionResponse, SuggestionListResponse,
|
UserDeck,
|
||||||
DeckFinalizeRequest, DeckFinalizeResponse,
|
UserDeckCard,
|
||||||
CardSearchRequest,
|
|
||||||
)
|
)
|
||||||
from app.schemas.card_search_schemas import CardSearchResponse
|
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_manager import DeckManager
|
||||||
from app.services.deck_parser import DeckParser
|
from app.services.deck_parser import DeckParser
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_deck_mgr = DeckManager()
|
_deck_mgr: DeckManager = DeckManager()
|
||||||
_deck_parser = DeckParser()
|
_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)
|
@router.get("/", response_model=UserDeckListResponse)
|
||||||
async def list_user_decks(
|
async def list_user_decks(
|
||||||
@@ -44,9 +161,13 @@ async def list_user_decks(
|
|||||||
page_size: int = Query(50, ge=1, le=100),
|
page_size: int = Query(50, ge=1, le=100),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> UserDeckListResponse:
|
||||||
"""List user's decks with optional filtering."""
|
"""List user's decks with optional filtering.
|
||||||
user_id = int(current_user["user_id"])
|
|
||||||
|
Supports filtering by deck status, folder, and whether the deck is a
|
||||||
|
precedent. Results are paginated.
|
||||||
|
"""
|
||||||
|
user_id = _get_user_id(current_user)
|
||||||
try:
|
try:
|
||||||
decks = await _deck_mgr.list_decks(
|
decks = await _deck_mgr.list_decks(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -60,10 +181,10 @@ async def list_user_decks(
|
|||||||
|
|
||||||
# Build response with card counts
|
# Build response with card counts
|
||||||
deck_ids = [d.id for d in decks]
|
deck_ids = [d.id for d in decks]
|
||||||
card_counts = {}
|
card_counts: dict[int, int] = {}
|
||||||
if deck_ids:
|
if deck_ids:
|
||||||
count_subquery = (
|
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))
|
.where(UserDeckCard.deck_id.in_(deck_ids))
|
||||||
.group_by(UserDeckCard.deck_id)
|
.group_by(UserDeckCard.deck_id)
|
||||||
.subquery()
|
.subquery()
|
||||||
@@ -74,7 +195,7 @@ async def list_user_decks(
|
|||||||
count_result = await db.execute(count_stmt)
|
count_result = await db.execute(count_stmt)
|
||||||
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
|
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
|
||||||
|
|
||||||
deck_responses = []
|
deck_responses: list[UserDeckResponse] = []
|
||||||
for deck in decks:
|
for deck in decks:
|
||||||
deck_data = UserDeckResponse.model_validate(deck)
|
deck_data = UserDeckResponse.model_validate(deck)
|
||||||
deck_data.card_count = card_counts.get(deck.id, 0)
|
deck_data.card_count = card_counts.get(deck.id, 0)
|
||||||
@@ -98,9 +219,9 @@ async def create_user_deck(
|
|||||||
request: UserDeckCreate,
|
request: UserDeckCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> UserDeckResponse:
|
||||||
"""Create a new user deck (DRAFT status)."""
|
"""Create a new user deck in DRAFT status."""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = _get_user_id(current_user)
|
||||||
try:
|
try:
|
||||||
new_deck = await _deck_mgr.create_deck(
|
new_deck = await _deck_mgr.create_deck(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -129,23 +250,17 @@ async def get_user_deck(
|
|||||||
deck_id: int,
|
deck_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> UserDeckResponse:
|
||||||
"""Get a specific user deck."""
|
"""Get a specific user deck by ID."""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = _get_user_id(current_user)
|
||||||
try:
|
try:
|
||||||
deck = await _deck_mgr.get_deck(db=db, deck_id=deck_id, user_id=user_id)
|
deck = await _deck_mgr.get_deck(db=db, deck_id=deck_id, user_id=user_id)
|
||||||
if not deck:
|
if not deck:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
|
||||||
|
|
||||||
# Get card count
|
card_count = await _get_card_count(db, deck_id)
|
||||||
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(deck)
|
return await _enrich_deck_response(deck, card_count)
|
||||||
deck_data.card_count = card_count
|
|
||||||
deck_data.is_owner = True
|
|
||||||
return deck_data
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -158,9 +273,9 @@ async def update_user_deck(
|
|||||||
request: UserDeckUpdate,
|
request: UserDeckUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> UserDeckResponse:
|
||||||
"""Update a user deck."""
|
"""Update a user deck's metadata."""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = _get_user_id(current_user)
|
||||||
try:
|
try:
|
||||||
update_data = request.model_dump(exclude_unset=True)
|
update_data = request.model_dump(exclude_unset=True)
|
||||||
if "status" in update_data and update_data["status"]:
|
if "status" in update_data and update_data["status"]:
|
||||||
@@ -176,15 +291,8 @@ async def update_user_deck(
|
|||||||
if not updated_deck:
|
if not updated_deck:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
|
||||||
|
|
||||||
# Get card count
|
card_count = await _get_card_count(db, deck_id)
|
||||||
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
|
return await _enrich_deck_response(updated_deck, card_count)
|
||||||
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
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -198,9 +306,9 @@ async def delete_user_deck(
|
|||||||
deck_id: int,
|
deck_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> MessageResponse:
|
||||||
"""Delete a user deck."""
|
"""Delete a user deck."""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = _get_user_id(current_user)
|
||||||
try:
|
try:
|
||||||
result = await _deck_mgr.delete_deck(db=db, deck_id=deck_id, user_id=user_id)
|
result = await _deck_mgr.delete_deck(db=db, deck_id=deck_id, user_id=user_id)
|
||||||
if not result:
|
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))
|
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)
|
@router.post("/{deck_id}/finalize", response_model=DeckFinalizeResponse)
|
||||||
async def finalize_user_deck(
|
async def finalize_user_deck(
|
||||||
deck_id: int,
|
deck_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> DeckFinalizeResponse:
|
||||||
"""Transition a deck from DRAFT to FINAL status."""
|
"""Transition a deck from DRAFT to FINAL status."""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = _get_user_id(current_user)
|
||||||
try:
|
try:
|
||||||
deck = await _deck_mgr.finalize_deck(db=db, deck_id=deck_id, user_id=user_id)
|
deck = await _deck_mgr.finalize_deck(db=db, deck_id=deck_id, user_id=user_id)
|
||||||
if not deck:
|
if not deck:
|
||||||
@@ -239,7 +349,9 @@ async def finalize_user_deck(
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
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)
|
@router.post("/{deck_id}/cards", response_model=DeckCardResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def add_deck_card(
|
async def add_deck_card(
|
||||||
@@ -247,16 +359,15 @@ async def add_deck_card(
|
|||||||
request: DeckCardCreate,
|
request: DeckCardCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> DeckCardResponse:
|
||||||
"""Add a card to a user's deck."""
|
"""Add a card to a user's deck.
|
||||||
user_id = int(current_user["user_id"])
|
|
||||||
|
|
||||||
# Verify deck exists and belongs to user
|
If a card already exists in the same zone, the quantity is incremented
|
||||||
deck_stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
|
rather than creating a duplicate entry.
|
||||||
deck_result = await db.execute(deck_stmt)
|
"""
|
||||||
deck = deck_result.scalar_one_or_none()
|
user_id = _get_user_id(current_user)
|
||||||
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":
|
if deck.status == "FINAL":
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
|
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,
|
zone: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> DeckCardListResponse:
|
||||||
"""Get all cards in a user's deck."""
|
"""Get all cards in a user's deck, optionally filtered by zone."""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = _get_user_id(current_user)
|
||||||
|
|
||||||
# Verify deck exists and belongs to user
|
await _verify_deck_ownership(db, deck_id, user_id)
|
||||||
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")
|
|
||||||
|
|
||||||
# Use service to get cards
|
# Use service to get cards
|
||||||
deck_cards = await _deck_mgr.get_deck_cards(db=db, deck_id=deck_id, zone=zone)
|
deck_cards = await _deck_mgr.get_deck_cards(db=db, deck_id=deck_id, zone=zone)
|
||||||
|
|
||||||
# Fetch card details from local mirror
|
# Fetch card details from local mirror
|
||||||
card_ids = [dc.card_id for dc in deck_cards]
|
card_ids = [dc.card_id for dc in deck_cards]
|
||||||
card_details = {}
|
card_details: dict[int, MtgonlineCard] = {}
|
||||||
if card_ids:
|
if card_ids:
|
||||||
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
|
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
|
||||||
card_result = await db.execute(card_stmt)
|
card_result = await db.execute(card_stmt)
|
||||||
for c in card_result.scalars().all():
|
for c in card_result.scalars().all():
|
||||||
card_details[c.id] = c
|
card_details[c.id] = c
|
||||||
|
|
||||||
card_responses = []
|
card_responses: list[DeckCardWithDetailsResponse] = []
|
||||||
for dc in deck_cards:
|
for dc in deck_cards:
|
||||||
card = card_details.get(dc.card_id)
|
card = card_details.get(dc.card_id)
|
||||||
response = DeckCardWithDetailsResponse.model_validate(dc)
|
response = DeckCardWithDetailsResponse.model_validate(dc)
|
||||||
@@ -351,17 +456,11 @@ async def update_deck_card(
|
|||||||
request: DeckCardUpdate,
|
request: DeckCardUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> DeckCardResponse:
|
||||||
"""Update a card's quantity or zone in a deck."""
|
"""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
|
deck = await _verify_deck_ownership(db, deck_id, user_id)
|
||||||
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")
|
|
||||||
|
|
||||||
if deck.status == "FINAL":
|
if deck.status == "FINAL":
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
|
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,
|
card_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> MessageResponse:
|
||||||
"""Remove a card from a user's deck."""
|
"""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
|
deck = await _verify_deck_ownership(db, deck_id, user_id)
|
||||||
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")
|
|
||||||
|
|
||||||
if deck.status == "FINAL":
|
if deck.status == "FINAL":
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
|
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")
|
return MessageResponse(message="Card removed from deck")
|
||||||
|
|
||||||
|
|
||||||
# ===== Deck Precedents =====
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deck Precedents
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
# Note: Precedent endpoints use direct DB operations as DeckManager
|
# Note: Precedent endpoints use direct DB operations as DeckManager
|
||||||
# does not yet have precedent-specific methods.
|
# does not yet have precedent-specific methods.
|
||||||
|
|
||||||
@@ -441,11 +536,11 @@ async def list_precedents(
|
|||||||
format_filter: Optional[str] = None,
|
format_filter: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> PrecedentListResponse:
|
||||||
"""List available deck precedents."""
|
"""List available (public) deck precedents, paginated."""
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
conditions = [DeckPrecedent.is_public == True]
|
conditions = [DeckPrecedent.is_public == True] # noqa: E712
|
||||||
if format_filter:
|
if format_filter:
|
||||||
conditions.append(DeckPrecedent.format == format_filter)
|
conditions.append(DeckPrecedent.format == format_filter)
|
||||||
|
|
||||||
@@ -461,10 +556,10 @@ async def list_precedents(
|
|||||||
|
|
||||||
# Get card counts
|
# Get card counts
|
||||||
prec_ids = [p.id for p in precedents]
|
prec_ids = [p.id for p in precedents]
|
||||||
card_counts = {}
|
card_counts: dict[int, int] = {}
|
||||||
if prec_ids:
|
if prec_ids:
|
||||||
count_subquery = (
|
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))
|
.where(DeckPrecedentCard.precedent_id.in_(prec_ids))
|
||||||
.group_by(DeckPrecedentCard.precedent_id)
|
.group_by(DeckPrecedentCard.precedent_id)
|
||||||
.subquery()
|
.subquery()
|
||||||
@@ -475,7 +570,7 @@ async def list_precedents(
|
|||||||
count_result = await db.execute(count_stmt)
|
count_result = await db.execute(count_stmt)
|
||||||
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
|
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
|
||||||
|
|
||||||
prec_responses = []
|
prec_responses: list[PrecedentResponse] = []
|
||||||
for p in precedents:
|
for p in precedents:
|
||||||
prec_data = PrecedentResponse.model_validate(p)
|
prec_data = PrecedentResponse.model_validate(p)
|
||||||
prec_data.card_count = card_counts.get(p.id, 0)
|
prec_data.card_count = card_counts.get(p.id, 0)
|
||||||
@@ -495,9 +590,9 @@ async def create_precedent(
|
|||||||
request: PrecedentCreate,
|
request: PrecedentCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> PrecedentResponse:
|
||||||
"""Create a deck precedent (template)."""
|
"""Create a deck precedent (template)."""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = _get_user_id(current_user)
|
||||||
|
|
||||||
new_precedent = DeckPrecedent(
|
new_precedent = DeckPrecedent(
|
||||||
name=request.name,
|
name=request.name,
|
||||||
@@ -519,8 +614,8 @@ async def get_precedent(
|
|||||||
precedent_id: int,
|
precedent_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> PrecedentResponse:
|
||||||
"""Get a specific deck precedent."""
|
"""Get a specific deck precedent by ID."""
|
||||||
stmt = select(DeckPrecedent).where(DeckPrecedent.id == precedent_id)
|
stmt = select(DeckPrecedent).where(DeckPrecedent.id == precedent_id)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
precedent = result.scalar_one_or_none()
|
precedent = result.scalar_one_or_none()
|
||||||
@@ -528,10 +623,7 @@ async def get_precedent(
|
|||||||
if not precedent:
|
if not precedent:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Precedent not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Precedent not found")
|
||||||
|
|
||||||
# Get card count
|
card_count = await _get_card_count(db, precedent_id)
|
||||||
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
|
|
||||||
|
|
||||||
prec_data = PrecedentResponse.model_validate(precedent)
|
prec_data = PrecedentResponse.model_validate(precedent)
|
||||||
prec_data.card_count = card_count
|
prec_data.card_count = card_count
|
||||||
@@ -543,9 +635,9 @@ async def use_precedent(
|
|||||||
precedent_id: int,
|
precedent_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> dict:
|
||||||
"""Clone a precedent into a new draft deck for the current user."""
|
"""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:
|
try:
|
||||||
new_deck = await _deck_mgr.clone_precedent(
|
new_deck = await _deck_mgr.clone_precedent(
|
||||||
@@ -553,11 +645,12 @@ async def use_precedent(
|
|||||||
precedent_id=precedent_id,
|
precedent_id=precedent_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
card_count = len(await _deck_mgr.get_deck_cards(db=db, deck_id=new_deck.id))
|
||||||
return {
|
return {
|
||||||
"message": "Precedent cloned into new deck",
|
"message": "Precedent cloned into new deck",
|
||||||
"deck_id": new_deck.id,
|
"deck_id": new_deck.id,
|
||||||
"deck_name": new_deck.name,
|
"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:
|
except ValueError as e:
|
||||||
if "not found" in str(e).lower():
|
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))
|
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)
|
@router.post("/search/cards", response_model=CardSearchResponse)
|
||||||
async def search_cards_for_deck(
|
async def search_cards_for_deck(
|
||||||
request: CardSearchRequest,
|
request: CardSearchRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> CardSearchResponse:
|
||||||
"""Search MTG cards for use in deck building."""
|
"""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
|
offset = (request.offset // request.limit) * request.limit
|
||||||
|
|
||||||
# Search across multiple fields using local mirror
|
# Search across multiple fields using local mirror
|
||||||
@@ -593,7 +692,7 @@ async def search_cards_for_deck(
|
|||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
cards = result.scalars().all()
|
cards = result.scalars().all()
|
||||||
|
|
||||||
card_list = []
|
card_list: list[dict] = []
|
||||||
for card in cards:
|
for card in cards:
|
||||||
card_data = {
|
card_data = {
|
||||||
"id": card.id,
|
"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
|
# Note: Suggestion endpoints use direct DB operations as DeckManager
|
||||||
# does not yet have suggestion-specific methods.
|
# does not yet have suggestion-specific methods.
|
||||||
|
|
||||||
@@ -644,17 +745,11 @@ async def get_deck_suggestions(
|
|||||||
suggestion_type: Optional[str] = None,
|
suggestion_type: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> SuggestionListResponse:
|
||||||
"""Get card suggestions for a deck."""
|
"""Get card suggestions for a deck, optionally filtered by type."""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = _get_user_id(current_user)
|
||||||
|
|
||||||
# Verify deck exists and belongs to user
|
await _verify_deck_ownership(db, deck_id, user_id)
|
||||||
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")
|
|
||||||
|
|
||||||
conditions = [CardSuggestion.deck_id == deck_id]
|
conditions = [CardSuggestion.deck_id == deck_id]
|
||||||
if suggestion_type:
|
if suggestion_type:
|
||||||
@@ -667,14 +762,14 @@ async def get_deck_suggestions(
|
|||||||
|
|
||||||
# Fetch card names from local mirror
|
# Fetch card names from local mirror
|
||||||
card_ids = [s.card_id for s in suggestions]
|
card_ids = [s.card_id for s in suggestions]
|
||||||
card_names = {}
|
card_names: dict[int, str] = {}
|
||||||
if card_ids:
|
if card_ids:
|
||||||
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
|
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
|
||||||
card_result = await db.execute(card_stmt)
|
card_result = await db.execute(card_stmt)
|
||||||
for c in card_result.scalars().all():
|
for c in card_result.scalars().all():
|
||||||
card_names[c.id] = c.name
|
card_names[c.id] = c.name
|
||||||
|
|
||||||
sugg_responses = []
|
sugg_responses: list[SuggestionResponse] = []
|
||||||
for s in suggestions:
|
for s in suggestions:
|
||||||
sugg_data = SuggestionResponse.model_validate(s)
|
sugg_data = SuggestionResponse.model_validate(s)
|
||||||
sugg_data.card_name = card_names.get(s.card_id, f"Card#{s.card_id}")
|
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,
|
request: SuggestionCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
) -> SuggestionResponse:
|
||||||
"""Add a card suggestion to a deck."""
|
"""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
|
deck = await _verify_deck_ownership(db, deck_id, user_id)
|
||||||
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")
|
|
||||||
|
|
||||||
if deck.status == "FINAL":
|
if deck.status == "FINAL":
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
|
||||||
@@ -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"}
|
|
||||||
@@ -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)
|
||||||
@@ -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",
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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"}
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
@@ -65,15 +65,6 @@ from app.schemas.mtg_card_schemas import (
|
|||||||
MtgSetResponse, MtgCardMirrorResponse,
|
MtgSetResponse, MtgCardMirrorResponse,
|
||||||
DeckCardLinkResponse as DeckCardLinkResponseV2, DeckWithCardsResponse as DeckWithCardsResponseV2,
|
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__ = [
|
__all__ = [
|
||||||
# Auth
|
# Auth
|
||||||
@@ -137,11 +128,4 @@ __all__ = [
|
|||||||
"MtgCardResponse", "MtgCardSearchRequest", "MtgCardSearchResponse",
|
"MtgCardResponse", "MtgCardSearchRequest", "MtgCardSearchResponse",
|
||||||
"MtgSetResponse", "MtgCardMirrorResponse",
|
"MtgSetResponse", "MtgCardMirrorResponse",
|
||||||
"DeckCardLinkResponseV2", "DeckWithCardsResponseV2",
|
"DeckCardLinkResponseV2", "DeckWithCardsResponseV2",
|
||||||
# Proto Messages
|
|
||||||
"ProtoMessageBase", "SessionCommand", "GameCommand", "GameEvent", "Response",
|
|
||||||
"ServerInfoUser", "ServerInfoDeckStorageFile", "ServerInfoDeckStorageFolder",
|
|
||||||
"ServerInfoDeckStorageTreeItem", "ServerInfoCard", "ServerInfoZone", "ServerInfoGame",
|
|
||||||
# Protocol Constants
|
|
||||||
"SessionCommandType", "GameCommandType", "GameEventType", "ResponseCode",
|
|
||||||
"ZoneType", "UserLevelFlag",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
"""Services package initialization."""
|
"""Services package initialization."""
|
||||||
from app.services.deck_parser import DeckParser
|
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_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_manager import MTGJSONManager, get_manager
|
||||||
from app.services.mtgjson_downloader import download_all_files, verify_downloads, get_file_list
|
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.file_parser import FileParser
|
||||||
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
|
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
|
||||||
from app.services.import_batch_processor import ImportBatchProcessor
|
from app.services.import_batch_processor import ImportBatchProcessor
|
||||||
@@ -23,34 +20,11 @@ __all__ = [
|
|||||||
"get_sets",
|
"get_sets",
|
||||||
"get_set_by_code",
|
"get_set_by_code",
|
||||||
"get_card_statistics",
|
"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",
|
"MTGJSONManager",
|
||||||
"get_manager",
|
"get_manager",
|
||||||
"download_all_files",
|
"download_all_files",
|
||||||
"verify_downloads",
|
"verify_downloads",
|
||||||
"get_file_list",
|
"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",
|
"FileParser",
|
||||||
"FuzzyCardMatcher",
|
"FuzzyCardMatcher",
|
||||||
"ImportBatchProcessor",
|
"ImportBatchProcessor",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select, or_, and_
|
from sqlalchemy import select, or_, and_
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.models.mtg_models import MtgCard, MtgSet
|
from app.models.mtg_models import MtgCard, MtgSet, MtgCardMirror
|
||||||
from app.models.mirror_models import MtgCardMirror
|
|
||||||
|
|
||||||
|
|
||||||
class CardSearchService:
|
class CardSearchService:
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select, update, delete
|
from sqlalchemy import select, update, delete
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard
|
from app.models.models import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, MtgonlineCard
|
||||||
from app.models.models import MtgonlineCard
|
|
||||||
|
|
||||||
|
|
||||||
class DeckManager:
|
class DeckManager:
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select, or_, and_, func, case
|
from sqlalchemy import select, or_, and_, func, case
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.models.user_deck import UserDeck, UserDeckCard, CardSuggestion
|
from app.models.models import UserDeck, UserDeckCard, CardSuggestion
|
||||||
from app.models.mtg_models import MtgCard
|
from app.models.mtg_models import MtgCard, MtgCardMirror
|
||||||
from app.models.mirror_models import MtgCardMirror
|
|
||||||
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
|
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 import select, update, insert, delete
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from app.models.card_import_batch import CardImportBatch
|
from app.models.models import CardImportBatch, UserCardImportRecord, UserCardCollection, MtgonlineCard
|
||||||
from app.models.user_card_import_record import UserCardImportRecord
|
|
||||||
from app.models.user_data import UserCardCollection
|
|
||||||
from app.models.models import MtgonlineCard
|
|
||||||
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
|
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
+67
-73
@@ -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.",
|
"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": [
|
"roadmap": [
|
||||||
{"phase": 1, "name": "Core Infrastructure", "status": "completed", "description": "Project setup, database, authentication, basic models"},
|
{"phase": "1", "status": "completed", "description": "Architecture simplification: unified database config, security, settings"},
|
||||||
{"phase": 2, "name": "Model Layer", "status": "completed", "description": "All SQLAlchemy models with proper relationships and constraints"},
|
{"phase": "2", "status": "completed", "description": "Model consolidation: 10+ files → 2 (models.py, mtg_models.py)"},
|
||||||
{"phase": 3, "name": "Schema Layer", "status": "completed", "description": "Pydantic v2 schemas matching models, deduplication, validation"},
|
{"phase": "3", "status": "completed", "description": "Router consolidation and cleanup: 54+ files → 37 files"},
|
||||||
{"phase": 4, "name": "Router Layer", "status": "completed", "description": "FastAPI routes with schema validation and error handling"},
|
{"phase": "4", "status": "pending", "description": "Docker packaging"}
|
||||||
{"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": {
|
"tech_stack": ["Python 3.12", "FastAPI", "SQLAlchemy 2.0", "PostgreSQL", "bcrypt", "PyJWT"],
|
||||||
"language": "Python 3.12",
|
"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."
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"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": {
|
"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.",
|
"task_description": "Phase 3: Complete architecture simplification - consolidate routers, remove duplicates, create startup script",
|
||||||
"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.",
|
"current_step": "All phases complete. Backend simplified from 54+ files to 37 files with clean architecture.",
|
||||||
"files_created": [
|
"files_created": [
|
||||||
"app/schemas/generic_schemas.py",
|
"backend/run.py (simple startup script)",
|
||||||
"test_routers.py",
|
"backend/app/core/config.py (configuration management)",
|
||||||
"test_schema_validation.py",
|
"backend/app/core/logging.py (logging configuration)",
|
||||||
".env",
|
"backend/app/routers/auth_router.py (consolidated auth)",
|
||||||
"app/core/logging_config.py",
|
"backend/app/routers/users_router.py (consolidated users)",
|
||||||
"scripts/docker-entrypoint.sh"
|
"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": [
|
"files_modified": [
|
||||||
"alembic.ini",
|
"backend/app/main.py (updated router registrations)",
|
||||||
"app/core/settings.py",
|
"backend/app/core/database.py (unified engine, backward compat aliases)",
|
||||||
"app/main.py",
|
"backend/app/core/redis_client.py (use config instead of settings)",
|
||||||
"app/routers/__init__.py",
|
"backend/app/core/security.py (updated imports)",
|
||||||
"app/routers/card_import.py",
|
"backend/app/routers/__init__.py (updated exports)",
|
||||||
"app/routers/card_router.py",
|
"backend/app/schemas/__init__.py (removed stale imports)",
|
||||||
"app/routers/decks.py",
|
"backend/app/services/__init__.py (removed stale imports)",
|
||||||
"app/routers/user_data.py",
|
"backend/app/services/card_search_service.py (updated model imports)",
|
||||||
"app/schemas/__init__.py",
|
"backend/app/services/deck_manager.py (updated model imports)",
|
||||||
"app/schemas/card_import_schemas.py",
|
"backend/app/services/deck_suggestion_service.py (updated model imports)",
|
||||||
"app/schemas/card_search_schemas.py",
|
"backend/app/services/import_batch_processor.py (updated model imports)"
|
||||||
"app/schemas/game_schemas.py",
|
],
|
||||||
"app/schemas/mtg_card_schemas.py",
|
"files_deleted": [
|
||||||
"app/schemas/schemas.py",
|
"backend/app/routers/auth.py",
|
||||||
"app/schemas/user_card_collection.py",
|
"backend/app/routers/users.py",
|
||||||
"app/schemas/user_data_schemas.py",
|
"backend/app/routers/decks.py",
|
||||||
"app/schemas/user_deck_schemas.py",
|
"backend/app/routers/card_router.py",
|
||||||
"Dockerfile",
|
"backend/app/routers/interactions.py",
|
||||||
"docker-compose.yml"
|
"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": [
|
"decisions": [
|
||||||
"Switched from psycopg2 to asyncpg for async SQLAlchemy support",
|
"All 11 old router files removed, replaced with 6 consolidated routers",
|
||||||
"Fixed router registration in main.py - removed duplicate prefixes",
|
"Duplicate core files (logging_config.py, settings.py) removed",
|
||||||
"Added user_data export to routers/__init__.py",
|
"Unused services removed (card_mirror_service, mtgjson_loader, mtgjson_uploader)",
|
||||||
"Refactored decks router to use DeckManager service layer",
|
"Unused schemas removed (proto_messages, protocol_constants)",
|
||||||
"Integrated FuzzyCardMatcher into card_router search endpoints",
|
"Created run.py for simple startup: python3 run.py",
|
||||||
"Made WishlistCreate.card_id optional for proper schema validation",
|
"SQLite by default for development, PostgreSQL via DATABASE_URL",
|
||||||
"Set PostgreSQL password and configured scram-sha-256 auth",
|
"All model imports updated to use consolidated app.models.models",
|
||||||
"Updated alembic.ini to use local PostgreSQL instead of Docker hostname",
|
"Backward compatibility aliases added to database.py for old import paths"
|
||||||
"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"
|
|
||||||
],
|
],
|
||||||
"next_steps": [
|
"next_steps": [
|
||||||
"Rebuild and restart Docker containers with fixed settings.py",
|
"Phase 4: Docker packaging (Dockerfile, docker-compose.yml)",
|
||||||
"Test all API endpoints with authenticated users",
|
"Add comprehensive tests",
|
||||||
"Phase 8: Performance Testing - Load testing, stress testing, optimization",
|
"Implement remaining game endpoints",
|
||||||
"Deploy to production environment"
|
"Add WebSocket game server integration"
|
||||||
],
|
],
|
||||||
"blockers": [],
|
"blockers": null,
|
||||||
"commit_hash": "d8f9526",
|
"commit_hash": null,
|
||||||
"timestamp": "2026-08-26T03:07:30Z"
|
"timestamp": "2025-08-26T15:15:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user