"""Groups API router. Endpoints: - POST /api/groups -- create (creator becomes owner) - GET /api/groups -- list groups (paginated), optional mine=true - GET /api/groups/{id} -- group page (name/description/members) - PATCH /api/groups/{id} -- update name/description (owner/admin) - DELETE /api/groups/{id} -- delete (owner only) - POST /api/groups/{id}/join -- join (idempotent) - POST /api/groups/{id}/leave -- leave (owner must transfer first) - POST /api/groups/{id}/members/{user_id}/role -- set role admin/member (owner only) - POST /api/groups/{id}/transfer -- transfer ownership (owner only) See app/models/groups.py for the schema and delete-behavior rationale (docs/planning/social.md US-5). This module owns create/join/leave, role authorization, and the group page aggregate. """ from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field, field_validator from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.dependencies import CurrentUser from app.models.groups import Group, GroupMember, GroupRole from app.models.user import User router = APIRouter() DbDep = Annotated[AsyncSession, Depends(get_db)] _NAME_MAX_LEN = 100 _DESCRIPTION_MAX_LEN = 2000 # ──────────────────────────── Pydantic schemas ──────────────────────────────── class GroupCreate(BaseModel): name: str = Field(..., min_length=1, max_length=_NAME_MAX_LEN) description: str | None = Field(default=None, max_length=_DESCRIPTION_MAX_LEN) @field_validator("name") @classmethod def strip_name(cls, v: str) -> str: v = v.strip() if not v: raise ValueError("name must not be blank") return v @field_validator("description") @classmethod def strip_description(cls, v: str | None) -> str | None: if v is not None: v = v.strip() or None return v class GroupUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=_NAME_MAX_LEN) description: str | None = Field(default=None, max_length=_DESCRIPTION_MAX_LEN) @field_validator("name") @classmethod def strip_name(cls, v: str | None) -> str | None: if v is not None: v = v.strip() if not v: raise ValueError("name must not be blank") return v @field_validator("description") @classmethod def strip_description(cls, v: str | None) -> str | None: if v is not None: v = v.strip() or None return v class GroupSummary(BaseModel): id: int name: str description: str | None owner_id: int | None member_count: int created_at: datetime class GroupListResponse(BaseModel): groups: list[GroupSummary] total: int class GroupMemberOut(BaseModel): """Public-safe member entry -- no email or other private User fields.""" user_id: int username: str role: GroupRole joined_at: datetime class GroupDetail(BaseModel): id: int name: str description: str | None owner_id: int | None member_count: int members: list[GroupMemberOut] created_at: datetime class RoleUpdateRequest(BaseModel): role: GroupRole @field_validator("role") @classmethod def only_admin_or_member(cls, v: GroupRole) -> GroupRole: if v == GroupRole.OWNER: raise ValueError( "cannot set role to owner directly -- use POST /transfer" ) return v class TransferRequest(BaseModel): new_owner_id: int class MembershipOut(BaseModel): group_id: int user_id: int role: GroupRole joined_at: datetime # ──────────────────────────────── Helpers ───────────────────────────────────── async def _get_group_or_404(db: AsyncSession, group_id: int) -> Group: group = await db.get(Group, group_id) if group is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") return group async def _get_membership(db: AsyncSession, group_id: int, user_id: int) -> GroupMember | None: result = await db.execute( select(GroupMember).where( GroupMember.group_id == group_id, GroupMember.user_id == user_id ) ) return result.scalar_one_or_none() async def _member_count(db: AsyncSession, group_id: int) -> int: result = await db.execute( select(func.count()).select_from(GroupMember).where(GroupMember.group_id == group_id) ) return int(result.scalar_one()) def _require_role(membership: GroupMember | None, *allowed: GroupRole) -> None: if membership is None or membership.role not in allowed: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You do not have permission to perform this action", ) # ──────────────────────────────── Endpoints ─────────────────────────────────── @router.post("", response_model=GroupDetail, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=GroupDetail, status_code=status.HTTP_201_CREATED, include_in_schema=False) async def create_group( body: GroupCreate, current_user: CurrentUser, db: DbDep, ): """Create a group. The creator becomes its owner.""" group = Group(name=body.name, description=body.description, owner_id=current_user.id) db.add(group) await db.flush() db.add(GroupMember(group_id=group.id, user_id=current_user.id, role=GroupRole.OWNER)) await db.commit() await db.refresh(group) return GroupDetail( id=group.id, name=group.name, description=group.description, owner_id=group.owner_id, member_count=1, members=[ GroupMemberOut( user_id=current_user.id, username=current_user.username, role=GroupRole.OWNER, joined_at=group.created_at, ) ], created_at=group.created_at, ) @router.get("", response_model=GroupListResponse) @router.get("/", response_model=GroupListResponse, include_in_schema=False) async def list_groups( current_user: CurrentUser, db: DbDep, skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), mine: bool = Query(False, description="If true, only groups the caller is a member of"), ): """List groups, paginated. With ``mine=true``, restrict to the caller's own groups.""" base_query = select(Group) count_query = select(func.count()).select_from(Group) if mine: base_query = base_query.join( GroupMember, GroupMember.group_id == Group.id ).where(GroupMember.user_id == current_user.id) count_query = count_query.join( GroupMember, GroupMember.group_id == Group.id ).where(GroupMember.user_id == current_user.id) total = (await db.execute(count_query)).scalar_one() result = await db.execute( base_query.order_by(Group.created_at.desc()).offset(skip).limit(limit) ) groups = result.scalars().all() summaries = [] for group in groups: summaries.append( GroupSummary( id=group.id, name=group.name, description=group.description, owner_id=group.owner_id, member_count=await _member_count(db, group.id), created_at=group.created_at, ) ) return GroupListResponse(groups=summaries, total=total) @router.get("/{group_id}", response_model=GroupDetail) async def get_group( group_id: int, current_user: CurrentUser, db: DbDep, ): """Group page: name, description, member count, and member list with roles.""" group = await _get_group_or_404(db, group_id) result = await db.execute( select(GroupMember, User.username) .join(User, User.id == GroupMember.user_id) .where(GroupMember.group_id == group_id) .order_by(GroupMember.joined_at.asc()) ) rows = result.all() members = [ GroupMemberOut( user_id=gm.user_id, username=username, role=gm.role, joined_at=gm.joined_at, ) for gm, username in rows ] return GroupDetail( id=group.id, name=group.name, description=group.description, owner_id=group.owner_id, member_count=len(members), members=members, created_at=group.created_at, ) @router.patch("/{group_id}", response_model=GroupDetail) async def update_group( group_id: int, body: GroupUpdate, current_user: CurrentUser, db: DbDep, ): """Update name/description. Owner/admin only.""" group = await _get_group_or_404(db, group_id) membership = await _get_membership(db, group_id, current_user.id) _require_role(membership, GroupRole.OWNER, GroupRole.ADMIN) if body.name is not None: group.name = body.name if body.description is not None: group.description = body.description if body.description else None await db.commit() await db.refresh(group) return await get_group(group_id, current_user, db) @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_group( group_id: int, current_user: CurrentUser, db: DbDep, ): """Delete a group. Owner only.""" group = await _get_group_or_404(db, group_id) if group.owner_id != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can delete this group", ) await db.delete(group) await db.commit() return None @router.post("/{group_id}/join", response_model=MembershipOut) async def join_group( group_id: int, current_user: CurrentUser, db: DbDep, ): """Join a group. Idempotent -- joining an already-joined group is a no-op.""" await _get_group_or_404(db, group_id) existing = await _get_membership(db, group_id, current_user.id) if existing is not None: return MembershipOut( group_id=group_id, user_id=current_user.id, role=existing.role, joined_at=existing.joined_at, ) membership = GroupMember(group_id=group_id, user_id=current_user.id, role=GroupRole.MEMBER) db.add(membership) await db.commit() await db.refresh(membership) return MembershipOut( group_id=group_id, user_id=current_user.id, role=membership.role, joined_at=membership.joined_at, ) @router.post("/{group_id}/leave", response_model=dict) async def leave_group( group_id: int, current_user: CurrentUser, db: DbDep, ): """Leave a group. The owner cannot leave without transferring ownership first.""" await _get_group_or_404(db, group_id) membership = await _get_membership(db, group_id, current_user.id) if membership is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="You are not a member of this group", ) if membership.role == GroupRole.OWNER: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Transfer ownership before leaving this group", ) await db.delete(membership) await db.commit() return {"status": "ok"} @router.post("/{group_id}/members/{user_id}/role", response_model=MembershipOut) async def set_member_role( group_id: int, user_id: int, body: RoleUpdateRequest, current_user: CurrentUser, db: DbDep, ): """Set a member's role (admin/member). Owner only.""" group = await _get_group_or_404(db, group_id) if group.owner_id != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can change member roles", ) target = await _get_membership(db, group_id, user_id) if target is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") if target.role == GroupRole.OWNER: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Use POST /transfer to change ownership", ) target.role = body.role await db.commit() await db.refresh(target) return MembershipOut( group_id=group_id, user_id=user_id, role=target.role, joined_at=target.joined_at ) @router.post("/{group_id}/transfer", response_model=GroupDetail) async def transfer_ownership( group_id: int, body: TransferRequest, current_user: CurrentUser, db: DbDep, ): """Transfer group ownership to another existing member. Owner only.""" group = await _get_group_or_404(db, group_id) if group.owner_id != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can transfer ownership", ) if body.new_owner_id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="You already own this group", ) new_owner_membership = await _get_membership(db, group_id, body.new_owner_id) if new_owner_membership is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="New owner must already be a member of this group", ) current_owner_membership = await _get_membership(db, group_id, current_user.id) new_owner_membership.role = GroupRole.OWNER if current_owner_membership is not None: current_owner_membership.role = GroupRole.ADMIN group.owner_id = body.new_owner_id await db.commit() return await get_group(group_id, current_user, db)