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:
parent
310e63224d
commit
58f039a91d
63
api/src/api/endpoints/users.py
Normal file
63
api/src/api/endpoints/users.py
Normal 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]
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
37
api/tests/test_api_users_brief.py
Normal file
37
api/tests/test_api_users_brief.py
Normal 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
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user