Files
mtgonline/backend/app/routers/users_router.py
T
akadmin 900f51af3a 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
2026-08-26 15:15:36 +00:00

550 lines
15 KiB
Python

"""
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,
}