mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-03 18:43:04 +00:00 
			
		
		
		
	Compare commits
	
		
			7 Commits
		
	
	
		
			ts-album-f
			...
			openapi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 106dc32a3d | |||
| 05edf33062 | |||
| 
						 | 
					98175e397c | ||
| 
						 | 
					62246f342d | ||
| 
						 | 
					bff6513192 | ||
| 
						 | 
					bf0779a096 | ||
| 
						 | 
					9991507297 | 
							
								
								
									
										3
									
								
								core/static/bundled/country-flags-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								core/static/bundled/country-flags-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					polyfillCountryFlagEmojis();
 | 
				
			||||||
@@ -14,7 +14,6 @@ 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>(
 | 
				
			||||||
@@ -31,7 +30,7 @@ export const paginated = async <T>(
 | 
				
			|||||||
  options?: PaginatedRequest,
 | 
					  options?: PaginatedRequest,
 | 
				
			||||||
): Promise<T[]> => {
 | 
					): Promise<T[]> => {
 | 
				
			||||||
  const maxPerPage = 199;
 | 
					  const maxPerPage = 199;
 | 
				
			||||||
  const queryParams = options ?? ({} as PaginatedRequest);
 | 
					  const queryParams = options ?? {};
 | 
				
			||||||
  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;
 | 
				
			||||||
@@ -56,7 +55,6 @@ export const paginated = async <T>(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
interface Request {
 | 
					interface Request {
 | 
				
			||||||
  client?: Client;
 | 
					  client?: Client;
 | 
				
			||||||
  url: string;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface InterceptorOptions {
 | 
					interface InterceptorOptions {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -106,6 +106,7 @@ $hovered-red-text-color: #ff4d4d;
 | 
				
			|||||||
        color: $text-color;
 | 
					        color: $text-color;
 | 
				
			||||||
        font-weight: normal;
 | 
					        font-weight: normal;
 | 
				
			||||||
        line-height: 1.3em;
 | 
					        line-height: 1.3em;
 | 
				
			||||||
 | 
					        font-family: "Twemoji Country Flags", sans-serif;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        &:hover {
 | 
					        &:hover {
 | 
				
			||||||
          background-color: $background-color-hovered;
 | 
					          background-color: $background-color-hovered;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,6 +23,7 @@
 | 
				
			|||||||
      <script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
 | 
					      <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/alpine-index.js') }}"></script>
 | 
				
			||||||
      <script type="module" src="{{ static('bundled/htmx-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 -->
 | 
					      <!-- Jquery declared here to be accessible in every django widgets -->
 | 
				
			||||||
      <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
 | 
					      <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,9 +6,7 @@ import { defineConfig } from "@hey-api/openapi-ts";
 | 
				
			|||||||
export default defineConfig({
 | 
					export default defineConfig({
 | 
				
			||||||
  input: resolve(__dirname, "./staticfiles/generated/openapi/schema.json"),
 | 
					  input: resolve(__dirname, "./staticfiles/generated/openapi/schema.json"),
 | 
				
			||||||
  output: {
 | 
					  output: {
 | 
				
			||||||
    lint: "biome",
 | 
					    path: resolve(__dirname, "./staticfiles/generated/openapi/client"),
 | 
				
			||||||
    format: "biome",
 | 
					 | 
				
			||||||
    path: resolve(__dirname, "./staticfiles/generated/openapi"),
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  plugins: [
 | 
					  plugins: [
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										7
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@@ -22,6 +22,7 @@
 | 
				
			|||||||
        "3d-force-graph": "^1.73.4",
 | 
					        "3d-force-graph": "^1.73.4",
 | 
				
			||||||
        "alpinejs": "^3.14.7",
 | 
					        "alpinejs": "^3.14.7",
 | 
				
			||||||
        "chart.js": "^4.4.4",
 | 
					        "chart.js": "^4.4.4",
 | 
				
			||||||
 | 
					        "country-flag-emoji-polyfill": "^0.1.8",
 | 
				
			||||||
        "cytoscape": "^3.30.2",
 | 
					        "cytoscape": "^3.30.2",
 | 
				
			||||||
        "cytoscape-cxtmenu": "^3.5.0",
 | 
					        "cytoscape-cxtmenu": "^3.5.0",
 | 
				
			||||||
        "cytoscape-klay": "^3.1.4",
 | 
					        "cytoscape-klay": "^3.1.4",
 | 
				
			||||||
@@ -3379,6 +3380,12 @@
 | 
				
			|||||||
        "url": "https://opencollective.com/core-js"
 | 
					        "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": {
 | 
					    "node_modules/cross-spawn": {
 | 
				
			||||||
      "version": "7.0.6",
 | 
					      "version": "7.0.6",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,6 +7,7 @@
 | 
				
			|||||||
    "compile": "vite build --mode production",
 | 
					    "compile": "vite build --mode production",
 | 
				
			||||||
    "compile-dev": "vite build --mode development",
 | 
					    "compile-dev": "vite build --mode development",
 | 
				
			||||||
    "serve": "vite build --mode development --watch --minify false",
 | 
					    "serve": "vite build --mode development --watch --minify false",
 | 
				
			||||||
 | 
					    "openapi": "openapi-ts",
 | 
				
			||||||
    "analyse-dev": "vite-bundle-visualizer --mode development",
 | 
					    "analyse-dev": "vite-bundle-visualizer --mode development",
 | 
				
			||||||
    "analyse-prod": "vite-bundle-visualizer --mode production",
 | 
					    "analyse-prod": "vite-bundle-visualizer --mode production",
 | 
				
			||||||
    "check": "biome check --write"
 | 
					    "check": "biome check --write"
 | 
				
			||||||
@@ -16,7 +17,7 @@
 | 
				
			|||||||
  "license": "GPL-3.0-only",
 | 
					  "license": "GPL-3.0-only",
 | 
				
			||||||
  "sideEffects": [".css"],
 | 
					  "sideEffects": [".css"],
 | 
				
			||||||
  "imports": {
 | 
					  "imports": {
 | 
				
			||||||
    "#openapi": "./staticfiles/generated/openapi/index.ts",
 | 
					    "#openapi": "./staticfiles/generated/openapi/client/index.ts",
 | 
				
			||||||
    "#core:*": "./core/static/bundled/*",
 | 
					    "#core:*": "./core/static/bundled/*",
 | 
				
			||||||
    "#pedagogy:*": "./pedagogy/static/bundled/*",
 | 
					    "#pedagogy:*": "./pedagogy/static/bundled/*",
 | 
				
			||||||
    "#counter:*": "./counter/static/bundled/*",
 | 
					    "#counter:*": "./counter/static/bundled/*",
 | 
				
			||||||
@@ -48,6 +49,7 @@
 | 
				
			|||||||
    "3d-force-graph": "^1.73.4",
 | 
					    "3d-force-graph": "^1.73.4",
 | 
				
			||||||
    "alpinejs": "^3.14.7",
 | 
					    "alpinejs": "^3.14.7",
 | 
				
			||||||
    "chart.js": "^4.4.4",
 | 
					    "chart.js": "^4.4.4",
 | 
				
			||||||
 | 
					    "country-flag-emoji-polyfill": "^0.1.8",
 | 
				
			||||||
    "cytoscape": "^3.30.2",
 | 
					    "cytoscape": "^3.30.2",
 | 
				
			||||||
    "cytoscape-cxtmenu": "^3.5.0",
 | 
					    "cytoscape-cxtmenu": "^3.5.0",
 | 
				
			||||||
    "cytoscape-klay": "^3.1.4",
 | 
					    "cytoscape-klay": "^3.1.4",
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										28
									
								
								sas/api.py
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								sas/api.py
									
									
									
									
									
								
							@@ -1,3 +1,6 @@
 | 
				
			|||||||
 | 
					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
 | 
				
			||||||
@@ -13,8 +16,6 @@ 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,
 | 
				
			||||||
@@ -30,30 +31,11 @@ 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 autocomplete_album(self, filters: Query[AlbumFilterSchema]):
 | 
					    def search_album(self, search: Annotated[str, MinLen(1)]):
 | 
				
			||||||
        """Search route to use exclusively on autocomplete input fields.
 | 
					        return Album.objects.filter(name__icontains=search)
 | 
				
			||||||
 | 
					 | 
				
			||||||
        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")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,6 @@
 | 
				
			|||||||
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
 | 
				
			||||||
@@ -11,37 +9,7 @@ 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"]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,37 +1,28 @@
 | 
				
			|||||||
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 AlbumPicturesConfig {
 | 
					interface AlbumConfig {
 | 
				
			||||||
  albumId: number;
 | 
					  albumId: number;
 | 
				
			||||||
  maxPageSize: number;
 | 
					  maxPageSize: number;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface SubAlbumsConfig {
 | 
					 | 
				
			||||||
  parentId: number;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
document.addEventListener("alpine:init", () => {
 | 
					document.addEventListener("alpine:init", () => {
 | 
				
			||||||
  Alpine.data("pictures", (config: AlbumPicturesConfig) => ({
 | 
					  Alpine.data("pictures", (config: AlbumConfig) => ({
 | 
				
			||||||
    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 */,
 | 
				
			||||||
    loading: false,
 | 
					    loading: false,
 | 
				
			||||||
    config: config,
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async init() {
 | 
					    async init() {
 | 
				
			||||||
      await this.fetchPictures();
 | 
					      await this.fetchPictures();
 | 
				
			||||||
      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", () => {
 | 
				
			||||||
@@ -39,6 +30,7 @@ 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) {
 | 
				
			||||||
@@ -63,23 +55,4 @@ 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[],
 | 
					 | 
				
			||||||
    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 { 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 AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi";
 | 
					import { type AlbumSchema, albumSearchAlbum } 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 albumAutocompleteAlbum({ query: { search: query } });
 | 
					    const resp = await albumSearchAlbum({ query: { search: query } });
 | 
				
			||||||
    if (resp.data) {
 | 
					    if (resp.data) {
 | 
				
			||||||
      return resp.data.results;
 | 
					      return resp.data.results;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return [];
 | 
					    return [];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protected renderOption(item: AlbumAutocompleteSchema, sanitize: typeof escape_html) {
 | 
					  protected renderOption(item: AlbumSchema, 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: AlbumAutocompleteSchema, sanitize: typeof escape_html) {
 | 
					  protected renderItem(item: AlbumSchema, sanitize: typeof escape_html) {
 | 
				
			||||||
    return `<span>${sanitize(item.path)}</span>`;
 | 
					    return `<span>${sanitize(item.path)}</span>`;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -53,43 +53,32 @@
 | 
				
			|||||||
      {% endif %}
 | 
					      {% endif %}
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  {% if show_albums %}
 | 
					  {% if children_albums|length > 0 %}
 | 
				
			||||||
    <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"> </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({ albumId: {{ album.id }}, maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }} })">
 | 
					  <div x-data="pictures({
 | 
				
			||||||
    <h4>{% trans %}Pictures{% endtrans %}</h4>
 | 
					               albumId: {{ album.id }},
 | 
				
			||||||
    <br>
 | 
					               maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }},
 | 
				
			||||||
 | 
					               })">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    {{ download_button(_("Download album")) }}
 | 
					    {{ download_button(_("Download album")) }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <h4>{% trans %}Pictures{% endtrans %}</h4>
 | 
				
			||||||
    <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 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" />
 | 
					            <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"> </div>
 | 
					              <div class="overlay"> </div>
 | 
				
			||||||
@@ -105,7 +94,7 @@
 | 
				
			|||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
      </template>
 | 
					      </template>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    {{ paginate_alpine("page", "nbPages()") }}
 | 
					    {{  paginate_alpine("page", "nbPages()") }}
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  {% if is_sas_admin %}
 | 
					  {% if is_sas_admin %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -228,16 +228,3 @@ 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"))
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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["show_albums"] = (
 | 
					        kwargs["children_albums"] = list(
 | 
				
			||||||
            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)
 | 
				
			||||||
            .exists()
 | 
					            .order_by("-date")
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        return kwargs
 | 
					        return kwargs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,7 @@ from pydantic import TypeAdapter
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
 | 
					from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
 | 
				
			||||||
from sas.models import Album
 | 
					from sas.models import Album
 | 
				
			||||||
from sas.schemas import AlbumAutocompleteSchema
 | 
					from sas.schemas import AlbumSchema
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_js = ["bundled/sas/components/ajax-select-index.ts"]
 | 
					_js = ["bundled/sas/components/ajax-select-index.ts"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -10,7 +10,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[AlbumAutocompleteSchema])
 | 
					    adapter = TypeAdapter(list[AlbumSchema])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    js = _js
 | 
					    js = _js
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -18,6 +18,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[AlbumAutocompleteSchema])
 | 
					    adapter = TypeAdapter(list[AlbumSchema])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    js = _js
 | 
					    js = _js
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -50,7 +50,13 @@ class Command(CollectStatic):
 | 
				
			|||||||
            return Path(location)
 | 
					            return Path(location)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Scss.compile(self.collect_scss())
 | 
					        Scss.compile(self.collect_scss())
 | 
				
			||||||
        OpenApi.compile()  # This needs to be prior to javascript bundling
 | 
					        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}"
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
        JSBundler.compile()
 | 
					        JSBundler.compile()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        collected = super().collect()
 | 
					        collected = super().collect()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,11 +15,18 @@ class Command(Runserver):
 | 
				
			|||||||
    """Light wrapper around default runserver that integrates javascirpt auto bundling."""
 | 
					    """Light wrapper around default runserver that integrates javascirpt auto bundling."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def run(self, **options):
 | 
					    def run(self, **options):
 | 
				
			||||||
        OpenApi.compile()
 | 
					        is_django_reload = os.environ.get(DJANGO_AUTORELOAD_ENV) is not None
 | 
				
			||||||
        if (
 | 
					
 | 
				
			||||||
            os.environ.get(DJANGO_AUTORELOAD_ENV) is None
 | 
					        proc = OpenApi.compile()
 | 
				
			||||||
            and settings.PROCFILE_STATIC is not None
 | 
					        # 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:
 | 
				
			||||||
            start_composer(settings.PROCFILE_STATIC)
 | 
					            start_composer(settings.PROCFILE_STATIC)
 | 
				
			||||||
            _ = atexit.register(stop_composer, procfile=settings.PROCFILE_STATIC)
 | 
					            _ = atexit.register(stop_composer, procfile=settings.PROCFILE_STATIC)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        super().run(**options)
 | 
					        super().run(**options)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -95,7 +95,7 @@ class JSBundler:
 | 
				
			|||||||
    def compile():
 | 
					    def compile():
 | 
				
			||||||
        """Bundle js files with the javascript bundler for production."""
 | 
					        """Bundle js files with the javascript bundler for production."""
 | 
				
			||||||
        process = subprocess.Popen(["npm", "run", "compile"])
 | 
					        process = subprocess.Popen(["npm", "run", "compile"])
 | 
				
			||||||
        process.wait()
 | 
					        _ = process.wait()
 | 
				
			||||||
        if process.returncode:
 | 
					        if process.returncode:
 | 
				
			||||||
            raise RuntimeError(f"Bundler failed with returncode {process.returncode}")
 | 
					            raise RuntimeError(f"Bundler failed with returncode {process.returncode}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -163,7 +163,7 @@ class OpenApi:
 | 
				
			|||||||
    OPENAPI_DIR = GENERATED_ROOT / "openapi"
 | 
					    OPENAPI_DIR = GENERATED_ROOT / "openapi"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def compile(cls):
 | 
					    def compile(cls) -> subprocess.Popen[bytes] | None:
 | 
				
			||||||
        """Compile a TS client for the sith API. Only generates it if it changed."""
 | 
					        """Compile a TS client for the sith API. Only generates it if it changed."""
 | 
				
			||||||
        logging.getLogger("django").info("Compiling open api typescript client")
 | 
					        logging.getLogger("django").info("Compiling open api typescript client")
 | 
				
			||||||
        out = cls.OPENAPI_DIR / "schema.json"
 | 
					        out = cls.OPENAPI_DIR / "schema.json"
 | 
				
			||||||
@@ -191,4 +191,4 @@ class OpenApi:
 | 
				
			|||||||
        with open(out, "w") as f:
 | 
					        with open(out, "w") as f:
 | 
				
			||||||
            _ = f.write(schema)
 | 
					            _ = f.write(schema)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        subprocess.run(["npx", "openapi-ts"], check=True)
 | 
					        return subprocess.Popen(["npm", "run", "openapi"])
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,8 +3,8 @@
 | 
				
			|||||||
    "outDir": "./staticfiles/generated/bundled/",
 | 
					    "outDir": "./staticfiles/generated/bundled/",
 | 
				
			||||||
    "sourceMap": true,
 | 
					    "sourceMap": true,
 | 
				
			||||||
    "noImplicitAny": true,
 | 
					    "noImplicitAny": true,
 | 
				
			||||||
    "module": "esnext",
 | 
					    "module": "es6",
 | 
				
			||||||
    "target": "es2022",
 | 
					    "target": "es6",
 | 
				
			||||||
    "allowJs": true,
 | 
					    "allowJs": true,
 | 
				
			||||||
    "moduleResolution": "node",
 | 
					    "moduleResolution": "node",
 | 
				
			||||||
    "experimentalDecorators": true,
 | 
					    "experimentalDecorators": true,
 | 
				
			||||||
@@ -12,8 +12,9 @@
 | 
				
			|||||||
    "esModuleInterop": true,
 | 
					    "esModuleInterop": true,
 | 
				
			||||||
    "resolveJsonModule": true,
 | 
					    "resolveJsonModule": true,
 | 
				
			||||||
    "types": ["jquery", "alpinejs"],
 | 
					    "types": ["jquery", "alpinejs"],
 | 
				
			||||||
 | 
					    "lib": ["es7"],
 | 
				
			||||||
    "paths": {
 | 
					    "paths": {
 | 
				
			||||||
      "#openapi": ["./staticfiles/generated/openapi/index.ts"],
 | 
					      "#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
 | 
				
			||||||
      "#core:*": ["./core/static/bundled/*"],
 | 
					      "#core:*": ["./core/static/bundled/*"],
 | 
				
			||||||
      "#pedagogy:*": ["./pedagogy/static/bundled/*"],
 | 
					      "#pedagogy:*": ["./pedagogy/static/bundled/*"],
 | 
				
			||||||
      "#counter:*": ["./counter/static/bundled/*"],
 | 
					      "#counter:*": ["./counter/static/bundled/*"],
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user