mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-03 18:43:04 +00:00 
			
		
		
		
	Compare commits
	
		
			4 Commits
		
	
	
		
			galaxy
			...
			ts-album-f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 6f39dfc803 | |||
| 
						 | 
					2d0fd4e3f6 | ||
| 
						 | 
					becc321cb9 | ||
| 
						 | 
					5b740c845c | 
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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
 | 
			
		||||
 
 | 
			
		||||
@@ -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/index.ts"],
 | 
			
		||||
      "#core:*": ["./core/static/bundled/*"],
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user