from __future__ import annotations from collections import Counter from datetime import datetime, timedelta, timezone from typing import Annotated, Literal from uuid import UUID from fastapi import Depends, FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import select from sqlalchemy.orm import Session, joinedload from .activity import activity_rows from .database import get_session from .models import Bait, CatchReport, Fish, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody from .schemas import ActivityOut, BaitOut, CatchOut, FishOut, ImportRunOut, OfficialRecordOut, SpotOut, WaterbodyOut app = FastAPI(title="RF4 Spotter API", version="0.1.0") app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:4321"], allow_methods=["GET"], allow_headers=["*"]) Db = Annotated[Session, Depends(get_session)] @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} @app.get("/api/v1/fishes", response_model=list[FishOut]) def fishes(db: Db) -> list[Fish]: return list(db.scalars(select(Fish).order_by(Fish.name_ru))) @app.get("/api/v1/waterbodies", response_model=list[WaterbodyOut]) def waterbodies(db: Db) -> list[Waterbody]: return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru))) @app.get("/api/v1/baits", response_model=list[BaitOut]) def baits(db: Db) -> list[Bait]: return list(db.scalars(select(Bait).order_by(Bait.name))) @app.get("/api/v1/activity", response_model=list[ActivityOut]) def activity( db: Db, 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), ) -> list[ActivityOut]: if hours not in {6, 12, 24, 72}: raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72") rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method) keys = {"activity": lambda r: r.activity_score, "confidence": lambda r: r.confidence_score, "freshness": lambda r: r.last_confirmed_at} rows.sort(key=keys[sort], reverse=True) return rows[offset:offset + limit] 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 @app.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))) now = datetime.now(timezone.utc) def count_since(delta: timedelta) -> int: return sum(_aware(r.reported_at) >= now - delta for r in reports) bait_counts = Counter(r.bait.name for r in reports if r.bait) 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)]) @app.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).order_by(CatchReport.reported_at.desc()).offset(offset).limit(limit))) return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports] @app.get("/api/v1/records", response_model=list[OfficialRecordOut]) def records( db: Db, fish: str | None = None, waterbody: str | None = None, category: str | None = None, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), ) -> list[OfficialRecordOut]: query = select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.official_record) if fish: query = query.join(CatchReport.fish).where(Fish.slug == fish) if waterbody: query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody) items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc()).offset(offset).limit(limit))) if category: items = [item for item in items if (item.raw_payload or {}).get("category") == category] return [OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items] @app.get("/api/v1/imports", response_model=list[ImportRunOut]) def imports(db: Db, limit: int = Query(20, ge=1, le=100)) -> list[OfficialRecordImport]: return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc()).limit(limit))) def _aware(value: datetime) -> datetime: return value if value.tzinfo else value.replace(tzinfo=timezone.utc)