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) 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)).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