mirror of
https://github.com/ae-utbm/sith.git
synced 2026-05-02 11:26:08 +00:00
feat: rotate pictures with API+AlpineJS
This commit is contained in:
+13
@@ -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],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { paginated } from "#core:utils/api.ts";
|
||||
import { paginated } from "#core:utils/api";
|
||||
import {
|
||||
type PictureSchema,
|
||||
type PicturesFetchPicturesData,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const widget: TomSelect = this.selector.widget;
|
||||
await picturesIdentifyUsers({
|
||||
path: {
|
||||
// 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)),
|
||||
});
|
||||
// refresh the identified users list
|
||||
|
||||
@@ -235,9 +235,7 @@
|
||||
|
||||
>.tools {
|
||||
flex: 1;
|
||||
|
||||
>div>div {
|
||||
>a.btn {
|
||||
.btn {
|
||||
background-color: $primary-neutral-light-color;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -253,7 +251,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
>a.text.danger {
|
||||
a.text.danger {
|
||||
color: red;
|
||||
|
||||
&:hover {
|
||||
@@ -261,11 +259,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.buttons {
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
-1
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user