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:
@@ -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)
|
||||
@@ -1,368 +0,0 @@
|
||||
"""
|
||||
Card import router endpoints.
|
||||
|
||||
Provides endpoints for importing card collections from files,
|
||||
viewing import status, and confirming imports.
|
||||
"""
|
||||
import json
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.card_import_batch import CardImportBatch
|
||||
from app.models.user_card_import_record import UserCardImportRecord
|
||||
from app.models.user_data import UserCardCollection
|
||||
from app.models.models import MtgonlineCard
|
||||
from app.models.user_deck import UserDeck, UserDeckCard
|
||||
from app.services.file_parser import FileParser
|
||||
from app.services.import_batch_processor import ImportBatchProcessor
|
||||
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
|
||||
from app.schemas.card_import_schemas import (
|
||||
CardImportRequest,
|
||||
CardImportResponse,
|
||||
CardImportStatusResponse,
|
||||
CardMatchResult,
|
||||
CardImportSummary,
|
||||
)
|
||||
from app.schemas.generic_schemas import MessageResponse
|
||||
from app.schemas.user_deck_schemas import DeckCardResponse, DeckCardListResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/import", response_model=CardImportResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_card_import(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Upload a card import file (XLSX, CSV, JSON, ODS).
|
||||
|
||||
Parses the file, performs fuzzy matching, and creates an import batch.
|
||||
"""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
# Validate file type
|
||||
if not file.filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must have a filename"
|
||||
)
|
||||
|
||||
file_type = file.filename.split(".")[-1].lower()
|
||||
supported_types = ["xlsx", "csv", "json", "ods"]
|
||||
if file_type not in supported_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported file type: {file_type}. Supported types: {supported_types}"
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Parse file
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=f".{file_type}", delete=False) as tmp_file:
|
||||
tmp_file.write(content)
|
||||
tmp_file_path = Path(tmp_file.name)
|
||||
|
||||
try:
|
||||
card_names = await FileParser.parse_file(tmp_file_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to parse file: {str(e)}"
|
||||
)
|
||||
|
||||
if not card_names:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File contains no card names"
|
||||
)
|
||||
|
||||
# Create import batch
|
||||
batch = await ImportBatchProcessor.create_batch(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
filename=file.filename,
|
||||
file_type=file_type,
|
||||
file_size=file_size,
|
||||
card_names=card_names,
|
||||
)
|
||||
|
||||
# Process batch
|
||||
result = await ImportBatchProcessor.process_batch(
|
||||
db=db,
|
||||
batch=batch,
|
||||
card_names=card_names,
|
||||
)
|
||||
|
||||
return CardImportResponse(
|
||||
message=f"Import batch created successfully",
|
||||
batch_id=batch.id,
|
||||
card_count=len(card_names),
|
||||
status=result["status"],
|
||||
imported_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/import/{import_id}/status", response_model=CardImportStatusResponse)
|
||||
async def get_import_status(
|
||||
import_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get the status of an import batch."""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
|
||||
if not batch:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Import batch {import_id} not found"
|
||||
)
|
||||
|
||||
if batch.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
return CardImportStatusResponse(
|
||||
has_import=True,
|
||||
batch_id=batch.id,
|
||||
status=batch.status,
|
||||
card_count=batch.total_cards,
|
||||
matched_count=batch.matched_cards,
|
||||
unmatched_count=batch.unmatched_cards,
|
||||
last_imported=batch.updated_at,
|
||||
error_message=batch.error_message,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/import/{import_id}/results", response_model=CardImportSummary)
|
||||
async def get_import_results(
|
||||
import_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get the match results for an import batch."""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
|
||||
if not batch:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Import batch {import_id} not found"
|
||||
)
|
||||
|
||||
if batch.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
if batch.status != "completed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Import batch {import_id} has not completed yet (status: {batch.status})"
|
||||
)
|
||||
|
||||
match_results = batch.match_results or []
|
||||
|
||||
matched_cards = []
|
||||
unmatched_cards = []
|
||||
|
||||
for original_name, card_id, matched_name, confidence, match_type in match_results:
|
||||
if matched_name:
|
||||
matched_cards.append(CardMatchResult(
|
||||
card_id=card_id,
|
||||
card_name=original_name,
|
||||
matched_name=matched_name,
|
||||
match_type=match_type,
|
||||
confidence=confidence,
|
||||
))
|
||||
else:
|
||||
unmatched_cards.append(original_name)
|
||||
|
||||
return CardImportSummary(
|
||||
total_cards=len(match_results),
|
||||
matched_cards=matched_cards,
|
||||
unmatched_cards=unmatched_cards,
|
||||
import_id=batch.id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/{import_id}/confirm", response_model=MessageResponse)
|
||||
async def confirm_import(
|
||||
import_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Confirm an import batch and save to user's card collection."""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
|
||||
if not batch:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Import batch {import_id} not found"
|
||||
)
|
||||
|
||||
if batch.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
if batch.status != "completed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Import batch {import_id} has not completed yet (status: {batch.status})"
|
||||
)
|
||||
|
||||
# Confirm batch
|
||||
try:
|
||||
await ImportBatchProcessor.confirm_batch(db, import_id, user_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
# Save matched cards to user's collection
|
||||
match_results = batch.match_results or []
|
||||
saved_count = 0
|
||||
|
||||
for original_name, card_id, matched_name, confidence, match_type in match_results:
|
||||
if card_id and match_type in ["exact", "high_confidence"]:
|
||||
# Check if already in collection
|
||||
stmt = select(UserCardCollection).where(
|
||||
UserCardCollection.user_id == user_id,
|
||||
UserCardCollection.card_id == card_id,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
existing.quantity += 1
|
||||
else:
|
||||
new_collection = UserCardCollection(
|
||||
user_id=user_id,
|
||||
card_id=card_id,
|
||||
quantity=1,
|
||||
)
|
||||
db.add(new_collection)
|
||||
saved_count += 1
|
||||
|
||||
await db.flush()
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Import confirmed. {saved_count} cards added to your collection."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/user/cards", response_model=List[Dict[str, Any]])
|
||||
async def get_user_cards(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List user's imported cards."""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
stmt = select(UserCardCollection).where(UserCardCollection.user_id == user_id).order_by(UserCardCollection.created_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
collections = result.scalars().all()
|
||||
|
||||
# Fetch card details
|
||||
card_ids = [c.card_id for c in collections]
|
||||
card_details = {}
|
||||
if card_ids:
|
||||
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
|
||||
card_result = await db.execute(card_stmt)
|
||||
for card in card_result.scalars().all():
|
||||
card_details[card.id] = card
|
||||
|
||||
result_list = []
|
||||
for collection in collections:
|
||||
card = card_details.get(collection.card_id)
|
||||
result_list.append({
|
||||
"collection_id": collection.id,
|
||||
"card_id": collection.card_id,
|
||||
"card_name": card.name if card else f"Card#{collection.card_id}",
|
||||
"card_type_line": card.type_line if card else "",
|
||||
"quantity": collection.quantity,
|
||||
"condition": collection.condition,
|
||||
"language": collection.language,
|
||||
"is_foil": collection.is_foil,
|
||||
"is_alt_art": collection.is_alt_art,
|
||||
"acquired_date": collection.acquired_date,
|
||||
})
|
||||
|
||||
return result_list
|
||||
|
||||
|
||||
@router.delete("/user/cards/{card_import_id}", response_model=MessageResponse)
|
||||
async def delete_user_card(
|
||||
card_import_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Remove a card from user's collection."""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
stmt = select(UserCardCollection).where(
|
||||
UserCardCollection.id == card_import_id,
|
||||
UserCardCollection.user_id == user_id,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
collection = result.scalar_one_or_none()
|
||||
|
||||
if not collection:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Card not found in your collection"
|
||||
)
|
||||
|
||||
await db.delete(collection)
|
||||
await db.flush()
|
||||
|
||||
return MessageResponse(message="Card removed from your collection")
|
||||
|
||||
|
||||
@router.get("/user/decks", response_model=List[Dict[str, Any]])
|
||||
async def get_user_decks(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List user's decks."""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
stmt = select(UserDeck).where(UserDeck.user_id == user_id).order_by(UserDeck.updated_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
decks = result.scalars().all()
|
||||
|
||||
result_list = []
|
||||
for deck in decks:
|
||||
result_list.append({
|
||||
"deck_id": deck.id,
|
||||
"name": deck.name,
|
||||
"status": deck.status,
|
||||
"format": deck.format,
|
||||
"folder_id": deck.folder_id,
|
||||
"notes": deck.notes,
|
||||
"is_precedent": deck.is_precedent,
|
||||
"created_at": deck.created_at,
|
||||
"updated_at": deck.updated_at,
|
||||
})
|
||||
|
||||
return result_list
|
||||
@@ -1,292 +0,0 @@
|
||||
"""
|
||||
Card search router for MTG card database.
|
||||
|
||||
Provides endpoints for searching and retrieving MTG card data
|
||||
with filters for type, set, and color.
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import mtg_get_db
|
||||
from app.core.redis_client import cache_get, cache_set
|
||||
from app.services.card_search_service import CardSearchService
|
||||
from app.services.deck_suggestion_service import DeckSuggestionService
|
||||
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
|
||||
from app.models.user_deck import UserDeck
|
||||
from app.models.mtg_models import MtgCard
|
||||
from app.schemas.card_search_schemas import CardSearchResponse, CardResponse, SetResponse, CardTypeResponse
|
||||
|
||||
router = APIRouter(prefix="/api/cards", tags=["Card Search"])
|
||||
|
||||
|
||||
@router.get("/search", response_model=CardSearchResponse)
|
||||
async def search_cards_endpoint(
|
||||
q: str = Query(..., min_length=1, description="Search query"),
|
||||
card_type: Optional[str] = Query(None, description="Filter by card type"),
|
||||
set_code: Optional[str] = Query(None, description="Filter by set code"),
|
||||
color: Optional[str] = Query(None, description="Filter by color (e.g., WU, BR)"),
|
||||
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
|
||||
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
||||
db: AsyncSession = Depends(mtg_get_db),
|
||||
):
|
||||
"""
|
||||
Search cards with filters.
|
||||
|
||||
Supports filtering by type, set, and color in addition to name search.
|
||||
Uses fuzzy matching as a fallback when exact/partial matches are not found.
|
||||
"""
|
||||
cache_key = f"card_search:{q}:{card_type}:{set_code}:{color}:{limit}:{offset}"
|
||||
|
||||
# Check cache first
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
# Normalize the search query for consistency
|
||||
normalized_query = FuzzyCardMatcher.normalize_card_name(q)
|
||||
|
||||
# Search cards using the existing service
|
||||
results = await CardSearchService.search_cards(
|
||||
db=db,
|
||||
query=q,
|
||||
card_type=card_type,
|
||||
set_code=set_code,
|
||||
color=color,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# If no results found, try fuzzy matching as a fallback
|
||||
if results["total"] == 0:
|
||||
fuzzy_results = await _fuzzy_search_fallback(
|
||||
db=db,
|
||||
query=q,
|
||||
normalized_query=normalized_query,
|
||||
card_type=card_type,
|
||||
set_code=set_code,
|
||||
color=color,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
results = fuzzy_results
|
||||
|
||||
# Add fuzzy matching metadata to results
|
||||
results["query_normalized"] = normalized_query
|
||||
results["fuzzy_match"] = True
|
||||
|
||||
# Cache results for 5 minutes
|
||||
await cache_set(cache_key, str(results), ttl=300)
|
||||
|
||||
return {"cached": False, "results": results}
|
||||
|
||||
|
||||
async def _fuzzy_search_fallback(
|
||||
db: AsyncSession,
|
||||
query: str,
|
||||
normalized_query: str,
|
||||
card_type: Optional[str] = None,
|
||||
set_code: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fallback fuzzy search when exact/partial matches yield no results.
|
||||
|
||||
Fetches all cards matching the type/set/color filters, then uses
|
||||
fuzzy matching to find the best card name matches.
|
||||
"""
|
||||
from sqlalchemy import select, or_
|
||||
|
||||
# Fetch candidate cards based on non-name filters
|
||||
conditions = []
|
||||
if card_type:
|
||||
conditions.append(MtgCard.type_line.ilike(f"%{card_type}%"))
|
||||
if set_code:
|
||||
conditions.append(MtgCard.set_code == set_code)
|
||||
if color:
|
||||
colors = [c.strip() for c in color.upper().split(",")]
|
||||
for c in colors:
|
||||
if c in ["W", "U", "B", "R", "G"]:
|
||||
conditions.append(MtgCard.colors.ilike(f"%{c}%"))
|
||||
|
||||
# If no filters, fetch a broader set for fuzzy matching
|
||||
if not conditions:
|
||||
stmt = select(MtgCard).limit(limit * 5)
|
||||
else:
|
||||
stmt = select(MtgCard).where(*conditions).limit(limit * 5)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
candidate_cards = result.scalars().all()
|
||||
|
||||
if not candidate_cards:
|
||||
return {
|
||||
"cards": [],
|
||||
"total": 0,
|
||||
"page": offset // limit + 1,
|
||||
"page_size": limit,
|
||||
"total_pages": 0,
|
||||
"fuzzy_fallback": True,
|
||||
"message": "No cards found matching your query.",
|
||||
}
|
||||
|
||||
# Build candidate name list and lookup
|
||||
candidate_names = [card.name for card in candidate_cards if card.name]
|
||||
card_lookup = {card.name.lower(): card for card in candidate_cards if card.name}
|
||||
|
||||
# Use fuzzy matching to find best matches
|
||||
matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match(
|
||||
normalized_query, candidate_names, threshold=FuzzyCardMatcher.MIN_MATCH_THRESHOLD
|
||||
)
|
||||
|
||||
# Build results from fuzzy matches
|
||||
card_list = []
|
||||
if matched_name and matched_name.lower() in card_lookup:
|
||||
card = card_lookup[matched_name.lower()]
|
||||
card_data = {
|
||||
"id": card.id,
|
||||
"name": card.name,
|
||||
"mana_cost": card.mana_cost,
|
||||
"type_line": card.type_line,
|
||||
"oracle_text": card.oracle_text,
|
||||
"power": card.power,
|
||||
"toughness": card.toughness,
|
||||
"rarity": card.rarity,
|
||||
"layout": card.layout,
|
||||
"colors": card.colors,
|
||||
"set_code": card.set_code,
|
||||
"set_name": card.set_name,
|
||||
"fuzzy_match": True,
|
||||
"match_confidence": confidence,
|
||||
"match_type": match_type,
|
||||
"original_query": query,
|
||||
}
|
||||
card_list.append(card_data)
|
||||
|
||||
return {
|
||||
"cards": card_list,
|
||||
"total": len(card_list),
|
||||
"page": offset // limit + 1,
|
||||
"page_size": limit,
|
||||
"total_pages": (len(card_list) + limit - 1) // limit if card_list else 0,
|
||||
"fuzzy_fallback": True,
|
||||
"match_type": match_type,
|
||||
"confidence": confidence,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{card_id}", response_model=CardResponse)
|
||||
async def get_card_endpoint(
|
||||
card_id: int,
|
||||
db: AsyncSession = Depends(mtg_get_db),
|
||||
):
|
||||
"""
|
||||
Get a specific card by ID.
|
||||
"""
|
||||
cache_key = f"card_by_id:{card_id}"
|
||||
|
||||
# Check cache first
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
# Get card
|
||||
card = await CardSearchService.get_card_by_id(db, card_id)
|
||||
|
||||
if not card:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
|
||||
# Cache for 10 minutes
|
||||
await cache_set(cache_key, str(card), ttl=600)
|
||||
|
||||
return {"cached": False, "results": card}
|
||||
|
||||
|
||||
@router.get("/sets", response_model=List[SetResponse])
|
||||
async def get_sets_endpoint(
|
||||
db: AsyncSession = Depends(mtg_get_db),
|
||||
):
|
||||
"""
|
||||
Get all available sets.
|
||||
"""
|
||||
cache_key = "all_sets:all"
|
||||
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
sets = await CardSearchService.get_sets(db)
|
||||
|
||||
# Cache for 1 hour
|
||||
await cache_set(cache_key, str(sets), ttl=3600)
|
||||
|
||||
return {"cached": False, "results": sets}
|
||||
|
||||
|
||||
@router.get("/types", response_model=List[CardTypeResponse])
|
||||
async def get_card_types_endpoint(
|
||||
db: AsyncSession = Depends(mtg_get_db),
|
||||
):
|
||||
"""
|
||||
Get all unique card types.
|
||||
"""
|
||||
cache_key = "card_types:all"
|
||||
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
types = await CardSearchService.get_card_types(db)
|
||||
|
||||
# Cache for 30 minutes
|
||||
await cache_set(cache_key, str(types), ttl=1800)
|
||||
|
||||
return {"cached": False, "results": types}
|
||||
|
||||
|
||||
@router.get("/rarities", response_model=List[str])
|
||||
async def get_card_rarities_endpoint(
|
||||
db: AsyncSession = Depends(mtg_get_db),
|
||||
):
|
||||
"""
|
||||
Get all unique card rarities.
|
||||
"""
|
||||
cache_key = "card_rarities:all"
|
||||
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
rarities = await CardSearchService.get_card_rarities(db)
|
||||
|
||||
# Cache for 30 minutes
|
||||
await cache_set(cache_key, str(rarities), ttl=1800)
|
||||
|
||||
return {"cached": False, "results": rarities}
|
||||
|
||||
|
||||
@router.get("/suggest", response_model=List[Dict[str, Any]])
|
||||
async def suggest_cards_endpoint(
|
||||
deck_id: int = Query(..., description="Deck ID to suggest cards for"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Maximum suggestions"),
|
||||
db: AsyncSession = Depends(mtg_get_db),
|
||||
):
|
||||
"""
|
||||
Suggest similar cards for a deck.
|
||||
|
||||
Matches by: same type, same color, same set, same mana cost,
|
||||
and cards often paired in existing user decks.
|
||||
"""
|
||||
# Verify deck exists
|
||||
stmt = select(UserDeck).where(UserDeck.id == deck_id)
|
||||
result = await db.execute(stmt)
|
||||
deck = result.scalar_one_or_none()
|
||||
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
|
||||
suggestions = await DeckSuggestionService.suggest_cards(db, deck_id, limit)
|
||||
|
||||
return suggestions
|
||||
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")
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Game management router endpoints."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, insert, update, delete
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.models import User
|
||||
from app.schemas.schemas import GameCreate, GameResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[GameResponse])
|
||||
async def list_games(
|
||||
room_id: Optional[int] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List active games."""
|
||||
# This would query a games table - simplified for now
|
||||
# In production, you'd have a MTG OnlineGames model
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/", response_model=GameResponse)
|
||||
async def create_game(
|
||||
request: GameCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new game."""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
# Verify user exists
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# In production, you'd create a game in the game database
|
||||
# For now, return a mock response
|
||||
game_data = {
|
||||
"id": 1, # Mock ID
|
||||
"room_id": request.room_id,
|
||||
"game_type": request.game_type,
|
||||
"description": request.description,
|
||||
"with_password": bool(request.password),
|
||||
"max_players": 4,
|
||||
"player_count": 1,
|
||||
"started": False,
|
||||
"creation_date": datetime.now(),
|
||||
}
|
||||
|
||||
return GameResponse(**game_data)
|
||||
|
||||
|
||||
@router.get("/{game_id}", response_model=GameResponse)
|
||||
async def get_game(
|
||||
game_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get game by ID."""
|
||||
# In production, query the games table
|
||||
return GameResponse(
|
||||
id=game_id,
|
||||
room_id=1,
|
||||
game_type="Casual",
|
||||
description="Test game",
|
||||
with_password=False,
|
||||
max_players=4,
|
||||
player_count=0,
|
||||
started=False,
|
||||
creation_date=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{game_id}/join")
|
||||
async def join_game(
|
||||
game_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Join a game."""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
# In production, add user to game players table
|
||||
return {"message": f"User {user_id} joined game {game_id}"}
|
||||
|
||||
|
||||
@router.post("/{game_id}/leave")
|
||||
async def leave_game(
|
||||
game_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Leave a game."""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
# In production, remove user from game players table
|
||||
return {"message": f"User {user_id} left game {game_id}"}
|
||||
|
||||
|
||||
@router.post("/{game_id}/start")
|
||||
async def start_game(
|
||||
game_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Start a game (host only)."""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
# In production, update game started status
|
||||
return {"message": f"Game {game_id} started"}
|
||||
|
||||
|
||||
@router.post("/{game_id}/end")
|
||||
async def end_game(
|
||||
game_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""End a game."""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
# In production, update game closed status
|
||||
return {"message": f"Game {game_id} ended"}
|
||||
@@ -0,0 +1,487 @@
|
||||
"""
|
||||
Games Router - Consolidated
|
||||
|
||||
This module consolidates room management, game operations, MTGJSON data refresh,
|
||||
and WebSocket connections into a unified router structure.
|
||||
|
||||
Endpoints:
|
||||
/rooms/* - Room CRUD operations (Admin/Judge only for write ops)
|
||||
/games/* - Game management stubs
|
||||
/mtgjson/* - MTGJSON dataset refresh and verification (Admin only)
|
||||
/ws/game/{game_id} - WebSocket connection for game sessions
|
||||
|
||||
Architecture:
|
||||
- Uses FastAPI sub-routers with different prefixes for logical grouping
|
||||
- WebSocket endpoint for real-time game communication
|
||||
- All endpoints require authentication via get_current_user dependency
|
||||
- Admin/Judge privilege checks on sensitive operations
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, WebSocket, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, insert, update, delete
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.models import Room, User
|
||||
from app.schemas.schemas import RoomResponse, GameCreate, GameResponse
|
||||
from app.services.mtgjson_manager import MTGJSONManager
|
||||
from app.services.game_server import game_websocket_endpoint
|
||||
|
||||
# Main router
|
||||
router = APIRouter()
|
||||
|
||||
# =============================================================================
|
||||
# Room Management Sub-Router (prefix: /rooms)
|
||||
# =============================================================================
|
||||
|
||||
rooms_router = APIRouter(prefix="/rooms", tags=["Rooms"])
|
||||
|
||||
|
||||
@rooms_router.get("/", response_model=List[RoomResponse])
|
||||
async def list_rooms(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> List[RoomResponse]:
|
||||
"""List all available rooms.
|
||||
|
||||
Returns a list of all rooms in the system, ordered by ID.
|
||||
Requires authentication.
|
||||
"""
|
||||
stmt = select(Room).order_by(Room.id)
|
||||
result = await db.execute(stmt)
|
||||
rooms = result.scalars().all()
|
||||
|
||||
return [RoomResponse.model_validate(room) for room in rooms]
|
||||
|
||||
|
||||
@rooms_router.get("/{room_id}", response_model=RoomResponse)
|
||||
async def get_room(
|
||||
room_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> RoomResponse:
|
||||
"""Get room by ID.
|
||||
|
||||
Returns detailed information about a specific room.
|
||||
Raises 404 if room not found.
|
||||
"""
|
||||
stmt = select(Room).where(Room.id == room_id)
|
||||
result = await db.execute(stmt)
|
||||
room = result.scalar_one_or_none()
|
||||
|
||||
if not room:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Room not found",
|
||||
)
|
||||
|
||||
return RoomResponse.model_validate(room)
|
||||
|
||||
|
||||
@rooms_router.post("/", status_code=status.HTTP_201_CREATED)
|
||||
async def create_room(
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
is_password_protected: bool = False,
|
||||
password_hash: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Create a new room (Admin/Judge only).
|
||||
|
||||
Creates a new game room with the specified configuration.
|
||||
Only users with Admin or Judge privileges can create rooms.
|
||||
Raises 409 if room name already exists.
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
# Check if room name already exists
|
||||
stmt = select(Room).where(Room.name == name)
|
||||
result = await db.execute(stmt)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Room name already exists",
|
||||
)
|
||||
|
||||
new_room = Room(
|
||||
name=name,
|
||||
description=description,
|
||||
is_password_protected=is_password_protected,
|
||||
password_hash=password_hash,
|
||||
)
|
||||
db.add(new_room)
|
||||
await db.flush()
|
||||
|
||||
return {"message": f"Room '{name}' created successfully", "room_id": new_room.id}
|
||||
|
||||
|
||||
@rooms_router.patch("/{room_id}")
|
||||
async def update_room(
|
||||
room_id: int,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
is_password_protected: Optional[bool] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Update room (Admin/Judge only).
|
||||
|
||||
Updates the specified fields of a room.
|
||||
Only users with Admin or Judge privileges can update rooms.
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = select(Room).where(Room.id == room_id)
|
||||
result = await db.execute(stmt)
|
||||
room = result.scalar_one_or_none()
|
||||
|
||||
if not room:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Room not found",
|
||||
)
|
||||
|
||||
# Update fields
|
||||
update_data = {}
|
||||
if name:
|
||||
update_data["name"] = name
|
||||
if description is not None:
|
||||
update_data["description"] = description
|
||||
if is_password_protected is not None:
|
||||
update_data["is_password_protected"] = is_password_protected
|
||||
|
||||
stmt = (
|
||||
update(Room)
|
||||
.where(Room.id == room_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
|
||||
return {"message": f"Room '{name or room.name}' updated successfully"}
|
||||
|
||||
|
||||
@rooms_router.delete("/{room_id}")
|
||||
async def delete_room(
|
||||
room_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Delete room (Admin/Judge only).
|
||||
|
||||
Permanently deletes a room from the system.
|
||||
Only users with Admin or Judge privileges can delete rooms.
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = select(Room).where(Room.id == room_id)
|
||||
result = await db.execute(stmt)
|
||||
room = result.scalar_one_or_none()
|
||||
|
||||
if not room:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Room not found",
|
||||
)
|
||||
|
||||
await db.execute(delete(Room).where(Room.id == room_id))
|
||||
|
||||
return {"message": f"Room '{room.name}' deleted successfully"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Game Management Sub-Router (prefix: /games)
|
||||
# =============================================================================
|
||||
|
||||
games_router = APIRouter(prefix="/games", tags=["Games"])
|
||||
|
||||
|
||||
@games_router.get("/", response_model=List[GameResponse])
|
||||
async def list_games(
|
||||
room_id: Optional[int] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> List[GameResponse]:
|
||||
"""List active games.
|
||||
|
||||
Returns a list of currently active games.
|
||||
Note: This is a stub implementation - returns empty list.
|
||||
"""
|
||||
# This would query a games table - simplified for now
|
||||
# In production, you'd have a MTG OnlineGames model
|
||||
return []
|
||||
|
||||
|
||||
@games_router.post("/", response_model=GameResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_game(
|
||||
request: GameCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> GameResponse:
|
||||
"""Create a new game (mock implementation).
|
||||
|
||||
Creates a new game session.
|
||||
Note: This is a stub - returns mock response.
|
||||
"""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
# Verify user exists
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# In production, you'd create a game in the game database
|
||||
# For now, return a mock response
|
||||
game_data = {
|
||||
"id": 1, # Mock ID
|
||||
"room_id": request.room_id,
|
||||
"game_type": request.game_type,
|
||||
"description": request.description,
|
||||
"with_password": bool(request.password),
|
||||
"max_players": 4,
|
||||
"player_count": 1,
|
||||
"started": False,
|
||||
"creation_date": datetime.now(),
|
||||
}
|
||||
|
||||
return GameResponse(**game_data)
|
||||
|
||||
|
||||
@games_router.get("/{game_id}", response_model=GameResponse)
|
||||
async def get_game(
|
||||
game_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> GameResponse:
|
||||
"""Get game by ID (mock implementation).
|
||||
|
||||
Returns details about a specific game.
|
||||
Note: This is a stub - returns mock response.
|
||||
"""
|
||||
# In production, query the games table
|
||||
return GameResponse(
|
||||
id=game_id,
|
||||
room_id=1,
|
||||
game_type="Casual",
|
||||
description="Test game",
|
||||
with_password=False,
|
||||
max_players=4,
|
||||
player_count=0,
|
||||
started=False,
|
||||
creation_date=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
@games_router.post("/{game_id}/join")
|
||||
async def join_game(
|
||||
game_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Join a game (stub implementation).
|
||||
|
||||
Adds the current user to a game session.
|
||||
Note: This is a stub - returns mock response.
|
||||
"""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
# In production, add user to game players table
|
||||
return {"message": f"User {user_id} joined game {game_id}"}
|
||||
|
||||
|
||||
@games_router.post("/{game_id}/leave")
|
||||
async def leave_game(
|
||||
game_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Leave a game (stub implementation).
|
||||
|
||||
Removes the current user from a game session.
|
||||
Note: This is a stub - returns mock response.
|
||||
"""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
# In production, remove user from game players table
|
||||
return {"message": f"User {user_id} left game {game_id}"}
|
||||
|
||||
|
||||
@games_router.post("/{game_id}/start")
|
||||
async def start_game(
|
||||
game_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Start a game (host only, stub implementation).
|
||||
|
||||
Initiates a game session.
|
||||
Note: This is a stub - returns mock response.
|
||||
"""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
# In production, update game started status
|
||||
return {"message": f"Game {game_id} started"}
|
||||
|
||||
|
||||
@games_router.post("/{game_id}/end")
|
||||
async def end_game(
|
||||
game_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""End a game (stub implementation).
|
||||
|
||||
Terminates a game session.
|
||||
Note: This is a stub - returns mock response.
|
||||
"""
|
||||
user_id = int(current_user["user_id"])
|
||||
|
||||
# In production, update game closed status
|
||||
return {"message": f"Game {game_id} ended"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MTGJSON Data Refresh Sub-Router (prefix: /mtgjson)
|
||||
# =============================================================================
|
||||
|
||||
mtgjson_router = APIRouter(prefix="/mtgjson", tags=["MTGJSON Data"])
|
||||
|
||||
|
||||
@mtgjson_router.post("/refresh")
|
||||
async def trigger_refresh(
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Trigger a refresh of MTGJSON datasets (Admin only).
|
||||
|
||||
Downloads files from MTGJSON API and upserts data into PostgreSQL.
|
||||
The refresh runs in the background and may take several minutes.
|
||||
Only Admin users can trigger refreshes.
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") != "Admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
# Create manager and trigger refresh in background
|
||||
manager = MTGJSONManager()
|
||||
background_tasks.add_task(manager.download_and_refresh, force=False)
|
||||
|
||||
return {
|
||||
"message": "Refresh initiated in background",
|
||||
"status": "started",
|
||||
}
|
||||
|
||||
|
||||
@mtgjson_router.get("/status")
|
||||
async def get_refresh_status(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Get current refresh status and data health (Admin only).
|
||||
|
||||
Returns information about:
|
||||
- Last successful refresh timestamp
|
||||
- Current data counts
|
||||
- File status
|
||||
- Required files status
|
||||
|
||||
Only Admin users can access this endpoint.
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") != "Admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
manager = MTGJSONManager()
|
||||
return await manager.get_health_status()
|
||||
|
||||
|
||||
@mtgjson_router.post("/verify")
|
||||
async def verify_files(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Verify the integrity of downloaded MTGJSON files (Admin only).
|
||||
|
||||
Checks that all required files exist and are valid.
|
||||
Only Admin users can verify files.
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") != "Admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
manager = MTGJSONManager()
|
||||
all_valid, errors = await manager.verify_files()
|
||||
|
||||
if not all_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"valid": False,
|
||||
"errors": errors,
|
||||
"message": "Some files failed verification",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"message": "All files verified successfully",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WebSocket Endpoint
|
||||
# =============================================================================
|
||||
|
||||
@router.websocket("/ws/game/{game_id}")
|
||||
async def game_websocket(
|
||||
websocket: WebSocket,
|
||||
game_id: int,
|
||||
player_id: Optional[int] = Query(None),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for game connections.
|
||||
|
||||
Handles real-time game state updates for connected players.
|
||||
Establishes a persistent connection for live game communication.
|
||||
"""
|
||||
await websocket.accept()
|
||||
await game_websocket_endpoint(websocket, game_id)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Include Sub-Routers
|
||||
# =============================================================================
|
||||
|
||||
router.include_router(rooms_router)
|
||||
router.include_router(games_router)
|
||||
router.include_router(mtgjson_router)
|
||||
@@ -1,101 +0,0 @@
|
||||
"""
|
||||
MTGJSON Data Refresh Router
|
||||
|
||||
Provides endpoints for triggering dataset downloads and managing refresh operations.
|
||||
"""
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.services.mtgjson_manager import MTGJSONManager
|
||||
|
||||
router = APIRouter(prefix="/mtgjson", tags=["MTGJSON Data"])
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def trigger_refresh(
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Trigger a refresh of MTGJSON datasets.
|
||||
|
||||
Downloads files from MTGJSON API and upserts data into PostgreSQL.
|
||||
Requires Admin privileges.
|
||||
|
||||
The refresh runs in the background and may take several minutes.
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") != "Admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
# Create manager and trigger refresh in background
|
||||
manager = MTGJSONManager()
|
||||
background_tasks.add_task(manager.download_and_refresh, force=False)
|
||||
|
||||
return {
|
||||
"message": "Refresh initiated in background",
|
||||
"status": "started",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_refresh_status(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Get current refresh status and data health.
|
||||
|
||||
Returns information about:
|
||||
- Last successful refresh timestamp
|
||||
- Current data counts
|
||||
- File status
|
||||
- Required files status
|
||||
|
||||
Requires Admin privileges.
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") != "Admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
manager = MTGJSONManager()
|
||||
return await manager.get_health_status()
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
async def verify_files(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Verify the integrity of downloaded MTGJSON files.
|
||||
|
||||
Checks that all required files exist and are valid.
|
||||
Requires Admin privileges.
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") != "Admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
manager = MTGJSONManager()
|
||||
all_valid, errors = await manager.verify_files()
|
||||
|
||||
if not all_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"valid": False,
|
||||
"errors": errors,
|
||||
"message": "Some files failed verification",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"message": "All files verified successfully",
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Room management router endpoints."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, insert, update, delete
|
||||
from typing import Optional, List
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.models import Room
|
||||
from app.schemas.schemas import RoomResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[RoomResponse])
|
||||
async def list_rooms(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all available rooms."""
|
||||
stmt = select(Room).order_by(Room.id)
|
||||
result = await db.execute(stmt)
|
||||
rooms = result.scalars().all()
|
||||
|
||||
return [RoomResponse.model_validate(room) for room in rooms]
|
||||
|
||||
|
||||
@router.get("/{room_id}", response_model=RoomResponse)
|
||||
async def get_room(
|
||||
room_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get room by ID."""
|
||||
stmt = select(Room).where(Room.id == room_id)
|
||||
result = await db.execute(stmt)
|
||||
room = result.scalar_one_or_none()
|
||||
|
||||
if not room:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Room not found",
|
||||
)
|
||||
|
||||
return RoomResponse.model_validate(room)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_room(
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
is_password_protected: bool = False,
|
||||
password_hash: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new room (admin only)."""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
# Check if room name already exists
|
||||
stmt = select(Room).where(Room.name == name)
|
||||
result = await db.execute(stmt)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Room name already exists",
|
||||
)
|
||||
|
||||
new_room = Room(
|
||||
name=name,
|
||||
description=description,
|
||||
is_password_protected=is_password_protected,
|
||||
password_hash=password_hash,
|
||||
)
|
||||
db.add(new_room)
|
||||
await db.flush()
|
||||
|
||||
return {"message": f"Room '{name}' created successfully", "room_id": new_room.id}
|
||||
|
||||
|
||||
@router.patch("/{room_id}")
|
||||
async def update_room(
|
||||
room_id: int,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
is_password_protected: Optional[bool] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update room (admin only)."""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = select(Room).where(Room.id == room_id)
|
||||
result = await db.execute(stmt)
|
||||
room = result.scalar_one_or_none()
|
||||
|
||||
if not room:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Room not found",
|
||||
)
|
||||
|
||||
# Update fields
|
||||
update_data = {}
|
||||
if name:
|
||||
update_data["name"] = name
|
||||
if description is not None:
|
||||
update_data["description"] = description
|
||||
if is_password_protected is not None:
|
||||
update_data["is_password_protected"] = is_password_protected
|
||||
|
||||
stmt = (
|
||||
update(Room)
|
||||
.where(Room.id == room_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
|
||||
return {"message": f"Room '{name or room.name}' updated successfully"}
|
||||
|
||||
|
||||
@router.delete("/{room_id}")
|
||||
async def delete_room(
|
||||
room_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete room (admin only)."""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = select(Room).where(Room.id == room_id)
|
||||
result = await db.execute(stmt)
|
||||
room = result.scalar_one_or_none()
|
||||
|
||||
if not room:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Room not found",
|
||||
)
|
||||
|
||||
await db.execute(delete(Room).where(Room.id == room_id))
|
||||
|
||||
return {"message": f"Room '{room.name}' deleted successfully"}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,168 +0,0 @@
|
||||
"""User management router endpoints."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user, hash_password
|
||||
from app.models.models import User
|
||||
from app.schemas.schemas import UserUpdate, UserResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get user by ID."""
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
request: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update user profile."""
|
||||
# Users can only update their own profile unless admin
|
||||
if int(current_user["user_id"]) != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot update another user's profile",
|
||||
)
|
||||
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Update fields
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
# Hash new password if provided
|
||||
if "new_password" in update_data:
|
||||
update_data["password_hash"] = hash_password(update_data.pop("new_password"))
|
||||
|
||||
# Update user
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(User.id == user_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.flush()
|
||||
|
||||
# Fetch updated user
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
updated_user = result.scalar_one_or_none()
|
||||
|
||||
return UserResponse.model_validate(updated_user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/ban")
|
||||
async def ban_user(
|
||||
user_id: int,
|
||||
reason: str,
|
||||
expiration_time: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Ban a user (admin only)."""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Update user ban status
|
||||
from datetime import datetime
|
||||
ban_ends = None
|
||||
if expiration_time:
|
||||
ban_ends = datetime.fromisoformat(expiration_time)
|
||||
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(User.id == user_id)
|
||||
.values(
|
||||
is_banned=True,
|
||||
ban_reason=reason,
|
||||
ban_ends=ban_ends,
|
||||
)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.flush()
|
||||
|
||||
return {"message": f"User {user_id} has been banned"}
|
||||
|
||||
|
||||
@router.post("/{user_id}/unban")
|
||||
async def unban_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Unban a user (admin only)."""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Update user ban status
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(User.id == user_id)
|
||||
.values(
|
||||
is_banned=False,
|
||||
ban_reason=None,
|
||||
ban_ends=None,
|
||||
)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.flush()
|
||||
|
||||
return {"message": f"User {user_id} has been unbanned"}
|
||||
@@ -0,0 +1,549 @@
|
||||
"""
|
||||
Users and Admin Router - Consolidated Endpoints
|
||||
|
||||
This module consolidates user management and administrative operations into a
|
||||
single router. It provides endpoints for:
|
||||
|
||||
User Management:
|
||||
- GET /{user_id}: Retrieve user profile by ID
|
||||
- PATCH /{user_id}: Update user profile (self or admin)
|
||||
- POST /{user_id}/ban: Ban a user (admin/judge only)
|
||||
- POST /{user_id}/unban: Unban a user (admin/judge only)
|
||||
|
||||
Admin Operations:
|
||||
- GET /users: List all users with pagination (admin/judge only)
|
||||
- GET /bans: List all bans, optionally active only (admin/judge only)
|
||||
- POST /bans: Create a new ban record (admin/judge only)
|
||||
- POST /bans/{ban_id}/unban: Unban via ban ID (admin/judge only)
|
||||
- GET /logs: List game logs with optional room filter (admin/judge only)
|
||||
- POST /audit: Create an audit log entry (admin/judge only)
|
||||
|
||||
Both per-user ban/unban and global ban listing/creation are maintained.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user, hash_password
|
||||
from app.models.models import User, Ban, GameLog, AuditLog
|
||||
from app.schemas.schemas import UserUpdate, UserResponse, BanCreate, BanResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Management Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
Get user by ID.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user to retrieve
|
||||
db: Database session
|
||||
current_user: The currently authenticated user
|
||||
|
||||
Returns:
|
||||
UserResponse with user profile data
|
||||
|
||||
Raises:
|
||||
HTTPException 404: If user not found
|
||||
"""
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
request: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
Update user profile.
|
||||
|
||||
Users can only update their own profile unless they have admin privileges.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user to update
|
||||
request: UserUpdate schema with fields to update
|
||||
db: Database session
|
||||
current_user: The currently authenticated user
|
||||
|
||||
Returns:
|
||||
UserResponse with updated user data
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If trying to update another user's profile
|
||||
HTTPException 404: If user not found
|
||||
"""
|
||||
# Users can only update their own profile unless admin
|
||||
if int(current_user["user_id"]) != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot update another user's profile",
|
||||
)
|
||||
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Update fields
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
# Hash new password if provided
|
||||
if "new_password" in update_data:
|
||||
update_data["password_hash"] = hash_password(update_data.pop("new_password"))
|
||||
|
||||
# Update user
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(User.id == user_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.flush()
|
||||
|
||||
# Fetch updated user
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
updated_user = result.scalar_one_or_none()
|
||||
|
||||
return UserResponse.model_validate(updated_user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/ban")
|
||||
async def ban_user(
|
||||
user_id: int,
|
||||
reason: str,
|
||||
expiration_time: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""
|
||||
Ban a user (admin/judge only).
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user to ban
|
||||
reason: Reason for the ban
|
||||
expiration_time: Optional ISO format datetime for ban expiration
|
||||
db: Database session
|
||||
current_user: The currently authenticated user
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If user is not admin/judge
|
||||
HTTPException 404: If user not found
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Update user ban status
|
||||
ban_ends = None
|
||||
if expiration_time:
|
||||
ban_ends = datetime.fromisoformat(expiration_time)
|
||||
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(User.id == user_id)
|
||||
.values(
|
||||
is_banned=True,
|
||||
ban_reason=reason,
|
||||
ban_ends=ban_ends,
|
||||
)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.flush()
|
||||
|
||||
return {"message": f"User {user_id} has been banned"}
|
||||
|
||||
|
||||
@router.post("/{user_id}/unban")
|
||||
async def unban_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""
|
||||
Unban a user (admin/judge only).
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user to unban
|
||||
db: Database session
|
||||
current_user: The currently authenticated user
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If user is not admin/judge
|
||||
HTTPException 404: If user not found
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Update user ban status
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(User.id == user_id)
|
||||
.values(
|
||||
is_banned=False,
|
||||
ban_reason=None,
|
||||
ban_ends=None,
|
||||
)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.flush()
|
||||
|
||||
return {"message": f"User {user_id} has been unbanned"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Admin Operations Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/users", response_model=List[dict])
|
||||
async def list_users(
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> List[dict]:
|
||||
"""
|
||||
List all users with pagination (admin/judge only).
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed)
|
||||
page_size: Number of users per page
|
||||
db: Database session
|
||||
current_user: The currently authenticated user
|
||||
|
||||
Returns:
|
||||
List of user dictionaries
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If user is not admin/judge
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(User)
|
||||
.order_by(User.id)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"privlevel": user.privlevel,
|
||||
"is_active": user.is_active,
|
||||
"is_banned": user.is_banned,
|
||||
"vip_status": user.vip_status,
|
||||
"creation_date": user.creation_date,
|
||||
}
|
||||
for user in users
|
||||
]
|
||||
|
||||
|
||||
@router.get("/bans", response_model=List[BanResponse])
|
||||
async def list_bans(
|
||||
active_only: bool = True,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> List[BanResponse]:
|
||||
"""
|
||||
List all bans (admin/judge only).
|
||||
|
||||
Args:
|
||||
active_only: If True, only return active bans
|
||||
db: Database session
|
||||
current_user: The currently authenticated user
|
||||
|
||||
Returns:
|
||||
List of BanResponse objects
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If user is not admin/judge
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = select(Ban).order_by(Ban.id)
|
||||
if active_only:
|
||||
stmt = stmt.where(Ban.active == True)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
bans = result.scalars().all()
|
||||
|
||||
return [BanResponse.model_validate(ban) for ban in bans]
|
||||
|
||||
|
||||
@router.post("/bans", response_model=BanResponse)
|
||||
async def create_ban(
|
||||
request: BanCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> BanResponse:
|
||||
"""
|
||||
Create a new ban record (admin/judge only).
|
||||
|
||||
Args:
|
||||
request: BanCreate schema with ban details
|
||||
db: Database session
|
||||
current_user: The currently authenticated user
|
||||
|
||||
Returns:
|
||||
BanResponse with created ban data
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If user is not admin/judge
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
new_ban = Ban(
|
||||
user_id=request.user_id,
|
||||
reason=request.reason,
|
||||
expiration_time=request.expiration_time,
|
||||
moderators=current_user.get("username"),
|
||||
ip_address=None, # Would get from request
|
||||
)
|
||||
db.add(new_ban)
|
||||
await db.flush()
|
||||
|
||||
return BanResponse.model_validate(new_ban)
|
||||
|
||||
|
||||
@router.post("/bans/{ban_id}/unban")
|
||||
async def unban(
|
||||
ban_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""
|
||||
Unban a user via ban ID (admin/judge only).
|
||||
|
||||
This deactivates the ban record and also updates the user's ban status.
|
||||
|
||||
Args:
|
||||
ban_id: The ID of the ban to unban
|
||||
db: Database session
|
||||
current_user: The currently authenticated user
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If user is not admin/judge
|
||||
HTTPException 404: If ban not found
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = select(Ban).where(Ban.id == ban_id)
|
||||
result = await db.execute(stmt)
|
||||
ban = result.scalar_one_or_none()
|
||||
|
||||
if not ban:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ban not found",
|
||||
)
|
||||
|
||||
# Deactivate ban
|
||||
stmt = (
|
||||
update(Ban)
|
||||
.where(Ban.id == ban_id)
|
||||
.values(active=False)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
|
||||
# Also unban the user
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(User.id == ban.user_id)
|
||||
.values(
|
||||
is_banned=False,
|
||||
ban_reason=None,
|
||||
ban_ends=None,
|
||||
)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
|
||||
return {"message": "User unbanned successfully"}
|
||||
|
||||
|
||||
@router.get("/logs", response_model=List[dict])
|
||||
async def list_logs(
|
||||
room_id: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> List[dict]:
|
||||
"""
|
||||
List game logs with optional room filter (admin/judge only).
|
||||
|
||||
Args:
|
||||
room_id: Optional room ID to filter logs
|
||||
limit: Maximum number of logs to return
|
||||
db: Database session
|
||||
current_user: The currently authenticated user
|
||||
|
||||
Returns:
|
||||
List of log dictionaries
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If user is not admin/judge
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
stmt = select(GameLog).order_by(GameLog.id.desc()).limit(limit)
|
||||
if room_id:
|
||||
stmt = stmt.where(GameLog.room_id == room_id)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"room_id": log.room_id,
|
||||
"player_id": log.player_id,
|
||||
"message": log.message,
|
||||
"timestamp": log.timestamp,
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.post("/audit", response_model=dict)
|
||||
async def log_audit(
|
||||
action_type: str,
|
||||
target_user_id: Optional[int] = None,
|
||||
details: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""
|
||||
Create an audit log entry (admin/judge only).
|
||||
|
||||
Args:
|
||||
action_type: Type of administrative action
|
||||
target_user_id: Optional target user ID
|
||||
details: Optional details about the action
|
||||
db: Database session
|
||||
current_user: The currently authenticated user
|
||||
|
||||
Returns:
|
||||
Dictionary with audit log data
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If user is not admin/judge
|
||||
"""
|
||||
# Check if current user is admin
|
||||
if current_user.get("privlevel") not in ["Admin", "Judge"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
|
||||
new_audit = AuditLog(
|
||||
admin_id=int(current_user["user_id"]),
|
||||
action_type=action_type,
|
||||
target_user_id=target_user_id,
|
||||
details=details,
|
||||
ip_address=None, # Would get from request
|
||||
)
|
||||
db.add(new_audit)
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"id": new_audit.id,
|
||||
"admin_id": new_audit.admin_id,
|
||||
"action_type": new_audit.action_type,
|
||||
"target_user_id": new_audit.target_user_id,
|
||||
"details": new_audit.details,
|
||||
"timestamp": new_audit.timestamp,
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"""WebSocket router for game connections."""
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from typing import Optional
|
||||
|
||||
from app.services.game_server import game_websocket_endpoint
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws/game/{game_id}")
|
||||
async def game_websocket(
|
||||
websocket: WebSocket,
|
||||
game_id: int,
|
||||
player_id: Optional[int] = None,
|
||||
):
|
||||
"""WebSocket endpoint for game connections."""
|
||||
await websocket.accept()
|
||||
await game_websocket_endpoint(websocket, game_id)
|
||||
Reference in New Issue
Block a user