""" MeshBay Hub — admin/moderation panel endpoints. All endpoints require moderator or admin role. Separate from moderation.py (which handles public reporting and content blocklist). """ import logging from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import decrypt_email from meshbay_hub.api.deps import require_moderator from meshbay_hub.api.revocation import get_connected_node_count from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User log = logging.getLogger(__name__) router = APIRouter(prefix="/v1/admin", tags=["admin"]) # ── Request models ─────────────────────────────────────────────────────────── class UserPatchRequest(BaseModel): role: str | None = None status: str | None = None class GroupPatchRequest(BaseModel): status: str | None = None # ── Stats ──────────────────────────────────────────────────────────────────── @router.get("/stats") async def admin_stats( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), ): user_count = (await db.execute(select(func.count()).select_from(User))).scalar_one() group_count = (await db.execute(select(func.count()).select_from(Group))).scalar_one() node_count = (await db.execute(select(func.count()).select_from(Node))).scalar_one() return { "users": user_count, "groups": group_count, "nodes": node_count, "online_nodes": get_connected_node_count(), } # ── Users ──────────────────────────────────────────────────────────────────── @router.get("/users") async def admin_list_users( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), q: str = "", offset: int = 0, limit: int = Query(default=50, le=200), ): query = select(User).order_by(User.created_at.desc()) if q: query = query.where(User.username.ilike(f"%{q}%")) query = query.offset(offset).limit(limit) result = await db.execute(query) users = result.scalars().all() total_query = select(func.count()).select_from(User) if q: total_query = total_query.where(User.username.ilike(f"%{q}%")) total = (await db.execute(total_query)).scalar_one() return { "users": [ { "id": u.id, "username": u.username, "role": u.role, "status": u.status, "created_at": u.created_at.isoformat(), } for u in users ], "total": total, } @router.get("/users/{user_id}") async def admin_get_user( user_id: str, current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), ): user = await db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") group_count = (await db.execute( select(func.count()).where(GroupMember.user_id == user_id) )).scalar_one() try: email = decrypt_email(user.email) except Exception: email = "(encrypted)" return { "id": user.id, "username": user.username, "email": email, "role": user.role, "status": user.status, "created_at": user.created_at.isoformat(), "group_count": group_count, } @router.patch("/users/{user_id}") async def admin_patch_user( user_id: str, body: UserPatchRequest, current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), ): user = await db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") if user.id == current_user.id: raise HTTPException(status_code=400, detail="Cannot modify your own account") from meshbay_hub.api.notifications import create_notification if body.role is not None: if body.role not in ("user", "moderator", "admin"): raise HTTPException(status_code=422, detail="role must be user, moderator, or admin") user.role = body.role log.info("User %s role changed to %s by %s", user.username, body.role, current_user.username) await create_notification( db, user.id, "role_change", f"Your role has been changed to {body.role}", ) if body.status is not None: if body.status not in ("active", "suspended", "revoked"): raise HTTPException(status_code=422, detail="status must be active, suspended, or revoked") user.status = body.status log.info("User %s status changed to %s by %s", user.username, body.status, current_user.username) if body.status == "suspended": await create_notification( db, user.id, "account_suspended", "Your account has been suspended", ) elif body.status == "active": await create_notification( db, user.id, "account_restored", "Your account has been restored", ) db.add(IPLog( user_id=current_user.id, event="admin_user_update", ip_address="admin", detail=f"{user.username}: role={user.role} status={user.status}", )) await db.commit() return { "id": user.id, "username": user.username, "role": user.role, "status": user.status, } # ── Groups ─────────────────────────────────────────────────────────────────── @router.get("/groups") async def admin_list_groups( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), offset: int = 0, limit: int = Query(default=50, le=200), ): query = ( select( Group, func.count(GroupMember.user_id).label("member_count"), ) .outerjoin(GroupMember, Group.id == GroupMember.group_id) .group_by(Group.id) .order_by(Group.created_at.desc()) .offset(offset) .limit(limit) ) result = await db.execute(query) rows = result.all() total = (await db.execute(select(func.count()).select_from(Group))).scalar_one() return { "groups": [ { "id": g.id, "name": g.name, "admin_id": g.admin_id, "visibility": g.visibility, "status": g.status, "created_at": g.created_at.isoformat(), "member_count": mc, } for g, mc in rows ], "total": total, } @router.patch("/groups/{group_id}") async def admin_patch_group( group_id: str, body: GroupPatchRequest, current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), ): group = await db.get(Group, group_id) if not group: raise HTTPException(status_code=404, detail="Group not found") if body.status is not None: if body.status not in ("active", "suspended", "revoked"): raise HTTPException(status_code=422, detail="status must be active, suspended, or revoked") group.status = body.status log.info("Group %s status changed to %s by %s", group.name, body.status, current_user.username) db.add(IPLog( user_id=current_user.id, event="admin_group_update", ip_address="admin", detail=f"{group.name}: status={group.status}", )) await db.commit() return { "id": group.id, "name": group.name, "status": group.status, } # ── IP Audit Logs ──────────────────────────────────────────────────────────── @router.get("/logs") async def admin_list_logs( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), user_id: str | None = None, event: str | None = None, offset: int = 0, limit: int = Query(default=50, le=200), ): query = select(IPLog).order_by(IPLog.timestamp.desc()) if user_id: query = query.where(IPLog.user_id == user_id) if event: query = query.where(IPLog.event == event) query = query.offset(offset).limit(limit) result = await db.execute(query) logs = result.scalars().all() return { "logs": [ { "id": lg.id, "user_id": lg.user_id, "event": lg.event, "ip_address": lg.ip_address, "detail": lg.detail, "timestamp": lg.timestamp.isoformat(), } for lg in logs ], }