From 650227b6e2c2b8362ae614baf328d8ea9cee85e3 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 20 Feb 2025 15:04:33 +0100 Subject: [PATCH 1/5] typescriptify album-index.js --- sas/static/bundled/sas/album-index.ts | 9 +++------ sas/templates/sas/album.jinja | 8 ++------ 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/sas/static/bundled/sas/album-index.ts b/sas/static/bundled/sas/album-index.ts index ff0976d9..02a744b6 100644 --- a/sas/static/bundled/sas/album-index.ts +++ b/sas/static/bundled/sas/album-index.ts @@ -1,10 +1,6 @@ import { paginated } from "#core:utils/api"; import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; -import { - type PictureSchema, - type PicturesFetchPicturesData, - picturesFetchPictures, -} from "#openapi"; +import { type PictureSchema, type PicturesFetchPicturesData, picturesFetchPictures } from "#openapi"; interface AlbumConfig { albumId: number; @@ -17,12 +13,14 @@ document.addEventListener("alpine:init", () => { page: Number.parseInt(initialUrlParams.get("page")) || 1, pushstate: History.Push /* Used to avoid pushing a state on a back action */, loading: false, + config: config, async init() { await this.fetchPictures(); this.$watch("page", () => { updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate); this.pushstate = History.Push; + this.fetchPictures(); }); window.addEventListener("popstate", () => { @@ -30,7 +28,6 @@ document.addEventListener("alpine:init", () => { this.page = Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1; }); - this.config = config; }, getPage(page: number) { diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja index 172e81ab..cb81d7b1 100644 --- a/sas/templates/sas/album.jinja +++ b/sas/templates/sas/album.jinja @@ -64,11 +64,7 @@
{% endif %} -
- +
{{ download_button(_("Download album")) }}

{% trans %}Pictures{% endtrans %}

@@ -94,7 +90,7 @@
- {{ paginate_alpine("page", "nbPages()") }} + {{ paginate_alpine("page", "nbPages()") }}
{% if is_sas_admin %} From 218aab1af3103d6054db53228a9c3251c64d9087 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 20 Feb 2025 15:33:05 +0100 Subject: [PATCH 2/5] 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/ajax_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/ajax_select.py b/sas/widgets/ajax_select.py index 1650a279..d7d15666 100644 --- a/sas/widgets/ajax_select.py +++ b/sas/widgets/ajax_select.py @@ -5,7 +5,7 @@ from core.views.widgets.ajax_select import ( AutoCompleteSelectMultiple, ) from sas.models import Album -from sas.schemas import AlbumSchema +from sas.schemas import AlbumAutocompleteSchema _js = ["bundled/sas/components/ajax-select-index.ts"] @@ -13,7 +13,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 @@ -21,6 +21,6 @@ class AutoCompleteSelectAlbum(AutoCompleteSelect): class AutoCompleteSelectMultipleAlbum(AutoCompleteSelectMultiple): component_name = "album-ajax-select" model = Album - adapter = TypeAdapter(list[AlbumSchema]) + adapter = TypeAdapter(list[AlbumAutocompleteSchema]) js = _js From 60db7e251651c9ce43cf2c250a82ad341b39fd91 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 21 Feb 2025 16:44:56 +0100 Subject: [PATCH 3/5] ajaxify album loading in the SAS --- sas/static/bundled/sas/album-index.ts | 35 +++++++++++++-- .../sas/components/ajax-select-index.ts | 4 +- sas/templates/sas/album.jinja | 43 +++++++++++++------ sas/views.py | 4 +- 4 files changed, 65 insertions(+), 21 deletions(-) diff --git a/sas/static/bundled/sas/album-index.ts b/sas/static/bundled/sas/album-index.ts index 02a744b6..c04df038 100644 --- a/sas/static/bundled/sas/album-index.ts +++ b/sas/static/bundled/sas/album-index.ts @@ -1,14 +1,24 @@ import { paginated } from "#core:utils/api"; import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; -import { type PictureSchema, type PicturesFetchPicturesData, picturesFetchPictures } from "#openapi"; +import { + type AlbumSchema, + type PictureSchema, + type PicturesFetchPicturesData, + albumFetchAlbum, + picturesFetchPictures, +} from "#openapi"; -interface AlbumConfig { +interface AlbumPicturesConfig { albumId: number; maxPageSize: number; } +interface SubAlbumsConfig { + parentId: number; +} + document.addEventListener("alpine:init", () => { - Alpine.data("pictures", (config: AlbumConfig) => ({ + Alpine.data("pictures", (config: AlbumPicturesConfig) => ({ pictures: [] as PictureSchema[], page: Number.parseInt(initialUrlParams.get("page")) || 1, pushstate: History.Push /* Used to avoid pushing a state on a back action */, @@ -52,4 +62,23 @@ document.addEventListener("alpine:init", () => { return Math.ceil(this.pictures.length / config.maxPageSize); }, })); + + Alpine.data("albums", (config: SubAlbumsConfig) => ({ + albums: [] as AlbumSchema[], + config: config, + loading: false, + + async init() { + await this.fetchAlbums(); + }, + + async fetchAlbums() { + this.loading = true; + this.albums = await paginated(albumFetchAlbum, { + // biome-ignore lint/style/useNamingConvention: API is snake_case + query: { parent_id: this.config.parentId }, + }); + this.loading = false; + }, + })); }); diff --git a/sas/static/bundled/sas/components/ajax-select-index.ts b/sas/static/bundled/sas/components/ajax-select-index.ts index e11d96c2..aa640556 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 AlbumAutocompleteSchema, albumSearchAlbum } from "#openapi"; +import { type AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi"; @registerComponent("album-ajax-select") export class AlbumAjaxSelect extends AjaxSelect { @@ -11,7 +11,7 @@ export class AlbumAjaxSelect extends AjaxSelect { protected searchField = ["path", "name"]; protected async search(query: string): Promise { - const resp = await albumSearchAlbum({ query: { search: query } }); + const resp = await albumAutocompleteAlbum({ query: { search: query } }); if (resp.data) { return resp.data.results; } diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja index cb81d7b1..6c2cbcf7 100644 --- a/sas/templates/sas/album.jinja +++ b/sas/templates/sas/album.jinja @@ -53,28 +53,43 @@ {% endif %} {% endif %} - {% if children_albums|length > 0 %} -

{% trans %}Albums{% endtrans %}

-
- {% for a in children_albums %} - {{ display_album(a, is_sas_admin) }} - {% endfor %} + {% if show_albums %} +
+

{% trans %}Albums{% endtrans %}

+
+ +
- -
{% endif %}
- {{ download_button(_("Download album")) }} -

{% trans %}Pictures{% endtrans %}

+
+ {{ download_button(_("Download album")) }}