Files
Nexus/server/infrastructure/database/audit_log_repo.py
T
Your Name 99201f48fd fix: MissingGreenlet error — convert all middleware to pure ASGI
Root cause: BaseHTTPMiddleware.call_next() runs the endpoint in a
separate async task, which breaks SQLAlchemy's greenlet context.
After session.commit(), the connection is released to the pool.
When session.refresh() tries to acquire a new connection for a
SELECT, it fails with MissingGreenlet because the greenlet spawn
context is not available in the call_next() task.

Fix (two-part):
1. Convert all 4 middleware classes from BaseHTTPMiddleware to pure
   ASGI middleware — eliminates call_next() entirely so the entire
   request chain runs in the same async context.
2. Set expire_on_commit=False on the session factory — after commit,
   objects retain their in-memory values instead of being expired.
   This removes the need for session.refresh() entirely.

All session.refresh() calls removed from 11 repository files.
With expire_on_commit=False, post-commit attribute access no longer
triggers lazy loads that would also fail with MissingGreenlet.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-25 15:24:15 +08:00

86 lines
3.1 KiB
Python

"""Nexus — AuditLog Repository (Async SQLAlchemy)"""
from datetime import datetime, timezone
from typing import List, Optional, Tuple
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import AuditLog
def _build_filters(
action: Optional[str] = None,
admin_username: Optional[str] = None,
date_from: Optional[str] = None, # ISO date or datetime string
date_to: Optional[str] = None,
) -> list:
"""Build SQLAlchemy WHERE clauses from optional filter params."""
filters = []
if action:
filters.append(AuditLog.action == action)
if admin_username:
filters.append(AuditLog.admin_username == admin_username)
if date_from:
try:
dt = datetime.fromisoformat(date_from)
# Treat bare date (YYYY-MM-DD) as start of day UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
filters.append(AuditLog.created_at >= dt.replace(tzinfo=None))
except ValueError:
pass
if date_to:
try:
dt = datetime.fromisoformat(date_to)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
filters.append(AuditLog.created_at <= dt.replace(tzinfo=None))
except ValueError:
pass
return filters
class AuditLogRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def create(self, log: AuditLog) -> AuditLog:
self.session.add(log)
await self.session.commit()
return log
async def query(
self,
action: Optional[str] = None,
admin_username: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> Tuple[List[AuditLog], int]:
"""Unified query with all filters + total count."""
filters = _build_filters(action, admin_username, date_from, date_to)
count_q = select(func.count(AuditLog.id))
data_q = select(AuditLog).order_by(AuditLog.created_at.desc()).offset(offset).limit(limit)
if filters:
count_q = count_q.where(*filters)
data_q = data_q.where(*filters)
total = int((await self.session.execute(count_q)).scalar() or 0)
rows = list((await self.session.execute(data_q)).scalars().all())
return rows, total
# ── Kept for backward compat ──
async def count(self, action: Optional[str] = None) -> int:
filters = _build_filters(action)
q = select(func.count(AuditLog.id))
if filters:
q = q.where(*filters)
return int((await self.session.execute(q)).scalar() or 0)
async def get_recent(self, limit: int = 200, offset: int = 0) -> List[AuditLog]:
rows, _ = await self.query(limit=limit, offset=offset)
return rows
async def get_by_action(self, action: str, limit: int = 50, offset: int = 0) -> List[AuditLog]:
rows, _ = await self.query(action=action, limit=limit, offset=offset)
return rows