"""Сервис аудита: единственная точка записи `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