11"""Audit listener — SQLAlchemy event listeners that write audit rows atomically.
22
3- AuditLog rows are created inside ``before_flush`` and added to the session
4- via ``session.add()``. SQLAlchemy re-runs ``before_flush`` until no new
5- pending objects appear, so the audit rows ride along in the same flush pass .
3+ AuditLog rows are created inside ``before_flush`` (UPDATE/DELETE) and
4+ ``after_flush_postexec`` (CREATE) and added to the session via ``session.add()``.
5+ CREATE uses ``after_flush_postexec`` so the auto-generated primary key is available .
66No IO, no queries, no ``MissingGreenlet`` — just already-loaded attributes.
77"""
88
99from __future__ import annotations
1010
11+ import threading
1112from typing import Any
1213
1314from sqlalchemy import event
1819from fastapi_admin_kit .audit .diff import serialize_value
1920from fastapi_admin_kit .audit .models import AuditLog
2021
22+ _pending_creates : dict [int , list [tuple [Any , dict [str , Any ], dict [str , Any ]]]] = {}
23+ _pending_lock = threading .Lock ()
24+
2125
2226def is_registered_model (obj : Any , registry : Any ) -> bool :
2327 """Check if a model class is registered with the admin."""
@@ -107,7 +111,7 @@ def attach_audit_listener(
107111 session_factory : Any ,
108112 registry : Any ,
109113) -> None :
110- """Set up SQLAlchemy ``before_flush`` listener for audit logging .
114+ """Set up SQLAlchemy ``before_flush`` and ``after_flush_postexec`` listeners .
111115
112116 Args:
113117 session_factory: The session factory (sync or async).
@@ -116,23 +120,27 @@ def attach_audit_listener(
116120
117121 @event .listens_for (Session , "before_flush" )
118122 def before_flush (session : Session , flush_context : Any , instances : Any ) -> None :
119- """Create AuditLog rows for all tracked mutations .
123+ """Capture new objects and create AuditLog rows for UPDATE/DELETE .
120124
121- Runs inside the same flush pass — ``session.add()`` puts the
122- AuditLog into the pending set and SQLAlchemy will re-run
123- ``before_flush`` until no new objects appear. No queries, no
124- lazy-loads, only already-loaded attribute history.
125+ New objects are stored to create audit rows in ``after_flush_postexec``
126+ where their auto-generated primary keys are available.
125127 """
126128 context = get_audit_context ()
129+ session_id = id (session )
127130
128- # ── INSERT ──────────────────────────────────────────────────
131+ # ── INSERT (capture for after_flush_postexec) ───────────────
132+ new_items = []
129133 for obj in list (session .new ):
130134 if not is_registered_model (obj , registry ):
131135 continue
132136 if obj .__tablename__ == AuditLog .__tablename__ :
133137 continue
134- row = _build_audit_row (obj , "CREATE" , context )
135- session .add (row )
138+ snap = _snapshot_current (obj )
139+ new_items .append ((obj , snap , context ))
140+
141+ if new_items :
142+ with _pending_lock :
143+ _pending_creates [session_id ] = new_items
136144
137145 # ── UPDATE ──────────────────────────────────────────────────
138146 for obj in list (session .dirty ):
@@ -157,3 +165,24 @@ def before_flush(session: Session, flush_context: Any, instances: Any) -> None:
157165 snap = _snapshot_current (obj )
158166 row = _build_audit_row (obj , "DELETE" , context , snapshot_data = snap )
159167 session .add (row )
168+
169+ @event .listens_for (Session , "after_flush_postexec" )
170+ def after_flush_postexec (session : Session , flush_context : Any ) -> None :
171+ """Create AuditLog rows for CREATE mutations after flush.
172+
173+ This runs after the flush completes, so auto-generated primary keys
174+ are available on the ORM objects.
175+ """
176+ session_id = id (session )
177+
178+ with _pending_lock :
179+ pending = _pending_creates .pop (session_id , [])
180+
181+ if not pending :
182+ return
183+
184+ for obj , snap , context in pending :
185+ # Update snapshot with the now-available id
186+ snap ["id" ] = getattr (obj , "id" , None )
187+ row = _build_audit_row (obj , "CREATE" , context , snapshot_data = snap )
188+ session .add (row )
0 commit comments