diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index e809aa4..5c39301 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, 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' diff --git a/backend/server/worldtravel/migrations/0013_visitedcity.py b/backend/server/worldtravel/migrations/0013_visitedcity.py new file mode 100644 index 0000000..3b1e294 --- /dev/null +++ b/backend/server/worldtravel/migrations/0013_visitedcity.py @@ -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)), + ], + ), + ] diff --git a/backend/server/worldtravel/models.py b/backend/server/worldtravel/models.py index 296b3bd..9e83f59 100644 --- a/backend/server/worldtravel/models.py +++ b/backend/server/worldtravel/models.py @@ -60,4 +60,21 @@ class VisitedRegion(models.Model): def save(self, *args, **kwargs): if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists(): raise ValidationError("Region already visited by user.") - super().save(*args, **kwargs) \ No newline at end of file + 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" \ No newline at end of file diff --git a/backend/server/worldtravel/serializers.py b/backend/server/worldtravel/serializers.py index 134658a..cccf754 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, City +from .models import Country, Region, VisitedRegion, City, VisitedCity from rest_framework import serializers from main.utils import CustomModelSerializer @@ -52,4 +52,14 @@ class VisitedRegionSerializer(CustomModelSerializer): class Meta: 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'] \ No newline at end of file diff --git a/backend/server/worldtravel/urls.py b/backend/server/worldtravel/urls.py index e20717c..f28beda 100644 --- a/backend/server/worldtravel/urls.py +++ b/backend/server/worldtravel/urls.py @@ -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('/regions/', regions_by_country, name='regions-by-country'), path('/visits/', visits_by_country, name='visits-by-country'), path('regions//cities/', cities_by_region, name='cities-by-region'), + path('regions//cities/visits/', visits_by_region, name='visits-by-region'), ] diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py index 0c41aa1..6cb2575 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, 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 @@ -101,4 +110,42 @@ class VisitedRegionViewSet(viewsets.ModelViewSet): 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) \ No newline at end of file + 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) \ No newline at end of file diff --git a/frontend/src/lib/components/CityCard.svelte b/frontend/src/lib/components/CityCard.svelte index 619a27a..20619e0 100644 --- a/frontend/src/lib/components/CityCard.svelte +++ b/frontend/src/lib/components/CityCard.svelte @@ -1,13 +1,42 @@

{city.name}

-
{city.region}
- +
{city.id}
+ {#if !visited} + + {/if} + {#if visited} + + {/if}
diff --git a/frontend/src/lib/components/RegionCard.svelte b/frontend/src/lib/components/RegionCard.svelte index f8fc333..5351c84 100644 --- a/frontend/src/lib/components/RegionCard.svelte +++ b/frontend/src/lib/components/RegionCard.svelte @@ -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: '' - }; - addToast('success', `Visit to ${region.name} marked`); - dispatch('visit', newVisit); - } + visited = true; + let data = await res.json(); + addToast('success', `Visit to ${region.name} marked`); + 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}`); } } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index aa244d3..ac153ca 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 9f8c2e9..90deaef 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -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", diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts index c77044c..815d4a7 100644 --- a/frontend/src/routes/api/[...path]/+server.ts +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -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); } diff --git a/frontend/src/routes/worldtravel/+page.server.ts b/frontend/src/routes/worldtravel/+page.server.ts index ec696bf..b84ae39 100644 --- a/frontend/src/routes/worldtravel/+page.server.ts +++ b/frontend/src/routes/worldtravel/+page.server.ts @@ -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 - }; - } - } -}; diff --git a/frontend/src/routes/worldtravel/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/+page.svelte index 53224e1..8092df9 100644 --- a/frontend/src/routes/worldtravel/[id]/+page.svelte +++ b/frontend/src/routes/worldtravel/[id]/+page.svelte @@ -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}`); + return; + } else { + visitedRegions = [...visitedRegions, await res.json()]; + addToast('success', `Visit to ${region.name} marked`); } } - async function removeVisit(region: Region, visitId: number) { - let res = await fetch(`/worldtravel?/removeVisited`, { - method: 'POST', - body: JSON.stringify({ visitId: visitId }) + async function removeVisit(region: Region) { + let res = await fetch(`/api/visitedregion/${region.id}`, { + headers: { 'Content-Type': 'application/json' }, + method: 'DELETE' }); - if (res.ok) { - addToast('info', `Visit to ${region.name} removed`); - } else { + 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}
diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts index 79a0d95..b92b47a 100644 --- a/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts @@ -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; diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte index 80b4108..aa230e5 100644 --- a/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte @@ -1,8 +1,7 @@ -

{$t('worldtravel.country_list')}

+

Cities in {data.props?.region.name}

{allCities.length} Cities Found

+
+
+
+
City Stats
+
{numVisitedCities}/{numCities} Visited
+ {#if numCities === numVisitedCities} +
You've visited all cities in {data.props?.region.name} 🎉!
+ {:else} +
Keep exploring!
+ {/if} +
+
+
+ -
- - {#if searchQuery.length > 0} - -
- -
- {/if} -
+{#if allCities.length > 0} +
+ + {#if searchQuery.length > 0} + +
+ +
+ {/if} +
-
- +
+ - - {#each filteredCities as city} - {#if city.latitude && city.longitude} - - - {city.name} - - - {/if} - {/each} - -
+ + {#each filteredCities as city} + {#if city.latitude && city.longitude} + + + {city.name} + + + {/if} + {/each} + +
+{/if}
{#each filteredCities as city} - + visitedCity.city === city.id)} + on:visit={(e) => { + visitedCities = [...visitedCities, e.detail]; + }} + on:remove={() => { + visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== city.id); + }} + /> {/each}
{#if filteredCities.length === 0} -

{$t('worldtravel.no_countries_found')}

+

{$t('worldtravel.no_cities_found')}

{/if}