Files
akadmin 1df04aea52 Phase 7: End-to-End API Testing - Database setup, migrations, and application fixes
- Switched from psycopg2 to asyncpg for async SQLAlchemy support
- Fixed router registration in main.py - removed duplicate prefixes
- Added user_data export to routers/__init__.py
- Refactored decks router to use DeckManager service layer
- Integrated FuzzyCardMatcher into card_router search endpoints
- Made WishlistCreate.card_id optional for proper schema validation
- Set PostgreSQL password and configured scram-sha-256 auth
- Updated alembic.ini to use local PostgreSQL instead of Docker hostname
- Created generic_schemas.py for reusable schema patterns
- Added test_routers.py and test_schema_validation.py test files
- All 6 Alembic migrations applied successfully (37 tables created)
- Application running on port 8000 with all services connected
2026-08-18 03:35:19 +00:00

1715 lines
54 KiB
Python

"""
User data API endpoints router.
Provides CRUD operations for user sessions, decks, replays,
card collection, groups, networks, preferences, and activity logs.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update, delete
from typing import Optional, List
from datetime import datetime, timezone
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import User
from app.models.user_data import (
UserSession, DeckVersion, GameReplay, ReplayPlayer,
GameOutcome, UserStatistics, UserCardCollection, CardWishlist,
UserGroup, GroupMember, GroupChatMessage, UserNetwork,
NetworkMember, UserPreference, UserActivityLog
)
from app.schemas.user_card_collection import (
CardCollectionCreate,
CardCollectionUpdate,
CardCollectionResponse,
CardCollectionListResponse,
WishlistCreate,
WishlistUpdate,
WishlistResponse,
WishlistListResponse,
)
from app.schemas.generic_schemas import MessageResponse, CountResponse, ErrorDetail
from app.schemas.user_data_schemas import (
SessionCleanupResponse,
DeckVersionCreate, DeckVersionUpdate, DeckVersionResponse, DeckVersionListResponse,
GameReplayCreate, GameReplayUpdate, GameReplayResponse, GameReplayListResponse,
GameOutcomeCreate, GameOutcomeResponse, GameOutcomeListResponse,
UserStatisticsResponse, StatisticsUpdateResponse,
GroupCreate, GroupUpdate, GroupResponse, GroupListResponse,
GroupMemberCreate, GroupMemberUpdate, GroupMemberRemove,
GroupChatMessageCreate, GroupChatMessageResponse, GroupChatMessageListResponse,
NetworkCreate, NetworkUpdate, NetworkResponse, NetworkListResponse,
NetworkMemberCreate,
UserPreferenceUpdate, UserPreferenceResponse,
ActivityLogEntry, ActivityLogListResponse,
)
router = APIRouter()
# ===== Session Management =====
@router.get("/sessions/me", response_model=List[SessionCleanupResponse])
async def get_current_sessions(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get active sessions for current user."""
user_id = int(current_user["user_id"])
stmt = (
select(UserSession)
.where(
UserSession.user_id == user_id,
UserSession.is_active == True,
UserSession.expires_at > datetime.now(timezone.utc)
)
)
result = await db.execute(stmt)
sessions = result.scalars().all()
return [
SessionCleanupResponse(
cleaned_count=len(sessions),
message=f"Found {len(sessions)} active sessions"
)
]
@router.delete("/sessions/cleanup", response_model=MessageResponse)
async def cleanup_expired_sessions(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Cleanup expired sessions for current user."""
user_id = int(current_user["user_id"])
now = datetime.now(timezone.utc)
stmt = (
update(UserSession)
.where(
UserSession.user_id == user_id,
UserSession.is_active == True,
UserSession.expires_at < now
)
.values(is_active=False)
)
await db.execute(stmt)
await db.flush()
# Count cleaned sessions
count_stmt = select(func.count()).select_from(UserSession).where(
UserSession.user_id == user_id,
UserSession.is_active == False,
UserSession.expires_at < now
)
count_result = await db.execute(count_stmt)
cleaned_count = count_result.scalar()
return MessageResponse(message=f"Cleaned {cleaned_count} expired sessions")
@router.post("/sessions/logout", response_model=MessageResponse)
async def logout_current_session(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Logout current user session."""
user_id = int(current_user["user_id"])
stmt = (
update(UserSession)
.where(
UserSession.user_id == user_id,
UserSession.is_active == True
)
.values(is_active=False)
)
await db.execute(stmt)
await db.flush()
return MessageResponse(message="Logged out successfully")
# ===== Deck Versions =====
@router.post("/decks/{deck_id}/versions", response_model=DeckVersionResponse, status_code=status.HTTP_201_CREATED)
async def create_deck_version(
deck_id: int,
request: DeckVersionCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new version of a deck."""
user_id = int(current_user["user_id"])
# Verify user owns the deck
deck_check = select(User).where(User.id == user_id)
deck_result = await db.execute(deck_check)
user = deck_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# Get current version number
version_stmt = (
select(func.max(DeckVersion.version_number))
.where(DeckVersion.deck_id == deck_id)
)
version_result = await db.execute(version_stmt)
max_version = version_result.scalar() or 0
new_version = DeckVersion(
deck_id=deck_id,
version_number=max_version + 1,
content=request.content,
status=request.status.value,
comment=request.comment,
created_at=datetime.now(timezone.utc)
)
db.add(new_version)
await db.flush()
return DeckVersionResponse.model_validate(new_version)
@router.get("/decks/{deck_id}/versions", response_model=DeckVersionListResponse)
async def get_deck_versions(
deck_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get all versions of a deck."""
offset = (page - 1) * page_size
count_stmt = select(func.count()).select_from(DeckVersion).where(DeckVersion.deck_id == deck_id)
total_result = await db.execute(count_stmt)
total = total_result.scalar()
stmt = (
select(DeckVersion)
.where(DeckVersion.deck_id == deck_id)
.order_by(DeckVersion.version_number.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(stmt)
versions = result.scalars().all()
return DeckVersionListResponse(
versions=[DeckVersionResponse.model_validate(v) for v in versions],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size
)
@router.patch("/decks/{deck_id}/versions/{version_id}", response_model=DeckVersionResponse)
async def update_deck_version(
deck_id: int,
version_id: int,
request: DeckVersionUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a deck version."""
stmt = select(DeckVersion).where(DeckVersion.id == version_id)
result = await db.execute(stmt)
version = result.scalar_one_or_none()
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found")
update_data = request.model_dump(exclude_unset=True)
if "status" in update_data:
update_data["status"] = update_data["status"].value
stmt = (
update(DeckVersion)
.where(DeckVersion.id == version_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
stmt = select(DeckVersion).where(DeckVersion.id == version_id)
result = await db.execute(stmt)
updated_version = result.scalar_one_or_none()
return DeckVersionResponse.model_validate(updated_version)
@router.delete("/decks/{deck_id}/versions/{version_id}", response_model=MessageResponse)
async def delete_deck_version(
deck_id: int,
version_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a deck version."""
stmt = select(DeckVersion).where(DeckVersion.id == version_id)
result = await db.execute(stmt)
version = result.scalar_one_or_none()
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found")
stmt = delete(DeckVersion).where(DeckVersion.id == version_id)
await db.execute(stmt)
await db.flush()
return MessageResponse(message="Version deleted successfully")
# ===== Game Replays =====
@router.post("/replays", response_model=GameReplayResponse, status_code=status.HTTP_201_CREATED)
async def create_game_replay(
request: GameReplayCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new game replay."""
# Check if replay already exists
stmt = select(GameReplay).where(GameReplay.game_uuid == request.game_uuid)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Replay already exists")
replay = GameReplay(
game_uuid=request.game_uuid,
room_id=request.room_id,
game_type=request.game_type,
format=request.format,
duration_seconds=request.duration_seconds,
start_time=request.start_time,
end_time=request.end_time,
status=request.status.value,
replay_data=request.replay_data,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(replay)
await db.flush()
return GameReplayResponse.model_validate(replay)
@router.get("/replays", response_model=GameReplayListResponse)
async def get_game_replays(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user_id: Optional[int] = None,
status_filter: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get game replays with optional filtering."""
offset = (page - 1) * page_size
# Build WHERE clause
conditions = []
if user_id:
conditions.append(ReplayPlayer.user_id == user_id)
if status_filter:
conditions.append(GameReplay.status == status_filter)
# Get total count
count_stmt = select(func.count()).select_from(GameReplay)
if conditions:
count_stmt = count_stmt.join(ReplayPlayer).where(*conditions)
total_result = await db.execute(count_stmt)
total = total_result.scalar()
# Get replays
stmt = select(GameReplay)
if conditions:
stmt = stmt.join(ReplayPlayer).where(*conditions)
stmt = stmt.order_by(GameReplay.start_time.desc()).offset(offset).limit(page_size)
result = await db.execute(stmt)
replays = result.scalars().all()
return GameReplayListResponse(
replays=[GameReplayResponse.model_validate(r) for r in replays],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size
)
@router.get("/replays/{replay_id}", response_model=GameReplayResponse)
async def get_game_replay(
replay_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a specific game replay."""
stmt = select(GameReplay).where(GameReplay.id == replay_id)
result = await db.execute(stmt)
replay = result.scalar_one_or_none()
if not replay:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found")
return GameReplayResponse.model_validate(replay)
@router.patch("/replays/{replay_id}", response_model=GameReplayResponse)
async def update_game_replay(
replay_id: int,
request: GameReplayUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a game replay."""
stmt = select(GameReplay).where(GameReplay.id == replay_id)
result = await db.execute(stmt)
replay = result.scalar_one_or_none()
if not replay:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found")
update_data = request.model_dump(exclude_unset=True)
if "status" in update_data:
update_data["status"] = update_data["status"].value
update_data["updated_at"] = datetime.now(timezone.utc)
stmt = (
update(GameReplay)
.where(GameReplay.id == replay_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
stmt = select(GameReplay).where(GameReplay.id == replay_id)
result = await db.execute(stmt)
updated_replay = result.scalar_one_or_none()
return GameReplayResponse.model_validate(updated_replay)
@router.delete("/replays/{replay_id}", response_model=MessageResponse)
async def delete_game_replay(
replay_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a game replay."""
stmt = select(GameReplay).where(GameReplay.id == replay_id)
result = await db.execute(stmt)
replay = result.scalar_one_or_none()
if not replay:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found")
stmt = delete(GameReplay).where(GameReplay.id == replay_id)
await db.execute(stmt)
await db.flush()
return MessageResponse(message="Replay deleted successfully")
# ===== Replay Players =====
@router.post("/replays/{replay_id}/players", status_code=status.HTTP_201_CREATED)
async def add_replay_player(
replay_id: int,
user_id: int,
deck_id: Optional[int] = None,
position: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Add a player to a game replay."""
# Verify replay exists
stmt = select(GameReplay).where(GameReplay.id == replay_id)
result = await db.execute(stmt)
replay = result.scalar_one_or_none()
if not replay:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found")
# Check if player already exists
player_check = (
select(ReplayPlayer)
.where(ReplayPlayer.replay_id == replay_id, ReplayPlayer.user_id == user_id)
)
player_result = await db.execute(player_check)
if player_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Player already in replay")
player = ReplayPlayer(
replay_id=replay_id,
user_id=user_id,
deck_id=deck_id,
position=position,
created_at=datetime.now(timezone.utc)
)
db.add(player)
await db.flush()
return {"message": "Player added to replay", "player_id": player.id}
@router.get("/replays/{replay_id}/players", response_model=List[dict])
async def get_replay_players(
replay_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get all players in a game replay."""
stmt = select(ReplayPlayer).where(ReplayPlayer.replay_id == replay_id)
result = await db.execute(stmt)
players = result.scalars().all()
return [
{
"id": p.id,
"user_id": p.user_id,
"deck_id": p.deck_id,
"position": p.position,
"won": p.won,
"lost": p.lost,
"concession": p.concession,
"turn_one": p.turn_one,
}
for p in players
]
# ===== Game Outcomes =====
@router.post("/outcomes", response_model=GameOutcomeResponse, status_code=status.HTTP_201_CREATED)
async def create_game_outcome(
request: GameOutcomeCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a game outcome record."""
outcome = GameOutcome(
user_id=int(current_user["user_id"]),
game_uuid=request.game_uuid,
outcome=request.outcome.value,
opponent_id=request.opponent_id,
format=request.format,
rating_before=request.rating_before,
rating_after=request.rating_after,
rating_change=request.rating_change,
created_at=datetime.now(timezone.utc)
)
db.add(outcome)
await db.flush()
return GameOutcomeResponse.model_validate(outcome)
@router.get("/outcomes", response_model=GameOutcomeListResponse)
async def get_game_outcomes(
user_id: Optional[int] = None,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get game outcomes with optional filtering."""
offset = (page - 1) * page_size
stmt = select(GameOutcome)
if user_id:
stmt = stmt.where(GameOutcome.user_id == user_id)
stmt = stmt.order_by(GameOutcome.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(stmt)
outcomes = result.scalars().all()
count_stmt = select(func.count()).select_from(GameOutcome)
if user_id:
count_stmt = count_stmt.where(GameOutcome.user_id == user_id)
total_result = await db.execute(count_stmt)
total = total_result.scalar()
return GameOutcomeListResponse(
outcomes=[GameOutcomeResponse.model_validate(o) for o in outcomes],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size
)
# ===== User Statistics =====
@router.get("/statistics/{user_id}", response_model=UserStatisticsResponse)
async def get_user_statistics(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get user game statistics."""
stmt = select(UserStatistics).where(UserStatistics.user_id == user_id)
result = await db.execute(stmt)
stats = result.scalar_one_or_none()
if not stats:
# Create default statistics
stats = UserStatistics(
user_id=user_id,
total_games=0,
total_wins=0,
total_losses=0,
total_concessions=0,
win_rate=0.0,
current_streak=0,
best_streak=0,
average_rating=0.0,
updated_at=datetime.now(timezone.utc)
)
db.add(stats)
await db.flush()
return UserStatisticsResponse.model_validate(stats)
@router.post("/statistics/update", response_model=StatisticsUpdateResponse)
async def update_user_statistics(
user_id: int,
outcome: str, # 'WIN', 'LOSS', 'CONCESSION'
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update user statistics after a game."""
stmt = select(UserStatistics).where(UserStatistics.user_id == user_id)
result = await db.execute(stmt)
stats = result.scalar_one_or_none()
if not stats:
stats = UserStatistics(
user_id=user_id,
total_games=0,
total_wins=0,
total_losses=0,
total_concessions=0,
win_rate=0.0,
current_streak=0,
best_streak=0,
average_rating=0.0,
updated_at=datetime.now(timezone.utc)
)
db.add(stats)
# Update statistics
stats.total_games += 1
stats.last_game_date = datetime.now(timezone.utc)
if outcome == "WIN":
stats.total_wins += 1
stats.current_streak = max(0, stats.current_streak) + 1
if stats.current_streak > stats.best_streak:
stats.best_streak = stats.current_streak
elif outcome in ("LOSS", "CONCESSION"):
stats.total_losses += 1
stats.total_concessions += 1 if outcome == "CONCESSION" else 0
stats.current_streak = 0
# Calculate win rate
if stats.total_games > 0:
stats.win_rate = (stats.total_wins / stats.total_games) * 100
stats.updated_at = datetime.now(timezone.utc)
await db.flush()
return StatisticsUpdateResponse(
user_id=stats.user_id,
total_games=stats.total_games,
total_wins=stats.total_wins,
total_losses=stats.total_losses,
win_rate=stats.win_rate,
current_streak=stats.current_streak,
updated_at=stats.updated_at
)
# ===== Card Collection =====
@router.post("/collection", response_model=CardCollectionResponse, status_code=status.HTTP_201_CREATED)
async def add_card_to_collection(
request: CardCollectionCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Add a card to user collection."""
user_id = int(current_user["user_id"])
# Check if card already exists in collection
check = (
select(UserCardCollection)
.where(
UserCardCollection.user_id == user_id,
UserCardCollection.card_id == request.card_id,
UserCardCollection.is_foil == request.is_foil,
UserCardCollection.is_alt_art == request.is_alt_art,
)
)
check_result = await db.execute(check)
existing = check_result.scalar_one_or_none()
if existing:
# Update quantity
stmt = (
update(UserCardCollection)
.where(UserCardCollection.id == existing.id)
.values(quantity=existing.quantity + request.quantity)
)
await db.execute(stmt)
await db.flush()
stmt = select(UserCardCollection).where(UserCardCollection.id == existing.id)
result = await db.execute(stmt)
updated = result.scalar_one_or_none()
return CardCollectionResponse.model_validate(updated)
card = UserCardCollection(
user_id=user_id,
card_id=request.card_id,
quantity=request.quantity,
condition=request.condition,
language=request.language,
is_foil=request.is_foil,
is_alt_art=request.is_alt_art,
acquired_date=request.acquired_date or datetime.now(timezone.utc),
acquisition_method=request.acquisition_method,
notes=request.notes,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(card)
await db.flush()
return CardCollectionResponse.model_validate(card)
@router.get("/collection", response_model=CardCollectionListResponse)
async def get_user_collection(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
is_foil: Optional[bool] = None,
is_alt_art: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get user's card collection."""
user_id = int(current_user["user_id"])
offset = (page - 1) * page_size
conditions = [UserCardCollection.user_id == user_id]
if is_foil is not None:
conditions.append(UserCardCollection.is_foil == is_foil)
if is_alt_art is not None:
conditions.append(UserCardCollection.is_alt_art == is_alt_art)
count_stmt = select(func.count()).select_from(UserCardCollection)
for cond in conditions:
count_stmt = count_stmt.where(cond)
total_result = await db.execute(count_stmt)
total = total_result.scalar()
stmt = select(UserCardCollection)
for cond in conditions:
stmt = stmt.where(cond)
stmt = stmt.order_by(UserCardCollection.acquired_date.desc()).offset(offset).limit(page_size)
result = await db.execute(stmt)
cards = result.scalars().all()
return CardCollectionListResponse(
cards=[CardCollectionResponse.model_validate(c) for c in cards],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size
)
@router.patch("/collection/{card_id}", response_model=CardCollectionResponse)
async def update_card_in_collection(
card_id: int,
request: CardCollectionUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a card in user collection."""
user_id = int(current_user["user_id"])
stmt = select(UserCardCollection).where(
UserCardCollection.id == card_id,
UserCardCollection.user_id == user_id
)
result = await db.execute(stmt)
card = result.scalar_one_or_none()
if not card:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in collection")
update_data = request.model_dump(exclude_unset=True)
update_data["updated_at"] = datetime.now(timezone.utc)
stmt = (
update(UserCardCollection)
.where(UserCardCollection.id == card_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
stmt = select(UserCardCollection).where(UserCardCollection.id == card_id)
result = await db.execute(stmt)
updated = result.scalar_one_or_none()
return CardCollectionResponse.model_validate(updated)
@router.delete("/collection/{card_id}", response_model=MessageResponse)
async def remove_card_from_collection(
card_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Remove a card from user collection."""
user_id = int(current_user["user_id"])
stmt = select(UserCardCollection).where(
UserCardCollection.id == card_id,
UserCardCollection.user_id == user_id
)
result = await db.execute(stmt)
card = result.scalar_one_or_none()
if not card:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in collection")
stmt = delete(UserCardCollection).where(UserCardCollection.id == card_id)
await db.execute(stmt)
await db.flush()
return MessageResponse(message="Card removed from collection")
# ===== Wishlist =====
@router.post("/wishlist", response_model=WishlistResponse, status_code=status.HTTP_201_CREATED)
async def add_to_wishlist(
request: WishlistCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Add a card to wishlist."""
user_id = int(current_user["user_id"])
# Check if already in wishlist
check = (
select(CardWishlist)
.where(CardWishlist.user_id == user_id, CardWishlist.card_id == request.card_id)
)
check_result = await db.execute(check)
if check_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Card already in wishlist")
item = CardWishlist(
user_id=user_id,
card_id=request.card_id,
max_price=request.max_price,
notes=request.notes,
created_at=datetime.now(timezone.utc)
)
db.add(item)
await db.flush()
return WishlistResponse.model_validate(item)
@router.get("/wishlist", response_model=WishlistListResponse)
async def get_wishlist(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get user's wishlist."""
user_id = int(current_user["user_id"])
offset = (page - 1) * page_size
count_stmt = select(func.count()).select_from(CardWishlist).where(CardWishlist.user_id == user_id)
total_result = await db.execute(count_stmt)
total = total_result.scalar()
stmt = (
select(CardWishlist)
.where(CardWishlist.user_id == user_id)
.order_by(CardWishlist.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(stmt)
items = result.scalars().all()
return WishlistListResponse(
items=[WishlistResponse.model_validate(i) for i in items],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size
)
@router.patch("/wishlist/{item_id}", response_model=WishlistResponse)
async def update_wishlist_item(
item_id: int,
request: WishlistUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a wishlist item."""
user_id = int(current_user["user_id"])
stmt = select(CardWishlist).where(
CardWishlist.id == item_id,
CardWishlist.user_id == user_id
)
result = await db.execute(stmt)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wishlist item not found")
update_data = request.model_dump(exclude_unset=True)
stmt = (
update(CardWishlist)
.where(CardWishlist.id == item_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
stmt = select(CardWishlist).where(CardWishlist.id == item_id)
result = await db.execute(stmt)
updated = result.scalar_one_or_none()
return WishlistResponse.model_validate(updated)
@router.delete("/wishlist/{item_id}", response_model=MessageResponse)
async def remove_from_wishlist(
item_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Remove a card from wishlist."""
user_id = int(current_user["user_id"])
stmt = select(CardWishlist).where(
CardWishlist.id == item_id,
CardWishlist.user_id == user_id
)
result = await db.execute(stmt)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wishlist item not found")
stmt = delete(CardWishlist).where(CardWishlist.id == item_id)
await db.execute(stmt)
await db.flush()
return MessageResponse(message="Removed from wishlist")
# ===== Groups =====
@router.post("/groups", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
async def create_group(
request: GroupCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new user group."""
user_id = int(current_user["user_id"])
group = UserGroup(
name=request.name,
description=request.description,
owner_id=user_id,
is_public=request.is_public,
max_members=request.max_members,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(group)
await db.flush()
# Add creator as owner
member = GroupMember(
group_id=group.id,
user_id=user_id,
role="OWNER",
joined_at=datetime.now(timezone.utc)
)
db.add(member)
await db.flush()
return GroupResponse.model_validate(group)
@router.get("/groups", response_model=GroupListResponse)
async def get_user_groups(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
is_public: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get user's groups."""
user_id = int(current_user["user_id"])
offset = (page - 1) * page_size
# Get groups where user is member or owner
member_groups = select(GroupMember.group_id).where(GroupMember.user_id == user_id)
count_stmt = select(func.count()).select_from(UserGroup).where(
UserGroup.id.in_(member_groups)
)
if is_public is not None:
count_stmt = count_stmt.where(UserGroup.is_public == is_public)
total_result = await db.execute(count_stmt)
total = total_result.scalar()
stmt = (
select(UserGroup)
.where(UserGroup.id.in_(member_groups))
.order_by(UserGroup.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(stmt)
groups = result.scalars().all()
# Get member counts
group_ids = [g.id for g in groups]
member_counts = {}
if group_ids:
count_subquery = (
select(GroupMember.group_id, func.count().label('count'))
.where(GroupMember.group_id.in_(group_ids))
.group_by(GroupMember.group_id)
.subquery()
)
count_stmt = select(count_subquery.c.group_id, count_subquery.c.count).where(
count_subquery.c.group_id.in_(group_ids)
)
count_result = await db.execute(count_stmt)
member_counts = {row[0]: row[1] for row in count_result.fetchall()}
return GroupListResponse(
groups=[
GroupResponse(
**GroupResponse.model_validate(g).model_dump(),
member_count=member_counts.get(g.id, 0)
)
for g in groups
],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size
)
@router.get("/groups/{group_id}", response_model=GroupResponse)
async def get_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a specific group."""
stmt = select(UserGroup).where(UserGroup.id == group_id)
result = await db.execute(stmt)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
return GroupResponse.model_validate(group)
@router.patch("/groups/{group_id}", response_model=GroupResponse)
async def update_group(
group_id: int,
request: GroupUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a group."""
user_id = int(current_user["user_id"])
# Verify user is owner
check = (
select(GroupMember)
.where(
GroupMember.group_id == group_id,
GroupMember.user_id == user_id,
GroupMember.role == "OWNER"
)
)
check_result = await db.execute(check)
if not check_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owner can update group")
stmt = select(UserGroup).where(UserGroup.id == group_id)
result = await db.execute(stmt)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
update_data = request.model_dump(exclude_unset=True)
update_data["updated_at"] = datetime.now(timezone.utc)
stmt = (
update(UserGroup)
.where(UserGroup.id == group_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
stmt = select(UserGroup).where(UserGroup.id == group_id)
result = await db.execute(stmt)
updated = result.scalar_one_or_none()
return GroupResponse.model_validate(updated)
@router.delete("/groups/{group_id}", response_model=MessageResponse)
async def delete_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a group."""
user_id = int(current_user["user_id"])
# Verify user is owner
check = (
select(GroupMember)
.where(
GroupMember.group_id == group_id,
GroupMember.user_id == user_id,
GroupMember.role == "OWNER"
)
)
check_result = await db.execute(check)
if not check_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owner can delete group")
stmt = delete(UserGroup).where(UserGroup.id == group_id)
await db.execute(stmt)
await db.flush()
return MessageResponse(message="Group deleted successfully")
# ===== Group Members =====
@router.post("/groups/{group_id}/members", status_code=status.HTTP_201_CREATED)
async def add_group_member(
group_id: int,
request: GroupMemberCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Add a member to a group."""
user_id = int(current_user["user_id"])
# Verify user is admin or owner
check = (
select(GroupMember)
.where(
GroupMember.group_id == group_id,
GroupMember.user_id == user_id,
GroupMember.role.in_(["OWNER", "ADMIN"])
)
)
check_result = await db.execute(check)
if not check_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
# Check if already a member
member_check = (
select(GroupMember)
.where(GroupMember.group_id == group_id, GroupMember.user_id == request.user_id)
)
member_result = await db.execute(member_check)
if member_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already a member")
member = GroupMember(
group_id=group_id,
user_id=request.user_id,
role=request.role.value,
joined_at=datetime.now(timezone.utc)
)
db.add(member)
await db.flush()
return {"message": "Member added", "member_id": member.id}
@router.patch("/groups/{group_id}/members/{member_id}", response_model=GroupMemberUpdate)
async def update_group_member(
group_id: int,
member_id: int,
request: GroupMemberUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a group member's role."""
user_id = int(current_user["user_id"])
# Verify user is owner
check = (
select(GroupMember)
.where(
GroupMember.group_id == group_id,
GroupMember.user_id == user_id,
GroupMember.role == "OWNER"
)
)
check_result = await db.execute(check)
if not check_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owner can update roles")
stmt = select(GroupMember).where(GroupMember.id == member_id)
result = await db.execute(stmt)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
stmt = (
update(GroupMember)
.where(GroupMember.id == member_id)
.values(role=request.role.value)
)
await db.execute(stmt)
await db.flush()
return {"message": "Member role updated"}
@router.delete("/groups/{group_id}/members/{member_id}", response_model=MessageResponse)
async def remove_group_member(
group_id: int,
member_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Remove a member from a group."""
user_id = int(current_user["user_id"])
# Verify user is admin or owner
check = (
select(GroupMember)
.where(
GroupMember.group_id == group_id,
GroupMember.user_id == user_id,
GroupMember.role.in_(["OWNER", "ADMIN"])
)
)
check_result = await db.execute(check)
if not check_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
stmt = select(GroupMember).where(GroupMember.id == member_id)
result = await db.execute(stmt)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
stmt = delete(GroupMember).where(GroupMember.id == member_id)
await db.execute(stmt)
await db.flush()
return MessageResponse(message="Member removed from group")
# ===== Group Chat Messages =====
@router.post("/groups/{group_id}/messages", response_model=GroupChatMessageResponse, status_code=status.HTTP_201_CREATED)
async def send_group_message(
group_id: int,
request: GroupChatMessageCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Send a message in a group chat."""
user_id = int(current_user["user_id"])
# Verify user is a member
member_check = (
select(GroupMember)
.where(GroupMember.group_id == group_id, GroupMember.user_id == user_id)
)
member_result = await db.execute(member_check)
if not member_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a group member")
# Verify group exists
group_check = select(UserGroup).where(UserGroup.id == group_id)
group_result = await db.execute(group_check)
if not group_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
message = GroupChatMessage(
group_id=group_id,
sender_id=user_id,
message=request.message,
created_at=datetime.now(timezone.utc)
)
db.add(message)
await db.flush()
# Get sender username
user_check = select(User).where(User.id == user_id)
user_result = await db.execute(user_check)
sender = user_result.scalar_one_or_none()
return GroupChatMessageResponse(
id=message.id,
group_id=message.group_id,
sender_id=message.sender_id,
sender_username=sender.username if sender else None,
message=message.message,
created_at=message.created_at
)
@router.get("/groups/{group_id}/messages", response_model=GroupChatMessageListResponse)
async def get_group_messages(
group_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get messages in a group chat."""
offset = (page - 1) * page_size
# Verify user is a member
member_check = (
select(GroupMember)
.where(GroupMember.group_id == group_id, GroupMember.user_id == int(current_user["user_id"]))
)
member_result = await db.execute(member_check)
if not member_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a group member")
count_stmt = select(func.count()).select_from(GroupChatMessage).where(GroupChatMessage.group_id == group_id)
total_result = await db.execute(count_stmt)
total = total_result.scalar()
stmt = (
select(GroupChatMessage)
.where(GroupChatMessage.group_id == group_id)
.order_by(GroupChatMessage.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(stmt)
messages = result.scalars().all()
# Get sender usernames
sender_ids = [m.sender_id for m in messages]
sender_names = {}
if sender_ids:
user_stmt = select(User.id, User.username).where(User.id.in_(sender_ids))
user_result = await db.execute(user_stmt)
sender_names = {row[0]: row[1] for row in user_result.fetchall()}
return GroupChatMessageListResponse(
messages=[
GroupChatMessageResponse(
id=m.id,
group_id=m.group_id,
sender_id=m.sender_id,
sender_username=sender_names.get(m.sender_id),
message=m.message,
created_at=m.created_at
)
for m in messages
],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size
)
# ===== Networks =====
@router.post("/networks", response_model=NetworkResponse, status_code=status.HTTP_201_CREATED)
async def create_network(
request: NetworkCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new user network."""
user_id = int(current_user["user_id"])
network = UserNetwork(
name=request.name,
description=request.description,
creator_id=user_id,
is_public=request.is_public,
created_at=datetime.now(timezone.utc)
)
db.add(network)
await db.flush()
# Add creator as owner
member = NetworkMember(
network_id=network.id,
user_id=user_id,
role="OWNER",
joined_at=datetime.now(timezone.utc)
)
db.add(member)
await db.flush()
return NetworkResponse.model_validate(network)
@router.get("/networks", response_model=NetworkListResponse)
async def get_user_networks(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get user's networks."""
user_id = int(current_user["user_id"])
offset = (page - 1) * page_size
member_networks = select(NetworkMember.network_id).where(NetworkMember.user_id == user_id)
count_stmt = select(func.count()).select_from(UserNetwork).where(
UserNetwork.id.in_(member_networks)
)
total_result = await db.execute(count_stmt)
total = total_result.scalar()
stmt = (
select(UserNetwork)
.where(UserNetwork.id.in_(member_networks))
.order_by(UserNetwork.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(stmt)
networks = result.scalars().all()
# Get member counts
network_ids = [n.id for n in networks]
member_counts = {}
if network_ids:
count_subquery = (
select(NetworkMember.network_id, func.count().label('count'))
.where(NetworkMember.network_id.in_(network_ids))
.group_by(NetworkMember.network_id)
.subquery()
)
count_stmt = select(count_subquery.c.network_id, count_subquery.c.count).where(
count_subquery.c.network_id.in_(network_ids)
)
count_result = await db.execute(count_stmt)
member_counts = {row[0]: row[1] for row in count_result.fetchall()}
return NetworkListResponse(
networks=[
NetworkResponse(
**NetworkResponse.model_validate(n).model_dump(),
member_count=member_counts.get(n.id, 0)
)
for n in networks
],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size
)
@router.get("/networks/{network_id}", response_model=NetworkResponse)
async def get_network(
network_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a specific network."""
stmt = select(UserNetwork).where(UserNetwork.id == network_id)
result = await db.execute(stmt)
network = result.scalar_one_or_none()
if not network:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Network not found")
return NetworkResponse.model_validate(network)
@router.patch("/networks/{network_id}", response_model=NetworkResponse)
async def update_network(
network_id: int,
request: NetworkUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a network."""
user_id = int(current_user["user_id"])
# Verify user is creator
check = (
select(UserNetwork)
.where(UserNetwork.id == network_id, UserNetwork.creator_id == user_id)
)
check_result = await db.execute(check)
if not check_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator can update network")
stmt = select(UserNetwork).where(UserNetwork.id == network_id)
result = await db.execute(stmt)
network = result.scalar_one_or_none()
if not network:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Network not found")
update_data = request.model_dump(exclude_unset=True)
stmt = (
update(UserNetwork)
.where(UserNetwork.id == network_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
stmt = select(UserNetwork).where(UserNetwork.id == network_id)
result = await db.execute(stmt)
updated = result.scalar_one_or_none()
return NetworkResponse.model_validate(updated)
@router.delete("/networks/{network_id}", response_model=MessageResponse)
async def delete_network(
network_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a network."""
user_id = int(current_user["user_id"])
# Verify user is creator
check = (
select(UserNetwork)
.where(UserNetwork.id == network_id, UserNetwork.creator_id == user_id)
)
check_result = await db.execute(check)
if not check_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator can delete network")
stmt = delete(UserNetwork).where(UserNetwork.id == network_id)
await db.execute(stmt)
await db.flush()
return MessageResponse(message="Network deleted successfully")
# ===== Network Members =====
@router.post("/networks/{network_id}/members", status_code=status.HTTP_201_CREATED)
async def add_network_member(
network_id: int,
request: NetworkMemberCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Add a member to a network."""
user_id = int(current_user["user_id"])
# Verify user is admin or owner
check = (
select(NetworkMember)
.where(
NetworkMember.network_id == network_id,
NetworkMember.user_id == user_id,
NetworkMember.role.in_(["OWNER", "ADMIN"])
)
)
check_result = await db.execute(check)
if not check_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
# Check if already a member
member_check = (
select(NetworkMember)
.where(NetworkMember.network_id == network_id, NetworkMember.user_id == request.user_id)
)
member_result = await db.execute(member_check)
if member_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already a member")
member = NetworkMember(
network_id=network_id,
user_id=request.user_id,
role=request.role.value,
joined_at=datetime.now(timezone.utc)
)
db.add(member)
await db.flush()
return {"message": "Member added", "member_id": member.id}
# ===== User Preferences =====
@router.get("/preferences", response_model=UserPreferenceResponse)
async def get_user_preferences(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get user preferences."""
user_id = int(current_user["user_id"])
stmt = select(UserPreference).where(UserPreference.user_id == user_id)
result = await db.execute(stmt)
prefs = result.scalar_one_or_none()
if not prefs:
# Create default preferences
prefs = UserPreference(
user_id=user_id,
theme="light",
notifications_enabled=True,
email_notifications=True,
auto_save_decks=True,
default_format="standard",
language="EN",
updated_at=datetime.now(timezone.utc)
)
db.add(prefs)
await db.flush()
return UserPreferenceResponse.model_validate(prefs)
@router.patch("/preferences", response_model=UserPreferenceResponse)
async def update_user_preferences(
request: UserPreferenceUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update user preferences."""
user_id = int(current_user["user_id"])
stmt = select(UserPreference).where(UserPreference.user_id == user_id)
result = await db.execute(stmt)
prefs = result.scalar_one_or_none()
if not prefs:
# Create default preferences first
prefs = UserPreference(
user_id=user_id,
updated_at=datetime.now(timezone.utc)
)
db.add(prefs)
await db.flush()
update_data = request.model_dump(exclude_unset=True)
update_data["updated_at"] = datetime.now(timezone.utc)
stmt = (
update(UserPreference)
.where(UserPreference.user_id == user_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
stmt = select(UserPreference).where(UserPreference.user_id == user_id)
result = await db.execute(stmt)
updated = result.scalar_one_or_none()
return UserPreferenceResponse.model_validate(updated)
# ===== Activity Log =====
@router.get("/activity", response_model=ActivityLogListResponse)
async def get_activity_log(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
activity_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get user activity log."""
user_id = int(current_user["user_id"])
offset = (page - 1) * page_size
conditions = [UserActivityLog.user_id == user_id]
if activity_type:
conditions.append(UserActivityLog.activity_type == activity_type)
count_stmt = select(func.count()).select_from(UserActivityLog)
for cond in conditions:
count_stmt = count_stmt.where(cond)
total_result = await db.execute(count_stmt)
total = total_result.scalar()
stmt = select(UserActivityLog)
for cond in conditions:
stmt = stmt.where(cond)
stmt = stmt.order_by(UserActivityLog.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(stmt)
entries = result.scalars().all()
return ActivityLogListResponse(
entries=[ActivityLogEntry.model_validate(e) for e in entries],
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size
)