Merge pull request #1053 from ae-utbm/sas-upload

Improve SAS upload
This commit is contained in:
thomas girod 2025-04-09 19:32:55 +02:00 committed by GitHub
commit 60fd72917d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 314 additions and 296 deletions

View File

@ -740,8 +740,9 @@ Welcome to the wiki page!
size=file.size, size=file.size,
) )
pict.file.name = p.name pict.file.name = p.name
pict.clean() pict.full_clean()
pict.generate_thumbnails() pict.generate_thumbnails()
pict.save()
img_skia = Picture.objects.get(name="skia.jpg") img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg") img_sli = Picture.objects.get(name="sli.jpg")

View File

@ -1,16 +1,29 @@
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated, Any
from annotated_types import MinLen from annotated_types import MinLen
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Q from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext as _
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field from pydantic import AliasChoices, Field
from pydantic_core.core_schema import ValidationInfo
from core.models import Group, SithFile, User from core.models import Group, SithFile, User
from core.utils import is_image
class UploadedImage(UploadedFile):
@classmethod
def _validate(cls, v: Any, info: ValidationInfo) -> Any:
super()._validate(v, info)
if not is_image(v):
msg = _("This file is not a valid image")
raise ValueError(msg)
return v
class SimpleUserSchema(ModelSchema): class SimpleUserSchema(ModelSchema):

View File

@ -17,15 +17,30 @@ from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
from typing import Final
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL import ExifTags from PIL import ExifTags
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
RED_PIXEL_PNG: Final[bytes] = (
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
b"\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xcf\xc0\x00"
b"\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00\x49\x45\x4e"
b"\x44\xae\x42\x60\x82"
)
"""A single red pixel, in PNG format.
Can be used in tests and in dev, when there is a need
to generate a dummy image that is considered valid nonetheless
"""
def get_start_of_semester(today: date | None = None) -> date: def get_start_of_semester(today: date | None = None) -> date:
"""Return the date of the start of the semester of the given date. """Return the date of the start of the semester of the given date.
@ -97,6 +112,18 @@ def get_semester_code(d: date | None = None) -> str:
return "P" + str(start.year)[-2:] return "P" + str(start.year)[-2:]
def is_image(file: UploadedFile):
try:
im = PIL.Image.open(file.file)
im.verify()
# go back to the start of the file, without closing it.
# Otherwise, further checks on django side will fail
file.seek(0)
except PIL.UnidentifiedImageError:
return False
return True
def resize_image( def resize_image(
im: Image, edge: int, img_format: str, *, optimize: bool = True im: Image, edge: int, img_format: str, *, optimize: bool = True
) -> ContentFile: ) -> ContentFile:

View File

@ -32,17 +32,10 @@ from django.utils import timezone
from club.models import Club, Membership from club.models import Club, Membership
from core.models import Group, Page, SithFile, User from core.models import Group, Page, SithFile, User
from core.utils import RED_PIXEL_PNG
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription from subscription.models import Subscription
RED_PIXEL_PNG: Final[bytes] = (
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
b"\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xcf\xc0\x00"
b"\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00\x49\x45\x4e"
b"\x44\xae\x42\x60\x82"
)
USER_PACK_SIZE: Final[int] = 1000 USER_PACK_SIZE: Final[int] = 1000

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-06 15:54+0200\n" "POT-Creation-Date: 2025-04-08 16:20+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -116,7 +116,7 @@ msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur"
msgid "You should specify a role" msgid "You should specify a role"
msgstr "Vous devez choisir un rôle" msgstr "Vous devez choisir un rôle"
#: club/forms.py sas/views.py #: club/forms.py sas/forms.py
msgid "You do not have the permission to do that" msgid "You do not have the permission to do that"
msgstr "Vous n'avez pas la permission de faire cela" msgstr "Vous n'avez pas la permission de faire cela"
@ -1047,7 +1047,7 @@ msgid "Posters - edit"
msgstr "Affiche - modifier" msgstr "Affiche - modifier"
#: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja #: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja
#: sas/templates/sas/main.jinja #: sas/templates/sas/fragments/album_create_form.jinja
msgid "Create" msgid "Create"
msgstr "Créer" msgstr "Créer"
@ -1644,6 +1644,10 @@ msgstr "étiquette"
msgid "operation type" msgid "operation type"
msgstr "type d'opération" msgstr "type d'opération"
#: core/schemas.py
msgid "This file is not a valid image"
msgstr "Ce fichier n'est pas une image valide"
#: core/templates/core/403.jinja #: core/templates/core/403.jinja
msgid "403, Forbidden" msgid "403, Forbidden"
msgstr "403, Non autorisé" msgstr "403, Non autorisé"
@ -2729,7 +2733,7 @@ msgstr "Ajouter un nouveau dossier"
msgid "Error creating folder %(folder_name)s: %(msg)s" msgid "Error creating folder %(folder_name)s: %(msg)s"
msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s" msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s"
#: core/views/files.py core/views/forms.py sas/forms.py #: core/views/files.py core/views/forms.py
#, python-format #, python-format
msgid "Error uploading file %(file_name)s: %(msg)s" msgid "Error uploading file %(file_name)s: %(msg)s"
msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s" msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s"
@ -4715,11 +4719,6 @@ msgstr "Ajouter un nouvel album"
msgid "Upload images" msgid "Upload images"
msgstr "Envoyer les images" msgstr "Envoyer les images"
#: sas/forms.py
#, python-format
msgid "Error creating album %(album)s: %(msg)s"
msgstr "Erreur de création de l'album %(album)s : %(msg)s"
#: sas/forms.py #: sas/forms.py
msgid "You already requested moderation for this picture." msgid "You already requested moderation for this picture."
msgstr "Vous avez déjà déposé une demande de retrait pour cette photo." msgstr "Vous avez déjà déposé une demande de retrait pour cette photo."

View File

@ -1,7 +1,10 @@
from typing import Any, Literal
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import F from django.db.models import F
from django.urls import reverse from django.urls import reverse
from ninja import Query from ninja import Body, File, Query
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.exceptions import NotFound, PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
@ -9,8 +12,15 @@ from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from core.auth.api_permissions import (
CanAccessLookup,
CanEdit,
CanView,
IsInGroup,
IsRoot,
)
from core.models import Notification, User from core.models import Notification, User
from core.schemas import UploadedImage
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumAutocompleteSchema, AlbumAutocompleteSchema,
@ -92,6 +102,38 @@ class PicturesController(ControllerBase):
.annotate(album=F("parent__name")) .annotate(album=F("parent__name"))
) )
@route.post(
"",
permissions=[CanEdit],
response={
200: None,
409: dict[Literal["detail"], dict[str, list[str]]],
422: dict[Literal["detail"], list[dict[str, Any]]],
},
url_name="upload_picture",
)
def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]):
album = self.get_object_or_exception(Album, pk=album_id)
user = self.context.request.user
self_moderate = user.has_perm("sas.moderate_sasfile")
new = Picture(
parent=album,
name=picture.name,
file=picture,
owner=user,
is_moderated=self_moderate,
is_folder=False,
mime_type=picture.content_type,
)
if self_moderate:
new.moderator = user
try:
new.generate_thumbnails()
new.full_clean()
new.save()
except ValidationError as e:
return self.create_response({"detail": dict(e)}, status_code=409)
@route.get( @route.get(
"/{picture_id}/identified", "/{picture_id}/identified",
permissions=[IsAuthenticated, CanView], permissions=[IsAuthenticated, CanView],

View File

@ -1,6 +1,7 @@
from typing import Any from typing import Any
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
@ -11,55 +12,28 @@ from sas.models import Album, Picture, PictureModerationRequest
from sas.widgets.ajax_select import AutoCompleteSelectAlbum from sas.widgets.ajax_select import AutoCompleteSelectAlbum
class SASForm(forms.Form): class AlbumCreateForm(forms.ModelForm):
album_name = forms.CharField( class Meta:
label=_("Add a new album"), max_length=Album.NAME_MAX_LENGTH, required=False model = Album
) fields = ["name", "parent"]
images = MultipleImageField( labels = {"name": _("Add a new album")}
label=_("Upload images"), widgets = {"parent": forms.HiddenInput}
required=False,
)
def process(self, parent, owner, files, *, automodere=False): def __init__(self, *args, owner: User, **kwargs):
try: super().__init__(*args, **kwargs)
if self.cleaned_data["album_name"] != "": self.instance.owner = owner
album = Album( if owner.has_perm("sas.moderate_sasfile"):
parent=parent, self.instance.is_moderated = True
name=self.cleaned_data["album_name"], self.instance.moderator = owner
owner=owner,
is_moderated=automodere, def clean(self):
) if not self.instance.owner.can_edit(self.instance.parent):
album.clean() raise ValidationError(_("You do not have the permission to do that"))
album.save() return super().clean()
except Exception as e:
self.add_error(
None, class PictureUploadForm(forms.Form):
_("Error creating album %(album)s: %(msg)s") images = MultipleImageField(label=_("Upload images"), required=False)
% {"album": self.cleaned_data["album_name"], "msg": repr(e)},
)
for f in files:
new_file = Picture(
parent=parent,
name=f.name,
file=f,
owner=owner,
mime_type=f.content_type,
size=f.size,
is_folder=False,
is_moderated=automodere,
)
if automodere:
new_file.moderator = owner
try:
new_file.clean()
new_file.generate_thumbnails()
new_file.save()
except Exception as e:
self.add_error(
None,
_("Error uploading file %(file_name)s: %(msg)s")
% {"file_name": f, "msg": repr(e)},
)
class PictureEditForm(forms.ModelForm): class PictureEditForm(forms.ModelForm):

View File

@ -134,7 +134,6 @@ class Picture(SasFile):
self.thumbnail.name = new_extension_name self.thumbnail.name = new_extension_name
self.compressed = compressed self.compressed = compressed
self.compressed.name = new_extension_name self.compressed.name = new_extension_name
self.save()
def rotate(self, degree): def rotate(self, degree):
for attr in ["file", "compressed", "thumbnail"]: for attr in ["file", "compressed", "thumbnail"]:
@ -235,6 +234,8 @@ class Album(SasFile):
return Album.objects.filter(parent=self) return Album.objects.filter(parent=self)
def get_absolute_url(self): def get_absolute_url(self):
if self.id == settings.SITH_SAS_ROOT_DIR_ID:
return reverse("sas:main")
return reverse("sas:album", kwargs={"album_id": self.id}) return reverse("sas:album", kwargs={"album_id": self.id})
def get_download_url(self): def get_download_url(self):

View File

@ -5,8 +5,10 @@ import {
type AlbumSchema, type AlbumSchema,
type PictureSchema, type PictureSchema,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
type PicturesUploadPictureErrors,
albumFetchAlbum, albumFetchAlbum,
picturesFetchPictures, picturesFetchPictures,
picturesUploadPicture,
} from "#openapi"; } from "#openapi";
interface AlbumPicturesConfig { interface AlbumPicturesConfig {
@ -78,4 +80,49 @@ document.addEventListener("alpine:init", () => {
this.loading = false; this.loading = false;
}, },
})); }));
Alpine.data("pictureUpload", (albumId: number) => ({
errors: [] as string[],
pictures: [],
sending: false,
progress: null as HTMLProgressElement,
init() {
this.progress = this.$refs.progress;
},
async sendPictures() {
const input = this.$refs.pictures as HTMLInputElement;
const files = input.files;
this.errors = [];
this.progress.value = 0;
this.progress.max = files.length;
this.sending = true;
for (const file of files) {
await this.sendPicture(file);
}
this.sending = false;
// This should trigger a reload of the pictures of the `picture` Alpine data
this.$dispatch("pictures-upload-done");
},
async sendPicture(file: File) {
const res = await picturesUploadPicture({
// biome-ignore lint/style/useNamingConvention: api is snake_case
body: { album_id: albumId, picture: file },
});
if (!res.response.ok) {
let msg = "";
if (res.response.status === 422) {
msg = (res.error as PicturesUploadPictureErrors[422]).detail
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
.join(" ; ");
} else {
msg = Object.values(res.error.detail).join(" ; ");
}
this.errors.push(`${file.name} : ${msg}`);
}
this.progress.value += 1;
},
}));
}); });

View File

@ -73,7 +73,7 @@
<div class="text">{% trans %}To be moderated{% endtrans %}</div> <div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template> </template>
</div> </div>
{% if edit_mode %} {% if is_sas_admin %}
<input type="checkbox" name="file_list" :value="album.id"> <input type="checkbox" name="file_list" :value="album.id">
{% endif %} {% endif %}
</a> </a>
@ -86,7 +86,7 @@
<h4>{% trans %}Pictures{% endtrans %}</h4> <h4>{% trans %}Pictures{% endtrans %}</h4>
<br> <br>
{{ download_button(_("Download album")) }} {{ download_button(_("Download album")) }}
<div class="photos" :aria-busy="loading"> <div class="photos" :aria-busy="loading" @pictures-upload-done.window="fetchPictures">
<template x-for="picture in getPage(page)"> <template x-for="picture in getPage(page)">
<a :href="picture.sas_url"> <a :href="picture.sas_url">
<div class="photo" :class="{not_moderated: !picture.is_moderated}"> <div class="photo" :class="{not_moderated: !picture.is_moderated}">
@ -110,13 +110,28 @@
{% if is_sas_admin %} {% if is_sas_admin %}
</form> </form>
<form class="add-files" id="upload_form" action="" method="post" enctype="multipart/form-data"> {{ album_create_fragment }}
<form
class="add-files"
id="upload_form"
x-data="pictureUpload({{ album.id }})"
@submit.prevent="sendPictures()"
>
{% csrf_token %} {% csrf_token %}
<div class="inputs"> <div class="inputs">
{{ form.as_p() }} <p>
<label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label>
{{ upload_form.images|add_attr("x-ref=pictures") }}
<span class="helptext">{{ upload_form.images.help_text }}</span>
</p>
<input type="submit" value="{% trans %}Upload{% endtrans %}" /> <input type="submit" value="{% trans %}Upload{% endtrans %}" />
<progress x-ref="progress" x-show="sending"></progress>
</div> </div>
<ul class="errorlist">
<template x-for="error in errors">
<li class="error" x-text="error"></li>
</template>
</ul>
</form> </form>
{% endif %} {% endif %}
@ -126,115 +141,3 @@
{{ timezone.now() - start }} {{ timezone.now() - start }}
</p> </p>
{% endblock %} {% endblock %}
{% block script %}
{{ super() }}
<script>
// Todo: migrate to alpine.js if we have some time
$("form#upload_form").submit(function (event) {
let formData = new FormData($(this)[0]);
if(!formData.get('album_name') && !formData.get('images').name)
return false;
if(!formData.get('images').name) {
return true;
}
event.preventDefault();
let errorList;
if((errorList = this.querySelector('#upload_form ul.errorlist.nonfield')) === null) {
errorList = document.createElement('ul');
errorList.classList.add('errorlist', 'nonfield');
this.insertBefore(errorList, this.firstElementChild);
}
while(errorList.childElementCount > 0)
errorList.removeChild(errorList.firstElementChild);
let progress;
if((progress = this.querySelector('progress')) === null) {
progress = document.createElement('progress');
progress.value = 0;
let p = document.createElement('p');
p.appendChild(progress);
this.insertBefore(p, this.lastElementChild);
}
let dataHolder;
if(formData.get('album_name')) {
dataHolder = new FormData();
dataHolder.set('csrfmiddlewaretoken', '{{ csrf_token }}');
dataHolder.set('album_name', formData.get('album_name'));
$.ajax({
method: 'POST',
url: "{{ url('sas:album_upload', album_id=object.id) }}",
data: dataHolder,
processData: false,
contentType: false,
success: onSuccess
});
}
let images = formData.getAll('images');
let imagesCount = images.length;
let completeCount = 0;
let poolSize = 1;
let imagePool = [];
while(images.length > 0 && imagePool.length < poolSize) {
let image = images.shift();
imagePool.push(image);
sendImage(image);
}
function sendImage(image) {
dataHolder = new FormData();
dataHolder.set('csrfmiddlewaretoken', '{{ csrf_token }}');
dataHolder.set('images', image);
$.ajax({
method: 'POST',
url: "{{ url('sas:album_upload', album_id=object.id) }}",
data: dataHolder,
processData: false,
contentType: false,
})
.fail(onSuccess.bind(undefined, image))
.done(onSuccess.bind(undefined, image))
.always(next.bind(undefined, image));
}
function next(image, _, __) {
let index = imagePool.indexOf(image);
let nextImage = images.shift();
if(index !== -1)
imagePool.splice(index, 1);
if(nextImage) {
imagePool.push(nextImage);
sendImage(nextImage);
}
}
function onSuccess(image, data, _, __) {
let errors = [];
if ($(data.responseText).find('.errorlist.nonfield')[0])
errors = Array.from($(data.responseText).find('.errorlist.nonfield')[0].children);
while(errors.length > 0)
errorList.appendChild(errors.shift());
progress.value = ++completeCount / imagesCount;
if(progress.value === 1 && errorList.children.length === 0)
document.location.reload()
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,18 @@
<form
class="add-files"
hx-post="{{ url("sas:album_create") }}"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
>
{% csrf_token %}
<div class="inputs">
<div>
<label for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
{{ form.name }}
</div>
{{ form.parent }}
<input type="submit" value="{% trans %}Create{% endtrans %}" />
</div>
{{ form.non_field_errors() }}
{{ form.name.errors }}
</form>

View File

@ -61,23 +61,8 @@
{% if is_sas_admin %} {% if is_sas_admin %}
</form> </form>
<br> <br>
{{ album_create_fragment }}
<form class="add-files" action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="inputs">
<div>
<label for="{{ form.album_name.name }}">{{ form.album_name.label }}</label>
{{ form.album_name }}
</div>
<input type="submit" value="{% trans %}Create{% endtrans %}" />
</div>
{{ form.non_field_errors() }}
{{ form.album_name.errors }}
</form>
{% endif %} {% endif %}
{% endif %} {% endif %}
</main> </main>

View File

@ -1,13 +1,16 @@
import pytest
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.uploadedfile import SimpleUploadedFile
from django.db import transaction from django.db import transaction
from django.test import 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 model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
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, SithFile, User from core.models import Group, SithFile, 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, PeoplePictureRelation, Picture, PictureModerationRequest from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
@ -241,3 +244,45 @@ class TestAlbumSearch(TestSas):
# - 1 for pagination # - 1 for pagination
# - 1 for the actual results # - 1 for the actual results
self.client.get(reverse("api:search-album")) self.client.get(reverse("api:search-album"))
@pytest.mark.django_db
def test_upload_picture(client: Client):
sas = SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
album = baker.make(Album, is_in_sas=True, parent=sas, name="test album")
user = baker.make(User, is_superuser=True)
client.force_login(user)
img = SimpleUploadedFile(
name="img.png", content=RED_PIXEL_PNG, content_type="image/png"
)
res = client.post(
reverse("api:upload_picture"), {"album_id": album.id, "picture": img}
)
assert res.status_code == 200
picture = Picture.objects.filter(parent_id=album.id).first()
assert picture is not None
assert picture.name == "img.png"
assert picture.owner == user
assert picture.file.name == "SAS/test album/img.png"
assert picture.compressed.name == ".compressed/SAS/test album/img.webp"
assert picture.thumbnail.name == ".thumbnails/SAS/test album/img.webp"
@pytest.mark.django_db
def test_upload_invalid_picture(client: Client):
sas = SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
album = baker.make(Album, is_in_sas=True, parent=sas, name="test album")
user = baker.make(User, is_superuser=True)
client.force_login(user)
file = SimpleUploadedFile(
name="file.txt",
content=b"azerty",
content_type="image/png", # the server shouldn't blindly trust the content_type
)
res = client.post(
reverse("api:upload_picture"), {"album_id": album.id, "picture": file}
)
assert res.status_code == 422
assert res.json()["detail"][0]["ctx"]["error"] == (
"Ce fichier n'est pas une image valide"
)

View File

@ -16,8 +16,8 @@
from django.urls import path from django.urls import path
from sas.views import ( from sas.views import (
AlbumCreateFragment,
AlbumEditView, AlbumEditView,
AlbumUploadView,
AlbumView, AlbumView,
ModerationView, ModerationView,
PictureAskRemovalView, PictureAskRemovalView,
@ -35,9 +35,6 @@ urlpatterns = [
path("", SASMainView.as_view(), name="main"), path("", SASMainView.as_view(), name="main"),
path("moderation/", ModerationView.as_view(), name="moderation"), path("moderation/", ModerationView.as_view(), name="moderation"),
path("album/<int:album_id>/", AlbumView.as_view(), name="album"), path("album/<int:album_id>/", AlbumView.as_view(), name="album"),
path(
"album/<int:album_id>/upload/", AlbumUploadView.as_view(), name="album_upload"
),
path("album/<int:album_id>/edit/", AlbumEditView.as_view(), name="album_edit"), path("album/<int:album_id>/edit/", AlbumEditView.as_view(), name="album_edit"),
path("album/<int:album_id>/preview/", send_album, name="album_preview"), path("album/<int:album_id>/preview/", send_album, name="album_preview"),
path("picture/<int:picture_id>/", PictureView.as_view(), name="picture"), path("picture/<int:picture_id>/", PictureView.as_view(), name="picture"),
@ -59,4 +56,5 @@ urlpatterns = [
path( path(
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures" "user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
), ),
path("fragment/album-create", AlbumCreateFragment.as_view(), name="album_create"),
] ]

View File

@ -16,48 +16,63 @@ from typing import Any
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.safestring import SafeString
from django.views.generic import DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormMixin, FormView, UpdateView from django.views.generic.edit import FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User from core.models import SithFile, User
from core.views import UseFragmentsMixin
from core.views.files import FileView, send_file from core.views.files import FileView, send_file
from core.views.mixins import FragmentMixin, FragmentRenderer
from core.views.user import UserTabsMixin from core.views.user import UserTabsMixin
from sas.forms import ( from sas.forms import (
AlbumCreateForm,
AlbumEditForm, AlbumEditForm,
PictureEditForm, PictureEditForm,
PictureModerationRequestForm, PictureModerationRequestForm,
SASForm, PictureUploadForm,
) )
from sas.models import Album, Picture from sas.models import Album, Picture
class SASMainView(FormView): class AlbumCreateFragment(FragmentMixin, CreateView):
form_class = SASForm model = Album
template_name = "sas/main.jinja" form_class = AlbumCreateForm
success_url = reverse_lazy("sas:main") template_name = "sas/fragments/album_create_form.jinja"
reload_on_redirect = True
def post(self, request, *args, **kwargs): def get_form_kwargs(self):
self.form = self.get_form() return super().get_form_kwargs() | {"owner": self.request.user}
parent = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
files = request.FILES.getlist("images") def render_fragment(
root = User.objects.filter(username="root").first() self, request, owner: User | None = None, **kwargs
if request.user.is_authenticated and request.user.is_in_group( ) -> SafeString:
pk=settings.SITH_GROUP_SAS_ADMIN_ID self.object = None
): self.owner = owner or self.request.user
if self.form.is_valid(): return super().render_fragment(request, **kwargs)
self.form.process(
parent=parent, owner=root, files=files, automodere=True def get_success_url(self):
) parent = self.object.parent
if self.form.is_valid(): parent.__class__ = Album
return super().form_valid(self.form) return parent.get_absolute_url()
else:
self.form.add_error(None, _("You do not have the permission to do that"))
return self.form_invalid(self.form) class SASMainView(UseFragmentsMixin, TemplateView):
template_name = "sas/main.jinja"
def get_fragments(self) -> dict[str, FragmentRenderer]:
form_init = {"parent": SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)}
return {
"album_create_fragment": AlbumCreateFragment.as_fragment(initial=form_init)
}
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
return {"album_create_fragment": {"owner": root_user}}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
@ -104,88 +119,45 @@ def send_thumb(request, picture_id):
return send_file(request, picture_id, Picture, "thumbnail") return send_file(request, picture_id, Picture, "thumbnail")
class AlbumUploadView(CanViewMixin, DetailView, FormMixin): class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
model = Album model = Album
form_class = SASForm
pk_url_kwarg = "album_id"
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if not self.object.file:
self.object.generate_thumbnail()
self.form = self.get_form()
parent = SithFile.objects.filter(id=self.object.id).first()
files = request.FILES.getlist("images")
if request.user.is_subscribed and self.form.is_valid():
self.form.process(
parent=parent,
owner=request.user,
files=files,
automodere=(
request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
or request.user.is_root
),
)
if self.form.is_valid():
return HttpResponse(str(self.form.errors), status=200)
return HttpResponse(str(self.form.errors), status=500)
class AlbumView(CanViewMixin, DetailView, FormMixin):
model = Album
form_class = SASForm
pk_url_kwarg = "album_id" pk_url_kwarg = "album_id"
template_name = "sas/album.jinja" template_name = "sas/album.jinja"
def get_fragments(self) -> dict[str, FragmentRenderer]:
return {
"album_create_fragment": AlbumCreateFragment.as_fragment(
initial={"parent": self.object}
)
}
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
try: try:
self.asked_page = int(request.GET.get("page", 1)) self.asked_page = int(request.GET.get("page", 1))
except ValueError as e: except ValueError as e:
raise Http404 from e raise Http404 from e
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
self.form = self.get_form()
if "clipboard" not in request.session: if "clipboard" not in request.session:
request.session["clipboard"] = [] request.session["clipboard"] = []
return super().get(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if not self.object.file: if not self.object.file:
self.object.generate_thumbnail() self.object.generate_thumbnail()
self.form = self.get_form()
if "clipboard" not in request.session:
request.session["clipboard"] = []
if request.user.can_edit(self.object): # Handle the copy-paste functions if request.user.can_edit(self.object): # Handle the copy-paste functions
FileView.handle_clipboard(request, self.object) FileView.handle_clipboard(request, self.object)
parent = SithFile.objects.filter(id=self.object.id).first() return HttpResponseRedirect(self.request.path)
files = request.FILES.getlist("images")
if request.user.is_authenticated and request.user.is_subscribed:
if self.form.is_valid():
self.form.process(
parent=parent,
owner=request.user,
files=files,
automodere=request.user.is_in_group(
pk=settings.SITH_GROUP_SAS_ADMIN_ID
),
)
if self.form.is_valid():
return super().form_valid(self.form)
else:
self.form.add_error(None, _("You do not have the permission to do that"))
return self.form_invalid(self.form)
def get_success_url(self): def get_fragment_data(self) -> dict[str, dict[str, Any]]:
return reverse("sas:album", kwargs={"album_id": self.object.id}) return {"album_create_fragment": {"owner": self.request.user}}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["form"] = self.form if ids := self.request.session.get("clipboard", None):
kwargs["clipboard"] = SithFile.objects.filter( kwargs["clipboard"] = SithFile.objects.filter(id__in=ids)
id__in=self.request.session["clipboard"] kwargs["upload_form"] = PictureUploadForm()
) # if True, the albums will be fetched with a request to the API
# if False, the section won't be displayed at all
kwargs["show_albums"] = ( kwargs["show_albums"] = (
Album.objects.viewable_by(self.request.user) Album.objects.viewable_by(self.request.user)
.filter(parent_id=self.object.id) .filter(parent_id=self.object.id)