diff --git a/backend/Dockerfile b/backend/Dockerfile index eee89660..6de10676 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,7 +13,7 @@ WORKDIR /code # Install system dependencies RUN apt-get update \ - && apt-get install -y git postgresql-client \ + && apt-get install -y git postgresql-client gdal-bin libgdal-dev \ && apt-get clean # Install Python dependencies diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 26fc0452..269a5aab 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -13,6 +13,9 @@ done >&2 echo "PostgreSQL is up - continuing..." +# run sql commands +# psql -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" -f /app/backend/init-postgis.sql + # Apply Django migrations python manage.py migrate diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index c19b5ea9..4d9e788e 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -58,7 +58,7 @@ 'adventures', 'worldtravel', 'users', - # 'django_apscheduler', + 'django.contrib.gis', ) @@ -101,7 +101,7 @@ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.postgresql', + 'ENGINE': 'django.contrib.gis.db.backends.postgis', 'NAME': getenv('PGDATABASE'), 'USER': getenv('PGUSER'), 'PASSWORD': getenv('PGPASSWORD'), @@ -228,11 +228,9 @@ 'LOGOUT_URL': 'logout', } - # For demo purposes only. Use a white list in the real world. CORS_ORIGIN_ALLOW_ALL = True - from os import getenv CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()] @@ -261,6 +259,4 @@ 'propagate': False, }, }, -} - -SCHEDULER_AUTOSTART = True \ No newline at end of file +} \ No newline at end of file diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index 179ef7e9..41a47476 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -11,4 +11,4 @@ psycopg2-binary Pillow whitenoise django-resized -django-apscheduler \ No newline at end of file +django-geojson \ No newline at end of file diff --git a/backend/server/static/data/fr.json b/backend/server/static/data/fr.json index 405893a6..8574bd43 100644 --- a/backend/server/static/data/fr.json +++ b/backend/server/static/data/fr.json @@ -4,7 +4,7 @@ { "type": "Feature", "geometry": { - "type": "Polygon", + "type": "MultiPolygon", "coordinates": [ [ [1.9221462784913, 48.457599361977], diff --git a/backend/server/static/data/mx.json b/backend/server/static/data/mx.json index b11ba3cd..4342ced8 100644 --- a/backend/server/static/data/mx.json +++ b/backend/server/static/data/mx.json @@ -10,7 +10,7 @@ "ISOCODE": "MX-CMX" }, "geometry": { - "type": "Polygon", + "type": "MultiPolygon", "coordinates": [ [ [-99.111241, 19.561498], diff --git a/backend/server/static/data/us.json b/backend/server/static/data/us.json index 9480fd07..557f51df 100644 --- a/backend/server/static/data/us.json +++ b/backend/server/static/data/us.json @@ -16,7 +16,7 @@ "AWATER": 23736382213 }, "geometry": { - "type": "Polygon", + "type": "MultiPolygon", "coordinates": [ [ [-94.0430515276176, 32.6930299766656], diff --git a/backend/server/worldtravel/management/commands/worldtravel-seed.py b/backend/server/worldtravel/management/commands/worldtravel-seed.py index f317bda7..f7ec9a41 100644 --- a/backend/server/worldtravel/management/commands/worldtravel-seed.py +++ b/backend/server/worldtravel/management/commands/worldtravel-seed.py @@ -4,11 +4,66 @@ import requests from worldtravel.models import Country, Region from django.db import transaction +from django.contrib.gis.geos import GEOSGeometry, Polygon, MultiPolygon +from django.contrib.gis.geos.error import GEOSException +import json from django.conf import settings media_root = settings.MEDIA_ROOT + +def setGeometry(region_code): + # Assuming the file name is the country code (e.g., 'AU.json' for Australia) + country_code = region_code.split('-')[0] + json_file = os.path.join('static/data', f'{country_code.lower()}.json') + + if not os.path.exists(json_file): + print(f'File {country_code}.json does not exist (it probably hasn''t been added, contributors are welcome!)') + return None + + try: + with open(json_file, 'r') as f: + geojson_data = json.load(f) + except json.JSONDecodeError as e: + print(f"Invalid JSON in file for {country_code}: {e}") + return None + + if 'type' not in geojson_data or geojson_data['type'] != 'FeatureCollection': + print(f"Invalid GeoJSON structure for {country_code}: missing or incorrect 'type'") + return None + + if 'features' not in geojson_data or not geojson_data['features']: + print(f"Invalid GeoJSON structure for {country_code}: missing or empty 'features'") + return None + + for feature in geojson_data['features']: + try: + properties = feature.get('properties', {}) + isocode = properties.get('ISOCODE') + + if isocode == region_code: + geometry = feature['geometry'] + geos_geom = GEOSGeometry(json.dumps(geometry)) + + if isinstance(geos_geom, Polygon): + Region.objects.filter(id=region_code).update(geometry=MultiPolygon([geos_geom])) + print(f"Updated geometry for region {region_code}") + return MultiPolygon([geos_geom]) + elif isinstance(geos_geom, MultiPolygon): + Region.objects.filter(id=region_code).update(geometry=geos_geom) + print(f"Updated geometry for region {region_code}") + return geos_geom + else: + print(f"Unexpected geometry type for region {region_code}: {type(geos_geom)}") + return None + + except (KeyError, ValueError, GEOSException) as e: + print(f"Error processing region {region_code}: {e}") + + print(f"No matching region found for {region_code}") + return None + def saveCountryFlag(country_code): flags_dir = os.path.join(media_root, 'flags') @@ -616,7 +671,9 @@ def sync_regions(self, regions): ) if created: self.stdout.write(f'Inserted {name} into worldtravel regions') + setGeometry(id) else: + setGeometry(id) self.stdout.write(f'Updated {name} in worldtravel regions') def insert_countries(self, countries): @@ -627,6 +684,7 @@ def insert_countries(self, countries): ) if created: saveCountryFlag(country_code) + self.stdout.write(f'Inserted {name} into worldtravel countries') else: saveCountryFlag(country_code) @@ -641,5 +699,7 @@ def insert_regions(self, regions): ) if created: self.stdout.write(f'Inserted {name} into worldtravel regions') + setGeometry(id) else: + setGeometry(id) self.stdout.write(f'{name} already exists in worldtravel regions') \ No newline at end of file diff --git a/backend/server/worldtravel/migrations/0004_country_geometry.py b/backend/server/worldtravel/migrations/0004_country_geometry.py new file mode 100644 index 00000000..89c8f2da --- /dev/null +++ b/backend/server/worldtravel/migrations/0004_country_geometry.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.8 on 2024-08-23 17:01 + +import django.contrib.gis.db.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0003_alter_region_name_en'), + ] + + operations = [ + migrations.AddField( + model_name='country', + name='geometry', + field=django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=4326), + ), + ] diff --git a/backend/server/worldtravel/migrations/0005_remove_country_geometry_region_geometry.py b/backend/server/worldtravel/migrations/0005_remove_country_geometry_region_geometry.py new file mode 100644 index 00000000..7a32c719 --- /dev/null +++ b/backend/server/worldtravel/migrations/0005_remove_country_geometry_region_geometry.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.8 on 2024-08-23 17:47 + +import django.contrib.gis.db.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0004_country_geometry'), + ] + + operations = [ + migrations.RemoveField( + model_name='country', + name='geometry', + ), + migrations.AddField( + model_name='region', + name='geometry', + field=django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=4326), + ), + ] diff --git a/backend/server/worldtravel/models.py b/backend/server/worldtravel/models.py index 8f4aca07..de0d87fe 100644 --- a/backend/server/worldtravel/models.py +++ b/backend/server/worldtravel/models.py @@ -1,6 +1,7 @@ from django.db import models from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError +from django.contrib.gis.db import models as gis_models User = get_user_model() @@ -34,6 +35,7 @@ class Country(models.Model): choices=CONTINENT_CHOICES, default=AFRICA ) + class Meta: verbose_name = "Country" @@ -47,6 +49,7 @@ class Region(models.Model): name = models.CharField(max_length=100) name_en = models.CharField(max_length=100, blank=True, null=True) country = models.ForeignKey(Country, on_delete=models.CASCADE) + geometry = gis_models.MultiPolygonField(srid=4326, null=True, blank=True) def __str__(self): return self.name diff --git a/backend/server/worldtravel/serializers.py b/backend/server/worldtravel/serializers.py index 051a9b1f..6cb9c468 100644 --- a/backend/server/worldtravel/serializers.py +++ b/backend/server/worldtravel/serializers.py @@ -22,7 +22,7 @@ class RegionSerializer(serializers.ModelSerializer): class Meta: model = Region fields = '__all__' # Serialize all fields of the Adventure model - read_only_fields = ['id', 'name', 'country', 'name_en'] + read_only_fields = ['id', 'name', 'country', 'name_en', 'geometry'] class VisitedRegionSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py index 1db56740..1332b1ed 100644 --- a/backend/server/worldtravel/views.py +++ b/backend/server/worldtravel/views.py @@ -8,8 +8,12 @@ from rest_framework.decorators import api_view, permission_classes import os import json +from django.http import JsonResponse +from django.contrib.gis.geos import Point from django.conf import settings +from rest_framework.decorators import action from django.contrib.staticfiles import finders +from adventures.models import Adventure @api_view(['GET']) @permission_classes([IsAuthenticated]) @@ -34,6 +38,39 @@ class CountryViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = CountrySerializer permission_classes = [IsAuthenticated] + @action(detail=False, methods=['get']) + def check_point_in_region(self, request): + lat = float(request.query_params.get('lat')) + lon = float(request.query_params.get('lon')) + point = Point(lon, lat, srid=4326) + + region = Region.objects.filter(geometry__contains=point).first() + + if region: + return Response({'in_region': True, 'region_name': region.name, 'region_id': region.id}) + else: + return Response({'in_region': False}) + + # make a post action that will get all of the users adventures and check if the point is in any of the regions if so make a visited region object for that user if it does not already exist + @action(detail=False, methods=['post']) + def region_check_all_adventures(self, request): + adventures = Adventure.objects.filter(user_id=request.user.id, type='visited') + count = 0 + for adventure in adventures: + if adventure.latitude is not None and adventure.longitude is not None: + try: + print(f"Adventure {adventure.id}: lat={adventure.latitude}, lon={adventure.longitude}") + point = Point(float(adventure.longitude), float(adventure.latitude), srid=4326) + region = Region.objects.filter(geometry__contains=point).first() + if region: + if not VisitedRegion.objects.filter(user_id=request.user.id, region=region).exists(): + VisitedRegion.objects.create(user_id=request.user, region=region) + count += 1 + except Exception as e: + print(f"Error processing adventure {adventure.id}: {e}") + continue + return Response({'regions_visited': count}) + class RegionViewSet(viewsets.ReadOnlyModelViewSet): queryset = Region.objects.all() serializer_class = RegionSerializer diff --git a/backup.sh b/backup.sh index d85a5f2a..69d5b120 100644 --- a/backup.sh +++ b/backup.sh @@ -1,3 +1,5 @@ +# This script will create a backup of the adventurelog_media volume and store it in the current directory as adventurelog-backup.tar.gz + docker run --rm \ -v adventurelog_adventurelog_media:/backup-volume \ -v "$(pwd)":/backup \ diff --git a/deploy.sh b/deploy.sh index 4844fb77..9548c7c2 100644 --- a/deploy.sh +++ b/deploy.sh @@ -1,3 +1,5 @@ +# This script is used to deploy the latest version of AdventureLog to the server. It pulls the latest version of the Docker images and starts the containers. It is a simple script that can be run on the server, possibly as a cron job, to keep the server up to date with the latest version of the application. + echo "Deploying latest version of AdventureLog" docker compose pull echo "Stating containers" diff --git a/docker-compose.yml b/docker-compose.yml index 871e5d35..287a0076 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: web: #build: ./frontend/ image: ghcr.io/seanmorley15/adventurelog-frontend:latest + container_name: adventurelog-frontend environment: - PUBLIC_SERVER_URL=http://server:8000 # MOST DOCKER USERS WILL NEVER NEED TO CHANGE THIS, EVEN IF YOU CHANGE THE PORTS - ORIGIN=http://localhost:8080 @@ -14,7 +15,8 @@ services: - server db: - image: postgres:latest + image: postgis/postgis:15-3.3 + container_name: adventurelog-db environment: POSTGRES_DB: database POSTGRES_USER: adventure @@ -25,6 +27,7 @@ services: server: #build: ./backend/ image: ghcr.io/seanmorley15/adventurelog-backend:latest + container_name: adventurelog-backend environment: - PGHOST=db - PGDATABASE=database @@ -47,6 +50,7 @@ services: nginx: image: nginx:latest + container_name: adventurelog-nginx ports: - "81:80" volumes: diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index ae4f0ddd..c2e6e036 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -13,7 +13,7 @@ export let is_collection: boolean = false; import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre'; - let markers: Point[] = []; + let query: string = ''; let places: OpenStreetMapPlace[] = []; let images: { id: string; image: string }[] = []; @@ -29,6 +29,9 @@ let noPlaces: boolean = false; + let region_name: string | null = null; + let region_id: string | null = null; + let adventure: Adventure = { id: '', name: '', @@ -69,6 +72,8 @@ collection: adventureToEdit?.collection || collection_id || null }; + let markers: Point[] = []; + let url: string = ''; let imageError: string = ''; let wikiImageError: string = ''; @@ -76,6 +81,7 @@ images = adventure.images || []; if (adventure.longitude && adventure.latitude) { + markers = []; markers = [ { lngLat: { lng: adventure.longitude, lat: adventure.latitude }, @@ -84,6 +90,7 @@ activity_type: '' } ]; + checkPointInRegion(); } if (longitude && latitude) { @@ -98,6 +105,13 @@ } } + function clearMap() { + console.log('CLEAR'); + markers = []; + region_id = null; + region_name = null; + } + let imageSearch: string = adventure.name || ''; async function removeImage(id: string) { @@ -132,6 +146,13 @@ } } + $: { + if (adventure.type != 'visited') { + region_id = null; + region_name = null; + } + } + async function fetchImage() { let res = await fetch(url); let data = await res.blob(); @@ -237,6 +258,7 @@ activity_type: data[0]?.type || '' } ]; + checkPointInRegion(); } } console.log(data); @@ -274,7 +296,30 @@ } } - function addMarker(e: CustomEvent) { + async function checkPointInRegion() { + if (adventure.type == 'visited') { + let lat = markers[0].lngLat.lat; + let lon = markers[0].lngLat.lng; + let res = await fetch(`/api/countries/check_point_in_region/?lat=${lat}&lon=${lon}`); + let data = await res.json(); + if (data.error) { + addToast('error', data.error); + } else { + if (data.in_region) { + region_name = data.region_name; + region_id = data.region_id; + } else { + region_id = null; + region_name = null; + } + } + } else { + region_id = null; + region_name = null; + } + } + + async function addMarker(e: CustomEvent) { markers = []; markers = [ ...markers, @@ -285,6 +330,8 @@ activity_type: '' } ]; + checkPointInRegion(); + console.log(markers); } @@ -308,6 +355,19 @@ async function handleSubmit(event: Event) { event.preventDefault(); + if (region_id && region_name) { + let res = await fetch(`/api/visitedregion/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ region: region_id }) + }); + if (res.ok) { + addToast('success', `Region ${region_name} marked as visited`); + } + } + if (adventure.date && adventure.end_date) { if (new Date(adventure.date) > new Date(adventure.end_date)) { addToast('error', 'Start date must be before end date'); @@ -608,6 +668,9 @@ bind:value={query} /> + {#if places.length > 0} @@ -628,6 +691,7 @@ activity_type: place.type } ]; + checkPointInRegion(); }} > {place.display_name} @@ -655,6 +719,9 @@ it would also work to just use on:click on the MapLibre component itself. --> {/each} + {#if region_name} +

Region: {region_name} ({region_id})

+ {/if}
diff --git a/frontend/src/routes/map/+page.svelte b/frontend/src/routes/map/+page.svelte index 1a0f484c..a1571983 100644 --- a/frontend/src/routes/map/+page.svelte +++ b/frontend/src/routes/map/+page.svelte @@ -19,21 +19,10 @@ let showVisited = true; let showPlanned = true; - $: { - if (!showVisited) { - markers = data.props.markers.filter((marker) => marker.type !== 'visited'); - } else { - const visitedMarkers = data.props.markers.filter((marker) => marker.type === 'visited'); - markers = [...markers, ...visitedMarkers]; - } - if (!showPlanned) { - markers = data.props.markers.filter((marker) => marker.type !== 'planned'); - } else { - const plannedMarkers = data.props.markers.filter((marker) => marker.type === 'planned'); - markers = [...markers, ...plannedMarkers]; - } - console.log(markers); - } + $: filteredMarkers = markers.filter( + (marker) => + (showVisited && marker.type === 'visited') || (showPlanned && marker.type === 'planned') + ); let newMarker = []; @@ -43,7 +32,6 @@ function addMarker(e) { newMarker = []; newMarker = [...newMarker, { lngLat: e.detail.lngLat, name: 'Marker 1' }]; - console.log(newMarker); newLongitude = e.detail.lngLat.lng; newLatitude = e.detail.lngLat.lat; } @@ -55,19 +43,15 @@ } function createNewAdventure(event) { - console.log(event.detail); - let newMarker = { lngLat: [event.detail.longitude, event.detail.latitude], name: event.detail.name, - type: 'planned' + type: event.detail.type }; markers = [...markers, newMarker]; clearMarkers(); - console.log(markers); createModalOpen = false; } - let visitedRegions = data.props.visitedRegions; let geoJSON = []; @@ -154,7 +138,7 @@ class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full" standardControls > - {#each markers as { lngLat, name, type }} + {#each filteredMarkers as { lngLat, name, type }} {#if type == 'visited'}

Settings Page

@@ -152,7 +167,20 @@
-
+ +
+

Visited Region Check

+

+ By selecting this, the server will check all of your visited adventures and mark the regions + they are located in as "visited" in world travel. +

+ +

This may take longer depending on the number of adventures you have.

+
+ +

Data Export

This may take a few seconds...