From 1b64f8db573d7bdd201c1c4c4e59c6675ab8bb83 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Fri, 2 Jan 2026 13:21:46 -0500 Subject: [PATCH] feat: add primary image support to Collection model, serializers, and UI components --- .../0066_collection_primary_image.py | 19 ++ backend/server/adventures/models.py | 7 + backend/server/adventures/serializers.py | 84 +++++++- .../adventures/views/collection_view.py | 55 ++--- .../adventures/views/import_export_view.py | 52 ++++- .../src/lib/components/CollectionModal.svelte | 197 ++++++++++++++++-- .../components/cards/CollectionCard.svelte | 29 ++- frontend/src/lib/types.ts | 4 + frontend/src/routes/collections/+page.svelte | 14 +- 9 files changed, 400 insertions(+), 61 deletions(-) create mode 100644 backend/server/adventures/migrations/0066_collection_primary_image.py diff --git a/backend/server/adventures/migrations/0066_collection_primary_image.py b/backend/server/adventures/migrations/0066_collection_primary_image.py new file mode 100644 index 00000000..3c659efc --- /dev/null +++ b/backend/server/adventures/migrations/0066_collection_primary_image.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.6 on 2026-01-02 18:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0065_transportation_end_code_transportation_start_code'), + ] + + operations = [ + migrations.AddField( + model_name='collection', + name='primary_image', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_for_collections', to='adventures.contentimage'), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index e1cc530f..3c4629b0 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -286,6 +286,13 @@ class Collection(models.Model): is_archived = models.BooleanField(default=False) shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True) link = models.URLField(blank=True, null=True, max_length=2083) + primary_image = models.ForeignKey( + 'ContentImage', + on_delete=models.SET_NULL, + related_name='primary_for_collections', + null=True, + blank=True, + ) # if connected locations are private and collection is public, raise an error def clean(self): diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 7949aab1..9c41a46c 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -659,11 +659,41 @@ class CollectionSerializer(CustomModelSerializer): lodging = serializers.SerializerMethodField() status = serializers.SerializerMethodField() days_until_start = serializers.SerializerMethodField() + primary_image = ContentImageSerializer(read_only=True) + primary_image_id = serializers.PrimaryKeyRelatedField( + queryset=ContentImage.objects.all(), + source='primary_image', + write_only=True, + required=False, + allow_null=True, + ) class Meta: model = Collection - fields = ['id', 'description', 'user', 'name', 'is_public', 'locations', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'lodging', 'status', 'days_until_start'] - read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'shared_with', 'status', 'days_until_start'] + fields = [ + 'id', + 'description', + 'user', + 'name', + 'is_public', + 'locations', + 'created_at', + 'start_date', + 'end_date', + 'transportations', + 'notes', + 'updated_at', + 'checklists', + 'is_archived', + 'shared_with', + 'link', + 'lodging', + 'status', + 'days_until_start', + 'primary_image', + 'primary_image_id', + ] + read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'shared_with', 'status', 'days_until_start', 'primary_image'] def get_locations(self, obj): if self.context.get('nested', False): @@ -735,6 +765,37 @@ class CollectionSerializer(CustomModelSerializer): return None + def validate(self, attrs): + data = super().validate(attrs) + + # Only validate primary image when explicitly provided + if 'primary_image' not in data: + return data + + primary_image = data.get('primary_image') + if primary_image is None: + return data + + request = self.context.get('request') + if request and primary_image.user != request.user: + raise serializers.ValidationError({ + 'primary_image_id': 'You can only choose cover images you own.' + }) + + if self.instance and not self._image_belongs_to_collection(primary_image, self.instance): + raise serializers.ValidationError({ + 'primary_image_id': 'Cover image must come from a location in this collection.' + }) + + return data + + def _image_belongs_to_collection(self, image, collection): + if ContentImage.objects.filter(id=image.id, location__collections=collection).exists(): + return True + if ContentImage.objects.filter(id=image.id, visit__location__collections=collection).exists(): + return True + return False + def to_representation(self, instance): representation = super().to_representation(instance) @@ -768,22 +829,33 @@ class UltraSlimCollectionSerializer(serializers.ModelSerializer): location_count = serializers.SerializerMethodField() status = serializers.SerializerMethodField() days_until_start = serializers.SerializerMethodField() + primary_image = ContentImageSerializer(read_only=True) class Meta: model = Collection fields = [ 'id', 'user', 'name', 'description', 'is_public', 'start_date', 'end_date', 'is_archived', 'link', 'created_at', 'updated_at', 'location_images', - 'location_count', 'shared_with', 'status', 'days_until_start' + 'location_count', 'shared_with', 'status', 'days_until_start', 'primary_image' ] read_only_fields = fields # All fields are read-only for listing def get_location_images(self, obj): """Get primary images from locations in this collection, optimized with select_related""" # Filter first, then slice (removed slicing) - images = ContentImage.objects.filter( - location__collections=obj - ).select_related('user').prefetch_related('location') + images = list( + ContentImage.objects.filter(location__collections=obj) + .select_related('user') + ) + + def sort_key(image): + if obj.primary_image and image.id == obj.primary_image.id: + return (0, str(image.id)) + if image.is_primary: + return (1, str(image.id)) + return (2, str(image.id)) + + images.sort(key=sort_key) serializer = ContentImageSerializer( images, diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index 46550d15..b2a30ab5 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -105,7 +105,7 @@ class CollectionViewSet(viewsets.ModelViewSet): def get_optimized_queryset_for_listing(self): """Get optimized queryset for list actions with prefetching""" - return self.get_base_queryset().select_related('user').prefetch_related( + return self.get_base_queryset().select_related('user', 'primary_image').prefetch_related( Prefetch( 'locations__images', queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), @@ -116,34 +116,37 @@ class CollectionViewSet(viewsets.ModelViewSet): def get_base_queryset(self): """Base queryset logic extracted for reuse""" if self.action == 'destroy': - return Collection.objects.filter(user=self.request.user.id) - - if self.action in ['update', 'partial_update', 'leave']: - return Collection.objects.filter( + queryset = Collection.objects.filter(user=self.request.user.id) + elif self.action in ['update', 'partial_update', 'leave']: + queryset = Collection.objects.filter( Q(user=self.request.user.id) | Q(shared_with=self.request.user) ).distinct() - # Allow access to collections with pending invites for accept/decline actions - if self.action in ['accept_invite', 'decline_invite']: + elif self.action in ['accept_invite', 'decline_invite']: if not self.request.user.is_authenticated: - return Collection.objects.none() - return Collection.objects.filter( - Q(user=self.request.user.id) | - Q(shared_with=self.request.user) | - Q(invites__invited_user=self.request.user) - ).distinct() - - if self.action == 'retrieve': + queryset = Collection.objects.none() + else: + queryset = Collection.objects.filter( + Q(user=self.request.user.id) + | Q(shared_with=self.request.user) + | Q(invites__invited_user=self.request.user) + ).distinct() + elif self.action == 'retrieve': if not self.request.user.is_authenticated: - return Collection.objects.filter(is_public=True) - return Collection.objects.filter( - Q(is_public=True) | Q(user=self.request.user.id) | Q(shared_with=self.request.user) + queryset = Collection.objects.filter(is_public=True) + else: + queryset = Collection.objects.filter( + Q(is_public=True) + | Q(user=self.request.user.id) + | Q(shared_with=self.request.user) + ).distinct() + else: + # For list action and default base queryset, return collections owned by the user (exclude shared) + queryset = Collection.objects.filter( + Q(user=self.request.user.id) & Q(is_archived=False) ).distinct() - - # For list action and default base queryset, return collections owned by the user (exclude shared) - return Collection.objects.filter( - Q(user=self.request.user.id) & Q(is_archived=False) - ).distinct() + + return queryset.select_related('primary_image') def get_queryset(self): """Get queryset with optimizations for list actions""" @@ -160,7 +163,7 @@ class CollectionViewSet(viewsets.ModelViewSet): # via the `shared` action). queryset = Collection.objects.filter( Q(user=request.user.id) & Q(is_archived=False) - ).distinct().select_related('user').prefetch_related( + ).distinct().select_related('user', 'primary_image').prefetch_related( Prefetch( 'locations__images', queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), @@ -179,7 +182,7 @@ class CollectionViewSet(viewsets.ModelViewSet): queryset = Collection.objects.filter( Q(user=request.user) - ).select_related('user').prefetch_related( + ).select_related('user', 'primary_image').prefetch_related( Prefetch( 'locations__images', queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), @@ -199,7 +202,7 @@ class CollectionViewSet(viewsets.ModelViewSet): queryset = Collection.objects.filter( Q(user=request.user.id) & Q(is_archived=True) - ).select_related('user').prefetch_related( + ).select_related('user', 'primary_image').prefetch_related( Prefetch( 'locations__images', queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), diff --git a/backend/server/adventures/views/import_export_view.py b/backend/server/adventures/views/import_export_view.py index 7c808731..5fb4c0c7 100644 --- a/backend/server/adventures/views/import_export_view.py +++ b/backend/server/adventures/views/import_export_view.py @@ -77,6 +77,9 @@ class BackupViewSet(viewsets.ViewSet): 'icon': category.icon, }) + # Track images so we can reference them for collection primary images + image_export_map = {} + # Export Collections for idx, collection in enumerate(user.collection_set.all()): export_data['collections'].append({ @@ -177,7 +180,7 @@ class BackupViewSet(viewsets.ViewSet): location_data['trails'].append(trail_data) # Add images - for image in location.images.all(): + for image_index, image in enumerate(location.images.all()): image_data = { 'immich_id': image.immich_id, 'is_primary': image.is_primary, @@ -186,6 +189,13 @@ class BackupViewSet(viewsets.ViewSet): if image.image: image_data['filename'] = image.image.name.split('/')[-1] location_data['images'].append(image_data) + + image_export_map[image.id] = { + 'location_export_id': idx, + 'image_index': image_index, + 'immich_id': image.immich_id, + 'filename': image_data['filename'], + } # Add attachments for attachment in location.attachments.all(): @@ -198,6 +208,12 @@ class BackupViewSet(viewsets.ViewSet): location_data['attachments'].append(attachment_data) export_data['locations'].append(location_data) + + # Attach collection primary image references (if any) + for idx, collection in enumerate(user.collection_set.all()): + primary = collection.primary_image + if primary and primary.id in image_export_map: + export_data['collections'][idx]['primary_image'] = image_export_map[primary.id] # Export Transportation for idx, transport in enumerate(user.transportation_set.all()): @@ -518,6 +534,9 @@ class BackupViewSet(viewsets.ViewSet): category_map[cat_data['name']] = category summary['categories'] += 1 + pending_primary_images = [] + location_images_map = {} + # Import Collections for col_data in backup_data.get('collections', []): collection = Collection.objects.create( @@ -541,6 +560,13 @@ class BackupViewSet(viewsets.ViewSet): collection.shared_with.add(shared_user) except User.DoesNotExist: pass + + # Defer primary image assignment until images are created + if col_data.get('primary_image'): + pending_primary_images.append({ + 'collection_export_id': col_data['export_id'], + 'data': col_data['primary_image'], + }) # Import Locations for adv_data in backup_data.get('locations', []): @@ -584,6 +610,7 @@ class BackupViewSet(viewsets.ViewSet): ) location.save(_skip_geocode=True) # Skip geocoding for now location_map[adv_data['export_id']] = location + location_images_map.setdefault(adv_data['export_id'], []) # Add to collections using export_ids - MUST be done after save() for collection_export_id in adv_data.get('collection_export_ids', []): @@ -681,13 +708,14 @@ class BackupViewSet(viewsets.ViewSet): for img_data in adv_data.get('images', []): immich_id = img_data.get('immich_id') if immich_id: - ContentImage.objects.create( + new_img = ContentImage.objects.create( user=user, immich_id=immich_id, is_primary=img_data.get('is_primary', False), content_type=content_type, object_id=location.id ) + location_images_map[adv_data['export_id']].append(new_img) summary['images'] += 1 else: filename = img_data.get('filename') @@ -695,13 +723,14 @@ class BackupViewSet(viewsets.ViewSet): try: img_content = zip_file.read(f'images/{filename}') img_file = ContentFile(img_content, name=filename) - ContentImage.objects.create( + new_img = ContentImage.objects.create( user=user, image=img_file, is_primary=img_data.get('is_primary', False), content_type=content_type, object_id=location.id ) + location_images_map[adv_data['export_id']].append(new_img) summary['images'] += 1 except KeyError: pass @@ -725,6 +754,23 @@ class BackupViewSet(viewsets.ViewSet): pass summary['locations'] += 1 + + # Apply primary image selections now that images exist + for entry in pending_primary_images: + collection = collection_map.get(entry['collection_export_id']) + data = entry.get('data', {}) or {} + if not collection: + continue + + loc_export_id = data.get('location_export_id') + img_index = data.get('image_index') + if loc_export_id is None or img_index is None: + continue + + images_for_location = location_images_map.get(loc_export_id, []) + if 0 <= img_index < len(images_for_location): + collection.primary_image = images_for_location[img_index] + collection.save(update_fields=['primary_image']) # Import Transportation transportation_map = {} # Map export_id to actual transportation object diff --git a/frontend/src/lib/components/CollectionModal.svelte b/frontend/src/lib/components/CollectionModal.svelte index 1f675c8c..e4342a86 100644 --- a/frontend/src/lib/components/CollectionModal.svelte +++ b/frontend/src/lib/components/CollectionModal.svelte @@ -1,13 +1,10 @@