diff --git a/core/admin.py b/core/admin.py index 5de89ada..eff77817 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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") diff --git a/core/api.py b/core/api.py index e1b3bbbd..830e06e9 100644 --- a/core/api.py +++ b/core/api.py @@ -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) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 0e41cc93..6bcc0e78 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -828,6 +828,7 @@ Welcome to the wiki page! "view_peoplepicturerelation", "add_peoplepicturerelation", "add_page", + "add_quickuploadimage", ] ) ) diff --git a/core/migrations/0045_quickuploadimage.py b/core/migrations/0045_quickuploadimage.py new file mode 100644 index 00000000..9464848c --- /dev/null +++ b/core/migrations/0045_quickuploadimage.py @@ -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, + ), + ), + ], + ), + ] diff --git a/core/models.py b/core/models.py index 7f42e83d..5c873c6c 100644 --- a/core/models.py +++ b/core/models.py @@ -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.""" diff --git a/core/schemas.py b/core/schemas.py index 64494ed8..b5f5991f 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -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 diff --git a/core/static/bundled/core/components/easymde-index.ts b/core/static/bundled/core/components/easymde-index.ts index d99799a0..09f08626 100644 --- a/core/static/bundled/core/components/easymde-index.ts +++ b/core/static/bundled/core/components/easymde-index.ts @@ -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, diff --git a/core/tests/test_files.py b/core/tests/test_files.py index 2f86507a..4afc4583 100644 --- a/core/tests/test_files.py +++ b/core/tests/test_files.py @@ -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] + ) diff --git a/core/views/files.py b/core/views/files.py index 714b505d..cd9103d9 100644 --- a/core/views/files.py +++ b/core/views/files.py @@ -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 diff --git a/docs/tutorial/install-advanced.md b/docs/tutorial/install-advanced.md index 2da2fc42..2998298f 100644 --- a/docs/tutorial/install-advanced.md +++ b/docs/tutorial/install-advanced.md @@ -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)/ { diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 249eee49..11100e5d 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 3693c144..336f9de7 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -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 \n" "Language-Team: AE info \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" diff --git a/package-lock.json b/package-lock.json index 10edbdeb..541a492d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index 0c52edb6..94fa21fc 100644 --- a/package.json +++ b/package.json @@ -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",