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 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.api.endpoints.dev_auth import build_dev_router
|
||||||
from src.core.config import Settings, get_settings
|
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 = APIRouter(prefix=API_PREFIX)
|
||||||
api_router.include_router(me.router)
|
api_router.include_router(me.router)
|
||||||
api_router.include_router(directories.router)
|
api_router.include_router(directories.router)
|
||||||
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(admin.router)
|
api_router.include_router(admin.router)
|
||||||
api_router.include_router(files.router)
|
api_router.include_router(files.router)
|
||||||
api_router.include_router(consolidations.router)
|
api_router.include_router(consolidations.router)
|
||||||
|
|||||||
@ -259,3 +259,11 @@ async def count_active_users_with_permission(
|
|||||||
if excluding_role is not None:
|
if excluding_role is not None:
|
||||||
stmt = stmt.where(Role.id != excluding_role)
|
stmt = stmt.where(Role.id != excluding_role)
|
||||||
return (await session.execute(stmt)).scalar_one()
|
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")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
permissions: list[str]
|
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"},
|
f"{D}/legal-entities/{{entity_id}}/aliases/{{alias_id}}": {"delete"},
|
||||||
# directory-import
|
# directory-import
|
||||||
f"{D}/import": {"post"},
|
f"{D}/import": {"post"},
|
||||||
|
# users
|
||||||
|
"/api/v1/users/brief": {"get"},
|
||||||
# users-admin
|
# users-admin
|
||||||
f"{A}/users": {"get", "post"},
|
f"{A}/users": {"get", "post"},
|
||||||
f"{A}/users/{{user_id}}": {"patch"},
|
f"{A}/users/{{user_id}}": {"patch"},
|
||||||
@ -75,6 +77,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
|||||||
CHECKED_PREFIXES = (
|
CHECKED_PREFIXES = (
|
||||||
D,
|
D,
|
||||||
A,
|
A,
|
||||||
|
"/api/v1/users",
|
||||||
"/api/v1/legal-entities",
|
"/api/v1/legal-entities",
|
||||||
"/api/v1/files",
|
"/api/v1/files",
|
||||||
"/api/v1/documents",
|
"/api/v1/documents",
|
||||||
@ -88,6 +91,7 @@ SINGLE_MODELS = [
|
|||||||
"CompanyGroupOut",
|
"CompanyGroupOut",
|
||||||
"LegalEntityOut",
|
"LegalEntityOut",
|
||||||
"UserOut",
|
"UserOut",
|
||||||
|
"UserBriefOut",
|
||||||
"RoleOut",
|
"RoleOut",
|
||||||
"ImportReportOut",
|
"ImportReportOut",
|
||||||
"FileOut",
|
"FileOut",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user