Files
modbison/backend/app/routers/groups.py
daddy f6c55d888f Add Groups API router: create/join/leave, roles, transfer, group page
Implements docs/planning/social.md US-5's API layer on top of the
already-merged Group/GroupMember models (app/models/groups.py):

- POST/GET /api/groups, GET/PATCH/DELETE /api/groups/{id}
- POST .../join (idempotent), .../leave (owner must transfer first)
- POST .../members/{user_id}/role (owner-only, admin/member),
  .../transfer (owner-only, demotes prior owner to admin)

Group page member list is a dedicated schema (user_id/username/role/
joined_at) so no private User fields leak. 27 new tests cover
authorization boundaries, idempotency, and exactly-one-owner invariants;
full backend suite (220 tests) passes with zero regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFSPt6rX1gwZmcr2HEfvzg
2026-09-15 01:09:29 +00:00

466 lines
14 KiB
Python

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