From ce9faa28f860e583955afe1ac66bef4d66fb65d0 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sat, 24 May 2025 18:00:05 -0400 Subject: [PATCH] Refactor recommendations feature: add RecommendationsViewSet, update routing, and remove OverpassViewSet --- backend/server/adventures/urls.py | 3 +- backend/server/adventures/views/__init__.py | 4 +- .../server/adventures/views/overpass_view.py | 183 -------------- .../adventures/views/recommendations_view.py | 232 ++++++++++++++++++ backend/server/requirements.txt | 3 +- frontend/src/locales/en.json | 1 + .../src/routes/collections/[id]/+page.svelte | 64 ++--- 7 files changed, 260 insertions(+), 230 deletions(-) delete mode 100644 backend/server/adventures/views/overpass_view.py create mode 100644 backend/server/adventures/views/recommendations_view.py diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 1a98273..d1bf6cb 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -15,11 +15,10 @@ router.register(r'images', AdventureImageViewSet, 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'overpass', OverpassViewSet, basename='overpass') 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') urlpatterns = [ # Include the router under the 'api/' prefix diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 8f531f7..c9aedb0 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -7,10 +7,10 @@ from .collection_view import * from .generate_description_view import * from .ics_calendar_view import * from .note_view import * -from .overpass_view import * from .reverse_geocode_view import * from .stats_view import * from .transportation_view import * from .global_search_view import * from .attachment_view import * -from .lodging_view import * \ No newline at end of file +from .lodging_view import * +from .recommendations_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/overpass_view.py b/backend/server/adventures/views/overpass_view.py deleted file mode 100644 index a72b4a7..0000000 --- a/backend/server/adventures/views/overpass_view.py +++ /dev/null @@ -1,183 +0,0 @@ -from rest_framework import viewsets -from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -import requests - -class OverpassViewSet(viewsets.ViewSet): - permission_classes = [IsAuthenticated] - BASE_URL = "https://overpass-api.de/api/interpreter" - HEADERS = {'User-Agent': 'AdventureLog Server'} - - def make_overpass_query(self, query): - """ - Sends a query to the Overpass API and returns the response data. - Args: - query (str): The Overpass QL query string. - Returns: - dict: Parsed JSON response from the Overpass API. - Raises: - Response: DRF Response object with an error message in case of failure. - """ - url = f"{self.BASE_URL}?data={query}" - try: - response = requests.get(url, headers=self.HEADERS) - response.raise_for_status() # Raise an exception for HTTP errors - return response.json() - except requests.exceptions.RequestException: - return Response({"error": "Failed to connect to Overpass API"}, status=500) - except requests.exceptions.JSONDecodeError: - return Response({"error": "Invalid response from Overpass API"}, status=400) - - def parse_overpass_response(self, data, request): - """ - Parses the JSON response from the Overpass API and extracts relevant data, - turning it into an adventure-structured object. - - Args: - response (dict): The JSON response from the Overpass API. - - Returns: - list: A list of adventure objects with structured data. - """ - # Extract elements (nodes/ways/relations) from the response - nodes = data.get('elements', []) - adventures = [] - - # include all entries, even the ones that do not have lat long - all = request.query_params.get('all', False) - - for node in nodes: - # Ensure we are working with a "node" type (can also handle "way" or "relation" if needed) - if node.get('type') not in ['node', 'way', 'relation']: - continue - - # Extract tags and general data - tags = node.get('tags', {}) - adventure = { - "id": node.get('id'), # Include the unique OSM ID - "type": node.get('type'), # Type of element (node, way, relation) - "name": tags.get('name', tags.get('official_name', '')), # Fallback to 'official_name' - "description": tags.get('description', None), # Additional descriptive information - "latitude": node.get('lat', None), # Use None for consistency with missing values - "longitude": node.get('lon', None), - "address": { - "city": tags.get('addr:city', None), - "housenumber": tags.get('addr:housenumber', None), - "postcode": tags.get('addr:postcode', None), - "state": tags.get('addr:state', None), - "street": tags.get('addr:street', None), - "country": tags.get('addr:country', None), # Add 'country' if available - "suburb": tags.get('addr:suburb', None), # Add 'suburb' for more granularity - }, - "feature_id": tags.get('gnis:feature_id', None), - "tag": next((tags.get(key, None) for key in ['leisure', 'tourism', 'natural', 'historic', 'amenity'] if key in tags), None), - "contact": { - "phone": tags.get('phone', None), - "email": tags.get('contact:email', None), - "website": tags.get('website', None), - "facebook": tags.get('contact:facebook', None), # Social media links - "twitter": tags.get('contact:twitter', None), - }, - # "tags": tags, # Include all raw tags for future use - } - - # Filter out adventures with no name, latitude, or longitude - if (adventure["name"] and - adventure["latitude"] is not None and -90 <= adventure["latitude"] <= 90 and - adventure["longitude"] is not None and -180 <= adventure["longitude"] <= 180) or all: - adventures.append(adventure) - - return adventures - - - @action(detail=False, methods=['get']) - def query(self, request): - """ - Radius-based search for tourism-related locations around given coordinates. - """ - lat = request.query_params.get('lat') - lon = request.query_params.get('lon') - radius = request.query_params.get('radius', '1000') # Default radius: 1000 meters - - valid_categories = ['lodging', 'food', 'tourism'] - category = request.query_params.get('category', 'all') - if category not in valid_categories: - return Response({"error": f"Invalid category. Valid categories: {', '.join(valid_categories)}"}, status=400) - - if category == 'tourism': - query = f""" - [out:json]; - ( - node(around:{radius},{lat},{lon})["tourism"]; - node(around:{radius},{lat},{lon})["leisure"]; - node(around:{radius},{lat},{lon})["historic"]; - node(around:{radius},{lat},{lon})["sport"]; - node(around:{radius},{lat},{lon})["natural"]; - node(around:{radius},{lat},{lon})["attraction"]; - node(around:{radius},{lat},{lon})["museum"]; - node(around:{radius},{lat},{lon})["zoo"]; - node(around:{radius},{lat},{lon})["aquarium"]; - ); - out; - """ - if category == 'lodging': - query = f""" - [out:json]; - ( - node(around:{radius},{lat},{lon})["tourism"="hotel"]; - node(around:{radius},{lat},{lon})["tourism"="motel"]; - node(around:{radius},{lat},{lon})["tourism"="guest_house"]; - node(around:{radius},{lat},{lon})["tourism"="hostel"]; - node(around:{radius},{lat},{lon})["tourism"="camp_site"]; - node(around:{radius},{lat},{lon})["tourism"="caravan_site"]; - node(around:{radius},{lat},{lon})["tourism"="chalet"]; - node(around:{radius},{lat},{lon})["tourism"="alpine_hut"]; - node(around:{radius},{lat},{lon})["tourism"="apartment"]; - ); - out; - """ - if category == 'food': - query = f""" - [out:json]; - ( - node(around:{radius},{lat},{lon})["amenity"="restaurant"]; - node(around:{radius},{lat},{lon})["amenity"="cafe"]; - node(around:{radius},{lat},{lon})["amenity"="fast_food"]; - node(around:{radius},{lat},{lon})["amenity"="pub"]; - node(around:{radius},{lat},{lon})["amenity"="bar"]; - node(around:{radius},{lat},{lon})["amenity"="food_court"]; - node(around:{radius},{lat},{lon})["amenity"="ice_cream"]; - node(around:{radius},{lat},{lon})["amenity"="bakery"]; - node(around:{radius},{lat},{lon})["amenity"="confectionery"]; - ); - out; - """ - - # Validate required parameters - if not lat or not lon: - return Response( - {"error": "Latitude and longitude parameters are required."}, status=400 - ) - - data = self.make_overpass_query(query) - adventures = self.parse_overpass_response(data, request) - return Response(adventures) - - @action(detail=False, methods=['get'], url_path='search') - def search(self, request): - """ - Name-based search for nodes with the specified name. - """ - name = request.query_params.get('name') - - # Validate required parameter - if not name: - return Response({"error": "Name parameter is required."}, status=400) - - # Construct Overpass API query - query = f'[out:json];node["name"~"{name}",i];out;' - data = self.make_overpass_query(query) - - adventures = self.parse_overpass_response(data, request) - return Response(adventures) diff --git a/backend/server/adventures/views/recommendations_view.py b/backend/server/adventures/views/recommendations_view.py new file mode 100644 index 0000000..f850ca0 --- /dev/null +++ b/backend/server/adventures/views/recommendations_view.py @@ -0,0 +1,232 @@ +from rest_framework import 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 time + + +class RecommendationsViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + BASE_URL = "https://overpass-api.de/api/interpreter" + HEADERS = {'User-Agent': 'AdventureLog Server'} + + def parse_google_places(self, places, origin): + adventures = [] + + for place in places: + location = place.get('geometry', {}).get('location', {}) + types = place.get('types', []) + formatted_address = place.get("vicinity") or place.get("formatted_address") or place.get("name") + + lat = location.get('lat') + lon = location.get('lng') + + if not place.get("name") or not lat or not lon: + continue + + distance_km = geodesic(origin, (lat, lon)).km + + adventure = { + "id": place.get('place_id'), + "type": 'place', + "name": place.get('name', ''), + "description": place.get('business_status', None), + "latitude": lat, + "longitude": lon, + "address": formatted_address, + "tag": types[0] if types else None, + "distance_km": round(distance_km, 2), # Optional: include in response + } + + adventures.append(adventure) + + # Sort by distance ascending + adventures.sort(key=lambda x: x["distance_km"]) + + return adventures + + def parse_overpass_response(self, data, request): + nodes = data.get('elements', []) + adventures = [] + all = request.query_params.get('all', False) + + origin = None + try: + origin = ( + float(request.query_params.get('lat')), + float(request.query_params.get('lon')) + ) + except: + pass + + for node in nodes: + if node.get('type') not in ['node', 'way', 'relation']: + continue + + tags = node.get('tags', {}) + lat = node.get('lat') + lon = node.get('lon') + name = tags.get('name', tags.get('official_name', '')) + + if not name or lat is None or lon is None: + if not all: + continue + + # Flatten address + address_parts = [tags.get(f'addr:{k}') for k in ['housenumber', 'street', 'suburb', 'city', 'state', 'postcode', 'country']] + formatted_address = ", ".join(filter(None, address_parts)) or name + + # Calculate distance if possible + distance_km = None + if origin: + distance_km = round(geodesic(origin, (lat, lon)).km, 2) + + # Unified format + adventure = { + "id": f"osm:{node.get('id')}", + "type": "place", + "name": name, + "description": tags.get('description'), + "latitude": lat, + "longitude": lon, + "address": formatted_address, + "tag": next((tags.get(key) for key in ['leisure', 'tourism', 'natural', 'historic', 'amenity'] if key in tags), None), + "distance_km": distance_km, + "powered_by": "osm" + } + + adventures.append(adventure) + + # Sort by distance if available + if origin: + adventures.sort(key=lambda x: x.get("distance_km") or float("inf")) + + return adventures + + + def query_overpass(self, lat, lon, radius, category, request): + if category == 'tourism': + query = f""" + [out:json]; + ( + node(around:{radius},{lat},{lon})["tourism"]; + node(around:{radius},{lat},{lon})["leisure"]; + node(around:{radius},{lat},{lon})["historic"]; + node(around:{radius},{lat},{lon})["sport"]; + node(around:{radius},{lat},{lon})["natural"]; + node(around:{radius},{lat},{lon})["attraction"]; + node(around:{radius},{lat},{lon})["museum"]; + node(around:{radius},{lat},{lon})["zoo"]; + node(around:{radius},{lat},{lon})["aquarium"]; + ); + out; + """ + elif category == 'lodging': + query = f""" + [out:json]; + ( + node(around:{radius},{lat},{lon})["tourism"="hotel"]; + node(around:{radius},{lat},{lon})["tourism"="motel"]; + node(around:{radius},{lat},{lon})["tourism"="guest_house"]; + node(around:{radius},{lat},{lon})["tourism"="hostel"]; + node(around:{radius},{lat},{lon})["tourism"="camp_site"]; + node(around:{radius},{lat},{lon})["tourism"="caravan_site"]; + node(around:{radius},{lat},{lon})["tourism"="chalet"]; + node(around:{radius},{lat},{lon})["tourism"="alpine_hut"]; + node(around:{radius},{lat},{lon})["tourism"="apartment"]; + ); + out; + """ + elif category == 'food': + query = f""" + [out:json]; + ( + node(around:{radius},{lat},{lon})["amenity"="restaurant"]; + node(around:{radius},{lat},{lon})["amenity"="cafe"]; + node(around:{radius},{lat},{lon})["amenity"="fast_food"]; + node(around:{radius},{lat},{lon})["amenity"="pub"]; + node(around:{radius},{lat},{lon})["amenity"="bar"]; + node(around:{radius},{lat},{lon})["amenity"="food_court"]; + node(around:{radius},{lat},{lon})["amenity"="ice_cream"]; + node(around:{radius},{lat},{lon})["amenity"="bakery"]; + node(around:{radius},{lat},{lon})["amenity"="confectionery"]; + ); + out; + """ + else: + return Response({"error": "Invalid category."}, status=400) + + overpass_url = f"{self.BASE_URL}?data={query}" + try: + response = requests.get(overpass_url, headers=self.HEADERS) + response.raise_for_status() + data = response.json() + except Exception as e: + print("Overpass API error:", e) + return Response({"error": "Failed to retrieve data from Overpass API."}, status=500) + + adventures = self.parse_overpass_response(data, request) + return Response(adventures) + + + + @action(detail=False, methods=['get']) + def query(self, request): + lat = request.query_params.get('lat') + lon = request.query_params.get('lon') + radius = request.query_params.get('radius', '1000') + category = request.query_params.get('category', 'all') + + if not lat or not lon: + return Response({"error": "Latitude and longitude parameters are required."}, status=400) + + valid_categories = { + 'lodging': 'lodging', + 'food': 'restaurant', + 'tourism': 'tourist_attraction', + } + + if category not in valid_categories: + return Response({"error": f"Invalid category. Valid categories: {', '.join(valid_categories)}"}, status=400) + + api_key = getattr(settings, 'GOOGLE_MAPS_API_KEY', None) + + # Fallback to Overpass if no API key configured + if not api_key: + return self.query_overpass(lat, lon, radius, category, request) + + base_url = "https://maps.googleapis.com/maps/api/place/nearbysearch/json" + params = { + 'location': f"{lat},{lon}", + 'radius': radius, + 'type': valid_categories[category], + 'key': api_key + } + + all_places = [] + page_token = None + + try: + for _ in range(3): # Max 3 pages + if page_token: + params['pagetoken'] = page_token + time.sleep(2.5) + + response = requests.get(base_url, params=params) + response.raise_for_status() + data = response.json() + all_places.extend(data.get('results', [])) + page_token = data.get('next_page_token') + if not page_token: + break + + origin = (float(lat), float(lon)) + adventures = self.parse_google_places(all_places, origin) + return Response(adventures) + + except Exception as e: + print("Google Places API failed, falling back to Overpass:", e) + return self.query_overpass(lat, lon, radius, category, request) diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index c43f171..f81b3fe 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -21,4 +21,5 @@ icalendar==6.1.0 ijson==3.3.0 tqdm==4.67.1 overpy==0.7 -publicsuffix2==2.20191221 \ No newline at end of file +publicsuffix2==2.20191221 +geopy==2.4.1 \ No newline at end of file diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 029f7fe..68fbe48 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -120,6 +120,7 @@ "add_an_activity": "Add an activity", "show_region_labels": "Show Region Labels", "no_images": "No Images", + "distance": "Distance", "upload_images_here": "Upload images here", "share_adventure": "Share this Adventure!", "copy_link": "Copy Link", diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index 63ee0ce..a89557b 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -453,7 +453,7 @@ let recomendationsData: any; let loadingRecomendations: boolean = false; - let recomendationsRange: number = 1600; + let recomendationsRange: number = 1000; let recomendationType: string = 'tourism'; let recomendationTags: { name: string; display_name: string }[] = []; let selectedRecomendationTag: string = ''; @@ -475,7 +475,7 @@ selectedRecomendationTag = ''; loadingRecomendations = true; let res = await fetch( - `/api/overpass/query/?lat=${adventure.latitude}&lon=${adventure.longitude}&radius=${recomendationsRange}&category=${recomendationType}` + `/api/recommendations/query/?lat=${adventure.latitude}&lon=${adventure.longitude}&radius=${recomendationsRange}&category=${recomendationType}` ); if (!res.ok) { console.log('Error fetching recommendations'); @@ -1388,19 +1388,18 @@
- {Math.round(recomendationsRange / 1600)} mile ({( - (recomendationsRange / 1600) * - 1.6 - ).toFixed(1)} km) + + {(recomendationsRange / 1609.344).toFixed(1)} miles ({( + recomendationsRange / 1000 + ).toFixed(1)} km) +
{recomendation.name || $t('recomendations.recommendation')} + {#if recomendation.address} +

{recomendation.address}

+ {/if} + + {#if recomendation.distance_km} +

+ {$t('adventures.distance')}: {recomendation.distance_km.toFixed(1)} km ({( + recomendation.distance_km / 1.609344 + ).toFixed(1)} miles) +

+ {/if} +

{recomendation.tag}
- {#if recomendation.address && (recomendation.address.housenumber || recomendation.address.street || recomendation.address.city || recomendation.address.state || recomendation.address.postcode)} -

- {$t('recomendations.address')}: - {#if recomendation.address.housenumber}{recomendation.address - .housenumber}{/if} - {#if recomendation.address.street} - {recomendation.address.street}{/if} - {#if recomendation.address.city}, {recomendation.address.city}{/if} - {#if recomendation.address.state}, {recomendation.address.state}{/if} - {#if recomendation.address.postcode}, {recomendation.address.postcode}{/if} -

- {/if} - {#if recomendation.contact} -

- {$t('recomendations.contact')}: - {#if recomendation.contact.phone} - {$t('recomendations.phone')}: {recomendation.contact.phone} - {/if} - {#if recomendation.contact.email} - {$t('auth.email')}: {recomendation.contact.email} - {/if} - {#if recomendation.contact.website} - {$t('recomendations.website')}: - {recomendation.contact.website} - {/if} -

- {/if}