4 Commits

Author SHA1 Message Date
Sli
6f39dfc803 Fix paginated TS interfaces 2025-03-05 17:04:06 +01:00
2d0fd4e3f6 ajaxify album loading in the SAS 2025-03-04 23:41:18 +01:00
becc321cb9 api to fetch albums 2025-03-04 23:41:18 +01:00
5b740c845c typescriptify album-index.js 2025-03-04 23:41:18 +01:00
19 changed files with 157 additions and 80 deletions

View File

@ -1,3 +0,0 @@
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
polyfillCountryFlagEmojis();

View File

@ -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;
@ -55,6 +56,7 @@ export const paginated = async <T>(
interface Request {
client?: Client;
url: string;
}
interface InterceptorOptions {

View File

@ -106,7 +106,6 @@ $hovered-red-text-color: #ff4d4d;
color: $text-color;
font-weight: normal;
line-height: 1.3em;
font-family: "Twemoji Country Flags", sans-serif;
&:hover {
background-color: $background-color-hovered;

View File

@ -23,7 +23,6 @@
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>

View File

@ -6,7 +6,9 @@ import { defineConfig } from "@hey-api/openapi-ts";
export default defineConfig({
input: resolve(__dirname, "./staticfiles/generated/openapi/schema.json"),
output: {
path: resolve(__dirname, "./staticfiles/generated/openapi/client"),
lint: "biome",
format: "biome",
path: resolve(__dirname, "./staticfiles/generated/openapi"),
},
plugins: [
{

7
package-lock.json generated
View File

@ -22,7 +22,6 @@
"3d-force-graph": "^1.73.4",
"alpinejs": "^3.14.7",
"chart.js": "^4.4.4",
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4",
@ -3380,12 +3379,6 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/country-flag-emoji-polyfill": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/country-flag-emoji-polyfill/-/country-flag-emoji-polyfill-0.1.8.tgz",
"integrity": "sha512-Mbah52sADS3gshUYhK5142gtUuJpHYOXlXtLFI3Ly4RqgkmPMvhX9kMZSTqDM8P7UqtSW99eHKFphhQSGXA3Cg==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",

View File

@ -7,7 +7,6 @@
"compile": "vite build --mode production",
"compile-dev": "vite build --mode development",
"serve": "vite build --mode development --watch --minify false",
"openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production",
"check": "biome check --write"
@ -17,7 +16,7 @@
"license": "GPL-3.0-only",
"sideEffects": [".css"],
"imports": {
"#openapi": "./staticfiles/generated/openapi/client/index.ts",
"#openapi": "./staticfiles/generated/openapi/index.ts",
"#core:*": "./core/static/bundled/*",
"#pedagogy:*": "./pedagogy/static/bundled/*",
"#counter:*": "./counter/static/bundled/*",
@ -49,7 +48,6 @@
"3d-force-graph": "^1.73.4",
"alpinejs": "^3.14.7",
"chart.js": "^4.4.4",
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4",

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,28 +1,37 @@
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 */,
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 +39,6 @@ document.addEventListener("alpine:init", () => {
this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1;
});
this.config = config;
},
getPage(page: number) {
@ -55,4 +63,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 },
} 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

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

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

View File

@ -50,13 +50,7 @@ class Command(CollectStatic):
return Path(location)
Scss.compile(self.collect_scss())
openapi = OpenApi.compile() # This needs to be prior to javascript bundling
if openapi is not None:
_ = openapi.wait()
if openapi.returncode:
raise RuntimeError(
f"Openapi generation failed with returncode {openapi.returncode}"
)
OpenApi.compile() # This needs to be prior to javascript bundling
JSBundler.compile()
collected = super().collect()

View File

@ -15,18 +15,11 @@ class Command(Runserver):
"""Light wrapper around default runserver that integrates javascirpt auto bundling."""
def run(self, **options):
is_django_reload = os.environ.get(DJANGO_AUTORELOAD_ENV) is not None
proc = OpenApi.compile()
# Ensure that the first runserver launch creates openapi files
# before the bundler starts so that it detects them
# When django is reloaded, we can keep this process in background
# to reduce reload time
if proc is not None and not is_django_reload:
_ = proc.wait()
if not is_django_reload and settings.PROCFILE_STATIC is not None:
OpenApi.compile()
if (
os.environ.get(DJANGO_AUTORELOAD_ENV) is None
and settings.PROCFILE_STATIC is not None
):
start_composer(settings.PROCFILE_STATIC)
_ = atexit.register(stop_composer, procfile=settings.PROCFILE_STATIC)
super().run(**options)

View File

@ -95,7 +95,7 @@ class JSBundler:
def compile():
"""Bundle js files with the javascript bundler for production."""
process = subprocess.Popen(["npm", "run", "compile"])
_ = process.wait()
process.wait()
if process.returncode:
raise RuntimeError(f"Bundler failed with returncode {process.returncode}")
@ -163,7 +163,7 @@ class OpenApi:
OPENAPI_DIR = GENERATED_ROOT / "openapi"
@classmethod
def compile(cls) -> subprocess.Popen[bytes] | None:
def compile(cls):
"""Compile a TS client for the sith API. Only generates it if it changed."""
logging.getLogger("django").info("Compiling open api typescript client")
out = cls.OPENAPI_DIR / "schema.json"
@ -191,4 +191,4 @@ class OpenApi:
with open(out, "w") as f:
_ = f.write(schema)
return subprocess.Popen(["npm", "run", "openapi"])
subprocess.run(["npx", "openapi-ts"], check=True)

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,9 +12,8 @@
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["jquery", "alpinejs"],
"lib": ["es7"],
"paths": {
"#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
"#openapi": ["./staticfiles/generated/openapi/index.ts"],
"#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"],