diff --git a/.github/workflows/backend-release.yml b/.github/workflows/backend-release.yml index e12143c..696f8b5 100644 --- a/.github/workflows/backend-release.yml +++ b/.github/workflows/backend-release.yml @@ -2,7 +2,7 @@ name: Upload the tagged release backend image to GHCR and Docker Hub on: release: - types: [published] + types: [released] env: IMAGE_NAME: "adventurelog-backend" diff --git a/.github/workflows/frontend-release.yml b/.github/workflows/frontend-release.yml index a73af32..bb7fc6b 100644 --- a/.github/workflows/frontend-release.yml +++ b/.github/workflows/frontend-release.yml @@ -2,7 +2,7 @@ name: Upload tagged release frontend image to GHCR and Docker Hub on: release: - types: [published] + types: [released] env: IMAGE_NAME: "adventurelog-frontend" diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 70eedc9..b7df19d 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,28 +1,20 @@ import os from django.contrib import admin from django.utils.html import mark_safe -from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage +from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit from worldtravel.models import Country, Region, VisitedRegion class AdventureAdmin(admin.ModelAdmin): - list_display = ('name', 'type', 'user_id', 'date', 'is_public', 'image_display') + list_display = ('name', 'type', 'user_id', 'is_public') list_filter = ('type', 'user_id', 'is_public') - - def image_display(self, obj): - if obj.image: - public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') - public_url = public_url.replace("'", "") - return mark_safe(f' self.end_date: + raise ValidationError('The start date must be before or equal to the end date.') + + def __str__(self): + return f"{self.adventure.name} - {self.start_date} to {self.end_date}" class Adventure(models.Model): #id = models.AutoField(primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) user_id = models.ForeignKey( User, on_delete=models.CASCADE, default=default_user_id) - type = models.CharField(max_length=100, choices=ADVENTURE_TYPES) + type = models.CharField(max_length=100, choices=ADVENTURE_TYPES, default='general') name = models.CharField(max_length=200) location = models.CharField(max_length=200, blank=True, null=True) activity_types = ArrayField(models.CharField( @@ -44,9 +76,6 @@ class Adventure(models.Model): description = models.TextField(blank=True, null=True) rating = models.FloatField(blank=True, null=True) link = models.URLField(blank=True, null=True, max_length=2083) - image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/') - date = models.DateField(blank=True, null=True) - end_date = models.DateField(blank=True, null=True) is_public = models.BooleanField(default=False) 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) @@ -54,6 +83,12 @@ class Adventure(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS + # Migrations performed in this version will remove these fields + # image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/') + # date = models.DateField(blank=True, null=True) + # end_date = models.DateField(blank=True, null=True) + def clean(self): if self.date and self.end_date and self.date > self.end_date: raise ValidationError('The start date must be before the end date. Start date: ' + str(self.date) + ' End date: ' + str(self.end_date)) diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index c012976..77d8d49 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -31,6 +31,46 @@ class AdventureSerializer(serializers.ModelSerializer): representation = super().to_representation(instance) return representation + def create(self, validated_data): + visits_data = validated_data.pop('visits', []) + adventure = Adventure.objects.create(**validated_data) + for visit_data in visits_data: + Visit.objects.create(adventure=adventure, **visit_data) + return adventure + + def update(self, instance, validated_data): + visits_data = validated_data.pop('visits', []) + + # Update Adventure fields + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + # Get current visits + current_visits = instance.visits.all() + current_visit_ids = set(current_visits.values_list('id', flat=True)) + + # Update or create visits + updated_visit_ids = set() + for visit_data in visits_data: + visit_id = visit_data.get('id') + if visit_id and visit_id in current_visit_ids: + visit = current_visits.get(id=visit_id) + for attr, value in visit_data.items(): + setattr(visit, attr, value) + visit.save() + updated_visit_ids.add(visit_id) + else: + # If no ID is provided or ID doesn't exist, create new visit + new_visit = Visit.objects.create(adventure=instance, **visit_data) + updated_visit_ids.add(new_visit.id) + + # Delete visits that are not in the updated data + visits_to_delete = current_visit_ids - updated_visit_ids + instance.visits.filter(id__in=visits_to_delete).delete() + + return instance + class TransportationSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 5a9dab6..7f90f73 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -5,7 +5,7 @@ from rest_framework.decorators import action from rest_framework import viewsets from django.db.models.functions import Lower from rest_framework.response import Response -from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage +from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage, ADVENTURE_TYPES from django.core.exceptions import PermissionDenied from worldtravel.models import VisitedRegion, Region, Country from .serializers import AdventureImageSerializer, AdventureSerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer @@ -104,7 +104,10 @@ class AdventureViewSet(viewsets.ModelViewSet): @action(detail=False, methods=['get']) def filtered(self, request): types = request.query_params.get('types', '').split(',') - valid_types = ['visited', 'planned'] + # handle case where types is all + if 'all' in types: + types = [t[0] for t in ADVENTURE_TYPES] + valid_types = [t[0] for t in ADVENTURE_TYPES] types = [t for t in types if t in valid_types] if not types: @@ -113,7 +116,7 @@ class AdventureViewSet(viewsets.ModelViewSet): queryset = Adventure.objects.none() for adventure_type in types: - if adventure_type in ['visited', 'planned']: + if adventure_type in valid_types: queryset |= Adventure.objects.filter( type=adventure_type, user_id=request.user.id) @@ -125,23 +128,21 @@ class AdventureViewSet(viewsets.ModelViewSet): def all(self, request): if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - # include_collections = request.query_params.get('include_collections', 'false') - # if include_collections not in ['true', 'false']: - # include_collections = 'false' + include_collections = request.query_params.get('include_collections', 'false') + if include_collections not in ['true', 'false']: + include_collections = 'false' - # if include_collections == 'true': - # queryset = Adventure.objects.filter( - # Q(is_public=True) | Q(user_id=request.user.id) - # ) - # else: - # queryset = Adventure.objects.filter( - # Q(is_public=True) | Q(user_id=request.user.id), collection=None - # ) - allowed_types = ['visited', 'planned'] + if include_collections == 'true': + queryset = Adventure.objects.filter( + Q(is_public=True) | Q(user_id=request.user.id) + ) + else: + queryset = Adventure.objects.filter( + Q(is_public=True) | Q(user_id=request.user.id), collection=None + ) queryset = Adventure.objects.filter( - Q(user_id=request.user.id) & Q(type__in=allowed_types) + Q(user_id=request.user.id) ) - queryset = self.apply_sorting(queryset) serializer = self.get_serializer(queryset, many=True) diff --git a/backend/server/templates/home.html b/backend/server/templates/home.html index 3f01ae6..7b90001 100644 --- a/backend/server/templates/home.html +++ b/backend/server/templates/home.html @@ -1,10 +1,10 @@ -{% extends "base.html" %} - -{% block content %} - -
-

AdventureLog API Server

-

Welcome to the server side of AdventureLog!

-

This site is only ment for administrative users

-
+{% extends "base.html" %} {% block content %} + +
+

AdventureLog API Server

+

+ Admin Site + API Docs +

+
{% endblock %} diff --git a/frontend/src/lib/components/ActivityComplete.svelte b/frontend/src/lib/components/ActivityComplete.svelte index 9415fc4..73f76e6 100644 --- a/frontend/src/lib/components/ActivityComplete.svelte +++ b/frontend/src/lib/components/ActivityComplete.svelte @@ -49,18 +49,21 @@
- { - if (e.key === 'Enter') { - e.preventDefault(); - addActivity(); - } - }} - /> +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + addActivity(); + } + }} + /> + +
{#if inputVal && filteredItems.length > 0}
- {#if adventure.type == 'visited'} -
Visited
- {:else if adventure.type == 'planned'} -
Planned
- {:else if adventure.type == 'lodging'} -
Lodging
- {:else if adventure.type == 'dining'} -
Dining
- {/if} - +
{typeToString(adventure.type)}
+
{isAdventureVisited(adventure) ? 'Visited' : 'Planned'}
{adventure.is_public ? 'Public' : 'Private'}
{#if adventure.location && adventure.location !== ''} @@ -227,16 +209,13 @@

{adventure.location}

{/if} - {#if adventure.date && adventure.date !== ''} -
+ {#if adventure.visits.length > 0} + +
-

- {new Date(adventure.date).toLocaleDateString(undefined, { - timeZone: 'UTC' - })}{adventure.end_date && adventure.end_date !== '' - ? ' - ' + - new Date(adventure.end_date).toLocaleDateString(undefined, { timeZone: 'UTC' }) - : ''} +

+ {adventure.visits.length} + {adventure.visits.length > 1 ? 'visits' : 'visit'}

{/if} @@ -268,7 +247,7 @@ >Open Details {#if adventure.type == 'visited' && user?.pk == adventure.user_id} {/if} - - {#if adventure.collection && (adventure.type == 'visited' || adventure.type == 'planned') && user?.pk == adventure.user_id} + + {#if adventure.collection && user?.pk == adventure.user_id} {/if} - - {#if (adventure.collection && adventure.type == 'lodging') || adventure.type == 'dining'} - - {/if} {#if !adventure.collection} import { createEventDispatcher } from 'svelte'; - import type { Adventure, OpenStreetMapPlace, Point } from '$lib/types'; + import type { Adventure, Collection, OpenStreetMapPlace, Point } from '$lib/types'; import { onMount } from 'svelte'; import { enhance } from '$app/forms'; import { addToast } from '$lib/toasts'; @@ -8,9 +8,7 @@ export let longitude: number | null = null; export let latitude: number | null = null; - export let collection_id: string | null = null; - - export let is_collection: boolean = false; + export let collection: Collection | null = null; import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre'; @@ -18,27 +16,21 @@ let places: OpenStreetMapPlace[] = []; let images: { id: string; image: string }[] = []; let warningMessage: string = ''; + let constrainDates: boolean = false; - import Earth from '~icons/mdi/earth'; import ActivityComplete from './ActivityComplete.svelte'; import { appVersion } from '$lib/config'; - - export let startDate: string | null = null; - export let endDate: string | null = null; + import { ADVENTURE_TYPES } from '$lib'; let wikiError: string = ''; let noPlaces: boolean = false; - let region_name: string | null = null; - let region_id: string | null = null; - let adventure: Adventure = { id: '', name: '', type: 'visited', - date: null, - end_date: null, + visits: [], link: null, description: null, activity_types: [], @@ -49,7 +41,7 @@ location: null, images: [], user_id: null, - collection: collection_id || null + collection: collection?.id || null }; export let adventureToEdit: Adventure | null = null; @@ -57,9 +49,7 @@ adventure = { id: adventureToEdit?.id || '', name: adventureToEdit?.name || '', - type: adventureToEdit?.type || 'visited', - date: adventureToEdit?.date || null, - end_date: adventureToEdit?.end_date || null, + type: adventureToEdit?.type || 'general', link: adventureToEdit?.link || null, description: adventureToEdit?.description || null, activity_types: adventureToEdit?.activity_types || [], @@ -70,7 +60,8 @@ location: adventureToEdit?.location || null, images: adventureToEdit?.images || [], user_id: adventureToEdit?.user_id || null, - collection: adventureToEdit?.collection || collection_id || null + collection: adventureToEdit?.collection || collection?.id || null, + visits: adventureToEdit?.visits || [] }; let markers: Point[] = []; @@ -91,7 +82,6 @@ activity_type: '' } ]; - checkPointInRegion(); } if (longitude && latitude) { @@ -109,8 +99,6 @@ function clearMap() { console.log('CLEAR'); markers = []; - region_id = null; - region_name = null; } let imageSearch: string = adventure.name || ''; @@ -147,13 +135,6 @@ } } - $: { - if (adventure.type != 'visited') { - region_id = null; - region_name = null; - } - } - async function fetchImage() { let res = await fetch(url); let data = await res.blob(); @@ -236,6 +217,39 @@ } } + let new_start_date: string = ''; + let new_end_date: string = ''; + let new_notes: string = ''; + function addNewVisit() { + if (new_start_date && !new_end_date) { + new_end_date = new_start_date; + } + if (new_start_date > new_end_date) { + addToast('error', 'Start date must be before end date'); + return; + } + if (new_start_date === '' || new_end_date === '') { + addToast('error', 'Please enter a start and end date'); + return; + } + if (new_end_date && !new_start_date) { + addToast('error', 'Please enter a start date'); + return; + } + adventure.visits = [ + ...adventure.visits, + { + start_date: new_start_date, + end_date: new_end_date, + notes: new_notes, + id: '' + } + ]; + new_start_date = ''; + new_end_date = ''; + new_notes = ''; + } + async function reverseGeocode() { let res = await fetch( `https://nominatim.openstreetmap.org/search?q=${adventure.latitude},${adventure.longitude}&format=jsonv2`, @@ -259,7 +273,6 @@ activity_type: data[0]?.type || '' } ]; - checkPointInRegion(); } } console.log(data); @@ -297,29 +310,6 @@ } } - 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) { markers = []; markers = [ @@ -331,8 +321,6 @@ activity_type: '' } ]; - checkPointInRegion(); - console.log(markers); } @@ -355,32 +343,6 @@ async function handleSubmit(event: Event) { 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 (new Date(adventure.date) > new Date(adventure.end_date)) { - addToast('error', 'Start date must be before end date'); - return; - } - } - - if (adventure.end_date && !adventure.date) { - adventure.end_date = null; - adventure.date = null; - } - console.log(adventure); if (adventure.id === '') { let res = await fetch('/api/adventures', { @@ -426,136 +388,240 @@ -