Merge pull request #1035 from ae-utbm/picture-upload

Picture upload from markdown editor
This commit is contained in:
Bartuccio Antoine 2025-04-10 11:41:54 +02:00 committed by GitHub
commit e96d224a8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 389 additions and 23 deletions

View File

@ -17,7 +17,16 @@ from django.contrib import admin
from django.contrib.auth.models import Group as AuthGroup
from django.contrib.auth.models import Permission
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
from core.models import (
BanGroup,
Group,
OperationLog,
Page,
QuickUploadImage,
SithFile,
User,
UserBan,
)
admin.site.unregister(AuthGroup)
@ -89,3 +98,11 @@ class OperationLogAdmin(admin.ModelAdmin):
list_display = ("label", "operator", "operation_type", "date")
search_fields = ("label", "date", "operation_type")
autocomplete_fields = ("operator",)
@admin.register(QuickUploadImage)
class QuickUploadImageAdmin(admin.ModelAdmin):
list_display = ("uuid", "uploader", "created_at", "name")
search_fields = ("uuid", "uploader", "name")
autocomplete_fields = ("uploader",)
readonly_fields = ("width", "height", "size")

View File

@ -1,23 +1,25 @@
from typing import Annotated
from typing import Annotated, Any, Literal
import annotated_types
from django.conf import settings
from django.db.models import F
from django.http import HttpResponse
from ninja import Query
from ninja import File, Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing
from core.auth.api_permissions import CanAccessLookup, CanView
from core.models import Group, SithFile, User
from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm
from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema,
SithFileSchema,
UploadedFileSchema,
UploadedImage,
UserFamilySchema,
UserFilterSchema,
UserProfileSchema,
@ -33,6 +35,25 @@ class MarkdownController(ControllerBase):
return HttpResponse(markdown(body.text), content_type="text/html")
@api_controller("/upload")
class UploadController(ControllerBase):
@route.post(
"/image",
response={
200: UploadedFileSchema,
422: dict[Literal["detail"], list[dict[str, Any]]],
403: dict[Literal["detail"], str],
},
permissions=[HasPerm("core.add_quickuploadimage")],
url_name="quick_upload_image",
)
def upload_image(self, file: File[UploadedImage]):
image = QuickUploadImage.create_from_uploaded(
file, uploader=self.context.request.user
)
return image
@api_controller("/mailings")
class MailingListController(ControllerBase):
@route.get("", response=str)

View File

@ -828,6 +828,7 @@ Welcome to the wiki page!
"view_peoplepicturerelation",
"add_peoplepicturerelation",
"add_page",
"add_quickuploadimage",
]
)
)

View File

@ -0,0 +1,56 @@
# Generated by Django 4.2.20 on 2025-04-10 09:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0044_alter_userban_options"),
]
operations = [
migrations.CreateModel(
name="QuickUploadImage",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("uuid", models.UUIDField(db_index=True, unique=True)),
("name", models.CharField(max_length=100)),
(
"image",
models.ImageField(
height_field="height",
unique=True,
upload_to="upload/%Y/%m/%d",
width_field="width",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
("width", models.PositiveIntegerField(verbose_name="width")),
("height", models.PositiveIntegerField(verbose_name="height")),
("size", models.PositiveIntegerField(verbose_name="size")),
(
"uploader",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="quick_uploads",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -17,7 +17,7 @@
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
@ -32,6 +32,7 @@ from datetime import timedelta
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Self
from uuid import uuid4
from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager
@ -41,6 +42,8 @@ from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File
from django.core.files.base import ContentFile
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q
@ -51,9 +54,10 @@ from django.utils.html import escape
from django.utils.timezone import localdate, now
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image
from PIL import Image, ImageOps
if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from pydantic import NonNegativeInt
from club.models import Club
@ -1102,6 +1106,68 @@ class SithFile(models.Model):
return reverse("core:download", kwargs={"file_id": self.id})
class QuickUploadImage(models.Model):
"""Images uploaded by user outside of the SithFile mechanism"""
IMAGE_NAME_SIZE = 100
MAX_IMAGE_SIZE = 600 # Maximum px on width / length
uuid = models.UUIDField(unique=True, db_index=True)
name = models.CharField(max_length=IMAGE_NAME_SIZE, blank=False)
image = models.ImageField(
upload_to="upload/%Y/%m/%d",
width_field="width",
height_field="height",
unique=True,
)
uploader = models.ForeignKey(
"User",
related_name="quick_uploads",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
width = models.PositiveIntegerField(_("width"))
height = models.PositiveIntegerField(_("height"))
size = models.PositiveIntegerField(_("size"))
def __str__(self) -> str:
return str(self.image.path)
def get_absolute_url(self):
return self.image.url
@classmethod
def create_from_uploaded(
cls, image: UploadedFile, uploader: User | None = None
) -> Self:
def convert_image(file: UploadedFile) -> ContentFile:
content = BytesIO()
image = Image.open(BytesIO(file.read()))
if image.width > cls.MAX_IMAGE_SIZE or image.height > cls.MAX_IMAGE_SIZE:
image = ImageOps.contain(image, (600, 600))
image.save(fp=content, format="webp", optimize=True)
return ContentFile(content.getvalue())
identifier = str(uuid4())
name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1]
file = File(convert_image(image), name=f"{identifier}.webp")
width, height = Image.open(file).size
return cls.objects.create(
uuid=identifier,
name=name,
image=file,
uploader=uploader,
size=file.size,
)
def delete(self, *args, **kwargs):
self.image.delete(save=False)
return super().delete(*args, **kwargs)
class LockError(Exception):
"""There was a lock error on the object."""

View File

@ -12,7 +12,7 @@ from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field
from pydantic_core.core_schema import ValidationInfo
from core.models import Group, SithFile, User
from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import is_image
@ -60,6 +60,18 @@ class UserProfileSchema(ModelSchema):
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
class UploadedFileSchema(ModelSchema):
class Meta:
model = QuickUploadImage
fields = ["uuid", "name", "width", "height", "size"]
href: str
@staticmethod
def resolve_href(obj: QuickUploadImage) -> str:
return obj.get_absolute_url()
class SithFileSchema(ModelSchema):
class Meta:
model = SithFile

View File

@ -6,13 +6,58 @@ import { inheritHtmlElement, registerComponent } from "#core:utils/web-component
import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde";
import { markdownRenderMarkdown } from "#openapi";
import {
type UploadUploadImageErrors,
markdownRenderMarkdown,
uploadUploadImage,
} from "#openapi";
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
new EasyMDE({
const easymde = new EasyMDE({
element: textarea,
spellChecker: false,
autoDownloadFontAwesome: false,
uploadImage: true,
imagePathAbsolute: false,
imageUploadFunction: async (file, onSuccess, onError) => {
const response = await uploadUploadImage({
body: {
file: file,
},
});
if (!response.response.ok) {
if (response.response.status === 422) {
onError(
(response.error as UploadUploadImageErrors[422]).detail
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
.join(" ; "),
);
} else if (response.response.status === 403) {
onError(gettext("You are not authorized to use this feature"));
} else {
onError(gettext("Could not upload image"));
}
return;
}
onSuccess(response.data.href);
// Workaround function to add an image name to uploaded image
// Without this, you get ![](url) instead of ![name](url)
let cursor = easymde.codemirror.getCursor();
easymde.codemirror.setSelection({
line: cursor.line,
ch: cursor.ch - response.data.href.length - 3,
});
easymde.codemirror.replaceSelection(response.data.name);
// Move cursor at the end of the url and add a new line
cursor = easymde.codemirror.getCursor();
easymde.codemirror.setSelection({
line: cursor.line,
ch: cursor.ch + response.data.href.length + 3,
});
easymde.codemirror.replaceSelection("\n");
},
previewRender: (plainText: string, preview: MarkdownInput) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
@ -30,6 +75,14 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}, 300)(plainText, preview);
},
forceSync: true, // Avoid validation error on generic create view
imageTexts: {
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
sbOnDragEnter: gettext("Drop image to upload it."),
sbOnDrop: gettext("Uploading image #images_names# …"),
sbProgress: gettext("Uploading #file_name#: #progress#%"),
sbOnUploaded: gettext("Uploaded #image_name#"),
sizeUnits: gettext(" B, KB, MB"),
},
toolbar: [
{
name: "heading-smaller",
@ -120,6 +173,12 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
className: "fa-regular fa-image",
title: gettext("Insert image"),
},
{
name: "upload-image",
action: EasyMDE.drawUploadedImage,
className: "fa-solid fa-file-arrow-up",
title: gettext("Upload image"),
},
{
name: "table",
action: EasyMDE.drawTable,

View File

@ -1,11 +1,12 @@
from io import BytesIO
from itertools import cycle
from pathlib import Path
from typing import Callable
from uuid import uuid4
import pytest
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from model_bakery import baker
@ -14,7 +15,8 @@ from PIL import Image
from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, SithFile, User
from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import RED_PIXEL_PNG
from sas.models import Picture
from sith import settings
@ -256,3 +258,89 @@ def test_apply_rights_recursively():
):
assert set(file.view_groups.all()) == set(groups[:3])
assert set(file.edit_groups.all()) == set(groups[2:6])
@pytest.mark.django_db
@pytest.mark.parametrize(
("user_receipe", "file", "expected_status"),
[
(
lambda: None,
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
403,
),
(
lambda: baker.make(User),
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
403,
),
(
lambda: subscriber_user.make(),
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
200,
),
(
lambda: old_subscriber_user.make(),
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
200,
),
(
lambda: old_subscriber_user.make(),
SimpleUploadedFile(
"ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestesttesttesttesttesttesttesttesttesttesttesttest.jpg",
content=RED_PIXEL_PNG,
content_type="image/jpg",
),
200,
), # very long file name
(
lambda: old_subscriber_user.make(),
SimpleUploadedFile(
"test.jpg", content=b"invalid", content_type="image/jpg"
),
422,
),
(
lambda: old_subscriber_user.make(),
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="invalid"
),
200, # PIL can guess
),
(
lambda: old_subscriber_user.make(),
SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"),
422,
),
],
)
def test_quick_upload_image(
client: Client,
user_receipe: Callable[[], User | None],
file: UploadedFile | None,
expected_status: int,
):
if (user := user_receipe()) is not None:
client.force_login(user)
resp = client.post(
reverse("api:quick_upload_image"), {"file": file} if file is not None else {}
)
assert resp.status_code == expected_status
if expected_status != 200:
return
parsed = resp.json()
assert QuickUploadImage.objects.filter(uuid=parsed["uuid"]).exists()
assert (
parsed["name"] == Path(file.name).stem[: QuickUploadImage.IMAGE_NAME_SIZE - 1]
)

View File

@ -86,7 +86,7 @@ def send_raw_file(path: Path) -> HttpResponse:
def send_file(
request: HttpRequest,
file_id: int,
file_id: int | str,
file_class: type[SithFile] = SithFile,
file_attr: str = "file",
) -> HttpResponse:
@ -97,7 +97,7 @@ def send_file(
deal with it.
In debug mode, the server will directly send the file.
"""
f = get_object_or_404(file_class, id=file_id)
f = get_object_or_404(file_class, pk=file_id)
if not can_view(f, request.user) and not is_logged_in_counter(request):
raise PermissionDenied
name = getattr(f, file_attr).name

View File

@ -224,7 +224,7 @@ server {
location /static/;
root /repertoire/du/projet;
}
location ~ ^/data/(products|com|club_logos)/ {
location ~ ^/data/(products|com|club_logos|upload)/ {
root /repertoire/du/projet;
}
location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ {

View File

@ -1572,6 +1572,14 @@ msgstr "Ceci n'est pas une miniature de dossier valide"
msgid "You must provide a file"
msgstr "Vous devez fournir un fichier"
#: core/models.py
msgid "width"
msgstr "largeur"
#: core/models.py
msgid "height"
msgstr "hauteur"
#: core/models.py
msgid "page unix name"
msgstr "nom unix de la page"

View File

@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-08 11:42+0200\n"
"POT-Creation-Date: 2025-04-09 23:23+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -63,6 +63,40 @@ msgstr "Vous devez taper %(number)s caractères de plus"
msgid "No results found"
msgstr "Aucun résultat trouvé"
#: core/static/bundled/core/components/easymde-index.ts
msgid "You are not authorized to use this feature"
msgstr "Vous n'êtes pas autorisé à utilisé cette fonctionalité"
#: core/static/bundled/core/components/easymde-index.ts
msgid "Could not upload image"
msgstr "L'image n'a pas pu être téléversée"
#: core/static/bundled/core/components/easymde-index.ts
msgid "Attach files by drag and dropping or pasting from clipboard."
msgstr ""
"Ajoutez des fichiez en glissant déposant ou collant depuis votre presse "
"papier."
#: core/static/bundled/core/components/easymde-index.ts
msgid "Drop image to upload it."
msgstr "Glissez une image pour la téléverser."
#: core/static/bundled/core/components/easymde-index.ts
msgid "Uploading image #images_names# …"
msgstr "Téléversement de l'image #images_names# …"
#: core/static/bundled/core/components/easymde-index.ts
msgid "Uploading #file_name#: #progress#%"
msgstr "Téléversement de #file_name#: #progress#%"
#: core/static/bundled/core/components/easymde-index.ts
msgid "Uploaded #image_name#"
msgstr "#image_name# téléversé"
#: core/static/bundled/core/components/easymde-index.ts
msgid " B, KB, MB"
msgstr " B, KB, MB"
#: core/static/bundled/core/components/easymde-index.ts
msgid "Heading"
msgstr "Titre"
@ -115,6 +149,10 @@ msgstr "Insérer lien"
msgid "Insert image"
msgstr "Insérer image"
#: core/static/bundled/core/components/easymde-index.ts
msgid "Upload image"
msgstr "Téléverser une image"
#: core/static/bundled/core/components/easymde-index.ts
msgid "Insert table"
msgstr "Insérer tableau"

12
package-lock.json generated
View File

@ -27,7 +27,7 @@
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.5",
"easymde": "^2.18.0",
"easymde": "^2.19.0",
"glob": "^11.0.0",
"htmx.org": "^2.0.3",
"jquery": "^3.7.1",
@ -3663,14 +3663,14 @@
"license": "MIT"
},
"node_modules/easymde": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.18.0.tgz",
"integrity": "sha512-IxVVUxNWIoXLeqtBU4BLc+eS/ScYhT1Dcb6yF5Wchoj1iXAV+TIIDWx+NCaZhY7RcSHqDPKllbYq7nwGKILnoA==",
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.19.0.tgz",
"integrity": "sha512-4F1aNImqse+9xIjLh9ttfpOVenecjFPxUmKbl1tGp72Z+OyIqLZPE/SgNyy88c/xU0mOy0WC3+tfbZDQ5PDWhg==",
"license": "MIT",
"dependencies": {
"@types/codemirror": "^5.60.4",
"@types/codemirror": "^5.60.10",
"@types/marked": "^4.0.7",
"codemirror": "^5.63.1",
"codemirror": "^5.65.15",
"codemirror-spell-checker": "1.1.2",
"marked": "^4.1.0"
}

View File

@ -54,7 +54,7 @@
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.5",
"easymde": "^2.18.0",
"easymde": "^2.19.0",
"glob": "^11.0.0",
"htmx.org": "^2.0.3",
"jquery": "^3.7.1",