114 lines
6.9 KiB
Python
114 lines
6.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, selectinload
|
|
|
|
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)
|
|
|
|
provenance = [
|
|
(report.raw_payload or {}).get("provenance", {})
|
|
for report in reports
|
|
if isinstance((report.raw_payload or {}).get("provenance", {}), dict)
|
|
]
|
|
precisions = [item.get("coordinate_precision") for item in provenance]
|
|
precision = max((value for value in precisions if value in {"exact", "approximate", "area", "missing"}), key={"exact": 0, "approximate": 1, "area": 2, "missing": 3}.get, default="exact")
|
|
sources = sorted({str(item.get("source_system")) for item in provenance if item.get("source_system")}) or ["players"]
|
|
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)], coordinate_precision=precision, coordinate_sources=sources)
|
|
|
|
|
|
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), selectinload(CatchReport.tackle_components)).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, tackle_components=[{
|
|
"id": component.id, "role": component.role, "position": component.position,
|
|
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
|
|
"rig_id": component.rig_id, "source_system": component.source_system,
|
|
"source_url": component.source_url,
|
|
} for component in sorted(report.tackle_components, key=lambda value: value.position)]) 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
|