1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 04:49:37 +02:00

Merge pull request #275 from seanmorley15/development

Merge for v0.6.0 update
This commit is contained in:
Sean Morley 2024-08-24 16:30:22 -04:00 committed by GitHub
commit 030cacf4b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 267 additions and 39 deletions

View file

@ -13,7 +13,7 @@ WORKDIR /code
# Install system dependencies # Install system dependencies
RUN apt-get update \ 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 && apt-get clean
# Install Python dependencies # Install Python dependencies

View file

@ -13,6 +13,9 @@ done
>&2 echo "PostgreSQL is up - continuing..." >&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 # Apply Django migrations
python manage.py migrate python manage.py migrate

View file

@ -58,7 +58,7 @@ INSTALLED_APPS = (
'adventures', 'adventures',
'worldtravel', 'worldtravel',
'users', 'users',
# 'django_apscheduler', 'django.contrib.gis',
) )
@ -101,7 +101,7 @@ SIMPLE_JWT = {
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.postgresql', 'ENGINE': 'django.contrib.gis.db.backends.postgis',
'NAME': getenv('PGDATABASE'), 'NAME': getenv('PGDATABASE'),
'USER': getenv('PGUSER'), 'USER': getenv('PGUSER'),
'PASSWORD': getenv('PGPASSWORD'), 'PASSWORD': getenv('PGPASSWORD'),
@ -228,11 +228,9 @@ SWAGGER_SETTINGS = {
'LOGOUT_URL': 'logout', 'LOGOUT_URL': 'logout',
} }
# For demo purposes only. Use a white list in the real world. # For demo purposes only. Use a white list in the real world.
CORS_ORIGIN_ALLOW_ALL = True CORS_ORIGIN_ALLOW_ALL = True
from os import getenv from os import getenv
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()] CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]
@ -261,6 +259,4 @@ LOGGING = {
'propagate': False, 'propagate': False,
}, },
}, },
} }
SCHEDULER_AUTOSTART = True

View file

@ -11,4 +11,4 @@ psycopg2-binary
Pillow Pillow
whitenoise whitenoise
django-resized django-resized
django-apscheduler django-geojson

View file

@ -4,7 +4,7 @@
{ {
"type": "Feature", "type": "Feature",
"geometry": { "geometry": {
"type": "Polygon", "type": "MultiPolygon",
"coordinates": [ "coordinates": [
[ [
[1.9221462784913, 48.457599361977], [1.9221462784913, 48.457599361977],

View file

@ -10,7 +10,7 @@
"ISOCODE": "MX-CMX" "ISOCODE": "MX-CMX"
}, },
"geometry": { "geometry": {
"type": "Polygon", "type": "MultiPolygon",
"coordinates": [ "coordinates": [
[ [
[-99.111241, 19.561498], [-99.111241, 19.561498],

View file

@ -16,7 +16,7 @@
"AWATER": 23736382213 "AWATER": 23736382213
}, },
"geometry": { "geometry": {
"type": "Polygon", "type": "MultiPolygon",
"coordinates": [ "coordinates": [
[ [
[-94.0430515276176, 32.6930299766656], [-94.0430515276176, 32.6930299766656],

View file

@ -4,11 +4,66 @@ from django.contrib.auth import get_user_model
import requests import requests
from worldtravel.models import Country, Region from worldtravel.models import Country, Region
from django.db import transaction 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 from django.conf import settings
media_root = settings.MEDIA_ROOT 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): def saveCountryFlag(country_code):
flags_dir = os.path.join(media_root, 'flags') flags_dir = os.path.join(media_root, 'flags')
@ -616,7 +671,9 @@ class Command(BaseCommand):
) )
if created: if created:
self.stdout.write(f'Inserted {name} into worldtravel regions') self.stdout.write(f'Inserted {name} into worldtravel regions')
setGeometry(id)
else: else:
setGeometry(id)
self.stdout.write(f'Updated {name} in worldtravel regions') self.stdout.write(f'Updated {name} in worldtravel regions')
def insert_countries(self, countries): def insert_countries(self, countries):
@ -627,6 +684,7 @@ class Command(BaseCommand):
) )
if created: if created:
saveCountryFlag(country_code) saveCountryFlag(country_code)
self.stdout.write(f'Inserted {name} into worldtravel countries') self.stdout.write(f'Inserted {name} into worldtravel countries')
else: else:
saveCountryFlag(country_code) saveCountryFlag(country_code)
@ -641,5 +699,7 @@ class Command(BaseCommand):
) )
if created: if created:
self.stdout.write(f'Inserted {name} into worldtravel regions') self.stdout.write(f'Inserted {name} into worldtravel regions')
setGeometry(id)
else: else:
setGeometry(id)
self.stdout.write(f'{name} already exists in worldtravel regions') self.stdout.write(f'{name} already exists in worldtravel regions')

View file

@ -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),
),
]

View file

@ -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),
),
]

View file

@ -1,6 +1,7 @@
from django.db import models from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.gis.db import models as gis_models
User = get_user_model() User = get_user_model()
@ -34,6 +35,7 @@ class Country(models.Model):
choices=CONTINENT_CHOICES, choices=CONTINENT_CHOICES,
default=AFRICA default=AFRICA
) )
class Meta: class Meta:
verbose_name = "Country" verbose_name = "Country"
@ -47,6 +49,7 @@ class Region(models.Model):
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
name_en = models.CharField(max_length=100, blank=True, null=True) name_en = models.CharField(max_length=100, blank=True, null=True)
country = models.ForeignKey(Country, on_delete=models.CASCADE) country = models.ForeignKey(Country, on_delete=models.CASCADE)
geometry = gis_models.MultiPolygonField(srid=4326, null=True, blank=True)
def __str__(self): def __str__(self):
return self.name return self.name

View file

@ -22,7 +22,7 @@ class RegionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Region model = Region
fields = '__all__' # Serialize all fields of the Adventure model 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 VisitedRegionSerializer(serializers.ModelSerializer):
class Meta: class Meta:

View file

@ -8,8 +8,12 @@ from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
import os import os
import json import json
from django.http import JsonResponse
from django.contrib.gis.geos import Point
from django.conf import settings from django.conf import settings
from rest_framework.decorators import action
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from adventures.models import Adventure
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@ -34,6 +38,39 @@ class CountryViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CountrySerializer serializer_class = CountrySerializer
permission_classes = [IsAuthenticated] 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): class RegionViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Region.objects.all() queryset = Region.objects.all()
serializer_class = RegionSerializer serializer_class = RegionSerializer

View file

@ -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 \ docker run --rm \
-v adventurelog_adventurelog_media:/backup-volume \ -v adventurelog_adventurelog_media:/backup-volume \
-v "$(pwd)":/backup \ -v "$(pwd)":/backup \

View file

@ -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" echo "Deploying latest version of AdventureLog"
docker compose pull docker compose pull
echo "Stating containers" echo "Stating containers"

View file

@ -4,6 +4,7 @@ services:
web: web:
#build: ./frontend/ #build: ./frontend/
image: ghcr.io/seanmorley15/adventurelog-frontend:latest image: ghcr.io/seanmorley15/adventurelog-frontend:latest
container_name: adventurelog-frontend
environment: environment:
- PUBLIC_SERVER_URL=http://server:8000 # MOST DOCKER USERS WILL NEVER NEED TO CHANGE THIS, EVEN IF YOU CHANGE THE PORTS - 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 - ORIGIN=http://localhost:8080
@ -14,7 +15,8 @@ services:
- server - server
db: db:
image: postgres:latest image: postgis/postgis:15-3.3
container_name: adventurelog-db
environment: environment:
POSTGRES_DB: database POSTGRES_DB: database
POSTGRES_USER: adventure POSTGRES_USER: adventure
@ -25,6 +27,7 @@ services:
server: server:
#build: ./backend/ #build: ./backend/
image: ghcr.io/seanmorley15/adventurelog-backend:latest image: ghcr.io/seanmorley15/adventurelog-backend:latest
container_name: adventurelog-backend
environment: environment:
- PGHOST=db - PGHOST=db
- PGDATABASE=database - PGDATABASE=database
@ -47,6 +50,7 @@ services:
nginx: nginx:
image: nginx:latest image: nginx:latest
container_name: adventurelog-nginx
ports: ports:
- "81:80" - "81:80"
volumes: volumes:

View file

@ -13,7 +13,7 @@
export let is_collection: boolean = false; export let is_collection: boolean = false;
import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre'; import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre';
let markers: Point[] = [];
let query: string = ''; let query: string = '';
let places: OpenStreetMapPlace[] = []; let places: OpenStreetMapPlace[] = [];
let images: { id: string; image: string }[] = []; let images: { id: string; image: string }[] = [];
@ -29,6 +29,9 @@
let noPlaces: boolean = false; let noPlaces: boolean = false;
let region_name: string | null = null;
let region_id: string | null = null;
let adventure: Adventure = { let adventure: Adventure = {
id: '', id: '',
name: '', name: '',
@ -69,6 +72,8 @@
collection: adventureToEdit?.collection || collection_id || null collection: adventureToEdit?.collection || collection_id || null
}; };
let markers: Point[] = [];
let url: string = ''; let url: string = '';
let imageError: string = ''; let imageError: string = '';
let wikiImageError: string = ''; let wikiImageError: string = '';
@ -76,6 +81,7 @@
images = adventure.images || []; images = adventure.images || [];
if (adventure.longitude && adventure.latitude) { if (adventure.longitude && adventure.latitude) {
markers = [];
markers = [ markers = [
{ {
lngLat: { lng: adventure.longitude, lat: adventure.latitude }, lngLat: { lng: adventure.longitude, lat: adventure.latitude },
@ -84,6 +90,7 @@
activity_type: '' activity_type: ''
} }
]; ];
checkPointInRegion();
} }
if (longitude && latitude) { if (longitude && latitude) {
@ -98,6 +105,13 @@
} }
} }
function clearMap() {
console.log('CLEAR');
markers = [];
region_id = null;
region_name = null;
}
let imageSearch: string = adventure.name || ''; let imageSearch: string = adventure.name || '';
async function removeImage(id: string) { async function removeImage(id: string) {
@ -132,6 +146,13 @@
} }
} }
$: {
if (adventure.type != 'visited') {
region_id = null;
region_name = null;
}
}
async function fetchImage() { async function fetchImage() {
let res = await fetch(url); let res = await fetch(url);
let data = await res.blob(); let data = await res.blob();
@ -237,6 +258,7 @@
activity_type: data[0]?.type || '' activity_type: data[0]?.type || ''
} }
]; ];
checkPointInRegion();
} }
} }
console.log(data); console.log(data);
@ -274,7 +296,30 @@
} }
} }
function addMarker(e: CustomEvent<any>) { 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<any>) {
markers = []; markers = [];
markers = [ markers = [
...markers, ...markers,
@ -285,6 +330,8 @@
activity_type: '' activity_type: ''
} }
]; ];
checkPointInRegion();
console.log(markers); console.log(markers);
} }
@ -308,6 +355,19 @@
async function handleSubmit(event: Event) { async function handleSubmit(event: Event) {
event.preventDefault(); 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 (adventure.date && adventure.end_date) {
if (new Date(adventure.date) > new Date(adventure.end_date)) { if (new Date(adventure.date) > new Date(adventure.end_date)) {
addToast('error', 'Start date must be before end date'); addToast('error', 'Start date must be before end date');
@ -608,6 +668,9 @@
bind:value={query} bind:value={query}
/> />
<button class="btn btn-neutral -mt-1" type="submit">Search</button> <button class="btn btn-neutral -mt-1" type="submit">Search</button>
<button class="btn btn-neutral -mt-1" type="button" on:click={clearMap}
>Clear Map</button
>
</form> </form>
</div> </div>
{#if places.length > 0} {#if places.length > 0}
@ -628,6 +691,7 @@
activity_type: place.type activity_type: place.type
} }
]; ];
checkPointInRegion();
}} }}
> >
{place.display_name} {place.display_name}
@ -655,6 +719,9 @@ it would also work to just use on:click on the MapLibre component itself. -->
{/each} {/each}
</MapLibre> </MapLibre>
</div> </div>
{#if region_name}
<p class="text-lg font-semibold mt-2">Region: {region_name} ({region_id})</p>
{/if}
<div class="mt-4"> <div class="mt-4">
<button type="submit" class="btn btn-primary">Save & Next</button> <button type="submit" class="btn btn-primary">Save & Next</button>

View file

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

View file

@ -44,6 +44,21 @@
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
async function checkVisitedRegions() {
let res = await fetch('/api/countries/region_check_all_adventures/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
let data = await res.json();
if (res.ok) {
addToast('success', `${data.regions_visited} regions updated`);
} else {
addToast('error', 'Error updating visited regions');
}
}
</script> </script>
<h1 class="text-center font-extrabold text-4xl mb-6">Settings Page</h1> <h1 class="text-center font-extrabold text-4xl mb-6">Settings Page</h1>
@ -152,7 +167,20 @@
<button class="py-2 px-4 btn btn-primary mt-2">Change Email</button> <button class="py-2 px-4 btn btn-primary mt-2">Change Email</button>
</form> </form>
</div> </div>
<div class="flex flex-col items-center">
<div class="flex flex-col items-center mt-4">
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">Visited Region Check</h1>
<p>
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.
</p>
<button class="btn btn-neutral mt-2 mb-2" on:click={checkVisitedRegions}
>Update Visited Regions</button
>
<p>This may take longer depending on the number of adventures you have.</p>
</div>
<div class="flex flex-col items-center mt-4">
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">Data Export</h1> <h1 class="text-center font-extrabold text-xl mt-4 mb-2">Data Export</h1>
<button class="btn btn-neutral mb-4" on:click={exportAdventures}> Export to JSON </button> <button class="btn btn-neutral mb-4" on:click={exportAdventures}> Export to JSON </button>
<p>This may take a few seconds...</p> <p>This may take a few seconds...</p>