Collection Speed Improvements (#874)

* Add UltraSlimCollectionSerializer and update CollectionViewSet for optimized listing

- Introduced UltraSlimCollectionSerializer for efficient data representation.
- Updated CollectionViewSet to use the new serializer for list actions.
- Enhanced queryset optimizations with prefetching for related images.
- Modified frontend components to support SlimCollection type for better performance.

* Optimize rendering of collection cards by adding a unique key to the each block
This commit is contained in:
Sean Morley
2025-09-22 08:34:23 -04:00
committed by GitHub
parent 240c617010
commit 8a0f7310b0
7 changed files with 203 additions and 86 deletions

View File

@@ -656,3 +656,48 @@ class CollectionInviteSerializer(serializers.ModelSerializer):
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']
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

View File

@@ -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 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'
]
# 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
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<uuid>[^/.]+)')
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)
@@ -436,12 +489,3 @@ class CollectionViewSet(viewsets.ModelViewSet):
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)

View File

@@ -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 @@
>
<!-- Image Carousel -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel
images={collection.locations.flatMap((location) => location.images)}
name={collection.name}
icon="📚"
/>
<CardCarousel images={location_images} name={collection.name} icon="📚" />
<!-- Badge Overlay -->
<div class="absolute top-4 left-4 flex flex-col gap-2">
@@ -124,7 +134,7 @@
<!-- Adventure Count -->
<p class="text-sm text-base-content/70">
{collection.locations.length}
{locationLength}
{$t('locations.locations')}
</p>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { Collection, User } from '$lib/types';
import type { Collection, SlimCollection, User } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
@@ -11,7 +11,7 @@
import Share from '~icons/mdi/share';
import Clear from '~icons/mdi/close';
export let collection: Collection;
export let collection: SlimCollection | Collection;
// Extended user interface to include status
interface UserWithStatus extends User {
@@ -160,6 +160,7 @@
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
class="modal-box w-11/12 max-w-5xl p-6 space-y-6"
role="dialog"

View File

@@ -137,6 +137,23 @@ export type Collection = {
link?: string | null;
};
export type SlimCollection = {
id: string;
user: string;
name: string;
description: string;
is_public: boolean;
start_date: string | null;
end_date: string | null;
is_archived: boolean;
link: string | null;
created_at: string;
updated_at: string;
location_images: ContentImage[];
location_count: number;
shared_with: string[];
};
export type GeocodeSearchResult = {
lat?: string;
lon?: string;

View File

@@ -1,7 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { Location, Collection } from '$lib/types';
import type { Location, Collection, SlimCollection } from '$lib/types';
import type { Actions } from '@sveltejs/kit';
import { fetchCSRFToken } from '$lib/index.server';
@@ -62,11 +62,11 @@ export const load = (async (event) => {
next: collectionsData.next,
previous: collectionsData.previous,
count: collectionsData.count,
sharedCollections: sharedData as Collection[],
sharedCollections: sharedData as SlimCollection[],
currentPage,
order_by,
order_direction,
archivedCollections: archivedData as Collection[],
archivedCollections: archivedData as SlimCollection[],
invites: invitesData
}
};

View File

@@ -5,7 +5,7 @@
import CollectionLink from '$lib/components/CollectionLink.svelte';
import CollectionModal from '$lib/components/CollectionModal.svelte';
import NotFound from '$lib/components/NotFound.svelte';
import type { Collection, CollectionInvite } from '$lib/types';
import type { Collection, CollectionInvite, SlimCollection } from '$lib/types';
import { t } from 'svelte-i18n';
import Plus from '~icons/mdi/plus';
@@ -23,9 +23,9 @@
export let data: any;
console.log('Collections page data:', data);
let collections: Collection[] = data.props.adventures || [];
let sharedCollections: Collection[] = data.props.sharedCollections || [];
let archivedCollections: Collection[] = data.props.archivedCollections || [];
let collections: SlimCollection[] = data.props.adventures || [];
let sharedCollections: SlimCollection[] = data.props.sharedCollections || [];
let archivedCollections: SlimCollection[] = data.props.archivedCollections || [];
let newType: string = '';
let resultsPerPage: number = 25;
@@ -142,7 +142,7 @@
}
}
function saveOrCreate(event: CustomEvent<Collection>) {
function saveOrCreate(event: CustomEvent<SlimCollection>) {
if (collections.find((collection) => collection.id === event.detail.id)) {
collections = collections.map((collection) => {
if (collection.id === event.detail.id) {
@@ -156,8 +156,8 @@
isShowingCollectionModal = false;
}
function editCollection(event: CustomEvent<Collection>) {
collectionToEdit = event.detail;
function editCollection(event: CustomEvent<SlimCollection>) {
collectionToEdit = event.detail as unknown as Collection;
isShowingCollectionModal = true;
}
@@ -181,7 +181,7 @@
}
}
function saveEdit(event: CustomEvent<Collection>) {
function saveEdit(event: CustomEvent<SlimCollection>) {
collections = collections.map((adventure) => {
if (adventure.id === event.detail.id) {
return event.detail;
@@ -490,7 +490,7 @@
<div
class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6"
>
{#each currentCollections as collection}
{#each currentCollections as collection (collection.id)}
<CollectionCard
type=""
{collection}