Private
Public Access
Adds is_site_admin-gated site administration: user management (list, deactivate/reactivate, reset password, promote/demote), room management (list all rooms including private ones, archive/unarchive, force-transfer ownership), and an audit log of every admin action. Backend: User.is_active (deactivation) and Room.is_archived (archive) are new columns; AdminAuditLog is a new table matching ARCHITECTURE.md's admin_audit_log design, written to in the same transaction as every mutating admin action. require_site_admin (dependencies.py) gates all /api/admin/* routes. get_current_user now rechecks is_active on every request, so deactivating a user kills their already-open session immediately, not just future logins. An admin can't deactivate or demote their own account (the one self-lockout guard included). Archived rooms drop out of the open-room browse list but stay readable for existing members. Frontend: new /admin route (AdminRoute guard, redirects non-admins to /rooms) with a tabbed Users / Rooms / Audit log / Settings page, plus an "Admin" link in the account menu for site admins. Bot/integration management and system settings -- both listed in the original issue -- are intentionally not here: bot management has nothing to manage until Phase 7 builds the actual bot data model, and there's no settings storage or concrete setting to configure yet. Settings has an empty placeholder tab; bot management is deferred entirely to Phase 7. Confirmed this scope cut with the repo owner before implementing. New tests/test_admin.py (14 tests, full suite now 58/58) covers every admin endpoint's permission gate, the self-action guards, deactivation's immediate effect on an already-open session, and that every mutating action produces exactly one audit log row. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
211 lines
6.3 KiB
Python
211 lines
6.3 KiB
Python
import uuid
|
|
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.models import Message, Room, RoomInvite, RoomMembership, RoomRole
|
|
from app.schemas.room import RoomCreate, RoomUpdate
|
|
|
|
|
|
class DuplicateRoomError(Exception):
|
|
pass
|
|
|
|
|
|
class RoomNotFoundError(Exception):
|
|
pass
|
|
|
|
|
|
class RoomIsPrivateError(Exception):
|
|
pass
|
|
|
|
|
|
class MembershipNotFoundError(Exception):
|
|
pass
|
|
|
|
|
|
class CannotRemoveOwnerError(Exception):
|
|
pass
|
|
|
|
|
|
class InsufficientRoleError(Exception):
|
|
pass
|
|
|
|
|
|
class OwnerMustTransferError(Exception):
|
|
pass
|
|
|
|
|
|
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
|
|
room = Room(
|
|
name=data.name,
|
|
description=data.description,
|
|
is_private=data.is_private,
|
|
owner_id=owner_id,
|
|
)
|
|
db.add(room)
|
|
try:
|
|
await db.flush()
|
|
except IntegrityError as exc:
|
|
await db.rollback()
|
|
raise DuplicateRoomError() from exc
|
|
|
|
db.add(RoomMembership(room_id=room.id, user_id=owner_id, role=RoomRole.owner))
|
|
await db.commit()
|
|
await db.refresh(room)
|
|
return room
|
|
|
|
|
|
async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, bool]]:
|
|
result = await db.execute(
|
|
select(Room)
|
|
.where(Room.is_private.is_(False), Room.is_archived.is_(False))
|
|
.options(selectinload(Room.memberships))
|
|
.order_by(Room.created_at)
|
|
)
|
|
rooms = result.scalars().all()
|
|
return [
|
|
(room, any(m.user_id == user_id for m in room.memberships)) for room in rooms
|
|
]
|
|
|
|
|
|
async def list_member_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, RoomRole]]:
|
|
result = await db.execute(
|
|
select(Room, RoomMembership.role)
|
|
.join(RoomMembership, RoomMembership.room_id == Room.id)
|
|
.where(RoomMembership.user_id == user_id)
|
|
.order_by(Room.created_at)
|
|
)
|
|
return [(room, role) for room, role in result.all()]
|
|
|
|
|
|
async def get_room(db: AsyncSession, room_id: uuid.UUID) -> Room:
|
|
room = await db.get(Room, room_id)
|
|
if room is None:
|
|
raise RoomNotFoundError()
|
|
return room
|
|
|
|
|
|
async def join_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> RoomMembership:
|
|
room = await get_room(db, room_id)
|
|
if room.is_private:
|
|
raise RoomIsPrivateError()
|
|
|
|
result = await db.execute(
|
|
select(RoomMembership).where(
|
|
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
|
|
)
|
|
)
|
|
membership = result.scalar_one_or_none()
|
|
if membership is not None:
|
|
return membership
|
|
|
|
membership = RoomMembership(room_id=room_id, user_id=user_id, role=RoomRole.member)
|
|
db.add(membership)
|
|
await db.commit()
|
|
await db.refresh(membership)
|
|
return membership
|
|
|
|
|
|
async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room:
|
|
if data.name is not None:
|
|
room.name = data.name
|
|
if data.description is not None:
|
|
room.description = data.description
|
|
try:
|
|
await db.commit()
|
|
except IntegrityError as exc:
|
|
await db.rollback()
|
|
raise DuplicateRoomError() from exc
|
|
await db.refresh(room)
|
|
return room
|
|
|
|
|
|
async def delete_room(db: AsyncSession, room: Room) -> None:
|
|
# Explicit deletes rather than relying on ORM cascade + eager-loading —
|
|
# simpler and more predictable in async code.
|
|
await db.execute(delete(Message).where(Message.room_id == room.id))
|
|
await db.execute(delete(RoomInvite).where(RoomInvite.room_id == room.id))
|
|
await db.execute(delete(RoomMembership).where(RoomMembership.room_id == room.id))
|
|
await db.delete(room)
|
|
await db.commit()
|
|
|
|
|
|
async def list_room_members(db: AsyncSession, room_id: uuid.UUID) -> list[RoomMembership]:
|
|
result = await db.execute(
|
|
select(RoomMembership)
|
|
.where(RoomMembership.room_id == room_id)
|
|
.options(selectinload(RoomMembership.user))
|
|
.order_by(RoomMembership.joined_at)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def _get_membership(
|
|
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID
|
|
) -> RoomMembership:
|
|
result = await db.execute(
|
|
select(RoomMembership).where(
|
|
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
|
|
)
|
|
)
|
|
membership = result.scalar_one_or_none()
|
|
if membership is None:
|
|
raise MembershipNotFoundError()
|
|
return membership
|
|
|
|
|
|
async def remove_member(
|
|
db: AsyncSession, room_id: uuid.UUID, target_user_id: uuid.UUID, acting_role: RoomRole
|
|
) -> None:
|
|
membership = await _get_membership(db, room_id, target_user_id)
|
|
if membership.role == RoomRole.owner:
|
|
raise CannotRemoveOwnerError()
|
|
if membership.role == RoomRole.admin and acting_role != RoomRole.owner:
|
|
raise InsufficientRoleError()
|
|
|
|
await db.delete(membership)
|
|
await db.commit()
|
|
|
|
|
|
async def change_member_role(
|
|
db: AsyncSession, room_id: uuid.UUID, target_user_id: uuid.UUID, new_role: RoomRole
|
|
) -> RoomMembership:
|
|
membership = await _get_membership(db, room_id, target_user_id)
|
|
if membership.role == RoomRole.owner or new_role == RoomRole.owner:
|
|
# Ownership changes only happen through transfer_ownership.
|
|
raise InsufficientRoleError()
|
|
|
|
membership.role = new_role
|
|
await db.commit()
|
|
result = await db.execute(
|
|
select(RoomMembership)
|
|
.where(RoomMembership.room_id == room_id, RoomMembership.user_id == target_user_id)
|
|
.options(selectinload(RoomMembership.user))
|
|
)
|
|
return result.scalar_one()
|
|
|
|
|
|
async def transfer_ownership(
|
|
db: AsyncSession, room: Room, current_owner_id: uuid.UUID, new_owner_user_id: uuid.UUID
|
|
) -> Room:
|
|
new_owner_membership = await _get_membership(db, room.id, new_owner_user_id)
|
|
current_owner_membership = await _get_membership(db, room.id, current_owner_id)
|
|
|
|
new_owner_membership.role = RoomRole.owner
|
|
current_owner_membership.role = RoomRole.admin
|
|
room.owner_id = new_owner_user_id
|
|
await db.commit()
|
|
await db.refresh(room)
|
|
return room
|
|
|
|
|
|
async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
|
membership = await _get_membership(db, room_id, user_id)
|
|
if membership.role == RoomRole.owner:
|
|
raise OwnerMustTransferError()
|
|
|
|
await db.delete(membership)
|
|
await db.commit()
|