From 9d3006ad8e326f6c298abdea0ac486ad5e3cda1d Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 10 Mar 2026 20:20:00 +0000 Subject: [PATCH] feat: remove recommendations surface and backend --- backend/server/adventures/tests.py | 22 - backend/server/adventures/urls.py | 5 - backend/server/adventures/views/__init__.py | 1 - .../adventures/views/recommendations_view.py | 910 ------------------ backend/server/integrations/tests.py | 25 - .../CollectionRecommendationView.svelte | 800 --------------- frontend/src/lib/types.ts | 40 - frontend/src/locales/ar.json | 1 - frontend/src/locales/de.json | 1 - frontend/src/locales/en.json | 1 - frontend/src/locales/es.json | 1 - frontend/src/locales/fr.json | 1 - frontend/src/locales/hu.json | 1 - frontend/src/locales/it.json | 1 - frontend/src/locales/ja.json | 1 - frontend/src/locales/ko.json | 1 - frontend/src/locales/nl.json | 1 - frontend/src/locales/no.json | 1 - frontend/src/locales/pl.json | 1 - frontend/src/locales/pt-br.json | 1 - frontend/src/locales/ro.json | 1 - frontend/src/locales/ru.json | 1 - frontend/src/locales/sk.json | 1 - frontend/src/locales/sv.json | 1 - frontend/src/locales/tr.json | 1 - frontend/src/locales/uk.json | 1 - frontend/src/locales/zh.json | 1 - .../src/routes/collections/[id]/+page.svelte | 209 +--- 28 files changed, 12 insertions(+), 2020 deletions(-) delete mode 100644 backend/server/adventures/views/recommendations_view.py delete mode 100644 frontend/src/lib/components/CollectionRecommendationView.svelte diff --git a/backend/server/adventures/tests.py b/backend/server/adventures/tests.py index 3a736237..8a904673 100644 --- a/backend/server/adventures/tests.py +++ b/backend/server/adventures/tests.py @@ -124,28 +124,6 @@ class WeatherViewTests(APITestCase): 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() diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 0a751b60..8936923b 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -18,11 +18,6 @@ router.register(r"ics-calendar", IcsCalendarGeneratorViewSet, basename="ics-cale 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") diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 0244be8a..a0f04a5d 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -13,7 +13,6 @@ from .transportation_view import * from .global_search_view import * from .attachment_view import * from .lodging_view import * -from .recommendations_view import * from .import_export_view import * from .trail_view import * from .activity_view import * diff --git a/backend/server/adventures/views/recommendations_view.py b/backend/server/adventures/views/recommendations_view.py deleted file mode 100644 index 53917b58..00000000 --- a/backend/server/adventures/views/recommendations_view.py +++ /dev/null @@ -1,910 +0,0 @@ -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 -from django.conf import settings -import requests -from geopy.distance import geodesic -import logging -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"} - - # 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 - - 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): - """ - Calculate a quality score based on multiple factors. - Higher score = better quality recommendation. - """ - import math - - score = 0.0 - - # Rating contribution (0-50 points) - 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") - 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") - if distance_km is not None: - if distance_km < 1: - score += 20 - elif distance_km < 5: - score += 15 - elif distance_km < 10: - 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" - ): - score += 10 - - # Has photos bonus (0-5 points) - 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") - if opening_hours and len(opening_hours) > 0: - score += 5 - - return round(score, 2) - - def parse_google_places(self, places, origin): - """ - Parse Google Places API results into unified format. - Enhanced with quality filtering and comprehensive data extraction. - """ - locations = [] - - for place in places: - 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 - ) - - # Extract coordinates - 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) - - # Quality filter: Skip low-rated or unreviewed places - if rating and rating < self.MIN_GOOGLE_RATING: - continue - if review_count < self.MIN_GOOGLE_REVIEWS: - continue - - # Calculate distance - distance_km = geodesic(origin, (lat, lon)).km - - # Extract address information - formatted_address = place.get("formattedAddress") or place.get( - "shortFormattedAddress" - ) - - # Extract business status - 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") - - # Extract photos and construct URLs - photos = place.get("photos", []) - photo_urls = [] - if photos: - # Get first 5 photos and construct full URLs - for photo in photos[:5]: - photo_name = photo.get("name", "") - if photo_name: - 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") - - # Extract price level - 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 - ) - - # Filter out unwanted types (generic categories) - 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"), - "source": "google", - "name": name, - "description": description, - "latitude": lat, - "longitude": lon, - "address": formatted_address, - "distance_km": round(distance_km, 2), - "rating": rating, - "review_count": review_count, - "price_level": price_level, - "types": filtered_types, - "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, - "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) - - 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", []) - locations = [] - - for node in nodes: - if node.get("type") not in ["node", "way", "relation"]: - continue - - 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") - - # Extract name (with fallbacks) - 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 - - # Calculate distance - distance_km = round(geodesic(origin, (lat, lon)).km, 2) if origin else None - - # 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"), - ] - 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") - ) - - # Extract opening hours - opening_hours = tags.get("opening_hours") - - # Extract rating/stars (if available) - stars = tags.get("stars") - - # Determine category/type hierarchy - 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") - - # Extract image if available - 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"): - continue - - # Build unified response - place_data = { - "id": f"osm:{node.get('type')}:{node.get('id')}", - "external_id": str(node.get("id")), - "source": "osm", - "name": name, - "description": description, - "latitude": lat, - "longitude": lon, - "address": formatted_address, - "distance_km": distance_km, - "rating": None, # OSM doesn't have ratings - "review_count": None, - "price_level": None, - "types": types, - "primary_type": primary_type, - "business_status": None, - "is_open_now": None, - "opening_hours": [opening_hours] if opening_hours else None, - "phone_number": phone, - "website": website, - "google_maps_url": None, - "photos": [image] if image else [], - "is_verified": bool(wikipedia), # Has Wikipedia = more verified - "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) - - locations.append(place_data) - - return locations - - def query_overpass(self, lat, lon, radius, category, request): - """ - Query Overpass API (OpenStreetMap) for nearby places. - Enhanced with better queries and error handling. - """ - # 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": - query = f""" - [out:json][timeout:25]; - ( - nwr["tourism"~"attraction|viewpoint|museum|gallery|zoo|aquarium"](around:{osm_radius},{lat},{lon}); - nwr["historic"~"monument|castle|memorial"](around:{osm_radius},{lat},{lon}); - nwr["leisure"~"park|garden|nature_reserve"](around:{osm_radius},{lat},{lon}); - ); - out center tags 50; - """ - 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": - query = f""" - [out:json][timeout:25]; - nwr["amenity"~"restaurant|cafe|bar|pub"](around:{osm_radius},{lat},{lon}); - out center tags 50; - """ - else: - logger.error(f"Invalid category requested: {category}") - return {"error": "Invalid category.", "results": []} - - try: - response = requests.post( - 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": [], - } - 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": [], - } - logger.warning(f"Overpass API HTTP error: {e}") - 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": [], - } - 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} - - def query_google_nearby(self, lat, lon, radius, category, request): - """ - Query Google Places API (New) for nearby places. - Enhanced with comprehensive field masks and better error handling. - """ - 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" - ), - } - - # 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", - ], - } - - payload = { - "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), - } - }, - } - - try: - response = requests.post(url, json=payload, headers=headers, timeout=15) - response.raise_for_status() - data = response.json() - - 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}'" - ) - - 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) - except requests.exceptions.RequestException as e: - logger.warning(f"Google Places API error: {e}, falling back to OSM") - return self.query_overpass(lat, lon, radius, category, request) - except Exception as e: - logger.error(f"Unexpected error with Google Places API: {e}") - return self.query_overpass(lat, lon, radius, category, request) - - def _prepare_final_results(self, locations): - """ - 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) - - # Limit to 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 - ) - - 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"]), - ).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 - ): - is_duplicate = True - break - - if not is_duplicate: - deduplicated.append(osm_loc) - - return deduplicated - - @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 - - 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") - # 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() - - # 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 request_google_api_key: - try: - 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 - - # Fallback to OSM Nominatim - if not geocode_results: - try: - geocode_results = search_osm(location_param) - except Exception: - logger.warning("OSM geocoding failed") - geocode_results = None - - # Validate geocode results - 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, - ) - - if not geocode_results: - 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 - if isinstance(geocode_results, list) and len(geocode_results) > 0: - best = geocode_results[0] - elif isinstance(geocode_results, dict): - # Some helpers might return a dict when only one result found - best = geocode_results - - if not best: - return Response({"error": "No geocoding results found."}, status=400) - - try: - 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 - ) - - # Replace location_param with display name when available for logging/debug - 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, - ) - - 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 - ) - - valid_categories = ["lodging", "food", "tourism"] - if category not in valid_categories: - return Response( - { - "error": f"Invalid category. Valid categories: {', '.join(valid_categories)}" - }, - status=400, - ) - - valid_sources = ["google", "osm", "both"] - if sources not in valid_sources: - return Response( - { - "error": f"Invalid sources. Valid options: {', '.join(valid_sources)}" - }, - status=400, - ) - - api_key = self._get_google_api_key(request) - - google_results = [] - osm_results = [] - - # Query Google Places if available and requested - 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" - ), - } - - type_mapping = { - "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"]), - "maxResultCount": 20, - "rankPreference": "DISTANCE", - "locationRestriction": { - "circle": { - "center": {"latitude": lat, "longitude": lon}, - "radius": radius, - } - }, - } - - response = requests.post(url, json=payload, headers=headers, timeout=15) - response.raise_for_status() - data = response.json() - 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): - osm_response = self.query_overpass(lat, lon, radius, category, request) - 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: - 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), - "results": final_results, - "sources_used": { - "google": len(google_results), - "osm": 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: - # 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) - - @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/integrations/tests.py b/backend/server/integrations/tests.py index e4cbdda9..16abb80b 100644 --- a/backend/server/integrations/tests.py +++ b/backend/server/integrations/tests.py @@ -1,5 +1,3 @@ -from unittest.mock import patch - from django.contrib.auth import get_user_model from django.test import override_settings from rest_framework.test import APITestCase @@ -29,29 +27,6 @@ class UserAPIKeyConfigurationTests(APITestCase): 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() - class UserAPIKeyCreateBehaviorTests(APITestCase): @override_settings( diff --git a/frontend/src/lib/components/CollectionRecommendationView.svelte b/frontend/src/lib/components/CollectionRecommendationView.svelte deleted file mode 100644 index ceafc707..00000000 --- a/frontend/src/lib/components/CollectionRecommendationView.svelte +++ /dev/null @@ -1,800 +0,0 @@ - - - -{#if photoModalOpen} - -{/if} - -{#if showLocationModal} - -{/if} - -{#if showLodgingModal} - -{/if} - -
- -
-
-

- - {$t('recomendations.discover_places')} -

- - -
- - {#if locationsWithCoords.length > 0} -
- - -
- {/if} - - -
- - e.key === 'Enter' && searchRecommendations()} - /> -
- - -
- - -
- - -
- - -
-
- - -
- - -
- - - {#if showFilters} -
{$t('adventures.filter')}
-
-
- - -
- -
- - - -
- -
- -
-
- {/if} - - - {#if error} -
- - {error} -
- {/if} -
-
- - - {#if loading} -
- -
- {:else if filteredResults.length > 0} - -
-
-
{$t('recomendations.total_results')}
-
{filteredResults.length}
-
-
-
{$t('recomendations.average_rating')}
-
- {( - filteredResults.filter((r) => r.rating).reduce((sum, r) => sum + (r.rating || 0), 0) / - filteredResults.filter((r) => r.rating).length - ).toFixed(1)} - ⭐ -
-
-
-
{$t('recomendations.search_radius_label')}
-
{radiusDisplay}
-
-
- - -
-
-

📍 {$t('recomendations.map_view')}

-
- - - {#each collection.locations as location} - {#if location.latitude && location.longitude} - - -
- - {location.name} - -

- {$t('recomendations.your_location')} -

-
-
-
- {/if} - {/each} - - - {#each filteredResults as result} - - -
-

{result.name}

- {#if result.rating} -
-
- {#each renderStars(result.rating) as star} - {#if star.type === 'full'} - - {:else if star.type === 'half'} - - {:else} - - {/if} - {/each} -
- {result.rating.toFixed(1)} - {#if result.review_count} - - ({result.review_count}) - - {/if} -
- {/if} - {#if result.address} -

📍 {result.address}

- {/if} -

- 🚶 {formatDistance(result.distance_km)} - {$t('recomendations.away')} -

-
-
-
- {/each} -
-
-
-
- - -
- {#each filteredResults as result} -
- - {#if result.photos && result.photos.length > 0} -
- - {#if result.photos.length > 1} -
- 📷 {result.photos.length} -
- {/if} -
- {:else} -
- -
- {/if} - -
- -

- {result.name} - {#if result.is_open_now} - {$t('recomendations.open')} - {/if} -

- - - {#if result.rating} -
-
- {#each renderStars(result.rating) as star} - {#if star.type === 'full'} - - {:else if star.type === 'half'} - - {:else} - - {/if} - {/each} -
- {result.rating.toFixed(1)} - {#if result.review_count} - - - {result.review_count} - - {/if} - {#if result.quality_score} -
- Score: {result.quality_score} -
- {/if} -
- {/if} - - - {#if result.address} -

- - {result.address} -

- {/if} - - -
-
- 🚶 {formatDistance(result.distance_km)} -
- {#if result.price_level} -
- - {getPriceLevelDisplay(result.price_level)} -
- {/if} -
- {result.source === 'google' ? '🔍 Google' : '🗺️ OSM'} -
-
- - - {#if result.description} -

- {result.description} -

- {/if} - - - {#if result.opening_hours && result.opening_hours.length > 0} -
- -
- - {$t('recomendations.hours')} -
-
- {#each result.opening_hours as hours} -

{hours}

- {/each} -
-
- {/if} - - -
- {#if result.phone_number} - - - - {/if} - {#if result.website} - - - - {/if} - {#if result.google_maps_uri} - - View on Maps - - - {/if} - - - - -
-
-
- {/each} -
- {:else if !loading && results.length === 0 && !error} -
-
- -

{$t('recomendations.no_results_yet')}

-

{$t('recomendations.select_location_or_query')}

-
-
- {/if} -
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 60105d9a..483aa72c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -526,46 +526,6 @@ export type Pin = { category: Category | null; }; -export type Recommendation = { - id: string; - external_id: string; - source: 'google' | 'osm'; - name: string; - description: string | null; - latitude: number; - longitude: number; - address: string | null; - distance_km: number; - rating: number | null; - review_count: number | null; - price_level: string | null; - types: string[]; - primary_type: string | null; - business_status: string | null; - is_open_now: boolean | null; - opening_hours: string[] | null; - phone_number: string | null; - website: string | null; - google_maps_url: string | null; - photos: string[]; - is_verified: boolean; - quality_score: number; - // OSM-specific fields - osm_type?: string; - wikipedia?: string; - stars?: string; -}; - -export type RecommendationResponse = { - count: number; - results: Recommendation[]; - sources_used: { - google: number; - osm: number; - total_before_dedup: number; - }; -}; - export type ChatProviderCatalogEntry = { id: string; label: string; diff --git a/frontend/src/locales/ar.json b/frontend/src/locales/ar.json index 2de40753..731c458d 100644 --- a/frontend/src/locales/ar.json +++ b/frontend/src/locales/ar.json @@ -753,7 +753,6 @@ }, "recomendations": { "food": "طعام", - "recommendations": "التوصيات", "tourism": "السياحة", "any": "أي", "average_rating": "متوسط ​​التقييم", diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index bd746992..47694e84 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -988,7 +988,6 @@ "try_different_date": "Versuchen Sie ein anderes Datum" }, "recomendations": { - "recommendations": "Empfehlungen", "food": "Essen", "tourism": "Tourismus", "any": "Beliebig", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index d4774158..d04b2039 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -1085,7 +1085,6 @@ "google_maps_integration_desc_no_staff": "This integration must first be enabled by the admin on this server." }, "recomendations": { - "recommendations": "Recommendations", "food": "Food", "tourism": "Tourism", "discover_places": "Discover Places", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 1cb8fe5a..3582f94e 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -956,7 +956,6 @@ "try_different_date": "Prueba una fecha diferente" }, "recomendations": { - "recommendations": "Recomendaciones", "food": "Comida", "tourism": "Turismo", "any": "Cualquier", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index f343649e..2d2a03a5 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -956,7 +956,6 @@ "try_different_date": "Essayez une date différente" }, "recomendations": { - "recommendations": "Recommandations", "food": "Nourriture", "tourism": "Tourisme", "any": "N'importe lequel", diff --git a/frontend/src/locales/hu.json b/frontend/src/locales/hu.json index 5eaa5e22..4d1856bd 100644 --- a/frontend/src/locales/hu.json +++ b/frontend/src/locales/hu.json @@ -983,7 +983,6 @@ "google_maps_integration_desc_no_staff": "Ezt az integrációt először a szerver adminisztrátorának kell engedélyeznie." }, "recomendations": { - "recommendations": "Ajánlások", "food": "Étel", "tourism": "Turizmus", "any": "Bármilyen", diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index 73bb8f17..730f08d0 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -956,7 +956,6 @@ "try_different_date": "Prova una data diversa" }, "recomendations": { - "recommendations": "Raccomandazioni", "food": "Cibo", "tourism": "Turismo", "any": "Qualunque", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index 78d560e8..ef02d8cb 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -753,7 +753,6 @@ }, "recomendations": { "food": "食べ物", - "recommendations": "推奨事項", "tourism": "観光", "any": "どれでも", "average_rating": "平均評価", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index ad165977..ea423a12 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -692,7 +692,6 @@ "public_location_experiences": "공개 위치 경험" }, "recomendations": { - "recommendations": "권장 사항", "food": "음식", "tourism": "관광 여행", "any": "어느", diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index 2504f809..c6e1255e 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -956,7 +956,6 @@ "try_different_date": "Probeer een andere datum" }, "recomendations": { - "recommendations": "Aanbevelingen", "food": "Voedsel", "tourism": "Toerisme", "any": "Elk", diff --git a/frontend/src/locales/no.json b/frontend/src/locales/no.json index 8b602af5..ff0ad64a 100644 --- a/frontend/src/locales/no.json +++ b/frontend/src/locales/no.json @@ -979,7 +979,6 @@ "try_different_date": "Prøv en annen dato" }, "recomendations": { - "recommendations": "Anbefalinger", "food": "Mat", "tourism": "Turisme", "any": "Noen", diff --git a/frontend/src/locales/pl.json b/frontend/src/locales/pl.json index d169df87..c6393a88 100644 --- a/frontend/src/locales/pl.json +++ b/frontend/src/locales/pl.json @@ -956,7 +956,6 @@ "try_different_date": "Wypróbuj inną datę" }, "recomendations": { - "recommendations": "Zalecenia", "food": "Żywność", "tourism": "Turystyka", "any": "Każdy", diff --git a/frontend/src/locales/pt-br.json b/frontend/src/locales/pt-br.json index ea0fd5c1..0840d06d 100644 --- a/frontend/src/locales/pt-br.json +++ b/frontend/src/locales/pt-br.json @@ -753,7 +753,6 @@ }, "recomendations": { "food": "Comida", - "recommendations": "Recomendações", "tourism": "Turismo", "any": "Qualquer", "average_rating": "Avaliação média", diff --git a/frontend/src/locales/ro.json b/frontend/src/locales/ro.json index 82509300..af07ab82 100644 --- a/frontend/src/locales/ro.json +++ b/frontend/src/locales/ro.json @@ -845,7 +845,6 @@ "no_results_yet": "Încă nu există rezultate", "open": "Deschide", "open_now_only": "Deschide numai acum", - "recommendations": "Recomandări", "search_around_location": "Căutați în jurul locației", "search_by_address": "Căutați după adresă", "search_radius_label": "Raza de căutare:", diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index edf4c74a..d24333a2 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -983,7 +983,6 @@ "google_maps_integration_desc_no_staff": "Эта интеграция должна сначала быть включена администратором на этом сервере." }, "recomendations": { - "recommendations": "Рекомендации", "food": "Еда", "tourism": "Туризм", "any": "Любой", diff --git a/frontend/src/locales/sk.json b/frontend/src/locales/sk.json index 5f47e191..a3fc2a0d 100644 --- a/frontend/src/locales/sk.json +++ b/frontend/src/locales/sk.json @@ -983,7 +983,6 @@ "google_maps_integration_desc_no_staff": "Túto integráciu musí najprv povoliť administrátor na tomto serveri." }, "recomendations": { - "recommendations": "Odporúčania", "food": "Jedlo", "tourism": "Turizmus", "any": "Akékoľvek", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 16c1508b..0d00406d 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -956,7 +956,6 @@ "try_different_date": "Prova ett annat datum" }, "recomendations": { - "recommendations": "Rekommendationer", "food": "Mat", "tourism": "Turism", "any": "Några", diff --git a/frontend/src/locales/tr.json b/frontend/src/locales/tr.json index e810f01a..4f5fc5d7 100644 --- a/frontend/src/locales/tr.json +++ b/frontend/src/locales/tr.json @@ -986,7 +986,6 @@ "google_maps_integration_desc_no_staff": "Bu entegrasyon öncelikle bu sunucudaki yönetici tarafından etkinleştirilmelidir." }, "recomendations": { - "recommendations": "Önerilenler", "food": "Yemek", "tourism": "Turizm", "any": "Herhangi", diff --git a/frontend/src/locales/uk.json b/frontend/src/locales/uk.json index 88a2cf41..736f0135 100644 --- a/frontend/src/locales/uk.json +++ b/frontend/src/locales/uk.json @@ -753,7 +753,6 @@ }, "recomendations": { "food": "харчування", - "recommendations": "Рекомендації", "tourism": "Туризм", "any": "Будь-який", "average_rating": "Середній рейтинг", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index f8e922c8..94cd7ccf 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -956,7 +956,6 @@ "try_different_date": "尝试其他日期" }, "recomendations": { - "recommendations": "建议", "food": "食物", "tourism": "旅游", "average_rating": "平均评分", diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index d59a1e7f..30f85c67 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -1,12 +1,5 @@