99201f48fd
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>
86 lines
3.1 KiB
Python
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 |