Skip to content

Commit 452a3cb

Browse files
authored
Merge pull request #177 from mftee/main
Tests for GET /stats/heatmap
2 parents 908d6e4 + 1c5f7a6 commit 452a3cb

4 files changed

Lines changed: 394 additions & 4 deletions

File tree

src/analytics/service.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
import json
33
import logging
44
import time
5-
from datetime import datetime, timedelta
5+
from datetime import date, datetime, timedelta
66
from typing import Any, Dict, List, Optional, Tuple
77

8-
from sqlalchemy import asc, desc, func, text
8+
from sqlalchemy import asc, desc, extract, func, text
99
from sqlalchemy.orm import Session
1010

1111
from src.analytics.models import (
@@ -540,7 +540,61 @@ def get_trending_events(self, limit: int = 10, hours: int = 24) -> List[Dict[str
540540
_trending_cache = (rows, time.monotonic() + _TRENDING_CACHE_TTL)
541541
return rows[:limit]
542542

543-
def _update_analytics_stats(self, event_id: str,
543+
def get_scan_heatmap(
544+
self,
545+
event_id: str,
546+
filter_date: Optional[date] = None,
547+
) -> Dict[str, Any]:
548+
"""Return hourly scan-density data (24 buckets) for an event.
549+
550+
Optionally scoped to a single calendar day via *filter_date*.
551+
Hours with no scans are filled with a count of 0 so the response
552+
always contains exactly 24 entries.
553+
"""
554+
session = None
555+
try:
556+
session = get_session()
557+
558+
hour_expr = extract("hour", TicketScan.scan_timestamp)
559+
query = (
560+
session.query(
561+
hour_expr.label("hour"),
562+
func.count(TicketScan.id).label("scan_count"),
563+
)
564+
.filter(TicketScan.event_id == event_id)
565+
)
566+
567+
if filter_date is not None:
568+
query = query.filter(
569+
func.date(TicketScan.scan_timestamp) == filter_date.isoformat()
570+
)
571+
572+
rows = query.group_by(hour_expr).all()
573+
574+
hour_counts: Dict[int, int] = {
575+
int(row.hour): int(row.scan_count) for row in rows
576+
}
577+
578+
data = [
579+
{"hour": h, "scan_count": hour_counts.get(h, 0)}
580+
for h in range(24)
581+
]
582+
583+
peak_hour = max(range(24), key=lambda h: hour_counts.get(h, 0))
584+
585+
return {"event_id": event_id, "data": data, "peak_hour": peak_hour}
586+
587+
except Exception as e:
588+
log_error("Failed to get scan heatmap", {
589+
"event_id": event_id,
590+
"error": str(e),
591+
})
592+
raise
593+
finally:
594+
if session:
595+
session.close()
596+
597+
def _update_analytics_stats(self, event_id: str,
544598
increment_scan: bool = False, is_valid: bool = True,
545599
increment_transfer: bool = False, is_successful: bool = True,
546600
increment_invalid: bool = False):

src/main.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from fastapi.staticfiles import StaticFiles
1515
from slowapi.errors import RateLimitExceeded
1616

17-
from src.auth.dependencies import require_admin_key
17+
from src.auth.dependencies import require_admin_key, require_service_key
1818

1919
from src.analytics.service import analytics_service
2020
from src.chat import ChatMessage, EscalationEvent, chat_manager
@@ -70,6 +70,8 @@
7070
AnalyticsScansResponse,
7171
AnalyticsStatsQuery,
7272
AnalyticsTransfersResponse,
73+
HeatmapQuery,
74+
HeatmapResponse,
7375
ChatEscalateRequest,
7476
ChatEscalateResponse,
7577
ChatEscalationsResponse,
@@ -455,6 +457,39 @@ def get_invalid_attempts(
455457
raise HTTPException(status_code=500, detail=f"Failed to retrieve invalid attempts: {exc}")
456458

457459

460+
@app.get("/stats/heatmap", response_model=HeatmapResponse)
461+
def get_scan_heatmap(
462+
query: Annotated[HeatmapQuery, Query()],
463+
_: str = Depends(require_service_key),
464+
) -> HeatmapResponse:
465+
"""Return hourly scan density for an event (24 buckets, zero-filled).
466+
467+
Useful for capacity planning and staffing decisions.
468+
Optionally scope to a single calendar day with the *date* parameter.
469+
Protected by SERVICE_API_KEY bearer auth.
470+
"""
471+
log_info("Scan heatmap requested", {
472+
"event_id": query.event_id,
473+
"date": str(query.date) if query.date else None,
474+
})
475+
try:
476+
result = analytics_service.get_scan_heatmap(
477+
event_id=query.event_id,
478+
filter_date=query.date,
479+
)
480+
return HeatmapResponse(
481+
event_id=result["event_id"],
482+
data=result["data"],
483+
peak_hour=result["peak_hour"],
484+
)
485+
except Exception as exc:
486+
log_error("Failed to retrieve scan heatmap", {
487+
"event_id": query.event_id,
488+
"error": str(exc),
489+
})
490+
raise HTTPException(status_code=500, detail=f"Failed to retrieve scan heatmap: {exc}")
491+
492+
458493
# ---------------------------------------------------------------------------
459494
# Fraud + scalper prediction
460495
# ---------------------------------------------------------------------------

src/types_custom.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,25 @@ class AnalyticsInvalidAttemptsResponse(BaseModel):
263263
to_ts: Optional[datetime] = Field(None, description="End datetime filter applied")
264264

265265

266+
class HeatmapEntry(BaseModel):
267+
model_config = ConfigDict(extra="forbid")
268+
hour: int = Field(..., ge=0, le=23, description="Hour of day (0-23)")
269+
scan_count: int = Field(..., ge=0, description="Number of scans in this hour")
270+
271+
272+
class HeatmapQuery(BaseModel):
273+
model_config = ConfigDict(extra="forbid")
274+
event_id: str = Field(..., min_length=1, description="Event UUID to scope the heatmap")
275+
date: Optional[date] = Field(None, description="Optional ISO date (YYYY-MM-DD) to scope to a specific day")
276+
277+
278+
class HeatmapResponse(BaseModel):
279+
model_config = ConfigDict(extra="forbid")
280+
event_id: str
281+
data: List[HeatmapEntry] = Field(..., description="24-entry array of hourly scan counts (hours 0-23)")
282+
peak_hour: int = Field(..., ge=0, le=23, description="Hour with the highest scan count")
283+
284+
266285
class RootResponse(BaseModel):
267286
model_config = ConfigDict(extra="forbid")
268287
message: str

0 commit comments

Comments
 (0)