mirror of
https://github.com/ae-utbm/sith.git
synced 2026-05-03 03:46: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):
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -97,14 +97,6 @@ class PictureView(CanViewMixin, DetailView):
|
|||||||
pk_url_kwarg = "picture_id"
|
pk_url_kwarg = "picture_id"
|
||||||
template_name = "sas/picture.jinja"
|
template_name = "sas/picture.jinja"
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
self.object = self.get_object()
|
|
||||||
if "rotate_right" in request.GET:
|
|
||||||
self.object.rotate(270)
|
|
||||||
if "rotate_left" in request.GET:
|
|
||||||
self.object.rotate(90)
|
|
||||||
return super().get(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
return super().get_context_data(**kwargs) | {
|
return super().get_context_data(**kwargs) | {
|
||||||
"album": Album.objects.get(children=self.object)
|
"album": Album.objects.get(children=self.object)
|
||||||
|
|||||||
Reference in New Issue
Block a user