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

View File

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

View File

@ -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"]

View File

@ -1,18 +1,25 @@
import { paginated } from "#core:utils/api";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import {
type AlbumFetchAlbumData,
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 */,
@ -23,6 +30,7 @@ document.addEventListener("alpine:init", () => {
this.$watch("page", () => {
updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
this.pushstate = History.Push;
this.fetchPictures();
});
window.addEventListener("popstate", () => {
@ -30,7 +38,6 @@ document.addEventListener("alpine:init", () => {
this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1;
});
this.config = config;
},
getPage(page: number) {
@ -43,11 +50,9 @@ document.addEventListener("alpine:init", () => {
async fetchPictures() {
this.loading = true;
this.pictures = await paginated(picturesFetchPictures, {
query: {
// biome-ignore lint/style/useNamingConvention: API is in snake_case
album_id: config.albumId,
} as PicturesFetchPicturesData["query"],
});
// biome-ignore lint/style/useNamingConvention: API is in snake_case
query: { album_id: config.albumId },
} as PicturesFetchPicturesData);
this.loading = false;
},
@ -55,4 +60,22 @@ document.addEventListener("alpine:init", () => {
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 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, albumAutocompleteAlbum } from "#openapi";
@registerComponent("album-ajax-select")
export class AlbumAjaxSelect extends AjaxSelect {
@ -11,20 +11,20 @@ export class AlbumAjaxSelect extends AjaxSelect {
protected searchField = ["path", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await albumSearchAlbum({ query: { search: query } });
const resp = await albumAutocompleteAlbum({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: AlbumSchema, sanitize: typeof escape_html) {
protected renderOption(item: AlbumAutocompleteSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.path)}</span>
</div>`;
}
protected renderItem(item: AlbumSchema, sanitize: typeof escape_html) {
protected renderItem(item: AlbumAutocompleteSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.path)}</span>`;
}
}

View File

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

View File

@ -53,32 +53,43 @@
{% endif %}
{% endif %}
{% if children_albums|length > 0 %}
<h4>{% trans %}Albums{% endtrans %}</h4>
<div class="albums">
{% for a in children_albums %}
{{ display_album(a, is_sas_admin) }}
{% endfor %}
{% if show_albums %}
<div x-data="albums({ parentId: {{ album.id }} })" class="margin-bottom">
<h4>{% trans %}Albums{% endtrans %}</h4>
<div class="albums" :aria-busy="loading">
<template x-for="album in albums" :key="album.id">
<a :href="album.sas_url">
<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>
<br>
{% endif %}
<div x-data="pictures({
albumId: {{ album.id }},
maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }},
})">
{{ download_button(_("Download album")) }}
<div x-data="pictures({ albumId: {{ album.id }}, maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }} })">
<h4>{% trans %}Pictures{% endtrans %}</h4>
<br>
{{ download_button(_("Download album")) }}
<div class="photos" :aria-busy="loading">
<template x-for="picture in getPage(page)">
<a :href="picture.sas_url">
<div
class="photo"
:class="{not_moderated: !picture.is_moderated}"
>
<div class="photo" :class="{not_moderated: !picture.is_moderated}">
<img :src="picture.thumb_url" :alt="picture.name" loading="lazy" />
<template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div>
@ -94,7 +105,7 @@
</a>
</template>
</div>
{{ paginate_alpine("page", "nbPages()") }}
{{ paginate_alpine("page", "nbPages()") }}
</div>
{% if is_sas_admin %}

View File

@ -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"))

View File

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

View File

@ -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

View File

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