diff --git a/com/tests/test_notifications.py b/com/tests/test_notifications.py index 8ddbfcb3..fc0e6aee 100644 --- a/com/tests/test_notifications.py +++ b/com/tests/test_notifications.py @@ -7,7 +7,7 @@ from model_bakery import baker from com.models import News, NewsDate from core.baker_recipes import subscriber_user -from core.models import Group, Notification, User +from core.models import Group, Notification, SithFile, User @pytest.mark.django_db @@ -18,6 +18,7 @@ def test_notification_created(): past_news = baker.make(News, is_published=False) baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1)) com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID) + SithFile.objects.filter(owner__in=com_admin_group.users.all()).delete() com_admin_group.users.all().delete() Notification.objects.all().delete() com_admin = baker.make(User, groups=[com_admin_group]) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index a326b8ed..22541841 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -622,8 +622,7 @@ class Command(BaseCommand): ) pict.file.name = p.name pict.full_clean() - pict.generate_thumbnails() - pict.save() + pict.generate_thumbnails(save=True) img_skia = Picture.objects.get(name="skia.jpg") img_sli = Picture.objects.get(name="sli.jpg") diff --git a/core/migrations/0050_alter_sithfile_moderator.py b/core/migrations/0050_alter_sithfile_moderator.py new file mode 100644 index 00000000..0b6e9ef1 --- /dev/null +++ b/core/migrations/0050_alter_sithfile_moderator.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.12 on 2026-05-01 08:59 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +from django.db.migrations.state import StateApps +from django.db.models import F + + +def set_updated_at(apps: StateApps, schema_editor): + SithFile = apps.get_model("core", "SithFile") + SithFile.objects.update(updated_at=F("date")) + + +class Migration(migrations.Migration): + dependencies = [("core", "0049_user_whitelisted_users")] + + operations = [ + migrations.AlterField( + model_name="sithfile", + name="moderator", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="moderated_files", + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + migrations.AlterField( + model_name="sithfile", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="owned_files", + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + migrations.AddField( + model_name="sithfile", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="updated at"), + ), + migrations.RunPython(set_updated_at, reverse_code=migrations.RunPython.noop), + ] diff --git a/core/models.py b/core/models.py index ffbdebfc..208f1937 100644 --- a/core/models.py +++ b/core/models.py @@ -853,7 +853,7 @@ class SithFile(models.Model): User, related_name="owned_files", verbose_name=_("owner"), - on_delete=models.CASCADE, + on_delete=models.PROTECT, ) edit_groups = models.ManyToManyField( Group, related_name="editable_files", verbose_name=_("edit group"), blank=True @@ -865,6 +865,7 @@ class SithFile(models.Model): mime_type = models.CharField(_("mime type"), max_length=30) size = models.IntegerField(_("size"), default=0) date = models.DateTimeField(_("date"), default=timezone.now) + updated_at = models.DateTimeField(_("updated at"), auto_now=True) is_moderated = models.BooleanField(_("is moderated"), default=False) moderator = models.ForeignKey( User, @@ -872,7 +873,7 @@ class SithFile(models.Model): verbose_name=_("owner"), null=True, blank=True, - on_delete=models.CASCADE, + on_delete=models.SET_NULL, ) asked_for_removal = models.BooleanField(_("asked for removal"), default=False) is_in_sas = models.BooleanField( diff --git a/core/templates/core/base/notifications.jinja b/core/templates/core/base/notifications.jinja index 89fb7aad..030b2d4e 100644 --- a/core/templates/core/base/notifications.jinja +++ b/core/templates/core/base/notifications.jinja @@ -1,13 +1,13 @@
@@ -157,7 +172,7 @@ @keyup.right.window="currentPicture = nextPicture" @click="currentPicture = nextPicture" > - {% trans %}Previous picture{% endtrans %} + {% trans %}Previous picture{% endtrans %}
diff --git a/sas/tests/test_model.py b/sas/tests/test_model.py index 537d7fd7..71edf905 100644 --- a/sas/tests/test_model.py +++ b/sas/tests/test_model.py @@ -1,6 +1,11 @@ +from io import BytesIO +from pathlib import Path + import pytest +from django.core.files.base import ContentFile from django.test import TestCase from model_bakery import baker +from PIL import Image from core.baker_recipes import old_subscriber_user, subscriber_user from core.models import User @@ -67,3 +72,36 @@ def test_identifications_viewable_by_user(): assert list(picture.people.viewable_by(identifications[1].user)) == [ identifications[1] ] + + +@pytest.mark.django_db +@pytest.mark.parametrize("save", [True, False]) +@pytest.mark.parametrize("initially_saved", [True, False]) +@pytest.mark.parametrize("pass_img_kwarg", [True, False]) +def test_generate_thumbnail(save, initially_saved, pass_img_kwarg): + """Test that Picture.generate_thumbnails works properly""" + image = Image.new("RGB", (2, 1)) + image.putdata([(255, 0, 0), (0, 255, 0)]) + buffer = BytesIO() + image.save(buffer, format="PNG") + file = ContentFile(buffer.getvalue(), "img.png") + picture: Picture = picture_recipe.prepare( + file=file, + name=file.name, + mime_type="image/png", + _save_related=True, + ) + if initially_saved: + picture.save() + picture.generate_thumbnails(img=image if pass_img_kwarg else None, save=save) + storage = picture.file.storage + for f in picture.file, picture.compressed, picture.thumbnail: + # the tested picture is alone in its album, + # so there should be a single file in each folder + assert storage.exists(f.name) + _dirs, files = storage.listdir(str(Path(f.path).parent)) + assert files == [Path(f.name).name] + new_img = Image.open(picture.file) + assert new_img.get_flattened_data() == image.get_flattened_data() + assert Image.open(picture.thumbnail).size == (200, 100) + assert Image.open(picture.compressed).size == (1200, 600) 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): diff --git a/sas/views.py b/sas/views.py index 7e7f0ba2..5e5e5825 100644 --- a/sas/views.py +++ b/sas/views.py @@ -97,14 +97,6 @@ class PictureView(CanViewMixin, DetailView): pk_url_kwarg = "picture_id" 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): return super().get_context_data(**kwargs) | { "album": Album.objects.get(children=self.object)