Complete architecture simplification: 54+ files → 37 files

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

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

Testing:
- All imports verified working
- App loads successfully
- Ready for development and testing
This commit is contained in:
2026-08-26 15:15:36 +00:00
parent 03f5433583
commit 900f51af3a
45 changed files with 5823 additions and 5460 deletions
+20 -22
View File
@@ -3,29 +3,27 @@ Router package exports.
All routers are mounted in app/main.py.
This package provides centralized access to all router modules.
## Consolidated Routers (Phase 3 Simplification)
- auth_router.py - Authentication endpoints
- users_router.py - User management + admin operations
- decks_router.py - Deck building and management
- cards_router.py - Card data search and interactions
- user_data_router.py - User data, sessions, and card imports
- games_router.py - Room management, games, and MTGJSON refresh
"""
from app.routers import auth
from app.routers import users
from app.routers import decks
from app.routers import rooms
from app.routers import games
from app.routers import admin
from app.routers import card_router
from app.routers import interactions
from app.routers import refresh
from app.routers import card_import
from app.routers import user_data
from app.routers import auth_router
from app.routers import users_router
from app.routers import decks_router
from app.routers import cards_router
from app.routers import user_data_router
from app.routers import games_router
__all__ = [
"auth",
"users",
"decks",
"rooms",
"games",
"admin",
"card_router",
"interactions",
"refresh",
"card_import",
"user_data",
"auth_router",
"users_router",
"decks_router",
"cards_router",
"user_data_router",
"games_router",
]
@@ -1,18 +1,34 @@
"""Authentication router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
"""
Authentication Router
This module provides the authentication router for the MTG Online application.
It handles user authentication, registration, and session management through
JWT (JSON Web Token) based authentication.
The router provides the following endpoints:
- POST /login: Authenticate user credentials and return JWT tokens
- POST /refresh: Refresh an expired access token using a refresh token
- POST /register: Register a new user account
- GET /me: Retrieve the current authenticated user's profile
All endpoints use async SQLAlchemy for database operations and FastAPI
dependencies for database session management.
"""
from datetime import datetime, timezone
from typing import Optional
from datetime import datetime, timezone, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import (
verify_password,
hash_password,
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from app.models.models import User
from app.schemas.schemas import (
@@ -28,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)
-368
View File
@@ -1,368 +0,0 @@
"""
Card import router endpoints.
Provides endpoints for importing card collections from files,
viewing import status, and confirming imports.
"""
import json
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.card_import_batch import CardImportBatch
from app.models.user_card_import_record import UserCardImportRecord
from app.models.user_data import UserCardCollection
from app.models.models import MtgonlineCard
from app.models.user_deck import UserDeck, UserDeckCard
from app.services.file_parser import FileParser
from app.services.import_batch_processor import ImportBatchProcessor
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
from app.schemas.card_import_schemas import (
CardImportRequest,
CardImportResponse,
CardImportStatusResponse,
CardMatchResult,
CardImportSummary,
)
from app.schemas.generic_schemas import MessageResponse
from app.schemas.user_deck_schemas import DeckCardResponse, DeckCardListResponse
router = APIRouter()
@router.post("/import", response_model=CardImportResponse, status_code=status.HTTP_201_CREATED)
async def upload_card_import(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Upload a card import file (XLSX, CSV, JSON, ODS).
Parses the file, performs fuzzy matching, and creates an import batch.
"""
user_id = int(current_user["user_id"])
# Validate file type
if not file.filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must have a filename"
)
file_type = file.filename.split(".")[-1].lower()
supported_types = ["xlsx", "csv", "json", "ods"]
if file_type not in supported_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported file type: {file_type}. Supported types: {supported_types}"
)
# Read file content
content = await file.read()
file_size = len(content)
# Parse file
from pathlib import Path
import tempfile
with tempfile.NamedTemporaryFile(suffix=f".{file_type}", delete=False) as tmp_file:
tmp_file.write(content)
tmp_file_path = Path(tmp_file.name)
try:
card_names = await FileParser.parse_file(tmp_file_path)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to parse file: {str(e)}"
)
if not card_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File contains no card names"
)
# Create import batch
batch = await ImportBatchProcessor.create_batch(
db=db,
user_id=user_id,
filename=file.filename,
file_type=file_type,
file_size=file_size,
card_names=card_names,
)
# Process batch
result = await ImportBatchProcessor.process_batch(
db=db,
batch=batch,
card_names=card_names,
)
return CardImportResponse(
message=f"Import batch created successfully",
batch_id=batch.id,
card_count=len(card_names),
status=result["status"],
imported_at=datetime.utcnow(),
)
@router.get("/import/{import_id}/status", response_model=CardImportStatusResponse)
async def get_import_status(
import_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get the status of an import batch."""
user_id = int(current_user["user_id"])
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
if not batch:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Import batch {import_id} not found"
)
if batch.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
return CardImportStatusResponse(
has_import=True,
batch_id=batch.id,
status=batch.status,
card_count=batch.total_cards,
matched_count=batch.matched_cards,
unmatched_count=batch.unmatched_cards,
last_imported=batch.updated_at,
error_message=batch.error_message,
)
@router.get("/import/{import_id}/results", response_model=CardImportSummary)
async def get_import_results(
import_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get the match results for an import batch."""
user_id = int(current_user["user_id"])
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
if not batch:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Import batch {import_id} not found"
)
if batch.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
if batch.status != "completed":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Import batch {import_id} has not completed yet (status: {batch.status})"
)
match_results = batch.match_results or []
matched_cards = []
unmatched_cards = []
for original_name, card_id, matched_name, confidence, match_type in match_results:
if matched_name:
matched_cards.append(CardMatchResult(
card_id=card_id,
card_name=original_name,
matched_name=matched_name,
match_type=match_type,
confidence=confidence,
))
else:
unmatched_cards.append(original_name)
return CardImportSummary(
total_cards=len(match_results),
matched_cards=matched_cards,
unmatched_cards=unmatched_cards,
import_id=batch.id,
)
@router.post("/import/{import_id}/confirm", response_model=MessageResponse)
async def confirm_import(
import_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Confirm an import batch and save to user's card collection."""
user_id = int(current_user["user_id"])
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
if not batch:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Import batch {import_id} not found"
)
if batch.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
if batch.status != "completed":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Import batch {import_id} has not completed yet (status: {batch.status})"
)
# Confirm batch
try:
await ImportBatchProcessor.confirm_batch(db, import_id, user_id)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# Save matched cards to user's collection
match_results = batch.match_results or []
saved_count = 0
for original_name, card_id, matched_name, confidence, match_type in match_results:
if card_id and match_type in ["exact", "high_confidence"]:
# Check if already in collection
stmt = select(UserCardCollection).where(
UserCardCollection.user_id == user_id,
UserCardCollection.card_id == card_id,
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
existing.quantity += 1
else:
new_collection = UserCardCollection(
user_id=user_id,
card_id=card_id,
quantity=1,
)
db.add(new_collection)
saved_count += 1
await db.flush()
return MessageResponse(
message=f"Import confirmed. {saved_count} cards added to your collection."
)
@router.get("/user/cards", response_model=List[Dict[str, Any]])
async def get_user_cards(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List user's imported cards."""
user_id = int(current_user["user_id"])
stmt = select(UserCardCollection).where(UserCardCollection.user_id == user_id).order_by(UserCardCollection.created_at.desc())
result = await db.execute(stmt)
collections = result.scalars().all()
# Fetch card details
card_ids = [c.card_id for c in collections]
card_details = {}
if card_ids:
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
card_result = await db.execute(card_stmt)
for card in card_result.scalars().all():
card_details[card.id] = card
result_list = []
for collection in collections:
card = card_details.get(collection.card_id)
result_list.append({
"collection_id": collection.id,
"card_id": collection.card_id,
"card_name": card.name if card else f"Card#{collection.card_id}",
"card_type_line": card.type_line if card else "",
"quantity": collection.quantity,
"condition": collection.condition,
"language": collection.language,
"is_foil": collection.is_foil,
"is_alt_art": collection.is_alt_art,
"acquired_date": collection.acquired_date,
})
return result_list
@router.delete("/user/cards/{card_import_id}", response_model=MessageResponse)
async def delete_user_card(
card_import_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Remove a card from user's collection."""
user_id = int(current_user["user_id"])
stmt = select(UserCardCollection).where(
UserCardCollection.id == card_import_id,
UserCardCollection.user_id == user_id,
)
result = await db.execute(stmt)
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Card not found in your collection"
)
await db.delete(collection)
await db.flush()
return MessageResponse(message="Card removed from your collection")
@router.get("/user/decks", response_model=List[Dict[str, Any]])
async def get_user_decks(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List user's decks."""
user_id = int(current_user["user_id"])
stmt = select(UserDeck).where(UserDeck.user_id == user_id).order_by(UserDeck.updated_at.desc())
result = await db.execute(stmt)
decks = result.scalars().all()
result_list = []
for deck in decks:
result_list.append({
"deck_id": deck.id,
"name": deck.name,
"status": deck.status,
"format": deck.format,
"folder_id": deck.folder_id,
"notes": deck.notes,
"is_precedent": deck.is_precedent,
"created_at": deck.created_at,
"updated_at": deck.updated_at,
})
return result_list
-292
View File
@@ -1,292 +0,0 @@
"""
Card search router for MTG card database.
Provides endpoints for searching and retrieving MTG card data
with filters for type, set, and color.
"""
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import mtg_get_db
from app.core.redis_client import cache_get, cache_set
from app.services.card_search_service import CardSearchService
from app.services.deck_suggestion_service import DeckSuggestionService
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
from app.models.user_deck import UserDeck
from app.models.mtg_models import MtgCard
from app.schemas.card_search_schemas import CardSearchResponse, CardResponse, SetResponse, CardTypeResponse
router = APIRouter(prefix="/api/cards", tags=["Card Search"])
@router.get("/search", response_model=CardSearchResponse)
async def search_cards_endpoint(
q: str = Query(..., min_length=1, description="Search query"),
card_type: Optional[str] = Query(None, description="Filter by card type"),
set_code: Optional[str] = Query(None, description="Filter by set code"),
color: Optional[str] = Query(None, description="Filter by color (e.g., WU, BR)"),
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(mtg_get_db),
):
"""
Search cards with filters.
Supports filtering by type, set, and color in addition to name search.
Uses fuzzy matching as a fallback when exact/partial matches are not found.
"""
cache_key = f"card_search:{q}:{card_type}:{set_code}:{color}:{limit}:{offset}"
# Check cache first
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
# Normalize the search query for consistency
normalized_query = FuzzyCardMatcher.normalize_card_name(q)
# Search cards using the existing service
results = await CardSearchService.search_cards(
db=db,
query=q,
card_type=card_type,
set_code=set_code,
color=color,
limit=limit,
offset=offset,
)
# If no results found, try fuzzy matching as a fallback
if results["total"] == 0:
fuzzy_results = await _fuzzy_search_fallback(
db=db,
query=q,
normalized_query=normalized_query,
card_type=card_type,
set_code=set_code,
color=color,
limit=limit,
offset=offset,
)
results = fuzzy_results
# Add fuzzy matching metadata to results
results["query_normalized"] = normalized_query
results["fuzzy_match"] = True
# Cache results for 5 minutes
await cache_set(cache_key, str(results), ttl=300)
return {"cached": False, "results": results}
async def _fuzzy_search_fallback(
db: AsyncSession,
query: str,
normalized_query: str,
card_type: Optional[str] = None,
set_code: Optional[str] = None,
color: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> Dict[str, Any]:
"""
Fallback fuzzy search when exact/partial matches yield no results.
Fetches all cards matching the type/set/color filters, then uses
fuzzy matching to find the best card name matches.
"""
from sqlalchemy import select, or_
# Fetch candidate cards based on non-name filters
conditions = []
if card_type:
conditions.append(MtgCard.type_line.ilike(f"%{card_type}%"))
if set_code:
conditions.append(MtgCard.set_code == set_code)
if color:
colors = [c.strip() for c in color.upper().split(",")]
for c in colors:
if c in ["W", "U", "B", "R", "G"]:
conditions.append(MtgCard.colors.ilike(f"%{c}%"))
# If no filters, fetch a broader set for fuzzy matching
if not conditions:
stmt = select(MtgCard).limit(limit * 5)
else:
stmt = select(MtgCard).where(*conditions).limit(limit * 5)
result = await db.execute(stmt)
candidate_cards = result.scalars().all()
if not candidate_cards:
return {
"cards": [],
"total": 0,
"page": offset // limit + 1,
"page_size": limit,
"total_pages": 0,
"fuzzy_fallback": True,
"message": "No cards found matching your query.",
}
# Build candidate name list and lookup
candidate_names = [card.name for card in candidate_cards if card.name]
card_lookup = {card.name.lower(): card for card in candidate_cards if card.name}
# Use fuzzy matching to find best matches
matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match(
normalized_query, candidate_names, threshold=FuzzyCardMatcher.MIN_MATCH_THRESHOLD
)
# Build results from fuzzy matches
card_list = []
if matched_name and matched_name.lower() in card_lookup:
card = card_lookup[matched_name.lower()]
card_data = {
"id": card.id,
"name": card.name,
"mana_cost": card.mana_cost,
"type_line": card.type_line,
"oracle_text": card.oracle_text,
"power": card.power,
"toughness": card.toughness,
"rarity": card.rarity,
"layout": card.layout,
"colors": card.colors,
"set_code": card.set_code,
"set_name": card.set_name,
"fuzzy_match": True,
"match_confidence": confidence,
"match_type": match_type,
"original_query": query,
}
card_list.append(card_data)
return {
"cards": card_list,
"total": len(card_list),
"page": offset // limit + 1,
"page_size": limit,
"total_pages": (len(card_list) + limit - 1) // limit if card_list else 0,
"fuzzy_fallback": True,
"match_type": match_type,
"confidence": confidence,
}
@router.get("/{card_id}", response_model=CardResponse)
async def get_card_endpoint(
card_id: int,
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get a specific card by ID.
"""
cache_key = f"card_by_id:{card_id}"
# Check cache first
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
# Get card
card = await CardSearchService.get_card_by_id(db, card_id)
if not card:
raise HTTPException(status_code=404, detail="Card not found")
# Cache for 10 minutes
await cache_set(cache_key, str(card), ttl=600)
return {"cached": False, "results": card}
@router.get("/sets", response_model=List[SetResponse])
async def get_sets_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all available sets.
"""
cache_key = "all_sets:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
sets = await CardSearchService.get_sets(db)
# Cache for 1 hour
await cache_set(cache_key, str(sets), ttl=3600)
return {"cached": False, "results": sets}
@router.get("/types", response_model=List[CardTypeResponse])
async def get_card_types_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all unique card types.
"""
cache_key = "card_types:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
types = await CardSearchService.get_card_types(db)
# Cache for 30 minutes
await cache_set(cache_key, str(types), ttl=1800)
return {"cached": False, "results": types}
@router.get("/rarities", response_model=List[str])
async def get_card_rarities_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all unique card rarities.
"""
cache_key = "card_rarities:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
rarities = await CardSearchService.get_card_rarities(db)
# Cache for 30 minutes
await cache_set(cache_key, str(rarities), ttl=1800)
return {"cached": False, "results": rarities}
@router.get("/suggest", response_model=List[Dict[str, Any]])
async def suggest_cards_endpoint(
deck_id: int = Query(..., description="Deck ID to suggest cards for"),
limit: int = Query(20, ge=1, le=100, description="Maximum suggestions"),
db: AsyncSession = Depends(mtg_get_db),
):
"""
Suggest similar cards for a deck.
Matches by: same type, same color, same set, same mana cost,
and cards often paired in existing user decks.
"""
# Verify deck exists
stmt = select(UserDeck).where(UserDeck.id == deck_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
suggestions = await DeckSuggestionService.suggest_cards(db, deck_id, limit)
return suggestions
File diff suppressed because it is too large Load Diff
@@ -1,39 +1,156 @@
"""
Deck management router endpoints for per-user deck building.
Deck Management Router — Complete Deck Building Functionality.
Provides CRUD operations for user decks with card management,
deck finalization, precedent templates, and card search integration.
This router provides the full deck building workflow for MTG Online users,
including:
* CRUD operations for per-user decklists (draft, final, precedent).
* Card management within decks (add, update, remove, list).
* Deck finalization (transition from DRAFT to FINAL status).
* Precedent (template) management — list, create, and clone into new decks.
* Card search integration using the local card mirror.
* Card suggestion system for deck refinement.
All endpoints require authentication via ``get_current_user`` and enforce
per-user ownership checks where appropriate.
Routes
------
Deck CRUD::
GET / List user decks (paginated, filterable)
POST / Create a new draft deck
GET /{deck_id} Get a specific deck
PATCH /{deck_id} Update deck metadata
DELETE /{deck_id} Delete a deck
Deck Finalize::
POST /{deck_id}/finalize Transition deck to FINAL status
Deck Card Management::
POST /{deck_id}/cards Add a card to a deck
GET /{deck_id}/cards List cards in a deck
PATCH /{deck_id}/cards/{card_id} Update card quantity/zone
DELETE /{deck_id}/cards/{card_id} Remove a card from a deck
Precedents::
GET /precedents List public precedents
POST /precedents Create a precedent
GET /precedents/{id} Get a specific precedent
POST /precedents/{id}/use Clone precedent into a new draft deck
Card Search::
POST /search/cards Search the local card mirror
Suggestions::
GET /{deck_id}/suggestions List suggestions for a deck
POST /{deck_id}/suggestions Add a suggestion to a deck
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_, update, delete
from __future__ import annotations
from typing import Optional, List
from app.core.database import get_db, mtg_get_db
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import User, DecklistFolder, MtgonlineCard
from app.models.mtg_models import MtgCard, MtgSet
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
from app.schemas.user_deck_schemas import (
UserDeckCreate, UserDeckUpdate, UserDeckResponse, UserDeckListResponse,
DeckCardCreate, DeckCardUpdate, DeckCardResponse, DeckCardWithDetailsResponse, DeckCardListResponse,
PrecedentCreate, PrecedentUpdate, PrecedentResponse, PrecedentListResponse,
SuggestionCreate, SuggestionResponse, SuggestionListResponse,
DeckFinalizeRequest, DeckFinalizeResponse,
CardSearchRequest,
from app.models.models import MtgonlineCard, User, DecklistFolder
from app.models.models import (
CardSuggestion,
DeckPrecedent,
DeckPrecedentCard,
UserDeck,
UserDeckCard,
)
from app.schemas.card_search_schemas import CardSearchResponse
from app.schemas.generic_schemas import MessageResponse, CountResponse
from app.schemas.generic_schemas import CountResponse, MessageResponse
from app.schemas.user_deck_schemas import (
CardSearchRequest,
DeckCardCreate,
DeckCardResponse,
DeckCardUpdate,
DeckCardListResponse,
DeckCardWithDetailsResponse,
DeckFinalizeRequest,
DeckFinalizeResponse,
PrecedentCreate,
PrecedentResponse,
PrecedentListResponse,
PrecedentUpdate,
SuggestionCreate,
SuggestionListResponse,
SuggestionResponse,
UserDeckCreate,
UserDeckResponse,
UserDeckUpdate,
UserDeckListResponse,
)
from app.services.deck_manager import DeckManager
from app.services.deck_parser import DeckParser
router = APIRouter()
_deck_mgr = DeckManager()
_deck_parser = DeckParser()
_deck_mgr: DeckManager = DeckManager()
_deck_parser: DeckParser = DeckParser()
# ===== Deck CRUD =====
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_user_id(current_user: dict) -> int:
"""Extract and convert the authenticated user's ID."""
return int(current_user["user_id"])
async def _verify_deck_ownership(
db: AsyncSession,
deck_id: int,
user_id: int,
) -> UserDeck:
"""Verify that a deck exists and belongs to the user.
Raises:
HTTPException: 404 if the deck is not found.
"""
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
return deck
async def _get_card_count(db: AsyncSession, deck_id: int) -> int:
"""Return the number of cards in a deck."""
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
return count_result.scalar() or 0
async def _enrich_deck_response(
deck: UserDeck,
card_count: int,
) -> UserDeckResponse:
"""Build a ``UserDeckResponse`` with card count and ownership flag."""
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
# ---------------------------------------------------------------------------
# Deck CRUD
# ---------------------------------------------------------------------------
@router.get("/", response_model=UserDeckListResponse)
async def list_user_decks(
@@ -44,9 +161,13 @@ async def list_user_decks(
page_size: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List user's decks with optional filtering."""
user_id = int(current_user["user_id"])
) -> UserDeckListResponse:
"""List user's decks with optional filtering.
Supports filtering by deck status, folder, and whether the deck is a
precedent. Results are paginated.
"""
user_id = _get_user_id(current_user)
try:
decks = await _deck_mgr.list_decks(
db=db,
@@ -60,10 +181,10 @@ async def list_user_decks(
# Build response with card counts
deck_ids = [d.id for d in decks]
card_counts = {}
card_counts: dict[int, int] = {}
if deck_ids:
count_subquery = (
select(UserDeckCard.deck_id, func.count().label('cnt'))
select(UserDeckCard.deck_id, func.count().label("cnt"))
.where(UserDeckCard.deck_id.in_(deck_ids))
.group_by(UserDeckCard.deck_id)
.subquery()
@@ -74,7 +195,7 @@ async def list_user_decks(
count_result = await db.execute(count_stmt)
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
deck_responses = []
deck_responses: list[UserDeckResponse] = []
for deck in decks:
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_counts.get(deck.id, 0)
@@ -98,9 +219,9 @@ async def create_user_deck(
request: UserDeckCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new user deck (DRAFT status)."""
user_id = int(current_user["user_id"])
) -> UserDeckResponse:
"""Create a new user deck in DRAFT status."""
user_id = _get_user_id(current_user)
try:
new_deck = await _deck_mgr.create_deck(
db=db,
@@ -129,23 +250,17 @@ async def get_user_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a specific user deck."""
user_id = int(current_user["user_id"])
) -> UserDeckResponse:
"""Get a specific user deck by ID."""
user_id = _get_user_id(current_user)
try:
deck = await _deck_mgr.get_deck(db=db, deck_id=deck_id, user_id=user_id)
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Get card count
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
card_count = await _get_card_count(db, deck_id)
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
return await _enrich_deck_response(deck, card_count)
except HTTPException:
raise
except Exception as e:
@@ -158,9 +273,9 @@ async def update_user_deck(
request: UserDeckUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a user deck."""
user_id = int(current_user["user_id"])
) -> UserDeckResponse:
"""Update a user deck's metadata."""
user_id = _get_user_id(current_user)
try:
update_data = request.model_dump(exclude_unset=True)
if "status" in update_data and update_data["status"]:
@@ -176,15 +291,8 @@ async def update_user_deck(
if not updated_deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Get card count
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
deck_data = UserDeckResponse.model_validate(updated_deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
card_count = await _get_card_count(db, deck_id)
return await _enrich_deck_response(updated_deck, card_count)
except HTTPException:
raise
except ValueError as e:
@@ -198,9 +306,9 @@ async def delete_user_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> MessageResponse:
"""Delete a user deck."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
try:
result = await _deck_mgr.delete_deck(db=db, deck_id=deck_id, user_id=user_id)
if not result:
@@ -212,16 +320,18 @@ async def delete_user_deck(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
# ===== Deck Finalize =====
# ---------------------------------------------------------------------------
# Deck Finalize
# ---------------------------------------------------------------------------
@router.post("/{deck_id}/finalize", response_model=DeckFinalizeResponse)
async def finalize_user_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> DeckFinalizeResponse:
"""Transition a deck from DRAFT to FINAL status."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
try:
deck = await _deck_mgr.finalize_deck(db=db, deck_id=deck_id, user_id=user_id)
if not deck:
@@ -239,7 +349,9 @@ async def finalize_user_deck(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ===== Deck Card Management =====
# ---------------------------------------------------------------------------
# Deck Card Management
# ---------------------------------------------------------------------------
@router.post("/{deck_id}/cards", response_model=DeckCardResponse, status_code=status.HTTP_201_CREATED)
async def add_deck_card(
@@ -247,16 +359,15 @@ async def add_deck_card(
request: DeckCardCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Add a card to a user's deck."""
user_id = int(current_user["user_id"])
) -> DeckCardResponse:
"""Add a card to a user's deck.
# Verify deck exists and belongs to user
deck_stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
deck_result = await db.execute(deck_stmt)
deck = deck_result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
If a card already exists in the same zone, the quantity is incremented
rather than creating a duplicate entry.
"""
user_id = _get_user_id(current_user)
deck = await _verify_deck_ownership(db, deck_id, user_id)
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
@@ -308,31 +419,25 @@ async def get_deck_cards(
zone: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get all cards in a user's deck."""
user_id = int(current_user["user_id"])
) -> DeckCardListResponse:
"""Get all cards in a user's deck, optionally filtered by zone."""
user_id = _get_user_id(current_user)
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
await _verify_deck_ownership(db, deck_id, user_id)
# Use service to get cards
deck_cards = await _deck_mgr.get_deck_cards(db=db, deck_id=deck_id, zone=zone)
# Fetch card details from local mirror
card_ids = [dc.card_id for dc in deck_cards]
card_details = {}
card_details: dict[int, MtgonlineCard] = {}
if card_ids:
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
card_result = await db.execute(card_stmt)
for c in card_result.scalars().all():
card_details[c.id] = c
card_responses = []
card_responses: list[DeckCardWithDetailsResponse] = []
for dc in deck_cards:
card = card_details.get(dc.card_id)
response = DeckCardWithDetailsResponse.model_validate(dc)
@@ -351,17 +456,11 @@ async def update_deck_card(
request: DeckCardUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> DeckCardResponse:
"""Update a card's quantity or zone in a deck."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
deck = await _verify_deck_ownership(db, deck_id, user_id)
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
@@ -398,17 +497,11 @@ async def remove_deck_card(
card_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> MessageResponse:
"""Remove a card from a user's deck."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
deck = await _verify_deck_ownership(db, deck_id, user_id)
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
@@ -430,7 +523,9 @@ async def remove_deck_card(
return MessageResponse(message="Card removed from deck")
# ===== Deck Precedents =====
# ---------------------------------------------------------------------------
# Deck Precedents
# ---------------------------------------------------------------------------
# Note: Precedent endpoints use direct DB operations as DeckManager
# does not yet have precedent-specific methods.
@@ -441,11 +536,11 @@ async def list_precedents(
format_filter: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List available deck precedents."""
) -> PrecedentListResponse:
"""List available (public) deck precedents, paginated."""
offset = (page - 1) * page_size
conditions = [DeckPrecedent.is_public == True]
conditions = [DeckPrecedent.is_public == True] # noqa: E712
if format_filter:
conditions.append(DeckPrecedent.format == format_filter)
@@ -461,10 +556,10 @@ async def list_precedents(
# Get card counts
prec_ids = [p.id for p in precedents]
card_counts = {}
card_counts: dict[int, int] = {}
if prec_ids:
count_subquery = (
select(DeckPrecedentCard.precedent_id, func.count().label('cnt'))
select(DeckPrecedentCard.precedent_id, func.count().label("cnt"))
.where(DeckPrecedentCard.precedent_id.in_(prec_ids))
.group_by(DeckPrecedentCard.precedent_id)
.subquery()
@@ -475,7 +570,7 @@ async def list_precedents(
count_result = await db.execute(count_stmt)
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
prec_responses = []
prec_responses: list[PrecedentResponse] = []
for p in precedents:
prec_data = PrecedentResponse.model_validate(p)
prec_data.card_count = card_counts.get(p.id, 0)
@@ -495,9 +590,9 @@ async def create_precedent(
request: PrecedentCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> PrecedentResponse:
"""Create a deck precedent (template)."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
new_precedent = DeckPrecedent(
name=request.name,
@@ -519,8 +614,8 @@ async def get_precedent(
precedent_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a specific deck precedent."""
) -> PrecedentResponse:
"""Get a specific deck precedent by ID."""
stmt = select(DeckPrecedent).where(DeckPrecedent.id == precedent_id)
result = await db.execute(stmt)
precedent = result.scalar_one_or_none()
@@ -528,10 +623,7 @@ async def get_precedent(
if not precedent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Precedent not found")
# Get card count
count_stmt = select(func.count()).select_from(DeckPrecedentCard).where(DeckPrecedentCard.precedent_id == precedent_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
card_count = await _get_card_count(db, precedent_id)
prec_data = PrecedentResponse.model_validate(precedent)
prec_data.card_count = card_count
@@ -543,9 +635,9 @@ async def use_precedent(
precedent_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> dict:
"""Clone a precedent into a new draft deck for the current user."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
try:
new_deck = await _deck_mgr.clone_precedent(
@@ -553,11 +645,12 @@ async def use_precedent(
precedent_id=precedent_id,
user_id=user_id,
)
card_count = len(await _deck_mgr.get_deck_cards(db=db, deck_id=new_deck.id))
return {
"message": "Precedent cloned into new deck",
"deck_id": new_deck.id,
"deck_name": new_deck.name,
"card_count": len(await _deck_mgr.get_deck_cards(db=db, deck_id=new_deck.id)),
"card_count": card_count,
}
except ValueError as e:
if "not found" in str(e).lower():
@@ -565,15 +658,21 @@ async def use_precedent(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ===== Card Search Integration =====
# ---------------------------------------------------------------------------
# Card Search Integration
# ---------------------------------------------------------------------------
@router.post("/search/cards", response_model=CardSearchResponse)
async def search_cards_for_deck(
request: CardSearchRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Search MTG cards for use in deck building."""
) -> CardSearchResponse:
"""Search MTG cards for use in deck building.
Searches across name, type line, and mana cost fields in the local
card mirror.
"""
offset = (request.offset // request.limit) * request.limit
# Search across multiple fields using local mirror
@@ -593,7 +692,7 @@ async def search_cards_for_deck(
result = await db.execute(stmt)
cards = result.scalars().all()
card_list = []
card_list: list[dict] = []
for card in cards:
card_data = {
"id": card.id,
@@ -634,7 +733,9 @@ async def search_cards_for_deck(
)
# ===== Card Suggestions =====
# ---------------------------------------------------------------------------
# Card Suggestions
# ---------------------------------------------------------------------------
# Note: Suggestion endpoints use direct DB operations as DeckManager
# does not yet have suggestion-specific methods.
@@ -644,17 +745,11 @@ async def get_deck_suggestions(
suggestion_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get card suggestions for a deck."""
user_id = int(current_user["user_id"])
) -> SuggestionListResponse:
"""Get card suggestions for a deck, optionally filtered by type."""
user_id = _get_user_id(current_user)
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
await _verify_deck_ownership(db, deck_id, user_id)
conditions = [CardSuggestion.deck_id == deck_id]
if suggestion_type:
@@ -667,14 +762,14 @@ async def get_deck_suggestions(
# Fetch card names from local mirror
card_ids = [s.card_id for s in suggestions]
card_names = {}
card_names: dict[int, str] = {}
if card_ids:
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
card_result = await db.execute(card_stmt)
for c in card_result.scalars().all():
card_names[c.id] = c.name
sugg_responses = []
sugg_responses: list[SuggestionResponse] = []
for s in suggestions:
sugg_data = SuggestionResponse.model_validate(s)
sugg_data.card_name = card_names.get(s.card_id, f"Card#{s.card_id}")
@@ -692,17 +787,11 @@ async def add_suggestion(
request: SuggestionCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
) -> SuggestionResponse:
"""Add a card suggestion to a deck."""
user_id = int(current_user["user_id"])
user_id = _get_user_id(current_user)
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
deck = await _verify_deck_ownership(db, deck_id, user_id)
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
-135
View File
@@ -1,135 +0,0 @@
"""Game management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, update, delete
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import User
from app.schemas.schemas import GameCreate, GameResponse
router = APIRouter()
@router.get("/", response_model=List[GameResponse])
async def list_games(
room_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List active games."""
# This would query a games table - simplified for now
# In production, you'd have a MTG OnlineGames model
return []
@router.post("/", response_model=GameResponse)
async def create_game(
request: GameCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new game."""
user_id = int(current_user["user_id"])
# Verify user exists
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# In production, you'd create a game in the game database
# For now, return a mock response
game_data = {
"id": 1, # Mock ID
"room_id": request.room_id,
"game_type": request.game_type,
"description": request.description,
"with_password": bool(request.password),
"max_players": 4,
"player_count": 1,
"started": False,
"creation_date": datetime.now(),
}
return GameResponse(**game_data)
@router.get("/{game_id}", response_model=GameResponse)
async def get_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get game by ID."""
# In production, query the games table
return GameResponse(
id=game_id,
room_id=1,
game_type="Casual",
description="Test game",
with_password=False,
max_players=4,
player_count=0,
started=False,
creation_date=datetime.now(),
)
@router.post("/{game_id}/join")
async def join_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Join a game."""
user_id = int(current_user["user_id"])
# In production, add user to game players table
return {"message": f"User {user_id} joined game {game_id}"}
@router.post("/{game_id}/leave")
async def leave_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Leave a game."""
user_id = int(current_user["user_id"])
# In production, remove user from game players table
return {"message": f"User {user_id} left game {game_id}"}
@router.post("/{game_id}/start")
async def start_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Start a game (host only)."""
user_id = int(current_user["user_id"])
# In production, update game started status
return {"message": f"Game {game_id} started"}
@router.post("/{game_id}/end")
async def end_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""End a game."""
user_id = int(current_user["user_id"])
# In production, update game closed status
return {"message": f"Game {game_id} ended"}
+487
View File
@@ -0,0 +1,487 @@
"""
Games Router - Consolidated
This module consolidates room management, game operations, MTGJSON data refresh,
and WebSocket connections into a unified router structure.
Endpoints:
/rooms/* - Room CRUD operations (Admin/Judge only for write ops)
/games/* - Game management stubs
/mtgjson/* - MTGJSON dataset refresh and verification (Admin only)
/ws/game/{game_id} - WebSocket connection for game sessions
Architecture:
- Uses FastAPI sub-routers with different prefixes for logical grouping
- WebSocket endpoint for real-time game communication
- All endpoints require authentication via get_current_user dependency
- Admin/Judge privilege checks on sensitive operations
"""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, WebSocket, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, update, delete
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import Room, User
from app.schemas.schemas import RoomResponse, GameCreate, GameResponse
from app.services.mtgjson_manager import MTGJSONManager
from app.services.game_server import game_websocket_endpoint
# Main router
router = APIRouter()
# =============================================================================
# Room Management Sub-Router (prefix: /rooms)
# =============================================================================
rooms_router = APIRouter(prefix="/rooms", tags=["Rooms"])
@rooms_router.get("/", response_model=List[RoomResponse])
async def list_rooms(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> List[RoomResponse]:
"""List all available rooms.
Returns a list of all rooms in the system, ordered by ID.
Requires authentication.
"""
stmt = select(Room).order_by(Room.id)
result = await db.execute(stmt)
rooms = result.scalars().all()
return [RoomResponse.model_validate(room) for room in rooms]
@rooms_router.get("/{room_id}", response_model=RoomResponse)
async def get_room(
room_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> RoomResponse:
"""Get room by ID.
Returns detailed information about a specific room.
Raises 404 if room not found.
"""
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
return RoomResponse.model_validate(room)
@rooms_router.post("/", status_code=status.HTTP_201_CREATED)
async def create_room(
name: str,
description: Optional[str] = None,
is_password_protected: bool = False,
password_hash: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Create a new room (Admin/Judge only).
Creates a new game room with the specified configuration.
Only users with Admin or Judge privileges can create rooms.
Raises 409 if room name already exists.
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
# Check if room name already exists
stmt = select(Room).where(Room.name == name)
result = await db.execute(stmt)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Room name already exists",
)
new_room = Room(
name=name,
description=description,
is_password_protected=is_password_protected,
password_hash=password_hash,
)
db.add(new_room)
await db.flush()
return {"message": f"Room '{name}' created successfully", "room_id": new_room.id}
@rooms_router.patch("/{room_id}")
async def update_room(
room_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
is_password_protected: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Update room (Admin/Judge only).
Updates the specified fields of a room.
Only users with Admin or Judge privileges can update rooms.
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
# Update fields
update_data = {}
if name:
update_data["name"] = name
if description is not None:
update_data["description"] = description
if is_password_protected is not None:
update_data["is_password_protected"] = is_password_protected
stmt = (
update(Room)
.where(Room.id == room_id)
.values(**update_data)
)
await db.execute(stmt)
return {"message": f"Room '{name or room.name}' updated successfully"}
@rooms_router.delete("/{room_id}")
async def delete_room(
room_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Delete room (Admin/Judge only).
Permanently deletes a room from the system.
Only users with Admin or Judge privileges can delete rooms.
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
await db.execute(delete(Room).where(Room.id == room_id))
return {"message": f"Room '{room.name}' deleted successfully"}
# =============================================================================
# Game Management Sub-Router (prefix: /games)
# =============================================================================
games_router = APIRouter(prefix="/games", tags=["Games"])
@games_router.get("/", response_model=List[GameResponse])
async def list_games(
room_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> List[GameResponse]:
"""List active games.
Returns a list of currently active games.
Note: This is a stub implementation - returns empty list.
"""
# This would query a games table - simplified for now
# In production, you'd have a MTG OnlineGames model
return []
@games_router.post("/", response_model=GameResponse, status_code=status.HTTP_201_CREATED)
async def create_game(
request: GameCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> GameResponse:
"""Create a new game (mock implementation).
Creates a new game session.
Note: This is a stub - returns mock response.
"""
user_id = int(current_user["user_id"])
# Verify user exists
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# In production, you'd create a game in the game database
# For now, return a mock response
game_data = {
"id": 1, # Mock ID
"room_id": request.room_id,
"game_type": request.game_type,
"description": request.description,
"with_password": bool(request.password),
"max_players": 4,
"player_count": 1,
"started": False,
"creation_date": datetime.now(),
}
return GameResponse(**game_data)
@games_router.get("/{game_id}", response_model=GameResponse)
async def get_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> GameResponse:
"""Get game by ID (mock implementation).
Returns details about a specific game.
Note: This is a stub - returns mock response.
"""
# In production, query the games table
return GameResponse(
id=game_id,
room_id=1,
game_type="Casual",
description="Test game",
with_password=False,
max_players=4,
player_count=0,
started=False,
creation_date=datetime.now(),
)
@games_router.post("/{game_id}/join")
async def join_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Join a game (stub implementation).
Adds the current user to a game session.
Note: This is a stub - returns mock response.
"""
user_id = int(current_user["user_id"])
# In production, add user to game players table
return {"message": f"User {user_id} joined game {game_id}"}
@games_router.post("/{game_id}/leave")
async def leave_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Leave a game (stub implementation).
Removes the current user from a game session.
Note: This is a stub - returns mock response.
"""
user_id = int(current_user["user_id"])
# In production, remove user from game players table
return {"message": f"User {user_id} left game {game_id}"}
@games_router.post("/{game_id}/start")
async def start_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Start a game (host only, stub implementation).
Initiates a game session.
Note: This is a stub - returns mock response.
"""
user_id = int(current_user["user_id"])
# In production, update game started status
return {"message": f"Game {game_id} started"}
@games_router.post("/{game_id}/end")
async def end_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""End a game (stub implementation).
Terminates a game session.
Note: This is a stub - returns mock response.
"""
user_id = int(current_user["user_id"])
# In production, update game closed status
return {"message": f"Game {game_id} ended"}
# =============================================================================
# MTGJSON Data Refresh Sub-Router (prefix: /mtgjson)
# =============================================================================
mtgjson_router = APIRouter(prefix="/mtgjson", tags=["MTGJSON Data"])
@mtgjson_router.post("/refresh")
async def trigger_refresh(
background_tasks: BackgroundTasks,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Trigger a refresh of MTGJSON datasets (Admin only).
Downloads files from MTGJSON API and upserts data into PostgreSQL.
The refresh runs in the background and may take several minutes.
Only Admin users can trigger refreshes.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
# Create manager and trigger refresh in background
manager = MTGJSONManager()
background_tasks.add_task(manager.download_and_refresh, force=False)
return {
"message": "Refresh initiated in background",
"status": "started",
}
@mtgjson_router.get("/status")
async def get_refresh_status(
current_user: dict = Depends(get_current_user),
) -> dict:
"""Get current refresh status and data health (Admin only).
Returns information about:
- Last successful refresh timestamp
- Current data counts
- File status
- Required files status
Only Admin users can access this endpoint.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
manager = MTGJSONManager()
return await manager.get_health_status()
@mtgjson_router.post("/verify")
async def verify_files(
current_user: dict = Depends(get_current_user),
) -> dict:
"""Verify the integrity of downloaded MTGJSON files (Admin only).
Checks that all required files exist and are valid.
Only Admin users can verify files.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
manager = MTGJSONManager()
all_valid, errors = await manager.verify_files()
if not all_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"valid": False,
"errors": errors,
"message": "Some files failed verification",
},
)
return {
"valid": True,
"message": "All files verified successfully",
}
# =============================================================================
# WebSocket Endpoint
# =============================================================================
@router.websocket("/ws/game/{game_id}")
async def game_websocket(
websocket: WebSocket,
game_id: int,
player_id: Optional[int] = Query(None),
) -> None:
"""WebSocket endpoint for game connections.
Handles real-time game state updates for connected players.
Establishes a persistent connection for live game communication.
"""
await websocket.accept()
await game_websocket_endpoint(websocket, game_id)
# =============================================================================
# Include Sub-Routers
# =============================================================================
router.include_router(rooms_router)
router.include_router(games_router)
router.include_router(mtgjson_router)
-101
View File
@@ -1,101 +0,0 @@
"""
MTGJSON Data Refresh Router
Provides endpoints for triggering dataset downloads and managing refresh operations.
"""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.services.mtgjson_manager import MTGJSONManager
router = APIRouter(prefix="/mtgjson", tags=["MTGJSON Data"])
@router.post("/refresh")
async def trigger_refresh(
background_tasks: BackgroundTasks,
current_user: dict = Depends(get_current_user),
):
"""
Trigger a refresh of MTGJSON datasets.
Downloads files from MTGJSON API and upserts data into PostgreSQL.
Requires Admin privileges.
The refresh runs in the background and may take several minutes.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
# Create manager and trigger refresh in background
manager = MTGJSONManager()
background_tasks.add_task(manager.download_and_refresh, force=False)
return {
"message": "Refresh initiated in background",
"status": "started",
}
@router.get("/status")
async def get_refresh_status(current_user: dict = Depends(get_current_user)):
"""
Get current refresh status and data health.
Returns information about:
- Last successful refresh timestamp
- Current data counts
- File status
- Required files status
Requires Admin privileges.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
manager = MTGJSONManager()
return await manager.get_health_status()
@router.post("/verify")
async def verify_files(current_user: dict = Depends(get_current_user)):
"""
Verify the integrity of downloaded MTGJSON files.
Checks that all required files exist and are valid.
Requires Admin privileges.
"""
# Check if current user is admin
if current_user.get("privlevel") != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
manager = MTGJSONManager()
all_valid, errors = await manager.verify_files()
if not all_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"valid": False,
"errors": errors,
"message": "Some files failed verification",
},
)
return {
"valid": True,
"message": "All files verified successfully",
}
-158
View File
@@ -1,158 +0,0 @@
"""Room management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, update, delete
from typing import Optional, List
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import Room
from app.schemas.schemas import RoomResponse
router = APIRouter()
@router.get("/", response_model=List[RoomResponse])
async def list_rooms(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all available rooms."""
stmt = select(Room).order_by(Room.id)
result = await db.execute(stmt)
rooms = result.scalars().all()
return [RoomResponse.model_validate(room) for room in rooms]
@router.get("/{room_id}", response_model=RoomResponse)
async def get_room(
room_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get room by ID."""
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
return RoomResponse.model_validate(room)
@router.post("/")
async def create_room(
name: str,
description: Optional[str] = None,
is_password_protected: bool = False,
password_hash: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new room (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
# Check if room name already exists
stmt = select(Room).where(Room.name == name)
result = await db.execute(stmt)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Room name already exists",
)
new_room = Room(
name=name,
description=description,
is_password_protected=is_password_protected,
password_hash=password_hash,
)
db.add(new_room)
await db.flush()
return {"message": f"Room '{name}' created successfully", "room_id": new_room.id}
@router.patch("/{room_id}")
async def update_room(
room_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
is_password_protected: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update room (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
# Update fields
update_data = {}
if name:
update_data["name"] = name
if description is not None:
update_data["description"] = description
if is_password_protected is not None:
update_data["is_password_protected"] = is_password_protected
stmt = (
update(Room)
.where(Room.id == room_id)
.values(**update_data)
)
await db.execute(stmt)
return {"message": f"Room '{name or room.name}' updated successfully"}
@router.delete("/{room_id}")
async def delete_room(
room_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete room (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
await db.execute(delete(Room).where(Room.id == room_id))
return {"message": f"Room '{room.name}' deleted successfully"}
File diff suppressed because it is too large Load Diff
-168
View File
@@ -1,168 +0,0 @@
"""User management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user, hash_password
from app.models.models import User
from app.schemas.schemas import UserUpdate, UserResponse
router = APIRouter()
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get user by ID."""
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserResponse.model_validate(user)
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
request: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update user profile."""
# Users can only update their own profile unless admin
if int(current_user["user_id"]) != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot update another user's profile",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update fields
update_data = request.model_dump(exclude_unset=True)
# Hash new password if provided
if "new_password" in update_data:
update_data["password_hash"] = hash_password(update_data.pop("new_password"))
# Update user
stmt = (
update(User)
.where(User.id == user_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
# Fetch updated user
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
updated_user = result.scalar_one_or_none()
return UserResponse.model_validate(updated_user)
@router.post("/{user_id}/ban")
async def ban_user(
user_id: int,
reason: str,
expiration_time: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Ban a user (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update user ban status
from datetime import datetime
ban_ends = None
if expiration_time:
ban_ends = datetime.fromisoformat(expiration_time)
stmt = (
update(User)
.where(User.id == user_id)
.values(
is_banned=True,
ban_reason=reason,
ban_ends=ban_ends,
)
)
await db.execute(stmt)
await db.flush()
return {"message": f"User {user_id} has been banned"}
@router.post("/{user_id}/unban")
async def unban_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Unban a user (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update user ban status
stmt = (
update(User)
.where(User.id == user_id)
.values(
is_banned=False,
ban_reason=None,
ban_ends=None,
)
)
await db.execute(stmt)
await db.flush()
return {"message": f"User {user_id} has been unbanned"}
+549
View File
@@ -0,0 +1,549 @@
"""
Users and Admin Router - Consolidated Endpoints
This module consolidates user management and administrative operations into a
single router. It provides endpoints for:
User Management:
- GET /{user_id}: Retrieve user profile by ID
- PATCH /{user_id}: Update user profile (self or admin)
- POST /{user_id}/ban: Ban a user (admin/judge only)
- POST /{user_id}/unban: Unban a user (admin/judge only)
Admin Operations:
- GET /users: List all users with pagination (admin/judge only)
- GET /bans: List all bans, optionally active only (admin/judge only)
- POST /bans: Create a new ban record (admin/judge only)
- POST /bans/{ban_id}/unban: Unban via ban ID (admin/judge only)
- GET /logs: List game logs with optional room filter (admin/judge only)
- POST /audit: Create an audit log entry (admin/judge only)
Both per-user ban/unban and global ban listing/creation are maintained.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user, hash_password
from app.models.models import User, Ban, GameLog, AuditLog
from app.schemas.schemas import UserUpdate, UserResponse, BanCreate, BanResponse
router = APIRouter()
# =============================================================================
# User Management Endpoints
# =============================================================================
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> UserResponse:
"""
Get user by ID.
Args:
user_id: The ID of the user to retrieve
db: Database session
current_user: The currently authenticated user
Returns:
UserResponse with user profile data
Raises:
HTTPException 404: If user not found
"""
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserResponse.model_validate(user)
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
request: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> UserResponse:
"""
Update user profile.
Users can only update their own profile unless they have admin privileges.
Args:
user_id: The ID of the user to update
request: UserUpdate schema with fields to update
db: Database session
current_user: The currently authenticated user
Returns:
UserResponse with updated user data
Raises:
HTTPException 403: If trying to update another user's profile
HTTPException 404: If user not found
"""
# Users can only update their own profile unless admin
if int(current_user["user_id"]) != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot update another user's profile",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update fields
update_data = request.model_dump(exclude_unset=True)
# Hash new password if provided
if "new_password" in update_data:
update_data["password_hash"] = hash_password(update_data.pop("new_password"))
# Update user
stmt = (
update(User)
.where(User.id == user_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
# Fetch updated user
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
updated_user = result.scalar_one_or_none()
return UserResponse.model_validate(updated_user)
@router.post("/{user_id}/ban")
async def ban_user(
user_id: int,
reason: str,
expiration_time: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""
Ban a user (admin/judge only).
Args:
user_id: The ID of the user to ban
reason: Reason for the ban
expiration_time: Optional ISO format datetime for ban expiration
db: Database session
current_user: The currently authenticated user
Returns:
Success message
Raises:
HTTPException 403: If user is not admin/judge
HTTPException 404: If user not found
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update user ban status
ban_ends = None
if expiration_time:
ban_ends = datetime.fromisoformat(expiration_time)
stmt = (
update(User)
.where(User.id == user_id)
.values(
is_banned=True,
ban_reason=reason,
ban_ends=ban_ends,
)
)
await db.execute(stmt)
await db.flush()
return {"message": f"User {user_id} has been banned"}
@router.post("/{user_id}/unban")
async def unban_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""
Unban a user (admin/judge only).
Args:
user_id: The ID of the user to unban
db: Database session
current_user: The currently authenticated user
Returns:
Success message
Raises:
HTTPException 403: If user is not admin/judge
HTTPException 404: If user not found
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update user ban status
stmt = (
update(User)
.where(User.id == user_id)
.values(
is_banned=False,
ban_reason=None,
ban_ends=None,
)
)
await db.execute(stmt)
await db.flush()
return {"message": f"User {user_id} has been unbanned"}
# =============================================================================
# Admin Operations Endpoints
# =============================================================================
@router.get("/users", response_model=List[dict])
async def list_users(
page: int = 1,
page_size: int = 50,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> List[dict]:
"""
List all users with pagination (admin/judge only).
Args:
page: Page number (1-indexed)
page_size: Number of users per page
db: Database session
current_user: The currently authenticated user
Returns:
List of user dictionaries
Raises:
HTTPException 403: If user is not admin/judge
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = (
select(User)
.order_by(User.id)
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
users = result.scalars().all()
return [
{
"id": user.id,
"username": user.username,
"email": user.email,
"privlevel": user.privlevel,
"is_active": user.is_active,
"is_banned": user.is_banned,
"vip_status": user.vip_status,
"creation_date": user.creation_date,
}
for user in users
]
@router.get("/bans", response_model=List[BanResponse])
async def list_bans(
active_only: bool = True,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> List[BanResponse]:
"""
List all bans (admin/judge only).
Args:
active_only: If True, only return active bans
db: Database session
current_user: The currently authenticated user
Returns:
List of BanResponse objects
Raises:
HTTPException 403: If user is not admin/judge
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Ban).order_by(Ban.id)
if active_only:
stmt = stmt.where(Ban.active == True)
result = await db.execute(stmt)
bans = result.scalars().all()
return [BanResponse.model_validate(ban) for ban in bans]
@router.post("/bans", response_model=BanResponse)
async def create_ban(
request: BanCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> BanResponse:
"""
Create a new ban record (admin/judge only).
Args:
request: BanCreate schema with ban details
db: Database session
current_user: The currently authenticated user
Returns:
BanResponse with created ban data
Raises:
HTTPException 403: If user is not admin/judge
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
new_ban = Ban(
user_id=request.user_id,
reason=request.reason,
expiration_time=request.expiration_time,
moderators=current_user.get("username"),
ip_address=None, # Would get from request
)
db.add(new_ban)
await db.flush()
return BanResponse.model_validate(new_ban)
@router.post("/bans/{ban_id}/unban")
async def unban(
ban_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""
Unban a user via ban ID (admin/judge only).
This deactivates the ban record and also updates the user's ban status.
Args:
ban_id: The ID of the ban to unban
db: Database session
current_user: The currently authenticated user
Returns:
Success message
Raises:
HTTPException 403: If user is not admin/judge
HTTPException 404: If ban not found
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Ban).where(Ban.id == ban_id)
result = await db.execute(stmt)
ban = result.scalar_one_or_none()
if not ban:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ban not found",
)
# Deactivate ban
stmt = (
update(Ban)
.where(Ban.id == ban_id)
.values(active=False)
)
await db.execute(stmt)
# Also unban the user
stmt = (
update(User)
.where(User.id == ban.user_id)
.values(
is_banned=False,
ban_reason=None,
ban_ends=None,
)
)
await db.execute(stmt)
return {"message": "User unbanned successfully"}
@router.get("/logs", response_model=List[dict])
async def list_logs(
room_id: Optional[int] = None,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> List[dict]:
"""
List game logs with optional room filter (admin/judge only).
Args:
room_id: Optional room ID to filter logs
limit: Maximum number of logs to return
db: Database session
current_user: The currently authenticated user
Returns:
List of log dictionaries
Raises:
HTTPException 403: If user is not admin/judge
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(GameLog).order_by(GameLog.id.desc()).limit(limit)
if room_id:
stmt = stmt.where(GameLog.room_id == room_id)
result = await db.execute(stmt)
logs = result.scalars().all()
return [
{
"id": log.id,
"room_id": log.room_id,
"player_id": log.player_id,
"message": log.message,
"timestamp": log.timestamp,
}
for log in logs
]
@router.post("/audit", response_model=dict)
async def log_audit(
action_type: str,
target_user_id: Optional[int] = None,
details: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> dict:
"""
Create an audit log entry (admin/judge only).
Args:
action_type: Type of administrative action
target_user_id: Optional target user ID
details: Optional details about the action
db: Database session
current_user: The currently authenticated user
Returns:
Dictionary with audit log data
Raises:
HTTPException 403: If user is not admin/judge
"""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
new_audit = AuditLog(
admin_id=int(current_user["user_id"]),
action_type=action_type,
target_user_id=target_user_id,
details=details,
ip_address=None, # Would get from request
)
db.add(new_audit)
await db.flush()
return {
"id": new_audit.id,
"admin_id": new_audit.admin_id,
"action_type": new_audit.action_type,
"target_user_id": new_audit.target_user_id,
"details": new_audit.details,
"timestamp": new_audit.timestamp,
}
-18
View File
@@ -1,18 +0,0 @@
"""WebSocket router for game connections."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from typing import Optional
from app.services.game_server import game_websocket_endpoint
router = APIRouter()
@router.websocket("/ws/game/{game_id}")
async def game_websocket(
websocket: WebSocket,
game_id: int,
player_id: Optional[int] = None,
):
"""WebSocket endpoint for game connections."""
await websocket.accept()
await game_websocket_endpoint(websocket, game_id)