Skip to content

Event System API Reference

With track_events=True, CRUDAdmin records what admins do: logins, logouts, refused logins, lockouts, and every create, update and delete made through the interface. Superusers read the log on the Event Logs page.

How Events Are Recorded

Authentication events come from crudauth hooks:

Event Type Status
Successful login LOGIN SUCCESS
Logout LOGOUT SUCCESS
Wrong password, unknown or inactive admin FAILED_LOGIN FAILURE
Login refused by the lockout FAILED_LOGIN FAILURE, with reason: "lockout" in the details

The username tried is stored under details.username, which is what EventService.get_security_alerts groups failed logins by. A hook that fails is logged and doesn't affect the login.

Model events are recorded by the create, update and bulk-delete routes through admin.events, once they know the outcome:

  • SUCCESS once the change is committed; FAILURE when it is refused (invalid input, a record that doesn't exist, a constraint the database enforces). A refused change writes no audit rows, since nothing changed.
  • A create writes one audit row with the new record; an update, one with the record before and after and the fields that changed. Both are keyed by the model's primary key, whatever its name.
  • A delete writes one audit row per deleted record, holding the record as it was.
  • The event and its audit rows are written in one transaction. If any write fails, they're rolled back together, logged, and the admin's change itself still stands.

Stored values:

  • session_id holds the session's public handle, never the session id itself.
  • Values under credential-like keys (any key containing password, secret, token, session_id or api_key) are stored as [redacted], in event details, audit snapshots, change sets and audit metadata alike. A changed password still shows up as a change.
  • Request details are stored in the event's details and in the audit row's audit_metadata.
  • Before 0.7, event details and audit metadata weren't redacted. The Event Logs page redacts the details of those older events when it shows them; the rows themselves are unchanged.

Core Components

Event Types and Status

Bases: str, Enum

Source code in crudadmin/event/schemas.py
class EventType(str, enum.Enum):
    CREATE = "create"
    UPDATE = "update"
    DELETE = "delete"
    LOGIN = "login"
    LOGOUT = "logout"
    FAILED_LOGIN = "failed_login"

Bases: str, Enum

Source code in crudadmin/event/schemas.py
class EventStatus(str, enum.Enum):
    SUCCESS = "success"
    FAILURE = "failure"
    WARNING = "warning"

Model Creation Functions

Source code in crudadmin/event/models.py
def create_admin_event_log(base: type[DeclarativeBase]) -> type[DeclarativeBase]:
    tablename = "admin_event_log"

    if hasattr(base, "registry") and hasattr(base.registry, "_class_registry"):
        existing_class = base.registry._class_registry.get("AdminEventLog")
        if existing_class is not None and isinstance(existing_class, type):
            if issubclass(existing_class, base):
                return existing_class

    class AdminEventLog(base):  # type: ignore
        __tablename__ = tablename
        __table_args__ = {"extend_existing": True}

        id: Mapped[int] = mapped_column(
            "id",
            autoincrement=True,
            nullable=False,
            unique=True,
            primary_key=True,
        )
        timestamp: Mapped[datetime] = mapped_column(
            DateTime(timezone=True),
            default=lambda: datetime.now(UTC),
            nullable=False,
        )
        event_type: Mapped[EventType] = mapped_column(
            SQLEnum(EventType), nullable=False
        )
        status: Mapped[EventStatus] = mapped_column(
            SQLEnum(EventStatus), nullable=False
        )
        user_id: Mapped[int] = mapped_column(index=True)
        session_id: Mapped[str] = mapped_column(String(SESSION_ID_LENGTH), index=True)
        ip_address: Mapped[str] = mapped_column(String(45))
        user_agent: Mapped[str] = mapped_column(String(USER_AGENT_LENGTH))
        resource_type: Mapped[str | None] = mapped_column(String(128))
        resource_id: Mapped[str | None] = mapped_column(String(128))
        details: Mapped[dict[str, Any]] = mapped_column(
            JSON, default=dict, nullable=False
        )

        def __repr__(self):
            return f"<AdminEventLog(id={self.id}, event_type={self.event_type}, user_id={self.user_id})>"

    return AdminEventLog
Source code in crudadmin/event/models.py
def create_admin_audit_log(base: type[DeclarativeBase]) -> type[DeclarativeBase]:
    tablename = "admin_audit_log"

    if hasattr(base, "registry") and hasattr(base.registry, "_class_registry"):
        existing_class = base.registry._class_registry.get("AdminAuditLog")
        if existing_class is not None and isinstance(existing_class, type):
            if issubclass(existing_class, base):
                return existing_class

    class AdminAuditLog(base):  # type: ignore
        __tablename__ = tablename
        __table_args__ = {"extend_existing": True}

        id: Mapped[int] = mapped_column(
            "id",
            autoincrement=True,
            nullable=False,
            unique=True,
            primary_key=True,
        )
        event_id: Mapped[int] = mapped_column(index=True)
        timestamp: Mapped[datetime] = mapped_column(
            DateTime(timezone=True),
            default=lambda: datetime.now(UTC),
            nullable=False,
        )
        resource_type: Mapped[str] = mapped_column(String(128))
        resource_id: Mapped[str] = mapped_column(String(128))
        action: Mapped[str] = mapped_column(String(64))
        previous_state: Mapped[dict[str, Any] | None] = mapped_column(
            JSON, nullable=True
        )
        new_state: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
        changes: Mapped[dict[str, Any]] = mapped_column(
            JSON, default=dict, nullable=False
        )
        audit_metadata: Mapped[dict[str, Any]] = mapped_column(
            JSON, default=dict, nullable=False
        )

        def __repr__(self):
            return f"<AdminAuditLog(id={self.id}, resource_type={self.resource_type}, resource_id={self.resource_id})>"

    return AdminAuditLog

Event Service

The service that writes and queries events and audit rows.

Source code in crudadmin/event/service.py
class EventService:
    def __init__(self, db_config):
        self.db_config = db_config
        self.crud_events: FastCRUD[Any, Any, Any, Any, Any, Any] = FastCRUD(
            db_config.AdminEventLog
        )
        self.crud_audits: FastCRUD[Any, Any, Any, Any, Any, Any] = FastCRUD(
            db_config.AdminAuditLog
        )
        self.json_encoder = CustomJSONEncoder()

    def _serialize_dict(self, data: dict | None) -> dict:
        if not data:
            return {}
        return cast(dict, json.loads(self.json_encoder.encode(data)))

    async def log_event(
        self,
        db: AsyncSession,
        event_type: EventType,
        status: EventStatus,
        user_id: int,
        session_id: str,
        request: Request,
        resource_type: str | None = None,
        resource_id: str | None = None,
        details: dict | None = None,
        commit: bool = True,
    ) -> AdminEventLogRead:
        ip_address = request.client.host if request.client else "unknown"

        event_data = AdminEventLogCreate(
            event_type=event_type,
            status=status,
            user_id=user_id,
            session_id=session_id,
            ip_address=ip_address,
            user_agent=request.headers.get("user-agent", "")[:USER_AGENT_LENGTH],
            resource_type=resource_type,
            resource_id=resource_id,
            details=self._serialize_dict(redact_secrets(details)),
        )

        result = await self.crud_events.create(
            db=db,
            object=event_data,
            schema_to_select=AdminEventLogRead,
            return_as_model=False,
            commit=commit,
        )
        return AdminEventLogRead(**cast(dict, result))

    async def create_audit_log(
        self,
        db: AsyncSession,
        event_id: int,
        resource_type: str,
        resource_id: str,
        action: str,
        previous_state: dict | None = None,
        new_state: dict | None = None,
        metadata: dict | None = None,
        commit: bool = True,
    ) -> AdminAuditLogRead:
        audit_data = AdminAuditLogCreate(
            event_id=event_id,
            resource_type=resource_type,
            resource_id=resource_id,
            action=action,
            previous_state=self._serialize_dict(redact_secrets(previous_state)),
            new_state=self._serialize_dict(redact_secrets(new_state)),
            changes=self._serialize_dict(
                redact_secrets(self._compute_changes(previous_state, new_state))
            ),
            audit_metadata=self._serialize_dict(redact_secrets(metadata)),
        )

        result = await self.crud_audits.create(
            db=db,
            object=audit_data,
            schema_to_select=AdminAuditLogRead,
            return_as_model=False,
            commit=commit,
        )

        return AdminAuditLogRead(**cast(dict, result))

    def _compute_changes(
        self,
        previous_state: dict | None,
        new_state: dict | None,
    ) -> dict:
        """Compute changes between previous and new states."""
        changes: dict = {}

        if not previous_state or not new_state:
            return changes

        all_keys = set(previous_state.keys()) | set(new_state.keys())

        for key in all_keys:
            old_value = previous_state.get(key)
            new_value = new_state.get(key)

            if old_value != new_value:
                changes[key] = {"old": old_value, "new": new_value}

        return changes

    async def get_user_activity(
        self,
        db: AsyncSession,
        user_id: int,
        start_time: datetime | None = None,
        end_time: datetime | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> dict:
        """Get user activity logs."""
        filters: dict = {"user_id": user_id}

        if start_time:
            filters["timestamp__gte"] = start_time
        if end_time:
            filters["timestamp__lte"] = end_time

        result = await self.crud_events.get_multi(
            db,
            offset=offset,
            limit=limit,
            sort_columns=["timestamp"],
            sort_orders=["desc"],
            **filters,
        )

        return cast(dict, result)

    async def get_resource_history(
        self,
        db: AsyncSession,
        resource_type: str,
        resource_id: str,
        limit: int = 50,
        offset: int = 0,
    ) -> dict:
        """Get audit history for a specific resource."""
        result = await self.crud_audits.get_multi(
            db,
            offset=offset,
            limit=limit,
            sort_columns=["timestamp"],
            sort_orders=["desc"],
            resource_type=resource_type,
            resource_id=resource_id,
        )

        return cast(dict[str, Any], result)

    async def get_security_alerts(
        self, db: AsyncSession, lookback_hours: int = 24
    ) -> list[dict[str, Any]]:
        """Get security alerts based on event patterns."""
        alerts: list[dict[str, Any]] = []
        lookback_time = datetime.now(UTC) - timedelta(hours=lookback_hours)

        failed_logins = await self.crud_events.get_multi(
            db,
            event_type=EventType.FAILED_LOGIN,
            status=EventStatus.FAILURE,
            timestamp__gte=lookback_time,
        )

        failed_login_patterns: dict[tuple, int] = {}

        for login in failed_logins.get("data", []):
            key = (
                login.get("ip_address", "unknown"),
                login.get("details", {}).get("username", "unknown"),
            )
            failed_login_patterns[key] = failed_login_patterns.get(key, 0) + 1

        for (ip, username), count in failed_login_patterns.items():
            if count >= 5:
                alerts.append(
                    {
                        "type": "multiple_failed_logins",
                        "severity": "high",
                        "details": {
                            "ip_address": ip,
                            "username": username,
                            "attempts": count,
                        },
                    }
                )

        return alerts

    async def cleanup_old_logs(
        self, db: AsyncSession, retention_days: int = 90
    ) -> None:
        """Clean up old logs based on retention policy."""
        cutoff_date = datetime.now(UTC) - timedelta(days=retention_days)

        await self.crud_events.delete(
            db, allow_multiple=True, timestamp__lt=cutoff_date
        )

        await self.crud_audits.delete(
            db, allow_multiple=True, timestamp__lt=cutoff_date
        )

cleanup_old_logs(db, retention_days=90) async

Clean up old logs based on retention policy.

Source code in crudadmin/event/service.py
async def cleanup_old_logs(
    self, db: AsyncSession, retention_days: int = 90
) -> None:
    """Clean up old logs based on retention policy."""
    cutoff_date = datetime.now(UTC) - timedelta(days=retention_days)

    await self.crud_events.delete(
        db, allow_multiple=True, timestamp__lt=cutoff_date
    )

    await self.crud_audits.delete(
        db, allow_multiple=True, timestamp__lt=cutoff_date
    )

get_resource_history(db, resource_type, resource_id, limit=50, offset=0) async

Get audit history for a specific resource.

Source code in crudadmin/event/service.py
async def get_resource_history(
    self,
    db: AsyncSession,
    resource_type: str,
    resource_id: str,
    limit: int = 50,
    offset: int = 0,
) -> dict:
    """Get audit history for a specific resource."""
    result = await self.crud_audits.get_multi(
        db,
        offset=offset,
        limit=limit,
        sort_columns=["timestamp"],
        sort_orders=["desc"],
        resource_type=resource_type,
        resource_id=resource_id,
    )

    return cast(dict[str, Any], result)

get_security_alerts(db, lookback_hours=24) async

Get security alerts based on event patterns.

Source code in crudadmin/event/service.py
async def get_security_alerts(
    self, db: AsyncSession, lookback_hours: int = 24
) -> list[dict[str, Any]]:
    """Get security alerts based on event patterns."""
    alerts: list[dict[str, Any]] = []
    lookback_time = datetime.now(UTC) - timedelta(hours=lookback_hours)

    failed_logins = await self.crud_events.get_multi(
        db,
        event_type=EventType.FAILED_LOGIN,
        status=EventStatus.FAILURE,
        timestamp__gte=lookback_time,
    )

    failed_login_patterns: dict[tuple, int] = {}

    for login in failed_logins.get("data", []):
        key = (
            login.get("ip_address", "unknown"),
            login.get("details", {}).get("username", "unknown"),
        )
        failed_login_patterns[key] = failed_login_patterns.get(key, 0) + 1

    for (ip, username), count in failed_login_patterns.items():
        if count >= 5:
            alerts.append(
                {
                    "type": "multiple_failed_logins",
                    "severity": "high",
                    "details": {
                        "ip_address": ip,
                        "username": username,
                        "attempts": count,
                    },
                }
            )

    return alerts

get_user_activity(db, user_id, start_time=None, end_time=None, limit=50, offset=0) async

Get user activity logs.

Source code in crudadmin/event/service.py
async def get_user_activity(
    self,
    db: AsyncSession,
    user_id: int,
    start_time: datetime | None = None,
    end_time: datetime | None = None,
    limit: int = 50,
    offset: int = 0,
) -> dict:
    """Get user activity logs."""
    filters: dict = {"user_id": user_id}

    if start_time:
        filters["timestamp__gte"] = start_time
    if end_time:
        filters["timestamp__lte"] = end_time

    result = await self.crud_events.get_multi(
        db,
        offset=offset,
        limit=limit,
        sort_columns=["timestamp"],
        sort_orders=["desc"],
        **filters,
    )

    return cast(dict, result)

Event System Integration

Writes an event together with its audit rows.

Source code in crudadmin/event/integration.py
class EventSystemIntegration:
    def __init__(self, event_service: EventService):
        self.event_service = event_service

    async def log_model_event(
        self,
        db: AsyncSession,
        event_type: EventType,
        model: type[DeclarativeBase],
        user_id: int,
        session_id: str,
        request: Request,
        resource_id: str | None = None,
        previous_state: dict[str, Any] | None = None,
        new_state: dict[str, Any] | None = None,
        details: dict[str, Any] | None = None,
        succeeded: bool = True,
        deleted_records: list[dict[str, Any]] | None = None,
        primary_key_name: str = "id",
    ):
        """Record a model change: one event, and its audit rows if the change happened.

        A failed action is recorded as a FAILURE event without audit rows, since
        nothing changed. A delete gets one audit row per deleted record, keyed by
        the model's primary key. The event and its audit rows are written in one
        transaction, rolled back together if any write fails.
        """
        try:
            event = await self.event_service.log_event(
                db=db,
                event_type=event_type,
                status=EventStatus.SUCCESS if succeeded else EventStatus.FAILURE,
                user_id=user_id,
                session_id=session_id,
                request=request,
                resource_type=model.__name__,
                resource_id=str(resource_id) if resource_id else None,
                details=details,
                commit=False,
            )

            if succeeded and event:
                for audited_id, before, after in self._audit_entries(
                    event_type,
                    resource_id,
                    previous_state,
                    new_state,
                    deleted_records,
                    primary_key_name,
                ):
                    await self.event_service.create_audit_log(
                        db=db,
                        event_id=event.id,
                        resource_type=model.__name__,
                        resource_id=audited_id,
                        action=event_type.value,
                        previous_state=before,
                        new_state=after,
                        metadata=details,
                        commit=False,
                    )

            await db.commit()
            return event

        except Exception:
            await db.rollback()
            raise

    @staticmethod
    def _audit_entries(
        event_type: EventType,
        resource_id: str | None,
        previous_state: dict[str, Any] | None,
        new_state: dict[str, Any] | None,
        deleted_records: list[dict[str, Any]] | None,
        primary_key_name: str,
    ) -> list[tuple[str, dict[str, Any] | None, dict[str, Any] | None]]:
        """``(resource_id, previous_state, new_state)`` for each audit row to write."""
        if event_type == EventType.DELETE:
            return [
                (str(record[primary_key_name]), record, None)
                for record in deleted_records or []
                if record.get(primary_key_name) is not None
            ]
        if event_type in (EventType.CREATE, EventType.UPDATE) and resource_id:
            return [(str(resource_id), previous_state, new_state)]
        return []

    async def log_auth_event(
        self,
        db: AsyncSession,
        event_type: EventType,
        user_id: int,
        session_id: str,
        request: Request,
        success: bool,
        details: dict[str, Any] | None = None,
    ):
        """Record an authentication event; a failure is logged, never raised.

        It runs inside login and logout, which must not fail because the event
        log can't be written.
        """
        try:
            status = EventStatus.SUCCESS if success else EventStatus.FAILURE

            await self.event_service.log_event(
                db=db,
                event_type=event_type,
                status=status,
                user_id=user_id,
                session_id=session_id,
                request=request,
                details=details,
            )

        except Exception:
            logger.exception("Could not record the %s event", event_type.value)
            await db.rollback()

    async def log_security_event(
        self,
        db: AsyncSession,
        event_type: EventType,
        user_id: int,
        session_id: str,
        request: Request,
        details: dict[str, Any],
    ):
        """Log security-related events with high priority."""
        event = await self.event_service.log_event(
            db=db,
            event_type=event_type,
            status=EventStatus.WARNING,
            user_id=user_id,
            session_id=session_id,
            request=request,
            details={**details, "priority": "high", "requires_attention": True},
        )

        return event

log_auth_event(db, event_type, user_id, session_id, request, success, details=None) async

Record an authentication event; a failure is logged, never raised.

It runs inside login and logout, which must not fail because the event log can't be written.

Source code in crudadmin/event/integration.py
async def log_auth_event(
    self,
    db: AsyncSession,
    event_type: EventType,
    user_id: int,
    session_id: str,
    request: Request,
    success: bool,
    details: dict[str, Any] | None = None,
):
    """Record an authentication event; a failure is logged, never raised.

    It runs inside login and logout, which must not fail because the event
    log can't be written.
    """
    try:
        status = EventStatus.SUCCESS if success else EventStatus.FAILURE

        await self.event_service.log_event(
            db=db,
            event_type=event_type,
            status=status,
            user_id=user_id,
            session_id=session_id,
            request=request,
            details=details,
        )

    except Exception:
        logger.exception("Could not record the %s event", event_type.value)
        await db.rollback()

log_model_event(db, event_type, model, user_id, session_id, request, resource_id=None, previous_state=None, new_state=None, details=None, succeeded=True, deleted_records=None, primary_key_name='id') async

Record a model change: one event, and its audit rows if the change happened.

A failed action is recorded as a FAILURE event without audit rows, since nothing changed. A delete gets one audit row per deleted record, keyed by the model's primary key. The event and its audit rows are written in one transaction, rolled back together if any write fails.

Source code in crudadmin/event/integration.py
async def log_model_event(
    self,
    db: AsyncSession,
    event_type: EventType,
    model: type[DeclarativeBase],
    user_id: int,
    session_id: str,
    request: Request,
    resource_id: str | None = None,
    previous_state: dict[str, Any] | None = None,
    new_state: dict[str, Any] | None = None,
    details: dict[str, Any] | None = None,
    succeeded: bool = True,
    deleted_records: list[dict[str, Any]] | None = None,
    primary_key_name: str = "id",
):
    """Record a model change: one event, and its audit rows if the change happened.

    A failed action is recorded as a FAILURE event without audit rows, since
    nothing changed. A delete gets one audit row per deleted record, keyed by
    the model's primary key. The event and its audit rows are written in one
    transaction, rolled back together if any write fails.
    """
    try:
        event = await self.event_service.log_event(
            db=db,
            event_type=event_type,
            status=EventStatus.SUCCESS if succeeded else EventStatus.FAILURE,
            user_id=user_id,
            session_id=session_id,
            request=request,
            resource_type=model.__name__,
            resource_id=str(resource_id) if resource_id else None,
            details=details,
            commit=False,
        )

        if succeeded and event:
            for audited_id, before, after in self._audit_entries(
                event_type,
                resource_id,
                previous_state,
                new_state,
                deleted_records,
                primary_key_name,
            ):
                await self.event_service.create_audit_log(
                    db=db,
                    event_id=event.id,
                    resource_type=model.__name__,
                    resource_id=audited_id,
                    action=event_type.value,
                    previous_state=before,
                    new_state=after,
                    metadata=details,
                    commit=False,
                )

        await db.commit()
        return event

    except Exception:
        await db.rollback()
        raise

log_security_event(db, event_type, user_id, session_id, request, details) async

Log security-related events with high priority.

Source code in crudadmin/event/integration.py
async def log_security_event(
    self,
    db: AsyncSession,
    event_type: EventType,
    user_id: int,
    session_id: str,
    request: Request,
    details: dict[str, Any],
):
    """Log security-related events with high priority."""
    event = await self.event_service.log_event(
        db=db,
        event_type=event_type,
        status=EventStatus.WARNING,
        user_id=user_id,
        session_id=session_id,
        request=request,
        details={**details, "priority": "high", "requires_attention": True},
    )

    return event

Recording Changes

Records changes made through the admin in the event log.

The admin's create, update and delete routes call [record][] once they know the outcome, with the records as they were and as they are; a route of your own mounted behind the admin's login can do the same. With event tracking off, record does nothing.

Secrets in the records are redacted before they are stored. A failure to write the event is logged and absorbed, since the change it describes is already committed.

Example
await admin.events.record(
    request,
    admin_db,
    EventType.UPDATE,
    Product,
    record_id=product.id,
    before=before,
    after=after,
)
Source code in crudadmin/event/recorder.py
class AdminEvents:
    """Records changes made through the admin in the event log.

    The admin's create, update and delete routes call [record][] once they know
    the outcome, with the records as they were and as they are; a route of your
    own mounted behind the admin's login can do the same. With event tracking
    off, ``record`` does nothing.

    Secrets in the records are redacted before they are stored. A failure to
    write the event is logged and absorbed, since the change it describes is
    already committed.

    Example:
        ```python
        await admin.events.record(
            request,
            admin_db,
            EventType.UPDATE,
            Product,
            record_id=product.id,
            before=before,
            after=after,
        )
        ```
    """

    def __init__(self, integration: EventSystemIntegration | None) -> None:
        self.integration = integration

    @property
    def enabled(self) -> bool:
        return self.integration is not None

    async def record(
        self,
        request: Request,
        admin_db: AsyncSession,
        event_type: EventType,
        model: type[DeclarativeBase],
        *,
        record_id: Any = None,
        before: dict[str, Any] | None = None,
        after: dict[str, Any] | None = None,
        deleted: list[dict[str, Any]] | None = None,
        succeeded: bool = True,
    ) -> None:
        """Record one create, update or delete of ``model`` by the logged-in admin.

        Args:
            request: The admin's request; the admin and the session handle are
                read from it.
            admin_db: A session on the admin database, where events are kept.
            event_type: ``CREATE``, ``UPDATE`` or ``DELETE``.
            model: The model that changed.
            record_id: The changed record's primary key.
            before: The record before an update.
            after: The record after a create or an update.
            deleted: The records a delete removed, as they were.
            succeeded: False records a refused change, without audit rows.
        """
        if self.integration is None:
            return
        admin = getattr(request.state, "user", None) or {}
        try:
            await self.integration.log_model_event(
                db=admin_db,
                event_type=event_type,
                model=model,
                user_id=admin.get("id") or 0,
                session_id=getattr(request.state, "session_handle", None) or "unknown",
                request=request,
                resource_id=None if record_id is None else str(record_id),
                previous_state=before,
                new_state=after,
                details=self._details(
                    request, event_type, model, record_id, before, after, deleted
                ),
                succeeded=succeeded,
                deleted_records=deleted,
                primary_key_name=_primary_key_name_or_id(model),
            )
        except Exception:
            logger.exception(
                "Could not record the %s of %s; the change itself stands",
                event_type.value,
                model.__name__,
            )

    @staticmethod
    def _details(
        request: Request,
        event_type: EventType,
        model: type[DeclarativeBase],
        record_id: Any,
        before: dict[str, Any] | None,
        after: dict[str, Any] | None,
        deleted: list[dict[str, Any]] | None,
    ) -> dict[str, Any]:
        """What the event log page shows: the record's changes, and the request."""
        changes: Any
        if event_type == EventType.UPDATE:
            changes = changed_fields(before, after)
        elif event_type == EventType.DELETE:
            changes = {"deleted_records": deleted or []}
        else:
            changes = after
        return {
            "resource_details": {
                "model": model.__name__,
                "id": None if record_id is None else str(record_id),
                "changes": changes,
            },
            "request_details": {
                "method": request.method,
                "path": request.url.path,
                "user_agent": request.headers.get("user-agent"),
            },
        }

record(request, admin_db, event_type, model, *, record_id=None, before=None, after=None, deleted=None, succeeded=True) async

Record one create, update or delete of model by the logged-in admin.

Parameters:

Name Type Description Default
request Request

The admin's request; the admin and the session handle are read from it.

required
admin_db AsyncSession

A session on the admin database, where events are kept.

required
event_type EventType

CREATE, UPDATE or DELETE.

required
model type[DeclarativeBase]

The model that changed.

required
record_id Any

The changed record's primary key.

None
before dict[str, Any] | None

The record before an update.

None
after dict[str, Any] | None

The record after a create or an update.

None
deleted list[dict[str, Any]] | None

The records a delete removed, as they were.

None
succeeded bool

False records a refused change, without audit rows.

True
Source code in crudadmin/event/recorder.py
async def record(
    self,
    request: Request,
    admin_db: AsyncSession,
    event_type: EventType,
    model: type[DeclarativeBase],
    *,
    record_id: Any = None,
    before: dict[str, Any] | None = None,
    after: dict[str, Any] | None = None,
    deleted: list[dict[str, Any]] | None = None,
    succeeded: bool = True,
) -> None:
    """Record one create, update or delete of ``model`` by the logged-in admin.

    Args:
        request: The admin's request; the admin and the session handle are
            read from it.
        admin_db: A session on the admin database, where events are kept.
        event_type: ``CREATE``, ``UPDATE`` or ``DELETE``.
        model: The model that changed.
        record_id: The changed record's primary key.
        before: The record before an update.
        after: The record after a create or an update.
        deleted: The records a delete removed, as they were.
        succeeded: False records a refused change, without audit rows.
    """
    if self.integration is None:
        return
    admin = getattr(request.state, "user", None) or {}
    try:
        await self.integration.log_model_event(
            db=admin_db,
            event_type=event_type,
            model=model,
            user_id=admin.get("id") or 0,
            session_id=getattr(request.state, "session_handle", None) or "unknown",
            request=request,
            resource_id=None if record_id is None else str(record_id),
            previous_state=before,
            new_state=after,
            details=self._details(
                request, event_type, model, record_id, before, after, deleted
            ),
            succeeded=succeeded,
            deleted_records=deleted,
            primary_key_name=_primary_key_name_or_id(model),
        )
    except Exception:
        logger.exception(
            "Could not record the %s of %s; the change itself stands",
            event_type.value,
            model.__name__,
        )

Event Schemas

Event Log Schemas

Bases: BaseModel

Source code in crudadmin/event/schemas.py
class AdminEventLogBase(BaseModel):
    event_type: EventType
    status: EventStatus
    user_id: int
    session_id: str
    ip_address: str
    user_agent: str
    resource_type: str | None = None
    resource_id: str | None = None
    details: dict = {}

Bases: AdminEventLogBase

Source code in crudadmin/event/schemas.py
class AdminEventLogCreate(AdminEventLogBase):
    pass

Bases: AdminEventLogBase

Source code in crudadmin/event/schemas.py
class AdminEventLogRead(AdminEventLogBase):
    model_config = ConfigDict(from_attributes=True)

    id: int
    timestamp: datetime

Audit Log Schemas

Bases: BaseModel

Source code in crudadmin/event/schemas.py
class AdminAuditLogBase(BaseModel):
    event_id: int
    resource_type: str
    resource_id: str
    action: str
    previous_state: dict | None = None
    new_state: dict | None = None
    changes: dict = {}
    audit_metadata: dict = Field(
        default_factory=dict,
        validation_alias=AliasChoices("audit_metadata", "metadata"),
    )

Bases: AdminAuditLogBase

Source code in crudadmin/event/schemas.py
class AdminAuditLogCreate(AdminAuditLogBase):
    pass

Bases: AdminAuditLogBase

Source code in crudadmin/event/schemas.py
class AdminAuditLogRead(AdminAuditLogBase):
    model_config = ConfigDict(from_attributes=True)

    id: int
    timestamp: datetime

Usage Examples

Enabling Event Tracking

from crudadmin import CRUDAdmin

admin = CRUDAdmin(
    session=get_session,
    SECRET_KEY=os.environ["ADMIN_SECRET_KEY"],
    track_events=True,
)

The event tables are created in the admin database by admin.initialize(), whichever session backend you use. admin.event_service and admin.event_integration give access to the service and the integration.

Logging an Event Yourself

from crudadmin.event import EventStatus, EventType

await admin.event_service.log_event(
    db=admin_db,
    event_type=EventType.CREATE,
    status=EventStatus.SUCCESS,
    user_id=user.id,
    session_id=session_handle,
    request=request,
    resource_type="Product",
    resource_id="123",
    details={"import_batch_id": "batch_001"},
)

log_event commits unless you pass commit=False; create_audit_log takes the same flag, so an event and its audit rows can share a transaction.

Logging a Model Change with Its Audit Row

await admin.event_integration.log_model_event(
    db=admin_db,
    event_type=EventType.UPDATE,
    model=Product,
    user_id=current_user.id,
    session_id=session_handle,
    request=request,
    resource_id=str(product.id),
    previous_state={"name": "Old Name", "price": 10.00},
    new_state={"name": "New Name", "price": 15.00},
)

For a delete, pass the deleted records and the primary key's name instead of resource_id: deleted_records=[...], primary_key_name="sku". Pass succeeded=False to record a failed action without audit rows.

Recording Changes from Your Own Routes

A route of your own can record the changes it makes the way the admin's routes do. admin.events.record reads the admin and the session from the request, which the admin's login dependency sets:

from fastapi import Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession

from crudadmin.event import EventType


@router.post(
    "/products/{sku}/restock",
    dependencies=[Depends(admin.admin_authentication.get_current_user())],
)
async def restock(
    sku: str,
    request: Request,
    db: AsyncSession = Depends(get_session),
    admin_db: AsyncSession = Depends(admin.db_config.get_admin_db),
):
    before = await product_as_dict(db, sku)
    await add_stock(db, sku, 10)
    await db.commit()
    await admin.events.record(
        request,
        admin_db,
        EventType.UPDATE,
        Product,
        record_id=sku,
        before=before,
        after=await product_as_dict(db, sku),
    )

Pass after= for a create, before= and after= for an update, and deleted=[...] with the removed records for a delete; succeeded=False records a refused change. With track_events=False, record does nothing.

Querying Event History

activity = await admin.event_service.get_user_activity(
    db=admin_db,
    user_id=user.id,
    start_time=datetime.now(timezone.utc) - timedelta(days=7),
    limit=100,
)

history = await admin.event_service.get_resource_history(
    db=admin_db,
    resource_type="Product",
    resource_id="123",
    limit=50,
)

alerts = await admin.event_service.get_security_alerts(db=admin_db, lookback_hours=24)

Event Model Fields

AdminEventLog Fields

Field Type Description
id int Primary key
timestamp datetime When the event occurred
event_type EventType create, update, delete, login, logout or failed_login
status EventStatus success, failure or warning
user_id int The admin who acted; 0 when a refused login named no account
session_id str The session's public handle
ip_address str Client IP address
user_agent str User agent string from the request
resource_type str Model name, for model events
resource_id str Primary key of the record, for creates and updates
details dict Event-specific details

AdminAuditLog Fields

Field Type Description
id int Primary key
event_id int The AdminEventLog row this belongs to
timestamp datetime When the audit row was written
resource_type str Model name
resource_id str Primary key of the record
action str create, update or delete
previous_state dict The record before the change (redacted)
new_state dict The record after the change (redacted)
changes dict Fields that changed, with old and new values (redacted)
audit_metadata dict Request details

Retention

EventService.cleanup_old_logs(db, retention_days=90) deletes older events. Schedule it if the log shouldn't grow without bound.