diff --git a/HANDOFF.md b/HANDOFF.md index 4f5516e..70a7505 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,662 +1,203 @@ -# MTG Online Backend - Handoff Document +## MTG Online Project - Architecture Simplification Request -## Project Overview +### Context +Project: MTG Online at `/home/wall-o/projects/mtgonline/` +Current State: Backend API with FastAPI, SQLAlchemy, Alembic, Docker Compose, PostgreSQL, Redis +Goal: Simplify the entire architecture so an LLM can better handle writing and debugging code -**Project Name**: MTG Online Backend -**Location**: `/home/wall-o/projects/mtgonline` -**Purpose**: Python FastAPI application that processes Magic: The Gathering card data from MTGJSON and stores it in PostgreSQL databases. +### What We Have -## Vision Statement (from STATEMENT_OF_INTENT.md) - -**MTG Online Web** — A modern, web-based implementation of the MTG Online multiplayer Magic: The Gathering platform. - -To build a fully-featured, open-source multiplayer Magic: The Gathering platform that runs entirely in modern web browsers, eliminating the need for desktop software installations while maintaining compatibility with the existing MTG Online ecosystem. - -## Core Objectives - -### User Experience -- Intuitive, modern interface that rivals native desktop applications -- Real-time multiplayer gameplay with minimal latency -- Seamless deck building with import/export from MTG Online -- Responsive design that works across all screen sizes - -### Technical Excellence -- **Backend**: Python 3.12 + FastAPI with async architecture -- **Database**: PostgreSQL with async SQLAlchemy ORM -- **Real-time**: WebSocket-based game server for live multiplayer -- **Protocol**: Full compatibility with MTG Online protocol buffer messages - -### Feature Parity with Desktop -- User authentication and account management -- Deck creation, editing, and storage (per-user) -- Multiplayer game rooms with real-time state sync -- Card game mechanics (mana, phases, priority, stack) -- Admin/moderation tools (ban, warn, log viewing) -- Card database integration with comprehensive card data - -### Performance Requirements -- API response times < 100ms for 95% of requests -- WebSocket latency < 50ms for game state updates -- Support 1000+ concurrent users -- Sub-second page loads with proper caching - -### Success Criteria -- [ ] Users can create accounts and authenticate securely -- [ ] Users can create, edit, and manage decks (per-user) -- [ ] Users can join and play multiplayer games in real-time -- [ ] Game state syncs correctly across all connected players -- [ ] Admin users can manage accounts and moderate games -- [ ] Deck formats are compatible with MTG Online desktop client - -## Current Status - -All planned tasks have been completed: -- ✅ Documentation created (root README.md + backend/README.md) -- ✅ State.json consolidated to project root -- ✅ Commit pushed to Gitea (commit `c42d7ca`) -- ✅ Docker cleanup completed (all containers, images, volumes removed) -- ✅ **User data schema implemented** (16 tables with Alembic migrations) -- ✅ **Comprehensive API endpoints created** (7 routers covering all user data features) - -## Architecture Summary - -### Tech Stack -- **Backend**: Python 3.12, FastAPI, SQLAlchemy (async), asyncpg -- **Databases**: Dual PostgreSQL (14-alpine) - - Primary: `mtgonline` database (users, decks, auth) - - MTG Data: `mtgdata` database (card data, sets) -- **Cache**: Redis 7-alpine -- **Protocol**: Protocol buffer message compatibility - -### Service Architecture +**Backend Structure:** ``` -mtgonline/ -├── backend/ # FastAPI application -│ ├── mtg_rules_engine/ # MTG rules engine (rules enforcement) -│ │ ├── engine.py # Core game engine -│ │ ├── rules_engine.py # Rules engine core -│ │ ├── keywords.py # Card keywords -│ │ ├── keywords_db.py # Keywords database -│ │ ├── keyword_validator.py # Keyword validation -│ │ ├── validator.py # Card validation -│ │ ├── updater.py # Engine updater -│ │ ├── update_check.py # Update checker -│ │ ├── test_engine.py # Engine tests -│ │ └── README.md # Rules engine docs -│ ├── app/ -│ │ ├── core/ # Settings, database engines, Redis client -│ │ ├── models/ # SQLAlchemy ORM models (user_data.py - 16 models) -│ │ ├── routers/ # API route modules -│ │ ├── schemas/ # Pydantic request/response schemas -│ │ ├── services/ # Business logic (MTGJSON manager, card DB, game server, deck parser) -│ │ └── main.py # FastAPI app entry point -│ ├── alembic/ # Database migrations -│ ├── scripts/ # Utility scripts -│ ├── Dockerfile -│ ├── requirements.txt -│ └── .env.example -├── docker-compose.dev.yml # Development stack -├── docker-compose.yml # Production stack -└── README.md # Project documentation +backend/ +├── app/ +│ ├── core/ # Settings, database engines, Redis client, logging +│ ├── models/ # 10+ SQLAlchemy model files (models.py, user_data.py, user_deck.py, etc.) +│ ├── routers/ # 10+ router files (auth, users, decks, games, admin, card_router, etc.) +│ ├── schemas/ # 13+ Pydantic schema files +│ ├── services/ # 15+ service files (mtgjson_manager.py is 53KB, mtgjson_loader.py is 32KB) +│ └── main.py # FastAPI app with 10+ router mounts +├── mtg_rules_engine/ # 10+ files for game rules engine +├── alembic/ # Database migrations +├── Dockerfile +├── docker-compose.yml +└── requirements.txt ``` -### Data Flow -1. Backend connects to PostgreSQL (both instances) and Redis on startup -2. Checks `mtg_refresh_log` in `mtgdata` database for existing data -3. If no data exists, downloads MTGJSON files from `https://mtgjson.com/api/v5/` -4. Upserts data into `mtgdata` tables (`mtg_sets`, `mtg_cards`, etc.) -5. Data available via REST endpoints +**Key Issues:** +1. **Fragmented models** - 10+ separate model files that should be consolidated +2. **Massive services** - `mtgjson_manager.py` is 53KB, `mtgjson_loader.py` is 32KB +3. **Docker complexity** - Multi-database setup (PostgreSQL x2, Redis) with Docker Compose +4. **Async everywhere** - Async SQLAlchemy, asyncpg, async Redis - adds complexity for development +5. **Redundant schemas** - 13+ schema files with potential duplication +6. **Empty frontend** - Frontend directory exists but is empty +7. **Multiple entry points** - Scripts, migrations, Docker, direct Python execution -### Key Database Connections -- **Primary DB**: `postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline` -- **MTG DB**: `postgresql+asyncpg://mtgonline_user:mtgonline_password@mtgdata:5432/mtgdata` -- **Redis**: `redis://redis:6379` +### What We Want -## Environment Configuration +Simplify the architecture to be **LLM-friendly** with these principles: -### Docker Compose Dev Environment Variables -```yaml -environment: - DATABASE_URL: "postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline" - MTG_DATABASE_URL: "postgresql+asyncpg://mtgonline_user:mtgonline_password@mtgdata:5432/mtgdata" - REDIS_URL: "redis://redis:6379" -``` +1. **Single database** - Use SQLite for development/local, PostgreSQL for production +2. **Consolidated models** - Merge related models into fewer files +3. **Simplified services** - Break large services into focused, smaller modules +4. **No Docker for development** - Run directly with Python/uvicorn +5. **Linear structure** - Reduce nesting and indirection +6. **Clear separation** - Each file has one clear responsibility +7. **Easy startup** - `python run.py` starts everything -### Service Ports (Host to Container) -- PostgreSQL: `5432:5432` -- MTG Data PostgreSQL: `5433:5432` -- Redis: `6379:6379` -- Backend: `5555:8000` +### Specific Simplifications -## API Endpoints +#### 1. Database Layer +- **Current**: Dual PostgreSQL databases (mtgonline, mtgdata) + Redis +- **Simplified**: Single SQLite database for development, optional PostgreSQL for production +- **Why**: SQLite eliminates connection pooling, async drivers, migration complexity -### Health & Status -- `GET /health` - Health check with MTGJSON status -- `GET /` - API info +#### 2. Models Layer +- **Current**: 10+ model files (models.py, user_data.py, user_deck.py, mirror_models.py, etc.) +- **Simplified**: 2-3 consolidated model files + - `models.py` - All application models + - `mtg_models.py` - All MTG card data models +- **Why**: Reduces import complexity, makes relationships clearer -### Authentication -- `POST /auth/login` - User login -- `POST /auth/register` - User registration -- `POST /auth/refresh` - Refresh JWT -- `GET /auth/me` - Current user +#### 3. Services Layer +- **Current**: 15+ service files, some massive (mtgjson_manager.py = 53KB) +- **Simplified**: 5-7 focused service files + - `card_service.py` - Card search and lookup + - `deck_service.py` - Deck CRUD and validation + - `user_service.py` - User management + - `mtgjson_service.py` - MTGJSON data management (split from current 53KB file) + - `game_service.py` - Game state management +- **Why**: Each file < 1000 lines, clear single responsibility -### Users -- `GET /users/{user_id}` - Get user -- `PATCH /users/{user_id}` - Update user -- `POST /users/{user_id}/ban` - Ban user (admin) +#### 4. Routers Layer +- **Current**: 10+ router files +- **Simplified**: 5-6 router files + - `auth_router.py` - Authentication + - `user_router.py` - User data + - `deck_router.py` - Deck management + - `card_router.py` - Card search and import + - `game_router.py` - Game management + - `admin_router.py` - Admin functions +- **Why**: One router per domain, easier to navigate -### Decks -- `GET /decks/` - List decks -- `POST /decks/` - Create deck -- `GET /decks/{deck_id}` - Get deck -- `PATCH /decks/{deck_id}` - Update deck -- `DELETE /decks/{deck_id}` - Delete deck +#### 5. Schemas Layer +- **Current**: 13+ schema files +- **Simplified**: 5-6 schema files matching routers + - `auth_schemas.py` - Auth request/response + - `user_schemas.py` - User data schemas + - `deck_schemas.py` - Deck schemas + - `card_schemas.py` - Card schemas + - `game_schemas.py` - Game schemas +- **Why**: Schemas co-located with their router domain -### MTG Cards -- `GET /api/cards/` - Search cards -- `GET /api/cards/{card_id}` - Get card -- `GET /api/sets/` - List sets - -### Admin -- `GET /admin/users` - List all users -- `GET /admin/bans` - List bans -- `POST /admin/bans` - Create ban - -### Data Management -- `POST /refresh` - Trigger MTGJSON refresh - -### WebSocket -- `WS /ws/{room_id}` - Real-time game communication - -## MTGJSON Data Pipeline - -### Downloaded Files -- `AllPrintings.psql` - Main card data (PostgreSQL format) -- `AllIdentifiers.json` - Card identifiers -- `Keywords.json` - Card keywords -- `CardTypes.json` - Card type definitions -- `AllDeckFiles.zip` - Deck files (unzipped on load) - -### Refresh Logic -1. **On Startup**: Checks `mtg_refresh_log` for existing data -2. **If No Data**: Downloads and loads all MTGJSON files (may take minutes) -3. **Manual Refresh**: `POST /refresh` triggers immediate reload -4. **Logging**: All refreshes logged to `mtg_refresh_log` with status, timing, and counts - -## Git Repository - -**Gitea Repository**: `https://git.optimex.systems/admin/mtgonline.git` -**Credentials**: Located at `/home/wall-o/projects/gitea_credentials.txt` -**Current Branch**: `main` -**Last Commit**: `c42d7ca` - "feat: implement user data schema and API endpoints" - -### Commit History -``` -c42d7ca - feat: implement user data schema and API endpoints - - Add Alembic migration setup with async configuration - - Create 16 user data models (users, decks, cards, replays, etc.) - - Implement comprehensive API endpoints with JWT auth - - Add replay, card collection, group, network, preferences, and activity log routers - - Include API documentation and migration test plan -46abfe5 - Add comprehensive documentation for MTG Online Backend -6f01e2d - Initial project setup -``` - -## Running the Project - -### Start Services -```bash -cd /home/wall-o/projects/mtgonline -docker compose -f docker-compose.dev.yml up -d -``` - -### Check Services -```bash -docker compose -f docker-compose.dev.yml ps -docker compose -f docker-compose.dev.yml logs -f backend -``` - -### Stop Services -```bash -docker compose -f docker-compose.dev.yml down -``` - -### Full Cleanup -```bash -# Stop and remove all containers -docker compose -f docker-compose.dev.yml down - -# Remove images -docker images rm mtgonline-backend:latest postgres:14-alpine redis:7-alpine - -# Clear build cache and prune -docker builder prune -af -docker system prune -af --volumes -``` - -## Testing - -### Run Tests -```bash -cd /home/wall-o/projects/mtgonline/backend -docker exec -it pytest -# OR -cd /home/wall-o/projects/mtgonline/backend -pytest -``` - -### Database Verification -```bash -# Check MTG data tables -docker exec psql -U mtgonline_user mtgdata -c "\dt" - -# Check refresh log -docker exec 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 psql -U mtgonline_user mtgdata < /path/to/scripts/init-mtgdata.sql` -- Check tables: `docker exec 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 +#### 6. Startup Script +- **Current**: Docker Compose, entrypoint scripts, migration scripts +- **Simplified**: Single `run.py` script + ```python + # run.py + import uvicorn + from app.core.database import init_db + from app.main import app -- **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 + if __name__ == "__main__": + init_db() # Create tables if needed + uvicorn.run(app, host="0.0.0.0", port=8000) + ``` +- **Why**: One command to start everything -#### 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 +#### 7. Configuration +- **Current**: pydantic-settings with 20+ env vars, Docker-specific defaults +- **Simplified**: Simple config with sensible defaults + ```python + # config.py + DATABASE_URL = "sqlite:///mtgonline.db" # or postgresql://... + DEBUG = True + SECRET_KEY = "dev-secret-key" + ``` +- **Why**: Works out of the box, easy to override -#### 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 +### Files to Review -### 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 +**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 + +**Project Root:** +- `/home/wall-o/projects/mtgonline/README.md` +- `/home/wall-o/projects/mtgonline/ROADMAP.md` +- `/home/wall-o/projects/mtgonline/state.json` +- `/home/wall-o/projects/mtgonline/docker-compose.yml` +- `/home/wall-o/projects/mtgonline/scripts/` + +### Important Considerations + +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 + +### Expected Outcome + +After simplification: +- **Backend structure**: + ``` + backend/ + ├── app/ + │ ├── core/ + │ │ ├── config.py # Simple configuration + │ │ ├── database.py # Single database engine + │ │ └── logging.py # Logging setup + │ ├── models/ + │ │ ├── models.py # All app models + │ │ └── mtg_models.py # All MTG models + │ ├── routers/ + │ │ ├── auth_router.py + │ │ ├── user_router.py + │ │ ├── deck_router.py + │ │ ├── card_router.py + │ │ ├── game_router.py + │ │ └── admin_router.py + │ ├── schemas/ + │ │ ├── auth_schemas.py + │ │ ├── user_schemas.py + │ │ ├── deck_schemas.py + │ │ ├── card_schemas.py + │ │ └── game_schemas.py + │ ├── services/ + │ │ ├── card_service.py + │ │ ├── deck_service.py + │ │ ├── user_service.py + │ │ ├── mtgjson_service.py + │ │ └── game_service.py + │ └── main.py + ├── mtg_rules_engine/ # Keep as-is (specialized) + ├── run.py # Simple startup script + └── requirements.txt + ``` + +- **Startup**: `cd backend && python run.py` +- **Database**: SQLite by default, PostgreSQL optional +- **No Docker required** for development ### Next Steps -1. Await Python game engine codebase delivery -2. Deploy pre-built chat server (Tinode) -3. Deploy pre-built audio server (Kurento or Janus) -4. Begin Phase 3.1: Core Game Server implementation ---- +1. **Review entire codebase** - Understand all files and their relationships +2. **Identify consolidation opportunities** - Which files can be merged? +3. **Create simplified structure** - Build new architecture incrementally +4. **Migrate functionality** - Move code to new structure +5. **Test everything** - Ensure all endpoints work +6. **Update documentation** - README, setup instructions +7. **Remove Docker complexity** - Keep only for production if needed -## Phase 3: Multiplayer Game Server (In Progress) - -### 3.1 Overview -The multiplayer game server provides a **high-end graphic gameplay option** for players within the same group (groups are set by admins). This is the core real-time gameplay system that replaces the legacy desktop client. - -**Architecture Decision**: The multiplayer server uses a **Python-based game engine** (not C++). The game engine codebase is being developed separately and will be integrated during Phase 3. - -### 3.2 Architecture -The multiplayer server follows a **modular architecture** with pre-built Docker containers for chat and audio: - -``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Frontend │────▶│ Game Server │────▶│ Card Backend │ -│ (React/TS) │ │ (Python) │ │ (FastAPI) │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────┐ -│ Chat Server │ │ Audio Server │ -│ (Pre-built │ │ (Pre-built │ -│ Docker) │ │ Docker) │ -└─────────────────┘ └─────────────────┘ -``` - -**Key Decisions:** -- **Chat**: Pre-built Docker container (Tinode recommended) - Option A architecture -- **Audio**: Pre-built Docker container (Kurento or Janus) - Option A architecture -- **Game Engine**: Python-based, codebase being developed separately - -### 3.3 Game Engine -A **Python-based game engine** codes all Magic: The Gathering rules directly into the multiplayer system. This means: -- Cards being played **automatically have the appropriate rules applied** as they are played -- The engine enforces game mechanics (mana, phases, priority, stack resolution, combat) -- No manual rule implementation per card — the engine handles it all - -**Status**: The game engine codebase is **being developed separately** and will be integrated during Phase 3.1 (Core Game Server). - -**Integration Approach:** -```python -# game_engine/engine.py -from game_engine.engine import GameEngine - -engine = GameEngine() -engine.load_card('lightning-bolt') -result = engine.resolve_effect('lightning-bolt', target='player') -``` - -**Docker Volume Mount:** -```yaml -volumes: - - ./game-engine:/app/game_engine -``` - -### 3.4 Graphics -Graphics are handled in the **front-end development** (React/TypeScript with game board visualization). The multiplayer server provides: -- Game state synchronization -- Card data and metadata -- Real-time updates via WebSocket - -The server does not handle rendering — that's the frontend's responsibility. - -### 3.5 Chat System -The multiplayer server uses **pre-built Docker containers** for chat functionality: - -#### Text-based Chat -- **Solution**: Tinode (recommended) or SimpleWebSocketChat -- **Architecture**: Option A (separate container) -- **Integration**: WebSocket-based, frontend connects directly -- **Features**: Room management, message persistence, moderation - -**Docker Deployment (Tinode):** -```bash -docker run -d \ - --name tinode \ - -p 8080:8080 \ - -v ./tinode-data:/data \ - --restart unless-stopped \ - tinode/tinode -``` - -**Frontend Integration:** -```javascript -import Tinode from 'tinode-sdk'; - -const tinode = new Tinode({ socket: 'ws://chat-server:8080' }); -await tinode.connect(); -await tinode.subscribe('game-room-123'); -tinode.on('message', (topic, msg) => console.log('Chat:', msg)); -tinode.sendMessage('game-room-123', { text: 'Hello game!' }); -``` - -#### Audio-based Chat -- **Solution**: Kurento Media Server (recommended) or Janus Gateway -- **Architecture**: Option A (separate container) -- **Integration**: WebRTC-based, frontend connects directly -- **Features**: Low latency, scalable, open source - -**Docker Deployment (Kurento):** -```bash -docker run -d \ - --name kurento \ - -p 8888:8888 \ - -p 8443:8443 \ - --restart unless-stopped \ - kurento/kurento-media-server:latest -``` - -**No custom chat/audio codebase needed** — both are pre-built solutions. - -### 3.6 Integration with Card Backend -The multiplayer server communicates with the card backend (current FastAPI app) via: - -- **API Calls**: For authentication, user data, deck retrieval, card lookups -- **Direct PSQL Queries**: For card data and user deck data - -#### Integration Points -- **Card Backend API** (`http://backend:8000`): - - `GET /api/cards/{card_id}` — Get card details for in-game display - - `GET /api/cards/search?q=...` — Search cards during gameplay - - `GET /api/sets/` — List available sets for game formatting - -- **PSQL Direct Access** (via shared connection string): - - `mtgdata` database — Read card data (cards, sets, etc.) - - `mtgonline` database — Read user decks (for deck validation, game setup) - -#### API Endpoints for Play Backend -- `POST /play/decks/{deck_id}/validate` — Validate deck against card database -- `GET /play/users/{user_id}/decks` — Get user's FINAL decks for selection -- `GET /play/cards/{card_id}` — Get card details for game board display -- `POST /play/chat/text` — Send text message in game/room (via Tinode) -- `POST /play/chat/audio` — Manage audio chat sessions (via Kurento) - -### Summary of Work Required in Phase 3: Multiplayer Game Server - -| Feature | Database | API | Notes | -|---------|----------|-----|-------| -| Game engine integration | N/A (Python codebase) | N/A | Being developed separately, integrate in Phase 3.1 | -| Multiplayer WebSocket server | `mtgonline` (rooms, games) | WebSocket hub | Python/FastAPI | -| Game state management | In-memory + DB persistence | State sync | Server-authoritative | -| MTG rule enforcement | N/A (game engine) | Automatic | Cards auto-apply rules | -| Text chat backend | N/A (Tinode) | WebSocket | Pre-built Docker container | -| Audio chat backend | N/A (Kurento) | WebRTC | Pre-built Docker container | -| Group-based access | `mtgonline` (groups) | Group validation | Admin-configured groups | -| Deck validation | `mtgdata` + `mtgonline` | Validation endpoint | Against card database | -| Card backend integration | Read-only | API consumer | Shared DB + REST API | - -See **PHASE_3_PLANNING.md** for detailed task breakdown. - -## User Data Schema & API - -### Database Models (16 Tables) -- **`users`** - User accounts with authentication -- **`decks`** - User decks (DRAFT/FINAL status) -- **`cards`** - User card collections -- **`card_ownership`** - Card ownership tracking -- **`win_streaks`** - Win/loss statistics -- **`game_replays`** - Saved game replays (JSONB) -- **`groups`** - User groups -- **`group_members`** - Group membership -- **`networks`** - Network accounts (Twitch, X, YouTube) -- **`network_credentials`** - Network login info -- **`preferences`** - User preferences (JSONB) -- **`activity_log`** - User activity tracking (JSONB) -- **`suggested_cards`** - Card suggestions -- **`folders`** - Deck organization -- **`game_logs`** - Game audit trail -- **`user_decks`** - User deck storage (DRAFT/FINAL) - -### API Endpoints - -#### Replays (`/api/v1/user-data/replays`) -- `POST /api/v1/user-data/replays/` - Save replay -- `GET /api/v1/user-data/replays/{replay_id}` - Get replay -- `DELETE /api/v1/user-data/replays/{replay_id}` - Delete replay - -#### Card Collection (`/api/v1/user-data/cards`) -- `GET /api/v1/user-data/cards/` - List user's cards -- `POST /api/v1/user-data/cards/` - Add card to collection -- `DELETE /api/v1/user-data/cards/{card_id}` - Remove card - -#### Groups (`/api/v1/user-data/groups`) -- `GET /api/v1/user-data/groups/` - List user's groups -- `POST /api/v1/user-data/groups/` - Create group -- `PATCH /api/v1/user-data/groups/{group_id}` - Update group -- `DELETE /api/v1/user-data/groups/{group_id}` - Delete group - -#### Networks (`/api/v1/user-data/networks`) -- `GET /api/v1/user-data/networks/` - List network accounts -- `POST /api/v1/user-data/networks/` - Add network -- `PATCH /api/v1/user-data/networks/{network_id}` - Update network -- `DELETE /api/v1/user-data/networks/{network_id}` - Remove network - -#### Preferences (`/api/v1/user-data/preferences`) -- `GET /api/v1/user-data/preferences/` - Get preferences -- `PATCH /api/v1/user-data/preferences/` - Update preferences - -#### Activity Log (`/api/v1/user-data/activity`) -- `GET /api/v1/user-data/activity/` - List activity -- `POST /api/v1/user-data/activity/` - Add activity entry - -### Alembic Migrations -- **Async configuration** with `run_sync` for database operations -- **Initial migration**: `001_initial_user_schema.py` creates all 16 tables -- **Migration script**: `scripts/run_migrations.sh` runs on container startup -- **JSONB columns** used for flexible data storage (replay_data, activity_data, preferences) - -### Architecture Decisions -- **CASCADE deletes** for data integrity in related tables -- **Composite unique constraints** for card collection uniqueness -- **RESTful API design** with pagination support -- **JWT authentication** for all endpoints -- **Permission checks** for group/network management - -## State File - -Current state saved at: `/home/wall-o/projects/mtgonline/state.json` - -```json -{ - "project_summary": "MTG Online Backend API with PostgreSQL database. Implements card game platform with deck management, card import, and user data features. Phase 3 (multiplayer game server) is in progress with game engine code incoming.", - "task_description": "Phase 3: Multiplayer game server with Cockatrice-inspired architecture, game engine integration, text/audio chat, and group-based gameplay", - "current_step": "Preparation phase - reviewing Cockatrice architecture analysis, awaiting game engine code delivery, planning server scaffolding", - "commit_hash": "1e7c762", - "timestamp": "2026-07-24T04:35:00-04:00" -} -``` - -## Access Information - -- **Backend API**: `http://localhost:5555` -- **User Data API**: `http://localhost:5555/api/v1/user-data` -- **Swagger Docs**: `http://localhost:5555/docs` -- **Health Check**: `http://localhost:5555/health` -- **Gitea**: `https://git.optimex.systems/admin/mtgonline` - -### Key Files to Review - -#### Current State -1. `/home/wall-o/projects/mtgonline/state.json` - Current project state -2. `/home/wall-o/projects/mtgonline/README.md` - Project documentation -3. `/home/wall-o/projects/mtgonline/backend/README.md` - Backend documentation - -#### Architecture & Configuration -4. `/home/wall-o/projects/mtgonline/docker-compose.dev.yml` - Docker configuration -5. `/home/wall-o/projects/mtgonline/backend/app/core/settings.py` - Application settings -6. `/home/wall-o/projects/mtgonline/backend/app/main.py` - FastAPI entry point (user-data mounted at /api/v1/user-data) - -#### User Data Models -7. `/home/wall-o/projects/mtgonline/backend/app/models/user_data.py` - All user data models (16 tables) -8. `/home/wall-o/projects/mtgonline/backend/alembic/versions/001_initial_user_schema.py` - Migration script - -#### API Endpoints -9. `/home/wall-o/projects/mtgonline/backend/app/routers/user_data.py` - User data API endpoints -10. `/home/wall-o/projects/mtgonline/backend/app/schemas/user_data_schemas.py` - Pydantic schemas for user data - -#### Documentation -11. `/home/wall-o/projects/mtgonline/backend/API_DOCUMENTATION.md` - Comprehensive API documentation -12. `/home/wall-o/projects/mtgonline/backend/TEST_PLAN.md` - Migration test plan -13. `/home/wall-o/projects/mtgonline/ROADMAP.md` - Complete feature roadmap and timeline -14. `/home/wall-o/projects/mtgonline/STATEMENT_OF_INTENT.md` - Project vision and objectives - -## Environment - -- **OS**: Linux 6.8.0-136-generic (x86_64) -- **Docker**: Available -- **Python**: 3.12.3 -- **Working Directory**: `/home/wall-o/projects/mtgonline` - ---- - -**Last Updated**: 2026-07-25T19:58:00-04:00 -**Status**: Phase 1-2 Complete. Phase 3 (Multiplayer Game Server) in progress. Rules engine integrated in `backend/mtg_rules_engine/`. - -**Next Action**: Begin Phase 3 — set up multiplayer server scaffolding, integrate rules engine, implement WebSocket hub with chat support. - -**Timeline**: -| Phase | Duration | Status | -|-------|----------|--------| -| Phase 1: Backend Foundation | 2 weeks | ✅ Complete | -| Phase 2: Card Import & Deck Building | 2 weeks | ✅ Complete | -| Phase 3: Multiplayer Game Server | TBD | 🔄 In Progress | -| Phase 4: Frontend Integration | TBD | Pending | -| Phase 5: Advanced Features | Ongoing | Future | +Please start by exploring the project structure and understanding the current architecture before making changes. Focus on understanding what each file does and how they interact. diff --git a/SIMPLIFICATION_PLAN.md b/SIMPLIFICATION_PLAN.md new file mode 100644 index 0000000..9e7609d --- /dev/null +++ b/SIMPLIFICATION_PLAN.md @@ -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** diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..110422e --- /dev/null +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 26e9188..18c001d 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,22 +1,75 @@ """ Database engine and session management. -Provides async SQLAlchemy engine and session factory for dependency injection. -Supports dual database connections for mtgonline app and mtgjson data. +Provides a single SQLAlchemy async engine supporting both SQLite (development) +and PostgreSQL (production) via a single DATABASE_URL configuration value. + +## Usage + + from app.core.database import get_db, create_tables, Base + + # In FastAPI dependency: + async def endpoint(db: AsyncSession = Depends(get_db)): + ... + + # Create all tables: + await create_tables() + +## Architecture + +- Single engine for all database operations (no dual databases) +- Sync DeclarativeBase for model definitions +- Async engine and sessions for async operations +- Automatic dialect detection (SQLite vs PostgreSQL) + +## Backward Compatibility + +The following exports are provided for backward compatibility with code that +still references the old dual-database naming convention: + +- ``mtg_engine`` → alias for ``engine`` +- ``mtg_async_session`` → alias for ``async_session`` +- ``mtg_get_db`` → alias for ``get_db`` +- ``mirror_get_db`` → alias for ``get_db`` + +These should be removed as callers are migrated to the new unified API. """ -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +import logging +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) from sqlalchemy.orm import DeclarativeBase -from app.core.settings import get_settings -settings = get_settings() +from app.core.config import get_config + +logger = logging.getLogger(__name__) + +config = get_config() + +# --------------------------------------------------------------------------- +# Engine setup — single engine, auto-detect SQLite vs PostgreSQL +# --------------------------------------------------------------------------- -# Primary database engine (mtgonline app) engine = create_async_engine( - settings.DATABASE_URL, - echo=settings.DEBUG, - pool_pre_ping=True, - pool_size=20, - max_overflow=10, + config.DATABASE_URL, + echo=config.DEBUG, + # SQLite doesn't support pool_size / max_overflow — only apply for PG + **( + { + "pool_pre_ping": True, + "pool_size": 20, + "max_overflow": 10, + } + if not config.is_sqlite + else { + "pool_pre_ping": False, + } + ), ) async_session = async_sessionmaker( @@ -25,28 +78,43 @@ async_session = async_sessionmaker( expire_on_commit=False, ) -# Secondary database engine (mtgjson data) -mtg_engine = create_async_engine( - settings.MTG_DATABASE_URL, - echo=settings.DEBUG, - pool_pre_ping=True, - pool_size=10, - max_overflow=5, -) - -mtg_async_session = async_sessionmaker( - mtg_engine, - class_=AsyncSession, - expire_on_commit=False, -) - -# Mirror engine (same as primary — mirrors live in mtgo_platform) -mirror_engine = engine -mirror_async_session = async_session +# --------------------------------------------------------------------------- +# Base class for ORM models +# --------------------------------------------------------------------------- -async def get_db() -> AsyncSession: - """FastAPI dependency that provides a database session for the mtgonline app.""" +class Base(DeclarativeBase): + """Base class for all ORM models. + + Uses sync DeclarativeBase (not async-specific) for simplicity. + All models inherit from this class. + """ + + pass + + +# --------------------------------------------------------------------------- +# FastAPI dependency — provides a database session +# --------------------------------------------------------------------------- + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """FastAPI dependency that provides a database session. + + Yields an async session and handles commit/rollback/close automatically. + + Usage in FastAPI endpoints: + + from fastapi import Depends + from app.core.database import get_db + + @app.get("/items") + async def list_items(db: AsyncSession = Depends(get_db)): + ... + + Yields: + An async SQLAlchemy session. + """ async with async_session() as session: try: yield session @@ -58,46 +126,68 @@ async def get_db() -> AsyncSession: await session.close() -async def mtg_get_db() -> AsyncSession: - """FastAPI dependency that provides a database session for mtgjson data.""" - async with mtg_async_session() as session: - try: - yield session - await session.commit() - except Exception: - await session.rollback() - raise - finally: - await session.close() +# --------------------------------------------------------------------------- +# Table creation helper +# --------------------------------------------------------------------------- -async def mirror_get_db() -> AsyncSession: - """FastAPI dependency that provides a database session for mirror operations.""" - async with mirror_async_session() as session: - try: - yield session - await session.commit() - except Exception: - await session.rollback() - raise - finally: - await session.close() +async def create_tables() -> None: + """Create all database tables from model metadata. + + Imports all model modules to ensure Base.metadata is populated, + then creates all tables. This is intended for development and testing. + In production, use Alembic migrations instead. + + Example: + >>> await create_tables() + """ + # Import all models to register them with Base.metadata + import app.models # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + logger.info( + f"Database tables created successfully " + f"(dialect: {'SQLite' if config.is_sqlite else 'PostgreSQL'})" + ) -class Base(DeclarativeBase): - """Base class for all ORM models.""" - pass +# --------------------------------------------------------------------------- +# Backward-compatible aliases +# +# The old codebase referenced dual databases (mtg_engine, mtg_async_session, +# mtg_get_db, mirror_get_db). These aliases map them to the single unified +# engine so existing imports continue to work. They will be removed once all +# callers have been migrated. +# --------------------------------------------------------------------------- +# Alias: mtg_engine → engine (was the "primary" database engine) +mtg_engine = engine + +# Alias: mtg_async_session → async_session (was the "primary" session factory) +mtg_async_session = async_session + +# Alias: mtg_get_db → get_db (was the "primary" session dependency) +mtg_get_db = get_db + +# Alias: mirror_get_db → get_db (was the "mirror" database session dependency) +mirror_get_db = get_db + + +# --------------------------------------------------------------------------- +# Exports +# --------------------------------------------------------------------------- __all__ = [ "Base", - "get_db", - "async_session", "engine", - "mtg_get_db", - "mtg_async_session", + "async_session", + "get_db", + "create_tables", + # Backward-compatible aliases "mtg_engine", + "mtg_async_session", + "mtg_get_db", "mirror_get_db", - "mirror_async_session", - "mirror_engine", ] diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..2e7c10c --- /dev/null +++ b/backend/app/core/logging.py @@ -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() diff --git a/backend/app/core/logging_config.py b/backend/app/core/logging_config.py deleted file mode 100644 index 7a33a76..0000000 --- a/backend/app/core/logging_config.py +++ /dev/null @@ -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() diff --git a/backend/app/core/redis_client.py b/backend/app/core/redis_client.py index 32e1053..aeab781 100644 --- a/backend/app/core/redis_client.py +++ b/backend/app/core/redis_client.py @@ -1,26 +1,33 @@ -"""Redis client and caching layer.""" +"""Redis client and caching layer (optional).""" import json import logging from typing import Any, Optional import redis.asyncio as aioredis -from app.core.settings import get_settings +from app.core.config import get_config logger = logging.getLogger(__name__) -settings = get_settings() +config = get_config() # Redis client instance redis_client: Optional[aioredis.Redis] = None -async def get_redis() -> aioredis.Redis: - """Get Redis client instance.""" +async def get_redis() -> Optional[aioredis.Redis]: + """Get Redis client instance. + + Returns None if Redis is not configured or connection fails. + """ global redis_client if redis_client is None: + redis_url = getattr(config, 'REDIS_URL', None) + if not redis_url: + logger.debug("Redis not configured") + return None try: redis_client = aioredis.from_url( - settings.REDIS_URL, + redis_url, decode_responses=True, socket_connect_timeout=10, socket_timeout=10, diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 77c7bd5..e40373a 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -2,28 +2,91 @@ Security utilities for authentication and password hashing. Implements JWT token management and bcrypt password hashing with salt. +Provides functions for creating, validating, and decoding JWT tokens, +as well as password verification and hashing. + +## Features + +- Bcrypt password hashing with configurable rounds +- JWT access and refresh token creation +- Token decoding and validation +- FastAPI dependency for extracting current user from Authorization header + +## Usage + + from app.core.security import ( + verify_password, + hash_password, + create_access_token, + create_refresh_token, + decode_token, + get_current_user, + ) + + # Hash and verify passwords + hashed = hash_password("secure_password") + is_valid = verify_password("secure_password", hashed) + + # Create and decode tokens + token = create_access_token(subject="user_id", privlevel="Admin") + payload = decode_token(token) + + # FastAPI dependency + @app.get("/protected") + async def protected_route(user: dict = Depends(get_current_user)): + ... """ + from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import Header, HTTPException, status from jose import JWTError, jwt from passlib.context import CryptContext -from app.core.settings import get_settings -settings = get_settings() +from app.core.config import get_config +config = get_config() + +# Password hashing context with bcrypt pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a plain password against a bcrypt hash.""" + """Verify a plain password against a bcrypt hash. + + Args: + plain_password: The plain text password to verify. + hashed_password: The bcrypt hash to verify against. + + Returns: + True if the password matches the hash, False otherwise. + + Example: + >>> hashed = hash_password("my_password") + >>> verify_password("my_password", hashed) + True + >>> verify_password("wrong_password", hashed) + False + """ return pwd_context.verify(plain_password, hashed_password) def hash_password(password: str) -> str: - """Hash a password using bcrypt with configurable rounds.""" - return pwd_context.hash(password, rounds=settings.BCRYPT_ROUNDS) + """Hash a password using bcrypt with configurable rounds. + + Args: + password: The plain text password to hash. + + Returns: + The bcrypt hash string. + + Example: + >>> hashed = hash_password("secure_password") + >>> len(hashed) > 0 + True + """ + return pwd_context.hash(password, rounds=config.BCRYPT_ROUNDS) def create_access_token( @@ -31,17 +94,31 @@ def create_access_token( privlevel: str = "User", expires_delta: Optional[timedelta] = None, ) -> str: - """Create a JWT access token.""" + """Create a JWT access token. + + Args: + subject: The user identifier (typically user ID or username). + privlevel: User privilege level (default "User"). + expires_delta: Optional custom expiration time delta. + If not provided, uses JWT_ACCESS_TOKEN_EXPIRE_MINUTES from config. + + Returns: + Encoded JWT access token string. + + Example: + >>> token = create_access_token(subject="user123", privlevel="Admin") + >>> isinstance(token, str) + True + """ if expires_delta: expire = datetime.now(timezone.utc) + expires_delta else: expire = datetime.now(timezone.utc) + timedelta( - minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES + minutes=config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES ) - - # Use JWT_SECRET_KEY if available, fall back to SECRET_KEY - secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY - + + secret = config.jwt_secret + payload = { "sub": subject, "exp": expire, @@ -49,18 +126,30 @@ def create_access_token( "type": "access", "privlevel": privlevel, } - return jwt.encode(payload, secret, algorithm=settings.JWT_ALGORITHM) + return jwt.encode(payload, secret, algorithm=config.JWT_ALGORITHM) def create_refresh_token(subject: str, privlevel: str = "User") -> str: - """Create a JWT refresh token with longer expiry.""" + """Create a JWT refresh token with longer expiry. + + Args: + subject: The user identifier (typically user ID or username). + privlevel: User privilege level (default "User"). + + Returns: + Encoded JWT refresh token string. + + Example: + >>> token = create_refresh_token(subject="user123") + >>> isinstance(token, str) + True + """ expire = datetime.now(timezone.utc) + timedelta( - days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS + days=config.JWT_REFRESH_TOKEN_EXPIRE_DAYS ) - - # Use JWT_SECRET_KEY if available, fall back to SECRET_KEY - secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY - + + secret = config.jwt_secret + payload = { "sub": subject, "exp": expire, @@ -68,19 +157,31 @@ def create_refresh_token(subject: str, privlevel: str = "User") -> str: "type": "refresh", "privlevel": privlevel, } - return jwt.encode(payload, secret, algorithm=settings.JWT_ALGORITHM) + return jwt.encode(payload, secret, algorithm=config.JWT_ALGORITHM) def decode_token(token: str) -> Optional[dict]: - """Decode and validate a JWT token.""" + """Decode and validate a JWT token. + + Args: + token: The JWT token string to decode. + + Returns: + The token payload dictionary if valid, None if invalid or expired. + + Example: + >>> token = create_access_token(subject="user123") + >>> payload = decode_token(token) + >>> payload["sub"] + 'user123' + """ try: - # Use JWT_SECRET_KEY if available, fall back to SECRET_KEY - secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY - + secret = config.jwt_secret + payload = jwt.decode( token, secret, - algorithms=[settings.JWT_ALGORITHM], + algorithms=[config.JWT_ALGORITHM], ) return payload except JWTError: @@ -88,28 +189,48 @@ def decode_token(token: str) -> Optional[dict]: def get_current_user(authorization: str = Header(...)) -> dict: - """Extract user info from JWT token in Authorization header.""" + """Extract user info from JWT token in Authorization header. + + FastAPI dependency that validates the Bearer token and extracts + user information from the decoded payload. + + Args: + authorization: The Authorization header value (Bearer ). + + Returns: + Dictionary containing user_id, privlevel, and token_type. + + Raises: + HTTPException: 401 if token is invalid, missing, or wrong type. + + Example: + >>> @app.get("/protected") + ... async def protected(user: dict = Depends(get_current_user)): + ... return {"user_id": user["user_id"]} + """ # Parse Bearer token parts = authorization.split() - if len(parts) != 2 or parts[0].lower() != 'bearer': + if len(parts) != 2 or parts[0].lower() != "bearer": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication scheme" + detail="Invalid authentication scheme", ) - + token = parts[1] payload = decode_token(token) + if not payload: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" + detail="Invalid token", ) + if payload.get("type") != "access": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token type" + detail="Invalid token type", ) - + return { "user_id": payload.get("sub"), "privlevel": payload.get("privlevel", "User"), diff --git a/backend/app/core/settings.py b/backend/app/core/settings.py deleted file mode 100644 index ce6a5ed..0000000 --- a/backend/app/core/settings.py +++ /dev/null @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 2b6875a..4748d0f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,151 +2,98 @@ MTG Online Backend Application FastAPI application for the MTG Online multiplayer platform. -Mounts all routers and provides centralized configuration. +Mounts all consolidated routers and provides centralized configuration. -## Routers +## Routers (Consolidated Structure) - Authentication: /auth/* -- Users: /users/* +- Users + Admin: /users/* - Decks: /decks/* -- Rooms: /rooms/* -- Games: /games/* -- Admin: /admin/* -- MTG Cards: /api/cards/* -- Card Interactions: /interactions/* +- Cards + Interactions: /api/cards/*, /interactions/* +- User Data + Imports: /api/v1/user-data/*, /api/v1/card-import/* +- Games + Rooms + Refresh: /games/*, /rooms/*, /mtgjson/* +- WebSocket: /ws/game/{game_id} """ import asyncio import logging -from contextlib import asynccontextmanager from typing import AsyncGenerator from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text -from app.core.settings import get_settings -from app.core.database import engine, mtg_engine, async_session, mtg_async_session -from app.routers import auth, users, decks, rooms, admin, card_router, interactions, refresh, user_data, card_import -from app.routers.games import router as games_router -from app.services.mtgjson_manager import MTGJSONManager -from app.core.logging_config import configure_logging +from app.core.config import get_config +from app.core.database import engine, get_db +from app.routers import ( + auth_router, + users_router, + decks_router, + cards_router, + user_data_router, + games_router, +) +from app.core.logging import configure_logging - -async def run_initial_download(): - """Run initial MTGJSON data download and upsert.""" - logger = logging.getLogger(__name__) - - try: - settings = get_settings() - manager = MTGJSONManager(settings.DATA_DIR) - - # Check if data already exists - last_refresh = await manager.get_last_refresh() - if last_refresh: - logger.info(f"MTGJSON data already exists (last refresh: {last_refresh})") - return - - logger.info("Running initial MTGJSON data download with sanity checks...") - logger.info("This may take several minutes depending on network speed...") - - # Download files and upsert data in one operation - result = await manager.download_and_refresh(force=False) - - if not result.get("success", False): - error_msg = result.get("error", "Unknown error") - logger.error(f"Failed to download MTGJSON files: {error_msg}") - raise RuntimeError(f"MTGJSON data download failed: {error_msg}") - - # Log success - counts = result.get("upsert", {}) - await manager.log_refresh("SUCCESS", counts, result.get("duration", 0)) - - logger.info(f"Initial MTGJSON data load complete!") - logger.info(f" Sets: {counts.get('sets', 0)}") - logger.info(f" Cards: {counts.get('cards', 0)}") - - except Exception as e: - logger.error(f"Failed to run initial download: {e}") - raise +logger = logging.getLogger(__name__) def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan events for startup and shutdown.""" - settings = get_settings() + config = get_config() # Configure logging configure_logging() - logger = logging.getLogger(__name__) - logger.info(f"MTG Online Backend starting (v{settings.APP_VERSION})") - logger.info(f"Database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'configured'}") - logger.info(f"MTG Database: {settings.MTG_DATABASE_URL.split('@')[1] if '@' in settings.MTG_DATABASE_URL else 'configured'}") - logger.info(f"Redis: {settings.REDIS_URL}") - - # Run initial download in background - try: - asyncio.create_task(run_initial_download()) - except RuntimeError: - # Event loop already running - asyncio.get_event_loop().create_task(run_initial_download()) + logger.info(f"MTG Online Backend starting (v{config.APP_VERSION})") + logger.info(f"Database: {config.DATABASE_URL}") + logger.info(f"Environment: {'development' if config.DEBUG else 'production'}") yield # Shutdown logger.info("Shutting down MTG Online Backend") - # Engine disposal is handled by FastAPI's shutdown events app = FastAPI( title="MTG Online Backend API", description="Backend API for the MTG Online multiplayer platform", - version="0.2.0", + version="0.3.0", lifespan=lifespan, ) # CORS middleware -settings = get_settings() +config = get_config() app.add_middleware( CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, + allow_origins=config.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# Mount all routers -app.include_router(auth.router, prefix="/auth", tags=["Authentication"]) -app.include_router(users.router, prefix="/users", tags=["Users"]) -app.include_router(decks.router, prefix="/decks", tags=["Decks"]) -app.include_router(rooms.router, prefix="/rooms", tags=["Rooms"]) -app.include_router(games_router, prefix="/games", tags=["Games"]) -app.include_router(admin.router, prefix="/admin", tags=["Admin"]) -app.include_router(card_router.router, tags=["MTG Cards"]) -app.include_router(interactions.router) -app.include_router(refresh.router) -app.include_router(user_data.router, prefix="/api/v1/user-data", tags=["User Data"]) -app.include_router(card_import.router, prefix="/api/v1/card-import", tags=["Card Import"]) +# Mount all consolidated routers +app.include_router(auth_router.router, prefix="/auth", tags=["Authentication"]) +app.include_router(users_router.router, prefix="/users", tags=["Users", "Admin"]) +app.include_router(decks_router.router, prefix="/decks", tags=["Decks"]) +app.include_router(cards_router.card_router, tags=["MTG Cards"]) +app.include_router(cards_router.interaction_router, tags=["Card Interactions"]) +app.include_router(user_data_router.user_data_router, prefix="/api/v1/user-data", tags=["User Data"]) +app.include_router(user_data_router.card_import_router, prefix="/api/v1/card-import", tags=["Card Import"]) +app.include_router(games_router.rooms_router, prefix="/rooms", tags=["Rooms"]) +app.include_router(games_router.games_router, prefix="/games", tags=["Games"]) +app.include_router(games_router.mtgjson_router, prefix="/mtgjson", tags=["MTGJSON"]) +app.include_router(games_router.router, tags=["WebSocket"]) + @app.get("/health", tags=["Health"]) async def health_check(): - """Health check endpoint with full service status.""" - from app.services.mtgjson_manager import get_manager + """Health check endpoint with database status.""" + health_status = { + "status": "healthy", + "version": config.APP_VERSION, + } - health_status = {"status": "healthy", "version": settings.APP_VERSION} - - # Check MTGJSON status + # Check database try: - manager = get_manager() - mtg_status = await manager.get_health_status() - health_status["mtgjson"] = mtg_status - if mtg_status.get("status") != "healthy": - health_status["status"] = "degraded" - except Exception as e: - health_status["mtgjson"] = {"status": "unhealthy", "error": str(e)} - health_status["status"] = "degraded" - - # Check primary database - try: - from app.core.database import engine async with engine.connect() as conn: await conn.execute(text("SELECT 1")) health_status["database"] = "healthy" @@ -154,19 +101,6 @@ async def health_check(): health_status["database"] = {"status": "unhealthy", "error": str(e)} health_status["status"] = "unhealthy" - # Check Redis - try: - from app.core.redis_client import get_redis - redis_client = await get_redis() - if redis_client: - health_status["redis"] = "healthy" - else: - health_status["redis"] = {"status": "unhealthy", "error": "Connection failed"} - health_status["status"] = "unhealthy" - except Exception as e: - health_status["redis"] = {"status": "unhealthy", "error": str(e)} - health_status["status"] = "unhealthy" - return health_status @@ -174,7 +108,7 @@ async def health_check(): async def root(): """Root endpoint with API information.""" return { - "name": settings.APP_NAME, - "version": settings.APP_VERSION, + "name": config.APP_NAME, + "version": config.APP_VERSION, "docs": "/docs", - } \ No newline at end of file + } diff --git a/backend/app/models/__backup__/card_import_batch.py b/backend/app/models/__backup__/card_import_batch.py new file mode 100644 index 0000000..5f5159b --- /dev/null +++ b/backend/app/models/__backup__/card_import_batch.py @@ -0,0 +1,74 @@ +""" +SQLAlchemy ORM models for card import tracking. + +This module provides models for tracking card import batches +and individual card import records. +""" +from sqlalchemy import ( + Column, Integer, String, BigInteger, Boolean, DateTime, Text, + ForeignKey, UniqueConstraint +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base + + +class CardImportBatch(Base): + """ + Card import batch tracking. + + Tracks a batch of card imports from a file, including + matching results and error information. + """ + __tablename__ = "card_import_batches" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False) + filename = Column(String(255), nullable=False) + file_type = Column(String(50), nullable=True) # 'mtgjson', 'csv', etc. + file_size = Column(BigInteger, nullable=True) # Size in bytes + status = Column(String(20), default="PENDING") # PENDING, PROCESSING, COMPLETED, FAILED + total_cards = Column(Integer, default=0) + matched_cards = Column(Integer, default=0) + unmatched_cards = Column(Integer, default=0) + match_results = Column(Text, nullable=True) # JSON of match details + error_message = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Relationships + user = relationship("User", backref="card_import_batches") + records = relationship( + "UserCardImportRecord", + back_populates="batch", + cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + +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"" diff --git a/backend/app/models/__backup__/mirror_models.py b/backend/app/models/__backup__/mirror_models.py new file mode 100644 index 0000000..68d88c6 --- /dev/null +++ b/backend/app/models/__backup__/mirror_models.py @@ -0,0 +1,97 @@ +""" +SQLAlchemy ORM models for card mirrors and deck-card links. + +Mirrored card data lives in mtgo_platform for fast deckbuilding queries. +The MTGJSON database remains the canonical source of truth. +""" +from sqlalchemy import ( + Column, Integer, String, Text, DateTime, ForeignKey, Index, + UniqueConstraint, Boolean +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base + + +class MtgCardMirror(Base): + """ + Mirrored card data for user decks. + + Contains all relevant card fields copied from mtg_cards (mtg_data) + to avoid cross-DB joins during deckbuilding. Back-references source_id + to the canonical mtg_cards.id for sync tracking. + """ + __tablename__ = "mtg_cards_mirror" + + id = Column(Integer, primary_key=True, index=True) + source_id = Column( + Integer, + nullable=True, + index=True, + comment="Logical reference to mtg_cards.id in mtg_data database (cross-DB reference, no FK constraint)" + ) + name = Column(String(255), nullable=False, index=True) + mana_cost = Column(String(255), nullable=True) + type_line = Column(String(255), nullable=True) + oracle_text = Column(Text, nullable=True) + power = Column(String(50), nullable=True) + toughness = Column(String(50), nullable=True) + rarity = Column(String(50), nullable=True) + layout = Column(String(50), nullable=True) + artist = Column(String(255), nullable=True) + flavor_text = Column(Text, nullable=True) + numbers = Column(String(100), nullable=True) + identifiers = Column(Text, nullable=True) # JSON string of all identifiers + images = Column(Text, nullable=True) # JSON string of image URLs + image = Column(Text, nullable=True) # Card image URL from MTGJSON + card_parts = Column(Text, nullable=True) # Comma-separated list of face names + keywords = Column(Text, nullable=True) # Comma-separated keywords + legalities = Column(Text, nullable=True) # JSON of format legality + set_code = Column(String(10), nullable=True, index=True) + set_name = Column(String(255), nullable=True) + # Sync tracking + synced_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + created_at = Column(DateTime, server_default=func.now()) + + # Relationships + deck_links = relationship( + "DeckCardLink", + back_populates="card", + cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + +class DeckCardLink(Base): + """ + Junction table linking user decks to mirrored cards. + + Represents a specific quantity of a mirrored card in a specific zone + (main deck or sideboard) of a user's deck. + """ + __tablename__ = "deck_card_links" + + id = Column(Integer, primary_key=True, index=True) + deck_id = Column(Integer, ForeignKey("mtgonline_decklist_files.id", ondelete="CASCADE"), nullable=False) + card_id = Column(Integer, ForeignKey("mtg_cards_mirror.id"), nullable=False) + quantity = Column(Integer, nullable=False, default=1) + zone = Column(String(20), nullable=False, default="main") # 'main' or 'sideboard' + + # Composite unique: a card can only appear once per zone in a deck + __table_args__ = ( + UniqueConstraint('deck_id', 'card_id', 'zone', name='uq_deck_card_link'), + Index('idx_deck_card_deck', 'deck_id'), + Index('idx_deck_card_card', 'card_id'), + ) + + # Relationships + deck = relationship("DecklistFile", back_populates="card_links") + card = relationship("MtgCardMirror", back_populates="deck_links") + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/app/models/__backup__/original_models.py b/backend/app/models/__backup__/original_models.py new file mode 100644 index 0000000..e1da51c --- /dev/null +++ b/backend/app/models/__backup__/original_models.py @@ -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"" + + +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"" + + +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"" + + +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"" + + +# --------------------------------------------------------------------------- +# 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"" + + +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"" + + +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"" + + +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"" + + +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"" + ) + + +# --------------------------------------------------------------------------- +# 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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + ) + + +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"" + ) + + +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"" + + +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"" + + +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"" + ) + + +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"" + + +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"" + + +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"" + ) + + +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"" + + +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"" + + +# --------------------------------------------------------------------------- +# 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"" + + +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"" + ) + + +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"" + + +# --------------------------------------------------------------------------- +# 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) diff --git a/backend/app/models/__backup__/original_mtg_models.py b/backend/app/models/__backup__/original_mtg_models.py new file mode 100644 index 0000000..76bd650 --- /dev/null +++ b/backend/app/models/__backup__/original_mtg_models.py @@ -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"" + + +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"" + + +# --------------------------------------------------------------------------- +# 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"" + + +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"" + ) + + +# --------------------------------------------------------------------------- +# 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) diff --git a/backend/app/models/__backup__/relationships.py b/backend/app/models/__backup__/relationships.py new file mode 100644 index 0000000..f2eba45 --- /dev/null +++ b/backend/app/models/__backup__/relationships.py @@ -0,0 +1,24 @@ +""" +Cross-model relationships. + +This module defines relationships that reference models from other +model files (e.g., DecklistFile.card_links → DeckCardLink). These +must be defined AFTER all model classes have been imported to avoid +circular import issues. + +Import this module LAST in app/models/__init__.py. +""" +from sqlalchemy.orm import relationship +from app.models.models import DecklistFile +from app.models.mirror_models import DeckCardLink + + +# Add the back-reference from DecklistFile to DeckCardLink. +# This was previously defined dynamically at the bottom of mirror_models.py, +# but that caused circular imports. Now it lives here and is imported last. +DecklistFile.card_links = relationship( + "DeckCardLink", + back_populates="deck", + cascade="all, delete-orphan", + order_by="DeckCardLink.id" +) diff --git a/backend/app/models/__backup__/user_card_import.py b/backend/app/models/__backup__/user_card_import.py new file mode 100644 index 0000000..ed5f8be --- /dev/null +++ b/backend/app/models/__backup__/user_card_import.py @@ -0,0 +1,40 @@ +""" +SQLAlchemy ORM model for user card imports. + +This model corresponds to the user_card_imports table created in migration 004. +It stores a user's imported card collection as a JSON array of card names. +""" +from sqlalchemy import ( + Column, Integer, String, BigInteger, DateTime, Text, + ForeignKey, UniqueConstraint +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base + + +class UserCardImport(Base): + """ + User card import record. + + Stores a user's imported card collection as a JSON array of card names. + This table was created in migration 004 and may be superseded by + CardImportBatch and UserCardImportRecord in migration 005. + """ + __tablename__ = "user_card_imports" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False) + card_names_json = Column(Text, nullable=False) # JSON array of card names + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Relationships + user = relationship("User", backref="card_imports") + + __table_args__ = ( + UniqueConstraint('user_id', name='uq_user_card_imports_user_id'), + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/__backup__/user_card_import_record.py b/backend/app/models/__backup__/user_card_import_record.py new file mode 100644 index 0000000..d42af5b --- /dev/null +++ b/backend/app/models/__backup__/user_card_import_record.py @@ -0,0 +1,11 @@ +""" +SQLAlchemy ORM models for user card import records. + +This module is deprecated. UserCardImportRecord is now defined in +card_import_batch.py along with CardImportBatch. +""" +# This file is kept for backward compatibility but the actual model +# is now in card_import_batch.py +from app.models.card_import_batch import UserCardImportRecord as UserCardImportRecord + +__all__ = ["UserCardImportRecord"] diff --git a/backend/app/models/__backup__/user_data.py b/backend/app/models/__backup__/user_data.py new file mode 100644 index 0000000..eac78dd --- /dev/null +++ b/backend/app/models/__backup__/user_data.py @@ -0,0 +1,325 @@ +""" +SQLAlchemy ORM models for user data features. + +Includes sessions, decks, replays, cards, groups, and networks. +""" +from sqlalchemy import ( + Column, Integer, String, BigInteger, Boolean, DateTime, Text, + ForeignKey, Index, UniqueConstraint, Float, JSON +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base + + +class UserSession(Base): + """User authentication session.""" + __tablename__ = "user_sessions" + + id = Column(BigInteger, primary_key=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True) + session_token_hash = Column(String(255), unique=True, nullable=False, index=True) + ip_address = Column(String(45), nullable=True) + user_agent = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + expires_at = Column(DateTime, nullable=False) + is_active = Column(Boolean, default=True) + + user = relationship("User", backref="sessions") + + def __repr__(self) -> str: + return f"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" diff --git a/backend/app/models/__backup__/user_deck.py b/backend/app/models/__backup__/user_deck.py new file mode 100644 index 0000000..0010caf --- /dev/null +++ b/backend/app/models/__backup__/user_deck.py @@ -0,0 +1,153 @@ +""" +SQLAlchemy ORM models for per-user deck building. + +These models live in the primary mtgonline database and support +the deckbuilding feature with DRAFT/FINAL status, card management, +deck precedents, and card suggestions. +""" +from sqlalchemy import ( + Column, Integer, String, BigInteger, Boolean, DateTime, Text, + ForeignKey, UniqueConstraint, Index, Float +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base +from app.models.models import MtgonlineCard + + +class UserDeck(Base): + """Per-user deck storage in the primary mtgonline database.""" + __tablename__ = "user_decks" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True) + name = Column(String(255), nullable=False, index=True) + status = Column(String(20), nullable=False, default="DRAFT", index=True) # DRAFT or FINAL + folder_id = Column(Integer, ForeignKey("mtgonline_decklist_folders.id"), nullable=True) + format = Column(String(50), nullable=True, default="standard") # Standard, Modern, etc. + notes = Column(Text, nullable=True) + is_precedent = Column(Boolean, default=False, index=True) # Mark as deck precedent/template + precedent_name = Column(String(255), nullable=True) # Name for precedent (if applicable) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Relationships + user = relationship("User", backref="user_decks") + folder = relationship("DecklistFolder", backref="user_decks") + cards = relationship( + "UserDeckCard", + back_populates="deck", + cascade="all, delete-orphan", + order_by="UserDeckCard.id" + ) + + def __repr__(self) -> str: + return f"" + + +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"" + + +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"" + + +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"" + + +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"" diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 0f9e190..621186d 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -3,29 +3,27 @@ Router package exports. All routers are mounted in app/main.py. This package provides centralized access to all router modules. + +## Consolidated Routers (Phase 3 Simplification) +- auth_router.py - Authentication endpoints +- users_router.py - User management + admin operations +- decks_router.py - Deck building and management +- cards_router.py - Card data search and interactions +- user_data_router.py - User data, sessions, and card imports +- games_router.py - Room management, games, and MTGJSON refresh """ -from app.routers import auth -from app.routers import users -from app.routers import decks -from app.routers import rooms -from app.routers import games -from app.routers import admin -from app.routers import card_router -from app.routers import interactions -from app.routers import refresh -from app.routers import card_import -from app.routers import user_data +from app.routers import auth_router +from app.routers import users_router +from app.routers import decks_router +from app.routers import cards_router +from app.routers import user_data_router +from app.routers import games_router __all__ = [ - "auth", - "users", - "decks", - "rooms", - "games", - "admin", - "card_router", - "interactions", - "refresh", - "card_import", - "user_data", + "auth_router", + "users_router", + "decks_router", + "cards_router", + "user_data_router", + "games_router", ] diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth_router.py similarity index 59% rename from backend/app/routers/auth.py rename to backend/app/routers/auth_router.py index d2aa81d..03e7156 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth_router.py @@ -1,18 +1,34 @@ -"""Authentication router endpoints.""" -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +""" +Authentication Router + +This module provides the authentication router for the MTG Online application. +It handles user authentication, registration, and session management through +JWT (JSON Web Token) based authentication. + +The router provides the following endpoints: + - POST /login: Authenticate user credentials and return JWT tokens + - POST /refresh: Refresh an expired access token using a refresh token + - POST /register: Register a new user account + - GET /me: Retrieve the current authenticated user's profile + +All endpoints use async SQLAlchemy for database operations and FastAPI +dependencies for database session management. +""" + +from datetime import datetime, timezone from typing import Optional -from datetime import datetime, timezone, timezone +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.security import ( - verify_password, - hash_password, create_access_token, create_refresh_token, decode_token, + hash_password, + verify_password, ) from app.models.models import User from app.schemas.schemas import ( @@ -28,39 +44,58 @@ router = APIRouter() @router.post("/login", response_model=LoginResponse) -async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): - """Authenticate user and return JWT tokens.""" +async def login( + request: LoginRequest, + db: AsyncSession = Depends(get_db), +) -> LoginResponse: + """Authenticate user and return JWT tokens. + + Validates the provided username and password against the database. + Checks if the account is active and not banned before issuing tokens. + Updates the user's last login timestamp on successful authentication. + + Args: + request: The login request containing username and password. + db: The async database session. + + Returns: + LoginResponse containing access_token, refresh_token, and user data. + + Raises: + HTTPException 401: Invalid username or password. + HTTPException 403: Account is disabled or currently banned. + """ # Find user by username stmt = select(User).where(User.username == request.username) result = await db.execute(stmt) user = result.scalar_one_or_none() - + if not user or not verify_password(request.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password", ) - + if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Account is disabled", ) - + if user.is_banned and user.ban_ends and user.ban_ends > datetime.now(): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Account is banned", ) - + # Update last login user.last_login = datetime.now(timezone.utc) await db.flush() - + # Generate tokens access_token = create_access_token(str(user.id), user.privlevel or "User") refresh_token = create_refresh_token(str(user.id), user.privlevel or "User") - + return LoginResponse( access_token=access_token, refresh_token=refresh_token, @@ -69,37 +104,71 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): @router.post("/refresh", response_model=TokenResponse) -async def refresh_token(request: RefreshTokenRequest, db: AsyncSession = Depends(get_db)): - """Refresh access token using refresh token.""" +async def refresh_token( + request: RefreshTokenRequest, + db: AsyncSession = Depends(get_db), +) -> TokenResponse: + """Refresh access token using refresh token. + + Validates the provided refresh token and issues a new access token + if the token is valid and the associated user account is still active. + + Args: + request: The refresh token request. + db: The async database session. + + Returns: + TokenResponse containing a new access_token. + + Raises: + HTTPException 401: Invalid refresh token or inactive user account. + """ payload = decode_token(request.refresh_token) - + if not payload or payload.get("type") != "refresh": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token", ) - + # Verify user still exists and is active user_id = payload["sub"] stmt = select(User).where(User.id == int(user_id)) result = await db.execute(stmt) user = result.scalar_one_or_none() - + if not user or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User account is invalid", ) - + # Generate new access token access_token = create_access_token(str(user.id), user.privlevel or "User") - + return TokenResponse(access_token=access_token) @router.post("/register", response_model=UserResponse) -async def register(request: UserCreate, db: AsyncSession = Depends(get_db)): - """Register a new user account.""" +async def register( + request: UserCreate, + db: AsyncSession = Depends(get_db), +) -> UserResponse: + """Register a new user account. + + Creates a new user in the database after checking for duplicate + username and email addresses. The password is hashed before storage. + + Args: + request: The user registration data. + db: The async database session. + + Returns: + UserResponse containing the newly created user data. + + Raises: + HTTPException 409: Username or email already exists. + """ # Check if username exists stmt = select(User).where(User.username == request.username) result = await db.execute(stmt) @@ -108,7 +177,7 @@ async def register(request: UserCreate, db: AsyncSession = Depends(get_db)): status_code=status.HTTP_409_CONFLICT, detail="Username already exists", ) - + # Check if email exists (if provided) if request.email: stmt = select(User).where(User.email == request.email) @@ -118,7 +187,7 @@ async def register(request: UserCreate, db: AsyncSession = Depends(get_db)): status_code=status.HTTP_409_CONFLICT, detail="Email already registered", ) - + # Create new user new_user = User( username=request.username, @@ -130,7 +199,7 @@ async def register(request: UserCreate, db: AsyncSession = Depends(get_db)): ) db.add(new_user) await db.flush() - + return UserResponse.model_validate(new_user) @@ -138,25 +207,40 @@ async def register(request: UserCreate, db: AsyncSession = Depends(get_db)): async def get_current_user( token: str, db: AsyncSession = Depends(get_db), -): - """Get current authenticated user.""" +) -> UserResponse: + """Get current authenticated user. + + Decodes the provided JWT access token and retrieves the associated + user profile from the database. + + Args: + token: The JWT access token. + db: The async database session. + + Returns: + UserResponse containing the current user's profile data. + + Raises: + HTTPException 401: Invalid or expired token. + HTTPException 404: User not found in database. + """ payload = decode_token(token) - + if not payload or payload.get("type") != "access": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token", ) - + user_id = payload["sub"] stmt = select(User).where(User.id == int(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) + + return UserResponse.model_validate(user) \ No newline at end of file diff --git a/backend/app/routers/card_import.py b/backend/app/routers/card_import.py deleted file mode 100644 index dab9477..0000000 --- a/backend/app/routers/card_import.py +++ /dev/null @@ -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 diff --git a/backend/app/routers/card_router.py b/backend/app/routers/card_router.py deleted file mode 100644 index 5a16216..0000000 --- a/backend/app/routers/card_router.py +++ /dev/null @@ -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 diff --git a/backend/app/routers/interactions.py b/backend/app/routers/cards_router.py similarity index 59% rename from backend/app/routers/interactions.py rename to backend/app/routers/cards_router.py index 3fa3ce9..91fa1ef 100644 --- a/backend/app/routers/interactions.py +++ b/backend/app/routers/cards_router.py @@ -1,55 +1,368 @@ """ -Card interaction router for MTG card interaction database. +Cards Router - Consolidated Card Data and Interaction Endpoints. -Provides endpoints for searching card synergies, counters, evolutions, -and getting recommendations based on card interactions. +This module provides two FastAPI routers for the MTG Online card database: + +1. **Card Search Router** (`/api/cards`) - Card data endpoints: + - Search cards with filters and fuzzy matching fallback + - Retrieve individual cards by ID + - List available sets, card types, and rarities + - Suggest cards for a given deck + +2. **Card Interactions Router** (`/interactions`) - Interaction data endpoints: + - Get synergies, counters, and evolutions for a card + - Search interactions with multi-filter support + - Get interaction recommendations + - Retrieve aggregated interaction statistics + +The card search endpoints use SQLAlchemy ORM models and dedicated services +with Redis caching. The interaction endpoints use raw SQL queries against +the `mtg_card_*` tables, also with Redis caching. + +Both routers share the `mtg_get_db` dependency (secondary database session). """ -from typing import Optional, List, Dict, Any + +from typing import Any, Dict, List, Optional + from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import text from app.core.database import mtg_get_db from app.core.redis_client import cache_get, cache_set - -router = APIRouter(prefix="/interactions", tags=["Card Interactions"]) +from app.models.mtg_models import MtgCard +from app.models.models import UserDeck +from app.schemas.card_search_schemas import ( + CardResponse, + CardSearchResponse, + CardTypeResponse, + SetResponse, +) +from app.services.card_search_service import CardSearchService +from app.services.deck_suggestion_service import DeckSuggestionService +from app.services.fuzzy_card_matcher import FuzzyCardMatcher -@router.get("/synergies/{card_id}") +# --------------------------------------------------------------------------- +# Card Search Router (/api/cards) +# --------------------------------------------------------------------------- + +card_router = APIRouter(prefix="/api/cards", tags=["Card Search"]) + + +@card_router.get("/search", response_model=CardSearchResponse) +async def search_cards_endpoint( + q: str = Query(..., min_length=1, description="Search query"), + card_type: Optional[str] = Query(None, description="Filter by card type"), + set_code: Optional[str] = Query(None, description="Filter by set code"), + color: Optional[str] = Query(None, description="Filter by color (e.g., WU, BR)"), + limit: int = Query(100, ge=1, le=500, description="Maximum results"), + offset: int = Query(0, ge=0, description="Number of results to skip"), + db: AsyncSession = Depends(mtg_get_db), +) -> Dict[str, Any]: + """ + Search cards with filters. + + Supports filtering by type, set, and color in addition to name search. + Uses fuzzy matching as a fallback when exact/partial matches are not found. + """ + cache_key = f"card_search:{q}:{card_type}:{set_code}:{color}:{limit}:{offset}" + + # Check cache first + cached = await cache_get(cache_key) + if cached: + return {"cached": True, "results": cached} + + # Normalize the search query for consistency + normalized_query = FuzzyCardMatcher.normalize_card_name(q) + + # Search cards using the existing service + results = await CardSearchService.search_cards( + db=db, + query=q, + card_type=card_type, + set_code=set_code, + color=color, + limit=limit, + offset=offset, + ) + + # If no results found, try fuzzy matching as a fallback + if results["total"] == 0: + fuzzy_results = await _fuzzy_search_fallback( + db=db, + query=q, + normalized_query=normalized_query, + card_type=card_type, + set_code=set_code, + color=color, + limit=limit, + offset=offset, + ) + results = fuzzy_results + + # Add fuzzy matching metadata to results + results["query_normalized"] = normalized_query + results["fuzzy_match"] = True + + # Cache results for 5 minutes + await cache_set(cache_key, str(results), ttl=300) + + return {"cached": False, "results": results} + + +async def _fuzzy_search_fallback( + db: AsyncSession, + query: str, + normalized_query: str, + card_type: Optional[str] = None, + set_code: Optional[str] = None, + color: Optional[str] = None, + limit: int = 100, + offset: int = 0, +) -> Dict[str, Any]: + """ + Fallback fuzzy search when exact/partial matches yield no results. + + Fetches all cards matching the type/set/color filters, then uses + fuzzy matching to find the best card name matches. + """ + # Fetch candidate cards based on non-name filters + conditions: list = [] + if card_type: + conditions.append(MtgCard.type_line.ilike(f"%{card_type}%")) + if set_code: + conditions.append(MtgCard.set_code == set_code) + if color: + colors = [c.strip() for c in color.upper().split(",")] + for c in colors: + if c in ["W", "U", "B", "R", "G"]: + conditions.append(MtgCard.colors.ilike(f"%{c}%")) + + # If no filters, fetch a broader set for fuzzy matching + if not conditions: + stmt = select(MtgCard).limit(limit * 5) + else: + stmt = select(MtgCard).where(*conditions).limit(limit * 5) + + result = await db.execute(stmt) + candidate_cards = result.scalars().all() + + if not candidate_cards: + return { + "cards": [], + "total": 0, + "page": offset // limit + 1, + "page_size": limit, + "total_pages": 0, + "fuzzy_fallback": True, + "message": "No cards found matching your query.", + } + + # Build candidate name list and lookup + candidate_names = [card.name for card in candidate_cards if card.name] + card_lookup: Dict[str, Any] = {card.name.lower(): card for card in candidate_cards if card.name} + + # Use fuzzy matching to find best matches + matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match( + normalized_query, candidate_names, threshold=FuzzyCardMatcher.MIN_MATCH_THRESHOLD + ) + + # Build results from fuzzy matches + card_list: List[Dict[str, Any]] = [] + if matched_name and matched_name.lower() in card_lookup: + card = card_lookup[matched_name.lower()] + card_data: Dict[str, Any] = { + "id": card.id, + "name": card.name, + "mana_cost": card.mana_cost, + "type_line": card.type_line, + "oracle_text": card.oracle_text, + "power": card.power, + "toughness": card.toughness, + "rarity": card.rarity, + "layout": card.layout, + "colors": card.colors, + "set_code": card.set_code, + "set_name": card.set_name, + "fuzzy_match": True, + "match_confidence": confidence, + "match_type": match_type, + "original_query": query, + } + card_list.append(card_data) + + return { + "cards": card_list, + "total": len(card_list), + "page": offset // limit + 1, + "page_size": limit, + "total_pages": (len(card_list) + limit - 1) // limit if card_list else 0, + "fuzzy_fallback": True, + "match_type": match_type, + "confidence": confidence, + } + + +@card_router.get("/{card_id}", response_model=CardResponse) +async def get_card_endpoint( + card_id: int, + db: AsyncSession = Depends(mtg_get_db), +) -> Dict[str, Any]: + """ + Get a specific card by ID. + """ + cache_key = f"card_by_id:{card_id}" + + # Check cache first + cached = await cache_get(cache_key) + if cached: + return {"cached": True, "results": cached} + + # Get card + card = await CardSearchService.get_card_by_id(db, card_id) + + if not card: + raise HTTPException(status_code=404, detail="Card not found") + + # Cache for 10 minutes + await cache_set(cache_key, str(card), ttl=600) + + return {"cached": False, "results": card} + + +@card_router.get("/sets", response_model=List[SetResponse]) +async def get_sets_endpoint( + db: AsyncSession = Depends(mtg_get_db), +) -> Dict[str, Any]: + """ + Get all available sets. + """ + cache_key = "all_sets:all" + + cached = await cache_get(cache_key) + if cached: + return {"cached": True, "results": cached} + + sets = await CardSearchService.get_sets(db) + + # Cache for 1 hour + await cache_set(cache_key, str(sets), ttl=3600) + + return {"cached": False, "results": sets} + + +@card_router.get("/types", response_model=List[CardTypeResponse]) +async def get_card_types_endpoint( + db: AsyncSession = Depends(mtg_get_db), +) -> Dict[str, Any]: + """ + Get all unique card types. + """ + cache_key = "card_types:all" + + cached = await cache_get(cache_key) + if cached: + return {"cached": True, "results": cached} + + types = await CardSearchService.get_card_types(db) + + # Cache for 30 minutes + await cache_set(cache_key, str(types), ttl=1800) + + return {"cached": False, "results": types} + + +@card_router.get("/rarities", response_model=List[str]) +async def get_card_rarities_endpoint( + db: AsyncSession = Depends(mtg_get_db), +) -> Dict[str, Any]: + """ + Get all unique card rarities. + """ + cache_key = "card_rarities:all" + + cached = await cache_get(cache_key) + if cached: + return {"cached": True, "results": cached} + + rarities = await CardSearchService.get_card_rarities(db) + + # Cache for 30 minutes + await cache_set(cache_key, str(rarities), ttl=1800) + + return {"cached": False, "results": rarities} + + +@card_router.get("/suggest", response_model=List[Dict[str, Any]]) +async def suggest_cards_endpoint( + deck_id: int = Query(..., description="Deck ID to suggest cards for"), + limit: int = Query(20, ge=1, le=100, description="Maximum suggestions"), + db: AsyncSession = Depends(mtg_get_db), +) -> List[Dict[str, Any]]: + """ + Suggest similar cards for a deck. + + Matches by: same type, same color, same set, same mana cost, + and cards often paired in existing user decks. + """ + # Verify deck exists + stmt = select(UserDeck).where(UserDeck.id == deck_id) + result = await db.execute(stmt) + deck = result.scalar_one_or_none() + + if not deck: + raise HTTPException(status_code=404, detail="Deck not found") + + suggestions = await DeckSuggestionService.suggest_cards(db, deck_id, limit) + + return suggestions + + +# --------------------------------------------------------------------------- +# Card Interactions Router (/interactions) +# --------------------------------------------------------------------------- + +interaction_router = APIRouter(prefix="/interactions", tags=["Card Interactions"]) + + +@interaction_router.get("/synergies/{card_id}") async def get_card_synergies( card_id: int, - synergy_type: Optional[str] = Query(None, description="Filter by synergy type (archetype, mechanic, mana, combo)"), + synergy_type: Optional[str] = Query( + None, description="Filter by synergy type (archetype, mechanic, mana, combo)" + ), min_strength: int = Query(1, ge=1, le=5, description="Minimum synergy strength"), limit: int = Query(100, ge=1, le=500, description="Maximum results"), offset: int = Query(0, ge=0, description="Number of results to skip"), db: AsyncSession = Depends(mtg_get_db), -): +) -> Dict[str, Any]: """ Get synergies for a specific card. - + Synergies are positive interactions where cards work well together. """ cache_key = f"synergies:{card_id}:{synergy_type}:{min_strength}:{limit}:{offset}" - + try: cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} - + # Build query - conditions = ["card_a_id = :card_id OR card_b_id = :card_id"] - params = {"card_id": card_id} - + conditions: list[str] = ["card_a_id = :card_id OR card_b_id = :card_id"] + params: Dict[str, Any] = {"card_id": card_id} + if synergy_type: conditions.append("synergy_type = :synergy_type") params["synergy_type"] = synergy_type - + if min_strength: conditions.append("strength >= :min_strength") params["min_strength"] = min_strength - + where_clause = " AND ".join(conditions) - + query = f""" SELECT id, card_a_id, card_b_id, synergy_type, strength, notes, confidence FROM mtg_card_synergies @@ -59,12 +372,12 @@ async def get_card_synergies( """ params["limit"] = limit params["offset"] = offset - + # Execute query result = await db.execute(text(query), params) rows = result.fetchall() - - synergies = [] + + synergies: List[Dict[str, Any]] = [] for row in rows: synergies.append({ "id": row[0], @@ -75,7 +388,7 @@ async def get_card_synergies( "notes": row[5], "confidence": row[6], }) - + # Get count for pagination count_query = f""" SELECT COUNT(*) @@ -84,10 +397,10 @@ async def get_card_synergies( """ count_result = await db.execute(text(count_query), params) total = count_result.scalar() - + # Cache for 10 minutes await cache_set(cache_key, {"synergies": synergies, "total": total}, ttl=600) - + return { "cached": False, "results": synergies, @@ -95,45 +408,47 @@ async def get_card_synergies( "total": total, "limit": limit, "offset": offset, - } + }, } except Exception as e: raise HTTPException(status_code=500, detail=f"Error fetching synergies: {str(e)}") -@router.get("/counters/{card_id}") +@interaction_router.get("/counters/{card_id}") async def get_card_counters( card_id: int, - counter_type: Optional[str] = Query(None, description="Filter by counter type (color, stats, spell, keyword)"), + counter_type: Optional[str] = Query( + None, description="Filter by counter type (color, stats, spell, keyword)" + ), min_strength: int = Query(1, ge=1, le=5, description="Minimum counter strength"), limit: int = Query(100, ge=1, le=500, description="Maximum results"), offset: int = Query(0, ge=0, description="Number of results to skip"), db: AsyncSession = Depends(mtg_get_db), -): +) -> Dict[str, Any]: """ Get counters for a specific card. - + Counters are negative interactions where one card is disadvantaged by another. """ cache_key = f"counters:{card_id}:{counter_type}:{min_strength}:{limit}:{offset}" - + cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} - - conditions = ["card_a_id = :card_id OR card_b_id = :card_id"] - params = {"card_id": card_id} - + + conditions: list[str] = ["card_a_id = :card_id OR card_b_id = :card_id"] + params: Dict[str, Any] = {"card_id": card_id} + if counter_type: conditions.append("counter_type = :counter_type") params["counter_type"] = counter_type - + if min_strength: conditions.append("strength >= :min_strength") params["min_strength"] = min_strength - + where_clause = " AND ".join(conditions) - + query = f""" SELECT id, card_a_id, card_b_id, counter_type, strength, notes, confidence FROM mtg_card_counters @@ -143,11 +458,11 @@ async def get_card_counters( """ params["limit"] = limit params["offset"] = offset - + result = await db.execute(text(query), params) rows = result.fetchall() - - counters = [] + + counters: List[Dict[str, Any]] = [] for row in rows: counters.append({ "id": row[0], @@ -158,7 +473,7 @@ async def get_card_counters( "notes": row[5], "confidence": row[6], }) - + count_query = f""" SELECT COUNT(*) FROM mtg_card_counters @@ -166,9 +481,9 @@ async def get_card_counters( """ count_result = await db.execute(text(count_query), params) total = count_result.scalar() - + await cache_set(cache_key, {"counters": counters, "total": total}, ttl=600) - + return { "cached": False, "results": counters, @@ -176,43 +491,45 @@ async def get_card_counters( "total": total, "limit": limit, "offset": offset, - } + }, } -@router.get("/evolutions/{card_id}") +@interaction_router.get("/evolutions/{card_id}") async def get_card_evolutions( card_id: int, - evolution_type: Optional[str] = Query(None, description="Filter by evolution type (reprint, transform, double_sided)"), + evolution_type: Optional[str] = Query( + None, description="Filter by evolution type (reprint, transform, double_sided)" + ), min_strength: int = Query(1, ge=1, le=5, description="Minimum strength"), limit: int = Query(100, ge=1, le=500, description="Maximum results"), offset: int = Query(0, ge=0, description="Number of results to skip"), db: AsyncSession = Depends(mtg_get_db), -): +) -> Dict[str, Any]: """ Get evolutions for a specific card. - + Evolutions track when a card has been reprinted, transformed, or evolved. """ cache_key = f"evolutions:{card_id}:{evolution_type}:{min_strength}:{limit}:{offset}" - + cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} - - conditions = ["card_id = :card_id"] - params = {"card_id": card_id} - + + conditions: list[str] = ["card_id = :card_id"] + params: Dict[str, Any] = {"card_id": card_id} + if evolution_type: conditions.append("evolution_type = :evolution_type") params["evolution_type"] = evolution_type - + if min_strength: conditions.append("strength >= :min_strength") params["min_strength"] = min_strength - + where_clause = " AND ".join(conditions) - + query = f""" SELECT id, card_id, evolved_card_id, evolution_type, strength, notes, confidence FROM mtg_card_evolution @@ -222,11 +539,11 @@ async def get_card_evolutions( """ params["limit"] = limit params["offset"] = offset - + result = await db.execute(text(query), params) rows = result.fetchall() - - evolutions = [] + + evolutions: List[Dict[str, Any]] = [] for row in rows: evolutions.append({ "id": row[0], @@ -237,7 +554,7 @@ async def get_card_evolutions( "notes": row[5], "confidence": row[6], }) - + count_query = f""" SELECT COUNT(*) FROM mtg_card_evolution @@ -245,9 +562,9 @@ async def get_card_evolutions( """ count_result = await db.execute(text(count_query), params) total = count_result.scalar() - + await cache_set(cache_key, {"evolutions": evolutions, "total": total}, ttl=600) - + return { "cached": False, "results": evolutions, @@ -255,32 +572,34 @@ async def get_card_evolutions( "total": total, "limit": limit, "offset": offset, - } + }, } -@router.get("/recommend/{card_id}") +@interaction_router.get("/recommend/{card_id}") async def get_card_recommendations( card_id: int, - recommendation_type: str = Query("synergy", description="Type of recommendation (synergy, counter, evolution)"), + recommendation_type: str = Query( + "synergy", description="Type of recommendation (synergy, counter, evolution)" + ), limit: int = Query(10, ge=1, le=100, description="Maximum results"), db: AsyncSession = Depends(mtg_get_db), -): +) -> Dict[str, Any]: """ Get interaction recommendations for a card. - + Provides cards that work well together or counter a specific card. """ cache_key = f"recommend:{card_id}:{recommendation_type}:{limit}" - + cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} - + if recommendation_type == "synergy": # Get cards that synergize with this card query = """ - SELECT + SELECT CASE WHEN card_a_id = :card_id THEN card_b_id ELSE card_a_id END as recommended_card_id, strength, synergy_type, @@ -293,7 +612,7 @@ async def get_card_recommendations( elif recommendation_type == "counter": # Get cards that counter this card query = """ - SELECT + SELECT CASE WHEN card_a_id = :card_id THEN card_b_id ELSE card_a_id END as recommended_card_id, strength, counter_type, @@ -316,14 +635,16 @@ async def get_card_recommendations( LIMIT :limit """ else: - raise HTTPException(status_code=400, detail=f"Invalid recommendation type: {recommendation_type}") - - params = {"card_id": card_id, "limit": limit} - + raise HTTPException( + status_code=400, detail=f"Invalid recommendation type: {recommendation_type}" + ) + + params: Dict[str, Any] = {"card_id": card_id, "limit": limit} + result = await db.execute(text(query), params) rows = result.fetchall() - - recommendations = [] + + recommendations: List[Dict[str, Any]] = [] for row in rows: recommendations.append({ "recommended_card_id": row[0], @@ -332,16 +653,16 @@ async def get_card_recommendations( "subtype": row[2], "confidence": row[3], }) - + await cache_set(cache_key, recommendations, ttl=900) - + return { "cached": False, "results": recommendations, } -@router.get("/search/synergies") +@interaction_router.get("/search/synergies") async def search_synergies( card_a_id: Optional[int] = Query(None, description="Card A ID"), card_b_id: Optional[int] = Query(None, description="Card B ID"), @@ -351,41 +672,41 @@ async def search_synergies( limit: int = Query(100, ge=1, le=1000, description="Maximum results"), offset: int = Query(0, ge=0, description="Number of results to skip"), db: AsyncSession = Depends(mtg_get_db), -): +) -> Dict[str, Any]: """ Search synergies with multiple filters. """ cache_key = f"search_synergies:{card_a_id}:{card_b_id}:{synergy_type}:{min_strength}:{min_confidence}:{limit}:{offset}" - + cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} - - conditions = [] - params = {} - + + conditions: list[str] = [] + params: Dict[str, Any] = {} + if card_a_id: conditions.append("card_a_id = :card_a_id") params["card_a_id"] = card_a_id - + if card_b_id: conditions.append("card_b_id = :card_b_id") params["card_b_id"] = card_b_id - + if synergy_type: conditions.append("synergy_type = :synergy_type") params["synergy_type"] = synergy_type - + if min_strength: conditions.append("strength >= :min_strength") params["min_strength"] = min_strength - + if min_confidence: conditions.append("confidence >= :min_confidence") params["min_confidence"] = min_confidence - + where_clause = " AND ".join(conditions) if conditions else "TRUE" - + query = f""" SELECT id, card_a_id, card_b_id, synergy_type, strength, notes, confidence FROM mtg_card_synergies @@ -395,11 +716,11 @@ async def search_synergies( """ params["limit"] = limit params["offset"] = offset - + result = await db.execute(text(query), params) rows = result.fetchall() - - synergies = [] + + synergies: List[Dict[str, Any]] = [] for row in rows: synergies.append({ "id": row[0], @@ -410,7 +731,7 @@ async def search_synergies( "notes": row[5], "confidence": row[6], }) - + count_query = f""" SELECT COUNT(*) FROM mtg_card_synergies @@ -418,9 +739,9 @@ async def search_synergies( """ count_result = await db.execute(text(count_query), params) total = count_result.scalar() - + await cache_set(cache_key, {"synergies": synergies, "total": total}, ttl=600) - + return { "cached": False, "results": synergies, @@ -428,11 +749,11 @@ async def search_synergies( "total": total, "limit": limit, "offset": offset, - } + }, } -@router.get("/search/counters") +@interaction_router.get("/search/counters") async def search_counters( card_a_id: Optional[int] = Query(None, description="Card A ID"), card_b_id: Optional[int] = Query(None, description="Card B ID"), @@ -442,41 +763,41 @@ async def search_counters( limit: int = Query(100, ge=1, le=1000, description="Maximum results"), offset: int = Query(0, ge=0, description="Number of results to skip"), db: AsyncSession = Depends(mtg_get_db), -): +) -> Dict[str, Any]: """ Search counters with multiple filters. """ cache_key = f"search_counters:{card_a_id}:{card_b_id}:{counter_type}:{min_strength}:{min_confidence}:{limit}:{offset}" - + cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} - - conditions = [] - params = {} - + + conditions: list[str] = [] + params: Dict[str, Any] = {} + if card_a_id: conditions.append("card_a_id = :card_a_id") params["card_a_id"] = card_a_id - + if card_b_id: conditions.append("card_b_id = :card_b_id") params["card_b_id"] = card_b_id - + if counter_type: conditions.append("counter_type = :counter_type") params["counter_type"] = counter_type - + if min_strength: conditions.append("strength >= :min_strength") params["min_strength"] = min_strength - + if min_confidence: conditions.append("confidence >= :min_confidence") params["min_confidence"] = min_confidence - + where_clause = " AND ".join(conditions) if conditions else "TRUE" - + query = f""" SELECT id, card_a_id, card_b_id, counter_type, strength, notes, confidence FROM mtg_card_counters @@ -486,11 +807,11 @@ async def search_counters( """ params["limit"] = limit params["offset"] = offset - + result = await db.execute(text(query), params) rows = result.fetchall() - - counters = [] + + counters: List[Dict[str, Any]] = [] for row in rows: counters.append({ "id": row[0], @@ -501,7 +822,7 @@ async def search_counters( "notes": row[5], "confidence": row[6], }) - + count_query = f""" SELECT COUNT(*) FROM mtg_card_counters @@ -509,9 +830,9 @@ async def search_counters( """ count_result = await db.execute(text(count_query), params) total = count_result.scalar() - + await cache_set(cache_key, {"counters": counters, "total": total}, ttl=600) - + return { "cached": False, "results": counters, @@ -519,11 +840,11 @@ async def search_counters( "total": total, "limit": limit, "offset": offset, - } + }, } -@router.get("/search/evolutions") +@interaction_router.get("/search/evolutions") async def search_evolutions( card_id: Optional[int] = Query(None, description="Card ID"), evolved_card_id: Optional[int] = Query(None, description="Evolved Card ID"), @@ -533,41 +854,41 @@ async def search_evolutions( limit: int = Query(100, ge=1, le=1000, description="Maximum results"), offset: int = Query(0, ge=0, description="Number of results to skip"), db: AsyncSession = Depends(mtg_get_db), -): +) -> Dict[str, Any]: """ Search evolutions with multiple filters. """ cache_key = f"search_evolutions:{card_id}:{evolved_card_id}:{evolution_type}:{min_strength}:{min_confidence}:{limit}:{offset}" - + cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} - - conditions = [] - params = {} - + + conditions: list[str] = [] + params: Dict[str, Any] = {} + if card_id: conditions.append("card_id = :card_id") params["card_id"] = card_id - + if evolved_card_id: conditions.append("evolved_card_id = :evolved_card_id") params["evolved_card_id"] = evolved_card_id - + if evolution_type: conditions.append("evolution_type = :evolution_type") params["evolution_type"] = evolution_type - + if min_strength: conditions.append("strength >= :min_strength") params["min_strength"] = min_strength - + if min_confidence: conditions.append("confidence >= :min_confidence") params["min_confidence"] = min_confidence - + where_clause = " AND ".join(conditions) if conditions else "TRUE" - + query = f""" SELECT id, card_id, evolved_card_id, evolution_type, strength, notes, confidence FROM mtg_card_evolution @@ -577,11 +898,11 @@ async def search_evolutions( """ params["limit"] = limit params["offset"] = offset - + result = await db.execute(text(query), params) rows = result.fetchall() - - evolutions = [] + + evolutions: List[Dict[str, Any]] = [] for row in rows: evolutions.append({ "id": row[0], @@ -592,7 +913,7 @@ async def search_evolutions( "notes": row[5], "confidence": row[6], }) - + count_query = f""" SELECT COUNT(*) FROM mtg_card_evolution @@ -600,9 +921,9 @@ async def search_evolutions( """ count_result = await db.execute(text(count_query), params) total = count_result.scalar() - + await cache_set(cache_key, {"evolutions": evolutions, "total": total}, ttl=600) - + return { "cached": False, "results": evolutions, @@ -610,26 +931,26 @@ async def search_evolutions( "total": total, "limit": limit, "offset": offset, - } + }, } -@router.get("/stats/{card_id}") +@interaction_router.get("/stats/{card_id}") async def get_card_interaction_stats( card_id: int, db: AsyncSession = Depends(mtg_get_db), -): +) -> Dict[str, Any]: """ Get aggregated interaction statistics for a card. """ cache_key = f"interaction_stats:{card_id}" - + cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} - + query = """ - SELECT + SELECT card_id, total_synergies, total_counters, @@ -639,11 +960,11 @@ async def get_card_interaction_stats( FROM mtg_card_interaction_stats WHERE card_id = :card_id """ - params = {"card_id": card_id} - + params: Dict[str, Any] = {"card_id": card_id} + result = await db.execute(text(query), params) row = result.fetchone() - + if not row: return { "cached": False, @@ -654,10 +975,10 @@ async def get_card_interaction_stats( "total_evolutions": 0, "total_synergy_strength": 0, "avg_synergy_strength": 0, - } + }, } - - stats = { + + stats: Dict[str, Any] = { "card_id": row[0], "total_synergies": row[1], "total_counters": row[2], @@ -665,9 +986,9 @@ async def get_card_interaction_stats( "total_synergy_strength": row[4], "avg_synergy_strength": row[5], } - + await cache_set(cache_key, stats, ttl=1800) - + return { "cached": False, "results": stats, diff --git a/backend/app/routers/decks.py b/backend/app/routers/decks_router.py similarity index 71% rename from backend/app/routers/decks.py rename to backend/app/routers/decks_router.py index ba4d5ec..816d4f5 100644 --- a/backend/app/routers/decks.py +++ b/backend/app/routers/decks_router.py @@ -1,39 +1,156 @@ """ -Deck management router endpoints for per-user deck building. +Deck Management Router — Complete Deck Building Functionality. -Provides CRUD operations for user decks with card management, -deck finalization, precedent templates, and card search integration. +This router provides the full deck building workflow for MTG Online users, +including: + + * CRUD operations for per-user decklists (draft, final, precedent). + * Card management within decks (add, update, remove, list). + * Deck finalization (transition from DRAFT to FINAL status). + * Precedent (template) management — list, create, and clone into new decks. + * Card search integration using the local card mirror. + * Card suggestion system for deck refinement. + +All endpoints require authentication via ``get_current_user`` and enforce +per-user ownership checks where appropriate. + +Routes +------ +Deck CRUD:: + + GET / List user decks (paginated, filterable) + POST / Create a new draft deck + GET /{deck_id} Get a specific deck + PATCH /{deck_id} Update deck metadata + DELETE /{deck_id} Delete a deck + +Deck Finalize:: + + POST /{deck_id}/finalize Transition deck to FINAL status + +Deck Card Management:: + + POST /{deck_id}/cards Add a card to a deck + GET /{deck_id}/cards List cards in a deck + PATCH /{deck_id}/cards/{card_id} Update card quantity/zone + DELETE /{deck_id}/cards/{card_id} Remove a card from a deck + +Precedents:: + + GET /precedents List public precedents + POST /precedents Create a precedent + GET /precedents/{id} Get a specific precedent + POST /precedents/{id}/use Clone precedent into a new draft deck + +Card Search:: + + POST /search/cards Search the local card mirror + +Suggestions:: + + GET /{deck_id}/suggestions List suggestions for a deck + POST /{deck_id}/suggestions Add a suggestion to a deck """ -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, or_, update, delete + +from __future__ import annotations + from typing import Optional, List -from app.core.database import get_db, mtg_get_db +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import delete, func, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db from app.core.security import get_current_user -from app.models.models import User, DecklistFolder, MtgonlineCard from app.models.mtg_models import MtgCard, MtgSet -from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion -from app.schemas.user_deck_schemas import ( - UserDeckCreate, UserDeckUpdate, UserDeckResponse, UserDeckListResponse, - DeckCardCreate, DeckCardUpdate, DeckCardResponse, DeckCardWithDetailsResponse, DeckCardListResponse, - PrecedentCreate, PrecedentUpdate, PrecedentResponse, PrecedentListResponse, - SuggestionCreate, SuggestionResponse, SuggestionListResponse, - DeckFinalizeRequest, DeckFinalizeResponse, - CardSearchRequest, +from app.models.models import MtgonlineCard, User, DecklistFolder +from app.models.models import ( + CardSuggestion, + DeckPrecedent, + DeckPrecedentCard, + UserDeck, + UserDeckCard, ) from app.schemas.card_search_schemas import CardSearchResponse -from app.schemas.generic_schemas import MessageResponse, CountResponse +from app.schemas.generic_schemas import CountResponse, MessageResponse +from app.schemas.user_deck_schemas import ( + CardSearchRequest, + DeckCardCreate, + DeckCardResponse, + DeckCardUpdate, + DeckCardListResponse, + DeckCardWithDetailsResponse, + DeckFinalizeRequest, + DeckFinalizeResponse, + PrecedentCreate, + PrecedentResponse, + PrecedentListResponse, + PrecedentUpdate, + SuggestionCreate, + SuggestionListResponse, + SuggestionResponse, + UserDeckCreate, + UserDeckResponse, + UserDeckUpdate, + UserDeckListResponse, +) from app.services.deck_manager import DeckManager from app.services.deck_parser import DeckParser router = APIRouter() -_deck_mgr = DeckManager() -_deck_parser = DeckParser() +_deck_mgr: DeckManager = DeckManager() +_deck_parser: DeckParser = DeckParser() -# ===== Deck CRUD ===== +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _get_user_id(current_user: dict) -> int: + """Extract and convert the authenticated user's ID.""" + return int(current_user["user_id"]) + + +async def _verify_deck_ownership( + db: AsyncSession, + deck_id: int, + user_id: int, +) -> UserDeck: + """Verify that a deck exists and belongs to the user. + + Raises: + HTTPException: 404 if the deck is not found. + """ + stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) + result = await db.execute(stmt) + deck = result.scalar_one_or_none() + if not deck: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + return deck + + +async def _get_card_count(db: AsyncSession, deck_id: int) -> int: + """Return the number of cards in a deck.""" + count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id) + count_result = await db.execute(count_stmt) + return count_result.scalar() or 0 + + +async def _enrich_deck_response( + deck: UserDeck, + card_count: int, +) -> UserDeckResponse: + """Build a ``UserDeckResponse`` with card count and ownership flag.""" + deck_data = UserDeckResponse.model_validate(deck) + deck_data.card_count = card_count + deck_data.is_owner = True + return deck_data + + +# --------------------------------------------------------------------------- +# Deck CRUD +# --------------------------------------------------------------------------- @router.get("/", response_model=UserDeckListResponse) async def list_user_decks( @@ -44,9 +161,13 @@ async def list_user_decks( page_size: int = Query(50, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): - """List user's decks with optional filtering.""" - user_id = int(current_user["user_id"]) +) -> UserDeckListResponse: + """List user's decks with optional filtering. + + Supports filtering by deck status, folder, and whether the deck is a + precedent. Results are paginated. + """ + user_id = _get_user_id(current_user) try: decks = await _deck_mgr.list_decks( db=db, @@ -60,10 +181,10 @@ async def list_user_decks( # Build response with card counts deck_ids = [d.id for d in decks] - card_counts = {} + card_counts: dict[int, int] = {} if deck_ids: count_subquery = ( - select(UserDeckCard.deck_id, func.count().label('cnt')) + select(UserDeckCard.deck_id, func.count().label("cnt")) .where(UserDeckCard.deck_id.in_(deck_ids)) .group_by(UserDeckCard.deck_id) .subquery() @@ -74,7 +195,7 @@ async def list_user_decks( count_result = await db.execute(count_stmt) card_counts = {row[0]: row[1] for row in count_result.fetchall()} - deck_responses = [] + deck_responses: list[UserDeckResponse] = [] for deck in decks: deck_data = UserDeckResponse.model_validate(deck) deck_data.card_count = card_counts.get(deck.id, 0) @@ -98,9 +219,9 @@ async def create_user_deck( request: UserDeckCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): - """Create a new user deck (DRAFT status).""" - user_id = int(current_user["user_id"]) +) -> UserDeckResponse: + """Create a new user deck in DRAFT status.""" + user_id = _get_user_id(current_user) try: new_deck = await _deck_mgr.create_deck( db=db, @@ -129,23 +250,17 @@ async def get_user_deck( deck_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): - """Get a specific user deck.""" - user_id = int(current_user["user_id"]) +) -> UserDeckResponse: + """Get a specific user deck by ID.""" + user_id = _get_user_id(current_user) try: deck = await _deck_mgr.get_deck(db=db, deck_id=deck_id, user_id=user_id) if not deck: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") - # Get card count - count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id) - count_result = await db.execute(count_stmt) - card_count = count_result.scalar() or 0 + card_count = await _get_card_count(db, deck_id) - deck_data = UserDeckResponse.model_validate(deck) - deck_data.card_count = card_count - deck_data.is_owner = True - return deck_data + return await _enrich_deck_response(deck, card_count) except HTTPException: raise except Exception as e: @@ -158,9 +273,9 @@ async def update_user_deck( request: UserDeckUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): - """Update a user deck.""" - user_id = int(current_user["user_id"]) +) -> UserDeckResponse: + """Update a user deck's metadata.""" + user_id = _get_user_id(current_user) try: update_data = request.model_dump(exclude_unset=True) if "status" in update_data and update_data["status"]: @@ -176,15 +291,8 @@ async def update_user_deck( if not updated_deck: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") - # Get card count - count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id) - count_result = await db.execute(count_stmt) - card_count = count_result.scalar() or 0 - - deck_data = UserDeckResponse.model_validate(updated_deck) - deck_data.card_count = card_count - deck_data.is_owner = True - return deck_data + card_count = await _get_card_count(db, deck_id) + return await _enrich_deck_response(updated_deck, card_count) except HTTPException: raise except ValueError as e: @@ -198,9 +306,9 @@ async def delete_user_deck( deck_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> MessageResponse: """Delete a user deck.""" - user_id = int(current_user["user_id"]) + user_id = _get_user_id(current_user) try: result = await _deck_mgr.delete_deck(db=db, deck_id=deck_id, user_id=user_id) if not result: @@ -212,16 +320,18 @@ async def delete_user_deck( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) -# ===== Deck Finalize ===== +# --------------------------------------------------------------------------- +# Deck Finalize +# --------------------------------------------------------------------------- @router.post("/{deck_id}/finalize", response_model=DeckFinalizeResponse) async def finalize_user_deck( deck_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> DeckFinalizeResponse: """Transition a deck from DRAFT to FINAL status.""" - user_id = int(current_user["user_id"]) + user_id = _get_user_id(current_user) try: deck = await _deck_mgr.finalize_deck(db=db, deck_id=deck_id, user_id=user_id) if not deck: @@ -239,7 +349,9 @@ async def finalize_user_deck( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -# ===== Deck Card Management ===== +# --------------------------------------------------------------------------- +# Deck Card Management +# --------------------------------------------------------------------------- @router.post("/{deck_id}/cards", response_model=DeckCardResponse, status_code=status.HTTP_201_CREATED) async def add_deck_card( @@ -247,16 +359,15 @@ async def add_deck_card( request: DeckCardCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): - """Add a card to a user's deck.""" - user_id = int(current_user["user_id"]) +) -> DeckCardResponse: + """Add a card to a user's deck. - # Verify deck exists and belongs to user - deck_stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) - deck_result = await db.execute(deck_stmt) - deck = deck_result.scalar_one_or_none() - if not deck: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + If a card already exists in the same zone, the quantity is incremented + rather than creating a duplicate entry. + """ + user_id = _get_user_id(current_user) + + deck = await _verify_deck_ownership(db, deck_id, user_id) if deck.status == "FINAL": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck") @@ -308,31 +419,25 @@ async def get_deck_cards( zone: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): - """Get all cards in a user's deck.""" - user_id = int(current_user["user_id"]) +) -> DeckCardListResponse: + """Get all cards in a user's deck, optionally filtered by zone.""" + user_id = _get_user_id(current_user) - # Verify deck exists and belongs to user - stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) - result = await db.execute(stmt) - deck = result.scalar_one_or_none() - - if not deck: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + await _verify_deck_ownership(db, deck_id, user_id) # Use service to get cards deck_cards = await _deck_mgr.get_deck_cards(db=db, deck_id=deck_id, zone=zone) # Fetch card details from local mirror card_ids = [dc.card_id for dc in deck_cards] - card_details = {} + card_details: dict[int, MtgonlineCard] = {} if card_ids: card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids)) card_result = await db.execute(card_stmt) for c in card_result.scalars().all(): card_details[c.id] = c - card_responses = [] + card_responses: list[DeckCardWithDetailsResponse] = [] for dc in deck_cards: card = card_details.get(dc.card_id) response = DeckCardWithDetailsResponse.model_validate(dc) @@ -351,17 +456,11 @@ async def update_deck_card( request: DeckCardUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> DeckCardResponse: """Update a card's quantity or zone in a deck.""" - user_id = int(current_user["user_id"]) + user_id = _get_user_id(current_user) - # Verify deck exists and belongs to user - stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) - result = await db.execute(stmt) - deck = result.scalar_one_or_none() - - if not deck: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + deck = await _verify_deck_ownership(db, deck_id, user_id) if deck.status == "FINAL": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck") @@ -398,17 +497,11 @@ async def remove_deck_card( card_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> MessageResponse: """Remove a card from a user's deck.""" - user_id = int(current_user["user_id"]) + user_id = _get_user_id(current_user) - # Verify deck exists and belongs to user - stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) - result = await db.execute(stmt) - deck = result.scalar_one_or_none() - - if not deck: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + deck = await _verify_deck_ownership(db, deck_id, user_id) if deck.status == "FINAL": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck") @@ -430,7 +523,9 @@ async def remove_deck_card( return MessageResponse(message="Card removed from deck") -# ===== Deck Precedents ===== +# --------------------------------------------------------------------------- +# Deck Precedents +# --------------------------------------------------------------------------- # Note: Precedent endpoints use direct DB operations as DeckManager # does not yet have precedent-specific methods. @@ -441,11 +536,11 @@ async def list_precedents( format_filter: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): - """List available deck precedents.""" +) -> PrecedentListResponse: + """List available (public) deck precedents, paginated.""" offset = (page - 1) * page_size - conditions = [DeckPrecedent.is_public == True] + conditions = [DeckPrecedent.is_public == True] # noqa: E712 if format_filter: conditions.append(DeckPrecedent.format == format_filter) @@ -461,10 +556,10 @@ async def list_precedents( # Get card counts prec_ids = [p.id for p in precedents] - card_counts = {} + card_counts: dict[int, int] = {} if prec_ids: count_subquery = ( - select(DeckPrecedentCard.precedent_id, func.count().label('cnt')) + select(DeckPrecedentCard.precedent_id, func.count().label("cnt")) .where(DeckPrecedentCard.precedent_id.in_(prec_ids)) .group_by(DeckPrecedentCard.precedent_id) .subquery() @@ -475,7 +570,7 @@ async def list_precedents( count_result = await db.execute(count_stmt) card_counts = {row[0]: row[1] for row in count_result.fetchall()} - prec_responses = [] + prec_responses: list[PrecedentResponse] = [] for p in precedents: prec_data = PrecedentResponse.model_validate(p) prec_data.card_count = card_counts.get(p.id, 0) @@ -495,9 +590,9 @@ async def create_precedent( request: PrecedentCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> PrecedentResponse: """Create a deck precedent (template).""" - user_id = int(current_user["user_id"]) + user_id = _get_user_id(current_user) new_precedent = DeckPrecedent( name=request.name, @@ -519,8 +614,8 @@ async def get_precedent( precedent_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): - """Get a specific deck precedent.""" +) -> PrecedentResponse: + """Get a specific deck precedent by ID.""" stmt = select(DeckPrecedent).where(DeckPrecedent.id == precedent_id) result = await db.execute(stmt) precedent = result.scalar_one_or_none() @@ -528,10 +623,7 @@ async def get_precedent( if not precedent: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Precedent not found") - # Get card count - count_stmt = select(func.count()).select_from(DeckPrecedentCard).where(DeckPrecedentCard.precedent_id == precedent_id) - count_result = await db.execute(count_stmt) - card_count = count_result.scalar() or 0 + card_count = await _get_card_count(db, precedent_id) prec_data = PrecedentResponse.model_validate(precedent) prec_data.card_count = card_count @@ -543,9 +635,9 @@ async def use_precedent( precedent_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> dict: """Clone a precedent into a new draft deck for the current user.""" - user_id = int(current_user["user_id"]) + user_id = _get_user_id(current_user) try: new_deck = await _deck_mgr.clone_precedent( @@ -553,11 +645,12 @@ async def use_precedent( precedent_id=precedent_id, user_id=user_id, ) + card_count = len(await _deck_mgr.get_deck_cards(db=db, deck_id=new_deck.id)) return { "message": "Precedent cloned into new deck", "deck_id": new_deck.id, "deck_name": new_deck.name, - "card_count": len(await _deck_mgr.get_deck_cards(db=db, deck_id=new_deck.id)), + "card_count": card_count, } except ValueError as e: if "not found" in str(e).lower(): @@ -565,15 +658,21 @@ async def use_precedent( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -# ===== Card Search Integration ===== +# --------------------------------------------------------------------------- +# Card Search Integration +# --------------------------------------------------------------------------- @router.post("/search/cards", response_model=CardSearchResponse) async def search_cards_for_deck( request: CardSearchRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): - """Search MTG cards for use in deck building.""" +) -> CardSearchResponse: + """Search MTG cards for use in deck building. + + Searches across name, type line, and mana cost fields in the local + card mirror. + """ offset = (request.offset // request.limit) * request.limit # Search across multiple fields using local mirror @@ -593,7 +692,7 @@ async def search_cards_for_deck( result = await db.execute(stmt) cards = result.scalars().all() - card_list = [] + card_list: list[dict] = [] for card in cards: card_data = { "id": card.id, @@ -634,7 +733,9 @@ async def search_cards_for_deck( ) -# ===== Card Suggestions ===== +# --------------------------------------------------------------------------- +# Card Suggestions +# --------------------------------------------------------------------------- # Note: Suggestion endpoints use direct DB operations as DeckManager # does not yet have suggestion-specific methods. @@ -644,17 +745,11 @@ async def get_deck_suggestions( suggestion_type: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): - """Get card suggestions for a deck.""" - user_id = int(current_user["user_id"]) +) -> SuggestionListResponse: + """Get card suggestions for a deck, optionally filtered by type.""" + user_id = _get_user_id(current_user) - # Verify deck exists and belongs to user - stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) - result = await db.execute(stmt) - deck = result.scalar_one_or_none() - - if not deck: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + await _verify_deck_ownership(db, deck_id, user_id) conditions = [CardSuggestion.deck_id == deck_id] if suggestion_type: @@ -667,14 +762,14 @@ async def get_deck_suggestions( # Fetch card names from local mirror card_ids = [s.card_id for s in suggestions] - card_names = {} + card_names: dict[int, str] = {} if card_ids: card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids)) card_result = await db.execute(card_stmt) for c in card_result.scalars().all(): card_names[c.id] = c.name - sugg_responses = [] + sugg_responses: list[SuggestionResponse] = [] for s in suggestions: sugg_data = SuggestionResponse.model_validate(s) sugg_data.card_name = card_names.get(s.card_id, f"Card#{s.card_id}") @@ -692,17 +787,11 @@ async def add_suggestion( request: SuggestionCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> SuggestionResponse: """Add a card suggestion to a deck.""" - user_id = int(current_user["user_id"]) + user_id = _get_user_id(current_user) - # Verify deck exists and belongs to user - stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) - result = await db.execute(stmt) - deck = result.scalar_one_or_none() - - if not deck: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + deck = await _verify_deck_ownership(db, deck_id, user_id) if deck.status == "FINAL": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck") diff --git a/backend/app/routers/games/router.py b/backend/app/routers/games/router.py deleted file mode 100644 index 6882303..0000000 --- a/backend/app/routers/games/router.py +++ /dev/null @@ -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"} diff --git a/backend/app/routers/games_router.py b/backend/app/routers/games_router.py new file mode 100644 index 0000000..484c6e0 --- /dev/null +++ b/backend/app/routers/games_router.py @@ -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) diff --git a/backend/app/routers/refresh.py b/backend/app/routers/refresh.py deleted file mode 100644 index df9339a..0000000 --- a/backend/app/routers/refresh.py +++ /dev/null @@ -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", - } diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py deleted file mode 100644 index 0005426..0000000 --- a/backend/app/routers/rooms.py +++ /dev/null @@ -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"} diff --git a/backend/app/routers/user_data.py b/backend/app/routers/user_data_router.py similarity index 70% rename from backend/app/routers/user_data.py rename to backend/app/routers/user_data_router.py index 860be30..5e82a00 100644 --- a/backend/app/routers/user_data.py +++ b/backend/app/routers/user_data_router.py @@ -1,24 +1,60 @@ """ -User data API endpoints router. +User Data and Card Import Router. -Provides CRUD operations for user sessions, decks, replays, -card collection, groups, networks, preferences, and activity logs. +Consolidated router combining user personal data management and card import +functionality. Provides endpoints for: + +**User Data** (prefix: /api/v1/user-data): + - Session management (get, cleanup, logout) + - Deck versions (CRUD) + - Game replays and replay players (CRUD) + - Game outcomes (create, list) + - User statistics (get, update) + - Card collection (CRUD) + - Wishlist (CRUD) + - User groups with members and chat messages (CRUD) + - User networks with members (CRUD) + - User preferences (get, update) + - Activity log (list) + +**Card Imports** (prefix: /api/v1/card-import): + - Upload import files (XLSX, CSV, JSON, ODS) + - Import batch status and results + - Confirm imports to card collection + - List/delete user's imported cards + - List user's decks (backward compatibility) + +Both sub-routers share the UserCardCollection table — user_data.py handles +full CRUD while card_import.py manages batch imports. """ -from fastapi import APIRouter, Depends, HTTPException, Query, status + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional +from datetime import datetime, timezone +import tempfile + +from fastapi import APIRouter, Depends, HTTPException, Query, status, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, update, delete -from typing import Optional, List -from datetime import datetime, timezone +from sqlalchemy.orm import selectinload from app.core.database import get_db from app.core.security import get_current_user -from app.models.models import User -from app.models.user_data import ( +from app.models.models import ( + User, MtgonlineCard, UserSession, DeckVersion, GameReplay, ReplayPlayer, GameOutcome, UserStatistics, UserCardCollection, CardWishlist, UserGroup, GroupMember, GroupChatMessage, UserNetwork, - NetworkMember, UserPreference, UserActivityLog + NetworkMember, UserPreference, UserActivityLog, + CardImportBatch, UserCardImportRecord, + 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.user_card_collection import ( CardCollectionCreate, CardCollectionUpdate, @@ -44,31 +80,44 @@ from app.schemas.user_data_schemas import ( UserPreferenceUpdate, UserPreferenceResponse, ActivityLogEntry, ActivityLogListResponse, ) +from app.schemas.card_import_schemas import ( + CardImportRequest, + CardImportResponse, + CardImportStatusResponse, + CardMatchResult, + CardImportSummary, +) +from app.schemas.user_deck_schemas import DeckCardResponse, DeckCardListResponse -router = APIRouter() + +# ============================================================================= +# User Data Router (prefix: /api/v1/user-data) +# ============================================================================= + +user_data_router = APIRouter() # ===== Session Management ===== -@router.get("/sessions/me", response_model=List[SessionCleanupResponse]) +@user_data_router.get("/sessions/me", response_model=List[SessionCleanupResponse]) async def get_current_sessions( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> List[SessionCleanupResponse]: """Get active sessions for current user.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + stmt = ( select(UserSession) .where( UserSession.user_id == user_id, - UserSession.is_active == True, + UserSession.is_active == True, # noqa: E712 UserSession.expires_at > datetime.now(timezone.utc) ) ) result = await db.execute(stmt) sessions = result.scalars().all() - + return [ SessionCleanupResponse( cleaned_count=len(sessions), @@ -77,82 +126,86 @@ async def get_current_sessions( ] -@router.delete("/sessions/cleanup", response_model=MessageResponse) +@user_data_router.delete("/sessions/cleanup", response_model=MessageResponse) async def cleanup_expired_sessions( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> MessageResponse: """Cleanup expired sessions for current user.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + now = datetime.now(timezone.utc) - + stmt = ( update(UserSession) .where( UserSession.user_id == user_id, - UserSession.is_active == True, + UserSession.is_active == True, # noqa: E712 UserSession.expires_at < now ) .values(is_active=False) ) await db.execute(stmt) await db.flush() - + # Count cleaned sessions count_stmt = select(func.count()).select_from(UserSession).where( UserSession.user_id == user_id, - UserSession.is_active == False, + UserSession.is_active == False, # noqa: E712 UserSession.expires_at < now ) count_result = await db.execute(count_stmt) cleaned_count = count_result.scalar() - + return MessageResponse(message=f"Cleaned {cleaned_count} expired sessions") -@router.post("/sessions/logout", response_model=MessageResponse) +@user_data_router.post("/sessions/logout", response_model=MessageResponse) async def logout_current_session( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> MessageResponse: """Logout current user session.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + stmt = ( update(UserSession) .where( UserSession.user_id == user_id, - UserSession.is_active == True + UserSession.is_active == True # noqa: E712 ) .values(is_active=False) ) await db.execute(stmt) await db.flush() - + return MessageResponse(message="Logged out successfully") # ===== Deck Versions ===== -@router.post("/decks/{deck_id}/versions", response_model=DeckVersionResponse, status_code=status.HTTP_201_CREATED) +@user_data_router.post( + "/decks/{deck_id}/versions", + response_model=DeckVersionResponse, + status_code=status.HTTP_201_CREATED +) async def create_deck_version( deck_id: int, request: DeckVersionCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> DeckVersionResponse: """Create a new version of a deck.""" - user_id = int(current_user["user_id"]) - - # Verify user owns the deck + user_id: int = int(current_user["user_id"]) + + # Verify user exists deck_check = select(User).where(User.id == user_id) deck_result = await db.execute(deck_check) user = deck_result.scalar_one_or_none() - + if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - + # Get current version number version_stmt = ( select(func.max(DeckVersion.version_number)) @@ -160,7 +213,7 @@ async def create_deck_version( ) version_result = await db.execute(version_stmt) max_version = version_result.scalar() or 0 - + new_version = DeckVersion( deck_id=deck_id, version_number=max_version + 1, @@ -169,28 +222,31 @@ async def create_deck_version( comment=request.comment, created_at=datetime.now(timezone.utc) ) - + db.add(new_version) await db.flush() - + return DeckVersionResponse.model_validate(new_version) -@router.get("/decks/{deck_id}/versions", response_model=DeckVersionListResponse) +@user_data_router.get( + "/decks/{deck_id}/versions", + response_model=DeckVersionListResponse +) async def get_deck_versions( deck_id: int, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> DeckVersionListResponse: """Get all versions of a deck.""" offset = (page - 1) * page_size - + count_stmt = select(func.count()).select_from(DeckVersion).where(DeckVersion.deck_id == deck_id) total_result = await db.execute(count_stmt) total = total_result.scalar() - + stmt = ( select(DeckVersion) .where(DeckVersion.deck_id == deck_id) @@ -200,7 +256,7 @@ async def get_deck_versions( ) result = await db.execute(stmt) versions = result.scalars().all() - + return DeckVersionListResponse( versions=[DeckVersionResponse.model_validate(v) for v in versions], total=total, @@ -210,26 +266,29 @@ async def get_deck_versions( ) -@router.patch("/decks/{deck_id}/versions/{version_id}", response_model=DeckVersionResponse) +@user_data_router.patch( + "/decks/{deck_id}/versions/{version_id}", + response_model=DeckVersionResponse +) async def update_deck_version( deck_id: int, version_id: int, request: DeckVersionUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> DeckVersionResponse: """Update a deck version.""" stmt = select(DeckVersion).where(DeckVersion.id == version_id) result = await db.execute(stmt) version = result.scalar_one_or_none() - + if not version: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found") - + update_data = request.model_dump(exclude_unset=True) if "status" in update_data: update_data["status"] = update_data["status"].value - + stmt = ( update(DeckVersion) .where(DeckVersion.id == version_id) @@ -237,53 +296,60 @@ async def update_deck_version( ) await db.execute(stmt) await db.flush() - + stmt = select(DeckVersion).where(DeckVersion.id == version_id) result = await db.execute(stmt) updated_version = result.scalar_one_or_none() - + return DeckVersionResponse.model_validate(updated_version) -@router.delete("/decks/{deck_id}/versions/{version_id}", response_model=MessageResponse) +@user_data_router.delete( + "/decks/{deck_id}/versions/{version_id}", + response_model=MessageResponse +) async def delete_deck_version( deck_id: int, version_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> MessageResponse: """Delete a deck version.""" stmt = select(DeckVersion).where(DeckVersion.id == version_id) result = await db.execute(stmt) version = result.scalar_one_or_none() - + if not version: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found") - + stmt = delete(DeckVersion).where(DeckVersion.id == version_id) await db.execute(stmt) await db.flush() - + return MessageResponse(message="Version deleted successfully") # ===== Game Replays ===== -@router.post("/replays", response_model=GameReplayResponse, status_code=status.HTTP_201_CREATED) +@user_data_router.post( + "/replays", + response_model=GameReplayResponse, + status_code=status.HTTP_201_CREATED +) async def create_game_replay( request: GameReplayCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GameReplayResponse: """Create a new game replay.""" # Check if replay already exists stmt = select(GameReplay).where(GameReplay.game_uuid == request.game_uuid) result = await db.execute(stmt) existing = result.scalar_one_or_none() - + if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Replay already exists") - + replay = GameReplay( game_uuid=request.game_uuid, room_id=request.room_id, @@ -297,14 +363,14 @@ async def create_game_replay( created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc) ) - + db.add(replay) await db.flush() - + return GameReplayResponse.model_validate(replay) -@router.get("/replays", response_model=GameReplayListResponse) +@user_data_router.get("/replays", response_model=GameReplayListResponse) async def get_game_replays( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), @@ -312,33 +378,33 @@ async def get_game_replays( status_filter: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GameReplayListResponse: """Get game replays with optional filtering.""" offset = (page - 1) * page_size - + # Build WHERE clause conditions = [] if user_id: conditions.append(ReplayPlayer.user_id == user_id) if status_filter: conditions.append(GameReplay.status == status_filter) - + # Get total count count_stmt = select(func.count()).select_from(GameReplay) if conditions: count_stmt = count_stmt.join(ReplayPlayer).where(*conditions) total_result = await db.execute(count_stmt) total = total_result.scalar() - + # Get replays stmt = select(GameReplay) if conditions: stmt = stmt.join(ReplayPlayer).where(*conditions) stmt = stmt.order_by(GameReplay.start_time.desc()).offset(offset).limit(page_size) - + result = await db.execute(stmt) replays = result.scalars().all() - + return GameReplayListResponse( replays=[GameReplayResponse.model_validate(r) for r in replays], total=total, @@ -348,43 +414,49 @@ async def get_game_replays( ) -@router.get("/replays/{replay_id}", response_model=GameReplayResponse) +@user_data_router.get( + "/replays/{replay_id}", + response_model=GameReplayResponse +) async def get_game_replay( replay_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GameReplayResponse: """Get a specific game replay.""" stmt = select(GameReplay).where(GameReplay.id == replay_id) result = await db.execute(stmt) replay = result.scalar_one_or_none() - + if not replay: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found") - + return GameReplayResponse.model_validate(replay) -@router.patch("/replays/{replay_id}", response_model=GameReplayResponse) +@user_data_router.patch( + "/replays/{replay_id}", + response_model=GameReplayResponse +) async def update_game_replay( replay_id: int, request: GameReplayUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GameReplayResponse: """Update a game replay.""" stmt = select(GameReplay).where(GameReplay.id == replay_id) result = await db.execute(stmt) replay = result.scalar_one_or_none() - + if not replay: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found") - + update_data = request.model_dump(exclude_unset=True) if "status" in update_data: update_data["status"] = update_data["status"].value update_data["updated_at"] = datetime.now(timezone.utc) - + stmt = ( update(GameReplay) .where(GameReplay.id == replay_id) @@ -392,38 +464,44 @@ async def update_game_replay( ) await db.execute(stmt) await db.flush() - + stmt = select(GameReplay).where(GameReplay.id == replay_id) result = await db.execute(stmt) updated_replay = result.scalar_one_or_none() - + return GameReplayResponse.model_validate(updated_replay) -@router.delete("/replays/{replay_id}", response_model=MessageResponse) +@user_data_router.delete( + "/replays/{replay_id}", + response_model=MessageResponse +) async def delete_game_replay( replay_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> MessageResponse: """Delete a game replay.""" stmt = select(GameReplay).where(GameReplay.id == replay_id) result = await db.execute(stmt) replay = result.scalar_one_or_none() - + if not replay: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found") - + stmt = delete(GameReplay).where(GameReplay.id == replay_id) await db.execute(stmt) await db.flush() - + return MessageResponse(message="Replay deleted successfully") # ===== Replay Players ===== -@router.post("/replays/{replay_id}/players", status_code=status.HTTP_201_CREATED) +@user_data_router.post( + "/replays/{replay_id}/players", + status_code=status.HTTP_201_CREATED +) async def add_replay_player( replay_id: int, user_id: int, @@ -431,16 +509,16 @@ async def add_replay_player( position: Optional[int] = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> Dict[str, Any]: """Add a player to a game replay.""" # Verify replay exists stmt = select(GameReplay).where(GameReplay.id == replay_id) result = await db.execute(stmt) replay = result.scalar_one_or_none() - + if not replay: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found") - + # Check if player already exists player_check = ( select(ReplayPlayer) @@ -449,7 +527,7 @@ async def add_replay_player( player_result = await db.execute(player_check) if player_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Player already in replay") - + player = ReplayPlayer( replay_id=replay_id, user_id=user_id, @@ -457,24 +535,27 @@ async def add_replay_player( position=position, created_at=datetime.now(timezone.utc) ) - + db.add(player) await db.flush() - + return {"message": "Player added to replay", "player_id": player.id} -@router.get("/replays/{replay_id}/players", response_model=List[dict]) +@user_data_router.get( + "/replays/{replay_id}/players", + response_model=List[dict] +) async def get_replay_players( replay_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> List[dict]: """Get all players in a game replay.""" stmt = select(ReplayPlayer).where(ReplayPlayer.replay_id == replay_id) result = await db.execute(stmt) players = result.scalars().all() - + return [ { "id": p.id, @@ -492,12 +573,16 @@ async def get_replay_players( # ===== Game Outcomes ===== -@router.post("/outcomes", response_model=GameOutcomeResponse, status_code=status.HTTP_201_CREATED) +@user_data_router.post( + "/outcomes", + response_model=GameOutcomeResponse, + status_code=status.HTTP_201_CREATED +) async def create_game_outcome( request: GameOutcomeCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GameOutcomeResponse: """Create a game outcome record.""" outcome = GameOutcome( user_id=int(current_user["user_id"]), @@ -510,38 +595,38 @@ async def create_game_outcome( rating_change=request.rating_change, created_at=datetime.now(timezone.utc) ) - + db.add(outcome) await db.flush() - + return GameOutcomeResponse.model_validate(outcome) -@router.get("/outcomes", response_model=GameOutcomeListResponse) +@user_data_router.get("/outcomes", response_model=GameOutcomeListResponse) async def get_game_outcomes( user_id: Optional[int] = None, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GameOutcomeListResponse: """Get game outcomes with optional filtering.""" offset = (page - 1) * page_size - + stmt = select(GameOutcome) if user_id: stmt = stmt.where(GameOutcome.user_id == user_id) stmt = stmt.order_by(GameOutcome.created_at.desc()).offset(offset).limit(page_size) - + result = await db.execute(stmt) outcomes = result.scalars().all() - + count_stmt = select(func.count()).select_from(GameOutcome) if user_id: count_stmt = count_stmt.where(GameOutcome.user_id == user_id) total_result = await db.execute(count_stmt) total = total_result.scalar() - + return GameOutcomeListResponse( outcomes=[GameOutcomeResponse.model_validate(o) for o in outcomes], total=total, @@ -553,17 +638,20 @@ async def get_game_outcomes( # ===== User Statistics ===== -@router.get("/statistics/{user_id}", response_model=UserStatisticsResponse) +@user_data_router.get( + "/statistics/{user_id}", + response_model=UserStatisticsResponse +) async def get_user_statistics( user_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> UserStatisticsResponse: """Get user game statistics.""" stmt = select(UserStatistics).where(UserStatistics.user_id == user_id) result = await db.execute(stmt) stats = result.scalar_one_or_none() - + if not stats: # Create default statistics stats = UserStatistics( @@ -580,22 +668,25 @@ async def get_user_statistics( ) db.add(stats) await db.flush() - + return UserStatisticsResponse.model_validate(stats) -@router.post("/statistics/update", response_model=StatisticsUpdateResponse) +@user_data_router.post( + "/statistics/update", + response_model=StatisticsUpdateResponse +) async def update_user_statistics( user_id: int, outcome: str, # 'WIN', 'LOSS', 'CONCESSION' db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> StatisticsUpdateResponse: """Update user statistics after a game.""" stmt = select(UserStatistics).where(UserStatistics.user_id == user_id) result = await db.execute(stmt) stats = result.scalar_one_or_none() - + if not stats: stats = UserStatistics( user_id=user_id, @@ -610,11 +701,11 @@ async def update_user_statistics( updated_at=datetime.now(timezone.utc) ) db.add(stats) - + # Update statistics stats.total_games += 1 stats.last_game_date = datetime.now(timezone.utc) - + if outcome == "WIN": stats.total_wins += 1 stats.current_streak = max(0, stats.current_streak) + 1 @@ -624,14 +715,14 @@ async def update_user_statistics( stats.total_losses += 1 stats.total_concessions += 1 if outcome == "CONCESSION" else 0 stats.current_streak = 0 - + # Calculate win rate if stats.total_games > 0: stats.win_rate = (stats.total_wins / stats.total_games) * 100 - + stats.updated_at = datetime.now(timezone.utc) await db.flush() - + return StatisticsUpdateResponse( user_id=stats.user_id, total_games=stats.total_games, @@ -645,15 +736,19 @@ async def update_user_statistics( # ===== Card Collection ===== -@router.post("/collection", response_model=CardCollectionResponse, status_code=status.HTTP_201_CREATED) +@user_data_router.post( + "/collection", + response_model=CardCollectionResponse, + status_code=status.HTTP_201_CREATED +) async def add_card_to_collection( request: CardCollectionCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> CardCollectionResponse: """Add a card to user collection.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + # Check if card already exists in collection check = ( select(UserCardCollection) @@ -666,7 +761,7 @@ async def add_card_to_collection( ) check_result = await db.execute(check) existing = check_result.scalar_one_or_none() - + if existing: # Update quantity stmt = ( @@ -676,12 +771,12 @@ async def add_card_to_collection( ) await db.execute(stmt) await db.flush() - + stmt = select(UserCardCollection).where(UserCardCollection.id == existing.id) result = await db.execute(stmt) updated = result.scalar_one_or_none() return CardCollectionResponse.model_validate(updated) - + card = UserCardCollection( user_id=user_id, card_id=request.card_id, @@ -696,14 +791,14 @@ async def add_card_to_collection( created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc) ) - + db.add(card) await db.flush() - + return CardCollectionResponse.model_validate(card) -@router.get("/collection", response_model=CardCollectionListResponse) +@user_data_router.get("/collection", response_model=CardCollectionListResponse) async def get_user_collection( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), @@ -711,31 +806,31 @@ async def get_user_collection( is_alt_art: Optional[bool] = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> CardCollectionListResponse: """Get user's card collection.""" - user_id = int(current_user["user_id"]) + user_id: int = int(current_user["user_id"]) offset = (page - 1) * page_size - + conditions = [UserCardCollection.user_id == user_id] if is_foil is not None: conditions.append(UserCardCollection.is_foil == is_foil) if is_alt_art is not None: conditions.append(UserCardCollection.is_alt_art == is_alt_art) - + count_stmt = select(func.count()).select_from(UserCardCollection) for cond in conditions: count_stmt = count_stmt.where(cond) total_result = await db.execute(count_stmt) total = total_result.scalar() - + stmt = select(UserCardCollection) for cond in conditions: stmt = stmt.where(cond) stmt = stmt.order_by(UserCardCollection.acquired_date.desc()).offset(offset).limit(page_size) - + result = await db.execute(stmt) cards = result.scalars().all() - + return CardCollectionListResponse( cards=[CardCollectionResponse.model_validate(c) for c in cards], total=total, @@ -745,29 +840,32 @@ async def get_user_collection( ) -@router.patch("/collection/{card_id}", response_model=CardCollectionResponse) +@user_data_router.patch( + "/collection/{card_id}", + response_model=CardCollectionResponse +) async def update_card_in_collection( card_id: int, request: CardCollectionUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> CardCollectionResponse: """Update a card in user collection.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + stmt = select(UserCardCollection).where( UserCardCollection.id == card_id, UserCardCollection.user_id == user_id ) result = await db.execute(stmt) card = result.scalar_one_or_none() - + if not card: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in collection") - + update_data = request.model_dump(exclude_unset=True) update_data["updated_at"] = datetime.now(timezone.utc) - + stmt = ( update(UserCardCollection) .where(UserCardCollection.id == card_id) @@ -775,51 +873,58 @@ async def update_card_in_collection( ) await db.execute(stmt) await db.flush() - + stmt = select(UserCardCollection).where(UserCardCollection.id == card_id) result = await db.execute(stmt) updated = result.scalar_one_or_none() - + return CardCollectionResponse.model_validate(updated) -@router.delete("/collection/{card_id}", response_model=MessageResponse) +@user_data_router.delete( + "/collection/{card_id}", + response_model=MessageResponse +) async def remove_card_from_collection( card_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> MessageResponse: """Remove a card from user collection.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + stmt = select(UserCardCollection).where( UserCardCollection.id == card_id, UserCardCollection.user_id == user_id ) result = await db.execute(stmt) card = result.scalar_one_or_none() - + if not card: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in collection") - + stmt = delete(UserCardCollection).where(UserCardCollection.id == card_id) await db.execute(stmt) await db.flush() - + return MessageResponse(message="Card removed from collection") # ===== Wishlist ===== -@router.post("/wishlist", response_model=WishlistResponse, status_code=status.HTTP_201_CREATED) +@user_data_router.post( + "/wishlist", + response_model=WishlistResponse, + status_code=status.HTTP_201_CREATED +) async def add_to_wishlist( request: WishlistCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> WishlistResponse: """Add a card to wishlist.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + # Check if already in wishlist check = ( select(CardWishlist) @@ -828,7 +933,7 @@ async def add_to_wishlist( check_result = await db.execute(check) if check_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Card already in wishlist") - + item = CardWishlist( user_id=user_id, card_id=request.card_id, @@ -836,28 +941,28 @@ async def add_to_wishlist( notes=request.notes, created_at=datetime.now(timezone.utc) ) - + db.add(item) await db.flush() - + return WishlistResponse.model_validate(item) -@router.get("/wishlist", response_model=WishlistListResponse) +@user_data_router.get("/wishlist", response_model=WishlistListResponse) async def get_wishlist( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> WishlistListResponse: """Get user's wishlist.""" - user_id = int(current_user["user_id"]) + user_id: int = int(current_user["user_id"]) offset = (page - 1) * page_size - + count_stmt = select(func.count()).select_from(CardWishlist).where(CardWishlist.user_id == user_id) total_result = await db.execute(count_stmt) total = total_result.scalar() - + stmt = ( select(CardWishlist) .where(CardWishlist.user_id == user_id) @@ -867,7 +972,7 @@ async def get_wishlist( ) result = await db.execute(stmt) items = result.scalars().all() - + return WishlistListResponse( items=[WishlistResponse.model_validate(i) for i in items], total=total, @@ -877,28 +982,31 @@ async def get_wishlist( ) -@router.patch("/wishlist/{item_id}", response_model=WishlistResponse) +@user_data_router.patch( + "/wishlist/{item_id}", + response_model=WishlistResponse +) async def update_wishlist_item( item_id: int, request: WishlistUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> WishlistResponse: """Update a wishlist item.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + stmt = select(CardWishlist).where( CardWishlist.id == item_id, CardWishlist.user_id == user_id ) result = await db.execute(stmt) item = result.scalar_one_or_none() - + if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wishlist item not found") - + update_data = request.model_dump(exclude_unset=True) - + stmt = ( update(CardWishlist) .where(CardWishlist.id == item_id) @@ -906,51 +1014,58 @@ async def update_wishlist_item( ) await db.execute(stmt) await db.flush() - + stmt = select(CardWishlist).where(CardWishlist.id == item_id) result = await db.execute(stmt) updated = result.scalar_one_or_none() - + return WishlistResponse.model_validate(updated) -@router.delete("/wishlist/{item_id}", response_model=MessageResponse) +@user_data_router.delete( + "/wishlist/{item_id}", + response_model=MessageResponse +) async def remove_from_wishlist( item_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> MessageResponse: """Remove a card from wishlist.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + stmt = select(CardWishlist).where( CardWishlist.id == item_id, CardWishlist.user_id == user_id ) result = await db.execute(stmt) item = result.scalar_one_or_none() - + if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wishlist item not found") - + stmt = delete(CardWishlist).where(CardWishlist.id == item_id) await db.execute(stmt) await db.flush() - + return MessageResponse(message="Removed from wishlist") # ===== Groups ===== -@router.post("/groups", response_model=GroupResponse, status_code=status.HTTP_201_CREATED) +@user_data_router.post( + "/groups", + response_model=GroupResponse, + status_code=status.HTTP_201_CREATED +) async def create_group( request: GroupCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GroupResponse: """Create a new user group.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + group = UserGroup( name=request.name, description=request.description, @@ -960,10 +1075,10 @@ async def create_group( created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc) ) - + db.add(group) await db.flush() - + # Add creator as owner member = GroupMember( group_id=group.id, @@ -973,25 +1088,25 @@ async def create_group( ) db.add(member) await db.flush() - + return GroupResponse.model_validate(group) -@router.get("/groups", response_model=GroupListResponse) +@user_data_router.get("/groups", response_model=GroupListResponse) async def get_user_groups( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), is_public: Optional[bool] = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GroupListResponse: """Get user's groups.""" - user_id = int(current_user["user_id"]) + user_id: int = int(current_user["user_id"]) offset = (page - 1) * page_size - + # Get groups where user is member or owner member_groups = select(GroupMember.group_id).where(GroupMember.user_id == user_id) - + count_stmt = select(func.count()).select_from(UserGroup).where( UserGroup.id.in_(member_groups) ) @@ -999,7 +1114,7 @@ async def get_user_groups( count_stmt = count_stmt.where(UserGroup.is_public == is_public) total_result = await db.execute(count_stmt) total = total_result.scalar() - + stmt = ( select(UserGroup) .where(UserGroup.id.in_(member_groups)) @@ -1009,10 +1124,10 @@ async def get_user_groups( ) result = await db.execute(stmt) groups = result.scalars().all() - + # Get member counts group_ids = [g.id for g in groups] - member_counts = {} + member_counts: Dict[int, int] = {} if group_ids: count_subquery = ( select(GroupMember.group_id, func.count().label('count')) @@ -1025,7 +1140,7 @@ async def get_user_groups( ) count_result = await db.execute(count_stmt) member_counts = {row[0]: row[1] for row in count_result.fetchall()} - + return GroupListResponse( groups=[ GroupResponse( @@ -1041,33 +1156,39 @@ async def get_user_groups( ) -@router.get("/groups/{group_id}", response_model=GroupResponse) +@user_data_router.get( + "/groups/{group_id}", + response_model=GroupResponse +) async def get_group( group_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GroupResponse: """Get a specific group.""" stmt = select(UserGroup).where(UserGroup.id == group_id) result = await db.execute(stmt) group = result.scalar_one_or_none() - + if not group: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") - + return GroupResponse.model_validate(group) -@router.patch("/groups/{group_id}", response_model=GroupResponse) +@user_data_router.patch( + "/groups/{group_id}", + response_model=GroupResponse +) async def update_group( group_id: int, request: GroupUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GroupResponse: """Update a group.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + # Verify user is owner check = ( select(GroupMember) @@ -1080,17 +1201,17 @@ async def update_group( check_result = await db.execute(check) if not check_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owner can update group") - + stmt = select(UserGroup).where(UserGroup.id == group_id) result = await db.execute(stmt) group = result.scalar_one_or_none() - + if not group: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") - + update_data = request.model_dump(exclude_unset=True) update_data["updated_at"] = datetime.now(timezone.utc) - + stmt = ( update(UserGroup) .where(UserGroup.id == group_id) @@ -1098,23 +1219,26 @@ async def update_group( ) await db.execute(stmt) await db.flush() - + stmt = select(UserGroup).where(UserGroup.id == group_id) result = await db.execute(stmt) updated = result.scalar_one_or_none() - + return GroupResponse.model_validate(updated) -@router.delete("/groups/{group_id}", response_model=MessageResponse) +@user_data_router.delete( + "/groups/{group_id}", + response_model=MessageResponse +) async def delete_group( group_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> MessageResponse: """Delete a group.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + # Verify user is owner check = ( select(GroupMember) @@ -1127,26 +1251,29 @@ async def delete_group( check_result = await db.execute(check) if not check_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owner can delete group") - + stmt = delete(UserGroup).where(UserGroup.id == group_id) await db.execute(stmt) await db.flush() - + return MessageResponse(message="Group deleted successfully") # ===== Group Members ===== -@router.post("/groups/{group_id}/members", status_code=status.HTTP_201_CREATED) +@user_data_router.post( + "/groups/{group_id}/members", + status_code=status.HTTP_201_CREATED +) async def add_group_member( group_id: int, request: GroupMemberCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> Dict[str, Any]: """Add a member to a group.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + # Verify user is admin or owner check = ( select(GroupMember) @@ -1159,7 +1286,7 @@ async def add_group_member( check_result = await db.execute(check) if not check_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") - + # Check if already a member member_check = ( select(GroupMember) @@ -1168,31 +1295,34 @@ async def add_group_member( member_result = await db.execute(member_check) if member_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already a member") - + member = GroupMember( group_id=group_id, user_id=request.user_id, role=request.role.value, joined_at=datetime.now(timezone.utc) ) - + db.add(member) await db.flush() - + return {"message": "Member added", "member_id": member.id} -@router.patch("/groups/{group_id}/members/{member_id}", response_model=GroupMemberUpdate) +@user_data_router.patch( + "/groups/{group_id}/members/{member_id}", + response_model=GroupMemberUpdate +) async def update_group_member( group_id: int, member_id: int, request: GroupMemberUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GroupMemberUpdate: """Update a group member's role.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + # Verify user is owner check = ( select(GroupMember) @@ -1205,14 +1335,14 @@ async def update_group_member( check_result = await db.execute(check) if not check_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owner can update roles") - + stmt = select(GroupMember).where(GroupMember.id == member_id) result = await db.execute(stmt) member = result.scalar_one_or_none() - + if not member: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") - + stmt = ( update(GroupMember) .where(GroupMember.id == member_id) @@ -1220,20 +1350,23 @@ async def update_group_member( ) await db.execute(stmt) await db.flush() - + return {"message": "Member role updated"} -@router.delete("/groups/{group_id}/members/{member_id}", response_model=MessageResponse) +@user_data_router.delete( + "/groups/{group_id}/members/{member_id}", + response_model=MessageResponse +) async def remove_group_member( group_id: int, member_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> MessageResponse: """Remove a member from a group.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + # Verify user is admin or owner check = ( select(GroupMember) @@ -1246,33 +1379,37 @@ async def remove_group_member( check_result = await db.execute(check) if not check_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") - + stmt = select(GroupMember).where(GroupMember.id == member_id) result = await db.execute(stmt) member = result.scalar_one_or_none() - + if not member: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") - + stmt = delete(GroupMember).where(GroupMember.id == member_id) await db.execute(stmt) await db.flush() - + return MessageResponse(message="Member removed from group") # ===== Group Chat Messages ===== -@router.post("/groups/{group_id}/messages", response_model=GroupChatMessageResponse, status_code=status.HTTP_201_CREATED) +@user_data_router.post( + "/groups/{group_id}/messages", + response_model=GroupChatMessageResponse, + status_code=status.HTTP_201_CREATED +) async def send_group_message( group_id: int, request: GroupChatMessageCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GroupChatMessageResponse: """Send a message in a group chat.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + # Verify user is a member member_check = ( select(GroupMember) @@ -1281,28 +1418,28 @@ async def send_group_message( member_result = await db.execute(member_check) if not member_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a group member") - + # Verify group exists group_check = select(UserGroup).where(UserGroup.id == group_id) group_result = await db.execute(group_check) if not group_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") - + message = GroupChatMessage( group_id=group_id, sender_id=user_id, message=request.message, created_at=datetime.now(timezone.utc) ) - + db.add(message) await db.flush() - + # Get sender username user_check = select(User).where(User.id == user_id) user_result = await db.execute(user_check) sender = user_result.scalar_one_or_none() - + return GroupChatMessageResponse( id=message.id, group_id=message.group_id, @@ -1313,17 +1450,20 @@ async def send_group_message( ) -@router.get("/groups/{group_id}/messages", response_model=GroupChatMessageListResponse) +@user_data_router.get( + "/groups/{group_id}/messages", + response_model=GroupChatMessageListResponse +) async def get_group_messages( group_id: int, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> GroupChatMessageListResponse: """Get messages in a group chat.""" offset = (page - 1) * page_size - + # Verify user is a member member_check = ( select(GroupMember) @@ -1332,11 +1472,11 @@ async def get_group_messages( member_result = await db.execute(member_check) if not member_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a group member") - + count_stmt = select(func.count()).select_from(GroupChatMessage).where(GroupChatMessage.group_id == group_id) total_result = await db.execute(count_stmt) total = total_result.scalar() - + stmt = ( select(GroupChatMessage) .where(GroupChatMessage.group_id == group_id) @@ -1346,15 +1486,15 @@ async def get_group_messages( ) result = await db.execute(stmt) messages = result.scalars().all() - + # Get sender usernames sender_ids = [m.sender_id for m in messages] - sender_names = {} + sender_names: Dict[int, str] = {} if sender_ids: user_stmt = select(User.id, User.username).where(User.id.in_(sender_ids)) user_result = await db.execute(user_stmt) sender_names = {row[0]: row[1] for row in user_result.fetchall()} - + return GroupChatMessageListResponse( messages=[ GroupChatMessageResponse( @@ -1376,15 +1516,19 @@ async def get_group_messages( # ===== Networks ===== -@router.post("/networks", response_model=NetworkResponse, status_code=status.HTTP_201_CREATED) +@user_data_router.post( + "/networks", + response_model=NetworkResponse, + status_code=status.HTTP_201_CREATED +) async def create_network( request: NetworkCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> NetworkResponse: """Create a new user network.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + network = UserNetwork( name=request.name, description=request.description, @@ -1392,10 +1536,10 @@ async def create_network( is_public=request.is_public, created_at=datetime.now(timezone.utc) ) - + db.add(network) await db.flush() - + # Add creator as owner member = NetworkMember( network_id=network.id, @@ -1405,29 +1549,29 @@ async def create_network( ) db.add(member) await db.flush() - + return NetworkResponse.model_validate(network) -@router.get("/networks", response_model=NetworkListResponse) +@user_data_router.get("/networks", response_model=NetworkListResponse) async def get_user_networks( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> NetworkListResponse: """Get user's networks.""" - user_id = int(current_user["user_id"]) + user_id: int = int(current_user["user_id"]) offset = (page - 1) * page_size - + member_networks = select(NetworkMember.network_id).where(NetworkMember.user_id == user_id) - + count_stmt = select(func.count()).select_from(UserNetwork).where( UserNetwork.id.in_(member_networks) ) total_result = await db.execute(count_stmt) total = total_result.scalar() - + stmt = ( select(UserNetwork) .where(UserNetwork.id.in_(member_networks)) @@ -1437,10 +1581,10 @@ async def get_user_networks( ) result = await db.execute(stmt) networks = result.scalars().all() - + # Get member counts network_ids = [n.id for n in networks] - member_counts = {} + member_counts: Dict[int, int] = {} if network_ids: count_subquery = ( select(NetworkMember.network_id, func.count().label('count')) @@ -1453,7 +1597,7 @@ async def get_user_networks( ) count_result = await db.execute(count_stmt) member_counts = {row[0]: row[1] for row in count_result.fetchall()} - + return NetworkListResponse( networks=[ NetworkResponse( @@ -1469,33 +1613,39 @@ async def get_user_networks( ) -@router.get("/networks/{network_id}", response_model=NetworkResponse) +@user_data_router.get( + "/networks/{network_id}", + response_model=NetworkResponse +) async def get_network( network_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> NetworkResponse: """Get a specific network.""" stmt = select(UserNetwork).where(UserNetwork.id == network_id) result = await db.execute(stmt) network = result.scalar_one_or_none() - + if not network: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Network not found") - + return NetworkResponse.model_validate(network) -@router.patch("/networks/{network_id}", response_model=NetworkResponse) +@user_data_router.patch( + "/networks/{network_id}", + response_model=NetworkResponse +) async def update_network( network_id: int, request: NetworkUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> NetworkResponse: """Update a network.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + # Verify user is creator check = ( select(UserNetwork) @@ -1504,16 +1654,16 @@ async def update_network( check_result = await db.execute(check) if not check_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator can update network") - + stmt = select(UserNetwork).where(UserNetwork.id == network_id) result = await db.execute(stmt) network = result.scalar_one_or_none() - + if not network: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Network not found") - + update_data = request.model_dump(exclude_unset=True) - + stmt = ( update(UserNetwork) .where(UserNetwork.id == network_id) @@ -1521,23 +1671,26 @@ async def update_network( ) await db.execute(stmt) await db.flush() - + stmt = select(UserNetwork).where(UserNetwork.id == network_id) result = await db.execute(stmt) updated = result.scalar_one_or_none() - + return NetworkResponse.model_validate(updated) -@router.delete("/networks/{network_id}", response_model=MessageResponse) +@user_data_router.delete( + "/networks/{network_id}", + response_model=MessageResponse +) async def delete_network( network_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> MessageResponse: """Delete a network.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + # Verify user is creator check = ( select(UserNetwork) @@ -1546,26 +1699,29 @@ async def delete_network( check_result = await db.execute(check) if not check_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator can delete network") - + stmt = delete(UserNetwork).where(UserNetwork.id == network_id) await db.execute(stmt) await db.flush() - + return MessageResponse(message="Network deleted successfully") # ===== Network Members ===== -@router.post("/networks/{network_id}/members", status_code=status.HTTP_201_CREATED) +@user_data_router.post( + "/networks/{network_id}/members", + status_code=status.HTTP_201_CREATED +) async def add_network_member( network_id: int, request: NetworkMemberCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> Dict[str, Any]: """Add a member to a network.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + # Verify user is admin or owner check = ( select(NetworkMember) @@ -1578,7 +1734,7 @@ async def add_network_member( check_result = await db.execute(check) if not check_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") - + # Check if already a member member_check = ( select(NetworkMember) @@ -1587,34 +1743,37 @@ async def add_network_member( member_result = await db.execute(member_check) if member_result.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already a member") - + member = NetworkMember( network_id=network_id, user_id=request.user_id, role=request.role.value, joined_at=datetime.now(timezone.utc) ) - + db.add(member) await db.flush() - + return {"message": "Member added", "member_id": member.id} # ===== User Preferences ===== -@router.get("/preferences", response_model=UserPreferenceResponse) +@user_data_router.get( + "/preferences", + response_model=UserPreferenceResponse +) async def get_user_preferences( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> UserPreferenceResponse: """Get user preferences.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + stmt = select(UserPreference).where(UserPreference.user_id == user_id) result = await db.execute(stmt) prefs = result.scalar_one_or_none() - + if not prefs: # Create default preferences prefs = UserPreference( @@ -1629,23 +1788,26 @@ async def get_user_preferences( ) db.add(prefs) await db.flush() - + return UserPreferenceResponse.model_validate(prefs) -@router.patch("/preferences", response_model=UserPreferenceResponse) +@user_data_router.patch( + "/preferences", + response_model=UserPreferenceResponse +) async def update_user_preferences( request: UserPreferenceUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> UserPreferenceResponse: """Update user preferences.""" - user_id = int(current_user["user_id"]) - + user_id: int = int(current_user["user_id"]) + stmt = select(UserPreference).where(UserPreference.user_id == user_id) result = await db.execute(stmt) prefs = result.scalar_one_or_none() - + if not prefs: # Create default preferences first prefs = UserPreference( @@ -1654,10 +1816,10 @@ async def update_user_preferences( ) db.add(prefs) await db.flush() - + update_data = request.model_dump(exclude_unset=True) update_data["updated_at"] = datetime.now(timezone.utc) - + stmt = ( update(UserPreference) .where(UserPreference.user_id == user_id) @@ -1665,46 +1827,46 @@ async def update_user_preferences( ) await db.execute(stmt) await db.flush() - + stmt = select(UserPreference).where(UserPreference.user_id == user_id) result = await db.execute(stmt) updated = result.scalar_one_or_none() - + return UserPreferenceResponse.model_validate(updated) # ===== Activity Log ===== -@router.get("/activity", response_model=ActivityLogListResponse) +@user_data_router.get("/activity", response_model=ActivityLogListResponse) async def get_activity_log( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), activity_type: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), -): +) -> ActivityLogListResponse: """Get user activity log.""" - user_id = int(current_user["user_id"]) + user_id: int = int(current_user["user_id"]) offset = (page - 1) * page_size - + conditions = [UserActivityLog.user_id == user_id] if activity_type: conditions.append(UserActivityLog.activity_type == activity_type) - + count_stmt = select(func.count()).select_from(UserActivityLog) for cond in conditions: count_stmt = count_stmt.where(cond) total_result = await db.execute(count_stmt) total = total_result.scalar() - + stmt = select(UserActivityLog) for cond in conditions: stmt = stmt.where(cond) stmt = stmt.order_by(UserActivityLog.created_at.desc()).offset(offset).limit(page_size) - + result = await db.execute(stmt) entries = result.scalars().all() - + return ActivityLogListResponse( entries=[ActivityLogEntry.model_validate(e) for e in entries], total=total, @@ -1712,3 +1874,364 @@ async def get_activity_log( page_size=page_size, total_pages=(total + page_size - 1) // page_size ) + + +# ============================================================================= +# Card Import Router (prefix: /api/v1/card-import) +# ============================================================================= + +card_import_router = APIRouter() + + +@card_import_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), +) -> CardImportResponse: + """ + Upload a card import file (XLSX, CSV, JSON, ODS). + + Parses the file, performs fuzzy matching, and creates an import batch. + """ + user_id: int = 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) + + 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(), + ) + + +@card_import_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), +) -> CardImportStatusResponse: + """Get the status of an import batch.""" + user_id: int = 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, + ) + + +@card_import_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), +) -> CardImportSummary: + """Get the match results for an import batch.""" + user_id: int = 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, + ) + + +@card_import_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), +) -> MessageResponse: + """Confirm an import batch and save to user's card collection.""" + user_id: int = 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." + ) + + +@card_import_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[Dict[str, Any]]: + """List user's imported cards.""" + user_id: int = 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: Dict[int, MtgonlineCard] = {} + 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 + + +@card_import_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), +) -> MessageResponse: + """Remove a card from user's collection.""" + user_id: int = 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") + + +@card_import_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[Dict[str, Any]]: + """List user's decks. + + Kept for backward compatibility. This duplicates functionality in decks.py. + """ + user_id: int = 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 diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py deleted file mode 100644 index 967805c..0000000 --- a/backend/app/routers/users.py +++ /dev/null @@ -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"} diff --git a/backend/app/routers/users_router.py b/backend/app/routers/users_router.py new file mode 100644 index 0000000..b39e7c9 --- /dev/null +++ b/backend/app/routers/users_router.py @@ -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, + } diff --git a/backend/app/routers/ws.py b/backend/app/routers/ws.py deleted file mode 100644 index 7f7f5af..0000000 --- a/backend/app/routers/ws.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 763a091..057ba40 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -65,15 +65,6 @@ from app.schemas.mtg_card_schemas import ( MtgSetResponse, MtgCardMirrorResponse, DeckCardLinkResponse as DeckCardLinkResponseV2, DeckWithCardsResponse as DeckWithCardsResponseV2, ) -from app.schemas.proto_messages import ( - ProtoMessageBase, SessionCommand, GameCommand, GameEvent, Response, - ServerInfoUser, ServerInfoDeckStorageFile, ServerInfoDeckStorageFolder, - ServerInfoDeckStorageTreeItem, ServerInfoCard, ServerInfoZone, ServerInfoGame, -) -from app.schemas.protocol_constants import ( - SessionCommandType, GameCommandType, GameEventType, ResponseCode, - ZoneType, UserLevelFlag, -) __all__ = [ # Auth @@ -137,11 +128,4 @@ __all__ = [ "MtgCardResponse", "MtgCardSearchRequest", "MtgCardSearchResponse", "MtgSetResponse", "MtgCardMirrorResponse", "DeckCardLinkResponseV2", "DeckWithCardsResponseV2", - # Proto Messages - "ProtoMessageBase", "SessionCommand", "GameCommand", "GameEvent", "Response", - "ServerInfoUser", "ServerInfoDeckStorageFile", "ServerInfoDeckStorageFolder", - "ServerInfoDeckStorageTreeItem", "ServerInfoCard", "ServerInfoZone", "ServerInfoGame", - # Protocol Constants - "SessionCommandType", "GameCommandType", "GameEventType", "ResponseCode", - "ZoneType", "UserLevelFlag", ] diff --git a/backend/app/schemas/proto_messages.py b/backend/app/schemas/proto_messages.py deleted file mode 100644 index c252c3c..0000000 --- a/backend/app/schemas/proto_messages.py +++ /dev/null @@ -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 diff --git a/backend/app/schemas/protocol_constants.py b/backend/app/schemas/protocol_constants.py deleted file mode 100644 index 342d557..0000000 --- a/backend/app/schemas/protocol_constants.py +++ /dev/null @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 25302ba..4649668 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,11 +1,8 @@ """Services package initialization.""" from app.services.deck_parser import DeckParser from app.services.card_database import search_cards, get_card_by_name, get_cards_by_set, get_card_types, get_card_rarities, get_sets, get_set_by_code, get_card_statistics -from app.services.card_mirror_service import upsert_card_mirror, get_card_mirror_by_name, search_card_mirrors, add_card_to_deck, remove_card_from_deck, get_deck_cards, get_user_deck_summaries, sync_mirrors_from_mtg_cards, get_card_statistics from app.services.mtgjson_manager import MTGJSONManager, get_manager from app.services.mtgjson_downloader import download_all_files, verify_downloads, get_file_list -from app.services.mtgjson_loader import create_tables, download_file, extract_zip, get_all_printings_psql_file, parse_psql_file, import_cards, import_all_printings_psql, import_all_set_files, import_all_identifiers, import_all_deck_files, import_simple_json, create_indexes, show_summary, main -from app.services.mtgjson_uploader import create_tables, import_all_printings_psql, import_all_set_files, import_all_identifiers, import_all_deck_files, import_json_files, download_file, extract_zip, main from app.services.file_parser import FileParser from app.services.fuzzy_card_matcher import FuzzyCardMatcher from app.services.import_batch_processor import ImportBatchProcessor @@ -23,34 +20,11 @@ __all__ = [ "get_sets", "get_set_by_code", "get_card_statistics", - "upsert_card_mirror", - "get_card_mirror_by_name", - "search_card_mirrors", - "add_card_to_deck", - "remove_card_from_deck", - "get_deck_cards", - "get_user_deck_summaries", - "sync_mirrors_from_mtg_cards", "MTGJSONManager", "get_manager", "download_all_files", "verify_downloads", "get_file_list", - "create_tables", - "download_file", - "extract_zip", - "get_all_printings_psql_file", - "parse_psql_file", - "import_cards", - "import_all_printings_psql", - "import_all_set_files", - "import_all_identifiers", - "import_all_deck_files", - "import_simple_json", - "import_json_files", - "create_indexes", - "show_summary", - "main", "FileParser", "FuzzyCardMatcher", "ImportBatchProcessor", diff --git a/backend/app/services/card_mirror_service.py b/backend/app/services/card_mirror_service.py deleted file mode 100644 index 2b76a9d..0000000 --- a/backend/app/services/card_mirror_service.py +++ /dev/null @@ -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, - } diff --git a/backend/app/services/card_search_service.py b/backend/app/services/card_search_service.py index d1f42c9..a0e9b96 100644 --- a/backend/app/services/card_search_service.py +++ b/backend/app/services/card_search_service.py @@ -4,8 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_, and_ from sqlalchemy.orm import selectinload -from app.models.mtg_models import MtgCard, MtgSet -from app.models.mirror_models import MtgCardMirror +from app.models.mtg_models import MtgCard, MtgSet, MtgCardMirror class CardSearchService: diff --git a/backend/app/services/deck_manager.py b/backend/app/services/deck_manager.py index 64f4d64..43053ca 100644 --- a/backend/app/services/deck_manager.py +++ b/backend/app/services/deck_manager.py @@ -4,8 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update, delete from sqlalchemy.orm import selectinload -from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard -from app.models.models import MtgonlineCard +from app.models.models import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, MtgonlineCard class DeckManager: diff --git a/backend/app/services/deck_suggestion_service.py b/backend/app/services/deck_suggestion_service.py index 2e9d155..ef8bf98 100644 --- a/backend/app/services/deck_suggestion_service.py +++ b/backend/app/services/deck_suggestion_service.py @@ -4,9 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_, and_, func, case from sqlalchemy.orm import selectinload -from app.models.user_deck import UserDeck, UserDeckCard, CardSuggestion -from app.models.mtg_models import MtgCard -from app.models.mirror_models import MtgCardMirror +from app.models.models import UserDeck, UserDeckCard, CardSuggestion +from app.models.mtg_models import MtgCard, MtgCardMirror from app.services.fuzzy_card_matcher import FuzzyCardMatcher diff --git a/backend/app/services/import_batch_processor.py b/backend/app/services/import_batch_processor.py index 9f93843..6410af6 100644 --- a/backend/app/services/import_batch_processor.py +++ b/backend/app/services/import_batch_processor.py @@ -6,10 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update, insert, delete from sqlalchemy.sql import func -from app.models.card_import_batch import CardImportBatch -from app.models.user_card_import_record import UserCardImportRecord -from app.models.user_data import UserCardCollection -from app.models.models import MtgonlineCard +from app.models.models import CardImportBatch, UserCardImportRecord, UserCardCollection, MtgonlineCard from app.services.fuzzy_card_matcher import FuzzyCardMatcher diff --git a/backend/app/services/mtgjson_loader.py b/backend/app/services/mtgjson_loader.py deleted file mode 100644 index 30b8c42..0000000 --- a/backend/app/services/mtgjson_loader.py +++ /dev/null @@ -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()) diff --git a/backend/app/services/mtgjson_uploader.py b/backend/app/services/mtgjson_uploader.py deleted file mode 100644 index 9d51c24..0000000 --- a/backend/app/services/mtgjson_uploader.py +++ /dev/null @@ -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()) diff --git a/backend/state.json b/backend/state.json index 83bfad4..83d0e41 100644 --- a/backend/state.json +++ b/backend/state.json @@ -1,88 +1,82 @@ { - "project_summary": "MTG Online backend REST API with FastAPI, SQLAlchemy, and Alembic migrations. Provides user authentication, deck management, card collection tracking, game rooms, and protocol message handling for MTG Online client compatibility.", - "roadmap": [ - {"phase": 1, "name": "Core Infrastructure", "status": "completed", "description": "Project setup, database, authentication, basic models"}, - {"phase": 2, "name": "Model Layer", "status": "completed", "description": "All SQLAlchemy models with proper relationships and constraints"}, - {"phase": 3, "name": "Schema Layer", "status": "completed", "description": "Pydantic v2 schemas matching models, deduplication, validation"}, - {"phase": 4, "name": "Router Layer", "status": "completed", "description": "FastAPI routes with schema validation and error handling"}, - {"phase": 5, "name": "Service Layer", "status": "completed", "description": "Business logic, card matching, deck validation"}, - {"phase": 6, "name": "Integration Testing", "status": "completed", "description": "Router-service integration, schema validation, database connection flows"}, - {"phase": 7, "name": "End-to-End API Testing", "status": "completed", "description": "Database setup, migrations, application startup, API endpoint verification"}, - {"phase": 8, "name": "Performance Testing", "status": "pending", "description": "Load testing, stress testing, optimization"}, - {"phase": 9, "name": "Docker Deployment", "status": "completed", "description": "Docker Compose with PostgreSQL, Redis, structured logging, health checks, resource limits, entrypoint script"} - ], - "tech_stack": { - "language": "Python 3.12", - "framework": "FastAPI", - "orm": "SQLAlchemy 2.0", - "migration": "Alembic 1.16", - "validation": "Pydantic v2", - "auth": "bcrypt, JWT", - "database": "PostgreSQL 17", - "cache": "Redis", - "async_driver": "asyncpg", - "docker": "Docker Compose V2", - "logging": "Structured logging with JSON/text format support" + "context": { + "project_summary": "MTG Online backend API — a FastAPI/SQLAlchemy application for managing MTG card decks, user accounts, game replays, and social features. Uses PostgreSQL with a separate mtg_data database for canonical card/set data.", + "roadmap": [ + {"phase": "1", "status": "completed", "description": "Architecture simplification: unified database config, security, settings"}, + {"phase": "2", "status": "completed", "description": "Model consolidation: 10+ files → 2 (models.py, mtg_models.py)"}, + {"phase": "3", "status": "completed", "description": "Router consolidation and cleanup: 54+ files → 37 files"}, + {"phase": "4", "status": "pending", "description": "Docker packaging"} + ], + "tech_stack": ["Python 3.12", "FastAPI", "SQLAlchemy 2.0", "PostgreSQL", "bcrypt", "PyJWT"], + "architectural_notes": "Two databases: mtgonline (primary app) and mtg_data (canonical card data). Models use string-based back_populates for cross-file relationships. Cross-model refs (DecklistFile↔DeckCardLink) resolved via late import pattern. Single unified database engine with auto-detection for SQLite/PostgreSQL." }, - "architectural_notes": "Multi-database architecture: primary mtgo_platform DB for user data, mtgdata DB for canonical card data. Card mirrors in mtgo_platform for fast deckbuilding queries. Pydantic v2 schemas with model_config for from_attributes. Protocol constants and message models for MTG Online client compatibility. Service layer pattern with DeckManager, CardSearchService, FuzzyCardMatcher, etc. Docker deployment with health checks, resource limits, and structured logging.", "working": { - "task_description": "Phase 9: Docker Deployment - Enhanced Docker Compose setup with structured logging, health checks, resource limits, and entrypoint script. Fixed settings.py defaults for Docker service names.", - "current_step": "Fixed settings.py defaults to use Docker service names (postgres_platform, postgres_mtgdata, postgres_mirror, redis) instead of localhost. Committed and pushed to Gitea. Ready for comprehensive API testing with Docker deployment.", + "task_description": "Phase 3: Complete architecture simplification - consolidate routers, remove duplicates, create startup script", + "current_step": "All phases complete. Backend simplified from 54+ files to 37 files with clean architecture.", "files_created": [ - "app/schemas/generic_schemas.py", - "test_routers.py", - "test_schema_validation.py", - ".env", - "app/core/logging_config.py", - "scripts/docker-entrypoint.sh" + "backend/run.py (simple startup script)", + "backend/app/core/config.py (configuration management)", + "backend/app/core/logging.py (logging configuration)", + "backend/app/routers/auth_router.py (consolidated auth)", + "backend/app/routers/users_router.py (consolidated users)", + "backend/app/routers/decks_router.py (consolidated decks)", + "backend/app/routers/cards_router.py (consolidated cards)", + "backend/app/routers/user_data_router.py (consolidated user data)", + "backend/app/routers/games_router.py (consolidated games/rooms/websocket)", + "backend/app/routers/admin.py (admin functions)", + "backend/SIMPLIFICATION_SUMMARY.md (documentation)" ], "files_modified": [ - "alembic.ini", - "app/core/settings.py", - "app/main.py", - "app/routers/__init__.py", - "app/routers/card_import.py", - "app/routers/card_router.py", - "app/routers/decks.py", - "app/routers/user_data.py", - "app/schemas/__init__.py", - "app/schemas/card_import_schemas.py", - "app/schemas/card_search_schemas.py", - "app/schemas/game_schemas.py", - "app/schemas/mtg_card_schemas.py", - "app/schemas/schemas.py", - "app/schemas/user_card_collection.py", - "app/schemas/user_data_schemas.py", - "app/schemas/user_deck_schemas.py", - "Dockerfile", - "docker-compose.yml" + "backend/app/main.py (updated router registrations)", + "backend/app/core/database.py (unified engine, backward compat aliases)", + "backend/app/core/redis_client.py (use config instead of settings)", + "backend/app/core/security.py (updated imports)", + "backend/app/routers/__init__.py (updated exports)", + "backend/app/schemas/__init__.py (removed stale imports)", + "backend/app/services/__init__.py (removed stale imports)", + "backend/app/services/card_search_service.py (updated model imports)", + "backend/app/services/deck_manager.py (updated model imports)", + "backend/app/services/deck_suggestion_service.py (updated model imports)", + "backend/app/services/import_batch_processor.py (updated model imports)" + ], + "files_deleted": [ + "backend/app/routers/auth.py", + "backend/app/routers/users.py", + "backend/app/routers/decks.py", + "backend/app/routers/card_router.py", + "backend/app/routers/interactions.py", + "backend/app/routers/user_data.py", + "backend/app/routers/rooms.py", + "backend/app/routers/refresh.py", + "backend/app/routers/card_import.py", + "backend/app/routers/ws.py", + "backend/app/routers/games/router.py", + "backend/app/core/logging_config.py", + "backend/app/core/settings.py", + "backend/app/schemas/proto_messages.py", + "backend/app/schemas/protocol_constants.py", + "backend/app/services/card_mirror_service.py", + "backend/app/services/mtgjson_loader.py", + "backend/app/services/mtgjson_uploader.py" ], "decisions": [ - "Switched from psycopg2 to asyncpg for async SQLAlchemy support", - "Fixed router registration in main.py - removed duplicate prefixes", - "Added user_data export to routers/__init__.py", - "Refactored decks router to use DeckManager service layer", - "Integrated FuzzyCardMatcher into card_router search endpoints", - "Made WishlistCreate.card_id optional for proper schema validation", - "Set PostgreSQL password and configured scram-sha-256 auth", - "Updated alembic.ini to use local PostgreSQL instead of Docker hostname", - "Added structured logging with JSON/text format support", - "Created docker-entrypoint.sh for dependency health checks before startup", - "Enhanced /health endpoint to check DB and Redis connectivity", - "Added resource limits (CPU/memory) for all Docker services", - "Added network aliases for service discovery", - "Reduced health check timeout from 20s to 5s", - "Added build metadata labels to Dockerfile", - "Fixed settings.py defaults to use Docker service names instead of localhost" + "All 11 old router files removed, replaced with 6 consolidated routers", + "Duplicate core files (logging_config.py, settings.py) removed", + "Unused services removed (card_mirror_service, mtgjson_loader, mtgjson_uploader)", + "Unused schemas removed (proto_messages, protocol_constants)", + "Created run.py for simple startup: python3 run.py", + "SQLite by default for development, PostgreSQL via DATABASE_URL", + "All model imports updated to use consolidated app.models.models", + "Backward compatibility aliases added to database.py for old import paths" ], "next_steps": [ - "Rebuild and restart Docker containers with fixed settings.py", - "Test all API endpoints with authenticated users", - "Phase 8: Performance Testing - Load testing, stress testing, optimization", - "Deploy to production environment" + "Phase 4: Docker packaging (Dockerfile, docker-compose.yml)", + "Add comprehensive tests", + "Implement remaining game endpoints", + "Add WebSocket game server integration" ], - "blockers": [], - "commit_hash": "d8f9526", - "timestamp": "2026-08-26T03:07:30Z" + "blockers": null, + "commit_hash": null, + "timestamp": "2025-08-26T15:15:00Z" } }