feat: ship MVP itinerary optimization, weather, AI key prefs, and MCP tools
This commit is contained in:
160
backend/server/adventures/mcp.py
Normal file
160
backend/server/adventures/mcp.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Max, Q
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from adventures.models import (
|
||||
Checklist,
|
||||
Collection,
|
||||
CollectionItineraryDay,
|
||||
CollectionItineraryItem,
|
||||
Lodging,
|
||||
Location,
|
||||
Note,
|
||||
Transportation,
|
||||
Visit,
|
||||
)
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||
from adventures.serializers import (
|
||||
CollectionItineraryDaySerializer,
|
||||
CollectionItineraryItemSerializer,
|
||||
CollectionSerializer,
|
||||
UltraSlimCollectionSerializer,
|
||||
)
|
||||
from adventures.utils.itinerary import reorder_itinerary_items
|
||||
from mcp_server import MCPToolset
|
||||
from mcp_server.djangomcp import global_mcp_server
|
||||
|
||||
|
||||
class VoyageTripTools(MCPToolset):
|
||||
mcp_server = global_mcp_server
|
||||
|
||||
def _assert_authenticated(self):
|
||||
if (
|
||||
not getattr(self.request, "user", None)
|
||||
or not self.request.user.is_authenticated
|
||||
):
|
||||
raise ValidationError("Authentication required")
|
||||
|
||||
def _accessible_collections_queryset(self):
|
||||
self._assert_authenticated()
|
||||
user = self.request.user
|
||||
return Collection.objects.filter(Q(user=user) | Q(shared_with=user)).distinct()
|
||||
|
||||
def list_collections(self):
|
||||
"""List collections visible to authenticated user."""
|
||||
queryset = self._accessible_collections_queryset().order_by("-updated_at")
|
||||
return UltraSlimCollectionSerializer(
|
||||
queryset,
|
||||
many=True,
|
||||
context={"request": self.request},
|
||||
).data
|
||||
|
||||
def get_collection_details(self, collection_id: str):
|
||||
"""Get collection details, itinerary items, and itinerary-day metadata."""
|
||||
try:
|
||||
collection = self._accessible_collections_queryset().get(id=collection_id)
|
||||
except Collection.DoesNotExist as exc:
|
||||
raise ValidationError("Collection not found or not accessible") from exc
|
||||
data = CollectionSerializer(collection, context={"request": self.request}).data
|
||||
itinerary_items = CollectionItineraryItem.objects.filter(collection=collection)
|
||||
itinerary_days = CollectionItineraryDay.objects.filter(collection=collection)
|
||||
data["itinerary"] = CollectionItineraryItemSerializer(
|
||||
itinerary_items, many=True
|
||||
).data
|
||||
data["itinerary_days"] = CollectionItineraryDaySerializer(
|
||||
itinerary_days, many=True
|
||||
).data
|
||||
return data
|
||||
|
||||
def list_itinerary_items(self, collection_id: str | None = None):
|
||||
"""List itinerary items; optionally limit by collection_id."""
|
||||
self._assert_authenticated()
|
||||
queryset = CollectionItineraryItem.objects.filter(
|
||||
Q(collection__user=self.request.user)
|
||||
| Q(collection__shared_with=self.request.user)
|
||||
).distinct()
|
||||
|
||||
if collection_id:
|
||||
queryset = queryset.filter(collection_id=collection_id)
|
||||
|
||||
queryset = queryset.order_by("date", "order")
|
||||
return CollectionItineraryItemSerializer(queryset, many=True).data
|
||||
|
||||
def create_itinerary_item(
|
||||
self,
|
||||
collection_id: str,
|
||||
content_type: str,
|
||||
object_id: str,
|
||||
date: str | None = None,
|
||||
is_global: bool = False,
|
||||
order: int | None = None,
|
||||
):
|
||||
"""Create a new itinerary item."""
|
||||
try:
|
||||
collection = self._accessible_collections_queryset().get(id=collection_id)
|
||||
except Collection.DoesNotExist as exc:
|
||||
raise ValidationError("Collection not found or not accessible") from exc
|
||||
|
||||
content_map = {
|
||||
"location": Location,
|
||||
"transportation": Transportation,
|
||||
"note": Note,
|
||||
"lodging": Lodging,
|
||||
"visit": Visit,
|
||||
"checklist": Checklist,
|
||||
}
|
||||
model_class = content_map.get((content_type or "").lower())
|
||||
if not model_class:
|
||||
raise ValidationError("Invalid content_type")
|
||||
|
||||
try:
|
||||
content_object = model_class.objects.get(id=object_id)
|
||||
except model_class.DoesNotExist as exc:
|
||||
raise ValidationError("Referenced object not found") from exc
|
||||
permission_checker = IsOwnerOrSharedWithFullAccess()
|
||||
if not permission_checker.has_object_permission(
|
||||
self.request, None, content_object
|
||||
):
|
||||
raise ValidationError(
|
||||
"User does not have permission to access this content"
|
||||
)
|
||||
|
||||
if is_global and date:
|
||||
raise ValidationError("Global itinerary items must not include a date")
|
||||
if (not is_global) and not date:
|
||||
raise ValidationError("Dated itinerary items must include a date")
|
||||
|
||||
if order is None:
|
||||
if is_global:
|
||||
existing_max = (
|
||||
CollectionItineraryItem.objects.filter(
|
||||
collection=collection, is_global=True
|
||||
)
|
||||
.aggregate(max_order=Max("order"))
|
||||
.get("max_order")
|
||||
)
|
||||
else:
|
||||
existing_max = (
|
||||
CollectionItineraryItem.objects.filter(
|
||||
collection=collection, date=date, is_global=False
|
||||
)
|
||||
.aggregate(max_order=Max("order"))
|
||||
.get("max_order")
|
||||
)
|
||||
order = 0 if existing_max is None else int(existing_max) + 1
|
||||
|
||||
itinerary_item = CollectionItineraryItem.objects.create(
|
||||
collection=collection,
|
||||
content_type=ContentType.objects.get_for_model(model_class),
|
||||
object_id=object_id,
|
||||
date=date,
|
||||
is_global=is_global,
|
||||
order=order,
|
||||
)
|
||||
return CollectionItineraryItemSerializer(itinerary_item).data
|
||||
|
||||
def reorder_itinerary(self, items: list[dict]):
|
||||
"""Bulk reorder itinerary items."""
|
||||
self._assert_authenticated()
|
||||
updated_items = reorder_itinerary_items(self.request.user, items or [])
|
||||
return CollectionItineraryItemSerializer(updated_items, many=True).data
|
||||
@@ -1,3 +1,108 @@
|
||||
from django.test import TestCase
|
||||
from datetime import timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
# Create your tests here.
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient, APITestCase
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class WeatherEndpointTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="weather-user",
|
||||
email="weather@example.com",
|
||||
password="password123",
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
cache.clear()
|
||||
|
||||
def test_daily_temperatures_rejects_too_many_days(self):
|
||||
payload = {
|
||||
"days": [
|
||||
{"date": "2026-01-01", "latitude": 10, "longitude": 10}
|
||||
for _ in range(61)
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/api/weather/daily-temperatures/", payload, format="json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("maximum", response.json().get("error", "").lower())
|
||||
|
||||
@patch("adventures.views.weather_view.requests.get")
|
||||
def test_daily_temperatures_future_date_returns_unavailable_without_external_call(
|
||||
self, mock_requests_get
|
||||
):
|
||||
future_date = (timezone.now().date() + timedelta(days=10)).isoformat()
|
||||
|
||||
response = self.client.post(
|
||||
"/api/weather/daily-temperatures/",
|
||||
{"days": [{"date": future_date, "latitude": 12.34, "longitude": 56.78}]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json()["results"][0],
|
||||
{"date": future_date, "available": False, "temperature_c": None},
|
||||
)
|
||||
mock_requests_get.assert_not_called()
|
||||
|
||||
@patch("adventures.views.weather_view.requests.get")
|
||||
def test_daily_temperatures_accepts_zero_lat_lon(self, mock_requests_get):
|
||||
today = timezone.now().date().isoformat()
|
||||
mocked_response = Mock()
|
||||
mocked_response.raise_for_status.return_value = None
|
||||
mocked_response.json.return_value = {
|
||||
"daily": {
|
||||
"temperature_2m_max": [20.0],
|
||||
"temperature_2m_min": [10.0],
|
||||
}
|
||||
}
|
||||
mock_requests_get.return_value = mocked_response
|
||||
|
||||
response = self.client.post(
|
||||
"/api/weather/daily-temperatures/",
|
||||
{"days": [{"date": today, "latitude": 0, "longitude": 0}]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["results"][0]["date"], today)
|
||||
self.assertTrue(response.json()["results"][0]["available"])
|
||||
self.assertEqual(response.json()["results"][0]["temperature_c"], 15.0)
|
||||
|
||||
|
||||
class RecommendationPhotoProxyValidationTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="reco-user",
|
||||
email="reco@example.com",
|
||||
password="password123",
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_google_photo_rejects_invalid_photo_name(self):
|
||||
response = self.client.get(
|
||||
"/api/recommendations/google-photo/?photo_name=invalid-photo-name"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_google_photo_rejects_trailing_newline_photo_name(self):
|
||||
response = self.client.get(
|
||||
"/api/recommendations/google-photo/?photo_name=places/abc/photos/def%0A"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
class MCPAuthTests(APITestCase):
|
||||
def test_mcp_unauthenticated_access_is_rejected(self):
|
||||
unauthenticated_client = APIClient()
|
||||
response = unauthenticated_client.post("/api/mcp", {}, format="json")
|
||||
self.assertIn(response.status_code, [401, 403])
|
||||
|
||||
@@ -3,31 +3,36 @@ from rest_framework.routers import DefaultRouter
|
||||
from adventures.views import *
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'locations', LocationViewSet, basename='locations')
|
||||
router.register(r'collections', CollectionViewSet, basename='collections')
|
||||
router.register(r'stats', StatsViewSet, basename='stats')
|
||||
router.register(r'generate', GenerateDescription, basename='generate')
|
||||
router.register(r'tags', ActivityTypesView, basename='tags')
|
||||
router.register(r'transportations', TransportationViewSet, basename='transportations')
|
||||
router.register(r'notes', NoteViewSet, basename='notes')
|
||||
router.register(r'checklists', ChecklistViewSet, basename='checklists')
|
||||
router.register(r'images', ContentImageViewSet, basename='images')
|
||||
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
|
||||
router.register(r'categories', CategoryViewSet, basename='categories')
|
||||
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
|
||||
router.register(r'search', GlobalSearchView, basename='search')
|
||||
router.register(r'attachments', AttachmentViewSet, basename='attachments')
|
||||
router.register(r'lodging', LodgingViewSet, basename='lodging')
|
||||
router.register(r'recommendations', RecommendationsViewSet, basename='recommendations'),
|
||||
router.register(r'backup', BackupViewSet, basename='backup')
|
||||
router.register(r'trails', TrailViewSet, basename='trails')
|
||||
router.register(r'activities', ActivityViewSet, basename='activities')
|
||||
router.register(r'visits', VisitViewSet, basename='visits')
|
||||
router.register(r'itineraries', ItineraryViewSet, basename='itineraries')
|
||||
router.register(r'itinerary-days', ItineraryDayViewSet, basename='itinerary-days')
|
||||
router.register(r'route-metrics', RouteMetricsViewSet, basename='route-metrics')
|
||||
router.register(r"locations", LocationViewSet, basename="locations")
|
||||
router.register(r"collections", CollectionViewSet, basename="collections")
|
||||
router.register(r"stats", StatsViewSet, basename="stats")
|
||||
router.register(r"generate", GenerateDescription, basename="generate")
|
||||
router.register(r"tags", ActivityTypesView, basename="tags")
|
||||
router.register(r"transportations", TransportationViewSet, basename="transportations")
|
||||
router.register(r"notes", NoteViewSet, basename="notes")
|
||||
router.register(r"checklists", ChecklistViewSet, basename="checklists")
|
||||
router.register(r"images", ContentImageViewSet, basename="images")
|
||||
router.register(r"reverse-geocode", ReverseGeocodeViewSet, basename="reverse-geocode")
|
||||
router.register(r"categories", CategoryViewSet, basename="categories")
|
||||
router.register(r"ics-calendar", IcsCalendarGeneratorViewSet, basename="ics-calendar")
|
||||
router.register(r"search", GlobalSearchView, basename="search")
|
||||
router.register(r"attachments", AttachmentViewSet, basename="attachments")
|
||||
router.register(r"lodging", LodgingViewSet, basename="lodging")
|
||||
(
|
||||
router.register(
|
||||
r"recommendations", RecommendationsViewSet, basename="recommendations"
|
||||
),
|
||||
)
|
||||
router.register(r"backup", BackupViewSet, basename="backup")
|
||||
router.register(r"trails", TrailViewSet, basename="trails")
|
||||
router.register(r"activities", ActivityViewSet, basename="activities")
|
||||
router.register(r"visits", VisitViewSet, basename="visits")
|
||||
router.register(r"itineraries", ItineraryViewSet, basename="itineraries")
|
||||
router.register(r"itinerary-days", ItineraryDayViewSet, basename="itinerary-days")
|
||||
router.register(r"route-metrics", RouteMetricsViewSet, basename="route-metrics")
|
||||
router.register(r"weather", WeatherViewSet, basename="weather")
|
||||
|
||||
urlpatterns = [
|
||||
# Include the router under the 'api/' prefix
|
||||
path('', include(router.urls)),
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -20,3 +20,4 @@ from .activity_view import *
|
||||
from .visit_view import *
|
||||
from .itinerary_view import *
|
||||
from .route_metrics_view import *
|
||||
from .weather_view import *
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
154
backend/server/adventures/views/weather_view.py
Normal file
154
backend/server/adventures/views/weather_view.py
Normal file
@@ -0,0 +1,154 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import date as date_cls
|
||||
|
||||
import requests
|
||||
from django.core.cache import cache
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WeatherViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
OPEN_METEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
|
||||
OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
CACHE_TIMEOUT_SECONDS = 60 * 60 * 6
|
||||
MAX_DAYS_PER_REQUEST = 60
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="daily-temperatures")
|
||||
def daily_temperatures(self, request):
|
||||
days = request.data.get("days", [])
|
||||
if not isinstance(days, list):
|
||||
return Response(
|
||||
{"error": "'days' must be a list"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
if len(days) > self.MAX_DAYS_PER_REQUEST:
|
||||
return Response(
|
||||
{
|
||||
"error": f"A maximum of {self.MAX_DAYS_PER_REQUEST} days is allowed per request"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
results = []
|
||||
for entry in days:
|
||||
if not isinstance(entry, dict):
|
||||
results.append(
|
||||
{"date": None, "available": False, "temperature_c": None}
|
||||
)
|
||||
continue
|
||||
|
||||
date = entry.get("date")
|
||||
latitude = entry.get("latitude")
|
||||
longitude = entry.get("longitude")
|
||||
|
||||
if not date or latitude is None or longitude is None:
|
||||
results.append(
|
||||
{"date": date, "available": False, "temperature_c": None}
|
||||
)
|
||||
continue
|
||||
|
||||
parsed_date = self._parse_date(date)
|
||||
if parsed_date is None:
|
||||
results.append(
|
||||
{"date": date, "available": False, "temperature_c": None}
|
||||
)
|
||||
continue
|
||||
|
||||
if parsed_date > date_cls.today():
|
||||
results.append(
|
||||
{"date": date, "available": False, "temperature_c": None}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
lat = float(latitude)
|
||||
lon = float(longitude)
|
||||
except (TypeError, ValueError):
|
||||
results.append(
|
||||
{"date": date, "available": False, "temperature_c": None}
|
||||
)
|
||||
continue
|
||||
|
||||
cache_key = self._cache_key(date, lat, lon)
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
results.append(cached)
|
||||
continue
|
||||
|
||||
payload = self._fetch_daily_temperature(date, lat, lon)
|
||||
cache.set(cache_key, payload, timeout=self.CACHE_TIMEOUT_SECONDS)
|
||||
results.append(payload)
|
||||
|
||||
return Response({"results": results}, status=status.HTTP_200_OK)
|
||||
|
||||
def _fetch_daily_temperature(self, date: str, latitude: float, longitude: float):
|
||||
base_payload = {
|
||||
"date": date,
|
||||
"available": False,
|
||||
"temperature_c": None,
|
||||
}
|
||||
|
||||
for url in (self.OPEN_METEO_ARCHIVE_URL, self.OPEN_METEO_FORECAST_URL):
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
params={
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"start_date": date,
|
||||
"end_date": date,
|
||||
"daily": "temperature_2m_max,temperature_2m_min",
|
||||
"timezone": "UTC",
|
||||
},
|
||||
timeout=8,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except requests.RequestException:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
daily = data.get("daily") or {}
|
||||
max_values = daily.get("temperature_2m_max") or []
|
||||
min_values = daily.get("temperature_2m_min") or []
|
||||
if not max_values or not min_values:
|
||||
continue
|
||||
|
||||
try:
|
||||
avg = (float(max_values[0]) + float(min_values[0])) / 2
|
||||
except (TypeError, ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return {
|
||||
"date": date,
|
||||
"available": True,
|
||||
"temperature_c": round(avg, 1),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"No weather data available for date=%s lat=%s lon=%s",
|
||||
date,
|
||||
latitude,
|
||||
longitude,
|
||||
)
|
||||
return base_payload
|
||||
|
||||
def _cache_key(self, date: str, latitude: float, longitude: float) -> str:
|
||||
rounded_lat = round(latitude, 3)
|
||||
rounded_lon = round(longitude, 3)
|
||||
raw = f"{date}:{rounded_lat}:{rounded_lon}"
|
||||
digest = hashlib.sha256(raw.encode()).hexdigest()
|
||||
return f"weather_daily:{digest}"
|
||||
|
||||
def _parse_date(self, value: str):
|
||||
try:
|
||||
return date_cls.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
Reference in New Issue
Block a user