"""Friends/follow/block router. docs/planning/social.md §4/§5 (US-1). Builds the request/accept/block business logic on top of the models in app/models/social.py (already merged): friend requests are directional (requester -> addressee) until accepted, at which point the relationship is treated as mutual; follow is one-way with no approval step; block is a hard deny that clears any existing friendship/follow between the two users and is queryable so a later layer (e.g. chat) can check it -- enforcing the block anywhere but here is out of scope for this router. Endpoints: - POST /api/friends/requests -- send a friend request - GET /api/friends/requests -- list incoming (pending) requests - POST /api/friends/requests/{id}/accept -- accept an incoming request - POST /api/friends/requests/{id}/decline -- decline an incoming request - DELETE /api/friends/{user_id} -- unfriend - POST /api/friends/{user_id}/follow -- follow a user - DELETE /api/friends/{user_id}/follow -- unfollow a user - POST /api/friends/{user_id}/block -- block a user - DELETE /api/friends/{user_id}/block -- unblock a user - GET /api/friends -- list friends - GET /api/friends/following -- list users followed - GET /api/friends/blocked -- list blocked users """ from datetime import datetime from typing import Annotated from fastapi import APIRouter, HTTPException, status, Depends from pydantic import BaseModel from sqlalchemy import select, delete, or_, and_ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.dependencies import CurrentUser from app.models.social import Friendship, Follow, Block, FriendshipStatus from app.models.user import User router = APIRouter() DbDep = Annotated[AsyncSession, Depends(get_db)] # ──────────────────────────── Pydantic schemas ──────────────────────────────── class FriendRequestCreate(BaseModel): user_id: int class FriendRequestResponse(BaseModel): id: int requester_id: int addressee_id: int status: FriendshipStatus created_at: datetime model_config = {"from_attributes": True} class UserSummary(BaseModel): id: int username: str model_config = {"from_attributes": True} class FriendRequestListResponse(BaseModel): requests: list[FriendRequestResponse] class FriendListResponse(BaseModel): friends: list[UserSummary] class FollowListResponse(BaseModel): following: list[UserSummary] class BlockedListResponse(BaseModel): blocked: list[UserSummary] class ActionResponse(BaseModel): status: str # ──────────────────────────────── Helpers ────────────────────────────────────── def _friendship_pair(a: int, b: int): """SQLAlchemy predicate matching a Friendship row between a and b, regardless of which one is the requester/addressee.""" return or_( and_(Friendship.requester_id == a, Friendship.addressee_id == b), and_(Friendship.requester_id == b, Friendship.addressee_id == a), ) def _follow_pair(a: int, b: int): """Predicate matching a Follow row in either direction between a and b.""" return or_( and_(Follow.follower_id == a, Follow.followed_id == b), and_(Follow.follower_id == b, Follow.followed_id == a), ) async def _is_blocked_either_direction(db: AsyncSession, a: int, b: int) -> bool: result = await db.execute( select(Block.id).where( or_( and_(Block.blocker_id == a, Block.blocked_id == b), and_(Block.blocker_id == b, Block.blocked_id == a), ) ) ) return result.scalar_one_or_none() is not None async def _get_user_or_404(db: AsyncSession, user_id: int) -> User: result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") return user # ──────────────────────────────── Requests ───────────────────────────────────── @router.post("/requests", response_model=FriendRequestResponse, status_code=status.HTTP_201_CREATED) async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUser, db: DbDep): """Send a friend request to another user.""" target_id = body.user_id if target_id == current_user.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a friend request to yourself") await _get_user_or_404(db, target_id) if await _is_blocked_either_direction(db, current_user.id, target_id): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot send a friend request to this user") existing = await db.execute(select(Friendship).where(_friendship_pair(current_user.id, target_id))) if existing.scalar_one_or_none() is not None: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists") fr = Friendship(requester_id=current_user.id, addressee_id=target_id) db.add(fr) try: await db.commit() except IntegrityError: await db.rollback() raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists") await db.refresh(fr) return fr @router.get("/requests", response_model=FriendRequestListResponse) async def list_incoming_requests(current_user: CurrentUser, db: DbDep): """List pending friend requests sent *to* the authenticated user.""" result = await db.execute( select(Friendship).where( Friendship.addressee_id == current_user.id, Friendship.status == FriendshipStatus.PENDING, ) ) return FriendRequestListResponse(requests=list(result.scalars().all())) @router.post("/requests/{request_id}/accept", response_model=FriendRequestResponse) async def accept_friend_request(request_id: int, current_user: CurrentUser, db: DbDep): """Accept an incoming friend request, making the friendship mutual.""" result = await db.execute(select(Friendship).where(Friendship.id == request_id)) fr = result.scalar_one_or_none() # 404 (not a 403) both when the row doesn't exist and when it isn't # addressed to the caller, so ids can't be enumerated to probe other # accounts' pending requests. if fr is None or fr.addressee_id != current_user.id or fr.status != FriendshipStatus.PENDING: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Friend request not found") fr.status = FriendshipStatus.ACCEPTED await db.commit() await db.refresh(fr) return fr @router.post("/requests/{request_id}/decline", response_model=ActionResponse) async def decline_friend_request(request_id: int, current_user: CurrentUser, db: DbDep): """Decline (and remove) an incoming friend request.""" result = await db.execute(select(Friendship).where(Friendship.id == request_id)) fr = result.scalar_one_or_none() if fr is None or fr.addressee_id != current_user.id or fr.status != FriendshipStatus.PENDING: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Friend request not found") await db.delete(fr) await db.commit() return ActionResponse(status="declined") # ──────────────────────────────── Friends ────────────────────────────────────── @router.get("", response_model=FriendListResponse) async def list_friends(current_user: CurrentUser, db: DbDep): """List the authenticated user's accepted friends.""" result = await db.execute( select(Friendship).where( or_(Friendship.requester_id == current_user.id, Friendship.addressee_id == current_user.id), Friendship.status == FriendshipStatus.ACCEPTED, ) ) friendships = result.scalars().all() other_ids = [ fr.addressee_id if fr.requester_id == current_user.id else fr.requester_id for fr in friendships ] if not other_ids: return FriendListResponse(friends=[]) users_result = await db.execute(select(User).where(User.id.in_(other_ids))) return FriendListResponse(friends=list(users_result.scalars().all())) @router.delete("/{user_id}", response_model=ActionResponse) async def unfriend(user_id: int, current_user: CurrentUser, db: DbDep): """Remove an existing (accepted) friendship.""" if user_id == current_user.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unfriend yourself") result = await db.execute( select(Friendship).where( _friendship_pair(current_user.id, user_id), Friendship.status == FriendshipStatus.ACCEPTED, ) ) fr = result.scalar_one_or_none() if fr is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not friends with this user") await db.delete(fr) await db.commit() return ActionResponse(status="unfriended") # ──────────────────────────────── Follow ─────────────────────────────────────── @router.post("/{user_id}/follow", response_model=ActionResponse, status_code=status.HTTP_201_CREATED) async def follow_user(user_id: int, current_user: CurrentUser, db: DbDep): """Follow another user (one-way, no approval).""" if user_id == current_user.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot follow yourself") await _get_user_or_404(db, user_id) if await _is_blocked_either_direction(db, current_user.id, user_id): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot follow this user") existing = await db.execute( select(Follow).where(Follow.follower_id == current_user.id, Follow.followed_id == user_id) ) if existing.scalar_one_or_none() is not None: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already following this user") db.add(Follow(follower_id=current_user.id, followed_id=user_id)) try: await db.commit() except IntegrityError: await db.rollback() raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already following this user") return ActionResponse(status="followed") @router.delete("/{user_id}/follow", response_model=ActionResponse) async def unfollow_user(user_id: int, current_user: CurrentUser, db: DbDep): """Unfollow a user.""" if user_id == current_user.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unfollow yourself") result = await db.execute( select(Follow).where(Follow.follower_id == current_user.id, Follow.followed_id == user_id) ) follow = result.scalar_one_or_none() if follow is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not following this user") await db.delete(follow) await db.commit() return ActionResponse(status="unfollowed") @router.get("/following", response_model=FollowListResponse) async def list_following(current_user: CurrentUser, db: DbDep): """List users the authenticated user follows.""" result = await db.execute(select(Follow).where(Follow.follower_id == current_user.id)) followed_ids = [f.followed_id for f in result.scalars().all()] if not followed_ids: return FollowListResponse(following=[]) users_result = await db.execute(select(User).where(User.id.in_(followed_ids))) return FollowListResponse(following=list(users_result.scalars().all())) # ──────────────────────────────── Block ──────────────────────────────────────── @router.post("/{user_id}/block", response_model=ActionResponse, status_code=status.HTTP_201_CREATED) async def block_user(user_id: int, current_user: CurrentUser, db: DbDep): """Block a user: hard deny that also clears any existing friendship/follow between the two users in either direction.""" if user_id == current_user.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot block yourself") await _get_user_or_404(db, user_id) existing = await db.execute( select(Block).where(Block.blocker_id == current_user.id, Block.blocked_id == user_id) ) if existing.scalar_one_or_none() is not None: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already blocked") db.add(Block(blocker_id=current_user.id, blocked_id=user_id)) await db.execute(delete(Friendship).where(_friendship_pair(current_user.id, user_id))) await db.execute(delete(Follow).where(_follow_pair(current_user.id, user_id))) try: await db.commit() except IntegrityError: await db.rollback() raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already blocked") return ActionResponse(status="blocked") @router.delete("/{user_id}/block", response_model=ActionResponse) async def unblock_user(user_id: int, current_user: CurrentUser, db: DbDep): """Remove a block.""" if user_id == current_user.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unblock yourself") result = await db.execute( select(Block).where(Block.blocker_id == current_user.id, Block.blocked_id == user_id) ) block = result.scalar_one_or_none() if block is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User is not blocked") await db.delete(block) await db.commit() return ActionResponse(status="unblocked") @router.get("/blocked", response_model=BlockedListResponse) async def list_blocked(current_user: CurrentUser, db: DbDep): """List users the authenticated user has blocked.""" result = await db.execute(select(Block).where(Block.blocker_id == current_user.id)) blocked_ids = [b.blocked_id for b in result.scalars().all()] if not blocked_ids: return BlockedListResponse(blocked=[]) users_result = await db.execute(select(User).where(User.id.in_(blocked_ids))) return BlockedListResponse(blocked=list(users_result.scalars().all()))