from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database import get_db from app.deps import get_current_admin from app.models.group import Group, GroupMembership from app.models.user import User from app.schemas.group import GroupCreate, GroupDetailOut, GroupMemberAdminUpdate, GroupMemberOut, GroupOut, GroupUpdate router = APIRouter() def _to_group_out(group: Group) -> GroupOut: return GroupOut( id=group.id, name=group.name, description=group.description, created_at=group.created_at, member_count=len(group.memberships), ) def _to_group_detail(group: Group) -> GroupDetailOut: members = [] for m in group.memberships: # memberships are loaded with joined user via selectinload user = m.__dict__.get("_user") or getattr(m, "user", None) if user is None: continue members.append(GroupMemberOut( id=user.id, email=user.email, full_name=user.full_name, is_active=user.is_active, joined_at=m.joined_at, )) return GroupDetailOut( id=group.id, name=group.name, description=group.description, created_at=group.created_at, member_count=len(members), members=members, ) @router.get("", response_model=list[GroupOut]) async def list_groups( _admin: User = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ) -> list[GroupOut]: result = await db.execute( select(Group).options(selectinload(Group.memberships)).order_by(Group.name) ) groups = list(result.scalars().all()) return [_to_group_out(g) for g in groups] @router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED) async def create_group( body: GroupCreate, _admin: User = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ) -> GroupOut: existing = await db.execute(select(Group).where(Group.name == body.name)) if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="A group with that name already exists") group = Group(name=body.name, description=body.description) db.add(group) await db.commit() await db.refresh(group) # refresh doesn't load relationships — load fresh with memberships result = await db.execute( select(Group).options(selectinload(Group.memberships)).where(Group.id == group.id) ) group = result.scalar_one() return _to_group_out(group) @router.get("/{group_id}", response_model=GroupDetailOut) async def get_group( group_id: str, _admin: User = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ) -> GroupDetailOut: result = await db.execute( select(Group) .options( selectinload(Group.memberships).joinedload(GroupMembership.group), ) .where(Group.id == group_id) ) group = result.scalar_one_or_none() if not group: raise HTTPException(status_code=404, detail="Group not found") # Load members separately for simplicity mem_result = await db.execute( select(GroupMembership, User) .join(User, User.id == GroupMembership.user_id) .where(GroupMembership.group_id == group_id) .order_by(User.email) ) rows = mem_result.all() members = [ GroupMemberOut( id=user.id, email=user.email, full_name=user.full_name, is_active=user.is_active, is_group_admin=membership.is_group_admin, joined_at=membership.joined_at, ) for membership, user in rows ] return GroupDetailOut( id=group.id, name=group.name, description=group.description, created_at=group.created_at, member_count=len(members), members=members, ) @router.patch("/{group_id}", response_model=GroupOut) async def update_group( group_id: str, body: GroupUpdate, _admin: User = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ) -> GroupOut: result = await db.execute( select(Group).options(selectinload(Group.memberships)).where(Group.id == group_id) ) group = result.scalar_one_or_none() if not group: raise HTTPException(status_code=404, detail="Group not found") if body.name is not None and body.name != group.name: dupe = await db.execute(select(Group).where(Group.name == body.name)) if dupe.scalar_one_or_none(): raise HTTPException(status_code=400, detail="A group with that name already exists") group.name = body.name if body.description is not None: group.description = body.description await db.commit() await db.refresh(group) result2 = await db.execute( select(Group).options(selectinload(Group.memberships)).where(Group.id == group.id) ) group = result2.scalar_one() return _to_group_out(group) @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_group( group_id: str, _admin: User = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ) -> None: result = await db.execute(select(Group).where(Group.id == group_id)) group = result.scalar_one_or_none() if not group: raise HTTPException(status_code=404, detail="Group not found") await db.delete(group) await db.commit() @router.post("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def add_member( group_id: str, user_id: str, _admin: User = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ) -> None: group_result = await db.execute(select(Group).where(Group.id == group_id)) if not group_result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Group not found") user_result = await db.execute(select(User).where(User.id == user_id)) if not user_result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="User not found") existing = await db.execute( select(GroupMembership).where( GroupMembership.group_id == group_id, GroupMembership.user_id == user_id ) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="User is already a member of this group") db.add(GroupMembership(group_id=group_id, user_id=user_id)) await db.commit() @router.patch("/{group_id}/members/{user_id}/admin", status_code=status.HTTP_204_NO_CONTENT) async def set_member_admin( group_id: str, user_id: str, body: GroupMemberAdminUpdate, _admin: User = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ) -> None: result = await db.execute( select(GroupMembership).where( GroupMembership.group_id == group_id, GroupMembership.user_id == user_id ) ) membership = result.scalar_one_or_none() if not membership: raise HTTPException(status_code=404, detail="User is not a member of this group") membership.is_group_admin = body.is_group_admin await db.commit() @router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_member( group_id: str, user_id: str, _admin: User = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ) -> None: result = await db.execute( select(GroupMembership).where( GroupMembership.group_id == group_id, GroupMembership.user_id == user_id ) ) membership = result.scalar_one_or_none() if not membership: raise HTTPException(status_code=404, detail="User is not a member of this group") await db.delete(membership) await db.commit()