From c47ffdfc381f46943dddbf3236612337de93f7c9 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Mon, 5 Jan 2026 12:36:54 -0500 Subject: [PATCH] feat: add CollectionItineraryDay model and related functionality for itinerary day metadata management --- backend/server/adventures/admin.py | 3 +- .../migrations/0070_collectionitineraryday.py | 33 ++++ backend/server/adventures/models.py | 21 +++ backend/server/adventures/serializers.py | 24 ++- backend/server/adventures/urls.py | 1 + .../adventures/views/collection_view.py | 11 +- .../server/adventures/views/itinerary_view.py | 60 ++++++- .../src/lib/components/CollectionModal.svelte | 63 +++---- .../CollectionItineraryPlanner.svelte | 157 +++++++++++++++++- frontend/src/lib/types.ts | 11 ++ 10 files changed, 340 insertions(+), 44 deletions(-) create mode 100644 backend/server/adventures/migrations/0070_collectionitineraryday.py diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 85ce2607..6440ccee 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, format_html from django.urls import reverse -from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem +from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem, CollectionItineraryDay from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity from allauth.account.decorators import secure_admin_login @@ -194,6 +194,7 @@ admin.site.register(CollectionInvite, CollectionInviteAdmin) admin.site.register(Trail) admin.site.register(Activity, ActivityAdmin) admin.site.register(CollectionItineraryItem, CollectionItineraryItemAdmin) +admin.site.register(CollectionItineraryDay) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/adventures/migrations/0070_collectionitineraryday.py b/backend/server/adventures/migrations/0070_collectionitineraryday.py new file mode 100644 index 00000000..66812199 --- /dev/null +++ b/backend/server/adventures/migrations/0070_collectionitineraryday.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.8 on 2026-01-05 17:06 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0069_location_price_location_price_currency'), + ] + + operations = [ + migrations.CreateModel( + name='CollectionItineraryDay', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('date', models.DateField()), + ('name', models.CharField(blank=True, max_length=200, null=True)), + ('description', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='itinerary_days', to='adventures.collection')), + ], + options={ + 'verbose_name': 'Collection Itinerary Day', + 'verbose_name_plural': 'Collection Itinerary Days', + 'ordering': ['date'], + 'unique_together': {('collection', 'date')}, + }, + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index c4e30d6e..2e20c3a8 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -685,6 +685,27 @@ class Activity(models.Model): verbose_name = "Activity" verbose_name_plural = "Activities" +class CollectionItineraryDay(models.Model): + """Metadata for a specific day in a collection's itinerary""" + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + collection = models.ForeignKey('Collection', on_delete=models.CASCADE, related_name='itinerary_days') + date = models.DateField() + name = models.CharField(max_length=200, blank=True, null=True) + description = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [['collection', 'date']] + ordering = ['date'] + verbose_name = "Collection Itinerary Day" + verbose_name_plural = "Collection Itinerary Days" + + def __str__(self): + return f"{ + self.collection.name} - {self.date} - {self.name or 'Unnamed Day'}" + + class CollectionItineraryItem(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 3a302191..0ee03169 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,5 +1,5 @@ import os -from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem +from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem, CollectionItineraryDay from rest_framework import serializers from main.utils import CustomModelSerializer from users.serializers import CustomUserDetailsSerializer @@ -944,6 +944,19 @@ class UltraSlimCollectionSerializer(serializers.ModelSerializer): representation['shared_with'] = shared_uuids return representation +class CollectionItineraryDaySerializer(CustomModelSerializer): + class Meta: + model = CollectionItineraryDay + fields = ['id', 'collection', 'date', 'name', 'description', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def update(self, instance, validated_data): + # Security: Prevent changing collection or date after creation + # This prevents shared users from reassigning itinerary days to themselves + validated_data.pop('collection', None) + validated_data.pop('date', None) + return super().update(instance, validated_data) + class CollectionItineraryItemSerializer(CustomModelSerializer): item = serializers.SerializerMethodField() start_datetime = serializers.ReadOnlyField() @@ -955,6 +968,15 @@ class CollectionItineraryItemSerializer(CustomModelSerializer): fields = ['id', 'collection', 'content_type', 'object_id', 'item', 'date', 'order', 'start_datetime', 'end_datetime', 'created_at', 'object_name'] read_only_fields = ['id', 'created_at', 'start_datetime', 'end_datetime', 'item', 'object_name'] + def update(self, instance, validated_data): + # Security: Prevent changing collection, content_type, or object_id after creation + # This prevents shared users from reassigning itinerary items to themselves + # or linking items to objects they don't have permission to access + validated_data.pop('collection', None) + validated_data.pop('content_type', None) + validated_data.pop('object_id', None) + return super().update(instance, validated_data) + def get_item(self, obj): """Return id and type for the linked item""" if not obj.item: diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 4759080b..11e7e3d7 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -24,6 +24,7 @@ router.register(r'trails', TrailViewSet, basename='trails') router.register(r'activities', ActivityViewSet, basename='activities') router.register(r'visits', VisitViewSet, basename='visits') router.register(r'itineraries', ItineraryViewSet, basename='itineraries') +router.register(r'itinerary-days', ItineraryDayViewSet, basename='itinerary-days') urlpatterns = [ # Include the router under the 'api/' prefix diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index b2a30ab5..b1148188 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -4,9 +4,9 @@ from django.db import transaction from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response -from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite, ContentImage, CollectionItineraryItem, Lodging +from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite, ContentImage, CollectionItineraryItem, Lodging, CollectionItineraryDay from adventures.permissions import CollectionShared -from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer, CollectionItineraryItemSerializer +from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer, CollectionItineraryItemSerializer, CollectionItineraryDaySerializer from users.models import CustomUser as User from adventures.utils import pagination from users.serializers import CustomUserDetailsSerializer as UserSerializer @@ -216,7 +216,7 @@ class CollectionViewSet(viewsets.ModelViewSet): return Response(serializer.data) def retrieve(self, request, pk=None): - """Retrieve a collection and include itinerary items in the response.""" + """Retrieve a collection and include itinerary items and day metadata in the response.""" collection = self.get_object() serializer = self.get_serializer(collection) data = serializer.data @@ -225,6 +225,11 @@ class CollectionViewSet(viewsets.ModelViewSet): itinerary_items = CollectionItineraryItem.objects.filter(collection=collection) itinerary_serializer = CollectionItineraryItemSerializer(itinerary_items, many=True) data['itinerary'] = itinerary_serializer.data + + # Include itinerary day metadata + itinerary_days = CollectionItineraryDay.objects.filter(collection=collection) + days_serializer = CollectionItineraryDaySerializer(itinerary_days, many=True) + data['itinerary_days'] = days_serializer.data return Response(data) diff --git a/backend/server/adventures/views/itinerary_view.py b/backend/server/adventures/views/itinerary_view.py index c4c81af9..e6676997 100644 --- a/backend/server/adventures/views/itinerary_view.py +++ b/backend/server/adventures/views/itinerary_view.py @@ -1,9 +1,9 @@ -from adventures.models import Location, Collection, CollectionItineraryItem, Transportation, Note, Lodging, Visit, Checklist, Note +from adventures.models import Location, Collection, CollectionItineraryItem, Transportation, Note, Lodging, Visit, Checklist, Note, CollectionItineraryDay import datetime from django.utils.dateparse import parse_date, parse_datetime from django.contrib.contenttypes.models import ContentType from django.db import models -from adventures.serializers import CollectionItineraryItemSerializer +from adventures.serializers import CollectionItineraryItemSerializer, CollectionItineraryDaySerializer from adventures.utils.itinerary import reorder_itinerary_items from adventures.utils.autogenerate_itinerary import auto_generate_itinerary from rest_framework import viewsets, status @@ -342,4 +342,58 @@ class ItineraryViewSet(viewsets.ModelViewSet): "items": serializer.data }, status=status.HTTP_201_CREATED) except ValidationError as e: - return Response(e.detail, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(e.detail, status=status.HTTP_400_BAD_REQUEST) + + +class ItineraryDayViewSet(viewsets.ModelViewSet): + """ViewSet for managing itinerary day metadata (names and descriptions)""" + serializer_class = CollectionItineraryDaySerializer + permission_classes = [IsOwnerOrSharedWithFullAccess] + + def get_queryset(self): + user = self.request.user + + if not user.is_authenticated: + return CollectionItineraryDay.objects.none() + + # Return day metadata from collections the user owns or is shared with + return CollectionItineraryDay.objects.filter( + Q(collection__user=user) | Q(collection__shared_with=user) + ).distinct().select_related('collection', 'collection__user').order_by('date') + + def perform_create(self, serializer): + """Ensure the user has permission to modify the collection""" + collection = serializer.validated_data.get('collection') + + if not collection: + raise ValidationError("Collection is required") + + # Check if user has permission to modify this collection + if not (collection.user == self.request.user or + collection.shared_with.filter(id=self.request.user.id).exists()): + raise PermissionDenied("You do not have permission to modify this collection") + + serializer.save() + + def perform_update(self, serializer): + """Ensure the user has permission to modify the collection""" + instance = self.get_object() + collection = instance.collection + + # Check if user has permission to modify this collection + if not (collection.user == self.request.user or + collection.shared_with.filter(id=self.request.user.id).exists()): + raise PermissionDenied("You do not have permission to modify this collection") + + serializer.save() + + def perform_destroy(self, instance): + """Ensure the user has permission to modify the collection""" + collection = instance.collection + + # Check if user has permission to modify this collection + if not (collection.user == self.request.user or + collection.shared_with.filter(id=self.request.user.id).exists()): + raise PermissionDenied("You do not have permission to modify this collection") + + instance.delete() \ No newline at end of file diff --git a/frontend/src/lib/components/CollectionModal.svelte b/frontend/src/lib/components/CollectionModal.svelte index e4342a86..0e130330 100644 --- a/frontend/src/lib/components/CollectionModal.svelte +++ b/frontend/src/lib/components/CollectionModal.svelte @@ -35,7 +35,8 @@ status: collectionToEdit?.status || 'folder', days_until_start: collectionToEdit?.days_until_start ?? null, primary_image: collectionToEdit?.primary_image ?? null, - primary_image_id: collectionToEdit?.primary_image_id ?? null + primary_image_id: collectionToEdit?.primary_image_id ?? null, + itinerary_days: [] }; let availableImages: ContentImage[] = []; @@ -389,36 +390,6 @@ {/if} - - {#if collection.is_public && collection.id} -
-
-

{$t('adventures.share_collection')}

-
- - -
-
-
- {/if} -
@@ -493,6 +464,36 @@
+ + {#if collection.is_public && collection.id} +
+
+

{$t('adventures.share_collection')}

+
+ + +
+
+
+ {/if} +
-
-

{day.displayDate}

-
+
+ +
+

{day.displayDate}

+ + + {#if canModify} + {#if day.dayMetadata?.name} + { + const newName = e.currentTarget.value.trim() || null; + if (newName !== day.dayMetadata?.name) { + saveDayMetadata(day.date, newName, day.dayMetadata?.description || null); + } + }} + /> + {:else} + + { + const newName = e.currentTarget.value.trim() || null; + if (newName) { + saveDayMetadata(day.date, newName, day.dayMetadata?.description || null); + } else { + e.currentTarget.classList.add('w-0'); + e.currentTarget.classList.remove('w-auto'); + } + }} + on:focus={(e) => { + e.currentTarget.classList.remove('w-0'); + e.currentTarget.classList.add('w-auto'); + }} + /> + {/if} + {:else if day.dayMetadata?.name} + — {day.dayMetadata.name} + {/if} +
+ + +
Day {dayNumber} of {totalDays} {day.items.length} {day.items.length === 1 ? 'item' : 'items'} {#if day.overnightLodging.length > 0} - Overnight Lodging + Overnight {/if}
+ + + {#if canModify} +