Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions backend/app/api/events.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 13 additions & 23 deletions backend/app/api/notifications.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 라 위험. 필요 시 인증 추가해 재도입.
1 change: 1 addition & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 라우터 등록
Expand Down
Loading