From d52e302e9b05c48c5e4c0edc78bf673e2e22b165 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Thu, 22 May 2025 20:05:13 -0400 Subject: [PATCH] Add geocoding functionality and enhance Adventure model with location fields --- backend/server/adventures/admin.py | 1 + backend/server/adventures/geocoding.py | 71 ++++++++++++++++++ ...city_adventure_country_adventure_region.py | 30 ++++++++ backend/server/adventures/models.py | 25 +++++++ backend/server/adventures/serializers.py | 2 +- .../adventures/views/reverse_geocode_view.py | 73 +++---------------- backend/server/main/settings.py | 2 +- backend/server/requirements.txt | 2 +- 8 files changed, 141 insertions(+), 65 deletions(-) create mode 100644 backend/server/adventures/geocoding.py create mode 100644 backend/server/adventures/migrations/0029_adventure_city_adventure_country_adventure_region.py diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 3210f7a..5add8c9 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -12,6 +12,7 @@ class AdventureAdmin(admin.ModelAdmin): list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public') list_filter = ( 'user_id', 'is_public') search_fields = ('name',) + readonly_fields = ('city', 'region', 'country') def get_category(self, obj): if obj.category and obj.category.display_name and obj.category.icon: diff --git a/backend/server/adventures/geocoding.py b/backend/server/adventures/geocoding.py new file mode 100644 index 0000000..31ba13f --- /dev/null +++ b/backend/server/adventures/geocoding.py @@ -0,0 +1,71 @@ +import requests +from worldtravel.models import Region, City, VisitedRegion, VisitedCity + +def extractIsoCode(user, data): + """ + Extract the ISO code from the response data. + Returns a dictionary containing the region name, country name, and ISO code if found. + """ + iso_code = None + town_city_or_county = None + display_name = None + country_code = None + city = None + visited_city = None + location_name = None + + # town = None + # city = None + # county = None + + if 'name' in data.keys(): + location_name = data['name'] + + if 'address' in data.keys(): + keys = data['address'].keys() + for key in keys: + if key.find("ISO") != -1: + iso_code = data['address'][key] + if 'town' in keys: + town_city_or_county = data['address']['town'] + if 'county' in keys: + town_city_or_county = data['address']['county'] + if 'city' in keys: + town_city_or_county = data['address']['city'] + if not iso_code: + return {"error": "No region found"} + + region = Region.objects.filter(id=iso_code).first() + visited_region = VisitedRegion.objects.filter(region=region, user_id=user).first() + + region_visited = False + city_visited = False + country_code = iso_code[:2] + + if region: + if town_city_or_county: + display_name = f"{town_city_or_county}, {region.name}, {country_code}" + city = City.objects.filter(name__contains=town_city_or_county, region=region).first() + visited_city = VisitedCity.objects.filter(city=city, user_id=user).first() + + if visited_region: + region_visited = True + if visited_city: + city_visited = True + if region: + return {"region_id": iso_code, "region": region.name, "country": region.country.name, "country_id": region.country.country_code, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited, 'location_name': location_name} + return {"error": "No region found"} + +def reverse_geocode(lat, lon, user): + """ + Reverse geocode the given latitude and longitude using Nominatim API. + Returns a dictionary containing the region name, country name, and ISO code if found. + """ + url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}" + headers = {'User-Agent': 'AdventureLog Server'} + response = requests.get(url, headers=headers) + try: + data = response.json() + except requests.exceptions.JSONDecodeError: + return {"error": "Invalid response from geocoding service"} + return extractIsoCode(user, data) diff --git a/backend/server/adventures/migrations/0029_adventure_city_adventure_country_adventure_region.py b/backend/server/adventures/migrations/0029_adventure_city_adventure_country_adventure_region.py new file mode 100644 index 0000000..3df78e0 --- /dev/null +++ b/backend/server/adventures/migrations/0029_adventure_city_adventure_country_adventure_region.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.11 on 2025-05-22 22:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0028_lodging_timezone'), + ('worldtravel', '0015_city_insert_id_country_insert_id_region_insert_id'), + ] + + operations = [ + migrations.AddField( + model_name='adventure', + name='city', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='worldtravel.city'), + ), + migrations.AddField( + model_name='adventure', + name='country', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='worldtravel.country'), + ), + migrations.AddField( + model_name='adventure', + name='region', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='worldtravel.region'), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 800677a..dafd655 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -9,6 +9,8 @@ from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField from django.forms import ValidationError from django_resized import ResizedImageField +from worldtravel.models import City, Country, Region +from adventures.geocoding import reverse_geocode def validate_file_extension(value): import os @@ -525,9 +527,16 @@ class Adventure(models.Model): rating = models.FloatField(blank=True, null=True) link = models.URLField(blank=True, null=True, max_length=2083) is_public = models.BooleanField(default=False) + longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + + city = models.ForeignKey(City, on_delete=models.SET_NULL, blank=True, null=True) + region = models.ForeignKey(Region, on_delete=models.SET_NULL, blank=True, null=True) + country = models.ForeignKey(Country, on_delete=models.SET_NULL, blank=True, null=True) + collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -567,6 +576,22 @@ class Adventure(models.Model): } ) self.category = category + + if self.latitude and self.longitude: + reverse_geocode_result = reverse_geocode(self.latitude, self.longitude, self.user_id) + print(reverse_geocode_result) + if 'region_id' in reverse_geocode_result: + region = Region.objects.filter(id=reverse_geocode_result['region_id']).first() + if region: + self.region = region + if 'city_id' in reverse_geocode_result: + city = City.objects.filter(id=reverse_geocode_result['city_id']).first() + if city: + self.city = city + if 'country_id' in reverse_geocode_result: + country = Country.objects.filter(country_code=reverse_geocode_result['country_id']).first() + if country: + self.country = country return super().save(force_insert, force_update, using, update_fields) diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index b352c65..8e8b73d 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -88,7 +88,7 @@ class AdventureSerializer(CustomModelSerializer): fields = [ 'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location', 'is_public', 'collection', 'created_at', 'updated_at', 'images', 'link', 'longitude', - 'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user' + 'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user', 'city', 'country', 'region' ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited', 'user'] diff --git a/backend/server/adventures/views/reverse_geocode_view.py b/backend/server/adventures/views/reverse_geocode_view.py index 4dc1d6b..e7c4562 100644 --- a/backend/server/adventures/views/reverse_geocode_view.py +++ b/backend/server/adventures/views/reverse_geocode_view.py @@ -6,77 +6,26 @@ from worldtravel.models import Region, City, VisitedRegion, VisitedCity from adventures.models import Adventure from adventures.serializers import AdventureSerializer import requests +from adventures.geocoding import reverse_geocode class ReverseGeocodeViewSet(viewsets.ViewSet): permission_classes = [IsAuthenticated] - def extractIsoCode(self, data): - """ - Extract the ISO code from the response data. - Returns a dictionary containing the region name, country name, and ISO code if found. - """ - iso_code = None - town_city_or_county = None - display_name = None - country_code = None - city = None - visited_city = None - location_name = None - - # town = None - # city = None - # county = None - - if 'name' in data.keys(): - location_name = data['name'] - - if 'address' in data.keys(): - keys = data['address'].keys() - for key in keys: - if key.find("ISO") != -1: - iso_code = data['address'][key] - if 'town' in keys: - town_city_or_county = data['address']['town'] - if 'county' in keys: - town_city_or_county = data['address']['county'] - if 'city' in keys: - town_city_or_county = data['address']['city'] - if not iso_code: - return {"error": "No region found"} - - region = Region.objects.filter(id=iso_code).first() - visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first() - - region_visited = False - city_visited = False - country_code = iso_code[:2] - - if region: - if town_city_or_county: - display_name = f"{town_city_or_county}, {region.name}, {country_code}" - city = City.objects.filter(name__contains=town_city_or_county, region=region).first() - visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first() - - if visited_region: - region_visited = True - if visited_city: - city_visited = True - if region: - return {"region_id": iso_code, "region": region.name, "country": region.country.name, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited, 'location_name': location_name} - return {"error": "No region found"} - @action(detail=False, methods=['get']) def reverse_geocode(self, request): lat = request.query_params.get('lat', '') lon = request.query_params.get('lon', '') - url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}" - headers = {'User-Agent': 'AdventureLog Server'} - response = requests.get(url, headers=headers) + if not lat or not lon: + return Response({"error": "Latitude and longitude are required"}, status=400) try: - data = response.json() - except requests.exceptions.JSONDecodeError: - return Response({"error": "Invalid response from geocoding service"}, status=400) - return Response(self.extractIsoCode(data)) + lat = float(lat) + lon = float(lon) + except ValueError: + return Response({"error": "Invalid latitude or longitude"}, status=400) + data = reverse_geocode(lat, lon, self.request.user) + if 'error' in data: + return Response(data, status=400) + return Response(data) @action(detail=False, methods=['post']) def mark_visited_region(self, request): diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index e5d24f8..5c00cae 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -313,4 +313,4 @@ LOGGING = { # ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app') # https://github.com/dr5hn/countries-states-cities-database/tags -COUNTRY_REGION_JSON_VERSION = 'v2.5' \ No newline at end of file +COUNTRY_REGION_JSON_VERSION = 'v2.6' \ No newline at end of file diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index dcd0125..c43f171 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -1,4 +1,4 @@ -Django==5.0.11 +Django==5.2.1 djangorestframework>=3.15.2 django-allauth==0.63.3 drf-yasg==1.21.4