v1
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Audit router.
|
||||
|
||||
Admin:
|
||||
GET /admin/audit — paginated, filtered audit log (admin-authenticated)
|
||||
|
||||
Member:
|
||||
POST /members/audit/page-visit — record a page navigation (member-authenticated)
|
||||
"""
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.auth.member_deps import get_authenticated_member
|
||||
from app.database import get_db
|
||||
from app.middleware.rate_limit import limiter
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.member import Member
|
||||
from app.models.user import User
|
||||
from app.schemas.audit import AuditLogPage, AuditLogResponse, PageVisitSchema
|
||||
from app.services.audit import log_audit
|
||||
from app.services.settings import get_feature_settings_snapshot
|
||||
|
||||
router = APIRouter(tags=["Audit"])
|
||||
|
||||
|
||||
async def _require_audit_history_enabled(db: AsyncSession) -> None:
|
||||
feature_settings = await get_feature_settings_snapshot(db)
|
||||
if not feature_settings.audit_history_enabled:
|
||||
raise HTTPException(status_code=404, detail="Audit history is currently disabled.")
|
||||
|
||||
|
||||
# ── Admin: query audit log ─────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin/audit", response_model=AuditLogPage)
|
||||
async def admin_list_audit(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
member_id: Optional[uuid.UUID] = Query(None),
|
||||
action_type: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
area: Optional[str] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
sort_by: str = Query("timestamp"),
|
||||
sort_dir: str = Query("desc"),
|
||||
_admin: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _require_audit_history_enabled(db)
|
||||
|
||||
allowed_sort = {"timestamp", "action_type", "status", "area", "member_email"}
|
||||
if sort_by not in allowed_sort:
|
||||
sort_by = "timestamp"
|
||||
|
||||
col = getattr(AuditLog, sort_by)
|
||||
order = col.desc() if sort_dir == "desc" else col.asc()
|
||||
|
||||
conditions = []
|
||||
if member_id is not None:
|
||||
conditions.append(AuditLog.member_id == member_id)
|
||||
if action_type:
|
||||
conditions.append(AuditLog.action_type == action_type)
|
||||
if status:
|
||||
conditions.append(AuditLog.status == status)
|
||||
if area:
|
||||
conditions.append(AuditLog.area.ilike(f"%{area}%"))
|
||||
if date_from:
|
||||
conditions.append(AuditLog.timestamp >= date_from)
|
||||
if date_to:
|
||||
conditions.append(AuditLog.timestamp <= date_to)
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
AuditLog.member_email.ilike(term),
|
||||
AuditLog.description.ilike(term),
|
||||
AuditLog.area.ilike(term),
|
||||
AuditLog.action_type.ilike(term),
|
||||
AuditLog.error_message.ilike(term),
|
||||
)
|
||||
)
|
||||
|
||||
base_q = select(AuditLog)
|
||||
if conditions:
|
||||
from sqlalchemy import and_
|
||||
base_q = base_q.where(and_(*conditions))
|
||||
|
||||
count_result = await db.execute(select(func.count()).select_from(base_q.subquery()))
|
||||
total = count_result.scalar_one()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
items_result = await db.execute(base_q.order_by(order).offset(offset).limit(page_size))
|
||||
items = items_result.scalars().all()
|
||||
|
||||
return AuditLogPage(
|
||||
items=[AuditLogResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=max(1, math.ceil(total / page_size)),
|
||||
)
|
||||
|
||||
|
||||
# ── Member: page visit ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/members/audit/page-visit", status_code=204)
|
||||
@limiter.limit("120/minute")
|
||||
async def member_log_page_visit(
|
||||
request: Request,
|
||||
response: Response,
|
||||
data: PageVisitSchema,
|
||||
member: Member = Depends(get_authenticated_member),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
feature_settings = await get_feature_settings_snapshot(db)
|
||||
if not feature_settings.audit_history_enabled:
|
||||
return
|
||||
|
||||
path = data.path[:255] if data.path else "unknown"
|
||||
title = data.title or path
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
member_id=member.id,
|
||||
member_email=member.email,
|
||||
action_type="page_visit",
|
||||
area=path,
|
||||
description=f"Visited: {title}",
|
||||
status="success",
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
Reference in New Issue
Block a user