diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index be1793b..e809aa4 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -2,7 +2,7 @@ import os from django.contrib import admin from django.utils.html import mark_safe from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category -from worldtravel.models import Country, Region, VisitedRegion +from worldtravel.models import Country, Region, VisitedRegion, City from allauth.account.decorators import secure_admin_login admin.autodiscover() @@ -51,6 +51,16 @@ class RegionAdmin(admin.ModelAdmin): number_of_visits.short_description = 'Number of Visits' +class CityAdmin(admin.ModelAdmin): + list_display = ('name', 'region', 'country') + list_filter = ('region', 'region__country') + search_fields = ('name', 'region__name', 'region__country__name') + + def country(self, obj): + return obj.region.country.name + + country.short_description = 'Country' + from django.contrib import admin from django.contrib.auth.admin import UserAdmin from users.models import CustomUser @@ -127,6 +137,7 @@ admin.site.register(Checklist) admin.site.register(ChecklistItem) admin.site.register(AdventureImage, AdventureImageAdmin) admin.site.register(Category, CategoryAdmin) +admin.site.register(City, CityAdmin) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index aa57388..06bd33b 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -276,4 +276,4 @@ LOGGING = { }, } # https://github.com/dr5hn/countries-states-cities-database/tags -COUNTRY_REGION_JSON_VERSION = 'v2.4' \ No newline at end of file +COUNTRY_REGION_JSON_VERSION = 'v2.5' \ No newline at end of file diff --git a/backend/server/worldtravel/management/commands/download-countries.py b/backend/server/worldtravel/management/commands/download-countries.py index 06382cb..1b741bf 100644 --- a/backend/server/worldtravel/management/commands/download-countries.py +++ b/backend/server/worldtravel/management/commands/download-countries.py @@ -1,7 +1,7 @@ import os from django.core.management.base import BaseCommand import requests -from worldtravel.models import Country, Region +from worldtravel.models import Country, Region, City from django.db import transaction import json @@ -37,16 +37,28 @@ def saveCountryFlag(country_code): class Command(BaseCommand): help = 'Imports the world travel data' + def add_arguments(self, parser): + parser.add_argument('--force', action='store_true', help='Force download the countries+regions+states.json file') + def handle(self, *args, **options): - countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions-{COUNTRY_REGION_JSON_VERSION}.json') - if not os.path.exists(countries_json_path): - res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/countries%2Bstates.json') + force = options['force'] + countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions+states-{COUNTRY_REGION_JSON_VERSION}.json') + if not os.path.exists(countries_json_path) or force: + res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/json/countries%2Bstates%2Bcities.json') if res.status_code == 200: with open(countries_json_path, 'w') as f: f.write(res.text) else: - self.stdout.write(self.style.ERROR('Error downloading countries+regions.json')) + self.stdout.write(self.style.ERROR('Error downloading countries+regions+states.json')) return + elif not os.path.isfile(countries_json_path): + self.stdout.write(self.style.ERROR('countries+regions+states.json is not a file')) + return + elif os.path.getsize(countries_json_path) == 0: + self.stdout.write(self.style.ERROR('countries+regions+states.json is empty')) + else: + self.stdout.write(self.style.SUCCESS('countries+regions+states.json already exists')) + return with open(countries_json_path, 'r') as f: data = json.load(f) @@ -54,14 +66,18 @@ class Command(BaseCommand): with transaction.atomic(): existing_countries = {country.country_code: country for country in Country.objects.all()} existing_regions = {region.id: region for region in Region.objects.all()} + existing_cities = {city.id: city for city in City.objects.all()} countries_to_create = [] regions_to_create = [] countries_to_update = [] regions_to_update = [] + cities_to_create = [] + cities_to_update = [] processed_country_codes = set() processed_region_ids = set() + processed_city_ids = set() for country in data: country_code = country['iso2'] @@ -102,6 +118,11 @@ class Command(BaseCommand): latitude = round(float(state['latitude']), 6) if state['latitude'] else None longitude = round(float(state['longitude']), 6) if state['longitude'] else None + # Check for duplicate regions + if state_id in processed_region_ids: + self.stdout.write(self.style.ERROR(f'State {state_id} already processed')) + continue + processed_region_ids.add(state_id) if state_id in existing_regions: @@ -121,6 +142,39 @@ class Command(BaseCommand): ) regions_to_create.append(region_obj) self.stdout.write(self.style.SUCCESS(f'State {state_id} prepared')) + + if 'cities' in state and len(state['cities']) > 0: + for city in state['cities']: + city_id = f"{state_id}-{city['id']}" + city_name = city['name'] + latitude = round(float(city['latitude']), 6) if city['latitude'] else None + longitude = round(float(city['longitude']), 6) if city['longitude'] else None + + # Check for duplicate cities + if city_id in processed_city_ids: + self.stdout.write(self.style.ERROR(f'City {city_id} already processed')) + continue + + processed_city_ids.add(city_id) + + if city_id in existing_cities: + city_obj = existing_cities[city_id] + city_obj.name = city_name + city_obj.region = region_obj + city_obj.longitude = longitude + city_obj.latitude = latitude + cities_to_update.append(city_obj) + else: + city_obj = City( + id=city_id, + name=city_name, + region=region_obj, + longitude=longitude, + latitude=latitude + ) + cities_to_create.append(city_obj) + self.stdout.write(self.style.SUCCESS(f'City {city_id} prepared')) + else: state_id = f"{country_code}-00" processed_region_ids.add(state_id) @@ -141,13 +195,16 @@ class Command(BaseCommand): # Bulk create new countries and regions Country.objects.bulk_create(countries_to_create) Region.objects.bulk_create(regions_to_create) + City.objects.bulk_create(cities_to_create) # Bulk update existing countries and regions Country.objects.bulk_update(countries_to_update, ['name', 'subregion', 'capital']) Region.objects.bulk_update(regions_to_update, ['name', 'country', 'longitude', 'latitude']) + City.objects.bulk_update(cities_to_update, ['name', 'region', 'longitude', 'latitude']) # Delete countries and regions that are no longer in the data Country.objects.exclude(country_code__in=processed_country_codes).delete() Region.objects.exclude(id__in=processed_region_ids).delete() + City.objects.exclude(id__in=processed_city_ids).delete() self.stdout.write(self.style.SUCCESS('All data imported successfully')) \ No newline at end of file diff --git a/backend/server/worldtravel/migrations/0012_city.py b/backend/server/worldtravel/migrations/0012_city.py new file mode 100644 index 0000000..d14b088 --- /dev/null +++ b/backend/server/worldtravel/migrations/0012_city.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.8 on 2025-01-09 15:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0011_country_latitude_country_longitude'), + ] + + operations = [ + migrations.CreateModel( + name='City', + fields=[ + ('id', models.CharField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('region', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.region')), + ], + options={ + 'verbose_name_plural': 'Cities', + }, + ), + ] diff --git a/backend/server/worldtravel/models.py b/backend/server/worldtravel/models.py index 2acc629..296b3bd 100644 --- a/backend/server/worldtravel/models.py +++ b/backend/server/worldtravel/models.py @@ -34,6 +34,19 @@ class Region(models.Model): def __str__(self): return self.name + +class City(models.Model): + id = models.CharField(primary_key=True) + name = models.CharField(max_length=100) + region = models.ForeignKey(Region, on_delete=models.CASCADE) + 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) + + class Meta: + verbose_name_plural = "Cities" + + def __str__(self): + return self.name class VisitedRegion(models.Model): id = models.AutoField(primary_key=True) diff --git a/backend/server/worldtravel/serializers.py b/backend/server/worldtravel/serializers.py index 70f569b..134658a 100644 --- a/backend/server/worldtravel/serializers.py +++ b/backend/server/worldtravel/serializers.py @@ -1,5 +1,5 @@ import os -from .models import Country, Region, VisitedRegion +from .models import Country, Region, VisitedRegion, City from rest_framework import serializers from main.utils import CustomModelSerializer @@ -38,6 +38,12 @@ class RegionSerializer(serializers.ModelSerializer): fields = '__all__' read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude'] +class CitySerializer(serializers.ModelSerializer): + class Meta: + model = City + fields = '__all__' + read_only_fields = ['id', 'name', 'region', 'longitude', 'latitude'] + class VisitedRegionSerializer(CustomModelSerializer): longitude = serializers.DecimalField(source='region.longitude', max_digits=9, decimal_places=6, read_only=True) latitude = serializers.DecimalField(source='region.latitude', max_digits=9, decimal_places=6, read_only=True) diff --git a/backend/server/worldtravel/urls.py b/backend/server/worldtravel/urls.py index 46fe197..e20717c 100644 --- a/backend/server/worldtravel/urls.py +++ b/backend/server/worldtravel/urls.py @@ -2,8 +2,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country - +from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region router = DefaultRouter() router.register(r'countries', CountryViewSet, basename='countries') router.register(r'regions', RegionViewSet, basename='regions') @@ -12,5 +11,6 @@ router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion' urlpatterns = [ path('', include(router.urls)), path('/regions/', regions_by_country, name='regions-by-country'), - path('/visits/', visits_by_country, name='visits-by-country') + path('/visits/', visits_by_country, name='visits-by-country'), + path('regions//cities/', cities_by_region, name='cities-by-region'), ] diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py index 5a92e39..0c41aa1 100644 --- a/backend/server/worldtravel/views.py +++ b/backend/server/worldtravel/views.py @@ -1,6 +1,6 @@ from django.shortcuts import render -from .models import Country, Region, VisitedRegion -from .serializers import CountrySerializer, RegionSerializer, VisitedRegionSerializer +from .models import Country, Region, VisitedRegion, City +from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer from rest_framework import viewsets, status from rest_framework.permissions import IsAuthenticated from django.shortcuts import get_object_or_404 @@ -33,6 +33,14 @@ def visits_by_country(request, country_code): serializer = VisitedRegionSerializer(visits, many=True) return Response(serializer.data) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def cities_by_region(request, region_id): + region = get_object_or_404(Region, id=region_id) + cities = City.objects.filter(region=region).order_by('name') + serializer = CitySerializer(cities, many=True) + return Response(serializer.data) + class CountryViewSet(viewsets.ReadOnlyModelViewSet): queryset = Country.objects.all().order_by('name') serializer_class = CountrySerializer diff --git a/frontend/src/lib/components/CityCard.svelte b/frontend/src/lib/components/CityCard.svelte new file mode 100644 index 0000000..619a27a --- /dev/null +++ b/frontend/src/lib/components/CityCard.svelte @@ -0,0 +1,36 @@ + + +
+
+

{city.name}

+
+
{city.region}
+ +
+ +
+ +
+
+
diff --git a/frontend/src/lib/components/RegionCard.svelte b/frontend/src/lib/components/RegionCard.svelte index c9754f0..f8fc333 100644 --- a/frontend/src/lib/components/RegionCard.svelte +++ b/frontend/src/lib/components/RegionCard.svelte @@ -4,12 +4,18 @@ import type { Region, VisitedRegion } from '$lib/types'; import { createEventDispatcher } from 'svelte'; const dispatch = createEventDispatcher(); + import { t } from 'svelte-i18n'; export let region: Region; export let visited: boolean; export let visit_id: number | undefined | null; + function goToCity() { + console.log(region); + goto(`/worldtravel/${region.id.split('-')[0]}/${region.id}`); + } + async function markVisited() { let res = await fetch(`/worldtravel?/markVisited`, { method: 'POST', @@ -65,11 +71,16 @@
{#if !visited} - + {/if} {#if visited} - + {/if} +
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index afac6a1..aa244d3 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -57,13 +57,21 @@ export type Country = { }; export type Region = { - id: number; + id: string; name: string; - country: number; + country: string; latitude: number; longitude: number; }; +export type City = { + id: string; + name: string; + latitude: number | null; + longitude: number | null; + region: string; +}; + export type VisitedRegion = { id: number; region: number; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a46986d..9f8c2e9 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -275,7 +275,8 @@ "completely_visited": "Completely Visited", "all_subregions": "All Subregions", "clear_search": "Clear Search", - "no_countries_found": "No countries found" + "no_countries_found": "No countries found", + "view_cities": "View Cities" }, "auth": { "username": "Username", diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts new file mode 100644 index 0000000..79a0d95 --- /dev/null +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts @@ -0,0 +1,65 @@ +const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; +import type { City, Country, Region, VisitedRegion } from '$lib/types'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + +export const load = (async (event) => { + const id = event.params.id.toUpperCase(); + + let cities: City[] = []; + let region = {} as Region; + + let sessionId = event.cookies.get('sessionid'); + + if (!sessionId) { + return redirect(302, '/login'); + } + + let res = await fetch(`${endpoint}/api/regions/${id}/cities/`, { + method: 'GET', + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + if (!res.ok) { + console.error('Failed to fetch regions'); + return redirect(302, '/404'); + } else { + cities = (await res.json()) as City[]; + } + + // res = await fetch(`${endpoint}/api/${id}/visits/`, { + // method: 'GET', + // headers: { + // Cookie: `sessionid=${sessionId}` + // } + // }); + // if (!res.ok) { + // console.error('Failed to fetch visited regions'); + // return { status: 500 }; + // } else { + // visitedRegions = (await res.json()) as VisitedRegion[]; + // } + + res = await fetch(`${endpoint}/api/regions/${id}/`, { + method: 'GET', + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + if (!res.ok) { + console.error('Failed to fetch country'); + return { status: 500 }; + } else { + region = (await res.json()) as Region; + } + + return { + props: { + cities, + region + } + }; +}) satisfies PageServerLoad; diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte new file mode 100644 index 0000000..80b4108 --- /dev/null +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte @@ -0,0 +1,148 @@ + + +

{$t('worldtravel.country_list')}

+ +

+ {allCities.length} + Cities Found +

+ + + +
+ + {#if searchQuery.length > 0} + +
+ +
+ {/if} +
+ +
+ + + + {#each filteredCities as city} + {#if city.latitude && city.longitude} + + + {city.name} + + + {/if} + {/each} + +
+ +
+ {#each filteredCities as city} + + {/each} +
+ +{#if filteredCities.length === 0} +

{$t('worldtravel.no_countries_found')}

+{/if} + + + Countries | World Travel + +