From 9f7248ea0bb361d720496f9ccde657ef761e92eb Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 1 May 2026 18:54:13 +0200 Subject: [PATCH] feat: rotate pictures with API+AlpineJS --- sas/api.py | 13 ++++ sas/static/bundled/sas/user/pictures-index.ts | 2 +- sas/static/bundled/sas/viewer-index.ts | 49 ++++++++++--- sas/static/sas/css/picture.scss | 47 ++++++------ sas/tests/test_views.py | 71 ++++++++++++++++++- 5 files changed, 144 insertions(+), 38 deletions(-) diff --git a/sas/api.py b/sas/api.py index 61ea73dc..a09914a7 100644 --- a/sas/api.py +++ b/sas/api.py @@ -176,6 +176,19 @@ class PicturesController(ControllerBase): def delete_picture(self, picture_id: int): self.get_object_or_exception(Picture, pk=picture_id).delete() + @route.post( + "/{picture_id}/rotate/{direction}", + permissions=[IsSasAdmin], + response=PictureSchema, + url_name="rotate_picture", + ) + def rotate_picture(self, picture_id: int, direction: Literal["left", "right"]): + """Rotate the given picture and returns its edited data.""" + angle = 90 if direction == "left" else 270 + picture = self.get_object_or_exception(Picture, pk=picture_id) + picture.rotate(angle) + return picture + @route.patch( "/{picture_id}/moderation", permissions=[IsSasAdmin], diff --git a/sas/static/bundled/sas/user/pictures-index.ts b/sas/static/bundled/sas/user/pictures-index.ts index d4c75f17..32615bf9 100644 --- a/sas/static/bundled/sas/user/pictures-index.ts +++ b/sas/static/bundled/sas/user/pictures-index.ts @@ -1,4 +1,4 @@ -import { paginated } from "#core:utils/api.ts"; +import { paginated } from "#core:utils/api"; import { type PictureSchema, type PicturesFetchPicturesData, diff --git a/sas/static/bundled/sas/viewer-index.ts b/sas/static/bundled/sas/viewer-index.ts index 3573970f..863de3a0 100644 --- a/sas/static/bundled/sas/viewer-index.ts +++ b/sas/static/bundled/sas/viewer-index.ts @@ -1,7 +1,7 @@ import type TomSelect from "tom-select"; -import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts"; -import { paginated } from "#core:utils/api.ts"; -import { History } from "#core:utils/history.ts"; +import type { UserAjaxSelect } from "#core:core/components/ajax-select-index"; +import { paginated } from "#core:utils/api"; +import { History } from "#core:utils/history"; import { type IdentifiedUserSchema, type ModerationRequestSchema, @@ -14,6 +14,7 @@ import { picturesFetchPictures, picturesIdentifyUsers, picturesModeratePicture, + picturesRotatePicture, type UserProfileSchema, usersidentifiedDeleteRelation, } from "#openapi"; @@ -28,18 +29,32 @@ class PictureWithIdentifications { identificationsLoading = false; moderationLoading = false; id: number; - // biome-ignore lint/style/useNamingConvention: api is in snake_case - compressed_url: string; + compressedUrl: string = ""; + thumbUrl: string = ""; + fullSizeUrl: string = ""; moderationRequests: ModerationRequestSchema[] = null; constructor(picture: PictureSchema) { Object.assign(this, picture); + this.compressedUrl = picture.compressed_url; + this.thumbUrl = picture.thumb_url; + this.fullSizeUrl = picture.full_size_url; } static fromPicture(picture: PictureSchema): PictureWithIdentifications { return new PictureWithIdentifications(picture); } + rebuildUrls(date: Date) { + const buildUrl = (url: string) => { + const base = url.split("?", 1)[0]; + return `${base}?date=${date.getTime().toString()}`; + }; + this.compressedUrl = buildUrl(this.compressedUrl); + this.thumbUrl = buildUrl(this.thumbUrl); + this.fullSizeUrl = buildUrl(this.fullSizeUrl); + } + /** * If not already done, fetch the users identified on this picture and * populate the identifications field @@ -82,12 +97,25 @@ class PictureWithIdentifications { this.moderationLoading = false; } + async rotate(direction: "left" | "right") { + this.imageLoading = true; + const res = await picturesRotatePicture({ + // biome-ignore lint/style/useNamingConvention: api is snake case + path: { picture_id: this.id, direction: direction }, + }); + // urls returned by the api include a timestamp for cache busting + this.fullSizeUrl = res.data.full_size_url; + this.compressedUrl = res.data.compressed_url; + this.thumbUrl = res.data.thumb_url; + this.imageLoading = false; + } + /** * Preload the photo and the identifications */ async preload(): Promise { const img = new Image(); - img.src = this.compressed_url; + img.src = this.compressedUrl; if (!img.complete) { this.imageLoading = true; img.addEventListener("load", () => { @@ -140,7 +168,8 @@ document.addEventListener("alpine:init", () => { // biome-ignore lint/style/useNamingConvention: api is in snake_case full_size_url: "", owner: "", - date: new Date(), + // biome-ignore lint/style/useNamingConvention: api is in snake_case + created_at: new Date(), identifications: [] as IdentifiedUserSchema[], }, /** @@ -291,10 +320,8 @@ document.addEventListener("alpine:init", () => { async submitIdentification(): Promise { const widget: TomSelect = this.selector.widget; await picturesIdentifyUsers({ - path: { - // biome-ignore lint/style/useNamingConvention: api is in snake_case - picture_id: this.currentPicture.id, - }, + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { picture_id: this.currentPicture.id }, body: widget.items.map((i: string) => Number.parseInt(i, 10)), }); // refresh the identified users list diff --git a/sas/static/sas/css/picture.scss b/sas/static/sas/css/picture.scss index 03103a25..4ed49eea 100644 --- a/sas/static/sas/css/picture.scss +++ b/sas/static/sas/css/picture.scss @@ -235,37 +235,34 @@ >.tools { flex: 1; + .btn { + background-color: $primary-neutral-light-color; + display: flex; + justify-content: center; + align-items: center; + padding: 0; + color: black; + width: 40px; + height: 40px; + font-size: 20px; - >div>div { - >a.btn { - background-color: $primary-neutral-light-color; - display: flex; - justify-content: center; - align-items: center; - padding: 0; - color: black; - width: 40px; - height: 40px; - font-size: 20px; - - &:hover { - background-color: #aaa; - } + &:hover { + background-color: #aaa; } + } - >a.text.danger { - color: red; + a.text.danger { + color: red; - &:hover { - color: darkred; - } + &:hover { + color: darkred; } + } - &.buttons { - display: flex; - flex-direction: row; - gap: 5px; - } + .buttons { + display: flex; + flex-direction: row; + gap: 5px; } } } \ No newline at end of file diff --git a/sas/tests/test_views.py b/sas/tests/test_views.py index 57a69750..aa57a0b5 100644 --- a/sas/tests/test_views.py +++ b/sas/tests/test_views.py @@ -12,19 +12,23 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -from typing import Callable +from typing import Callable, Literal +from unittest.mock import patch import pytest from bs4 import BeautifulSoup from django.conf import settings from django.core.cache import cache +from django.core.files.base import ContentFile from django.test import Client, TestCase from django.urls import reverse from model_bakery import baker +from PIL import Image from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects from core.baker_recipes import old_subscriber_user, subscriber_user from core.models import Group, User +from core.utils import RED_PIXEL_PNG from sas.baker_recipes import picture_recipe from sas.models import Album, Picture @@ -162,6 +166,71 @@ class TestAlbumUpload: assert not album.children.exists() +@pytest.mark.django_db +class TestPictureRotation: + @pytest.fixture + def picture(self) -> Picture: + return picture_recipe.make( + parent_id=settings.SITH_SAS_ROOT_DIR_ID, + file=ContentFile(name="foo.png", content=RED_PIXEL_PNG), + compressed=ContentFile(name="foo.png", content=RED_PIXEL_PNG), + thumbnail=ContentFile(name="foo.png", content=RED_PIXEL_PNG), + ) + + @pytest.mark.parametrize( + "user", + [ + None, + lambda: baker.make(User), + subscriber_user.make, + old_subscriber_user.make, + ], + ) + def test_permission_denied( + self, client: Client, picture: Picture, user: Callable[[], User] | None + ): + if user: + client.force_login(user()) + + url = reverse( + "api:rotate_picture", kwargs={"picture_id": picture.id, "direction": "left"} + ) + response = client.post(url) + assert response.status_code == 403 if user else 401 + + @pytest.mark.parametrize( + "user", + [ + lambda: baker.make(User, is_superuser=True), + lambda: baker.make( + User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] + ), + ], + ) + @pytest.mark.parametrize(("direction", "angle"), [("left", 90), ("right", 270)]) + def test_rotation( + self, + client: Client, + picture: Picture, + user: Callable[[], User], + direction: Literal["left", "right"], + angle: Literal[90, 270], + ): + client.force_login(user()) + url = reverse( + "api:rotate_picture", + kwargs={"picture_id": picture.id, "direction": direction}, + ) + with ( + patch.object(Image.Image, "rotate") as mocked_rotate, + patch.object(Picture, "generate_thumbnails") as mocked_thumb, + ): + response = client.post(url) + assert response.status_code == 200 + mocked_rotate.assert_called_once_with(angle) + mocked_thumb.assert_called_once() + + class TestSasModeration(TestCase): @classmethod def setUpTestData(cls):