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

feat: add VisitedCity model and serializer, update admin interface, and enhance city visit tracking functionality

This commit is contained in:
Sean Morley 2025-01-09 12:38:29 -05:00
parent 44810e6343
commit 80cec30fda
15 changed files with 344 additions and 234 deletions

View file

@ -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, City
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
from allauth.account.decorators import secure_admin_login
admin.autodiscover()
@ -138,6 +138,7 @@ admin.site.register(ChecklistItem)
admin.site.register(AdventureImage, AdventureImageAdmin)
admin.site.register(Category, CategoryAdmin)
admin.site.register(City, CityAdmin)
admin.site.register(VisitedCity)
admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site'

View file

@ -0,0 +1,24 @@
# Generated by Django 5.0.8 on 2025-01-09 17:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('worldtravel', '0012_city'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='VisitedCity',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('city', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.city')),
('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -61,3 +61,20 @@ class VisitedRegion(models.Model):
if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists():
raise ValidationError("Region already visited by user.")
super().save(*args, **kwargs)
class VisitedCity(models.Model):
id = models.AutoField(primary_key=True)
user_id = models.ForeignKey(
User, on_delete=models.CASCADE, default=default_user_id)
city = models.ForeignKey(City, on_delete=models.CASCADE)
def __str__(self):
return f'{self.city.name} ({self.city.region.name}) visited by: {self.user_id.username}'
def save(self, *args, **kwargs):
if VisitedCity.objects.filter(user_id=self.user_id, city=self.city).exists():
raise ValidationError("City already visited by user.")
super().save(*args, **kwargs)
class Meta:
verbose_name_plural = "Visited Cities"

View file

@ -1,5 +1,5 @@
import os
from .models import Country, Region, VisitedRegion, City
from .models import Country, Region, VisitedRegion, City, VisitedCity
from rest_framework import serializers
from main.utils import CustomModelSerializer
@ -53,3 +53,13 @@ class VisitedRegionSerializer(CustomModelSerializer):
model = VisitedRegion
fields = ['id', 'user_id', 'region', 'longitude', 'latitude', 'name']
read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']
class VisitedCitySerializer(CustomModelSerializer):
longitude = serializers.DecimalField(source='city.longitude', max_digits=9, decimal_places=6, read_only=True)
latitude = serializers.DecimalField(source='city.latitude', max_digits=9, decimal_places=6, read_only=True)
name = serializers.CharField(source='city.name', read_only=True)
class Meta:
model = VisitedCity
fields = ['id', 'user_id', 'city', 'longitude', 'latitude', 'name']
read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']

View file

@ -2,15 +2,17 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region
from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region, VisitedCityViewSet, visits_by_region
router = DefaultRouter()
router.register(r'countries', CountryViewSet, basename='countries')
router.register(r'regions', RegionViewSet, basename='regions')
router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion')
router.register(r'visitedcity', VisitedCityViewSet, basename='visitedcity')
urlpatterns = [
path('', include(router.urls)),
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('regions/<str:region_id>/cities/', cities_by_region, name='cities-by-region'),
path('regions/<str:region_id>/cities/visits/', visits_by_region, name='visits-by-region'),
]

View file

@ -1,6 +1,6 @@
from django.shortcuts import render
from .models import Country, Region, VisitedRegion, City
from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer
from .models import Country, Region, VisitedRegion, City, VisitedCity
from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer
from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404
@ -41,6 +41,15 @@ def cities_by_region(request, region_id):
serializer = CitySerializer(cities, many=True)
return Response(serializer.data)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def visits_by_region(request, region_id):
region = get_object_or_404(Region, id=region_id)
visits = VisitedCity.objects.filter(city__region=region, user_id=request.user.id)
serializer = VisitedCitySerializer(visits, many=True)
return Response(serializer.data)
class CountryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Country.objects.all().order_by('name')
serializer_class = CountrySerializer
@ -102,3 +111,41 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, **kwargs):
# delete by region id
region = get_object_or_404(Region, id=kwargs['pk'])
visited_region = VisitedRegion.objects.filter(user_id=request.user.id, region=region)
if visited_region.exists():
visited_region.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
else:
return Response({"error": "Visited region not found."}, status=status.HTTP_404_NOT_FOUND)
class VisitedCityViewSet(viewsets.ModelViewSet):
serializer_class = VisitedCitySerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return VisitedCity.objects.filter(user_id=self.request.user.id)
def perform_create(self, serializer):
serializer.save(user_id=self.request.user)
def create(self, request, *args, **kwargs):
request.data['user_id'] = request.user
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, **kwargs):
# delete by city id
city = get_object_or_404(City, id=kwargs['pk'])
visited_city = VisitedCity.objects.filter(user_id=request.user.id, city=city)
if visited_city.exists():
visited_city.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
else:
return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND)

View file

@ -1,13 +1,42 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { continentCodeToString, getFlag } from '$lib';
import type { City, Country } from '$lib/types';
import { addToast } from '$lib/toasts';
import type { City } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import MapMarkerStar from '~icons/mdi/map-marker-star';
import { t } from 'svelte-i18n';
export let city: City;
export let visited: boolean;
async function markVisited() {
let res = await fetch(`/api/visitedcity/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ city: city.id })
});
if (res.ok) {
visited = true;
let data = await res.json();
addToast('success', `Visit to ${city.name} marked`);
dispatch('visit', data);
} else {
console.error('Failed to mark city as visited');
addToast('error', `Failed to mark visit to ${city.name}`);
}
}
async function removeVisit() {
let res = await fetch(`/api/visitedcity/${city.id}`, {
method: 'DELETE'
});
if (res.ok) {
visited = false;
addToast('info', `Visit to ${city.name} removed`);
dispatch('remove', city);
} else {
console.error('Failed to remove visit');
addToast('error', `Failed to remove visit to ${city.name}`);
}
}
</script>
<div
@ -16,17 +45,15 @@
<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 class="badge badge-primary">{city.id}</div>
{#if !visited}
<button class="btn btn-primary" on:click={markVisited}
>{$t('adventures.mark_visited')}</button
>
{/if}
{#if visited}
<button class="btn btn-warning" on:click={removeVisit}>{$t('adventures.remove')}</button>
{/if}
</div>
<div class="card-actions justify-end">

View file

@ -9,55 +9,39 @@
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`, {
let res = await fetch(`/api/visitedregion/`, {
headers: { 'Content-Type': 'application/json' },
method: 'POST',
body: JSON.stringify({ regionId: region.id })
body: JSON.stringify({ region: region.id })
});
if (res.ok) {
// visited = true;
const result = await res.json();
const data = JSON.parse(result.data);
if (data[1] !== undefined) {
console.log('New adventure created with id:', data[3]);
let visit_id = data[3];
let region_id = data[5];
let user_id = data[4];
let newVisit: VisitedRegion = {
id: visit_id,
region: region_id,
user_id: user_id,
longitude: 0,
latitude: 0,
name: ''
};
visited = true;
let data = await res.json();
addToast('success', `Visit to ${region.name} marked`);
dispatch('visit', newVisit);
}
dispatch('visit', data);
} else {
console.error('Failed to mark region as visited');
addToast('error', `Failed to mark visit to ${region.name}`);
}
}
async function removeVisit() {
let res = await fetch(`/worldtravel?/removeVisited`, {
method: 'POST',
body: JSON.stringify({ visitId: visit_id })
let res = await fetch(`/api/visitedregion/${region.id}`, {
headers: { 'Content-Type': 'application/json' },
method: 'DELETE'
});
if (res.ok) {
visited = false;
addToast('info', `Visit to ${region.name} removed`);
dispatch('remove', null);
dispatch('remove', region);
} else {
console.error('Failed to remove visit');
addToast('error', `Failed to remove visit to ${region.name}`);
}
}
</script>

View file

@ -74,7 +74,16 @@ export type City = {
export type VisitedRegion = {
id: number;
region: number;
region: string;
user_id: string;
longitude: number;
latitude: number;
name: string;
};
export type VisitedCity = {
id: number;
city: string;
user_id: string;
longitude: number;
latitude: number;

View file

@ -276,7 +276,8 @@
"all_subregions": "All Subregions",
"clear_search": "Clear Search",
"no_countries_found": "No countries found",
"view_cities": "View Cities"
"view_cities": "View Cities",
"no_cities_found": "No cities found"
},
"auth": {
"username": "Username",

View file

@ -12,7 +12,7 @@ export async function GET(event) {
/** @type {import('./$types').RequestHandler} */
export async function POST({ url, params, request, fetch, cookies }) {
const searchParam = url.search ? `${url.search}&format=json` : '?format=json';
const searchParam = url.search ? `${url.search}` : '';
return handleRequest(url, params, request, fetch, cookies, searchParam, true);
}

View file

@ -30,80 +30,3 @@ export const load = (async (event) => {
}
}
}) satisfies PageServerLoad;
export const actions: Actions = {
markVisited: async (event) => {
const body = await event.request.json();
if (!body || !body.regionId) {
return {
status: 400
};
}
let sessionId = event.cookies.get('sessionid');
if (!event.locals.user || !sessionId) {
return redirect(302, '/login');
}
let csrfToken = await fetchCSRFToken();
const res = await fetch(`${endpoint}/api/visitedregion/`, {
method: 'POST',
headers: {
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ region: body.regionId })
});
if (!res.ok) {
console.error('Failed to mark country as visited');
return { status: 500 };
} else {
return {
status: 200,
data: await res.json()
};
}
},
removeVisited: async (event) => {
const body = await event.request.json();
if (!body || !body.visitId) {
return {
status: 400
};
}
const visitId = body.visitId as number;
let sessionId = event.cookies.get('sessionid');
if (!event.locals.user || !sessionId) {
return redirect(302, '/login');
}
let csrfToken = await fetchCSRFToken();
const res = await fetch(`${endpoint}/api/visitedregion/${visitId}/`, {
method: 'DELETE',
headers: {
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
if (res.status !== 204) {
console.error('Failed to remove country from visited');
return { status: 500 };
} else {
return {
status: 200
};
}
}
};

View file

@ -24,7 +24,7 @@
visitedRegions = visitedRegions.filter(
(visitedRegion) => visitedRegion.region !== region.id
);
removeVisit(region, visitedRegion.id);
removeVisit(region);
} else {
markVisited(region);
}
@ -32,48 +32,32 @@
}
async function markVisited(region: Region) {
let res = await fetch(`/worldtravel?/markVisited`, {
let res = await fetch(`/api/visitedregion/`, {
method: 'POST',
body: JSON.stringify({ regionId: region.id })
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ region: region.id })
});
if (res.ok) {
// visited = true;
const result = await res.json();
const data = JSON.parse(result.data);
if (data[1] !== undefined) {
console.log('New adventure created with id:', data[3]);
let visit_id = data[3];
let region_id = data[5];
let user_id = data[4];
visitedRegions = [
...visitedRegions,
{
id: visit_id,
region: region_id,
user_id: user_id,
longitude: 0,
latitude: 0,
name: ''
}
];
addToast('success', `Visit to ${region.name} marked`);
}
} else {
if (!res.ok) {
console.error('Failed to mark region as visited');
addToast('error', `Failed to mark visit to ${region.name}`);
}
}
async function removeVisit(region: Region, visitId: number) {
let res = await fetch(`/worldtravel?/removeVisited`, {
method: 'POST',
body: JSON.stringify({ visitId: visitId })
});
if (res.ok) {
addToast('info', `Visit to ${region.name} removed`);
return;
} else {
visitedRegions = [...visitedRegions, await res.json()];
addToast('success', `Visit to ${region.name} marked`);
}
}
async function removeVisit(region: Region) {
let res = await fetch(`/api/visitedregion/${region.id}`, {
headers: { 'Content-Type': 'application/json' },
method: 'DELETE'
});
if (!res.ok) {
console.error('Failed to remove visit');
addToast('error', `Failed to remove visit to ${region.name}`);
return;
} else {
visitedRegions = visitedRegions.filter((visitedRegion) => visitedRegion.region !== region.id);
addToast('info', `Visit to ${region.name} removed`);
}
}
@ -110,8 +94,12 @@
visitedRegions = [...visitedRegions, e.detail];
numVisitedRegions++;
}}
visit_id={visitedRegions.find((visitedRegion) => visitedRegion.region === region.id)?.id}
on:remove={() => numVisitedRegions--}
on:remove={() => {
visitedRegions = visitedRegions.filter(
(visitedRegion) => visitedRegion.region !== region.id
);
numVisitedRegions--;
}}
/>
{/each}
</div>

View file

@ -1,5 +1,5 @@
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { City, Country, Region, VisitedRegion } from '$lib/types';
import type { City, Country, Region, VisitedCity, VisitedRegion } from '$lib/types';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
@ -10,6 +10,7 @@ export const load = (async (event) => {
let cities: City[] = [];
let region = {} as Region;
let visitedCities: VisitedCity[] = [];
let sessionId = event.cookies.get('sessionid');
@ -30,19 +31,6 @@ export const load = (async (event) => {
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: {
@ -56,10 +44,24 @@ export const load = (async (event) => {
region = (await res.json()) as Region;
}
res = await fetch(`${endpoint}/api/regions/${region.id}/cities/visits/`, {
method: 'GET',
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (!res.ok) {
console.error('Failed to fetch visited regions');
return { status: 500 };
} else {
visitedCities = (await res.json()) as VisitedCity[];
}
return {
props: {
cities,
region
region,
visitedCities
}
};
}) satisfies PageServerLoad;

View file

@ -1,8 +1,7 @@
<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 { addToast } from '$lib/toasts';
import type { City } from '$lib/types';
import type { PageData } from './$types';
import { t } from 'svelte-i18n';
import { MapLibre, Marker } from 'svelte-maplibre';
@ -14,10 +13,7 @@
let filteredCities: City[] = [];
const allCities: City[] = data.props?.cities || [];
let showMap: boolean = false;
let filterOption: string = 'all';
let subRegionOption: string = '';
let visitedCities = data.props?.visitedCities || [];
$: {
if (searchQuery === '') {
@ -29,15 +25,78 @@
);
}
}
function togleVisited(city: City) {
return () => {
const visitedCity = visitedCities.find((visitedCity) => visitedCity.city === city.id);
if (visitedCity) {
visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== city.id);
removeVisit(city);
} else {
markVisited(city);
}
};
}
async function markVisited(city: City) {
let res = await fetch(`/api/visitedcity/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ city: city.id })
});
if (!res.ok) {
console.error('Failed to mark city as visited');
addToast('error', `Failed to mark visit to ${city.name}`);
return;
} else {
visitedCities = [...visitedCities, await res.json()];
addToast('success', `Visit to ${city.name} marked`);
}
}
async function removeVisit(region: City) {
let res = await fetch(`/api/visitedcity/${region.id}`, {
headers: { 'Content-Type': 'application/json' },
method: 'DELETE'
});
if (!res.ok) {
console.error('Failed to remove visit');
addToast('error', `Failed to remove visit to ${region.name}`);
return;
} else {
visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== region.id);
addToast('info', `Visit to ${region.name} removed`);
}
}
let numCities: number = data.props?.cities?.length || 0;
let numVisitedCities: number = visitedCities.length;
$: {
numVisitedCities = visitedCities.length;
}
</script>
<h1 class="text-center font-bold text-4xl">{$t('worldtravel.country_list')}</h1>
<h1 class="text-center font-bold text-4xl">Cities in {data.props?.region.name}</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="stats shadow bg-base-300">
<div class="stat">
<div class="stat-title">City Stats</div>
<div class="stat-value">{numVisitedCities}/{numCities} Visited</div>
{#if numCities === numVisitedCities}
<div class="stat-desc">You've visited all cities in {data.props?.region.name} 🎉!</div>
{:else}
<div class="stat-desc">Keep exploring!</div>
{/if}
</div>
</div>
</div>
<!-- <div class="flex items-center justify-center mb-4">
<div class="join">
<input
@ -88,7 +147,8 @@
</div>
</div> -->
<div class="flex items-center justify-center mb-4">
{#if allCities.length > 0}
<div class="flex items-center justify-center mb-4">
<input
type="text"
placeholder={$t('navbar.search')}
@ -103,9 +163,9 @@
</button>
</div>
{/if}
</div>
</div>
<div class="mt-4 mb-4 flex justify-center">
<div class="mt-4 mb-4 flex justify-center">
<!-- checkbox to toggle marker -->
<MapLibre
@ -121,7 +181,12 @@
{#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"
class="grid px-2 py-1 place-items-center rounded-full border border-gray-200 {visitedCities.some(
(visitedCity) => visitedCity.city === city.id
)
? 'bg-red-300'
: 'bg-blue-300'} text-black focus:outline-6 focus:outline-black"
on:click={togleVisited(city)}
>
<span class="text-xs">
{city.name}
@ -130,16 +195,26 @@
{/if}
{/each}
</MapLibre>
</div>
</div>
{/if}
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each filteredCities as city}
<CityCard {city} />
<CityCard
{city}
visited={visitedCities.some((visitedCity) => visitedCity.city === city.id)}
on:visit={(e) => {
visitedCities = [...visitedCities, e.detail];
}}
on:remove={() => {
visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== city.id);
}}
/>
{/each}
</div>
{#if filteredCities.length === 0}
<p class="text-center font-bold text-2xl mt-12">{$t('worldtravel.no_countries_found')}</p>
<p class="text-center font-bold text-2xl mt-12">{$t('worldtravel.no_cities_found')}</p>
{/if}
<svelte:head>