diff --git a/backend/server/adventures/migrations/0071_alter_collectionitineraryitem_unique_together_and_more.py b/backend/server/adventures/migrations/0071_alter_collectionitineraryitem_unique_together_and_more.py new file mode 100644 index 00000000..c67e928f --- /dev/null +++ b/backend/server/adventures/migrations/0071_alter_collectionitineraryitem_unique_together_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.8 on 2026-01-06 16:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0070_collectionitineraryday'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='collectionitineraryitem', + unique_together=set(), + ), + migrations.AddField( + model_name='collectionitineraryitem', + name='is_global', + field=models.BooleanField(default=False, help_text='Applies to the whole trip (no specific date)'), + ), + migrations.AddConstraint( + model_name='collectionitineraryitem', + constraint=models.UniqueConstraint(condition=models.Q(('is_global', False), ('date__isnull', False)), fields=('collection', 'date', 'order'), name='unique_order_per_collection_day'), + ), + migrations.AddConstraint( + model_name='collectionitineraryitem', + constraint=models.UniqueConstraint(condition=models.Q(('is_global', True)), fields=('collection', 'order'), name='unique_order_per_collection_global'), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 2e20c3a8..bdeaeac9 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -15,6 +15,7 @@ from adventures.utils.timezones import TIMEZONES from adventures.utils.sports_types import SPORT_TYPE_CHOICES from adventures.utils.get_is_visited import is_location_visited from django.contrib.contenttypes.fields import GenericForeignKey +from django.db.models import Q from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericRelation @@ -721,17 +722,45 @@ class CollectionItineraryItem(models.Model): item = GenericForeignKey("content_type", "object_id") # Placement (planning concern, not content concern) + # Either a specific date or marked as trip-wide (global). Exactly one of these applies. date = models.DateField(blank=True, null=True) + is_global = models.BooleanField(default=False, help_text="Applies to the whole trip (no specific date)") order = models.PositiveIntegerField(help_text="Manual order within a day") created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["date", "order"] - unique_together = ("collection", "date", "order") + constraints = [ + # Ensure unique order per day for dated items + models.UniqueConstraint( + fields=["collection", "date", "order"], + name="unique_order_per_collection_day", + condition=Q(is_global=False) & Q(date__isnull=False), + ), + # Ensure unique order within the global group for a collection + models.UniqueConstraint( + fields=["collection", "order"], + name="unique_order_per_collection_global", + condition=Q(is_global=True), + ), + ] def __str__(self): - return f"{self.collection.name} - {self.content_type.model} - {self.date} ({self.order})" + scope = "GLOBAL" if self.is_global else str(self.date) + return f"{self.collection.name} - {self.content_type.model} - {scope} ({self.order})" + + def clean(self): + # Enforce XOR between date and is_global + if self.is_global and self.date is not None: + raise ValidationError({ + "is_global": "Global items must not have a date.", + "date": "Provide either a date or set is_global, not both.", + }) + if (not self.is_global) and self.date is None: + raise ValidationError({ + "date": "Dated items must include a date. To create a trip-wide item, set is_global=true.", + }) @property def start_datetime(self): diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index ef241931..05435739 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1042,7 +1042,7 @@ class CollectionItineraryItemSerializer(CustomModelSerializer): class Meta: model = CollectionItineraryItem - fields = ['id', 'collection', 'content_type', 'object_id', 'item', 'date', 'order', 'start_datetime', 'end_datetime', 'created_at', 'object_name'] + fields = ['id', 'collection', 'content_type', 'object_id', 'item', 'date', 'is_global', '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): diff --git a/backend/server/adventures/utils/itinerary.py b/backend/server/adventures/utils/itinerary.py index 4769ded3..0c476f27 100644 --- a/backend/server/adventures/utils/itinerary.py +++ b/backend/server/adventures/utils/itinerary.py @@ -74,8 +74,14 @@ def reorder_itinerary_items(user, items_data: List[dict]): continue new_date = item_data.get('date') + new_is_global = item_data.get('is_global') new_order = item_data.get('order') - if new_date is not None: + # If is_global is explicitly provided, set it and reconcile date accordingly + if new_is_global is not None: + item.is_global = bool(new_is_global) + if item.is_global: + item.date = None + if (new_date is not None) and (not item.is_global): # validate date is within collection bounds (if collection has start/end) parsed = None try: @@ -104,6 +110,6 @@ def reorder_itinerary_items(user, items_data: List[dict]): updated_items.append(item) if updated_items: - CollectionItineraryItem.objects.bulk_update(updated_items, ['date', 'order']) + CollectionItineraryItem.objects.bulk_update(updated_items, ['date', 'is_global', 'order']) return updated_items diff --git a/backend/server/adventures/views/import_export_view.py b/backend/server/adventures/views/import_export_view.py index 5fb4c0c7..191d118b 100644 --- a/backend/server/adventures/views/import_export_view.py +++ b/backend/server/adventures/views/import_export_view.py @@ -339,6 +339,7 @@ class BackupViewSet(viewsets.ViewSet): 'content_type': content_type_str, 'item_reference': item_reference, 'date': itinerary_item.date.isoformat() if itinerary_item.date else None, + 'is_global': itinerary_item.is_global, 'order': itinerary_item.order }) @@ -922,7 +923,8 @@ class BackupViewSet(viewsets.ViewSet): collection=collection, content_type=content_type, object_id=content_object.id, - date=itinerary_data.get('date'), + date=itinerary_data.get('date') if not itinerary_data.get('is_global') else None, + is_global=bool(itinerary_data.get('is_global', False)), order=itinerary_data['order'] ) summary['itinerary_items'] += 1 diff --git a/backend/server/adventures/views/itinerary_view.py b/backend/server/adventures/views/itinerary_view.py index e6676997..9ca0b127 100644 --- a/backend/server/adventures/views/itinerary_view.py +++ b/backend/server/adventures/views/itinerary_view.py @@ -49,6 +49,11 @@ class ItineraryViewSet(viewsets.ModelViewSet): object_id = data.get('object_id') update_item_date = data.get('update_item_date', False) target_date = data.get('date') + is_global = data.get('is_global', False) + # Normalize is_global to boolean + if isinstance(is_global, str): + is_global = is_global.lower() in ['1', 'true', 'yes'] + data['is_global'] = is_global # Support legacy field 'location' -> treat as content_type='location' if not content_type_val and data.get('location'): @@ -174,14 +179,20 @@ class ItineraryViewSet(viewsets.ModelViewSet): setattr(content_object, date_field, clean_date) content_object.save(update_fields=[date_field]) - # Ensure order is unique for this collection+date combination + # Ensure order is unique for this collection+group combination (day or global) collection_id = data.get('collection') item_date = data.get('date') item_order = data.get('order', 0) + # Basic XOR validation between date and is_global + if is_global and item_date: + return Response({'error': 'Global itinerary items must not include a date.'}, status=status.HTTP_400_BAD_REQUEST) + if (not is_global) and not item_date: + return Response({'error': 'Dated itinerary items must include a date.'}, status=status.HTTP_400_BAD_REQUEST) + # Validate that the itinerary date (if provided) falls within the # collection's start_date/end_date range (if those bounds are set). - if collection_id and item_date: + if collection_id and item_date and not is_global: # Try parse date or datetime-like values parsed_date = None try: @@ -207,17 +218,29 @@ class ItineraryViewSet(viewsets.ModelViewSet): if collection_obj.end_date and parsed_date > collection_obj.end_date: return Response({'error': 'Itinerary item date is after the collection end_date'}, status=status.HTTP_400_BAD_REQUEST) - if collection_id and item_date: - # Find the maximum order for this collection+date - existing_max = CollectionItineraryItem.objects.filter( - collection_id=collection_id, - date=item_date - ).aggregate(max_order=models.Max('order'))['max_order'] - - # Check if the requested order conflicts with existing items - if existing_max is not None and item_order <= existing_max: - # Assign next available order - data['order'] = existing_max + 1 + if collection_id: + if is_global: + # Max order within global group + existing_max = CollectionItineraryItem.objects.filter( + collection_id=collection_id, + is_global=True + ).aggregate(max_order=models.Max('order'))['max_order'] + if existing_max is None: + existing_max = -1 + if item_order is None or item_order <= existing_max: + data['order'] = existing_max + 1 + elif item_date: + # Find the maximum order for this collection+date + existing_max = CollectionItineraryItem.objects.filter( + collection_id=collection_id, + date=item_date, + is_global=False + ).aggregate(max_order=models.Max('order'))['max_order'] + + # Check if the requested order conflicts with existing items + if existing_max is not None and item_order <= existing_max: + # Assign next available order + data['order'] = existing_max + 1 # Proceed with normal serializer flow using modified data serializer = self.get_serializer(data=data) diff --git a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte index 29a1f8fc..871bd274 100644 --- a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte +++ b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte @@ -55,6 +55,11 @@ $: days = groupItemsByDay(collection); $: unscheduledItems = getUnscheduledItems(collection); + // Trip-wide (global) itinerary items + $: globalItems = (collection.itinerary || []) + .filter((it) => it.is_global) + .map((it) => resolveItineraryItem(it, collection)) + .sort((a, b) => a.order - b.order); // Auto-generate state let isAutoGenerating = false; @@ -605,6 +610,29 @@ days = [...days]; } + function handleDndConsiderGlobal(e: CustomEvent) { + const { items: newItems } = e.detail; + globalItems = newItems; + } + + async function handleDndFinalizeGlobal(e: CustomEvent) { + const { items: newItems, info } = e.detail; + globalItems = newItems; + if ( + info.trigger === TRIGGERS.DROPPED_INTO_ZONE || + info.trigger === TRIGGERS.DROPPED_INTO_ANOTHER + ) { + if (!isSavingOrder) { + isSavingOrder = true; + try { + await saveReorderedItems(); + } finally { + isSavingOrder = false; + } + } + } + } + async function handleDndFinalize(dayIndex: number, e: CustomEvent) { const { items: newItems, info } = e.detail; @@ -635,7 +663,7 @@ async function saveReorderedItems() { try { // Collect all items across all days with their new positions - const itemsToUpdate = days.flatMap((day) => + const dayUpdates = days.flatMap((day) => day.items .filter((item) => item.id && !item[SHADOW_ITEM_MARKER_PROPERTY_NAME]) .map((item, index) => ({ @@ -645,6 +673,12 @@ })) ); + const globalUpdates = globalItems + .filter((item) => item.id && !item[SHADOW_ITEM_MARKER_PROPERTY_NAME]) + .map((item, index) => ({ id: item.id, is_global: true, date: null, order: index })); + + const itemsToUpdate = [...dayUpdates, ...globalUpdates]; + if (itemsToUpdate.length === 0) { return; } @@ -672,6 +706,7 @@ return { ...it, date: updatedItem.date, + is_global: updatedItem.is_global ?? it.is_global, order: updatedItem.order }; } @@ -685,6 +720,69 @@ } } + // Add a trip-wide (global) itinerary item + async function addGlobalItineraryItemForObject(objectType: string, objectId: string) { + const tempId = `temp-global-${Date.now()}`; + const order = globalItems.length; + + const newIt = { + id: tempId, + collection: collection.id, + content_type: objectType, + object_id: objectId, + item: { id: objectId, type: objectType }, + date: null, + is_global: true, + order, + created_at: new Date().toISOString() + }; + + collection.itinerary = [...(collection.itinerary || []), newIt]; + // trigger reactive globals and days + days = groupItemsByDay(collection); + globalItems = (collection.itinerary || []) + .filter((it) => it.is_global) + .map((it) => resolveItineraryItem(it, collection)) + .sort((a, b) => a.order - b.order); + + try { + const res = await fetch('/api/itineraries/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + collection: collection.id, + content_type: objectType, + object_id: objectId, + is_global: true, + order + }) + }); + + if (!res.ok) { + const j = await res.json().catch(() => ({})); + throw new Error(j.detail || 'Failed to add global itinerary item'); + } + + const created = await res.json(); + collection.itinerary = collection.itinerary.map((it) => (it.id === tempId ? created : it)); + // refresh + days = groupItemsByDay(collection); + globalItems = (collection.itinerary || []) + .filter((it) => it.is_global) + .map((it) => resolveItineraryItem(it, collection)) + .sort((a, b) => a.order - b.order); + } catch (err) { + console.error('Error creating global itinerary item:', err); + alert('Failed to add item to trip-wide itinerary.'); + collection.itinerary = collection.itinerary.filter((it) => it.id !== tempId); + days = groupItemsByDay(collection); + globalItems = (collection.itinerary || []) + .filter((it) => it.is_global) + .map((it) => resolveItineraryItem(it, collection)) + .sort((a, b) => a.order - b.order); + } + } + // Handle opening the day picker modal for an unscheduled item function handleOpenDayPickerForItem(type: string, item: any) { // Check if the item already has a date, and if so, add it directly @@ -1139,6 +1237,166 @@ {:else}
+ {$t('itinerary.no_trip_wide_items') || 'No trip-wide items yet'} +
+π Folder view - showing all data
+ {/if} +