""" 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 )