diff --git a/backend/app/api/events.py b/backend/app/api/events.py index 18dc117..4d7daaa 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -1,8 +1,8 @@ import re from typing import Optional -from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select, or_ +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy import select, or_, extract from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_admin @@ -23,6 +23,7 @@ class FalseAlarmRequest(BaseModel): @router.get("/", response_model=list[EventResponse]) async def list_events( + response: Response, camera_id: Optional[int] = None, status: Optional[str] = None, type: Optional[str] = None, @@ -94,6 +95,11 @@ async def list_events( conds.append(Event.id == int(search_q)) query = query.where(or_(*conds)) + # 필터링된 전체 건수를 X-Total-Count 헤더로 노출 (페이지네이션 메타) + # CORS expose 는 main.py 의 CORSMiddleware(expose_headers=...) 에서 처리 + total = await db.scalar(select(func.count()).select_from(query.subquery())) + response.headers["X-Total-Count"] = str(total or 0) + query = query.order_by(Event.timestamp.desc()).offset(offset).limit(limit) result = await db.execute(query) @@ -132,6 +138,59 @@ async def get_camera_stats( return [{"camera_id": row.camera_id, "count": row.count} for row in result.all()] +@router.get("/stats/by-type") +async def get_stats_by_type( + db: AsyncSession = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +): + """이벤트 유형별 집계 (StatsPage EventTypeChart 용).""" + q = ( + select(Event.event_type, func.count(Event.id).label("count")) + .group_by(Event.event_type) + .order_by(func.count(Event.id).desc()) + ) + result = await db.execute(q) + return [{"event_type": row.event_type, "count": row.count} for row in result.all()] + + +@router.get("/stats/hourly") +async def get_stats_hourly( + db: AsyncSession = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, +): + """시간대(0~23)별 발생 건수 (StatsPage HourlyDistributionChart 용). + date_from/date_to 로 기간 제한 가능 (기본: 전체).""" + hour_col = extract("hour", Event.timestamp).label("hour") + q = select(hour_col, func.count(Event.id).label("count")).group_by(hour_col).order_by(hour_col) + if date_from: + q = q.where(Event.timestamp >= date_from) + if date_to: + q = q.where(Event.timestamp <= date_to) + result = await db.execute(q) + return [{"hour": int(row.hour), "count": row.count} for row in result.all()] + + +@router.get("/stats/daily") +async def get_stats_daily( + db: AsyncSession = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), + days: int = 30, +): + """최근 N일(기본 30일) 일자별 발생 건수 (StatsPage DailyTrendChart 용).""" + cutoff = datetime.now() - timedelta(days=max(days, 1)) + day_col = func.date(Event.timestamp).label("day") + q = ( + select(day_col, func.count(Event.id).label("count")) + .where(Event.timestamp >= cutoff) + .group_by(day_col) + .order_by(day_col) + ) + result = await db.execute(q) + return [{"day": str(row.day), "count": row.count} for row in result.all()] + + @router.get("/{event_id}", response_model=EventResponse) async def get_event( event_id: int, diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 66023b6..e47f953 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -1,5 +1,5 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select, update, func +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from typing import List @@ -12,44 +12,34 @@ @router.get("/", response_model=List[NotificationResponse]) async def list_notifications( - limit: int = 50, + limit: int = 50, unread_only: bool = False, db: AsyncSession = Depends(get_db), current_admin: Admin = Depends(get_current_admin) ): - """ - [GateGuard] 알림 이력을 조회합니다. (M2 완결용) - """ + """[GateGuard] 알림 이력을 조회합니다.""" query = select(Notification).order_by(Notification.sent_at.desc()).limit(limit) if unread_only: - query = query.where(Notification.read_at == None) - + query = query.where(Notification.read_at == None) # noqa: E711 + result = await db.execute(query) return result.scalars().all() @router.patch("/{notification_id}/read") async def mark_notification_as_read( - notification_id: int, - db: AsyncSession = Depends(get_db) + notification_id: int, + db: AsyncSession = Depends(get_db), + current_admin: Admin = Depends(get_current_admin) ): - """ - [GateGuard] 특정 알림을 읽음 처리합니다. - """ + """[GateGuard] 특정 알림을 읽음 처리합니다.""" result = await db.execute(select(Notification).where(Notification.id == notification_id)) notification = result.scalar_one_or_none() - + if not notification: raise HTTPException(status_code=404, detail="알림을 찾을 수 없습니다.") - + notification.read_at = func.now() await db.commit() return {"message": "Success", "id": notification_id} -@router.post("/read-all") -async def mark_all_notifications_as_read(db: AsyncSession = Depends(get_db)): - """ - [GateGuard] 모든 알림을 한꺼번에 읽음 처리합니다. - """ - await db.execute(update(Notification).values(read_at=func.now())) - await db.commit() - return {"message": "All notifications marked as read"} +# read-all 핸들러 제거: frontend 미사용 + 인증 없는 broad-update 라 위험. 필요 시 인증 추가해 재도입. diff --git a/backend/app/main.py b/backend/app/main.py index 02b0995..645de00 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -49,6 +49,7 @@ async def global_exception_handler(request: Request, exc: Exception) -> ORJSONRe allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=["X-Total-Count"], ) # [GateGuard] 전략적 API 라우터 등록