feat: add CollectionItineraryDay model and related functionality for itinerary day metadata management

This commit is contained in:
Sean Morley
2026-01-05 12:36:54 -05:00
parent 398dc06571
commit c47ffdfc38
10 changed files with 340 additions and 44 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
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()

View File

@@ -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 @@
</div>
{/if}
<!-- Share Link Section (only if public and has ID) -->
{#if collection.is_public && collection.id}
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<h3 class="font-semibold text-lg mb-3">{$t('adventures.share_collection')}</h3>
<div class="flex items-center gap-3">
<input
type="text"
value="{window.location.origin}/collections/{collection.id}"
readonly
class="input input-bordered flex-1 font-mono text-sm"
/>
<button
type="button"
on:click={() => {
navigator.clipboard.writeText(
`${window.location.origin}/collections/${collection.id}`
);
addToast('success', $t('adventures.link_copied'));
}}
class="btn btn-primary gap-2"
>
<LinkIcon class="w-4 h-4" />
{$t('adventures.copy_link')}
</button>
</div>
</div>
</div>
{/if}
<!-- Cover Image Selection -->
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6 space-y-4">
@@ -493,6 +464,36 @@
</div>
</div>
<!-- Share Link Section (only if public and has ID) -->
{#if collection.is_public && collection.id}
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<h3 class="font-semibold text-lg mb-3">{$t('adventures.share_collection')}</h3>
<div class="flex items-center gap-3">
<input
type="text"
value="{window.location.origin}/collections/{collection.id}"
readonly
class="input input-bordered flex-1 font-mono text-sm"
/>
<button
type="button"
on:click={() => {
navigator.clipboard.writeText(
`${window.location.origin}/collections/${collection.id}`
);
addToast('success', $t('adventures.link_copied'));
}}
class="btn btn-primary gap-2"
>
<LinkIcon class="w-4 h-4" />
{$t('adventures.copy_link')}
</button>
</div>
</div>
</div>
{/if}
<!-- Action Buttons -->
<div class="flex gap-3 justify-end pt-4">
<button type="button" class="btn btn-neutral gap-2" on:click={close}>

View File

@@ -3,6 +3,7 @@
import type {
Collection,
CollectionItineraryItem,
CollectionItineraryDay,
Location,
Transportation,
Lodging,
@@ -47,6 +48,7 @@
displayDate: string;
items: ResolvedItineraryItem[];
overnightLodging: Lodging[]; // Lodging where guest is staying overnight (not check-in day)
dayMetadata: CollectionItineraryDay | null; // Day name and description
};
$: days = groupItemsByDay(collection);
@@ -525,11 +527,16 @@
const iso = dt.toISODate();
const items = (grouped.get(iso) || []).sort((a, b) => a.order - b.order);
const overnightLodging = getOvernightLodgingForDate(collection, iso);
// Find day metadata for this date
const dayMetadata = collection.itinerary_days?.find((d) => d.date === iso) || null;
days.push({
date: iso,
displayDate: dt.toFormat('cccc, LLLL d, yyyy'),
items,
overnightLodging
overnightLodging,
dayMetadata
});
}
@@ -915,6 +922,66 @@
days = groupItemsByDay(collection);
}
}
// Save or update day metadata (name and description)
async function saveDayMetadata(date: string, name: string | null, description: string | null) {
if (!canModify) return;
try {
// Find existing day metadata for this date
const existing = collection.itinerary_days?.find((d) => d.date === date);
if (existing) {
// Update existing day metadata
const response = await fetch(`/api/itinerary-days/${existing.id}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name || null,
description: description || null
})
});
if (!response.ok) throw new Error('Failed to update day metadata');
const updated = await response.json();
// Update collection.itinerary_days immutably
collection.itinerary_days = collection.itinerary_days?.map((d) =>
d.id === existing.id ? updated : d
);
} else {
// Create new day metadata
const response = await fetch('/api/itinerary-days/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
collection: collection.id,
date,
name: name || null,
description: description || null
})
});
if (!response.ok) throw new Error('Failed to create day metadata');
const newDay = await response.json();
// Add to collection.itinerary_days immutably
collection.itinerary_days = [...(collection.itinerary_days || []), newDay];
}
// Trigger reactivity by reassigning collection
collection = { ...collection };
days = groupItemsByDay(collection);
} catch (err) {
console.error('Error saving day metadata:', err);
}
}
</script>
{#if isLocationModalOpen}
@@ -1106,16 +1173,96 @@
</div>
<!-- Title and meta -->
<div class="flex-1 min-w-0">
<h3 class="text-lg md:text-xl font-bold truncate">{day.displayDate}</h3>
<div class="text-sm opacity-70 mt-1 flex items-center gap-3">
<div class="flex-1 min-w-0 space-y-1">
<!-- Main date title + optional day name -->
<div class="flex items-baseline gap-2 flex-wrap">
<h3 class="text-lg md:text-xl font-bold">{day.displayDate}</h3>
<!-- Day name - inline with date -->
{#if canModify}
{#if day.dayMetadata?.name}
<input
type="text"
class="input input-ghost text-base font-medium px-1 py-0 -ml-1 focus:bg-base-100 focus:px-2 transition-all flex-shrink min-w-0"
style="width: {(day.dayMetadata.name.length + 5) * 8}px; max-width: 300px;"
value={day.dayMetadata.name}
placeholder="Day name"
on:blur={(e) => {
const newName = e.currentTarget.value.trim() || null;
if (newName !== day.dayMetadata?.name) {
saveDayMetadata(day.date, newName, day.dayMetadata?.description || null);
}
}}
/>
{:else}
<button
type="button"
class="text-sm opacity-40 hover:opacity-100 transition-opacity px-1"
on:click={(e) => {
const input = e.currentTarget.nextElementSibling;
if (input) input.focus();
}}
>
+ name
</button>
<input
type="text"
class="input input-ghost text-base font-medium px-1 py-0 opacity-0 focus:opacity-100 focus:bg-base-100 focus:px-2 transition-all w-0 focus:w-auto"
style="max-width: 300px;"
placeholder="Day name"
value=""
on:blur={(e) => {
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}
<span class="text-base font-medium opacity-90">{day.dayMetadata.name}</span>
{/if}
</div>
<!-- Day meta info -->
<div class="text-sm opacity-70 flex items-center gap-3">
<span class="font-medium">Day {dayNumber} of {totalDays}</span>
<span class="opacity-50"></span>
<span>{day.items.length} {day.items.length === 1 ? 'item' : 'items'}</span>
{#if day.overnightLodging.length > 0}
<span class="badge badge-info badge-outline">Overnight Lodging</span>
<span class="badge badge-info badge-outline badge-sm">Overnight</span>
{/if}
</div>
<!-- Description - shows when present, ghost input when editing -->
{#if canModify}
<textarea
class="textarea textarea-ghost w-full px-2 py-1 text-sm leading-relaxed resize-none focus:bg-base-100 transition-all {day
.dayMetadata?.description
? ''
: 'opacity-40 hover:opacity-70 focus:opacity-100'}"
rows="2"
placeholder="+ Add description..."
value={day.dayMetadata?.description || ''}
on:blur={(e) => {
const newDesc = e.currentTarget.value.trim() || null;
if (newDesc !== day.dayMetadata?.description) {
saveDayMetadata(day.date, day.dayMetadata?.name || null, newDesc);
}
}}
/>
{:else if day.dayMetadata?.description}
<p class="text-sm leading-relaxed opacity-80 whitespace-pre-wrap px-2 py-1">
{day.dayMetadata.description}
</p>
{/if}
</div>
<!-- Actions: saving indicator + Add dropdown -->

View File

@@ -146,6 +146,7 @@ export type Collection = {
primary_image?: ContentImage | null;
primary_image_id?: string | null;
itinerary: CollectionItineraryItem[];
itinerary_days: CollectionItineraryDay[]; // Day metadata (names/descriptions)
status: 'folder' | 'upcoming' | 'in_progress' | 'completed';
days_until_start: number | null;
};
@@ -554,6 +555,16 @@ export type RecommendationResponse = {
};
};
export type CollectionItineraryDay = {
id: string;
collection: string; // UUID of the collection
date: string; // ISO 8601 date string (YYYY-MM-DD)
name: string | null; // Optional custom name for the day
description: string | null; // Optional description for the day
created_at: string; // ISO 8601 date string
updated_at: string; // ISO 8601 date string
};
export type CollectionItineraryItem = {
id: string;
collection: string; // UUID of the collection