Add Groups management and split Admin navigation

- New backend: Group + GroupMembership models, schemas, CRUD router at
  /api/admin/groups (list, create, get detail, update, delete, add/remove members)
- New Alembic migration: groups and group_memberships tables
- Frontend: Admin sidebar item is now an expandable accordion with
  Users and Groups sub-items; AdminPage redirects to /admin/users;
  new AdminUsersPage and AdminGroupsPage with inline member management panel
- API client: 7 new group functions + TypeScript types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-17 20:49:54 +02:00
parent 2bb1e03adf
commit 4e9ed97b05
15 changed files with 1035 additions and 191 deletions
+216
View File
@@ -0,0 +1,216 @@
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, GroupOut, GroupUpdate, GroupMemberOut
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,
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.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()