fec3953009
- Three category scopes: personal / group / system (watch) - PascalCase-with-dashes naming convention enforced at backend + frontend - is_group_admin flag on GroupMembership; PATCH endpoint for admins to toggle it - Categories router: scope-based list/create/rename/delete with _check_can_manage_cat - Documents router: delete uses is_admin + can_delete share flag + group-admin check; remove_category requires doc ownership; assign_category accepts group/system categories - Proxy layers inject x-user-is-admin and x-user-admin-groups headers - Frontend: ManageCategoriesDialog grouped by scope with lock icons; SourcePanel scope picker + client-side name validation; AdminGroupsPage group-admin checkbox Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
238 lines
7.8 KiB
Python
238 lines
7.8 KiB
Python
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()
|