diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index a4fef401..e57b4d8b 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -655,4 +655,49 @@ class CollectionInviteSerializer(serializers.ModelSerializer): class Meta: model = CollectionInvite fields = ['id', 'collection', 'created_at', 'name', 'collection_owner_username', 'collection_user_first_name', 'collection_user_last_name'] - read_only_fields = ['id', 'created_at'] \ No newline at end of file + read_only_fields = ['id', 'created_at'] + +class UltraSlimCollectionSerializer(serializers.ModelSerializer): + location_images = serializers.SerializerMethodField() + location_count = serializers.SerializerMethodField() + + 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' + ] + 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') + + return ContentImageSerializer( + images, + many=True, + context={'request': self.context.get('request')} + ).data + + def get_location_count(self, obj): + """Get count of locations in this collection""" + # This uses the cached count if available, or does a simple count query + return obj.locations.count() + + def to_representation(self, instance): + representation = super().to_representation(instance) + + # make it show the uuid instead of the pk for the user + representation['user'] = str(instance.user.uuid) + + # Make it display the user uuid for the shared users instead of the PK + shared_uuids = [] + for user in instance.shared_with.all(): + shared_uuids.append(str(user.uuid)) + representation['shared_with'] = shared_uuids + return representation + \ No newline at end of file diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index 4e8f79a9..10e09e15 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -1,21 +1,28 @@ -from django.db.models import Q +from django.db.models import Q, Prefetch from django.db.models.functions import Lower 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 +from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite, ContentImage from adventures.permissions import CollectionShared -from adventures.serializers import CollectionSerializer, CollectionInviteSerializer +from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer from users.models import CustomUser as User from adventures.utils import pagination from users.serializers import CustomUserDetailsSerializer as UserSerializer + class CollectionViewSet(viewsets.ModelViewSet): serializer_class = CollectionSerializer permission_classes = [CollectionShared] pagination_class = pagination.StandardResultsSetPagination + def get_serializer_class(self): + """Return different serializers based on the action""" + if self.action in ['list', 'all', 'archived', 'shared']: + return UltraSlimCollectionSerializer + return CollectionSerializer + def apply_sorting(self, queryset): order_by = self.request.query_params.get('order_by', 'name') order_direction = self.request.query_params.get('order_direction', 'asc') @@ -51,30 +58,89 @@ class CollectionViewSet(viewsets.ModelViewSet): """Override to add nested and exclusion contexts based on query parameters""" context = super().get_serializer_context() - # Handle nested parameter - is_nested = self.request.query_params.get('nested', 'false').lower() == 'true' - if is_nested: - context['nested'] = True - - # Handle individual exclusion parameters (if using granular approach) - exclude_params = [ - 'exclude_transportations', - 'exclude_notes', - 'exclude_checklists', - 'exclude_lodging' - ] - - for param in exclude_params: - if self.request.query_params.get(param, 'false').lower() == 'true': - context[param] = True + # Handle nested parameter (only for full serializer actions) + if self.action not in ['list', 'all', 'archived', 'shared']: + is_nested = self.request.query_params.get('nested', 'false').lower() == 'true' + if is_nested: + context['nested'] = True + # Handle individual exclusion parameters (if using granular approach) + exclude_params = [ + 'exclude_transportations', + 'exclude_notes', + 'exclude_checklists', + 'exclude_lodging' + ] + + for param in exclude_params: + if self.request.query_params.get(param, 'false').lower() == 'true': + context[param] = True + return context + + 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( + Prefetch( + 'locations__images', + queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), + to_attr='primary_images' + ) + ) + + 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']: + return 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']: + 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': + 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) + ).distinct() + + # For list action, include collections owned by the user or shared with the user, that are not archived + return Collection.objects.filter( + (Q(user=self.request.user.id) | Q(shared_with=self.request.user)) & Q(is_archived=False) + ).distinct() + + def get_queryset(self): + """Get queryset with optimizations for list actions""" + if self.action in ['list', 'all', 'archived', 'shared']: + return self.get_optimized_queryset_for_listing() + return self.get_base_queryset() def list(self, request): # make sure the user is authenticated if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - queryset = Collection.objects.filter(user=request.user, is_archived=False) + + queryset = Collection.objects.filter( + (Q(user=request.user.id) | Q(shared_with=request.user)) & Q(is_archived=False) + ).distinct().select_related('user').prefetch_related( + Prefetch( + 'locations__images', + queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), + to_attr='primary_images' + ) + ) + queryset = self.apply_sorting(queryset) return self.paginate_and_respond(queryset, request) @@ -85,6 +151,12 @@ class CollectionViewSet(viewsets.ModelViewSet): queryset = Collection.objects.filter( Q(user=request.user) + ).select_related('user').prefetch_related( + Prefetch( + 'locations__images', + queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), + to_attr='primary_images' + ) ) queryset = self.apply_sorting(queryset) @@ -99,6 +171,12 @@ class CollectionViewSet(viewsets.ModelViewSet): queryset = Collection.objects.filter( Q(user=request.user.id) & Q(is_archived=True) + ).select_related('user').prefetch_related( + Prefetch( + 'locations__images', + queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), + to_attr='primary_images' + ) ) queryset = self.apply_sorting(queryset) @@ -173,9 +251,17 @@ class CollectionViewSet(viewsets.ModelViewSet): def shared(self, request): if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) + queryset = Collection.objects.filter( shared_with=request.user + ).select_related('user').prefetch_related( + Prefetch( + 'locations__images', + queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), + to_attr='primary_images' + ) ) + queryset = self.apply_sorting(queryset) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @@ -222,8 +308,6 @@ class CollectionViewSet(viewsets.ModelViewSet): return Response(serializer.data) - # Add these methods to your CollectionViewSet class - @action(detail=True, methods=['post'], url_path='revoke-invite/(?P[^/.]+)') def revoke_invite(self, request, pk=None, uuid=None): """Revoke a pending invite for a collection""" @@ -393,37 +477,6 @@ class CollectionViewSet(viewsets.ModelViewSet): return Response({"success": success_message}) - def get_queryset(self): - if self.action == 'destroy': - return Collection.objects.filter(user=self.request.user.id) - - if self.action in ['update', 'partial_update']: - return 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']: - 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': - 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) - ).distinct() - - # For list action, include collections owned by the user or shared with the user, that are not archived - return Collection.objects.filter( - (Q(user=self.request.user.id) | Q(shared_with=self.request.user)) & Q(is_archived=False) - ).distinct() - def perform_create(self, serializer): # This is ok because you cannot share a collection when creating it serializer.save(user=self.request.user) @@ -435,13 +488,4 @@ class CollectionViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(page, many=True) return paginator.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - - def get_serializer(self, *args, **kwargs): - # Add nested=True to serializer context for GET list requests - context = self.get_serializer_context() - # If this is a list action, make sure nested=True in context - if self.action == 'list': - context['nested'] = True - kwargs['context'] = context - return super().get_serializer(*args, **kwargs) + return Response(serializer.data) \ No newline at end of file diff --git a/frontend/src/lib/components/CollectionCard.svelte b/frontend/src/lib/components/CollectionCard.svelte index 0bc2ed33..51cfcf5d 100644 --- a/frontend/src/lib/components/CollectionCard.svelte +++ b/frontend/src/lib/components/CollectionCard.svelte @@ -9,7 +9,7 @@ import ShareVariant from '~icons/mdi/share-variant'; import { goto } from '$app/navigation'; - import type { Location, Collection, User } from '$lib/types'; + import type { Location, Collection, User, SlimCollection, ContentImage } from '$lib/types'; import { addToast } from '$lib/toasts'; import { t } from 'svelte-i18n'; @@ -55,7 +55,21 @@ } } - export let collection: Collection; + export let collection: Collection | SlimCollection; + + let location_images: ContentImage[] = []; + if ('location_images' in collection) { + location_images = collection.location_images; + } else { + location_images = collection.locations.flatMap((location: Location) => location.images); + } + + let locationLength: number = 0; + if ('location_count' in collection) { + locationLength = collection.location_count; + } else { + locationLength = collection.locations.length; + } async function deleteCollection() { let res = await fetch(`/api/collections/${collection.id}`, { @@ -92,11 +106,7 @@ >
- location.images)} - name={collection.name} - icon="📚" - /> +
@@ -124,7 +134,7 @@

- {collection.locations.length} + {locationLength} {$t('locations.locations')}

diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index 75fab760..f52b5c71 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -1,5 +1,5 @@