120 lines
5.7 KiB
Python
120 lines
5.7 KiB
Python
"""Сервис аудита: единственная точка записи `audit_event` (spec audit-trail, ТЗ 8.2).
|
||
|
||
Журнал append-only: у сервиса один публичный метод — `record`; методов изменения и удаления нет и не будет
|
||
(проверяется тестом `tests/test_audit_service.py`, он же сканирует `src/` на UPDATE/DELETE по `AuditEvent`).
|
||
|
||
Запись выполняется в ТЕКУЩЕЙ транзакции сессии: сервис делает `add` + `flush` и не вызывает `commit`/`rollback` —
|
||
фиксация и откат остаются за вызывающим кодом, поэтому откат изменения данных откатывает и строку аудита.
|
||
"""
|
||
|
||
import datetime
|
||
import decimal
|
||
import enum
|
||
import math
|
||
import uuid
|
||
from collections.abc import Mapping
|
||
from typing import Any
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from src.models.audit_ex import AuditEvent
|
||
from src.models.enums import AuditAction, AuditSection
|
||
|
||
|
||
def _scalar_to_jsonable(value: Any) -> Any:
|
||
if value is None or isinstance(value, bool | int | str):
|
||
return value
|
||
if isinstance(value, enum.Enum):
|
||
return to_jsonable(value.value)
|
||
if isinstance(value, float):
|
||
if not math.isfinite(value):
|
||
raise ValueError("Нечисловое значение float (NaN/Infinity) нельзя записать в jsonb")
|
||
return value
|
||
if isinstance(value, decimal.Decimal):
|
||
return format(value, "f")
|
||
if isinstance(value, uuid.UUID):
|
||
return str(value)
|
||
if isinstance(value, datetime.datetime | datetime.date | datetime.time):
|
||
return value.isoformat()
|
||
raise TypeError(f"Значение типа {type(value).__name__} не сериализуется в jsonb для audit_event")
|
||
|
||
|
||
def to_jsonable(value: Any) -> Any:
|
||
"""Приводит значение к JSON-совместимому виду для jsonb.
|
||
|
||
UUID -> str; Decimal -> строка без экспоненты (деньги не превращаются в float); datetime/date/time -> isoformat;
|
||
Enum -> `.value`; Mapping/list/tuple/set -> рекурсивно. Неизвестный тип — `TypeError`, а не молчаливое `str()`.
|
||
"""
|
||
if isinstance(value, Mapping):
|
||
return {str(key): to_jsonable(item) for key, item in value.items()}
|
||
if isinstance(value, list | tuple | set | frozenset):
|
||
return [to_jsonable(item) for item in value]
|
||
return _scalar_to_jsonable(value)
|
||
|
||
|
||
def _normalize_payload(name: str, payload: Mapping[str, Any] | None) -> Any:
|
||
if payload is None:
|
||
return None
|
||
if not isinstance(payload, Mapping):
|
||
raise TypeError(f"{name} должен быть словарём (Mapping) или None, получено {type(payload).__name__}")
|
||
return to_jsonable(payload)
|
||
|
||
|
||
class AuditService:
|
||
"""Запись событий журнала аудита в сессию вызывающего (ручной DI: сессия приходит в конструктор)."""
|
||
|
||
def __init__(self, session: AsyncSession) -> None:
|
||
self._session = session
|
||
|
||
async def record( # noqa: PLR0913 - сигнатура зафиксирована спекой audit-trail (все поля audit_event)
|
||
self,
|
||
*,
|
||
action: AuditAction,
|
||
section: AuditSection,
|
||
object_type: str,
|
||
object_id: uuid.UUID | None = None,
|
||
user_id: uuid.UUID | None = None,
|
||
consolidation_version_id: uuid.UUID | None = None,
|
||
description: str | None = None,
|
||
before_value: Mapping[str, Any] | None = None,
|
||
after_value: Mapping[str, Any] | None = None,
|
||
comment: str | None = None,
|
||
result: str | None = None,
|
||
changed_field: str | None = None,
|
||
ip_address: str | None = None,
|
||
user_agent: str | None = None,
|
||
) -> AuditEvent:
|
||
"""Добавляет событие в текущую транзакцию и делает `flush` (id, ранние нарушения ограничений); без `commit`.
|
||
|
||
`user_id=None` — системное действие. `occurred_at` ставит сервис (UTC), параметром он не принимается.
|
||
"""
|
||
if not isinstance(action, AuditAction):
|
||
raise TypeError(f"action должен быть членом AuditAction, получено {type(action).__name__}: {action!r}")
|
||
if not isinstance(section, AuditSection):
|
||
raise TypeError(f"section должен быть членом AuditSection, получено {type(section).__name__}: {section!r}")
|
||
if not isinstance(object_type, str):
|
||
raise TypeError(f"object_type должен быть строкой, получено {type(object_type).__name__}")
|
||
if not object_type.strip():
|
||
raise ValueError("object_type не может быть пустым")
|
||
|
||
event = AuditEvent(
|
||
occurred_at=datetime.datetime.now(datetime.UTC),
|
||
user_id=user_id,
|
||
action=action,
|
||
section=section,
|
||
object_type=object_type,
|
||
object_id=object_id,
|
||
consolidation_version_id=consolidation_version_id,
|
||
description=description,
|
||
before_value=_normalize_payload("before_value", before_value),
|
||
after_value=_normalize_payload("after_value", after_value),
|
||
comment=comment,
|
||
result=result,
|
||
changed_field=changed_field,
|
||
ip_address=ip_address,
|
||
user_agent=user_agent,
|
||
)
|
||
self._session.add(event)
|
||
await self._session.flush()
|
||
return event
|