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
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.routers import admin, auth, categories_proxy, documents_proxy, profile, services, users
from app.routers import admin, auth, categories_proxy, documents_proxy, groups, profile, services, users
from app.routers import settings as settings_router
from app.services.service_health import check_all, health_check_loop, register_services
@@ -41,6 +41,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(groups.router, prefix="/api/admin/groups", tags=["admin"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(services.router, prefix="/api/services", tags=["services"])
# categories_proxy MUST be registered before documents_proxy —
+2 -1
View File
@@ -1,4 +1,5 @@
from app.models.group import Group, GroupMembership
from app.models.profile import Profile
from app.models.user import User
__all__ = ["User", "Profile"]
__all__ = ["User", "Profile", "Group", "GroupMembership"]
+44
View File
@@ -0,0 +1,44 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Group(Base):
__tablename__ = "groups"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
memberships: Mapped[list["GroupMembership"]] = relationship(
"GroupMembership", back_populates="group", cascade="all, delete-orphan"
)
class GroupMembership(Base):
__tablename__ = "group_memberships"
__table_args__ = (UniqueConstraint("group_id", "user_id", name="uq_group_user"),)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
group_id: Mapped[str] = mapped_column(
String, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
joined_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
group: Mapped["Group"] = relationship("Group", back_populates="memberships")
+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()
+37
View File
@@ -0,0 +1,37 @@
from datetime import datetime
from pydantic import BaseModel, Field
class GroupCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128)
description: str | None = Field(None, max_length=512)
class GroupUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=128)
description: str | None = Field(None, max_length=512)
class GroupMemberOut(BaseModel):
id: str
email: str
full_name: str | None
is_active: bool
joined_at: datetime
model_config = {"from_attributes": True}
class GroupOut(BaseModel):
id: str
name: str
description: str | None
created_at: datetime
member_count: int = 0
model_config = {"from_attributes": True}
class GroupDetailOut(GroupOut):
members: list[GroupMemberOut] = []