120 lines
5.7 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.

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