feat: ship MVP itinerary optimization, weather, AI key prefs, and MCP tools

This commit is contained in:
2026-03-08 13:49:32 +00:00
parent 9eb0325c7a
commit 8c0637c518
25 changed files with 1888 additions and 511 deletions

View 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

View File

@@ -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])

View File

@@ -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)),
]

View File

@@ -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

View 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