varunka's picture
backend restart
0e66dad
Raw
History Blame Contribute Delete
9.08 kB
"""
Admin Router
God-mode onboarding and identity management for leader/authority accounts.
"""
from datetime import datetime, timezone
from typing import List, Optional
import re
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from database import get_db
from models.user import User
from routers.auth import get_current_admin, hash_password
router = APIRouter(prefix="/admin", tags=["Admin"])
ALLOWED_ROLES = {"authority", "leader", "admin", "citizen"}
ONBOARD_ROLES = {"authority", "leader", "admin"}
class UserCreateRequest(BaseModel):
name: str = Field(..., min_length=2, max_length=120)
email: str = Field(..., min_length=5, max_length=200)
phone: Optional[str] = Field(default=None, max_length=32)
state: str = Field(..., min_length=2, max_length=120)
district: str = Field(..., min_length=2, max_length=120)
password: str = Field(..., min_length=8, max_length=128)
role: str = Field(..., description="authority|leader|admin")
class BulkUserCreateRequest(BaseModel):
users: List[UserCreateRequest] = Field(default_factory=list, max_length=250)
class UpdateRoleRequest(BaseModel):
role: str = Field(..., description="authority|leader|admin|citizen")
class ResetPasswordRequest(BaseModel):
new_password: str = Field(..., min_length=8, max_length=128)
class ActivationRequest(BaseModel):
is_active: bool
def _normalized_email(raw: str) -> str:
email = (raw or "").strip().lower()
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
raise HTTPException(status_code=400, detail="Invalid email format")
return email
def _serialize_user(row: User) -> dict:
return {
"id": row.id,
"name": row.name,
"email": row.email,
"phone": row.phone,
"state": row.state,
"district": row.district,
"role": row.role or "citizen",
"is_active": bool(row.is_active if row.is_active is not None else True),
"created_at": row.created_at.isoformat() if row.created_at else None,
"last_login_at": row.last_login_at.isoformat() if row.last_login_at else None,
}
@router.get("/onboarding/stats")
def onboarding_stats(
db: Session = Depends(get_db),
_: User = Depends(get_current_admin),
):
total_users = db.query(func.count(User.id)).scalar() or 0
active_users = db.query(func.count(User.id)).filter(User.is_active.isnot(False)).scalar() or 0
role_rows = (
db.query(User.role, func.count(User.id))
.group_by(User.role)
.all()
)
role_counts = {"citizen": 0, "authority": 0, "leader": 0, "admin": 0}
for role, count in role_rows:
normalized = (role or "citizen").strip().lower()
role_counts[normalized] = int(count)
recent = (
db.query(User)
.order_by(User.created_at.desc())
.limit(8)
.all()
)
return {
"total_users": int(total_users),
"active_users": int(active_users),
"inactive_users": int(total_users) - int(active_users),
"role_counts": role_counts,
"recent_onboarded": [_serialize_user(u) for u in recent],
}
@router.get("/users")
def list_users(
role: Optional[str] = Query(default=None),
search: Optional[str] = Query(default=None),
limit: int = Query(default=120, ge=1, le=500),
db: Session = Depends(get_db),
_: User = Depends(get_current_admin),
):
query = db.query(User)
if role:
normalized_role = role.strip().lower()
if normalized_role not in ALLOWED_ROLES:
raise HTTPException(status_code=400, detail=f"Invalid role: {role}")
query = query.filter(func.lower(User.role) == normalized_role)
if search:
pattern = f"%{search.strip()}%"
query = query.filter(
or_(
User.name.ilike(pattern),
User.email.ilike(pattern),
User.phone.ilike(pattern),
User.role.ilike(pattern),
)
)
rows = query.order_by(User.created_at.desc()).limit(limit).all()
return {
"count": len(rows),
"users": [_serialize_user(row) for row in rows],
}
@router.post("/users/create")
def create_user(
req: UserCreateRequest,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin),
):
requested_role = req.role.strip().lower()
if requested_role not in ONBOARD_ROLES:
raise HTTPException(status_code=400, detail="Role must be authority, leader, or admin")
email = _normalized_email(req.email)
existing = db.query(User).filter(func.lower(User.email) == email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
name=req.name.strip(),
email=email,
phone=(req.phone or "").strip() or None,
state=req.state.strip(),
district=req.district.strip(),
role=requested_role,
hashed_password=hash_password(req.password),
is_active=True,
last_login_at=None,
)
db.add(user)
db.commit()
db.refresh(user)
return {
"message": f"{requested_role.title()} onboarded successfully by {admin.name}.",
"user": _serialize_user(user),
}
@router.post("/users/bulk-create")
def bulk_create_users(
req: BulkUserCreateRequest,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin),
):
if not req.users:
raise HTTPException(status_code=400, detail="No users provided")
created = []
skipped = []
for item in req.users:
role = item.role.strip().lower()
if role not in ONBOARD_ROLES:
skipped.append({"email": item.email, "reason": f"invalid_role:{role}"})
continue
try:
email = _normalized_email(item.email)
except HTTPException as exc:
skipped.append({"email": item.email, "reason": f"invalid_email:{exc.detail}"})
continue
exists = db.query(User).filter(func.lower(User.email) == email).first()
if exists:
skipped.append({"email": item.email, "reason": "email_exists"})
continue
user = User(
name=item.name.strip(),
email=email,
phone=(item.phone or "").strip() or None,
state=item.state.strip(),
district=item.district.strip(),
role=role,
hashed_password=hash_password(item.password),
is_active=True,
)
db.add(user)
created.append(user)
db.commit()
for user in created:
db.refresh(user)
return {
"message": f"Bulk onboarding complete by {admin.name}.",
"created_count": len(created),
"skipped_count": len(skipped),
"created": [_serialize_user(user) for user in created],
"skipped": skipped,
}
@router.patch("/users/{user_id}/role")
def update_user_role(
user_id: int,
req: UpdateRoleRequest,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin),
):
new_role = req.role.strip().lower()
if new_role not in ALLOWED_ROLES:
raise HTTPException(status_code=400, detail="Invalid role")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id and new_role != "admin":
raise HTTPException(status_code=400, detail="Admin cannot demote own account")
user.role = new_role
db.add(user)
db.commit()
db.refresh(user)
return {
"message": f"Role updated to {new_role}",
"user": _serialize_user(user),
}
@router.patch("/users/{user_id}/reset-password")
def reset_user_password(
user_id: int,
req: ResetPasswordRequest,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.hashed_password = hash_password(req.new_password)
user.last_login_at = None
db.add(user)
db.commit()
return {"message": "Password reset completed"}
@router.patch("/users/{user_id}/activation")
def set_user_activation(
user_id: int,
req: ActivationRequest,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id and not req.is_active:
raise HTTPException(status_code=400, detail="Admin cannot deactivate own account")
user.is_active = bool(req.is_active)
db.add(user)
db.commit()
db.refresh(user)
return {
"message": f"User {'activated' if req.is_active else 'deactivated'}",
"user": _serialize_user(user),
}