feat: rotate pictures with API+AlpineJS

This commit is contained in:
imperosol
2026-05-01 18:54:13 +02:00
parent 9d3d3ea005
commit 9f7248ea0b
5 changed files with 144 additions and 38 deletions
+13
View File
@@ -176,6 +176,19 @@ class PicturesController(ControllerBase):
def delete_picture(self, picture_id: int): def delete_picture(self, picture_id: int):
self.get_object_or_exception(Picture, pk=picture_id).delete() 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( @route.patch(
"/{picture_id}/moderation", "/{picture_id}/moderation",
permissions=[IsSasAdmin], permissions=[IsSasAdmin],
@@ -1,4 +1,4 @@
import { paginated } from "#core:utils/api.ts"; import { paginated } from "#core:utils/api";
import { import {
type PictureSchema, type PictureSchema,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
+37 -10
View File
@@ -1,7 +1,7 @@
import type TomSelect from "tom-select"; import type TomSelect from "tom-select";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts"; import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api.ts"; import { paginated } from "#core:utils/api";
import { History } from "#core:utils/history.ts"; import { History } from "#core:utils/history";
import { import {
type IdentifiedUserSchema, type IdentifiedUserSchema,
type ModerationRequestSchema, type ModerationRequestSchema,
@@ -14,6 +14,7 @@ import {
picturesFetchPictures, picturesFetchPictures,
picturesIdentifyUsers, picturesIdentifyUsers,
picturesModeratePicture, picturesModeratePicture,
picturesRotatePicture,
type UserProfileSchema, type UserProfileSchema,
usersidentifiedDeleteRelation, usersidentifiedDeleteRelation,
} from "#openapi"; } from "#openapi";
@@ -28,18 +29,32 @@ class PictureWithIdentifications {
identificationsLoading = false; identificationsLoading = false;
moderationLoading = false; moderationLoading = false;
id: number; id: number;
// biome-ignore lint/style/useNamingConvention: api is in snake_case compressedUrl: string = "";
compressed_url: string; thumbUrl: string = "";
fullSizeUrl: string = "";
moderationRequests: ModerationRequestSchema[] = null; moderationRequests: ModerationRequestSchema[] = null;
constructor(picture: PictureSchema) { constructor(picture: PictureSchema) {
Object.assign(this, picture); 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 { static fromPicture(picture: PictureSchema): PictureWithIdentifications {
return new PictureWithIdentifications(picture); 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 * If not already done, fetch the users identified on this picture and
* populate the identifications field * populate the identifications field
@@ -82,12 +97,25 @@ class PictureWithIdentifications {
this.moderationLoading = false; 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 * Preload the photo and the identifications
*/ */
async preload(): Promise<void> { async preload(): Promise<void> {
const img = new Image(); const img = new Image();
img.src = this.compressed_url; img.src = this.compressedUrl;
if (!img.complete) { if (!img.complete) {
this.imageLoading = true; this.imageLoading = true;
img.addEventListener("load", () => { img.addEventListener("load", () => {
@@ -140,7 +168,8 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
full_size_url: "", full_size_url: "",
owner: "", owner: "",
date: new Date(), // biome-ignore lint/style/useNamingConvention: api is in snake_case
created_at: new Date(),
identifications: [] as IdentifiedUserSchema[], identifications: [] as IdentifiedUserSchema[],
}, },
/** /**
@@ -291,10 +320,8 @@ document.addEventListener("alpine:init", () => {
async submitIdentification(): Promise<void> { async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget; const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({ await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id, path: { picture_id: this.currentPicture.id },
},
body: widget.items.map((i: string) => Number.parseInt(i, 10)), body: widget.items.map((i: string) => Number.parseInt(i, 10)),
}); });
// refresh the identified users list // refresh the identified users list
+3 -6
View File
@@ -235,9 +235,7 @@
>.tools { >.tools {
flex: 1; flex: 1;
.btn {
>div>div {
>a.btn {
background-color: $primary-neutral-light-color; background-color: $primary-neutral-light-color;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -253,7 +251,7 @@
} }
} }
>a.text.danger { a.text.danger {
color: red; color: red;
&:hover { &:hover {
@@ -261,11 +259,10 @@
} }
} }
&.buttons { .buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 5px; gap: 5px;
} }
} }
} }
}
+70 -1
View File
@@ -12,19 +12,23 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from typing import Callable from typing import Callable, Literal
from unittest.mock import patch
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.base import ContentFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from PIL import Image
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import Group, User from core.models import Group, User
from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe from sas.baker_recipes import picture_recipe
from sas.models import Album, Picture from sas.models import Album, Picture
@@ -162,6 +166,71 @@ class TestAlbumUpload:
assert not album.children.exists() 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): class TestSasModeration(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):