1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-23 14:59:36 +02:00

feat: add City model and serializer, update RegionCard and create CityCard component, enhance admin interface for City management

This commit is contained in:
Sean Morley 2025-01-09 11:11:02 -05:00
parent a883d4104d
commit 44810e6343
14 changed files with 409 additions and 18 deletions

View file

@ -2,7 +2,7 @@ import os
from django.contrib import admin from django.contrib import admin
from django.utils.html import mark_safe from django.utils.html import mark_safe
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category 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 from allauth.account.decorators import secure_admin_login
admin.autodiscover() admin.autodiscover()
@ -51,6 +51,16 @@ class RegionAdmin(admin.ModelAdmin):
number_of_visits.short_description = 'Number of Visits' 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 import admin
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from users.models import CustomUser from users.models import CustomUser
@ -127,6 +137,7 @@ admin.site.register(Checklist)
admin.site.register(ChecklistItem) admin.site.register(ChecklistItem)
admin.site.register(AdventureImage, AdventureImageAdmin) admin.site.register(AdventureImage, AdventureImageAdmin)
admin.site.register(Category, CategoryAdmin) admin.site.register(Category, CategoryAdmin)
admin.site.register(City, CityAdmin)
admin.site.site_header = 'AdventureLog Admin' admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site' admin.site.site_title = 'AdventureLog Admin Site'

View file

@ -276,4 +276,4 @@ LOGGING = {
}, },
} }
# https://github.com/dr5hn/countries-states-cities-database/tags # https://github.com/dr5hn/countries-states-cities-database/tags
COUNTRY_REGION_JSON_VERSION = 'v2.4' COUNTRY_REGION_JSON_VERSION = 'v2.5'

View file

@ -1,7 +1,7 @@
import os import os
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
import requests import requests
from worldtravel.models import Country, Region from worldtravel.models import Country, Region, City
from django.db import transaction from django.db import transaction
import json import json
@ -37,16 +37,28 @@ def saveCountryFlag(country_code):
class Command(BaseCommand): class Command(BaseCommand):
help = 'Imports the world travel data' 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): def handle(self, *args, **options):
countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions-{COUNTRY_REGION_JSON_VERSION}.json') force = options['force']
if not os.path.exists(countries_json_path): countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions+states-{COUNTRY_REGION_JSON_VERSION}.json')
res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/countries%2Bstates.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: if res.status_code == 200:
with open(countries_json_path, 'w') as f: with open(countries_json_path, 'w') as f:
f.write(res.text) f.write(res.text)
else: 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 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: with open(countries_json_path, 'r') as f:
data = json.load(f) data = json.load(f)
@ -54,14 +66,18 @@ class Command(BaseCommand):
with transaction.atomic(): with transaction.atomic():
existing_countries = {country.country_code: country for country in Country.objects.all()} existing_countries = {country.country_code: country for country in Country.objects.all()}
existing_regions = {region.id: region for region in Region.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 = [] countries_to_create = []
regions_to_create = [] regions_to_create = []
countries_to_update = [] countries_to_update = []
regions_to_update = [] regions_to_update = []
cities_to_create = []
cities_to_update = []
processed_country_codes = set() processed_country_codes = set()
processed_region_ids = set() processed_region_ids = set()
processed_city_ids = set()
for country in data: for country in data:
country_code = country['iso2'] country_code = country['iso2']
@ -102,6 +118,11 @@ class Command(BaseCommand):
latitude = round(float(state['latitude']), 6) if state['latitude'] else None latitude = round(float(state['latitude']), 6) if state['latitude'] else None
longitude = round(float(state['longitude']), 6) if state['longitude'] 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) processed_region_ids.add(state_id)
if state_id in existing_regions: if state_id in existing_regions:
@ -121,6 +142,39 @@ class Command(BaseCommand):
) )
regions_to_create.append(region_obj) regions_to_create.append(region_obj)
self.stdout.write(self.style.SUCCESS(f'State {state_id} prepared')) 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: else:
state_id = f"{country_code}-00" state_id = f"{country_code}-00"
processed_region_ids.add(state_id) processed_region_ids.add(state_id)
@ -141,13 +195,16 @@ class Command(BaseCommand):
# Bulk create new countries and regions # Bulk create new countries and regions
Country.objects.bulk_create(countries_to_create) Country.objects.bulk_create(countries_to_create)
Region.objects.bulk_create(regions_to_create) Region.objects.bulk_create(regions_to_create)
City.objects.bulk_create(cities_to_create)
# Bulk update existing countries and regions # Bulk update existing countries and regions
Country.objects.bulk_update(countries_to_update, ['name', 'subregion', 'capital']) Country.objects.bulk_update(countries_to_update, ['name', 'subregion', 'capital'])
Region.objects.bulk_update(regions_to_update, ['name', 'country', 'longitude', 'latitude']) 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 # Delete countries and regions that are no longer in the data
Country.objects.exclude(country_code__in=processed_country_codes).delete() Country.objects.exclude(country_code__in=processed_country_codes).delete()
Region.objects.exclude(id__in=processed_region_ids).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')) self.stdout.write(self.style.SUCCESS('All data imported successfully'))

View file

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

View file

@ -35,6 +35,19 @@ class Region(models.Model):
def __str__(self): def __str__(self):
return self.name 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): class VisitedRegion(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
user_id = models.ForeignKey( user_id = models.ForeignKey(

View file

@ -1,5 +1,5 @@
import os import os
from .models import Country, Region, VisitedRegion from .models import Country, Region, VisitedRegion, City
from rest_framework import serializers from rest_framework import serializers
from main.utils import CustomModelSerializer from main.utils import CustomModelSerializer
@ -38,6 +38,12 @@ class RegionSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude'] 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): class VisitedRegionSerializer(CustomModelSerializer):
longitude = serializers.DecimalField(source='region.longitude', max_digits=9, decimal_places=6, read_only=True) 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) latitude = serializers.DecimalField(source='region.latitude', max_digits=9, decimal_places=6, read_only=True)

View file

@ -2,8 +2,7 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter 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 = DefaultRouter()
router.register(r'countries', CountryViewSet, basename='countries') router.register(r'countries', CountryViewSet, basename='countries')
router.register(r'regions', RegionViewSet, basename='regions') router.register(r'regions', RegionViewSet, basename='regions')
@ -12,5 +11,6 @@ router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion'
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path('<str:country_code>/regions/', regions_by_country, name='regions-by-country'), path('<str:country_code>/regions/', regions_by_country, name='regions-by-country'),
path('<str:country_code>/visits/', visits_by_country, name='visits-by-country') path('<str:country_code>/visits/', visits_by_country, name='visits-by-country'),
path('regions/<str:region_id>/cities/', cities_by_region, name='cities-by-region'),
] ]

View file

@ -1,6 +1,6 @@
from django.shortcuts import render from django.shortcuts import render
from .models import Country, Region, VisitedRegion from .models import Country, Region, VisitedRegion, City
from .serializers import CountrySerializer, RegionSerializer, VisitedRegionSerializer from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@ -33,6 +33,14 @@ def visits_by_country(request, country_code):
serializer = VisitedRegionSerializer(visits, many=True) serializer = VisitedRegionSerializer(visits, many=True)
return Response(serializer.data) 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): class CountryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Country.objects.all().order_by('name') queryset = Country.objects.all().order_by('name')
serializer_class = CountrySerializer serializer_class = CountrySerializer

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { continentCodeToString, getFlag } from '$lib';
import type { City, Country } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import MapMarkerStar from '~icons/mdi/map-marker-star';
export let city: City;
</script>
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
>
<div class="card-body">
<h2 class="card-title overflow-ellipsis">{city.name}</h2>
<div class="flex flex-wrap gap-2">
<div class="badge badge-primary">{city.region}</div>
<!--
{#if country.num_visits > 0 && country.num_visits != country.num_regions}
<div class="badge badge-accent">
Visited {country.num_visits} Region{country.num_visits > 1 ? 's' : ''}
</div>
{:else if country.num_visits > 0 && country.num_visits === country.num_regions}
<div class="badge badge-success">Completed</div>
{:else}
<div class="badge badge-error">Not Visited</div>
{/if} -->
</div>
<div class="card-actions justify-end">
<!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> -->
</div>
</div>
</div>

View file

@ -4,12 +4,18 @@
import type { Region, VisitedRegion } from '$lib/types'; import type { Region, VisitedRegion } from '$lib/types';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
import { t } from 'svelte-i18n';
export let region: Region; export let region: Region;
export let visited: boolean; export let visited: boolean;
export let visit_id: number | undefined | null; export let visit_id: number | undefined | null;
function goToCity() {
console.log(region);
goto(`/worldtravel/${region.id.split('-')[0]}/${region.id}`);
}
async function markVisited() { async function markVisited() {
let res = await fetch(`/worldtravel?/markVisited`, { let res = await fetch(`/worldtravel?/markVisited`, {
method: 'POST', method: 'POST',
@ -65,11 +71,16 @@
<div class="card-actions justify-end"> <div class="card-actions justify-end">
<!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> --> <!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> -->
{#if !visited} {#if !visited}
<button class="btn btn-primary" on:click={markVisited}>Mark Visited</button> <button class="btn btn-primary" on:click={markVisited}
>{$t('adventures.mark_visited')}</button
>
{/if} {/if}
{#if visited} {#if visited}
<button class="btn btn-warning" on:click={removeVisit}>Remove</button> <button class="btn btn-warning" on:click={removeVisit}>{$t('adventures.remove')}</button>
{/if} {/if}
<button class="btn btn-neutral-300" on:click={goToCity}
>{$t('worldtravel.view_cities')}</button
>
</div> </div>
</div> </div>
</div> </div>

View file

@ -57,13 +57,21 @@ export type Country = {
}; };
export type Region = { export type Region = {
id: number; id: string;
name: string; name: string;
country: number; country: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
}; };
export type City = {
id: string;
name: string;
latitude: number | null;
longitude: number | null;
region: string;
};
export type VisitedRegion = { export type VisitedRegion = {
id: number; id: number;
region: number; region: number;

View file

@ -275,7 +275,8 @@
"completely_visited": "Completely Visited", "completely_visited": "Completely Visited",
"all_subregions": "All Subregions", "all_subregions": "All Subregions",
"clear_search": "Clear Search", "clear_search": "Clear Search",
"no_countries_found": "No countries found" "no_countries_found": "No countries found",
"view_cities": "View Cities"
}, },
"auth": { "auth": {
"username": "Username", "username": "Username",

View file

@ -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;

View file

@ -0,0 +1,148 @@
<script lang="ts">
import { goto } from '$app/navigation';
import CityCard from '$lib/components/CityCard.svelte';
import CountryCard from '$lib/components/CountryCard.svelte';
import type { City, Country } from '$lib/types';
import type { PageData } from './$types';
import { t } from 'svelte-i18n';
import { MapLibre, Marker } from 'svelte-maplibre';
export let data: PageData;
console.log(data);
let searchQuery: string = '';
let filteredCities: City[] = [];
const allCities: City[] = data.props?.cities || [];
let showMap: boolean = false;
let filterOption: string = 'all';
let subRegionOption: string = '';
$: {
if (searchQuery === '') {
filteredCities = allCities;
} else {
// otherwise, filter countries by name
filteredCities = filteredCities.filter((country) =>
country.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
}
</script>
<h1 class="text-center font-bold text-4xl">{$t('worldtravel.country_list')}</h1>
<!-- result count -->
<p class="text-center mb-4">
{allCities.length}
Cities Found
</p>
<!-- <div class="flex items-center justify-center mb-4">
<div class="join">
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('worldtravel.all')}
checked
on:click={() => (filterOption = 'all')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('worldtravel.partially_visited')}
on:click={() => (filterOption = 'partial')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('worldtravel.completely_visited')}
on:click={() => (filterOption = 'complete')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('worldtravel.not_visited')}
on:click={() => (filterOption = 'not')}
/>
</div>
<select class="select select-bordered w-full max-w-xs ml-4" bind:value={subRegionOption}>
<option value="">{$t('worldtravel.all_subregions')}</option>
{#each worldSubregions as subregion}
<option value={subregion}>{subregion}</option>
{/each}
</select>
<div class="flex items-center justify-center ml-4">
<input
type="checkbox"
class="checkbox checkbox-bordered"
bind:checked={showMap}
aria-label={$t('adventures.show_map')}
/>
<span class="ml-2">{$t('adventures.show_map')}</span>
</div>
</div> -->
<div class="flex items-center justify-center mb-4">
<input
type="text"
placeholder={$t('navbar.search')}
class="input input-bordered w-full max-w-xs"
bind:value={searchQuery}
/>
{#if searchQuery.length > 0}
<!-- clear button -->
<div class="flex items-center justify-center ml-4">
<button class="btn btn-neutral" on:click={() => (searchQuery = '')}>
{$t('worldtravel.clear_search')}
</button>
</div>
{/if}
</div>
<div class="mt-4 mb-4 flex justify-center">
<!-- checkbox to toggle marker -->
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
standardControls
center={allCities[0] && allCities[0].longitude !== null && allCities[0].latitude !== null
? [allCities[0].longitude, allCities[0].latitude]
: [0, 0]}
zoom={8}
>
{#each filteredCities as city}
{#if city.latitude && city.longitude}
<Marker
lngLat={[city.longitude, city.latitude]}
class="grid px-2 py-1 place-items-center rounded-full border border-gray-200 bg-green-200 text-black focus:outline-6 focus:outline-black"
>
<span class="text-xs">
{city.name}
</span>
</Marker>
{/if}
{/each}
</MapLibre>
</div>
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each filteredCities as city}
<CityCard {city} />
{/each}
</div>
{#if filteredCities.length === 0}
<p class="text-center font-bold text-2xl mt-12">{$t('worldtravel.no_countries_found')}</p>
{/if}
<svelte:head>
<title>Countries | World Travel</title>
<meta name="description" content="Explore the world and add countries to your visited list!" />
</svelte:head>