diff --git a/.env.example b/.env.example index f2e3bba4..3ac59564 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,10 @@ BACKEND_PORT=8016 # https://adventurelog.app/docs/configuration/google_maps_integration.html # GOOGLE_MAPS_API_KEY=your_google_maps_api_key +# Optional: encryption key for provider/user API keys (required only when using user API-key storage endpoints) +# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# FIELD_ENCRYPTION_KEY=replace_with_fernet_key + # Optional: disable registration # https://adventurelog.app/docs/configuration/disable_registration.html DISABLE_REGISTRATION=False @@ -55,4 +59,4 @@ DISABLE_REGISTRATION=False # Optional: Use Umami for analytics # https://adventurelog.app/docs/configuration/analytics.html # PUBLIC_UMAMI_SRC=https://cloud.umami.is/script.js # If you are using the hosted version of Umami -# PUBLIC_UMAMI_WEBSITE_ID= \ No newline at end of file +# PUBLIC_UMAMI_WEBSITE_ID= diff --git a/README.md b/README.md index 33001a98..ce52e5ab 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Voyage aims to be simple, beautiful, and open to everyone — inheriting Adventu - Itineraries can be created for any number of days and can include multiple destinations. - A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview. Lodging placement follows directional rules: on check-in day lodging appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single lodging card is shown (or two cards when a checkout and checkin are different lodgings). Lodging cards use the same compact style (no image header) as location cards within the itinerary. - Connector rows between consecutive locations display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as separate compact connector rows showing mode, duration, and distance. Boundary transitions between lodging and adjacent stops are also shown as connector rows. - - Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder. Lodging added from within a day is automatically scheduled to that day. + - Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and Optimize. Optimize performs nearest-neighbor stop ordering for coordinate-backed stops and keeps non-coordinate items at the end in their original relative order. The day date pill also shows a weather temperature summary (Open-Meteo based, with graceful unavailable fallback). - Itineraries include many planning features like flight information, notes, checklists, and links to external resources. - Itineraries can be shared with friends and family for collaborative planning. - **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together. diff --git a/backend/server/adventures/mcp.py b/backend/server/adventures/mcp.py new file mode 100644 index 00000000..e8986deb --- /dev/null +++ b/backend/server/adventures/mcp.py @@ -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 diff --git a/backend/server/adventures/tests.py b/backend/server/adventures/tests.py index 7ce503c2..010f6702 100644 --- a/backend/server/adventures/tests.py +++ b/backend/server/adventures/tests.py @@ -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]) diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 631d2475..0a751b60 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -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)), ] diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 79fd8963..0244be8a 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -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 * diff --git a/backend/server/adventures/views/recommendations_view.py b/backend/server/adventures/views/recommendations_view.py index 574e6265..53917b58 100644 --- a/backend/server/adventures/views/recommendations_view.py +++ b/backend/server/adventures/views/recommendations_view.py @@ -1,4 +1,7 @@ -from rest_framework import viewsets +from urllib.parse import urlencode +import re + +from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -6,20 +9,74 @@ from django.conf import settings import requests from geopy.distance import geodesic import logging -from ..geocoding import search_google, search_osm +from ..geocoding import search_osm +from integrations.models import EncryptionConfigurationError, UserAPIKey logger = logging.getLogger(__name__) + class RecommendationsViewSet(viewsets.ViewSet): permission_classes = [IsAuthenticated] OVERPASS_URL = "https://overpass-api.de/api/interpreter" NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" - HEADERS = {'User-Agent': 'Voyage Server'} + HEADERS = {"User-Agent": "Voyage Server"} # Quality thresholds MIN_GOOGLE_RATING = 3.0 # Minimum rating to include - MIN_GOOGLE_REVIEWS = 5 # Minimum number of reviews - MAX_RESULTS = 50 # Maximum results to return + MIN_GOOGLE_REVIEWS = 5 # Minimum number of reviews + MAX_RESULTS = 50 # Maximum results to return + + def _get_google_api_key(self, request): + user_key = UserAPIKey.objects.filter( + user=request.user, provider="google_maps" + ).first() + if user_key: + try: + decrypted = user_key.get_api_key() + except EncryptionConfigurationError: + decrypted = None + if decrypted: + return decrypted + return getattr(settings, "GOOGLE_MAPS_API_KEY", None) + + def _search_google_text(self, query, api_key): + if not api_key: + return None + + url = "https://places.googleapis.com/v1/places:searchText" + headers = { + "Content-Type": "application/json", + "X-Goog-Api-Key": api_key, + "X-Goog-FieldMask": "places.displayName.text,places.formattedAddress,places.location", + } + payload = { + "textQuery": query, + "maxResultCount": 5, + } + + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + response.raise_for_status() + data = response.json() + except Exception: + return None + + places = data.get("places", []) or [] + if not places: + return None + + normalized = [] + for place in places: + loc = place.get("location") or {} + normalized.append( + { + "lat": loc.get("latitude"), + "lon": loc.get("longitude"), + "name": (place.get("displayName") or {}).get("text"), + "display_name": place.get("formattedAddress"), + } + ) + return normalized def calculate_quality_score(self, place_data): """ @@ -27,21 +84,22 @@ class RecommendationsViewSet(viewsets.ViewSet): Higher score = better quality recommendation. """ import math + score = 0.0 - + # Rating contribution (0-50 points) - rating = place_data.get('rating') + rating = place_data.get("rating") if rating is not None and rating > 0: score += (rating / 5.0) * 50 - + # Review count contribution (0-30 points, logarithmic scale) - review_count = place_data.get('review_count') + review_count = place_data.get("review_count") if review_count is not None and review_count > 0: # Logarithmic scale: 10 reviews = ~10 pts, 100 = ~20 pts, 1000 = ~30 pts score += min(30, math.log10(review_count) * 10) - + # Distance penalty (0-20 points, closer is better) - distance_km = place_data.get('distance_km') + distance_km = place_data.get("distance_km") if distance_km is not None: if distance_km < 1: score += 20 @@ -51,21 +109,24 @@ class RecommendationsViewSet(viewsets.ViewSet): score += 10 elif distance_km < 20: score += 5 - + # Verified/business status bonus (0-10 points) - if place_data.get('is_verified') or place_data.get('business_status') == 'OPERATIONAL': + if ( + place_data.get("is_verified") + or place_data.get("business_status") == "OPERATIONAL" + ): score += 10 - + # Has photos bonus (0-5 points) - photos = place_data.get('photos') + photos = place_data.get("photos") if photos and len(photos) > 0: score += 5 - + # Has opening hours bonus (0-5 points) - opening_hours = place_data.get('opening_hours') + opening_hours = place_data.get("opening_hours") if opening_hours and len(opening_hours) > 0: score += 5 - + return round(score, 2) def parse_google_places(self, places, origin): @@ -74,27 +135,30 @@ class RecommendationsViewSet(viewsets.ViewSet): Enhanced with quality filtering and comprehensive data extraction. """ locations = [] - api_key = getattr(settings, 'GOOGLE_MAPS_API_KEY', None) for place in places: - location = place.get('location', {}) - types = place.get('types', []) - + location = place.get("location", {}) + types = place.get("types", []) + # Extract display name display_name = place.get("displayName", {}) - name = display_name.get("text") if isinstance(display_name, dict) else display_name + name = ( + display_name.get("text") + if isinstance(display_name, dict) + else display_name + ) # Extract coordinates - lat = location.get('latitude') - lon = location.get('longitude') + lat = location.get("latitude") + lon = location.get("longitude") if not name or not lat or not lon: continue # Extract rating information - rating = place.get('rating') - review_count = place.get('userRatingCount', 0) - + rating = place.get("rating") + review_count = place.get("userRatingCount", 0) + # Quality filter: Skip low-rated or unreviewed places if rating and rating < self.MIN_GOOGLE_RATING: continue @@ -105,49 +169,64 @@ class RecommendationsViewSet(viewsets.ViewSet): distance_km = geodesic(origin, (lat, lon)).km # Extract address information - formatted_address = place.get("formattedAddress") or place.get("shortFormattedAddress") - + formatted_address = place.get("formattedAddress") or place.get( + "shortFormattedAddress" + ) + # Extract business status - business_status = place.get('businessStatus') - is_operational = business_status == 'OPERATIONAL' - + business_status = place.get("businessStatus") + is_operational = business_status == "OPERATIONAL" + # Extract opening hours - opening_hours = place.get('regularOpeningHours', {}) - current_opening_hours = place.get('currentOpeningHours', {}) - is_open_now = current_opening_hours.get('openNow') - + opening_hours = place.get("regularOpeningHours", {}) + current_opening_hours = place.get("currentOpeningHours", {}) + is_open_now = current_opening_hours.get("openNow") + # Extract photos and construct URLs - photos = place.get('photos', []) + photos = place.get("photos", []) photo_urls = [] - if photos and api_key: + if photos: # Get first 5 photos and construct full URLs for photo in photos[:5]: - photo_name = photo.get('name', '') + photo_name = photo.get("name", "") if photo_name: - # Construct Google Places Photo API URL - # Format: https://places.googleapis.com/v1/{name}/media?key={key}&maxHeightPx=800&maxWidthPx=800 - photo_url = f"https://places.googleapis.com/v1/{photo_name}/media?key={api_key}&maxHeightPx=800&maxWidthPx=800" + query = urlencode( + { + "photo_name": photo_name, + "max_height": 800, + "max_width": 800, + } + ) + photo_url = f"/api/recommendations/google-photo/?{query}" photo_urls.append(photo_url) - + # Extract contact information - phone_number = place.get('nationalPhoneNumber') or place.get('internationalPhoneNumber') - website = place.get('websiteUri') - google_maps_uri = place.get('googleMapsUri') - + phone_number = place.get("nationalPhoneNumber") or place.get( + "internationalPhoneNumber" + ) + website = place.get("websiteUri") + google_maps_uri = place.get("googleMapsUri") + # Extract price level - price_level = place.get('priceLevel') - + price_level = place.get("priceLevel") + # Extract editorial summary/description - editorial_summary = place.get('editorialSummary', {}) - description = editorial_summary.get('text') if isinstance(editorial_summary, dict) else None - + editorial_summary = place.get("editorialSummary", {}) + description = ( + editorial_summary.get("text") + if isinstance(editorial_summary, dict) + else None + ) + # Filter out unwanted types (generic categories) - filtered_types = [t for t in types if t not in ['point_of_interest', 'establishment']] - + filtered_types = [ + t for t in types if t not in ["point_of_interest", "establishment"] + ] + # Build unified response place_data = { "id": f"google:{place.get('id')}", - "external_id": place.get('id'), + "external_id": place.get("id"), "source": "google", "name": name, "description": description, @@ -162,41 +241,43 @@ class RecommendationsViewSet(viewsets.ViewSet): "primary_type": filtered_types[0] if filtered_types else None, "business_status": business_status, "is_open_now": is_open_now, - "opening_hours": opening_hours.get('weekdayDescriptions', []) if opening_hours else None, + "opening_hours": opening_hours.get("weekdayDescriptions", []) + if opening_hours + else None, "phone_number": phone_number, "website": website, "google_maps_url": google_maps_uri, "photos": photo_urls, "is_verified": is_operational, } - + # Calculate quality score - place_data['quality_score'] = self.calculate_quality_score(place_data) - + place_data["quality_score"] = self.calculate_quality_score(place_data) + locations.append(place_data) return locations - + def parse_overpass_response(self, data, request, origin): """ Parse Overpass API (OSM) results into unified format. Enhanced with quality filtering and comprehensive data extraction. """ - nodes = data.get('elements', []) + nodes = data.get("elements", []) locations = [] for node in nodes: - if node.get('type') not in ['node', 'way', 'relation']: + if node.get("type") not in ["node", "way", "relation"]: continue - tags = node.get('tags', {}) - + tags = node.get("tags", {}) + # Get coordinates (for ways/relations, use center) - lat = node.get('lat') or node.get('center', {}).get('lat') - lon = node.get('lon') or node.get('center', {}).get('lon') - + lat = node.get("lat") or node.get("center", {}).get("lat") + lon = node.get("lon") or node.get("center", {}).get("lon") + # Extract name (with fallbacks) - name = tags.get('name') or tags.get('official_name') or tags.get('alt_name') + name = tags.get("name") or tags.get("official_name") or tags.get("alt_name") if not name or lat is None or lon is None: continue @@ -206,51 +287,62 @@ class RecommendationsViewSet(viewsets.ViewSet): # Extract address information address_parts = [ - tags.get('addr:housenumber'), - tags.get('addr:street'), - tags.get('addr:suburb') or tags.get('addr:neighbourhood'), - tags.get('addr:city'), - tags.get('addr:state'), - tags.get('addr:postcode'), - tags.get('addr:country') + tags.get("addr:housenumber"), + tags.get("addr:street"), + tags.get("addr:suburb") or tags.get("addr:neighbourhood"), + tags.get("addr:city"), + tags.get("addr:state"), + tags.get("addr:postcode"), + tags.get("addr:country"), ] formatted_address = ", ".join(filter(None, address_parts)) or None # Extract contact information - phone = tags.get('phone') or tags.get('contact:phone') - website = tags.get('website') or tags.get('contact:website') or tags.get('url') - + phone = tags.get("phone") or tags.get("contact:phone") + website = ( + tags.get("website") or tags.get("contact:website") or tags.get("url") + ) + # Extract opening hours - opening_hours = tags.get('opening_hours') - + opening_hours = tags.get("opening_hours") + # Extract rating/stars (if available) - stars = tags.get('stars') - + stars = tags.get("stars") + # Determine category/type hierarchy - category_keys = ['tourism', 'leisure', 'amenity', 'natural', 'historic', 'attraction', 'shop', 'sport'] + category_keys = [ + "tourism", + "leisure", + "amenity", + "natural", + "historic", + "attraction", + "shop", + "sport", + ] types = [tags.get(key) for key in category_keys if key in tags] primary_type = types[0] if types else None - + # Extract description and additional info - description = tags.get('description') or tags.get('note') - wikipedia = tags.get('wikipedia') or tags.get('wikidata') - + description = tags.get("description") or tags.get("note") + wikipedia = tags.get("wikipedia") or tags.get("wikidata") + # Extract image if available - image = tags.get('image') or tags.get('wikimedia_commons') - + image = tags.get("image") or tags.get("wikimedia_commons") + # Quality filters for OSM data # Skip if it's just a generic POI without specific category if not primary_type: continue - + # Skip construction or disused places - if tags.get('disused') or tags.get('construction'): + if tags.get("disused") or tags.get("construction"): continue - + # Build unified response place_data = { "id": f"osm:{node.get('type')}:{node.get('id')}", - "external_id": str(node.get('id')), + "external_id": str(node.get("id")), "source": "osm", "name": name, "description": description, @@ -271,19 +363,18 @@ class RecommendationsViewSet(viewsets.ViewSet): "google_maps_url": None, "photos": [image] if image else [], "is_verified": bool(wikipedia), # Has Wikipedia = more verified - "osm_type": node.get('type'), + "osm_type": node.get("type"), "wikipedia": wikipedia, "stars": stars, } - + # Calculate quality score (will be lower without ratings) - place_data['quality_score'] = self.calculate_quality_score(place_data) - + place_data["quality_score"] = self.calculate_quality_score(place_data) + locations.append(place_data) return locations - def query_overpass(self, lat, lon, radius, category, request): """ Query Overpass API (OpenStreetMap) for nearby places. @@ -291,10 +382,10 @@ class RecommendationsViewSet(viewsets.ViewSet): """ # Limit radius for OSM to prevent timeouts (max 5km for OSM due to server limits) osm_radius = min(radius, 5000) - + # Build optimized query - use simpler queries and limit results # Reduced timeout and simplified queries to prevent 504 errors - if category == 'tourism': + if category == "tourism": query = f""" [out:json][timeout:25]; ( @@ -304,13 +395,13 @@ class RecommendationsViewSet(viewsets.ViewSet): ); out center tags 50; """ - elif category == 'lodging': + elif category == "lodging": query = f""" [out:json][timeout:25]; nwr["tourism"~"hotel|motel|guest_house|hostel"](around:{osm_radius},{lat},{lon}); out center tags 50; """ - elif category == 'food': + elif category == "food": query = f""" [out:json][timeout:25]; nwr["amenity"~"restaurant|cafe|bar|pub"](around:{osm_radius},{lat},{lon}); @@ -322,32 +413,43 @@ class RecommendationsViewSet(viewsets.ViewSet): try: response = requests.post( - self.OVERPASS_URL, - data=query, - headers=self.HEADERS, - timeout=30 + self.OVERPASS_URL, data=query, headers=self.HEADERS, timeout=30 ) response.raise_for_status() data = response.json() except requests.exceptions.Timeout: - logger.warning(f"Overpass API timeout for {category} at ({lat}, {lon}) with radius {osm_radius}m") - return {"error": f"OpenStreetMap query timed out. The service is overloaded. Radius limited to {int(osm_radius)}m.", "results": []} + logger.warning( + f"Overpass API timeout for {category} at ({lat}, {lon}) with radius {osm_radius}m" + ) + return { + "error": f"OpenStreetMap query timed out. The service is overloaded. Radius limited to {int(osm_radius)}m.", + "results": [], + } except requests.exceptions.HTTPError as e: if e.response.status_code == 504: logger.warning(f"Overpass API 504 Gateway Timeout for {category}") - return {"error": "OpenStreetMap server is overloaded. Try again later or use Google source.", "results": []} + return { + "error": "OpenStreetMap server is overloaded. Try again later or use Google source.", + "results": [], + } logger.warning(f"Overpass API HTTP error: {e}") - return {"error": f"OpenStreetMap error: please try again later.", "results": []} + return { + "error": f"OpenStreetMap error: please try again later.", + "results": [], + } except requests.exceptions.RequestException as e: logger.warning(f"Overpass API error: {e}") - return {"error": f"OpenStreetMap temporarily unavailable: please try again later.", "results": []} + return { + "error": f"OpenStreetMap temporarily unavailable: please try again later.", + "results": [], + } except ValueError as e: logger.error(f"Invalid JSON response from Overpass: {e}") return {"error": "Invalid response from OpenStreetMap.", "results": []} origin = (float(lat), float(lon)) locations = self.parse_overpass_response(data, request, origin) - + logger.info(f"Overpass returned {len(locations)} results") return {"error": None, "results": locations} @@ -356,71 +458,92 @@ class RecommendationsViewSet(viewsets.ViewSet): Query Google Places API (New) for nearby places. Enhanced with comprehensive field masks and better error handling. """ - api_key = settings.GOOGLE_MAPS_API_KEY - + api_key = self._get_google_api_key(request) + url = "https://places.googleapis.com/v1/places:searchNearby" - + # Comprehensive field mask to get all useful information headers = { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': api_key, - 'X-Goog-FieldMask': ( - 'places.id,' - 'places.displayName,' - 'places.formattedAddress,' - 'places.shortFormattedAddress,' - 'places.location,' - 'places.types,' - 'places.rating,' - 'places.userRatingCount,' - 'places.businessStatus,' - 'places.priceLevel,' - 'places.websiteUri,' - 'places.googleMapsUri,' - 'places.nationalPhoneNumber,' - 'places.internationalPhoneNumber,' - 'places.editorialSummary,' - 'places.photos,' - 'places.currentOpeningHours,' - 'places.regularOpeningHours' - ) + "Content-Type": "application/json", + "X-Goog-Api-Key": api_key, + "X-Goog-FieldMask": ( + "places.id," + "places.displayName," + "places.formattedAddress," + "places.shortFormattedAddress," + "places.location," + "places.types," + "places.rating," + "places.userRatingCount," + "places.businessStatus," + "places.priceLevel," + "places.websiteUri," + "places.googleMapsUri," + "places.nationalPhoneNumber," + "places.internationalPhoneNumber," + "places.editorialSummary," + "places.photos," + "places.currentOpeningHours," + "places.regularOpeningHours" + ), } - + # Map categories to place types - use multiple types for better coverage type_mapping = { - 'lodging': ['lodging', 'hotel', 'hostel', 'resort_hotel', 'extended_stay_hotel'], - 'food': ['restaurant', 'cafe', 'bar', 'bakery', 'meal_takeaway', 'meal_delivery'], - 'tourism': ['tourist_attraction', 'museum', 'art_gallery', 'aquarium', 'zoo', 'amusement_park', 'park', 'natural_feature'], + "lodging": [ + "lodging", + "hotel", + "hostel", + "resort_hotel", + "extended_stay_hotel", + ], + "food": [ + "restaurant", + "cafe", + "bar", + "bakery", + "meal_takeaway", + "meal_delivery", + ], + "tourism": [ + "tourist_attraction", + "museum", + "art_gallery", + "aquarium", + "zoo", + "amusement_park", + "park", + "natural_feature", + ], } - + payload = { - "includedTypes": type_mapping.get(category, ['tourist_attraction']), + "includedTypes": type_mapping.get(category, ["tourist_attraction"]), "maxResultCount": 20, "rankPreference": "DISTANCE", # Sort by distance first "locationRestriction": { "circle": { - "center": { - "latitude": float(lat), - "longitude": float(lon) - }, - "radius": float(radius) + "center": {"latitude": float(lat), "longitude": float(lon)}, + "radius": float(radius), } - } + }, } - + try: response = requests.post(url, json=payload, headers=headers, timeout=15) response.raise_for_status() data = response.json() - - places = data.get('places', []) + + places = data.get("places", []) origin = (float(lat), float(lon)) locations = self.parse_google_places(places, origin) - - logger.info(f"Google Places returned {len(locations)} quality results for category '{category}'") - + + logger.info( + f"Google Places returned {len(locations)} quality results for category '{category}'" + ) + return Response(self._prepare_final_results(locations)) - + except requests.exceptions.Timeout: logger.warning("Google Places API timeout, falling back to OSM") return self.query_overpass(lat, lon, radius, category, request) @@ -436,77 +559,83 @@ class RecommendationsViewSet(viewsets.ViewSet): Prepare final results: sort by quality score and limit results. """ # Sort by quality score (highest first) - locations.sort(key=lambda x: x.get('quality_score', 0), reverse=True) - + locations.sort(key=lambda x: x.get("quality_score", 0), reverse=True) + # Limit to MAX_RESULTS - locations = locations[:self.MAX_RESULTS] - + locations = locations[: self.MAX_RESULTS] + return locations - + def _deduplicate_results(self, google_results, osm_results): """ Deduplicate results from both sources based on name and proximity. Prioritize Google results when duplicates are found. """ from difflib import SequenceMatcher - + def is_similar(name1, name2, threshold=0.85): """Check if two names are similar using fuzzy matching.""" - return SequenceMatcher(None, name1.lower(), name2.lower()).ratio() > threshold - + return ( + SequenceMatcher(None, name1.lower(), name2.lower()).ratio() > threshold + ) + def is_nearby(loc1, loc2, max_distance_m=50): """Check if two locations are within max_distance_m meters.""" dist = geodesic( - (loc1['latitude'], loc1['longitude']), - (loc2['latitude'], loc2['longitude']) + (loc1["latitude"], loc1["longitude"]), + (loc2["latitude"], loc2["longitude"]), ).meters return dist < max_distance_m - + # Start with all Google results (higher quality) deduplicated = list(google_results) - + # Add OSM results that don't match Google results for osm_loc in osm_results: is_duplicate = False for google_loc in google_results: - if (is_similar(osm_loc['name'], google_loc['name']) and - is_nearby(osm_loc, google_loc)): + if is_similar(osm_loc["name"], google_loc["name"]) and is_nearby( + osm_loc, google_loc + ): is_duplicate = True break - + if not is_duplicate: deduplicated.append(osm_loc) - + return deduplicated - @action(detail=False, methods=['get']) + @action(detail=False, methods=["get"]) def query(self, request): """ Query both Google Places and OSM for recommendations. Returns unified, high-quality results sorted by quality score. - + Query Parameters: - lat (required): Latitude - - lon (required): Longitude + - lon (required): Longitude - radius (optional): Search radius in meters (default: 5000, max: 50000) - category (required): Category - 'tourism', 'food', or 'lodging' - sources (optional): Comma-separated sources - 'google', 'osm', or 'both' (default: 'both') """ - lat = request.query_params.get('lat') - lon = request.query_params.get('lon') + lat = request.query_params.get("lat") + lon = request.query_params.get("lon") # Allow a free-text `location` parameter which will be geocoded - location_param = request.query_params.get('location') - radius = request.query_params.get('radius', '5000') - category = request.query_params.get('category') - sources = request.query_params.get('sources', 'both').lower() + location_param = request.query_params.get("location") + radius = request.query_params.get("radius", "5000") + category = request.query_params.get("category") + sources = request.query_params.get("sources", "both").lower() # If lat/lon not supplied, try geocoding the free-text location param if (not lat or not lon) and location_param: geocode_results = None + request_google_api_key = self._get_google_api_key(request) # Try Google first if API key configured - if getattr(settings, 'GOOGLE_MAPS_API_KEY', None): + if request_google_api_key: try: - geocode_results = search_google(location_param) + geocode_results = self._search_google_text( + location_param, request_google_api_key + ) except Exception: logger.warning("Google geocoding failed; falling back to OSM") geocode_results = None @@ -520,13 +649,20 @@ class RecommendationsViewSet(viewsets.ViewSet): geocode_results = None # Validate geocode results - if isinstance(geocode_results, dict) and geocode_results.get('error'): + if isinstance(geocode_results, dict) and geocode_results.get("error"): # Log internal geocoding error but avoid exposing sensitive details logger.warning("Geocoding helper returned an internal error") - return Response({"error": "Geocoding failed. Please try a different location or contact support."}, status=400) + return Response( + { + "error": "Geocoding failed. Please try a different location or contact support." + }, + status=400, + ) if not geocode_results: - return Response({"error": "Could not geocode provided location."}, status=400) + return Response( + {"error": "Could not geocode provided location."}, status=400 + ) # geocode_results expected to be a list of results; pick the best (first) best = None @@ -540,114 +676,138 @@ class RecommendationsViewSet(viewsets.ViewSet): return Response({"error": "No geocoding results found."}, status=400) try: - lat = float(best.get('lat') or best.get('latitude')) - lon = float(best.get('lon') or best.get('longitude')) + best_lat = best.get("lat") or best.get("latitude") + best_lon = best.get("lon") or best.get("longitude") + if best_lat is None or best_lon is None: + raise ValueError("missing_coordinates") + lat = float(best_lat) + lon = float(best_lon) except Exception: - return Response({"error": "Geocoding result missing coordinates."}, status=400) + return Response( + {"error": "Geocoding result missing coordinates."}, status=400 + ) # Replace location_param with display name when available for logging/debug - location_param = best.get('display_name') or best.get('name') or location_param + location_param = ( + best.get("display_name") or best.get("name") or location_param + ) # Validation: require lat and lon at this point if not lat or not lon: - return Response({ - "error": "Latitude and longitude parameters are required (or provide a 'location' parameter to geocode)." - }, status=400) - + return Response( + { + "error": "Latitude and longitude parameters are required (or provide a 'location' parameter to geocode)." + }, + status=400, + ) + try: lat = float(lat) lon = float(lon) radius = min(float(radius), 50000) # Max 50km radius except ValueError: - return Response({ - "error": "Invalid latitude, longitude, or radius value." - }, status=400) + return Response( + {"error": "Invalid latitude, longitude, or radius value."}, status=400 + ) - valid_categories = ['lodging', 'food', 'tourism'] + valid_categories = ["lodging", "food", "tourism"] if category not in valid_categories: - return Response({ - "error": f"Invalid category. Valid categories: {', '.join(valid_categories)}" - }, status=400) + return Response( + { + "error": f"Invalid category. Valid categories: {', '.join(valid_categories)}" + }, + status=400, + ) - valid_sources = ['google', 'osm', 'both'] + valid_sources = ["google", "osm", "both"] if sources not in valid_sources: - return Response({ - "error": f"Invalid sources. Valid options: {', '.join(valid_sources)}" - }, status=400) + return Response( + { + "error": f"Invalid sources. Valid options: {', '.join(valid_sources)}" + }, + status=400, + ) + + api_key = self._get_google_api_key(request) - api_key = getattr(settings, 'GOOGLE_MAPS_API_KEY', None) - google_results = [] osm_results = [] - + # Query Google Places if available and requested - if api_key and sources in ['google', 'both']: + if api_key and sources in ["google", "both"]: try: url = "https://places.googleapis.com/v1/places:searchNearby" headers = { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': api_key, - 'X-Goog-FieldMask': ( - 'places.id,places.displayName,places.formattedAddress,' - 'places.shortFormattedAddress,places.location,places.types,' - 'places.rating,places.userRatingCount,places.businessStatus,' - 'places.priceLevel,places.websiteUri,places.googleMapsUri,' - 'places.nationalPhoneNumber,places.internationalPhoneNumber,' - 'places.editorialSummary,places.photos,' - 'places.currentOpeningHours,places.regularOpeningHours' - ) + "Content-Type": "application/json", + "X-Goog-Api-Key": api_key, + "X-Goog-FieldMask": ( + "places.id,places.displayName,places.formattedAddress," + "places.shortFormattedAddress,places.location,places.types," + "places.rating,places.userRatingCount,places.businessStatus," + "places.priceLevel,places.websiteUri,places.googleMapsUri," + "places.nationalPhoneNumber,places.internationalPhoneNumber," + "places.editorialSummary,places.photos," + "places.currentOpeningHours,places.regularOpeningHours" + ), } - + type_mapping = { - 'lodging': ['lodging', 'hotel', 'hostel', 'resort_hotel'], - 'food': ['restaurant', 'cafe', 'bar', 'bakery'], - 'tourism': ['tourist_attraction', 'museum', 'art_gallery', 'aquarium', 'zoo', 'park'], + "lodging": ["lodging", "hotel", "hostel", "resort_hotel"], + "food": ["restaurant", "cafe", "bar", "bakery"], + "tourism": [ + "tourist_attraction", + "museum", + "art_gallery", + "aquarium", + "zoo", + "park", + ], } - + payload = { - "includedTypes": type_mapping.get(category, ['tourist_attraction']), + "includedTypes": type_mapping.get(category, ["tourist_attraction"]), "maxResultCount": 20, "rankPreference": "DISTANCE", "locationRestriction": { "circle": { "center": {"latitude": lat, "longitude": lon}, - "radius": radius + "radius": radius, } - } + }, } - + response = requests.post(url, json=payload, headers=headers, timeout=15) response.raise_for_status() data = response.json() - places = data.get('places', []) + places = data.get("places", []) origin = (lat, lon) google_results = self.parse_google_places(places, origin) logger.info(f"Google Places: {len(google_results)} quality results") - + except Exception as e: logger.warning(f"Google Places failed: {e}") - + # Query OSM if requested or as fallback osm_error = None - if sources in ['osm', 'both'] or (sources == 'google' and not google_results): + if sources in ["osm", "both"] or (sources == "google" and not google_results): osm_response = self.query_overpass(lat, lon, radius, category, request) - osm_results = osm_response.get('results', []) - osm_error = osm_response.get('error') - + osm_results = osm_response.get("results", []) + osm_error = osm_response.get("error") + if osm_error: logger.warning(f"OSM query had issues: {osm_error}") - + # Combine and deduplicate if using both sources - if sources == 'both' and google_results and osm_results: + if sources == "both" and google_results and osm_results: all_results = self._deduplicate_results(google_results, osm_results) else: all_results = google_results + osm_results - + # Prepare final results final_results = self._prepare_final_results(all_results) - + logger.info(f"Returning {len(final_results)} total recommendations") - + # Build response with metadata response_data = { "count": len(final_results), @@ -655,27 +815,96 @@ class RecommendationsViewSet(viewsets.ViewSet): "sources_used": { "google": len(google_results), "osm": len(osm_results), - "total_before_dedup": len(google_results) + len(osm_results) - } + "total_before_dedup": len(google_results) + len(osm_results), + }, } - + # Add warnings if there were errors but we still have some results warnings = [] if osm_error and len(osm_results) == 0: warnings.append(osm_error) - + if warnings: response_data["warnings"] = warnings - + # If no results at all and user requested only OSM, return error status - if len(final_results) == 0 and sources == 'osm' and osm_error: + if len(final_results) == 0 and sources == "osm" and osm_error: # Log internal error notice for investigation but do not expose details to clients logger.debug("OSM query error (internal)") - return Response({ - "error": "OpenStreetMap service temporarily unavailable. Please try again later.", - "count": 0, - "results": [], - "sources_used": response_data["sources_used"] - }, status=503) - - return Response(response_data) \ No newline at end of file + return Response( + { + "error": "OpenStreetMap service temporarily unavailable. Please try again later.", + "count": 0, + "results": [], + "sources_used": response_data["sources_used"], + }, + status=503, + ) + + return Response(response_data) + + @action(detail=False, methods=["get"], url_path="google-photo") + def google_photo(self, request): + photo_name = request.query_params.get("photo_name") + if not photo_name: + return Response( + {"error": "photo_name is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not re.fullmatch(r"places/[A-Za-z0-9_-]+/photos/[A-Za-z0-9_-]+", photo_name): + return Response( + { + "error": "photo_name must match pattern: places/{place_id}/photos/{photo_id}" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + api_key = self._get_google_api_key(request) + if not api_key: + return Response( + {"error": "Google API key is not configured for this account."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + max_height = min( + max(int(request.query_params.get("max_height", "800")), 1), 1600 + ) + max_width = min( + max(int(request.query_params.get("max_width", "800")), 1), 1600 + ) + except ValueError: + return Response( + {"error": "max_height and max_width must be integers."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + photo_url = f"https://places.googleapis.com/v1/{photo_name}/media" + try: + upstream = requests.get( + photo_url, + params={ + "key": api_key, + "maxHeightPx": max_height, + "maxWidthPx": max_width, + }, + timeout=15, + ) + except requests.RequestException: + return Response( + {"error": "Unable to fetch Google photo right now."}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + if upstream.status_code >= 400: + return Response( + {"error": "Google photo unavailable."}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + response = Response(upstream.content, status=status.HTTP_200_OK) + response["Content-Type"] = upstream.headers.get("Content-Type", "image/jpeg") + cache_control = upstream.headers.get("Cache-Control") + if cache_control: + response["Cache-Control"] = cache_control + return response diff --git a/backend/server/adventures/views/weather_view.py b/backend/server/adventures/views/weather_view.py new file mode 100644 index 00000000..e435eb95 --- /dev/null +++ b/backend/server/adventures/views/weather_view.py @@ -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 diff --git a/backend/server/integrations/admin.py b/backend/server/integrations/admin.py index 915b954a..8b95cb0d 100644 --- a/backend/server/integrations/admin.py +++ b/backend/server/integrations/admin.py @@ -1,11 +1,45 @@ from django.contrib import admin from allauth.account.decorators import secure_admin_login +from django.utils.html import format_html -from .models import ImmichIntegration, StravaToken, WandererIntegration +from .models import ( + ImmichIntegration, + StravaToken, + WandererIntegration, + UserAPIKey, + UserRecommendationPreferenceProfile, +) admin.autodiscover() admin.site.login = secure_admin_login(admin.site.login) admin.site.register(ImmichIntegration) admin.site.register(StravaToken) -admin.site.register(WandererIntegration) \ No newline at end of file +admin.site.register(WandererIntegration) + + +@admin.register(UserAPIKey) +class UserAPIKeyAdmin(admin.ModelAdmin): + list_display = ("user", "provider", "masked_value", "updated_at") + search_fields = ("user__username", "provider") + readonly_fields = ( + "user", + "provider", + "masked_value", + "created_at", + "updated_at", + ) + exclude = ("encrypted_api_key",) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + @admin.display(description="API key") + def masked_value(self, obj): + return format_html("{}", obj.masked_api_key) + + +admin.site.register(UserRecommendationPreferenceProfile) diff --git a/backend/server/integrations/migrations/0007_userapikey_userrecommendationpreferenceprofile.py b/backend/server/integrations/migrations/0007_userapikey_userrecommendationpreferenceprofile.py new file mode 100644 index 00000000..785d825c --- /dev/null +++ b/backend/server/integrations/migrations/0007_userapikey_userrecommendationpreferenceprofile.py @@ -0,0 +1,75 @@ +# Generated by Django 5.2.12 on 2026-03-08 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("integrations", "0006_alter_wandererintegration_token"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserRecommendationPreferenceProfile", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("cuisines", models.TextField(blank=True, null=True)), + ("interests", models.JSONField(blank=True, default=list)), + ("trip_style", models.CharField(blank=True, max_length=120, null=True)), + ("notes", models.TextField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="recommendation_profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="UserAPIKey", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("provider", models.CharField(max_length=100)), + ("encrypted_api_key", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_keys", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "provider")}, + }, + ), + ] diff --git a/backend/server/integrations/models.py b/backend/server/integrations/models.py index 79dc837e..0029308d 100644 --- a/backend/server/integrations/models.py +++ b/backend/server/integrations/models.py @@ -1,23 +1,52 @@ from django.db import models from django.contrib.auth import get_user_model import uuid +from django.conf import settings +from cryptography.fernet import Fernet, InvalidToken User = get_user_model() + +class EncryptionConfigurationError(Exception): + pass + + +def get_field_fernet() -> Fernet: + key = getattr(settings, "FIELD_ENCRYPTION_KEY", None) + if not key: + raise EncryptionConfigurationError( + "FIELD_ENCRYPTION_KEY is not configured. API key storage is unavailable." + ) + + key_bytes = key.encode() if isinstance(key, str) else key + try: + return Fernet(key_bytes) + except (TypeError, ValueError) as exc: + raise EncryptionConfigurationError( + "FIELD_ENCRYPTION_KEY is invalid. Provide a valid Fernet key." + ) from exc + + class ImmichIntegration(models.Model): server_url = models.CharField(max_length=255) api_key = models.CharField(max_length=255) - user = models.ForeignKey( - User, on_delete=models.CASCADE) - copy_locally = models.BooleanField(default=True, help_text="Copy image to local storage, instead of just linking to the remote URL.") - id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + user = models.ForeignKey(User, on_delete=models.CASCADE) + copy_locally = models.BooleanField( + default=True, + help_text="Copy image to local storage, instead of just linking to the remote URL.", + ) + id = models.UUIDField( + default=uuid.uuid4, editable=False, unique=True, primary_key=True + ) def __str__(self): - return self.user.username + ' - ' + self.server_url - + return self.user.username + " - " + self.server_url + + class StravaToken(models.Model): user = models.ForeignKey( - User, on_delete=models.CASCADE, related_name='strava_tokens') + User, on_delete=models.CASCADE, related_name="strava_tokens" + ) access_token = models.CharField(max_length=255) refresh_token = models.CharField(max_length=255) expires_at = models.BigIntegerField() # Unix timestamp @@ -25,18 +54,73 @@ class StravaToken(models.Model): scope = models.CharField(max_length=255, null=True, blank=True) updated_at = models.DateTimeField(auto_now=True) + class WandererIntegration(models.Model): server_url = models.CharField(max_length=255) username = models.CharField(max_length=255) user = models.ForeignKey( - User, on_delete=models.CASCADE, related_name='wanderer_integrations') + User, on_delete=models.CASCADE, related_name="wanderer_integrations" + ) token = models.CharField(null=True, blank=True) token_expiry = models.DateTimeField(null=True, blank=True) - id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + id = models.UUIDField( + default=uuid.uuid4, editable=False, unique=True, primary_key=True + ) def __str__(self): - return self.user.username + ' - ' + self.server_url - + return self.user.username + " - " + self.server_url + class Meta: verbose_name = "Wanderer Integration" - verbose_name_plural = "Wanderer Integrations" \ No newline at end of file + verbose_name_plural = "Wanderer Integrations" + + +class UserAPIKey(models.Model): + id = models.UUIDField( + default=uuid.uuid4, editable=False, unique=True, primary_key=True + ) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="api_keys") + provider = models.CharField(max_length=100) + encrypted_api_key = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ("user", "provider") + + def set_api_key(self, value: str) -> None: + if value is None: + raise ValueError("API key cannot be None") + fernet = get_field_fernet() + self.encrypted_api_key = fernet.encrypt(value.encode()).decode() + + def get_api_key(self) -> str | None: + if not self.encrypted_api_key: + return None + fernet = get_field_fernet() + try: + return fernet.decrypt(self.encrypted_api_key.encode()).decode() + except (InvalidToken, ValueError): + return None + + @property + def masked_api_key(self) -> str: + plain = self.get_api_key() or "" + if len(plain) <= 6: + return "*" * len(plain) + return f"{plain[:3]}{'*' * (len(plain) - 6)}{plain[-3:]}" + + +class UserRecommendationPreferenceProfile(models.Model): + id = models.UUIDField( + default=uuid.uuid4, editable=False, unique=True, primary_key=True + ) + user = models.OneToOneField( + User, on_delete=models.CASCADE, related_name="recommendation_profile" + ) + cuisines = models.TextField(blank=True, null=True) + interests = models.JSONField(default=list, blank=True) + trip_style = models.CharField(max_length=120, blank=True, null=True) + notes = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/server/integrations/serializers.py b/backend/server/integrations/serializers.py index cc92d211..5ae98598 100644 --- a/backend/server/integrations/serializers.py +++ b/backend/server/integrations/serializers.py @@ -1,13 +1,82 @@ -from .models import ImmichIntegration +from .models import ( + EncryptionConfigurationError, + ImmichIntegration, + UserAPIKey, + UserRecommendationPreferenceProfile, +) from rest_framework import serializers + class ImmichIntegrationSerializer(serializers.ModelSerializer): class Meta: model = ImmichIntegration - fields = '__all__' - read_only_fields = ['id', 'user'] + fields = "__all__" + read_only_fields = ["id", "user"] def to_representation(self, instance): representation = super().to_representation(instance) - representation.pop('user', None) + representation.pop("user", None) return representation + + +class UserAPIKeySerializer(serializers.ModelSerializer): + api_key = serializers.CharField(write_only=True, required=True, allow_blank=False) + masked_api_key = serializers.CharField(read_only=True) + + class Meta: + model = UserAPIKey + fields = [ + "id", + "provider", + "api_key", + "masked_api_key", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "masked_api_key", "created_at", "updated_at"] + + def validate_provider(self, value): + return (value or "").strip().lower() + + def create(self, validated_data): + api_key = validated_data.pop("api_key") + user = self.context["request"].user + instance = UserAPIKey(user=user, **validated_data) + try: + instance.set_api_key(api_key) + except EncryptionConfigurationError as exc: + raise serializers.ValidationError({"api_key": str(exc)}) from exc + instance.save() + return instance + + def update(self, instance, validated_data): + api_key = validated_data.pop("api_key", None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + if api_key is not None: + try: + instance.set_api_key(api_key) + except EncryptionConfigurationError as exc: + raise serializers.ValidationError({"api_key": str(exc)}) from exc + instance.save() + return instance + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation.pop("api_key", None) + return representation + + +class UserRecommendationPreferenceProfileSerializer(serializers.ModelSerializer): + class Meta: + model = UserRecommendationPreferenceProfile + fields = [ + "id", + "cuisines", + "interests", + "trip_style", + "notes", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] diff --git a/backend/server/integrations/tests.py b/backend/server/integrations/tests.py index 7ce503c2..00b1fd63 100644 --- a/backend/server/integrations/tests.py +++ b/backend/server/integrations/tests.py @@ -1,3 +1,53 @@ -from django.test import TestCase +from unittest.mock import patch -# Create your tests here. +from django.contrib.auth import get_user_model +from django.test import override_settings +from rest_framework.test import APITestCase + + +User = get_user_model() + + +class UserAPIKeyConfigurationTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + username="api-key-user", + email="apikey@example.com", + password="password123", + ) + self.client.force_authenticate(user=self.user) + + @override_settings(FIELD_ENCRYPTION_KEY="") + def test_api_key_endpoint_missing_encryption_key_is_graceful(self): + response = self.client.get("/api/integrations/api-keys/") + self.assertEqual(response.status_code, 503) + self.assertIn("FIELD_ENCRYPTION_KEY", response.json().get("detail", "")) + + @override_settings(FIELD_ENCRYPTION_KEY="invalid-key") + def test_api_key_endpoint_invalid_encryption_key_is_graceful(self): + response = self.client.get("/api/integrations/api-keys/") + self.assertEqual(response.status_code, 503) + self.assertIn("invalid", response.json().get("detail", "").lower()) + + @override_settings(FIELD_ENCRYPTION_KEY="") + @patch("adventures.views.recommendations_view.requests.get") + def test_google_photo_uses_graceful_fallback_when_user_key_unreadable( + self, mock_requests_get + ): + from integrations.models import UserAPIKey + + # Legacy/bad row exists but cannot be decrypted due to missing key. + UserAPIKey.objects.create( + user=self.user, + provider="google_maps", + encrypted_api_key="not-a-valid-fernet-token", + ) + + response = self.client.get( + "/api/recommendations/google-photo/?photo_name=places/abc/photos/def" + ) + + # Should fail gracefully as misconfigured key path, not crash (500). + self.assertEqual(response.status_code, 400) + self.assertIn("not configured", response.json().get("error", "").lower()) + mock_requests_get.assert_not_called() diff --git a/backend/server/integrations/urls.py b/backend/server/integrations/urls.py index b3994dbe..181bebca 100644 --- a/backend/server/integrations/urls.py +++ b/backend/server/integrations/urls.py @@ -1,15 +1,27 @@ from integrations.views import * from django.urls import path, include from rest_framework.routers import DefaultRouter -from integrations.views import IntegrationView, StravaIntegrationView, WandererIntegrationViewSet +from integrations.views import ( + IntegrationView, + StravaIntegrationView, + WandererIntegrationViewSet, + UserAPIKeyViewSet, + UserRecommendationPreferenceProfileViewSet, +) # Create the router and register the ViewSet router = DefaultRouter() -router.register(r'immich', ImmichIntegrationView, basename='immich') -router.register(r'', IntegrationView, basename='integrations') -router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset') -router.register(r'strava', StravaIntegrationView, basename='strava') -router.register(r'wanderer', WandererIntegrationViewSet, basename='wanderer') +router.register(r"immich", ImmichIntegrationView, basename="immich") +router.register(r"", IntegrationView, basename="integrations") +router.register(r"immich", ImmichIntegrationViewSet, basename="immich_viewset") +router.register(r"strava", StravaIntegrationView, basename="strava") +router.register(r"wanderer", WandererIntegrationViewSet, basename="wanderer") +router.register(r"api-keys", UserAPIKeyViewSet, basename="user-api-keys") +router.register( + r"recommendation-preferences", + UserRecommendationPreferenceProfileViewSet, + basename="user-recommendation-preferences", +) # Include the router URLs urlpatterns = [ diff --git a/backend/server/integrations/views/__init__.py b/backend/server/integrations/views/__init__.py index 9c727de4..f757b06d 100644 --- a/backend/server/integrations/views/__init__.py +++ b/backend/server/integrations/views/__init__.py @@ -1,4 +1,6 @@ from .immich_view import ImmichIntegrationView, ImmichIntegrationViewSet from .integration_view import IntegrationView from .strava_view import StravaIntegrationView -from .wanderer_view import WandererIntegrationViewSet \ No newline at end of file +from .wanderer_view import WandererIntegrationViewSet +from .user_api_key_view import UserAPIKeyViewSet +from .recommendation_profile_view import UserRecommendationPreferenceProfileViewSet diff --git a/backend/server/integrations/views/integration_view.py b/backend/server/integrations/views/integration_view.py index 7a391bdc..c8c3c810 100644 --- a/backend/server/integrations/views/integration_view.py +++ b/backend/server/integrations/views/integration_view.py @@ -3,40 +3,77 @@ from rest_framework.response import Response from rest_framework import viewsets, status from rest_framework.permissions import IsAuthenticated from django.utils import timezone -from integrations.models import ImmichIntegration, StravaToken, WandererIntegration +from integrations.models import ( + EncryptionConfigurationError, + ImmichIntegration, + StravaToken, + WandererIntegration, + UserAPIKey, + get_field_fernet, +) from django.conf import settings class IntegrationView(viewsets.ViewSet): permission_classes = [IsAuthenticated] + def list(self, request): """ RESTful GET method for listing all integrations. """ immich_integrations = ImmichIntegration.objects.filter(user=request.user) - google_map_integration = settings.GOOGLE_MAPS_API_KEY != '' - strava_integration_global = settings.STRAVA_CLIENT_ID != '' and settings.STRAVA_CLIENT_SECRET != '' + google_map_integration = ( + settings.GOOGLE_MAPS_API_KEY != "" + or UserAPIKey.objects.filter( + user=request.user, + provider="google_maps", + ).exists() + ) + strava_integration_global = ( + settings.STRAVA_CLIENT_ID != "" and settings.STRAVA_CLIENT_SECRET != "" + ) strava_integration_user = StravaToken.objects.filter(user=request.user).exists() - wanderer_integration = WandererIntegration.objects.filter(user=request.user).exists() + wanderer_integration = WandererIntegration.objects.filter( + user=request.user + ).exists() is_wanderer_expired = False if wanderer_integration: - token_expiry = WandererIntegration.objects.filter(user=request.user).first().token_expiry + token_expiry = ( + WandererIntegration.objects.filter(user=request.user) + .first() + .token_expiry + ) if token_expiry and token_expiry < timezone.now(): is_wanderer_expired = True + api_key_status = { + "enabled": UserAPIKey.objects.filter(user=request.user).exists(), + "available": True, + "error": None, + } + try: + get_field_fernet() + except EncryptionConfigurationError as exc: + api_key_status = { + "enabled": False, + "available": False, + "error": str(exc), + } + return Response( { - 'immich': immich_integrations.exists(), - 'google_maps': google_map_integration, - 'strava': { - 'global': strava_integration_global, - 'user': strava_integration_user + "immich": immich_integrations.exists(), + "google_maps": google_map_integration, + "api_keys": api_key_status, + "strava": { + "global": strava_integration_global, + "user": strava_integration_user, + }, + "wanderer": { + "exists": wanderer_integration, + "expired": is_wanderer_expired, }, - 'wanderer': { - 'exists': wanderer_integration, - 'expired': is_wanderer_expired - } }, - status=status.HTTP_200_OK + status=status.HTTP_200_OK, ) diff --git a/backend/server/integrations/views/recommendation_profile_view.py b/backend/server/integrations/views/recommendation_profile_view.py new file mode 100644 index 00000000..468a1e65 --- /dev/null +++ b/backend/server/integrations/views/recommendation_profile_view.py @@ -0,0 +1,43 @@ +from rest_framework import status, viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from integrations.models import UserRecommendationPreferenceProfile +from integrations.serializers import UserRecommendationPreferenceProfileSerializer + + +class UserRecommendationPreferenceProfileViewSet(viewsets.ModelViewSet): + serializer_class = UserRecommendationPreferenceProfileSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return UserRecommendationPreferenceProfile.objects.filter( + user=self.request.user + ) + + def list(self, request, *args, **kwargs): + instance = self.get_queryset().first() + if not instance: + return Response([], status=status.HTTP_200_OK) + serializer = self.get_serializer(instance) + return Response([serializer.data], status=status.HTTP_200_OK) + + def perform_create(self, serializer): + existing = UserRecommendationPreferenceProfile.objects.filter( + user=self.request.user + ).first() + if existing: + for field, value in serializer.validated_data.items(): + setattr(existing, field, value) + existing.save() + self._upserted_instance = existing + return + + self._upserted_instance = serializer.save(user=self.request.user) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + output = self.get_serializer(self._upserted_instance) + return Response(output.data, status=status.HTTP_200_OK) diff --git a/backend/server/integrations/views/user_api_key_view.py b/backend/server/integrations/views/user_api_key_view.py new file mode 100644 index 00000000..258b4dca --- /dev/null +++ b/backend/server/integrations/views/user_api_key_view.py @@ -0,0 +1,33 @@ +from rest_framework import viewsets +from rest_framework.exceptions import APIException +from rest_framework.permissions import IsAuthenticated + +from integrations.models import ( + EncryptionConfigurationError, + UserAPIKey, + get_field_fernet, +) +from integrations.serializers import UserAPIKeySerializer + + +class APIKeyConfigurationError(APIException): + status_code = 503 + default_detail = ( + "API key storage is unavailable due to server encryption configuration." + ) + default_code = "api_key_encryption_unavailable" + + +class UserAPIKeyViewSet(viewsets.ModelViewSet): + serializer_class = UserAPIKeySerializer + permission_classes = [IsAuthenticated] + + def initial(self, request, *args, **kwargs): + try: + get_field_fernet() + except EncryptionConfigurationError as exc: + raise APIKeyConfigurationError(detail=str(exc)) from exc + return super().initial(request, *args, **kwargs) + + def get_queryset(self): + return UserAPIKey.objects.filter(user=self.request.user).order_by("provider") diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index 8f9ce25b..fdd79d4d 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -28,88 +28,88 @@ BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # Core Security & Debug # --------------------------------------------------------------------------- # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = getenv('SECRET_KEY') +SECRET_KEY = getenv("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = getenv('DEBUG', 'true').lower() == 'true' +DEBUG = getenv("DEBUG", "true").lower() == "true" # ALLOWED_HOSTS = [ # 'localhost', # '127.0.0.1', # 'server' # ] -ALLOWED_HOSTS = ['*'] # In production, restrict to known hosts. +ALLOWED_HOSTS = ["*"] # In production, restrict to known hosts. # --------------------------------------------------------------------------- # Installed Apps # --------------------------------------------------------------------------- INSTALLED_APPS = ( "allauth_ui", - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.sites', - 'rest_framework', - 'rest_framework.authtoken', - 'allauth', - 'allauth.account', - 'allauth.mfa', - 'allauth.headless', - 'allauth.socialaccount', - 'allauth.socialaccount.providers.github', - 'allauth.socialaccount.providers.openid_connect', - 'invitations', - 'drf_yasg', - 'djmoney', - 'corsheaders', - 'adventures', - 'worldtravel', - 'users', - 'integrations', - 'django.contrib.gis', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.sites", + "rest_framework", + "rest_framework.authtoken", + "allauth", + "allauth.account", + "allauth.mfa", + "allauth.headless", + "allauth.socialaccount", + "allauth.socialaccount.providers.github", + "allauth.socialaccount.providers.openid_connect", + "invitations", + "drf_yasg", + "djmoney", + "corsheaders", + "adventures", + "worldtravel", + "users", + "integrations", + "mcp_server", + "django.contrib.gis", # 'achievements', # Not done yet, will be added later in a future update - 'widget_tweaks', - 'slippers', - + "widget_tweaks", + "slippers", ) # --------------------------------------------------------------------------- # Middleware # --------------------------------------------------------------------------- MIDDLEWARE = ( - 'whitenoise.middleware.WhiteNoiseMiddleware', - 'adventures.middleware.XSessionTokenMiddleware', - 'adventures.middleware.DisableCSRFForSessionTokenMiddleware', - 'adventures.middleware.DisableCSRFForMobileLoginSignup', - 'corsheaders.middleware.CorsMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'adventures.middleware.OverrideHostMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'allauth.account.middleware.AccountMiddleware', + "whitenoise.middleware.WhiteNoiseMiddleware", + "adventures.middleware.XSessionTokenMiddleware", + "adventures.middleware.DisableCSRFForSessionTokenMiddleware", + "adventures.middleware.DisableCSRFForMobileLoginSignup", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "adventures.middleware.OverrideHostMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "allauth.account.middleware.AccountMiddleware", ) # --------------------------------------------------------------------------- # Caching # --------------------------------------------------------------------------- CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache', - 'LOCATION': '127.0.0.1:11211', - 'TIMEOUT': 60 * 60 * 24, # Optional: 1 day cache + "default": { + "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", + "LOCATION": "127.0.0.1:11211", + "TIMEOUT": 60 * 60 * 24, # Optional: 1 day cache } } # For backwards compatibility for Django 1.8 MIDDLEWARE_CLASSES = MIDDLEWARE -ROOT_URLCONF = 'main.urls' +ROOT_URLCONF = "main.urls" # WSGI_APPLICATION = 'demo.wsgi.application' @@ -118,6 +118,7 @@ ROOT_URLCONF = 'main.urls' # --------------------------------------------------------------------------- # Using legacy PG environment variables for compatibility with existing setups + def env(*keys, default=None): """Return the first non-empty environment variable from a list of keys.""" for key in keys: @@ -126,16 +127,17 @@ def env(*keys, default=None): return value return default + DATABASES = { - 'default': { - 'ENGINE': 'django.contrib.gis.db.backends.postgis', - 'NAME': env('PGDATABASE', 'POSTGRES_DB'), - 'USER': env('PGUSER', 'POSTGRES_USER'), - 'PASSWORD': env('PGPASSWORD', 'POSTGRES_PASSWORD'), - 'HOST': env('PGHOST', default='localhost'), - 'PORT': int(env('PGPORT', default='5432')), - 'OPTIONS': { - 'sslmode': 'prefer', # Prefer SSL, but allow non-SSL connections + "default": { + "ENGINE": "django.contrib.gis.db.backends.postgis", + "NAME": env("PGDATABASE", "POSTGRES_DB"), + "USER": env("PGUSER", "POSTGRES_USER"), + "PASSWORD": env("PGPASSWORD", "POSTGRES_PASSWORD"), + "HOST": env("PGHOST", default="localhost"), + "PORT": int(env("PGPORT", default="5432")), + "OPTIONS": { + "sslmode": "prefer", # Prefer SSL, but allow non-SSL connections }, } } @@ -146,8 +148,8 @@ DATABASES = { # --------------------------------------------------------------------------- # Internationalization # --------------------------------------------------------------------------- -LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" USE_I18N = True USE_L10N = True USE_TZ = True @@ -156,20 +158,20 @@ USE_TZ = True # Frontend URL & Cookies # --------------------------------------------------------------------------- # Derive frontend URL from environment and configure cookie behavior. -unParsedFrontenedUrl = getenv('FRONTEND_URL', 'http://localhost:3000') -FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans('', '', '\'"')) +unParsedFrontenedUrl = getenv("FRONTEND_URL", "http://localhost:3000") +FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans("", "", "'\"")) -SESSION_COOKIE_SAMESITE = 'Lax' -SESSION_COOKIE_NAME = 'sessionid' +SESSION_COOKIE_SAMESITE = "Lax" +SESSION_COOKIE_NAME = "sessionid" # Secure cookies if frontend is served over HTTPS -SESSION_COOKIE_SECURE = FRONTEND_URL.startswith('https') -CSRF_COOKIE_SECURE = FRONTEND_URL.startswith('https') +SESSION_COOKIE_SECURE = FRONTEND_URL.startswith("https") +CSRF_COOKIE_SECURE = FRONTEND_URL.startswith("https") # Dynamically determine cookie domain to support subdomains while avoiding IPs hostname = urlparse(FRONTEND_URL).hostname -is_ip_address = hostname.replace('.', '').isdigit() -is_single_label = '.' not in hostname # single-label hostnames (e.g., "localhost") +is_ip_address = hostname.replace(".", "").isdigit() +is_single_label = "." not in hostname # single-label hostnames (e.g., "localhost") if is_ip_address or is_single_label: SESSION_COOKIE_DOMAIN = None @@ -181,15 +183,15 @@ else: # --------------------------------------------------------------------------- # Static & Media Files # --------------------------------------------------------------------------- -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") BASE_DIR = Path(__file__).resolve().parent.parent STATIC_ROOT = BASE_DIR / "staticfiles" -STATIC_URL = '/static/' +STATIC_URL = "/static/" -MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'media' # Must match NGINX root for media serving -STATICFILES_DIRS = [BASE_DIR / 'static'] +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" # Must match NGINX root for media serving +STATICFILES_DIRS = [BASE_DIR / "static"] STORAGES = { "staticfiles": { @@ -197,7 +199,7 @@ STORAGES = { }, "default": { "BACKEND": "django.core.files.storage.FileSystemStorage", - } + }, } SILENCED_SYSTEM_CHECKS = ["slippers.E001"] @@ -207,15 +209,17 @@ SILENCED_SYSTEM_CHECKS = ["slippers.E001"] # --------------------------------------------------------------------------- TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'templates'), ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + os.path.join(BASE_DIR, "templates"), + ], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, @@ -226,24 +230,29 @@ ALLAUTH_UI_THEME = "dim" # --------------------------------------------------------------------------- # Authentication & Accounts # --------------------------------------------------------------------------- -DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'false').lower() == 'true' -DISABLE_REGISTRATION_MESSAGE = getenv('DISABLE_REGISTRATION_MESSAGE', 'Registration is disabled. Please contact the administrator if you need an account.') +DISABLE_REGISTRATION = getenv("DISABLE_REGISTRATION", "false").lower() == "true" +DISABLE_REGISTRATION_MESSAGE = getenv( + "DISABLE_REGISTRATION_MESSAGE", + "Registration is disabled. Please contact the administrator if you need an account.", +) -SOCIALACCOUNT_ALLOW_SIGNUP = getenv('SOCIALACCOUNT_ALLOW_SIGNUP', 'false').lower() == 'true' +SOCIALACCOUNT_ALLOW_SIGNUP = ( + getenv("SOCIALACCOUNT_ALLOW_SIGNUP", "false").lower() == "true" +) -AUTH_USER_MODEL = 'users.CustomUser' -ACCOUNT_ADAPTER = 'users.adapters.CustomAccountAdapter' +AUTH_USER_MODEL = "users.CustomUser" +ACCOUNT_ADAPTER = "users.adapters.CustomAccountAdapter" INVITATIONS_ADAPTER = ACCOUNT_ADAPTER INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = True -INVITATIONS_EMAIL_SUBJECT_PREFIX = 'Voyage: ' -SOCIALACCOUNT_ADAPTER = 'users.adapters.CustomSocialAccountAdapter' -ACCOUNT_SIGNUP_FORM_CLASS = 'users.form_overrides.CustomSignupForm' +INVITATIONS_EMAIL_SUBJECT_PREFIX = "Voyage: " +SOCIALACCOUNT_ADAPTER = "users.adapters.CustomSocialAccountAdapter" +ACCOUNT_SIGNUP_FORM_CLASS = "users.form_overrides.CustomSignupForm" SESSION_SAVE_EVERY_REQUEST = True LOGIN_REDIRECT_URL = FRONTEND_URL # Redirect to frontend after login SOCIALACCOUNT_LOGIN_ON_GET = True -INVITATIONS_INVITE_FORM = 'users.form_overrides.UseAdminInviteForm' +INVITATIONS_INVITE_FORM = "users.form_overrides.UseAdminInviteForm" INVITATIONS_SIGNUP_REDIRECT_URL = f"{FRONTEND_URL}/signup" HEADLESS_FRONTEND_URLS = { @@ -256,34 +265,38 @@ HEADLESS_FRONTEND_URLS = { } AUTHENTICATION_BACKENDS = [ - 'users.backends.NoPasswordAuthBackend', + "users.backends.NoPasswordAuthBackend", # 'allauth.account.auth_backends.AuthenticationBackend', # 'django.contrib.auth.backends.ModelBackend', ] -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" SITE_ID = 1 ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_UNIQUE_EMAIL = True -ACCOUNT_EMAIL_VERIFICATION = getenv('ACCOUNT_EMAIL_VERIFICATION', 'none') # 'none', 'optional', 'mandatory' +ACCOUNT_EMAIL_VERIFICATION = getenv( + "ACCOUNT_EMAIL_VERIFICATION", "none" +) # 'none', 'optional', 'mandatory' SOCIALACCOUNT_EMAIL_AUTHENTICATION = True SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True # Auto-link by email SOCIALACCOUNT_AUTO_SIGNUP = True # Allow auto-signup post adapter checks -FORCE_SOCIALACCOUNT_LOGIN = getenv('FORCE_SOCIALACCOUNT_LOGIN', 'false').lower() == 'true' # When true, only social login is allowed (no password login) and the login page will show only social providers or redirect directly to the first provider if only one is configured. +FORCE_SOCIALACCOUNT_LOGIN = ( + getenv("FORCE_SOCIALACCOUNT_LOGIN", "false").lower() == "true" +) # When true, only social login is allowed (no password login) and the login page will show only social providers or redirect directly to the first provider if only one is configured. -if getenv('EMAIL_BACKEND', 'console') == 'console': - EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +if getenv("EMAIL_BACKEND", "console") == "console": + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" else: - EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' - EMAIL_HOST = getenv('EMAIL_HOST') - EMAIL_USE_TLS = getenv('EMAIL_USE_TLS', 'true').lower() == 'true' - EMAIL_PORT = getenv('EMAIL_PORT', 587) - EMAIL_USE_SSL = getenv('EMAIL_USE_SSL', 'false').lower() == 'true' - EMAIL_HOST_USER = getenv('EMAIL_HOST_USER') - EMAIL_HOST_PASSWORD = getenv('EMAIL_HOST_PASSWORD') - DEFAULT_FROM_EMAIL = getenv('DEFAULT_FROM_EMAIL') + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + EMAIL_HOST = getenv("EMAIL_HOST") + EMAIL_USE_TLS = getenv("EMAIL_USE_TLS", "true").lower() == "true" + EMAIL_PORT = getenv("EMAIL_PORT", 587) + EMAIL_USE_SSL = getenv("EMAIL_USE_SSL", "false").lower() == "true" + EMAIL_HOST_USER = getenv("EMAIL_HOST_USER") + EMAIL_HOST_PASSWORD = getenv("EMAIL_HOST_PASSWORD") + DEFAULT_FROM_EMAIL = getenv("DEFAULT_FROM_EMAIL") # EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # EMAIL_HOST = 'smtp.resend.com' @@ -299,63 +312,71 @@ else: # Django REST Framework # --------------------------------------------------------------------------- REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.SessionAuthentication', + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.SessionAuthentication", ), - 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', - 'DEFAULT_THROTTLE_CLASSES': [ - 'rest_framework.throttling.UserRateThrottle', + "DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema", + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.UserRateThrottle", ], - 'DEFAULT_THROTTLE_RATES': { - 'user': '1000/day', - 'image_proxy': '60/minute', + "DEFAULT_THROTTLE_RATES": { + "user": "1000/day", + "image_proxy": "60/minute", }, } if DEBUG: - REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = ( - 'rest_framework.renderers.JSONRenderer', - 'rest_framework.renderers.BrowsableAPIRenderer', + REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = ( + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", ) else: - REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = ( - 'rest_framework.renderers.JSONRenderer', + REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = ( + "rest_framework.renderers.JSONRenderer", ) # --------------------------------------------------------------------------- # CORS & CSRF # --------------------------------------------------------------------------- -CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()] -CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()] +CORS_ALLOWED_ORIGINS = [ + origin.strip() + for origin in getenv("CSRF_TRUSTED_ORIGINS", "http://localhost").split(",") + if origin.strip() +] +CSRF_TRUSTED_ORIGINS = [ + origin.strip() + for origin in getenv("CSRF_TRUSTED_ORIGINS", "http://localhost").split(",") + if origin.strip() +] CORS_ALLOW_CREDENTIALS = True -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", }, - 'file': { - 'class': 'logging.FileHandler', - 'filename': 'scheduler.log', + "file": { + "class": "logging.FileHandler", + "filename": "scheduler.log", }, }, - 'root': { - 'handlers': ['console', 'file'], - 'level': 'INFO', + "root": { + "handlers": ["console", "file"], + "level": "INFO", }, - 'loggers': { - 'django': { - 'handlers': ['console', 'file'], - 'level': 'INFO', - 'propagate': False, + "loggers": { + "django": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": False, }, }, } @@ -363,18 +384,28 @@ LOGGING = { # --------------------------------------------------------------------------- # Public URLs & Third-Party Integrations # --------------------------------------------------------------------------- -PUBLIC_URL = getenv('PUBLIC_URL', 'http://localhost:8000') +PUBLIC_URL = getenv("PUBLIC_URL", "http://localhost:8000") # VOYAGE_CDN_URL = getenv('VOYAGE_CDN_URL', 'https://cdn.voyage.app') # Major release version of Voyage, not including the patch version date. -VOYAGE_RELEASE_VERSION = 'v0.12.0' +VOYAGE_RELEASE_VERSION = "v0.12.0" # https://github.com/dr5hn/countries-states-cities-database/tags -COUNTRY_REGION_JSON_VERSION = 'v3.0' +COUNTRY_REGION_JSON_VERSION = "v3.0" # External service keys (do not hardcode secrets) -GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '') -STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '') -STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '') -OSRM_BASE_URL = getenv('OSRM_BASE_URL', 'https://router.project-osrm.org') \ No newline at end of file +GOOGLE_MAPS_API_KEY = getenv("GOOGLE_MAPS_API_KEY", "") +STRAVA_CLIENT_ID = getenv("STRAVA_CLIENT_ID", "") +STRAVA_CLIENT_SECRET = getenv("STRAVA_CLIENT_SECRET", "") +OSRM_BASE_URL = getenv("OSRM_BASE_URL", "https://router.project-osrm.org") + +FIELD_ENCRYPTION_KEY = getenv("FIELD_ENCRYPTION_KEY", "") + +DJANGO_MCP_ENDPOINT = getenv("DJANGO_MCP_ENDPOINT", "api/mcp") +DJANGO_MCP_AUTHENTICATION_CLASSES = [ + "rest_framework.authentication.TokenAuthentication", +] +DJANGO_MCP_ENDPOINT_PERMISSION_CLASSES = [ + "rest_framework.permissions.IsAuthenticated", +] diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index bc87bbe4..2a45622a 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -1,52 +1,100 @@ from django.urls import include, re_path, path from django.contrib import admin from django.views.generic import RedirectView, TemplateView -from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView, DisablePasswordAuthenticationView +from users.views import ( + IsRegistrationDisabled, + PublicUserListView, + PublicUserDetailView, + UserMetadataView, + UpdateUserMetadataView, + EnabledSocialProvidersView, + DisablePasswordAuthenticationView, +) from .views import get_csrf_token, get_public_url, serve_protected_media from drf_yasg.views import get_schema_view from drf_yasg import openapi +from mcp_server.views import MCPServerStreamableHttpView +from django.conf import settings +from django.utils.module_loading import import_string schema_view = get_schema_view( openapi.Info( - title='API Docs', - default_version='v1', + title="API Docs", + default_version="v1", ) ) urlpatterns = [ - path('api/', include('adventures.urls')), - path('api/', include('worldtravel.urls')), + path("api/", include("adventures.urls")), + path("api/", include("worldtravel.urls")), + path( + getattr(settings, "DJANGO_MCP_ENDPOINT", "api/mcp"), + MCPServerStreamableHttpView.as_view( + permission_classes=[ + import_string(cls) + for cls in getattr( + settings, + "DJANGO_MCP_ENDPOINT_PERMISSION_CLASSES", + ["rest_framework.permissions.IsAuthenticated"], + ) + ], + authentication_classes=[ + import_string(cls) + for cls in getattr( + settings, + "DJANGO_MCP_AUTHENTICATION_CLASSES", + ["rest_framework.authentication.TokenAuthentication"], + ) + ], + ), + name="mcp_server_streamable_http_endpoint", + ), path("auth/", include("allauth.headless.urls")), - # Serve protected media files - re_path(r'^media/(?P.*)$', serve_protected_media, name='serve-protected-media'), - - path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'), - path('auth/users/', PublicUserListView.as_view(), name='public-user-list'), - path('auth/user//', PublicUserDetailView.as_view(), name='public-user-detail'), - path('auth/update-user/', UpdateUserMetadataView.as_view(), name='update-user-metadata'), - - path('auth/user-metadata/', UserMetadataView.as_view(), name='user-metadata'), - - path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'), - - path('auth/disable-password/', DisablePasswordAuthenticationView.as_view(), name='disable-password-authentication'), - - path('csrf/', get_csrf_token, name='get_csrf_token'), - path('public-url/', get_public_url, name='get_public_url'), - - path("invitations/", include('invitations.urls', namespace='invitations')), - - path('', TemplateView.as_view(template_name='home.html')), - - re_path(r'^admin/', admin.site.urls), - re_path(r'^accounts/profile/$', RedirectView.as_view(url='/', - permanent=True), name='profile-redirect'), - re_path(r'^docs/$', schema_view.with_ui('swagger', - cache_timeout=0), name='api_docs'), + re_path( + r"^media/(?P.*)$", serve_protected_media, name="serve-protected-media" + ), + path( + "auth/is-registration-disabled/", + IsRegistrationDisabled.as_view(), + name="is_registration_disabled", + ), + path("auth/users/", PublicUserListView.as_view(), name="public-user-list"), + path( + "auth/user//", + PublicUserDetailView.as_view(), + name="public-user-detail", + ), + path( + "auth/update-user/", + UpdateUserMetadataView.as_view(), + name="update-user-metadata", + ), + path("auth/user-metadata/", UserMetadataView.as_view(), name="user-metadata"), + path( + "auth/social-providers/", + EnabledSocialProvidersView.as_view(), + name="enabled-social-providers", + ), + path( + "auth/disable-password/", + DisablePasswordAuthenticationView.as_view(), + name="disable-password-authentication", + ), + path("csrf/", get_csrf_token, name="get_csrf_token"), + path("public-url/", get_public_url, name="get_public_url"), + path("invitations/", include("invitations.urls", namespace="invitations")), + path("", TemplateView.as_view(template_name="home.html")), + re_path(r"^admin/", admin.site.urls), + re_path( + r"^accounts/profile/$", + RedirectView.as_view(url="/", permanent=True), + name="profile-redirect", + ), + re_path( + r"^docs/$", schema_view.with_ui("swagger", cache_timeout=0), name="api_docs" + ), # path('auth/account-confirm-email/', VerifyEmailView.as_view(), name='account_email_verification_sent'), path("accounts/", include("allauth.urls")), - path("api/integrations/", include("integrations.urls")), - - # Include the API endpoints: -] \ No newline at end of file + # Include the API endpoints: +] diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index 4c3a3822..6355a2bf 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -31,3 +31,5 @@ gpxpy==1.6.2 pymemcache==4.0.0 legacy-cgi==2.6.4 requests>=2.32.5 +cryptography>=46.0.5 +django-mcp-server>=0.5.7 diff --git a/documentation/docs/configuration/advanced_configuration.md b/documentation/docs/configuration/advanced_configuration.md index 3ba06453..534a8bd9 100644 --- a/documentation/docs/configuration/advanced_configuration.md +++ b/documentation/docs/configuration/advanced_configuration.md @@ -8,3 +8,5 @@ In addition to the primary configuration variables listed above, there are sever | `FORCE_SOCIALACCOUNT_LOGIN` | No | When set to `True`, only social login is allowed (no password login). The login page will show only social providers or redirect directly to the first provider if only one is configured. | `False` | Backend | | `SOCIALACCOUNT_ALLOW_SIGNUP` | No | When set to `True`, signup will be allowed via social providers even if registration is disabled. | `False` | Backend | | `OSRM_BASE_URL` | No | Base URL of the OSRM routing server used for itinerary connector distance/travel-time metrics. The public OSRM demo server is used by default. Set this to point at your own OSRM instance (e.g. `http://osrm:5000`) for higher rate limits or offline use. When the OSRM server is unreachable, the backend automatically falls back to haversine-based approximations so the itinerary UI always shows metrics. | `https://router.project-osrm.org` | Backend | +| `FIELD_ENCRYPTION_KEY` | No* | Fernet key used to encrypt user API keys at rest (integrations API key storage). Generate a 32-byte urlsafe base64 key (e.g. `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`). If missing/invalid, only API-key storage endpoints fail gracefully and the rest of the app remains available. | _(none)_ | Backend | +| `DJANGO_MCP_ENDPOINT` | No | HTTP path used for Django MCP server streamable endpoint. | `api/mcp` | Backend | diff --git a/documentation/docs/intro/voyage_overview.md b/documentation/docs/intro/voyage_overview.md index 37f72aad..7f951de0 100644 --- a/documentation/docs/intro/voyage_overview.md +++ b/documentation/docs/intro/voyage_overview.md @@ -16,7 +16,7 @@ Voyage is a full-fledged travel companion. With Voyage, you can log your adventu - Itineraries can be created for any number of days and can include multiple destinations. - A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview. Lodging placement follows directional rules: on check-in day lodging appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single lodging card is shown (or two cards when a checkout and checkin are different lodgings). Lodging cards use the same compact style (no image header) as location cards within the itinerary. - Connector rows between consecutive locations display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as separate compact connector rows showing mode, duration, and distance. Boundary transitions between lodging and adjacent stops are also shown as connector rows. - - Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder. Lodging added from within a day is automatically scheduled to that day. + - Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and Optimize (nearest-neighbor ordering for coordinate-backed stops). The day date pill includes a weather temperature summary when available. Lodging added from within a day is automatically scheduled to that day. - Itineraries include many planning features like flight information, notes, checklists, and links to external resources. - Itineraries can be shared with friends and family for collaborative planning. - **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together. diff --git a/documentation/docs/usage/usage.md b/documentation/docs/usage/usage.md index 1fb907d0..1477ae34 100644 --- a/documentation/docs/usage/usage.md +++ b/documentation/docs/usage/usage.md @@ -22,7 +22,7 @@ The term "Location" is now used instead of "Adventure" - the usage remains the s #### Collections -- **Collection**: a collection is a way to group locations together. Collections are flexible and can be used in many ways. When no start or end date is added to a collection, it acts like a folder to group locations together. When a start and end date is added to a collection, it acts like a trip to group locations together that were visited during that time period. With start and end dates, the collection is transformed into a full itinerary with a timeline-style day view — each day displays numbered stops as compact cards (without image banners), connector rows between consecutive locations showing distance and travel time via OSRM routing (walking if ≤ 20 min, driving otherwise) with automatic haversine fallback when OSRM is unavailable, and a single `+ Add` control for inserting new places. Lodging placement follows directional rules: on check-in day it appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single lodging card is shown (or two cards when a checkout and checkin are different lodgings). Connector rows link lodging to adjacent locations. Day-level quick actions include Auto-fill (populates an empty itinerary from dated records) and an Optimize placeholder for future route optimization. The itinerary also includes a map showing the route taken between locations. Your most recently updated collections also appear on the dashboard. For example, you could have a collection for a trip to Europe with dates so you can plan where you want to visit, a collection of local hiking trails, or a collection for a list of restaurants you want to try. +- **Collection**: a collection is a way to group locations together. Collections are flexible and can be used in many ways. When no start or end date is added to a collection, it acts like a folder to group locations together. When a start and end date is added to a collection, it acts like a trip to group locations together that were visited during that time period. With start and end dates, the collection is transformed into a full itinerary with a timeline-style day view — each day displays numbered stops as compact cards (without image banners), connector rows between consecutive locations showing distance and travel time via OSRM routing (walking if ≤ 20 min, driving otherwise) with automatic haversine fallback when OSRM is unavailable, and a single `+ Add` control for inserting new places. Lodging placement follows directional rules: on check-in day it appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single lodging card is shown (or two cards when a checkout and checkin are different lodgings). Connector rows link lodging to adjacent locations. Day-level quick actions include Auto-fill (populates an empty itinerary from dated records) and Optimize (nearest-neighbor route ordering for coordinate-backed stops). The day date pill displays a weather temperature summary when available, with graceful fallback if weather data is unavailable. The itinerary also includes a map showing the route taken between locations. Your most recently updated collections also appear on the dashboard. For example, you could have a collection for a trip to Europe with dates so you can plan where you want to visit, a collection of local hiking trails, or a collection for a list of restaurants you want to try. - **Transportation**: a transportation is a collection exclusive feature that allows you to add transportation information to your trip. In the itinerary timeline view, transportation items appear as compact connector rows between stops — showing the travel mode, duration, and distance. This can be used to show the route taken between locations and the mode of transportation used. It can also be used to track flight information, such as flight number and departure time. - **Lodging**: a lodging is a collection exclusive feature that allows you to add lodging information to your trip. This can be used to plan where you will stay during your trip and add notes about the lodging location. It can also be used to track reservation information, such as reservation number and check-in time. In the itinerary timeline view, lodging is displayed as compact cards (without image headers) using directional placement: on check-in day the lodging card appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single card is shown unless the checkout and checkin are different lodgings (in which case both appear). Lodging added from within a specific day is automatically scheduled to that day. Connector rows show boundary transitions between lodging and adjacent locations. - **Note**: a note is a collection exclusive feature that allows you to add notes to your trip. This can be used to add additional information about your trip, such as a summary of the trip or a list of things to do. Notes can be assigned to a specific day of the trip to help organize the information. diff --git a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte index 7427af1c..4eead504 100644 --- a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte +++ b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte @@ -62,6 +62,11 @@ dayMetadata: CollectionItineraryDay | null; // Day name and description }; + type DayTemperature = { + available: boolean; + temperature_c: number | null; + }; + $: days = groupItemsByDay(collection); $: unscheduledItems = getUnscheduledItems(collection); // Trip-wide (global) itinerary items @@ -77,6 +82,8 @@ let isSavingOrder = false; // Which day (ISO date string) is currently being saved. Used to show per-day spinner. let savingDay: string | null = null; + let dayTemperatures: Record = {}; + let activeTemperatureFetchVersion = 0; // Check if auto-generate is available (only for users with modify permission) $: canAutoGenerate = @@ -480,6 +487,23 @@ return Number.isFinite(distanceKm) ? distanceKm : null; } + function haversineDistanceBetweenCoordinates( + from: { latitude: number; longitude: number }, + to: { latitude: number; longitude: number } + ): number { + const earthRadiusKm = 6371; + const latDelta = toRadians(to.latitude - from.latitude); + const lonDelta = toRadians(to.longitude - from.longitude); + const fromLat = toRadians(from.latitude); + const toLat = toRadians(to.latitude); + + const a = + Math.sin(latDelta / 2) * Math.sin(latDelta / 2) + + Math.cos(fromLat) * Math.cos(toLat) * Math.sin(lonDelta / 2) * Math.sin(lonDelta / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return earthRadiusKm * c; + } + function formatTravelDuration(minutes: number): string { const totalMinutes = Math.max(0, Math.round(minutes)); const hours = Math.floor(totalMinutes / 60); @@ -544,6 +568,81 @@ return { latitude, longitude }; } + function getDayWeatherAnchor(day: DayGroup): { latitude: number; longitude: number } | null { + for (const item of day.items) { + if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue; + const coordinates = getCoordinatesFromItineraryItem(item); + if (coordinates) return coordinates; + } + + const boundaryCandidates = [day.preTimelineLodging, day.postTimelineLodging]; + for (const boundary of boundaryCandidates) { + const coordinates = getCoordinatesFromItineraryItem(boundary); + if (coordinates) return coordinates; + } + + return null; + } + + async function loadDayTemperatures(dayGroups: DayGroup[], fetchVersion: number) { + if (dayGroups.length === 0) { + if (fetchVersion === activeTemperatureFetchVersion) { + dayTemperatures = {}; + } + return; + } + + const payloadDays = dayGroups + .map((day) => { + const anchor = getDayWeatherAnchor(day); + if (!anchor) return null; + return { + date: day.date, + latitude: anchor.latitude, + longitude: anchor.longitude + }; + }) + .filter((entry): entry is { date: string; latitude: number; longitude: number } => !!entry); + + if (payloadDays.length === 0) { + if (fetchVersion === activeTemperatureFetchVersion) { + dayTemperatures = {}; + } + return; + } + + try { + const response = await fetch('/api/weather/daily-temperatures/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ days: payloadDays }) + }); + + if (!response.ok) throw new Error('Failed to load day temperatures'); + + const data = await response.json(); + if (fetchVersion !== activeTemperatureFetchVersion) return; + + const nextMap: Record = {}; + for (const result of data?.results || []) { + if (!result?.date) continue; + nextMap[result.date] = { + available: !!result.available, + temperature_c: + typeof result.temperature_c === 'number' ? result.temperature_c : null + }; + } + + dayTemperatures = nextMap; + } catch (error) { + if (fetchVersion !== activeTemperatureFetchVersion) return; + console.error('Failed to fetch day temperatures:', error); + dayTemperatures = {}; + } + } + function getFirstLocationItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null { for (const item of items) { if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue; @@ -868,6 +967,100 @@ loadConnectorMetrics(connectorPairs, fetchVersion); } + $: { + const daySnapshot = days + .map((day) => `${day.date}:${day.items.map((item) => item.id).join(',')}`) + .join('|'); + daySnapshot; + activeTemperatureFetchVersion += 1; + const fetchVersion = activeTemperatureFetchVersion; + loadDayTemperatures(days, fetchVersion); + } + + function formatDayTemperature(day: DayGroup): string { + const temperature = dayTemperatures[day.date]; + if (!temperature?.available || temperature.temperature_c === null) { + return getI18nText('itinerary.temperature_unavailable', 'Temperature unavailable'); + } + + const rounded = Math.round(temperature.temperature_c); + return `${rounded}°C`; + } + + function optimizeDayOrder(dayIndex: number) { + if (!canModify || isSavingOrder) return; + + const day = days[dayIndex]; + if (!day) return; + + const sortableItems = day.items.filter((item) => { + if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return false; + return !!getCoordinatesFromItineraryItem(item); + }); + + const nonSortableItems = day.items.filter((item) => { + if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return true; + return !getCoordinatesFromItineraryItem(item); + }); + + if (sortableItems.length < 2) { + addToast('info', getI18nText('itinerary.optimize_not_enough_items', 'Not enough stops to optimize')); + return; + } + + const remaining = [...sortableItems]; + const sorted: ResolvedItineraryItem[] = []; + + const firstItem = remaining.shift(); + if (!firstItem) return; + sorted.push(firstItem); + + while (remaining.length > 0) { + const last = sorted[sorted.length - 1]; + const lastCoords = getCoordinatesFromItineraryItem(last); + if (!lastCoords) break; + + let nearestIndex = 0; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (let index = 0; index < remaining.length; index += 1) { + const candidate = remaining[index]; + const candidateCoords = getCoordinatesFromItineraryItem(candidate); + if (!candidateCoords) continue; + + const distance = haversineDistanceBetweenCoordinates(lastCoords, candidateCoords); + + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } + } + + sorted.push(remaining.splice(nearestIndex, 1)[0]); + } + + days[dayIndex].items = [...sorted, ...nonSortableItems]; + days = [...days]; + + isSavingOrder = true; + savingDay = day.date; + saveReorderedItems() + .then((saved) => { + if (saved) { + addToast('success', getI18nText('itinerary.optimize_success', 'Day optimized')); + return; + } + addToast('error', getI18nText('itinerary.optimize_failed', 'Failed to optimize day')); + }) + .catch(() => { + addToast('error', getI18nText('itinerary.optimize_failed', 'Failed to optimize day')); + }) + .finally(() => { + isSavingOrder = false; + savingDay = null; + }); + } + function getFallbackLocationConnector( currentItem: ResolvedItineraryItem, nextItem: ResolvedItineraryItem | null @@ -1573,7 +1766,7 @@ } } - async function saveReorderedItems() { + async function saveReorderedItems(): Promise { try { // Collect all items across all days with their new positions const dayUpdates = days.flatMap((day) => @@ -1593,7 +1786,7 @@ const itemsToUpdate = [...dayUpdates, ...globalUpdates]; if (itemsToUpdate.length === 0) { - return; + return true; } const response = await fetch('/api/itineraries/reorder/', { @@ -1633,10 +1826,12 @@ .filter((it) => it.is_global) .map((it) => resolveItineraryItem(it, collection)) .sort((a, b) => a.order - b.order); + return true; } catch (error) { console.error('Error saving itinerary order:', error); // Optionally show error notification to user alert('Failed to save itinerary order. Please try again.'); + return false; } } @@ -2507,6 +2702,7 @@
{weekday}
{dayOfMonth}
{monthAbbrev}
+
{formatDayTemperature(day)}
@@ -2625,8 +2821,9 @@