Files
rf4-spotter/apps/api/app/routers/activity.py
T
ik 6974ae690d
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
refactor: extract activity and spot router
2026-09-12 21:12:35 +07:00

101 lines
5.9 KiB
Python

from collections import Counter
from datetime import datetime, timedelta, timezone
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
from ..activity import activity_rows
from ..config import settings
from ..dependencies import Db
from ..models import CatchReport, ModerationStatus, SourceType, Spot, Waterbody
from ..public_cache import public_cache
from ..schemas import CatchOut, PaginatedActivityOut, SpotOut
from ..time_utils import aware
router = APIRouter()
@router.get("/api/v1/activity", response_model=PaginatedActivityOut)
def activity(db: Db, response: Response, hours: int = Query(24), waterbody: str | None = None,
fish: str | None = None, method: str | None = None,
sort: Literal["activity", "confidence", "freshness"] = "activity",
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> PaginatedActivityOut:
if hours not in {6, 12, 24, 72}:
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
response.headers["Cache-Control"] = "no-store"
generation = public_cache.generation()
cache_key = ("activity", hours, waterbody, fish, method, sort, limit, offset)
cached = public_cache.get(cache_key, settings.public_cache_seconds)
if cached is not None:
response.headers["X-Cache"] = "HIT"
return cached
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
total = len(rows)
keys = {
"activity": lambda row: (row.activity_score, row.confidence_score, row.last_confirmed_at, str(row.spot_id)),
"confidence": lambda row: (row.confidence_score, row.activity_score, row.last_confirmed_at, str(row.spot_id)),
"freshness": lambda row: (row.last_confirmed_at, row.activity_score, row.confidence_score, str(row.spot_id)),
}
rows.sort(key=keys[sort], reverse=True)
response.headers["X-Cache"] = "MISS"
return public_cache.set(cache_key, PaginatedActivityOut(items=rows[offset:offset + limit], total=total, limit=limit, offset=offset), generation=generation)
def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
spot = db.scalar(select(Spot).options(joinedload(Spot.waterbody)).where(Spot.id == spot_id))
if spot is None:
raise HTTPException(status_code=404, detail="spot not found")
return spot
@router.get("/api/v1/spots/resolve", response_model=SpotOut)
def resolve_spot(db: Db, waterbody: str, x: int = Query(ge=-10_000, le=10_000), y: int = Query(ge=-10_000, le=10_000)) -> SpotOut:
spot = db.scalar(select(Spot).options(joinedload(Spot.waterbody)).join(Spot.waterbody).where(Waterbody.slug == waterbody, Spot.x == x, Spot.y == y))
if spot is None:
raise HTTPException(status_code=404, detail="spot not found")
return spot_detail(spot.id, db)
@router.get("/api/v1/spots/{spot_id}", response_model=SpotOut)
def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
spot = _spot_or_404(db, spot_id)
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot.id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None))))
now = datetime.now(timezone.utc)
bait_counts = Counter(report.bait.name for report in reports if report.bait)
def count_since(delta: timedelta) -> int:
return sum(aware(report.reported_at) >= now - delta for report in reports)
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)])
def _report_source(report: CatchReport) -> str:
provenance = (report.raw_payload or {}).get("provenance", {})
if isinstance(provenance, dict) and provenance.get("source_system"):
return str(provenance["source_system"])
if report.source_type == SourceType.official_record:
return "rf4-official"
return "players" if report.source_type == SourceType.user else "manual-import"
@router.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
_spot_or_404(db, spot_id)
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url) for report in reports]
@router.get("/api/v1/spots/{spot_id}/timeline")
def spot_timeline(spot_id: UUID, db: Db) -> list[dict]:
_spot_or_404(db, spot_id)
now = datetime.now(timezone.utc)
buckets = []
for index in range(6):
start = now - timedelta(hours=(6 - index) * 12)
end = start + timedelta(hours=12)
count = db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None), CatchReport.reported_at >= start, CatchReport.reported_at < end)) or 0
buckets.append({"start": start.isoformat(), "end": end.isoformat(), "count": count})
return buckets