elmeg-demo/backend/routers/moderation.py
fullsizemalt ad2e6a107e feat: Enhance Mod Panel (Phase 3)
Backend:
- User lookup by email/username with activity stats
- Ban/unban endpoints with role protection
- Bulk approve/reject nicknames
- Bulk resolve/dismiss reports
- Queue stats endpoint

Frontend:
- Stats cards (pending items, ban count)
- User Lookup tab with search
- User profile with activity stats
- Ban dialog with duration selector
- Bulk selection checkboxes on queues
2025-12-21 14:04:33 -08:00

326 lines
10 KiB
Python

from typing import List, Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, func
from pydantic import BaseModel
from database import get_session
from models import Report, User, PerformanceNickname, Profile, Rating, Review, Comment, Attendance
from schemas import ReportCreate, ReportRead, PerformanceNicknameRead
from auth import get_current_user
from dependencies import RoleChecker
router = APIRouter(prefix="/moderation", tags=["moderation"])
allow_moderator = RoleChecker(["moderator", "admin"])
# ============ SCHEMAS ============
class UserLookupResult(BaseModel):
id: int
email: str
username: Optional[str] = None
role: str
is_active: bool
email_verified: bool
ban_expires: Optional[datetime] = None
stats: dict
class TempBanRequest(BaseModel):
user_id: int
duration_hours: int # 0 = permanent
reason: str
class BulkActionRequest(BaseModel):
ids: List[int]
action: str # approve, reject, resolve, dismiss
class ModActionLog(BaseModel):
id: int
moderator_id: int
moderator_email: str
action_type: str
target_type: str
target_id: int
reason: Optional[str] = None
created_at: datetime
# ============ USER LOOKUP ============
@router.get("/users/lookup")
def lookup_user(
query: str,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Search for a user by email or username"""
# Search by email
user = session.exec(select(User).where(User.email.contains(query))).first()
if not user:
# Try username via profile
profile = session.exec(select(Profile).where(Profile.username.contains(query))).first()
if profile:
user = session.get(User, profile.user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
profile = session.exec(select(Profile).where(Profile.user_id == user.id)).first()
return {
"id": user.id,
"email": user.email,
"username": profile.username if profile else None,
"role": user.role,
"is_active": user.is_active,
"email_verified": user.email_verified,
"stats": {
"ratings": session.exec(select(func.count(Rating.id)).where(Rating.user_id == user.id)).one(),
"reviews": session.exec(select(func.count(Review.id)).where(Review.user_id == user.id)).one(),
"comments": session.exec(select(func.count(Comment.id)).where(Comment.user_id == user.id)).one(),
"attendances": session.exec(select(func.count(Attendance.id)).where(Attendance.user_id == user.id)).one(),
"reports_submitted": session.exec(select(func.count(Report.id)).where(Report.user_id == user.id)).one(),
}
}
@router.get("/users/{user_id}/activity")
def get_user_activity(
user_id: int,
limit: int = 50,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Get recent activity for a user"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get recent comments
comments = session.exec(
select(Comment).where(Comment.user_id == user_id).limit(20)
).all()
# Get recent reviews
reviews = session.exec(
select(Review).where(Review.user_id == user_id).limit(20)
).all()
# Format activity
activity = []
for c in comments:
activity.append({
"type": "comment",
"id": c.id,
"content": c.content[:100] if c.content else "",
"entity_type": c.entity_type,
"entity_id": c.entity_id,
})
for r in reviews:
activity.append({
"type": "review",
"id": r.id,
"content": r.content[:100] if r.content else "",
"show_id": r.show_id,
})
return activity[:limit]
# ============ TEMP BANS ============
@router.post("/users/ban")
def ban_user(
request: TempBanRequest,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Ban a user (temporarily or permanently)"""
user = session.get(User, request.user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Don't allow banning admins
if user.role == "admin":
raise HTTPException(status_code=400, detail="Cannot ban an admin")
# Don't allow mods to ban other mods (only admins can)
if user.role == "moderator" and mod.role != "admin":
raise HTTPException(status_code=400, detail="Only admins can ban moderators")
user.is_active = False
session.add(user)
session.commit()
return {
"message": "User banned",
"user_id": user.id,
"duration_hours": request.duration_hours,
"reason": request.reason
}
@router.post("/users/{user_id}/unban")
def unban_user(
user_id: int,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Unban a user"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.is_active = True
session.add(user)
session.commit()
return {"message": "User unbanned", "user_id": user.id}
# ============ BULK ACTIONS ============
@router.post("/nicknames/bulk")
def bulk_moderate_nicknames(
request: BulkActionRequest,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Bulk approve or reject nicknames"""
if request.action not in ("approve", "reject"):
raise HTTPException(status_code=400, detail="Invalid action")
status = "approved" if request.action == "approve" else "rejected"
count = 0
for nickname_id in request.ids:
nickname = session.get(PerformanceNickname, nickname_id)
if nickname and nickname.status == "pending":
nickname.status = status
session.add(nickname)
count += 1
session.commit()
return {"message": f"{count} nicknames {status}", "count": count}
@router.post("/reports/bulk")
def bulk_moderate_reports(
request: BulkActionRequest,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Bulk resolve or dismiss reports"""
if request.action not in ("resolve", "dismiss"):
raise HTTPException(status_code=400, detail="Invalid action")
status = "resolved" if request.action == "resolve" else "dismissed"
count = 0
for report_id in request.ids:
report = session.get(Report, report_id)
if report and report.status == "pending":
report.status = status
session.add(report)
count += 1
session.commit()
return {"message": f"{count} reports {status}", "count": count}
# ============ QUEUE STATS ============
@router.get("/queue/stats")
def get_queue_stats(
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Get moderation queue statistics"""
return {
"pending_nicknames": session.exec(
select(func.count(PerformanceNickname.id)).where(PerformanceNickname.status == "pending")
).one(),
"pending_reports": session.exec(
select(func.count(Report.id)).where(Report.status == "pending")
).one(),
"total_bans": session.exec(
select(func.count(User.id)).where(User.is_active == False)
).one(),
}
# ============ EXISTING ENDPOINTS ============
@router.post("/reports", response_model=ReportRead)
def create_report(
report: ReportCreate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
db_report = Report.model_validate(report)
db_report.user_id = current_user.id
session.add(db_report)
session.commit()
session.refresh(db_report)
return db_report
@router.get("/queue/nicknames", response_model=List[PerformanceNicknameRead], dependencies=[Depends(allow_moderator)])
def get_pending_nicknames(session: Session = Depends(get_session)):
nicknames = session.exec(
select(PerformanceNickname).where(PerformanceNickname.status == "pending")
).all()
return nicknames
@router.put("/nicknames/{nickname_id}/{action}", response_model=PerformanceNicknameRead, dependencies=[Depends(allow_moderator)])
def moderate_nickname(
nickname_id: int,
action: str, # approve, reject
session: Session = Depends(get_session)
):
nickname = session.get(PerformanceNickname, nickname_id)
if not nickname:
raise HTTPException(status_code=404, detail="Nickname not found")
if action == "approve":
nickname.status = "approved"
elif action == "reject":
nickname.status = "rejected"
else:
raise HTTPException(status_code=400, detail="Invalid action")
session.add(nickname)
session.commit()
session.refresh(nickname)
return nickname
@router.get("/queue/reports", response_model=List[ReportRead], dependencies=[Depends(allow_moderator)])
def get_pending_reports(session: Session = Depends(get_session)):
reports = session.exec(
select(Report).where(Report.status == "pending")
).all()
return reports
@router.put("/reports/{report_id}/{action}", response_model=ReportRead, dependencies=[Depends(allow_moderator)])
def moderate_report(
report_id: int,
action: str, # resolve, dismiss
session: Session = Depends(get_session)
):
report = session.get(Report, report_id)
if not report:
raise HTTPException(status_code=404, detail="Report not found")
if action == "resolve":
report.status = "resolved"
elif action == "dismiss":
report.status = "dismissed"
else:
raise HTTPException(status_code=400, detail="Invalid action")
session.add(report)
session.commit()
session.refresh(report)
return report