Merge pull request #1031 from ae-utbm/ts-album

ajaxify album loading
This commit is contained in:
thomas girod 2025-03-12 18:09:11 +01:00 committed by GitHub
commit aaa8c4ba67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 149 additions and 54 deletions

View File

@ -1,4 +1,4 @@
import type { Client, Options, RequestResult } from "@hey-api/client-fetch"; import type { Client, Options, RequestResult, TDataShape } from "@hey-api/client-fetch";
import { client } from "#openapi"; import { client } from "#openapi";
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -14,6 +14,7 @@ export interface PaginatedRequest {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size?: number; page_size?: number;
}; };
url: string;
} }
type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>( type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
@ -30,7 +31,7 @@ export const paginated = async <T>(
options?: PaginatedRequest, options?: PaginatedRequest,
): Promise<T[]> => { ): Promise<T[]> => {
const maxPerPage = 199; const maxPerPage = 199;
const queryParams = options ?? {}; const queryParams = options ?? ({} as PaginatedRequest);
queryParams.query = queryParams.query ?? {}; queryParams.query = queryParams.query ?? {};
queryParams.query.page_size = maxPerPage; queryParams.query.page_size = maxPerPage;
queryParams.query.page = 1; queryParams.query.page = 1;
@ -53,7 +54,7 @@ export const paginated = async <T>(
return results; return results;
}; };
interface Request { interface Request extends TDataShape {
client?: Client; client?: Client;
} }

View File

@ -108,7 +108,7 @@ document.addEventListener("alpine:init", () => {
* Build the object containing the query parameters corresponding * Build the object containing the query parameters corresponding
* to the current filters * to the current filters
*/ */
getQueryParams(): ProductSearchProductsDetailedData { getQueryParams(): Omit<ProductSearchProductsDetailedData, "url"> {
const search = this.search.length > 0 ? this.search : null; const search = this.search.length > 0 ? this.search : null;
// If active or archived products must be filtered, put the filter in the request // If active or archived products must be filtered, put the filter in the request
// Else, don't include the filter // Else, don't include the filter

View File

@ -1,6 +1,3 @@
from typing import Annotated
from annotated_types import MinLen
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.urls import reverse 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 core.models import Notification, User
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumAutocompleteSchema,
AlbumFilterSchema,
AlbumSchema, AlbumSchema,
IdentifiedUserSchema, IdentifiedUserSchema,
ModerationRequestSchema, ModerationRequestSchema,
@ -31,11 +30,30 @@ class AlbumController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[AlbumSchema], 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], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_album(self, search: Annotated[str, MinLen(1)]): def autocomplete_album(self, filters: Query[AlbumFilterSchema]):
return Album.objects.filter(name__icontains=search) """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") @api_controller("/sas/picture")

View File

@ -1,6 +1,8 @@
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated
from annotated_types import MinLen
from django.urls import reverse from django.urls import reverse
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt from pydantic import Field, NonNegativeInt
@ -9,7 +11,37 @@ from core.schemas import SimpleUserSchema, UserProfileSchema
from sas.models import Album, Picture, PictureModerationRequest 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 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: class Meta:
model = Album model = Album
fields = ["id", "name"] fields = ["id", "name"]

View File

@ -1,18 +1,25 @@
import { paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import { import {
type AlbumFetchAlbumData,
type AlbumSchema,
type PictureSchema, type PictureSchema,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
albumFetchAlbum,
picturesFetchPictures, picturesFetchPictures,
} from "#openapi"; } from "#openapi";
interface AlbumConfig { interface AlbumPicturesConfig {
albumId: number; albumId: number;
maxPageSize: number; maxPageSize: number;
} }
interface SubAlbumsConfig {
parentId: number;
}
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("pictures", (config: AlbumConfig) => ({ Alpine.data("pictures", (config: AlbumPicturesConfig) => ({
pictures: [] as PictureSchema[], pictures: [] as PictureSchema[],
page: Number.parseInt(initialUrlParams.get("page")) || 1, page: Number.parseInt(initialUrlParams.get("page")) || 1,
pushstate: History.Push /* Used to avoid pushing a state on a back action */, pushstate: History.Push /* Used to avoid pushing a state on a back action */,
@ -23,6 +30,7 @@ document.addEventListener("alpine:init", () => {
this.$watch("page", () => { this.$watch("page", () => {
updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate); updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
this.pushstate = History.Push; this.pushstate = History.Push;
this.fetchPictures();
}); });
window.addEventListener("popstate", () => { window.addEventListener("popstate", () => {
@ -30,7 +38,6 @@ document.addEventListener("alpine:init", () => {
this.page = this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1; Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1;
}); });
this.config = config;
}, },
getPage(page: number) { getPage(page: number) {
@ -43,11 +50,9 @@ document.addEventListener("alpine:init", () => {
async fetchPictures() { async fetchPictures() {
this.loading = true; this.loading = true;
this.pictures = await paginated(picturesFetchPictures, { this.pictures = await paginated(picturesFetchPictures, {
query: {
// biome-ignore lint/style/useNamingConvention: API is in snake_case // biome-ignore lint/style/useNamingConvention: API is in snake_case
album_id: config.albumId, query: { album_id: config.albumId },
} as PicturesFetchPicturesData["query"], } as PicturesFetchPicturesData);
});
this.loading = false; this.loading = false;
}, },
@ -55,4 +60,22 @@ document.addEventListener("alpine:init", () => {
return Math.ceil(this.pictures.length / config.maxPageSize); return Math.ceil(this.pictures.length / config.maxPageSize);
}, },
})); }));
Alpine.data("albums", (config: SubAlbumsConfig) => ({
albums: [] as AlbumSchema[],
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: config.parentId },
} as AlbumFetchAlbumData);
this.loading = false;
},
}));
}); });

View File

@ -2,7 +2,7 @@ import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components"; import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types"; import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/dist/types/utils";
import { type AlbumSchema, albumSearchAlbum } from "#openapi"; import { type AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi";
@registerComponent("album-ajax-select") @registerComponent("album-ajax-select")
export class AlbumAjaxSelect extends AjaxSelect { export class AlbumAjaxSelect extends AjaxSelect {
@ -11,20 +11,20 @@ export class AlbumAjaxSelect extends AjaxSelect {
protected searchField = ["path", "name"]; protected searchField = ["path", "name"];
protected async search(query: string): Promise<TomOption[]> { protected async search(query: string): Promise<TomOption[]> {
const resp = await albumSearchAlbum({ query: { search: query } }); const resp = await albumAutocompleteAlbum({ query: { search: query } });
if (resp.data) { if (resp.data) {
return resp.data.results; return resp.data.results;
} }
return []; return [];
} }
protected renderOption(item: AlbumSchema, sanitize: typeof escape_html) { protected renderOption(item: AlbumAutocompleteSchema, sanitize: typeof escape_html) {
return `<div class="select-item"> return `<div class="select-item">
<span class="select-item-text">${sanitize(item.path)}</span> <span class="select-item-text">${sanitize(item.path)}</span>
</div>`; </div>`;
} }
protected renderItem(item: AlbumSchema, sanitize: typeof escape_html) { protected renderItem(item: AlbumAutocompleteSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.path)}</span>`; return `<span>${sanitize(item.path)}</span>`;
} }
} }

View File

@ -17,11 +17,9 @@ document.addEventListener("alpine:init", () => {
async init() { async init() {
this.pictures = await paginated(picturesFetchPictures, { this.pictures = await paginated(picturesFetchPictures, {
query: {
// biome-ignore lint/style/useNamingConvention: from python api // biome-ignore lint/style/useNamingConvention: from python api
users_identified: [config.userId], query: { users_identified: [config.userId] },
} as PicturesFetchPicturesData["query"], } as PicturesFetchPicturesData);
});
this.albums = this.pictures.reduce( this.albums = this.pictures.reduce(
(acc: Record<string, PictureSchema[]>, picture: PictureSchema) => { (acc: Record<string, PictureSchema[]>, picture: PictureSchema) => {

View File

@ -53,32 +53,43 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if children_albums|length > 0 %} {% if show_albums %}
<div x-data="albums({ parentId: {{ album.id }} })" class="margin-bottom">
<h4>{% trans %}Albums{% endtrans %}</h4> <h4>{% trans %}Albums{% endtrans %}</h4>
<div class="albums"> <div class="albums" :aria-busy="loading">
{% for a in children_albums %} <template x-for="album in albums" :key="album.id">
{{ display_album(a, is_sas_admin) }} <a :href="album.sas_url">
{% endfor %} <div
x-data="{thumbUrl: album.thumbnail || '{{ static("core/img/sas.jpg") }}'}"
class="album"
:class="{not_moderated: !album.is_moderated}"
>
<img :src="thumbUrl" :alt="album.name" loading="lazy" />
<template x-if="album.is_moderated">
<div class="text" x-text="album.name"></div>
</template>
<template x-if="!album.is_moderated">
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template>
</div>
{% if edit_mode %}
<input type="checkbox" name="file_list" :value="album.id">
{% endif %}
</a>
</template>
</div>
</div> </div>
<br>
{% endif %} {% endif %}
<div x-data="pictures({ <div x-data="pictures({ albumId: {{ album.id }}, maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }} })">
albumId: {{ album.id }},
maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }},
})">
{{ download_button(_("Download album")) }}
<h4>{% trans %}Pictures{% endtrans %}</h4> <h4>{% trans %}Pictures{% endtrans %}</h4>
<br>
{{ download_button(_("Download album")) }}
<div class="photos" :aria-busy="loading"> <div class="photos" :aria-busy="loading">
<template x-for="picture in getPage(page)"> <template x-for="picture in getPage(page)">
<a :href="picture.sas_url"> <a :href="picture.sas_url">
<div <div class="photo" :class="{not_moderated: !picture.is_moderated}">
class="photo"
:class="{not_moderated: !picture.is_moderated}"
>
<img :src="picture.thumb_url" :alt="picture.name" loading="lazy" /> <img :src="picture.thumb_url" :alt="picture.name" loading="lazy" />
<template x-if="!picture.is_moderated"> <template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>

View File

@ -228,3 +228,16 @@ class TestPictureModeration(TestSas):
assert res.status_code == 200 assert res.status_code == 200
assert len(res.json()) == 1 assert len(res.json()) == 1
assert res.json()[0]["author"]["id"] == self.user_a.id 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"))

View File

@ -186,10 +186,10 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
kwargs["clipboard"] = SithFile.objects.filter( kwargs["clipboard"] = SithFile.objects.filter(
id__in=self.request.session["clipboard"] id__in=self.request.session["clipboard"]
) )
kwargs["children_albums"] = list( kwargs["show_albums"] = (
Album.objects.viewable_by(self.request.user) Album.objects.viewable_by(self.request.user)
.filter(parent_id=self.object.id) .filter(parent_id=self.object.id)
.order_by("-date") .exists()
) )
return kwargs return kwargs

View File

@ -5,7 +5,7 @@ from core.views.widgets.ajax_select import (
AutoCompleteSelectMultiple, AutoCompleteSelectMultiple,
) )
from sas.models import Album from sas.models import Album
from sas.schemas import AlbumSchema from sas.schemas import AlbumAutocompleteSchema
_js = ["bundled/sas/components/ajax-select-index.ts"] _js = ["bundled/sas/components/ajax-select-index.ts"]
@ -13,7 +13,7 @@ _js = ["bundled/sas/components/ajax-select-index.ts"]
class AutoCompleteSelectAlbum(AutoCompleteSelect): class AutoCompleteSelectAlbum(AutoCompleteSelect):
component_name = "album-ajax-select" component_name = "album-ajax-select"
model = Album model = Album
adapter = TypeAdapter(list[AlbumSchema]) adapter = TypeAdapter(list[AlbumAutocompleteSchema])
js = _js js = _js
@ -21,6 +21,6 @@ class AutoCompleteSelectAlbum(AutoCompleteSelect):
class AutoCompleteSelectMultipleAlbum(AutoCompleteSelectMultiple): class AutoCompleteSelectMultipleAlbum(AutoCompleteSelectMultiple):
component_name = "album-ajax-select" component_name = "album-ajax-select"
model = Album model = Album
adapter = TypeAdapter(list[AlbumSchema]) adapter = TypeAdapter(list[AlbumAutocompleteSchema])
js = _js js = _js

View File

@ -3,8 +3,8 @@
"outDir": "./staticfiles/generated/bundled/", "outDir": "./staticfiles/generated/bundled/",
"sourceMap": true, "sourceMap": true,
"noImplicitAny": true, "noImplicitAny": true,
"module": "es6", "module": "esnext",
"target": "es6", "target": "es2022",
"allowJs": true, "allowJs": true,
"moduleResolution": "node", "moduleResolution": "node",
"experimentalDecorators": true, "experimentalDecorators": true,
@ -12,7 +12,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["jquery", "alpinejs"], "types": ["jquery", "alpinejs"],
"lib": ["es7"],
"paths": { "paths": {
"#openapi": ["./staticfiles/generated/openapi/client/index.ts"], "#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
"#core:*": ["./core/static/bundled/*"], "#core:*": ["./core/static/bundled/*"],