f6c55d888f
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
466 lines
14 KiB
Python
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)
|