DataFlowing / processor /app /api /users.py
stouchinglhh
collector: 同步采集工程 v1.2.1(全部 LFS 模式文件指针化,与 Space 现有文件合并)
d646dd7
Raw History Blame Contribute Delete
6.43 kB
"""Administrator account management for the EGOData platform."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from app.auth import _is_expired, get_current_user, hash_password, require_roles
from app.database import async_session
from app.models import User
router = APIRouter(prefix="/api/v1/users", tags=["users"])
Role = Literal["admin", "reviewer", "engineer"]
UserStatus = Literal["active", "disabled"]
class UserCreate(BaseModel):
username: str = Field(..., min_length=1, max_length=64)
password: str = Field(..., min_length=6, max_length=128)
email: str | None = Field(default=None, max_length=128)
role: Role = "engineer"
expires_at: datetime | None = None
class UserUpdate(BaseModel):
email: str | None = Field(default=None, max_length=128)
role: Role | None = None
status: UserStatus | None = None
password: str | None = Field(default=None, min_length=6, max_length=128)
expires_at: datetime | None = None
def _utc(value: datetime | None) -> datetime | None:
if value is None:
return None
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
def _status(user: User) -> str:
return "expired" if user.status == "active" and _is_expired(user.expires_at) else user.status
def _out(user: User) -> dict:
return {
"id": str(user.id),
"username": user.username,
"email": user.email,
"role": user.role,
"status": _status(user),
"account_status": user.status,
"expires_at": user.expires_at.isoformat() if user.expires_at else None,
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
async def _get_user(db, user_id: str) -> User:
try:
user = await db.get(User, user_id)
except Exception:
user = None
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
async def _admin_count(db) -> int:
return int((await db.execute(
select(func.count(User.id)).where(User.role == "admin", User.status == "active")
)).scalar() or 0)
@router.get("")
async def list_users(
role: Role | None = Query(default=None),
status: str | None = Query(default=None),
_: dict = Depends(require_roles("admin")),
):
async with async_session() as db:
query = select(User).order_by(User.created_at.asc(), User.username.asc())
if role:
query = query.where(User.role == role)
if status in {"active", "disabled"}:
query = query.where(User.status == status)
users = (await db.execute(query)).scalars().all()
return {"users": [_out(user) for user in users], "total": len(users)}
@router.post("", status_code=201)
async def create_user(body: UserCreate, _: dict = Depends(require_roles("admin"))):
username = body.username.strip()
if not username:
raise HTTPException(status_code=422, detail="Username cannot be empty")
async with async_session() as db:
exists = (await db.execute(select(User).where(User.username == username))).scalar_one_or_none()
if exists:
raise HTTPException(status_code=409, detail="Username already exists")
user = User(
username=username,
password_hash=hash_password(body.password),
email=str(body.email) if body.email else None,
role=body.role,
status="active",
expires_at=_utc(body.expires_at),
)
db.add(user)
try:
await db.commit()
await db.refresh(user)
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="Username already exists")
return _out(user)
@router.patch("/{user_id}")
async def update_user(
user_id: str,
body: UserUpdate,
current_user: dict = Depends(require_roles("admin")),
):
async with async_session() as db:
user = await _get_user(db, user_id)
if body.status == "disabled" and str(user.id) == str(current_user.get("sub")):
raise HTTPException(status_code=400, detail="You cannot disable your own account")
if body.role == "admin" and user.role != "admin":
# No special action; kept explicit for readability of the audit path.
pass
if user.role == "admin" and body.role and body.role != "admin" and user.status == "active":
if await _admin_count(db) <= 1:
raise HTTPException(status_code=400, detail="At least one active admin is required")
if user.role == "admin" and body.status == "disabled" and user.status == "active":
if await _admin_count(db) <= 1:
raise HTTPException(status_code=400, detail="At least one active admin is required")
if "email" in body.model_fields_set:
user.email = str(body.email) if body.email else None
if body.role is not None:
user.role = body.role
if body.status is not None:
user.status = body.status
if body.password:
user.password_hash = hash_password(body.password)
if "expires_at" in body.model_fields_set:
user.expires_at = _utc(body.expires_at)
await db.commit()
await db.refresh(user)
return _out(user)
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
current_user: dict = Depends(require_roles("admin")),
):
"""Permanently remove an account; UI primarily uses disable instead."""
async with async_session() as db:
user = await _get_user(db, user_id)
if str(user.id) == str(current_user.get("sub")):
raise HTTPException(status_code=400, detail="You cannot delete your own account")
if user.role == "admin" and user.status == "active" and await _admin_count(db) <= 1:
raise HTTPException(status_code=400, detail="At least one active admin is required")
await db.delete(user)
await db.commit()
return {"message": "User deleted"}