mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-12 21:09:24 +00:00
Compare commits
4 Commits
openapi
...
ts-album-f
Author | SHA1 | Date | |
---|---|---|---|
6f39dfc803 | |||
2d0fd4e3f6 | |||
becc321cb9 | |||
5b740c845c |
@ -1,3 +0,0 @@
|
||||
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
|
||||
|
||||
polyfillCountryFlagEmojis();
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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
7
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
28
sas/api.py
28
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")
|
||||
|
@ -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"]
|
||||
|
@ -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;
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
@ -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>`;
|
||||
}
|
||||
}
|
||||
|
@ -53,32 +53,43 @@
|
||||
{% 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>
|
||||
<div class="albums">
|
||||
{% for a in children_albums %}
|
||||
{{ display_album(a, is_sas_admin) }}
|
||||
{% endfor %}
|
||||
<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"> </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"> </div>
|
||||
|
@ -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"))
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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/*"],
|
||||
|
Reference in New Issue
Block a user