Files
RoleForge/app/routers/admin_users.py
Sergey Antropoff b2d3b6b803 Профиль и аккаунт
- API и страницы профиля (редактирование, смена пароля, аватар), публичные карточки.
- Сайдбар: блок пользователя, пункт Users для admin/root, исправлен порядок
  инициализации (показ admin-only после initAuthSession, currentUser).
- GET /auth/me: ответ через ProfileMeResponse, исправлена валидация (is_founder bool).

Команды и роли
- Маршруты и UI команд; при редактировании роли: видимость Team, выбор команды
  в модалке, только команды с активным членством; API team_id в details/ update.
- GET /api/v1/teams?membership=active для списка «своих» команд.
- Форма роли: сегмент Team, панель выбора команды только при Team и не при
  с
2026-05-05 08:15:21 +03:00

378 lines
13 KiB
Python

"""Admin user management: list, detail, role (root), enable/disable, temporary ban, directory removal."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from app.deps.auth import get_current_admin_user_id, get_current_root_user_id
from app.db.pool import get_pool
from app.schemas.admin_users import AdminUserPatchRequest, AdminUserRoleUpdateRequest, TemporaryBanRequest
from app.services.founder_policy import (
account_group_from_role,
ensure_actor_may_modify_protected_user,
ensure_founder_role_not_changed,
)
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
def _user_base_select() -> str:
return """
u.id::text as id,
u.username,
u.email,
u.full_name,
coalesce(u.role, 'user') as role,
coalesce(u.is_active, true) as is_active,
coalesce(u.is_founder, false) as is_founder,
u.deleted_at,
u.ban_until,
u.ban_reason,
u.banned_by_id::text as banned_by_id,
ban_by.username as banned_by_username,
u.created_at,
u.updated_at
"""
def _user_from_clause() -> str:
return "from users u left join users ban_by on ban_by.id = u.banned_by_id"
def _row_to_user_dict(row: Any) -> dict[str, Any]:
d = dict(row)
for k in ("created_at", "updated_at", "deleted_at", "ban_until"):
v = d.get(k)
if v is not None and hasattr(v, "isoformat"):
d[k] = v.isoformat()
d["account_group"] = account_group_from_role(d.get("role"))
d["is_founder"] = bool(d.get("is_founder"))
return d
async def _fetch_user_detail(conn, target_user_id: str) -> Any:
return await conn.fetchrow(
f"select {_user_base_select()} {_user_from_clause()} where u.id = $1::uuid",
target_user_id,
)
def _detail_response_for_user_row(row: Any) -> _AdminUserDetailResponse:
return _AdminUserDetailResponse(
user=_row_to_user_dict(row),
owned_clusters=[],
cluster_roles=[],
namespace_roles=[],
access_requests=[],
audit={"total_entries": 0, "recent": []},
)
class _AdminUserDetailResponse(BaseModel):
user: dict[str, Any]
owned_clusters: list[dict[str, Any]] = Field(default_factory=list)
cluster_roles: list[dict[str, Any]] = Field(default_factory=list)
namespace_roles: list[dict[str, Any]] = Field(default_factory=list)
access_requests: list[dict[str, Any]] = Field(default_factory=list)
audit: dict[str, Any] = Field(default_factory=dict)
def _enrich_list_item(it: dict[str, Any]) -> None:
it["account_group"] = account_group_from_role(it.get("role"))
it["is_founder"] = bool(it.get("is_founder"))
@router.get("")
async def admin_list_users(
page: int = Query(1, ge=1),
per_page: int = Query(25, ge=1, le=200),
_: str = Depends(get_current_admin_user_id),
) -> dict[str, Any]:
offset = (page - 1) * per_page
pool = get_pool()
async with pool.acquire() as conn:
total = int(await conn.fetchval("select count(*)::int from users") or 0)
rows = await conn.fetch(
f"""
select
{_user_base_select()},
0::int as owned_clusters_count,
0::int as cluster_role_bindings,
0::int as namespace_role_bindings,
0::int as access_requests_count,
0::int as audit_log_entries
{_user_from_clause()}
order by u.created_at desc
limit $1 offset $2
""",
per_page,
offset,
)
items = []
for r in rows:
it = dict(r)
for k in ("created_at", "deleted_at", "updated_at", "ban_until"):
v = it.get(k)
if v is not None and hasattr(v, "isoformat"):
it[k] = v.isoformat()
_enrich_list_item(it)
items.append(it)
total_pages = (total + per_page - 1) // per_page if total else 0
return {
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
}
@router.patch("/{target_user_id}/role", response_model=_AdminUserDetailResponse)
async def admin_patch_user_role(
target_user_id: str,
body: AdminUserRoleUpdateRequest,
actor_id: str = Depends(get_current_root_user_id),
) -> _AdminUserDetailResponse:
try:
UUID(target_user_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"select coalesce(is_founder, false) as is_founder from users where id=$1::uuid",
target_user_id,
)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await ensure_actor_may_modify_protected_user(conn, actor_id=actor_id, target_id=target_user_id)
await ensure_founder_role_not_changed(
target_is_founder=bool(row["is_founder"]),
new_role=body.role,
)
await conn.execute(
"""
update users set role = $1, updated_at = now()
where id = $2::uuid
""",
body.role,
target_user_id,
)
row2 = await _fetch_user_detail(conn, target_user_id)
if not row2:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _detail_response_for_user_row(row2)
@router.post("/{target_user_id}/temporary-ban", response_model=_AdminUserDetailResponse)
async def admin_temporary_ban(
target_user_id: str,
body: TemporaryBanRequest,
chief_id: str = Depends(get_current_root_user_id),
) -> _AdminUserDetailResponse:
if target_user_id == chief_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot suspend your own account",
)
try:
UUID(target_user_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
until = body.until
now = datetime.now(tz=timezone.utc)
if until <= now:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Suspension end time must be in the future",
)
reason = body.reason.strip()
if not reason:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reason is required")
pool = get_pool()
async with pool.acquire() as conn:
await ensure_actor_may_modify_protected_user(conn, actor_id=chief_id, target_id=target_user_id)
deleted_at = await conn.fetchval("select deleted_at from users where id=$1::uuid", target_user_id)
if deleted_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User was removed from directory",
)
await conn.execute(
"""
update users
set
is_active = false,
ban_until = $1,
ban_reason = $2,
banned_by_id = $3::uuid,
updated_at = now()
where id = $4::uuid
""",
until,
reason,
chief_id,
target_user_id,
)
row = await _fetch_user_detail(conn, target_user_id)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _detail_response_for_user_row(row)
@router.delete("/{target_user_id}/temporary-ban", response_model=_AdminUserDetailResponse)
async def admin_lift_temporary_ban(
target_user_id: str,
root_id: str = Depends(get_current_root_user_id),
) -> _AdminUserDetailResponse:
try:
UUID(target_user_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
pool = get_pool()
async with pool.acquire() as conn:
await ensure_actor_may_modify_protected_user(conn, actor_id=root_id, target_id=target_user_id)
had_ban = await conn.fetchval(
"select ban_until from users where id=$1::uuid",
target_user_id,
)
if had_ban is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No active temporary suspension for this user",
)
await conn.execute(
"""
update users
set
is_active = true,
ban_until = null,
ban_reason = null,
banned_by_id = null,
updated_at = now()
where id = $1::uuid
""",
target_user_id,
)
row = await _fetch_user_detail(conn, target_user_id)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _detail_response_for_user_row(row)
@router.get("/{target_user_id}")
async def admin_get_user(
target_user_id: str,
_: str = Depends(get_current_admin_user_id),
) -> _AdminUserDetailResponse:
try:
UUID(target_user_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
pool = get_pool()
async with pool.acquire() as conn:
row = await _fetch_user_detail(conn, target_user_id)
if not row:
return _AdminUserDetailResponse(user={})
return _detail_response_for_user_row(row)
@router.patch("/{target_user_id}")
async def admin_patch_user(
target_user_id: str,
body: AdminUserPatchRequest,
admin_id: str = Depends(get_current_root_user_id),
) -> _AdminUserDetailResponse:
if target_user_id == admin_id and body.is_active is False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot disable your own account",
)
try:
UUID(target_user_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
pool = get_pool()
async with pool.acquire() as conn:
meta = await conn.fetchrow(
"select deleted_at, coalesce(is_founder, false) as is_founder from users where id=$1::uuid",
target_user_id,
)
if not meta:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await ensure_actor_may_modify_protected_user(conn, actor_id=admin_id, target_id=target_user_id)
if meta["deleted_at"] is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User was removed from directory; restore is not supported via this endpoint",
)
if bool(meta["is_founder"]) and body.is_active is False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="The founding account cannot be disabled",
)
await conn.execute(
"""
update users
set
is_active = $1,
ban_until = null,
ban_reason = null,
banned_by_id = null,
updated_at = now()
where id = $2::uuid
""",
body.is_active,
target_user_id,
)
row = await _fetch_user_detail(conn, target_user_id)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _detail_response_for_user_row(row)
@router.delete("/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def admin_remove_user_from_directory(
target_user_id: str,
admin_id: str = Depends(get_current_admin_user_id),
) -> None:
if target_user_id == admin_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot remove your own account",
)
try:
UUID(target_user_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
pool = get_pool()
async with pool.acquire() as conn:
await ensure_actor_may_modify_protected_user(conn, actor_id=admin_id, target_id=target_user_id)
row = await conn.fetchrow(
"select deleted_at from users where id=$1::uuid",
target_user_id,
)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if row["deleted_at"] is not None:
return None
await conn.execute(
"""
update users
set
deleted_at = now(),
is_active = false,
ban_until = null,
ban_reason = null,
banned_by_id = null,
updated_at = now()
where id = $1::uuid
""",
target_user_id,
)