arm-core/api/src/common/exceptions.py
Andrey Epifancev a93ab10523 feat(directories): алиасы и ГСК/ГСЗ в POST/PATCH ЮЛ (#84)
Формы «Добавить/Изменить юридическое лицо» (макет 51-02, 79) отправляют всё
одним запросом. POST/PATCH /directories/legal-entities принимают `aliases`,
`gsk_id`, `gsz_id` и применяют их в той же транзакции, что и поля ЮЛ.

- POST: алиасы и членства создаются вместе с ЮЛ; ошибка откатывает всё.
- PATCH: `aliases` — итоговый набор (сверка по normalized_alias; удаление —
  с правом DIRECTORY_DELETE, иначе 403), `gsk_id`/`gsz_id` — итоговые группы
  (`null` — выйти, другая — сменить); не переданное поле не меняется.
- Новая ошибка 422 WRONG_GROUP_KIND; дубль в списке — 409 ALIAS_EXISTS.
- Ответ POST и PATCH — LegalEntityDetail (надмножество LegalEntityOut).
- Логика ручек алиасов и состава ГСК/ГСЗ вынесена в функции модулей
  (add_alias/remove_alias, add_membership/close_membership) — форма и ручки
  ведут себя одинаково; PermissionDeniedError — единый 403 «недостаточно прав».
- Спека directories-api и README обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:48:14 +04:00

81 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Доменные ошибки (spec directories-api): обработчик отдаёт `{"detail": {"code", "message", **extra}}`.
`code` — машинный код ошибки (`NOT_FOUND`, `INN_EXISTS`, …), `message` — текст по-русски, `extra` — дополнительные поля
ответа (например, `legal_entity_id` существующей записи). Ответы `HTTPException` (401/403 строкой) — отдельно.
"""
import uuid
from typing import Any
from fastapi import HTTPException
from fastapi import status as http_status
_RESERVED_KEYS = frozenset({"code", "message"})
class DomainError(Exception):
"""Базовая доменная ошибка с HTTP-статусом ответа."""
def __init__(self, status: int, code: str, message: str, extra: dict[str, Any] | None = None) -> None:
extra = dict(extra or {})
shadowed = sorted(_RESERVED_KEYS & extra.keys())
if shadowed:
raise ValueError(f"дополнительные поля ошибки не могут называться {', '.join(shadowed)}")
super().__init__(message)
self.status = status
self.code = code
self.message = message
self.extra = extra
class NotFoundError(DomainError):
"""404: объекта нет или он вне видимости пользователя (тело одинаковое — без утечки существования)."""
def __init__(self, message: str = "объект не найден") -> None:
super().__init__(http_status.HTTP_404_NOT_FOUND, "NOT_FOUND", message)
class ConflictError(DomainError):
"""409: конфликт с существующими данными (`INN_EXISTS`, `MEMBER_EXISTS`, …)."""
def __init__(self, code: str, message: str, **extra: Any) -> None:
super().__init__(http_status.HTTP_409_CONFLICT, code, message, extra)
class DomainValidationError(DomainError):
"""422: нарушение доменного правила валидации (`INVALID_INN`, `DIFFERENT_COMPANY_GROUP`, …)."""
def __init__(self, code: str, message: str, **extra: Any) -> None:
super().__init__(http_status.HTTP_422_UNPROCESSABLE_CONTENT, code, message, extra)
class PermissionDeniedError(HTTPException):
"""403 АВТОРИЗАЦИИ: у пользователя нет права `code` (`require` маршрута или право на часть операции, например
удаление алиаса формой ЮЛ, #84). Ответ строкой, как у любого 403 «недостаточно прав»."""
def __init__(self, code: str) -> None:
super().__init__(http_status.HTTP_403_FORBIDDEN, detail=f"недостаточно прав: требуется {code}")
self.permission = code
class AuthenticationFailedError(HTTPException):
"""401/403 АУТЕНТИФИКАЦИИ (токен не принят; пользователь не найден, отключён, неоднозначен) — не авторизации.
Ответ — как у `HTTPException` (`{"detail": "<строка>"}`); обработчик дополнительно пишет LOGIN_FAILED в отдельной
транзакции (`services/session_audit.py`, OQ-64). `audit_description` — текст для журнала (без токена),
`user_id` — найденный пользователь (например, отключённый) или `None`.
"""
def __init__(
self,
status_code: int,
detail: str,
*,
audit_description: str,
user_id: uuid.UUID | None = None,
headers: dict[str, str] | None = None,
) -> None:
super().__init__(status_code, detail=detail, headers=headers)
self.audit_description = audit_description
self.user_id = user_id