Build Lake Eufaula recommendation vertical slice
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Conditions:
|
||||
air_temperature_f: int
|
||||
wind_mph: int
|
||||
wind_direction: str
|
||||
pressure_inhg: float
|
||||
pressure_trend: str
|
||||
cloud_cover_percent: int
|
||||
water_temperature_f: int
|
||||
sunrise: str
|
||||
|
||||
|
||||
def demo_conditions(starts_at: datetime) -> Conditions:
|
||||
"""Stable demo conditions until a live provider adapter is connected."""
|
||||
seasonal_water = {12: 52, 1: 49, 2: 53, 3: 61, 4: 69, 5: 76}.get(starts_at.month, 78)
|
||||
return Conditions(64 if starts_at.month in (2, 3, 11) else 72, 9, "SW", 29.91, "falling", 58, seasonal_water, "6:42 AM")
|
||||
|
||||
|
||||
def build_bass_plan(starts_at: datetime) -> dict:
|
||||
conditions = demo_conditions(starts_at)
|
||||
score = min(82 + (4 if conditions.pressure_trend == "falling" else 0) + (3 if 6 <= conditions.wind_mph <= 14 else 0) + (2 if conditions.cloud_cover_percent >= 50 else 0), 95)
|
||||
return {
|
||||
"bite_score": score,
|
||||
"confidence": 0.86,
|
||||
"summary": "Start on wind-blown secondary points at first light, then move to shaded wood and dock cover as the sun rises.",
|
||||
"conditions": asdict(conditions),
|
||||
"primary_pattern": {"name": "Wind-blown points", "depth": "6–12 ft", "structure": "Secondary points with rock, brush, or nearby creek-channel access", "presentation": "Slow-roll across the wind, then pause beside cover"},
|
||||
"tackle": {"lure": "3/8 oz chartreuse-white double willow spinnerbait", "rod": "7' medium-heavy, fast action", "reel": "7.1:1 baitcaster", "line": "15 lb fluorocarbon", "backup": "Green-pumpkin Texas-rigged creature bait for protected cover"},
|
||||
"evidence": ["A falling pressure trend favors active, roaming bass.", "A 9 mph southwest wind concentrates bait on exposed banks and points.", "Cloud cover extends the moving-bait window after sunrise.", f"Water near {conditions.water_temperature_f}°F supports a moderate retrieve."],
|
||||
"professor_finn": "I’d begin on the southwest-facing secondary points and let the wind choose the first three stops. Keep the spinnerbait moving until you contact fish. If the wind fades or the sun becomes direct, slow down with the Texas rig around shaded wood and the darkest dock edges.",
|
||||
"data_status": "Demo conditions — live weather provider not connected yet",
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime, timezone
|
||||
from django.test import SimpleTestCase
|
||||
from rest_framework.test import APIClient
|
||||
from .recommendations import build_bass_plan
|
||||
|
||||
class RecommendationEngineTests(SimpleTestCase):
|
||||
def test_plan_is_explainable_and_actionable(self):
|
||||
plan = build_bass_plan(datetime(2026, 9, 5, 11, tzinfo=timezone.utc))
|
||||
self.assertGreaterEqual(plan["bite_score"], 80)
|
||||
self.assertEqual(plan["primary_pattern"]["name"], "Wind-blown points")
|
||||
self.assertEqual(len(plan["evidence"]), 4)
|
||||
self.assertIn("spinnerbait", plan["tackle"]["lure"])
|
||||
|
||||
class DemoPlanApiTests(SimpleTestCase):
|
||||
def setUp(self): self.client = APIClient()
|
||||
def test_demo_plan_requires_no_account(self):
|
||||
response = self.client.post("/api/demo/plan/", {"starts_at": "2026-09-05T06:30:00-05:00"}, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["trip"]["water_body"], "Lake Eufaula")
|
||||
self.assertIn("professor_finn", response.data["plan"])
|
||||
def test_invalid_date_is_clear(self):
|
||||
self.assertEqual(self.client.post("/api/demo/plan/", {"starts_at": "Saturday morning"}, format="json").status_code, 400)
|
||||
@@ -1,11 +1,10 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import RecommendationViewSet, SpeciesViewSet, TripViewSet, WaterBodyViewSet, health
|
||||
from .views import RecommendationViewSet, SpeciesViewSet, TripViewSet, WaterBodyViewSet, demo_plan, health
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("species", SpeciesViewSet)
|
||||
router.register("water-bodies", WaterBodyViewSet)
|
||||
router.register("trips", TripViewSet, basename="trip")
|
||||
router.register("recommendations", RecommendationViewSet, basename="recommendation")
|
||||
urlpatterns = [path("health/", health), path("", include(router.urls))]
|
||||
|
||||
urlpatterns = [path("health/", health), path("demo/plan/", demo_plan), path("", include(router.urls))]
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
from datetime import timedelta
|
||||
from django.http import JsonResponse
|
||||
from rest_framework import permissions, viewsets
|
||||
from django.utils import timezone
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from .models import Recommendation, Species, Trip, WaterBody
|
||||
from .recommendations import build_bass_plan
|
||||
from .serializers import RecommendationSerializer, SpeciesSerializer, TripSerializer, WaterBodySerializer
|
||||
|
||||
def health(_request):
|
||||
return JsonResponse({"status": "ok", "service": "fishiq-api"})
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([permissions.AllowAny])
|
||||
def demo_plan(request):
|
||||
starts_at = timezone.now() + timedelta(days=3)
|
||||
supplied_start = request.data.get("starts_at") if request.method == "POST" else request.query_params.get("starts_at")
|
||||
if supplied_start:
|
||||
try:
|
||||
starts_at = timezone.datetime.fromisoformat(supplied_start.replace("Z", "+00:00"))
|
||||
if timezone.is_naive(starts_at): starts_at = timezone.make_aware(starts_at)
|
||||
except ValueError:
|
||||
return Response({"starts_at": ["Use an ISO-8601 date and time."]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"trip": {"water_body": "Lake Eufaula", "region": "Alabama / Georgia", "target_species": "Largemouth bass", "starts_at": starts_at.isoformat(), "duration_hours": 5}, "plan": build_bass_plan(starts_at)})
|
||||
|
||||
class ReadOnlyCatalogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@@ -27,4 +45,3 @@ class RecommendationViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = RecommendationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
def get_queryset(self): return Recommendation.objects.filter(trip__owner=self.request.user)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user