feat(users): GET /users/brief — имена пользователей для всех аутентифицированных (D-12)

Добавлен эндпоинт GET /api/v1/users/brief для получения краткого справочника
пользователей (id, ФИО, статус активности) по списку id. Доступен любому
аутентифицированному пользователю без отдельного права. Максимум 200 id,
иначе 422 TOO_MANY_IDS; некорректный UUID — 422 INVALID_IDS.

- Создан api/src/api/endpoints/users.py с маршрутом /brief
- Добавлен метод users_brief в repositories/users.py
- Добавлена схема UserBriefOut в schemas/admin.py
- Подключён маршрут users в build_api_router ДО admin.router
- Обновлены тесты OpenAPI: добавлены маршрут и префикс /users/brief
- Написаны интеграционные тесты в test_api_users_brief.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 13:08:29 +04:00
parent 310e63224d
commit 58f039a91d
6 changed files with 124 additions and 1 deletions

View File

@ -0,0 +1,63 @@
"""`GET /api/v1/users/brief` — краткий справочник пользователей по id (spec users-admin, D-12).
Доступен любому аутентифицированному пользователю без отдельного права. Возвращает id, ФИО и статус активности
найденных пользователей (включая неактивных); неизвестные id не включаются, максимум 200 id.
"""
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from src.api.dependencies.auth import Principal, current_user
from src.api.dependencies.db import SessionDep
from src.common.exceptions import DomainValidationError
from src.repositories import users
from src.schemas.admin import UserBriefOut
router = APIRouter(prefix="/users", tags=["users"])
MAX_BRIEF_IDS = 200
def _parse_ids(ids_str: str) -> list[uuid.UUID]:
"""Разбор строки id, отфильтровка пустых, валидация формата UUID."""
if not ids_str.strip():
return []
result = []
for id_part in ids_str.split(","):
id_str = id_part.strip()
if not id_str:
continue
try:
result.append(uuid.UUID(id_str))
except ValueError as e:
raise DomainValidationError("INVALID_IDS", "некорректный UUID в списке") from e
return result
@router.get(
"/brief",
response_model=list[UserBriefOut],
summary="Краткие данные пользователей",
)
async def get_users_brief(
principal: Annotated[Principal, Depends(current_user)],
session: SessionDep,
ids: str = Query(..., description="Список id через запятую"),
) -> list[UserBriefOut]:
"""Краткие данные пользователей по id: id, ФИО, статус активности.
Возвращает только найденные пользователей (включая неактивных); неизвестные id отсутствуют.
Максимум 200 id; свыше 200 — 422 `TOO_MANY_IDS`. Некорректный UUID — 422 `INVALID_IDS`.
"""
# Разбор и валидация id
parsed_ids = _parse_ids(ids)
# Проверка лимита
if len(parsed_ids) > MAX_BRIEF_IDS:
raise DomainValidationError("TOO_MANY_IDS", f"максимум {MAX_BRIEF_IDS} id")
# Получение пользователей
users_list = await users.users_brief(session, parsed_ids)
return [UserBriefOut.model_validate(user) for user in users_list]

View File

@ -8,7 +8,7 @@ dev-эмитент токенов — только при `AUTH_DEV_ISSUER and D
from fastapi import APIRouter
from src.api.endpoints import admin, consolidations, directories, files, me
from src.api.endpoints import admin, consolidations, directories, files, me, users
from src.api.endpoints.dev_auth import build_dev_router
from src.core.config import Settings, get_settings
@ -20,6 +20,7 @@ def build_api_router(settings: Settings) -> APIRouter:
api_router = APIRouter(prefix=API_PREFIX)
api_router.include_router(me.router)
api_router.include_router(directories.router)
api_router.include_router(users.router)
api_router.include_router(admin.router)
api_router.include_router(files.router)
api_router.include_router(consolidations.router)

View File

@ -259,3 +259,11 @@ async def count_active_users_with_permission(
if excluding_role is not None:
stmt = stmt.where(Role.id != excluding_role)
return (await session.execute(stmt)).scalar_one()
async def users_brief(session: AsyncSession, ids: Sequence[uuid.UUID]) -> list[AppUser]:
"""Краткие данные пользователей по id (включая неактивных); неизвестные id не возвращаются."""
if not ids:
return []
stmt = select(AppUser).where(AppUser.id.in_(ids)).order_by(AppUser.id)
return list((await session.execute(stmt)).scalars().all())

View File

@ -148,3 +148,13 @@ class PermissionsSet(BaseModel):
model_config = ConfigDict(extra="forbid")
permissions: list[str]
class UserBriefOut(BaseModel):
"""Краткие данные пользователя (справочник для загрузивших файлы; доступен любому аутентифицированному)."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
full_name: str
is_active: bool

View File

@ -0,0 +1,37 @@
"""Краткий справочник пользователей (spec users-admin «Краткий справочник пользователей»; D-12)."""
import uuid
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.endpoints.users import MAX_BRIEF_IDS
from src.models.access import AppUser
from tests.conftest import AuthClientFactory
pytestmark = pytest.mark.integration
HTTP_OK = 200
HTTP_UNPROCESSABLE = 422
KA_RF = "ka_rf@armka.local"
async def test_brief_for_non_admin(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
users = (await seeded.execute(select(AppUser).order_by(AppUser.email).limit(2))).scalars().all()
unknown = uuid.uuid4()
client = await auth_client(KA_RF) # без USERS_ADMIN
ids = ",".join([*(str(user.id) for user in users), str(unknown)])
response = await client.get("/users/brief", params={"ids": ids})
assert response.status_code == HTTP_OK
body = {item["id"]: item for item in response.json()}
assert set(body) == {str(user.id) for user in users}
assert all(set(item) == {"id", "full_name", "is_active"} for item in body.values())
async def test_brief_limits(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ARG001
client = await auth_client(KA_RF)
too_many = ",".join(str(uuid.uuid4()) for _ in range(MAX_BRIEF_IDS + 1))
response = await client.get("/users/brief", params={"ids": too_many})
assert response.status_code == HTTP_UNPROCESSABLE and response.json()["detail"]["code"] == "TOO_MANY_IDS"
assert (await client.get("/users/brief", params={"ids": "not-a-uuid"})).status_code == HTTP_UNPROCESSABLE

View File

@ -38,6 +38,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
f"{D}/legal-entities/{{entity_id}}/aliases/{{alias_id}}": {"delete"},
# directory-import
f"{D}/import": {"post"},
# users
"/api/v1/users/brief": {"get"},
# users-admin
f"{A}/users": {"get", "post"},
f"{A}/users/{{user_id}}": {"patch"},
@ -75,6 +77,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
CHECKED_PREFIXES = (
D,
A,
"/api/v1/users",
"/api/v1/legal-entities",
"/api/v1/files",
"/api/v1/documents",
@ -88,6 +91,7 @@ SINGLE_MODELS = [
"CompanyGroupOut",
"LegalEntityOut",
"UserOut",
"UserBriefOut",
"RoleOut",
"ImportReportOut",
"FileOut",