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",