From cfefd9bd5b2c554b7ea696b845ef85984e0c8ec1 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 20 Feb 2025 15:33:05 +0100 Subject: [PATCH] api to fetch albums --- sas/api.py | 28 +++++++++++++--- sas/schemas.py | 32 +++++++++++++++++++ .../sas/components/ajax-select-index.ts | 6 ++-- sas/tests/test_api.py | 13 ++++++++ sas/widgets/select.py | 6 ++-- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/sas/api.py b/sas/api.py index 11355de5..d9e2ad2e 100644 --- a/sas/api.py +++ b/sas/api.py @@ -1,6 +1,3 @@ -from typing import Annotated - -from annotated_types import MinLen from django.conf import settings from django.db.models import F from django.urls import reverse @@ -16,6 +13,8 @@ from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoo from core.models import Notification, User from sas.models import Album, PeoplePictureRelation, Picture from sas.schemas import ( + AlbumAutocompleteSchema, + AlbumFilterSchema, AlbumSchema, IdentifiedUserSchema, ModerationRequestSchema, @@ -31,11 +30,30 @@ class AlbumController(ControllerBase): @route.get( "/search", response=PaginatedResponseSchema[AlbumSchema], + permissions=[IsAuthenticated], + url_name="search-album", + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def fetch_album(self, filters: Query[AlbumFilterSchema]): + """General-purpose album search.""" + return filters.filter(Album.objects.viewable_by(self.context.request.user)) + + @route.get( + "/autocomplete-search", + response=PaginatedResponseSchema[AlbumAutocompleteSchema], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) - def search_album(self, search: Annotated[str, MinLen(1)]): - return Album.objects.filter(name__icontains=search) + def autocomplete_album(self, filters: Query[AlbumFilterSchema]): + """Search route to use exclusively on autocomplete input fields. + + This route is separated from `GET /sas/album/search` because + getting the path of an album may need an absurd amount of db queries. + + If you don't need the path of the albums, + do NOT use this route. + """ + return filters.filter(Album.objects.viewable_by(self.context.request.user)) @api_controller("/sas/picture") diff --git a/sas/schemas.py b/sas/schemas.py index d606219b..76eb908a 100644 --- a/sas/schemas.py +++ b/sas/schemas.py @@ -1,6 +1,8 @@ from datetime import datetime from pathlib import Path +from typing import Annotated +from annotated_types import MinLen from django.urls import reverse from ninja import FilterSchema, ModelSchema, Schema from pydantic import Field, NonNegativeInt @@ -9,7 +11,37 @@ from core.schemas import SimpleUserSchema, UserProfileSchema from sas.models import Album, Picture, PictureModerationRequest +class AlbumFilterSchema(FilterSchema): + search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains") + before_date: datetime | None = Field(None, q="event_date__lte") + after_date: datetime | None = Field(None, q="event_date__gte") + parent_id: int | None = Field(None, q="parent_id") + + class AlbumSchema(ModelSchema): + class Meta: + model = Album + fields = ["id", "name", "is_moderated"] + + thumbnail: str | None + sas_url: str + + @staticmethod + def resolve_thumbnail(obj: Album) -> str | None: + # Album thumbnails aren't stored in `Album.thumbnail` but in `Album.file` + # Don't ask me why. + if not obj.file: + return None + return obj.get_download_url() + + @staticmethod + def resolve_sas_url(obj: Album) -> str: + return obj.get_absolute_url() + + +class AlbumAutocompleteSchema(ModelSchema): + """Schema to use on album autocomplete input field.""" + class Meta: model = Album fields = ["id", "name"] diff --git a/sas/static/bundled/sas/components/ajax-select-index.ts b/sas/static/bundled/sas/components/ajax-select-index.ts index 5b811f52..e11d96c2 100644 --- a/sas/static/bundled/sas/components/ajax-select-index.ts +++ b/sas/static/bundled/sas/components/ajax-select-index.ts @@ -2,7 +2,7 @@ import { AjaxSelect } from "#core:core/components/ajax-select-base"; import { registerComponent } from "#core:utils/web-components"; import type { TomOption } from "tom-select/dist/types/types"; import type { escape_html } from "tom-select/dist/types/utils"; -import { type AlbumSchema, albumSearchAlbum } from "#openapi"; +import { type AlbumAutocompleteSchema, albumSearchAlbum } from "#openapi"; @registerComponent("album-ajax-select") export class AlbumAjaxSelect extends AjaxSelect { @@ -18,13 +18,13 @@ export class AlbumAjaxSelect extends AjaxSelect { return []; } - protected renderOption(item: AlbumSchema, sanitize: typeof escape_html) { + protected renderOption(item: AlbumAutocompleteSchema, sanitize: typeof escape_html) { return `
${sanitize(item.path)}
`; } - protected renderItem(item: AlbumSchema, sanitize: typeof escape_html) { + protected renderItem(item: AlbumAutocompleteSchema, sanitize: typeof escape_html) { return `${sanitize(item.path)}`; } } diff --git a/sas/tests/test_api.py b/sas/tests/test_api.py index 9b24688b..25014e86 100644 --- a/sas/tests/test_api.py +++ b/sas/tests/test_api.py @@ -228,3 +228,16 @@ class TestPictureModeration(TestSas): assert res.status_code == 200 assert len(res.json()) == 1 assert res.json()[0]["author"]["id"] == self.user_a.id + + +class TestAlbumSearch(TestSas): + def test_num_queries(self): + """Check the number of queries is stable""" + self.client.force_login(subscriber_user.make()) + cache.clear() + with self.assertNumQueries(7): + # - 2 for authentication + # - 3 to check permissions + # - 1 for pagination + # - 1 for the actual results + self.client.get(reverse("api:search-album")) diff --git a/sas/widgets/select.py b/sas/widgets/select.py index 1d124a27..7071652e 100644 --- a/sas/widgets/select.py +++ b/sas/widgets/select.py @@ -2,7 +2,7 @@ from pydantic import TypeAdapter from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple from sas.models import Album -from sas.schemas import AlbumSchema +from sas.schemas import AlbumAutocompleteSchema _js = ["bundled/sas/components/ajax-select-index.ts"] @@ -10,7 +10,7 @@ _js = ["bundled/sas/components/ajax-select-index.ts"] class AutoCompleteSelectAlbum(AutoCompleteSelect): component_name = "album-ajax-select" model = Album - adapter = TypeAdapter(list[AlbumSchema]) + adapter = TypeAdapter(list[AlbumAutocompleteSchema]) js = _js @@ -18,6 +18,6 @@ class AutoCompleteSelectAlbum(AutoCompleteSelect): class AutoCompleteSelectMultipleAlbum(AutoCompleteSelectMultiple): component_name = "album-ajax-select" model = Album - adapter = TypeAdapter(list[AlbumSchema]) + adapter = TypeAdapter(list[AlbumAutocompleteSchema]) js = _js