From 58f039a91d1ee439a332bc2429f01f2596ad7bf6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:08:29 +0400 Subject: [PATCH] =?UTF-8?q?feat(users):=20GET=20/users/brief=20=E2=80=94?= =?UTF-8?q?=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5=D1=85=20=D0=B0=D1=83=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B8=D1=84=D0=B8=D1=86=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20(D-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен эндпоинт 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/endpoints/users.py | 63 +++++++++++++++++++++++++++++++ api/src/api/router.py | 3 +- api/src/repositories/users.py | 8 ++++ api/src/schemas/admin.py | 10 +++++ api/tests/test_api_users_brief.py | 37 ++++++++++++++++++ api/tests/test_openapi_routes.py | 4 ++ 6 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 api/src/api/endpoints/users.py create mode 100644 api/tests/test_api_users_brief.py diff --git a/api/src/api/endpoints/users.py b/api/src/api/endpoints/users.py new file mode 100644 index 0000000..9fd8da8 --- /dev/null +++ b/api/src/api/endpoints/users.py @@ -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] diff --git a/api/src/api/router.py b/api/src/api/router.py index 8460dff..acc2f81 100644 --- a/api/src/api/router.py +++ b/api/src/api/router.py @@ -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) diff --git a/api/src/repositories/users.py b/api/src/repositories/users.py index 044a57e..aa07668 100644 --- a/api/src/repositories/users.py +++ b/api/src/repositories/users.py @@ -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()) diff --git a/api/src/schemas/admin.py b/api/src/schemas/admin.py index 8209028..467094a 100644 --- a/api/src/schemas/admin.py +++ b/api/src/schemas/admin.py @@ -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 diff --git a/api/tests/test_api_users_brief.py b/api/tests/test_api_users_brief.py new file mode 100644 index 0000000..794cbd6 --- /dev/null +++ b/api/tests/test_api_users_brief.py @@ -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 diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 8928736..8d426bb 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -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",