Формы «Добавить/Изменить юридическое лицо» (макет 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>
81 lines
3.9 KiB
Python
81 lines
3.9 KiB
Python
"""Доменные ошибки (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
|