Compare commits

..

2 Commits

11 changed files with 430 additions and 374 deletions
+3 -3
View File
@@ -1,13 +1,13 @@
<div id="quick-notifications"
x-data="{
x-data='{
messages: [
{%- for message in messages -%}
{%- if not message.extra_tags -%}
{ tag: '{{ message.tags }}', text: '{{ message }}' },
{ tag: {{ message.tags|string|tojson }}, text: {{ message|string|tojson }} },
{%- endif -%}
{%- endfor -%}
]
}"
}'
@quick-notification-add="(e) => messages.push(e?.detail)"
@quick-notification-delete="messages = []">
<template x-for="(message, index) in messages">
+82 -69
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-23 22:21+0100\n"
"POT-Creation-Date: 2026-04-25 01:00+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -181,6 +181,22 @@ msgstr "Vous devez être cotisant pour faire partie d'un club"
msgid "You are already a member of this club"
msgstr "Vous êtes déjà membre de ce club."
#: club/forms.py
msgid "Club status"
msgstr "État du club"
#: club/forms.py
msgid "Active"
msgstr "Actif"
#: club/forms.py
msgid "Inactive"
msgstr "Inactif"
#: club/forms.py
msgid "All clubs"
msgstr "Tous les clubs"
#: club/models.py
msgid "slug name"
msgstr "nom slug"
@@ -301,37 +317,22 @@ msgstr "Cet email est déjà abonné à cette mailing"
msgid "Unregistered user"
msgstr "Utilisateur non enregistré"
#: club/templates/club/club_list.jinja
msgid "Club list"
msgstr "Liste des clubs"
#: club/templates/club/club_list.jinja
msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM"
#: club/templates/club/club_list.jinja
msgid "Club list"
msgstr "Liste des clubs"
#: club/templates/club/club_list.jinja
msgid "Filters"
msgstr "Filtres"
#: club/templates/club/club_list.jinja
msgid "Name"
msgstr "Nom"
#: club/templates/club/club_list.jinja
msgid "Club state"
msgstr "Etat du club"
#: club/templates/club/club_list.jinja
msgid "Active"
msgstr "Actif"
#: club/templates/club/club_list.jinja
msgid "Inactive"
msgstr "Inactif"
#: club/templates/club/club_list.jinja
msgid "All clubs"
msgstr "Tous les clubs"
#: club/templates/club/club_list.jinja core/templates/core/base/header.jinja
#: forum/templates/forum/macros.jinja matmat/templates/matmat/search_form.jinja
msgid "Search"
msgstr "Recherche"
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
msgid "New club"
@@ -1863,11 +1864,6 @@ msgstr "Connexion"
msgid "Register"
msgstr "Inscription"
#: core/templates/core/base/header.jinja forum/templates/forum/macros.jinja
#: matmat/templates/matmat/search_form.jinja
msgid "Search"
msgstr "Recherche"
#: core/templates/core/base/header.jinja
msgid "Logout"
msgstr "Déconnexion"
@@ -4212,6 +4208,47 @@ msgstr ""
msgid "this page"
msgstr "cette page"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#: eboutic/templates/eboutic/eboutic_main.jinja
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
msgstr ""
"Vous devez être cotisant pour bénéficier du partenariat avec les "
"Eurockéennes."
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"This partnership offers a discount of up to 33%% on tickets for Friday, "
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
msgstr ""
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente"
@@ -4985,6 +5022,14 @@ msgstr "Envoyer les images"
msgid "You already requested moderation for this picture."
msgstr "Vous avez déjà déposé une demande de retrait pour cette photo."
#: sas/forms.py
msgid "Left"
msgstr ""
#: sas/forms.py
msgid "Right"
msgstr "Droite"
#: sas/models.py
msgid "picture"
msgstr "photo"
@@ -5106,6 +5151,14 @@ msgstr "Photos de %(user_name)s"
msgid "Download all my pictures"
msgstr "Télécharger toutes mes photos"
#: sas/views.py
msgid ""
"Newly rotated image might not be immediately displayed due to your web "
"browser's cache"
msgstr ""
"Les images nouvellements pivotées peuvent ne pas s'afficher immédiatement "
"à cause du cache de votre navigateur internet"
#: sith/settings.py
msgid "English"
msgstr "Anglais"
@@ -5638,10 +5691,6 @@ msgstr "fin"
msgid "Moderate Trombi comments"
msgstr "Modérer les commentaires du Trombi"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Reject"
msgstr "Refuser"
@@ -5883,39 +5932,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format
msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
msgstr ""
"Vous devez être cotisant pour bénéficier du partenariat avec les "
"Eurockéennes."
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"This partnership offers a discount of up to 33%% on tickets for Friday, "
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
msgstr ""
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
+12 -38
View File
@@ -1,23 +1,16 @@
import copy
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import Any
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from PIL import Image
from core.models import User
from core.utils import resize_image
from core.views import MultipleImageField
from core.views.forms import SelectDate
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleGroup
from sas.models import Album, Picture, PictureModerationRequest
from sas.widgets.ajax_select import AutoCompleteSelectAlbum
if TYPE_CHECKING:
from django.db.models.fields.files import FieldFile
class AlbumCreateForm(forms.ModelForm):
class Meta:
@@ -56,43 +49,17 @@ class AlbumEditForm(forms.ModelForm):
class Meta:
model = Album
fields = ["name", "date", "file", "parent", "edit_groups"]
widgets = {"edit_groups": AutoCompleteSelectMultipleGroup, "date": SelectDate}
widgets = {
"edit_groups": AutoCompleteSelectMultipleGroup,
}
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
parent = forms.ModelChoiceField(
Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum
)
def clean_file(self):
# if a file was given in the form, resize it
f: FieldFile = self.cleaned_data["file"]
if self.errors or not f or "file" not in self.changed_data:
return f
f.file = resize_image(Image.open(f.file), 200, "WEBP")
return f
def save(self, commit=True): # noqa: FBT002
initial_file = copy.copy(self.initial["file"])
if not self.cleaned_data["file"]:
# if no file is in the form, it can mean either :
# - there was a file initially, but the deletion box was checked
# - there was no file initially, and there still isn't
# in both cases, we procedurally generate the thumbnail
self.instance.generate_thumbnail()
elif "file" in self.changed_data:
self.instance.file.name = str(Path(self.instance.name) / "thumb.webp")
res = super().save(commit=commit)
if initial_file and (
not self.instance.file or initial_file.path != self.instance.file.path
):
# The initial file must be removed from storage
# AFTER the new one has been dealt with,
# in order to be sure that django will generate a different filename.
# Otherwise, the client cache wouldn't be properly busted.
initial_file.delete(save=False)
return res
class PictureModerationRequestForm(forms.ModelForm):
"""Form to create a PictureModerationRequest.
@@ -123,3 +90,10 @@ class PictureModerationRequestForm(forms.ModelForm):
self.instance.author = self.user
self.instance.picture = self.picture
return super().save(commit)
class PictureRotationForm(forms.Form):
picture = forms.ModelChoiceField(Picture.objects.all(), required=True)
direction = forms.ChoiceField(
choices=[("LEFT", _("Left")), ("RIGHT", _("Right"))], required=True
)
+29 -21
View File
@@ -22,7 +22,6 @@ from typing import ClassVar, Self
from django.conf import settings
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.db import models
from django.db.models import Exists, OuterRef, Q
from django.urls import reverse
@@ -111,7 +110,7 @@ class Picture(SasFile):
def get_absolute_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.id})
def generate_thumbnails(self):
def generate_thumbnails(self, *, overwrite=False):
im = Image.open(BytesIO(self.file.read()))
with contextlib.suppress(Exception):
im = exif_auto_rotate(im)
@@ -127,6 +126,10 @@ class Picture(SasFile):
file = resize_image(im, max(im.size), extension, optimize=False)
thumb = resize_image(im, 200, "webp")
compressed = resize_image(im, 1200, "webp")
if overwrite:
self.file.delete()
self.thumbnail.delete()
self.compressed.delete()
new_extension_name = str(Path(self.name).with_suffix(".webp"))
self.file = file
self.file.name = self.name
@@ -136,20 +139,20 @@ class Picture(SasFile):
self.compressed.name = new_extension_name
def rotate(self, degree):
for attr in ["file", "compressed", "thumbnail"]:
name = self.__getattribute__(attr).name
with open(settings.MEDIA_ROOT / name, "r+b") as file:
if file:
im = Image.open(BytesIO(file.read()))
file.seek(0)
im = im.rotate(degree, expand=True)
im.save(
fp=file,
format=self.mime_type.split("/")[-1].upper(),
quality=90,
optimize=True,
progressive=True,
)
im = Image.open(BytesIO(self.file.read()))
self.file.seek(0)
with open(self.file.path, "r+b") as f:
im = im.rotate(degree, expand=True)
im.save(
fp=f,
format=self.mime_type.split("/")[-1].upper(),
quality=90,
optimize=True,
progressive=True,
)
self.file.seek(0)
self.generate_thumbnails(overwrite=True)
self.save()
def get_next(self):
if self.is_moderated:
@@ -242,12 +245,17 @@ class Album(SasFile):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = self.children_pictures.order_by("?").first()
if p and p.thumbnail:
image = ContentFile(
name=str(Path(self.name) / "thumb.webp"), content=p.thumbnail.read()
)
p = (
self.children_pictures.order_by("?").first()
or self.children_albums.exclude(file=None)
.exclude(file="")
.order_by("?")
.first()
)
if p and p.file:
image = resize_image(Image.open(BytesIO(p.file.read())), 200, "webp")
self.file = image
self.file.name = f"{self.name}/thumb.webp"
self.save()
+20 -5
View File
@@ -191,7 +191,9 @@
}
>form {
input, .ts-wrapper {
input,
.ts-wrapper {
min-width: 100%;
max-width: 100%;
margin: 5px;
@@ -212,22 +214,27 @@
flex-direction: column;
}
.infos, .tools {
.infos,
.tools {
flex: 1;
display: flex;
flex-direction: column;
gap: .5em;
@media (min-width: 700px) {
max-width: 350px;
}
}
.infos > div, .tools > div > div {
.infos>div,
.tools>div>div {
display: flex;
flex-direction: column;
gap: .35em;
}
.tools > div, >.infos >div>div {
.tools>div,
>.infos>div>div {
display: flex;
flex-direction: row;
justify-content: space-between;
@@ -237,7 +244,15 @@
flex: 1;
>div>div {
>a.btn {
>form {
margin: 0;
padding: 0;
display: flex;
}
>a.btn,
>form>button {
background-color: $primary-neutral-light-color;
display: flex;
justify-content: center;
+4 -4
View File
@@ -2,19 +2,19 @@
<a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %}
{% set img = a.get_download_url() %}
{% set alt = a.name %}
{% set src = a.name %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set picture = a.children.filter(is_folder=False).first().as_picture %}
{% set img = picture.get_download_thumb_url() %}
{% set alt = picture.name %}
{% set src = picture.name %}
{% else %}
{% set img = static('core/img/sas.jpg') %}
{% set alt = "sas.jpg" %}
{% set src = "sas.jpg" %}
{% endif %}
<div
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
>
<img src="{{ img }}" alt="{{ alt }}" loading="lazy" />
<img src="{{ img }}" alt="{{ src }}" loading="lazy" />
{% if not a.is_moderated %}
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
+18 -3
View File
@@ -122,7 +122,8 @@
{% trans %}Ask for removal{% endtrans %}
</a>
</div>
<div class="buttons">
<div class="buttons"
>
<a
class="btn btn-no-text"
:href="currentPicture.edit_url"
@@ -130,8 +131,22 @@
>
<i class="fa-regular fa-pen-to-square edit-action"></i>
</a>
<a class="btn btn-no-text" href="?rotate_left"><i class="fa-solid fa-rotate-left"></i></a>
<a class="btn btn-no-text" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a>
<form method="post" action="{{ url("sas:picture_rotate") }}"
x-show="{{ user.has_perm("sas.change_sasfile")|tojson}}"
>
{% csrf_token %}
<input type="hidden" name="picture" :value="currentPicture.id">
<input type="hidden" name="direction" value="LEFT">
<button><i class="fa-solid fa-rotate-left"></i></button>
</form>
<form method="post" action="{{ url("sas:picture_rotate") }}"
x-show="{{ user.has_perm("sas.change_sasfile")|tojson}}"
>
{% csrf_token %}
<input type="hidden" name="picture" :value="currentPicture.id">
<input type="hidden" name="direction" value="RIGHT">
<button><i class="fa-solid fa-rotate-right"></i></button>
</form>
</div>
</div>
</div>
-218
View File
@@ -1,218 +0,0 @@
import random
import string
from pathlib import Path
from typing import Callable
from unittest.mock import patch
import pytest
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client
from django.urls import reverse
from django.utils.datastructures import MultiValueDict
from django.utils.timezone import localdate
from model_bakery import baker
from PIL import Image
from pytest_django.asserts import assertInHTML, assertRedirects
from core.baker_recipes import subscriber_user
from core.models import Group, User
from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe
from sas.forms import AlbumEditForm
from sas.models import Album
@pytest.fixture
def sas_root(db) -> Album:
return Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
@pytest.fixture
def album(db) -> Album:
name = "".join(
random.choice(string.ascii_letters) for _ in range(Album.NAME_MAX_LENGTH)
)
return baker.make(
Album, name=name, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
)
@pytest.mark.parametrize("user", [None, lambda: baker.make(User), subscriber_user.make])
@pytest.mark.django_db
def test_permission_denied(
client: Client, album: Album, user: Callable[[], User] | None
):
if user:
client.force_login(user())
url = reverse("sas:album_edit", kwargs={"album_id": album.pk})
for method in client.get, client.post:
assert method(url).status_code == 403
@pytest.mark.django_db
def test_sas_root_read_only(client: Client, sas_root: Album):
moderator = baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
client.force_login(moderator)
url = reverse("sas:album_edit", kwargs={"album_id": sas_root.pk})
for method in client.get, client.post:
assert method(url).status_code == 404
@pytest.mark.parametrize(
("excluded", "is_valid"),
[
("name", False),
("date", False),
("file", True),
("parent", False),
("edit_groups", True),
("recursive", True),
],
)
@pytest.mark.django_db
def test_form_required(album: Album, excluded: str, is_valid: bool): # noqa: FBT001
data = {
"name": album.name,
"parent": baker.make(Album, parent=album.parent, is_moderated=True).pk,
"date": localdate(),
"file": "/random/path",
"edit_groups": [settings.SITH_GROUP_SAS_ADMIN_ID],
"recursive": False,
}
del data[excluded]
assert AlbumEditForm(data=data).is_valid() == is_valid
@pytest.mark.django_db
def test_form_album_name(album: Album):
data = {
"name": "a" * Album.NAME_MAX_LENGTH,
"parent": album.pk,
"date": localdate(),
}
assert AlbumEditForm(data=data).is_valid()
data["name"] = "a" * (Album.NAME_MAX_LENGTH + 1)
assert not AlbumEditForm(data=data).is_valid()
@pytest.mark.django_db
def test_update_recursive_parent(client: Client, album: Album):
client.force_login(baker.make(User, is_superuser=True))
payload = {"name": album.name, "parent": album.pk, "date": localdate()}
response = client.post(
reverse("sas:album_edit", kwargs={"album_id": album.pk}), payload
)
assertInHTML("<li>Boucle dans l'arborescence des dossiers</li>", response.text)
assert response.status_code == 200
@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(
"parent",
[
lambda: baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
),
lambda: Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID),
],
)
@pytest.mark.django_db
def test_update(
client: Client,
album: Album,
sas_root: Album,
user: Callable[[], User],
parent: Callable[[], Album],
):
client.force_login(user())
expected_redirect = reverse("sas:album", kwargs={"album_id": album.pk})
payload = {
"name": "foo",
"parent": parent().id,
"date": localdate(),
"recursive": False,
}
response = client.post(
reverse("sas:album_edit", kwargs={"album_id": album.pk}), payload
)
assertRedirects(response, expected_redirect)
album.refresh_from_db()
assert album.name == "foo"
assert album.parent.id == payload["parent"]
assert localdate(album.date) == localdate()
class TestAlbumThumbnail:
@pytest.fixture
def files(self):
return MultiValueDict(
{"file": [SimpleUploadedFile(name="foo.png", content=RED_PIXEL_PNG)]}
)
def test_thumbnail_resized(self, album, files):
"""Test that album thumbnails are resized to the correct dimensions."""
form = AlbumEditForm(
data={"name": album.name, "date": localdate(), "parent": album.parent.id},
files=files,
instance=album,
)
assert form.is_valid()
form.save()
album.refresh_from_db()
assert album.file.name == f"SAS/{album.name}/thumb.webp"
assert Image.open(album.file).size == (200, 200)
def test_thumbnail_removed(self, album):
"""Test the case where the user checks the box to remove the thumbnail"""
album.file = ContentFile(name="foo.png", content=RED_PIXEL_PNG)
album.save()
previous_filename = album.file.name
form = AlbumEditForm(
data={
"name": "foo",
"date": localdate(),
"parent": album.parent.id,
"file-clear": True,
},
instance=album,
)
# as there is now no picture, a thumbnail should be generated
with patch.object(Album, "generate_thumbnail") as mock:
assert form.is_valid()
form.save()
album.refresh_from_db()
assert album.file.storage.exists(album.file.name)
assert not album.file.storage.exists(previous_filename)
mock.assert_called_once()
def test_generate_thumbnail(self, album):
"""Test that if no image is given and the album has pictures,
the thumbnail is automatically generated.
"""
picture = picture_recipe.make(
parent=album, thumbnail=ContentFile(name="foo.png", content=RED_PIXEL_PNG)
)
form = AlbumEditForm(
data={"name": "foo", "date": localdate(), "parent": album.parent.id},
instance=album,
)
assert form.is_valid()
form.save()
album.refresh_from_db()
assert Path(album.file.name) == Path("SAS/foo/thumb.webp")
assert album.file.storage.exists(album.file.name)
assert Image.open(album.file) == Image.open(picture.thumbnail)
+226 -1
View File
@@ -12,20 +12,25 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from io import BytesIO
from typing import Callable
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 django.utils.timezone import localdate
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 sas.baker_recipes import picture_recipe
from sas.forms import AlbumEditForm
from sas.models import Album, Picture
# Create your tests here.
@@ -95,7 +100,6 @@ def test_main_page_content_anonymous(client: Client):
@pytest.mark.django_db
def test_album_access_non_subscriber(client: Client):
"""Test that non-subscribers can only access albums where they are identified."""
cache.clear()
album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID)
user = baker.make(User)
client.force_login(user)
@@ -162,6 +166,227 @@ class TestAlbumUpload:
assert not album.children.exists()
@pytest.mark.django_db
class TestAlbumEdit:
@pytest.fixture
def sas_root(self) -> Album:
return Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
@pytest.fixture
def album(self) -> Album:
return baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
)
@pytest.mark.parametrize(
"user",
[None, lambda: baker.make(User), subscriber_user.make],
)
def test_permission_denied(
self,
client: Client,
album: Album,
user: Callable[[], User] | None,
):
if user:
client.force_login(user())
url = reverse("sas:album_edit", kwargs={"album_id": album.pk})
response = client.get(url)
assert response.status_code == 403
response = client.post(url)
assert response.status_code == 403
def test_sas_root_read_only(self, client: Client, sas_root: Album):
moderator = baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
client.force_login(moderator)
url = reverse("sas:album_edit", kwargs={"album_id": sas_root.pk})
response = client.get(url)
assert response.status_code == 404
response = client.post(url)
assert response.status_code == 404
@pytest.mark.parametrize(
("excluded", "is_valid"),
[
("name", False),
("date", False),
("file", True),
("parent", False),
("edit_groups", True),
("recursive", True),
],
)
def test_form_required(self, album: Album, excluded: str, is_valid: bool): # noqa: FBT001
data = {
"name": album.name[: Album.NAME_MAX_LENGTH],
"parent": baker.make(Album, parent=album.parent, is_moderated=True).pk,
"date": localdate().strftime("%Y-%m-%d"),
"file": "/random/path",
"edit_groups": [settings.SITH_GROUP_SAS_ADMIN_ID],
"recursive": False,
}
del data[excluded]
assert AlbumEditForm(data=data).is_valid() == is_valid
def test_form_album_name(self, album: Album):
data = {
"name": album.name[: Album.NAME_MAX_LENGTH],
"parent": album.pk,
"date": localdate().strftime("%Y-%m-%d"),
}
assert AlbumEditForm(data=data).is_valid()
data["name"] = album.name[: Album.NAME_MAX_LENGTH + 1]
assert not AlbumEditForm(data=data).is_valid()
def test_update_recursive_parent(self, client: Client, album: Album):
client.force_login(baker.make(User, is_superuser=True))
payload = {
"name": album.name[: Album.NAME_MAX_LENGTH],
"parent": album.pk,
"date": localdate().strftime("%Y-%m-%d"),
}
response = client.post(
reverse("sas:album_edit", kwargs={"album_id": album.pk}), payload
)
assertInHTML("<li>Boucle dans l'arborescence des dossiers</li>", response.text)
assert response.status_code == 200
@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(
"parent",
[
lambda: baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
),
lambda: Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID),
],
)
def test_update(
self,
client: Client,
album: Album,
sas_root: Album,
user: Callable[[], User],
parent: Callable[[], Album],
):
client.force_login(user())
expected_redirect = reverse("sas:album", kwargs={"album_id": album.pk})
payload = {
"name": album.name[: Album.NAME_MAX_LENGTH],
"parent": parent().id,
"date": localdate().strftime("%Y-%m-%d"),
"recursive": False,
}
response = client.post(
reverse("sas:album_edit", kwargs={"album_id": album.pk}), payload
)
assertRedirects(response, expected_redirect)
album.refresh_from_db()
assert album.name == payload["name"]
assert album.parent.id == payload["parent"]
assert localdate(album.date) == localdate()
@pytest.mark.django_db
class TestPictureRotation:
@pytest.fixture
def picture(self) -> Picture:
# Creating a fake image from scratch is painful
# One of the base image in the test set is good enough
return Picture.objects.get(name="sli.jpg")
def load_image(self, file: ContentFile) -> Image.Image:
file.seek(0)
im = Image.open(BytesIO(file.read()))
file.seek(0)
return im
@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())
payload = {
"picture": picture.pk,
"direction": "LEFT",
}
url = reverse("sas:picture_rotate")
response = client.post(url, payload)
if user:
assert response.status_code == 403
else:
assertRedirects(
response,
reverse(
"core:login",
query={
"next": url,
},
),
)
@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)]
),
],
)
def test_rotation(
self,
client: Client,
picture: Picture,
user: Callable[[], User],
):
client.force_login(user())
payload = {
"picture": picture.pk,
"direction": "LEFT",
}
response = client.post(reverse("sas:picture_rotate"), payload)
assertRedirects(
response, reverse("sas:picture", kwargs={"picture_id": picture.pk})
)
payload = {
"picture": picture.pk,
"direction": "RIGHT",
}
response = client.post(reverse("sas:picture_rotate"), payload)
assertRedirects(
response, reverse("sas:picture", kwargs={"picture_id": picture.pk})
)
class TestSasModeration(TestCase):
@classmethod
def setUpTestData(cls):
+2
View File
@@ -22,6 +22,7 @@ from sas.views import (
ModerationView,
PictureAskRemovalView,
PictureEditView,
PictureRotateView,
PictureView,
SASMainView,
UserPicturesView,
@@ -52,6 +53,7 @@ urlpatterns = [
send_compressed,
name="download_compressed",
),
path("picture/rotate", PictureRotateView.as_view(), name="picture_rotate"),
path("picture/<int:picture_id>/download/thumb/", send_thumb, name="download_thumb"),
path(
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
+34 -12
View File
@@ -15,13 +15,14 @@
from typing import Any
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db.models import Count, OuterRef, Subquery
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import SafeString
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView, UpdateView
@@ -36,6 +37,7 @@ from sas.forms import (
AlbumEditForm,
PictureEditForm,
PictureModerationRequestForm,
PictureRotationForm,
PictureUploadForm,
)
from sas.models import Album, PeoplePictureRelation, Picture
@@ -97,20 +99,39 @@ 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)
}
class PictureRotateView(PermissionRequiredMixin, FormView):
form_class = PictureRotationForm
template_name = "core/edit.jinja"
permission_required = "sas.moderate_sasfile"
def form_valid(self, form: PictureRotationForm):
angles = {"RIGHT": 270, "LEFT": 90}
cleaned = form.clean()
cleaned["picture"].rotate(angles[cleaned["direction"]])
self._success_url = reverse(
"sas:picture",
kwargs={
"picture_id": cleaned["picture"].pk,
},
)
messages.warning(
self.request,
_(
"Newly rotated image might not be immediately displayed due to your web browser's cache"
),
)
return super().form_valid(form)
def get_success_url(self):
return self._success_url
def send_album(request, album_id):
return send_file(request, album_id, Album)
@@ -153,9 +174,10 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if not request.user.can_edit(self.object):
raise PermissionDenied
FileView.handle_clipboard(request, self.object)
if not self.object.file:
self.object.generate_thumbnail()
if request.user.can_edit(self.object): # Handle the copy-paste functions
FileView.handle_clipboard(request, self.object)
return HttpResponseRedirect(self.request.path)
def get_fragment_data(self) -> dict[str, dict[str, Any]]: