From 10367d21ab4b30395311c369d29fda028f81f253 Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 26 Feb 2025 23:15:26 +0100 Subject: [PATCH 01/10] Add API endpoint to upload images --- core/api.py | 64 +++++++++++++++++++++++++++- core/management/commands/populate.py | 4 ++ core/schemas.py | 13 ++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/core/api.py b/core/api.py index e1b3bbbd..47601a05 100644 --- a/core/api.py +++ b/core/api.py @@ -1,23 +1,30 @@ +from io import BytesIO +from pathlib import Path from typing import Annotated +from uuid import uuid4 import annotated_types from django.conf import settings +from django.core.files.base import ContentFile +from django.db import transaction from django.db.models import F from django.http import HttpResponse -from ninja import Query +from ninja import Query, UploadedFile 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 PIL import Image, UnidentifiedImageError from club.models import Mailing -from core.auth.api_permissions import CanAccessLookup, CanView +from core.auth.api_permissions import CanAccessLookup, CanView, IsOldSubscriber from core.models import Group, SithFile, User from core.schemas import ( FamilyGodfatherSchema, GroupSchema, MarkdownSchema, SithFileSchema, + UploadedFileSchema, UserFamilySchema, UserFilterSchema, UserProfileSchema, @@ -33,6 +40,59 @@ class MarkdownController(ControllerBase): return HttpResponse(markdown(body.text), content_type="text/html") +@api_controller("/upload") +class UploadController(ControllerBase): + @route.post("/images", response=UploadedFileSchema, permissions=[IsOldSubscriber]) + def upload_assets(self, file: UploadedFile): + if file.content_type.split("/")[0] != "image": + return self.create_response( + message=f"{file.name} isn't a file image", status_code=400 + ) + + def convert_image(file: UploadedFile) -> ContentFile: + content = BytesIO() + Image.open(BytesIO(file.read())).save( + fp=content, format="webp", optimize=True + ) + return ContentFile(content.getvalue()) + + try: + converted = convert_image(file) + except UnidentifiedImageError: + return self.create_response( + message=f"{file.name} can't be processed", status_code=400 + ) + + with transaction.atomic(): + parent = SithFile.objects.filter(parent=None, name="upload").first() + if parent is None: + root = User.objects.get(id=settings.SITH_ROOT_USER_ID) + parent = SithFile.objects.create( + parent=None, + name="upload", + owner=root, + ) + image = SithFile( + parent=parent, + name=f"{Path(file.name).stem}_{uuid4()}.webp", + file=converted, + owner=self.context.request.user, + is_folder=False, + mime_type="img/webp", + size=converted.size, + moderator=self.context.request.user, + is_moderated=True, + ) + image.file.name = image.name + image.clean() + image.save() + image.view_groups.add( + Group.objects.filter(id=settings.SITH_GROUP_PUBLIC_ID).first() + ) + image.save() + 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..d6bf6a87 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -764,6 +764,10 @@ Welcome to the wiki page! ] ) + # Upload folder + SithFile.objects.create(name="upload", owner=root) + (settings.MEDIA_ROOT / "upload").mkdir(parents=True, exist_ok=True) + def _create_profile_pict(self, user: User): path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg" file = resize_image(Image.open(path), 400, "WEBP") diff --git a/core/schemas.py b/core/schemas.py index 64494ed8..e0106d18 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -10,6 +10,7 @@ from django.utils.translation import gettext as _ from haystack.query import SearchQuerySet from ninja import FilterSchema, ModelSchema, Schema, UploadedFile from pydantic import AliasChoices, Field +from pydantic_core import Url from pydantic_core.core_schema import ValidationInfo from core.models import Group, SithFile, User @@ -60,6 +61,18 @@ class UserProfileSchema(ModelSchema): return reverse("core:download", kwargs={"file_id": obj.profile_pict_id}) +class UploadedFileSchema(ModelSchema): + class Meta: + model = SithFile + fields = ["id", "name", "mime_type", "size"] + + href: str + + @staticmethod + def resolve_href(obj: SithFile) -> Url: + return reverse("core:download", kwargs={"file_id": obj.id}) + + class SithFileSchema(ModelSchema): class Meta: model = SithFile From 7b23196071cf2baf55fe49501e77c082ce368e17 Mon Sep 17 00:00:00 2001 From: Sli Date: Thu, 27 Feb 2025 00:33:37 +0100 Subject: [PATCH 02/10] Add image upload to easymde widget --- core/api.py | 8 ++-- .../bundled/core/components/easymde-index.ts | 48 ++++++++++++++++++- locale/fr/LC_MESSAGES/djangojs.po | 32 +++++++++++++ package-lock.json | 12 ++--- package.json | 2 +- 5 files changed, 89 insertions(+), 13 deletions(-) diff --git a/core/api.py b/core/api.py index 47601a05..e8f89479 100644 --- a/core/api.py +++ b/core/api.py @@ -42,11 +42,11 @@ class MarkdownController(ControllerBase): @api_controller("/upload") class UploadController(ControllerBase): - @route.post("/images", response=UploadedFileSchema, permissions=[IsOldSubscriber]) - def upload_assets(self, file: UploadedFile): + @route.post("/image", response=UploadedFileSchema, permissions=[IsOldSubscriber]) + def upload_image(self, file: UploadedFile): if file.content_type.split("/")[0] != "image": return self.create_response( - message=f"{file.name} isn't a file image", status_code=400 + message=f"{file.name} isn't a file image", status_code=415 ) def convert_image(file: UploadedFile) -> ContentFile: @@ -60,7 +60,7 @@ class UploadController(ControllerBase): converted = convert_image(file) except UnidentifiedImageError: return self.create_response( - message=f"{file.name} can't be processed", status_code=400 + message=f"{file.name} can't be processed", status_code=415 ) with transaction.atomic(): diff --git a/core/static/bundled/core/components/easymde-index.ts b/core/static/bundled/core/components/easymde-index.ts index d99799a0..8c337fb6 100644 --- a/core/static/bundled/core/components/easymde-index.ts +++ b/core/static/bundled/core/components/easymde-index.ts @@ -6,13 +6,43 @@ 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 { 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.status !== 200) { + onError(gettext("Invalid file")); + return; + } + onSuccess(response.data.href); + // Workaround function to add ! and 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 - 1 }); + easymde.codemirror.replaceSelection("!"); + + easymde.codemirror.setSelection({ line: cursor.line, ch: cursor.ch + 1 }); + easymde.codemirror.replaceSelection(file.name.split(".").slice(0, -1).join(".")); + + // 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 +60,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 +158,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/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 3693c144..70c4bd4b 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -63,6 +63,34 @@ 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 "Invalid file" +msgstr "Fichier invalide" + +#: 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 +143,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", From c236092c4f5a2a083ab6291952261882b9a2d0e3 Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 28 Feb 2025 15:06:47 +0100 Subject: [PATCH 03/10] Create dedicated image upload model --- core/admin.py | 18 +++++- core/api.py | 47 ++-------------- core/management/commands/populate.py | 4 -- core/migrations/0045_quickuploadimage.py | 37 +++++++++++++ core/models.py | 55 ++++++++++++++++++- core/schemas.py | 26 +++++++-- .../bundled/core/components/easymde-index.ts | 2 +- core/urls.py | 11 ++++ core/views/files.py | 8 +-- 9 files changed, 149 insertions(+), 59 deletions(-) create mode 100644 core/migrations/0045_quickuploadimage.py diff --git a/core/admin.py b/core/admin.py index 5de89ada..894f6a55 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,10 @@ 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", "name", "uploader", "content_type", "date") + search_fields = ("uuid", "name", "uploader") + autocomplete_fields = ("uploader",) diff --git a/core/api.py b/core/api.py index e8f89479..b9393dda 100644 --- a/core/api.py +++ b/core/api.py @@ -1,12 +1,7 @@ -from io import BytesIO -from pathlib import Path from typing import Annotated -from uuid import uuid4 import annotated_types from django.conf import settings -from django.core.files.base import ContentFile -from django.db import transaction from django.db.models import F from django.http import HttpResponse from ninja import Query, UploadedFile @@ -14,11 +9,11 @@ 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 PIL import Image, UnidentifiedImageError +from PIL import UnidentifiedImageError from club.models import Mailing from core.auth.api_permissions import CanAccessLookup, CanView, IsOldSubscriber -from core.models import Group, SithFile, User +from core.models import Group, QuickUploadImage, SithFile, User from core.schemas import ( FamilyGodfatherSchema, GroupSchema, @@ -49,47 +44,15 @@ class UploadController(ControllerBase): message=f"{file.name} isn't a file image", status_code=415 ) - def convert_image(file: UploadedFile) -> ContentFile: - content = BytesIO() - Image.open(BytesIO(file.read())).save( - fp=content, format="webp", optimize=True - ) - return ContentFile(content.getvalue()) - try: - converted = convert_image(file) + image = QuickUploadImage.create_from_uploaded( + file, uploader=self.context.request.user + ) except UnidentifiedImageError: return self.create_response( message=f"{file.name} can't be processed", status_code=415 ) - with transaction.atomic(): - parent = SithFile.objects.filter(parent=None, name="upload").first() - if parent is None: - root = User.objects.get(id=settings.SITH_ROOT_USER_ID) - parent = SithFile.objects.create( - parent=None, - name="upload", - owner=root, - ) - image = SithFile( - parent=parent, - name=f"{Path(file.name).stem}_{uuid4()}.webp", - file=converted, - owner=self.context.request.user, - is_folder=False, - mime_type="img/webp", - size=converted.size, - moderator=self.context.request.user, - is_moderated=True, - ) - image.file.name = image.name - image.clean() - image.save() - image.view_groups.add( - Group.objects.filter(id=settings.SITH_GROUP_PUBLIC_ID).first() - ) - image.save() return image diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index d6bf6a87..0e41cc93 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -764,10 +764,6 @@ Welcome to the wiki page! ] ) - # Upload folder - SithFile.objects.create(name="upload", owner=root) - (settings.MEDIA_ROOT / "upload").mkdir(parents=True, exist_ok=True) - def _create_profile_pict(self, user: User): path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg" file = resize_image(Image.open(path), 400, "WEBP") diff --git a/core/migrations/0045_quickuploadimage.py b/core/migrations/0045_quickuploadimage.py new file mode 100644 index 00000000..ee6a6a7b --- /dev/null +++ b/core/migrations/0045_quickuploadimage.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.20 on 2025-04-05 16:28 + +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=[ + ( + "uuid", + models.CharField(max_length=36, primary_key=True, serialize=False), + ), + ("name", models.CharField(max_length=100)), + ("image", models.ImageField(upload_to="upload")), + ("content_type", models.CharField(max_length=50)), + ("date", models.DateTimeField(auto_now=True, verbose_name="date")), + ( + "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..883d21f4 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 @@ -54,6 +57,7 @@ from phonenumber_field.modelfields import PhoneNumberField from PIL import Image if TYPE_CHECKING: + from django.core.files.uploadedfile import UploadedFile from pydantic import NonNegativeInt from club.models import Club @@ -1102,6 +1106,55 @@ 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 + UUID_4_SIZE = 36 + + uuid = models.CharField(max_length=UUID_4_SIZE, blank=False, primary_key=True) + name = models.CharField(max_length=IMAGE_NAME_SIZE, blank=False) + image = models.ImageField(upload_to="upload") + content_type = models.CharField(max_length=50, blank=False) + uploader = models.ForeignKey( + "User", + related_name="quick_uploads", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + date = models.DateTimeField(_("date"), auto_now=True) + + def __str__(self) -> str: + return f"{self.name}{Path(self.image.path).suffix}" + + def get_absolute_url(self): + return reverse("core:uploaded_image", kwargs={"image_uuid": self.uuid}) + + @classmethod + def create_from_uploaded( + cls, image: UploadedFile, uploader: User | None = None + ) -> Self: + def convert_image(file: UploadedFile) -> ContentFile: + content = BytesIO() + Image.open(BytesIO(file.read())).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") + + return cls.objects.create( + uuid=identifier, + name=name, + image=file, + content_type="image/webp", + uploader=uploader, + ) + + class LockError(Exception): """There was a lock error on the object.""" diff --git a/core/schemas.py b/core/schemas.py index e0106d18..f532d676 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -10,10 +10,9 @@ from django.utils.translation import gettext as _ from haystack.query import SearchQuerySet from ninja import FilterSchema, ModelSchema, Schema, UploadedFile from pydantic import AliasChoices, Field -from pydantic_core import Url 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 @@ -63,14 +62,29 @@ class UserProfileSchema(ModelSchema): class UploadedFileSchema(ModelSchema): class Meta: - model = SithFile - fields = ["id", "name", "mime_type", "size"] + model = QuickUploadImage + fields = ["uuid", "name", "content_type"] + width: int + height: int + size: int href: str @staticmethod - def resolve_href(obj: SithFile) -> Url: - return reverse("core:download", kwargs={"file_id": obj.id}) + def resolve_width(obj: QuickUploadImage): + return obj.image.width + + @staticmethod + def resolve_height(obj: QuickUploadImage): + return obj.image.height + + @staticmethod + def resolve_size(obj: QuickUploadImage): + return obj.image.size + + @staticmethod + def resolve_href(obj: QuickUploadImage) -> str: + return obj.get_absolute_url() class SithFileSchema(ModelSchema): diff --git a/core/static/bundled/core/components/easymde-index.ts b/core/static/bundled/core/components/easymde-index.ts index 8c337fb6..10ad06a5 100644 --- a/core/static/bundled/core/components/easymde-index.ts +++ b/core/static/bundled/core/components/easymde-index.ts @@ -33,7 +33,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => { easymde.codemirror.replaceSelection("!"); easymde.codemirror.setSelection({ line: cursor.line, ch: cursor.ch + 1 }); - easymde.codemirror.replaceSelection(file.name.split(".").slice(0, -1).join(".")); + easymde.codemirror.replaceSelection(response.data.name); // Move cursor at the end of the url and add a new line cursor = easymde.codemirror.getCursor(); diff --git a/core/urls.py b/core/urls.py index 23fa9f11..10810458 100644 --- a/core/urls.py +++ b/core/urls.py @@ -30,6 +30,7 @@ from core.converters import ( FourDigitYearConverter, TwoDigitMonthConverter, ) +from core.models import QuickUploadImage from core.views import ( FileDeleteView, FileEditPropView, @@ -213,6 +214,16 @@ urlpatterns = [ "file//moderate/", FileModerateView.as_view(), name="file_moderate" ), path("file//download/", send_file, name="download"), + path( + "file//uploads/", + lambda request, image_uuid: send_file( + request=request, + file_id=image_uuid, + file_class=QuickUploadImage, + file_attr="image", + ), + name="uploaded_image", + ), # Page views path("page/", PageListView.as_view(), name="page_list"), path("page/create/", PageCreateView.as_view(), name="page_new"), diff --git a/core/views/files.py b/core/views/files.py index 714b505d..5848666f 100644 --- a/core/views/files.py +++ b/core/views/files.py @@ -39,7 +39,7 @@ from core.auth.mixins import ( CanViewMixin, can_view, ) -from core.models import Notification, SithFile, User +from core.models import Notification, QuickUploadImage, SithFile, User from core.views.mixins import AllowFragment from core.views.widgets.ajax_select import ( AutoCompleteSelectMultipleGroup, @@ -86,8 +86,8 @@ def send_raw_file(path: Path) -> HttpResponse: def send_file( request: HttpRequest, - file_id: int, - file_class: type[SithFile] = SithFile, + file_id: int | str, + file_class: type[SithFile | QuickUploadImage] = SithFile, file_attr: str = "file", ) -> HttpResponse: """Send a protected file, if the user can see it. @@ -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 From 91b30e755060fda61654393650d7c2e318f3c068 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 6 Apr 2025 10:52:25 +0200 Subject: [PATCH 04/10] Add quick upload tests --- core/api.py | 7 +++- core/tests/test_files.py | 78 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/core/api.py b/core/api.py index b9393dda..e038e0f3 100644 --- a/core/api.py +++ b/core/api.py @@ -37,7 +37,12 @@ class MarkdownController(ControllerBase): @api_controller("/upload") class UploadController(ControllerBase): - @route.post("/image", response=UploadedFileSchema, permissions=[IsOldSubscriber]) + @route.post( + "/image", + response=UploadedFileSchema, + permissions=[IsOldSubscriber], + url_name="quick_upload_image", + ) def upload_image(self, file: UploadedFile): if file.content_type.split("/")[0] != "image": return self.create_response( diff --git a/core/tests/test_files.py b/core/tests/test_files.py index 2f86507a..37557db1 100644 --- a/core/tests/test_files.py +++ b/core/tests/test_files.py @@ -5,7 +5,7 @@ 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 +14,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 +257,76 @@ 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( + "test.jpg", content=b"invalid", content_type="image/jpg" + ), + 415, + ), + ( + lambda: old_subscriber_user.make(), + SimpleUploadedFile( + "test.jpg", content=RED_PIXEL_PNG, content_type="invalid" + ), + 415, + ), + ( + lambda: old_subscriber_user.make(), + SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"), + 415, + ), + ], +) +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 + + assert QuickUploadImage.objects.filter(pk=resp.json()["uuid"]).exists() From 67bc49fb2169de01c367f610fecf5cdfd0d084c7 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 6 Apr 2025 12:08:15 +0200 Subject: [PATCH 05/10] Serve upload files directly from nginx --- core/admin.py | 5 ++-- core/migrations/0045_quickuploadimage.py | 23 ++++++++++++++----- core/models.py | 20 +++++++++------- core/schemas.py | 17 +------------- .../bundled/core/components/easymde-index.ts | 11 +++++---- core/tests/test_files.py | 16 ++++++++++++- core/urls.py | 11 --------- core/views/files.py | 4 ++-- docs/tutorial/install-advanced.md | 2 +- locale/fr/LC_MESSAGES/django.po | 8 +++++++ 10 files changed, 65 insertions(+), 52 deletions(-) diff --git a/core/admin.py b/core/admin.py index 894f6a55..eff77817 100644 --- a/core/admin.py +++ b/core/admin.py @@ -102,6 +102,7 @@ class OperationLogAdmin(admin.ModelAdmin): @admin.register(QuickUploadImage) class QuickUploadImageAdmin(admin.ModelAdmin): - list_display = ("uuid", "name", "uploader", "content_type", "date") - search_fields = ("uuid", "name", "uploader") + list_display = ("uuid", "uploader", "created_at", "name") + search_fields = ("uuid", "uploader", "name") autocomplete_fields = ("uploader",) + readonly_fields = ("width", "height", "size") diff --git a/core/migrations/0045_quickuploadimage.py b/core/migrations/0045_quickuploadimage.py index ee6a6a7b..83edcc65 100644 --- a/core/migrations/0045_quickuploadimage.py +++ b/core/migrations/0045_quickuploadimage.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.20 on 2025-04-05 16:28 +# Generated by Django 4.2.20 on 2025-04-06 09:55 import django.db.models.deletion from django.conf import settings @@ -15,13 +15,24 @@ class Migration(migrations.Migration): name="QuickUploadImage", fields=[ ( - "uuid", - models.CharField(max_length=36, primary_key=True, serialize=False), + "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(upload_to="upload")), - ("content_type", models.CharField(max_length=50)), - ("date", models.DateTimeField(auto_now=True, verbose_name="date")), + ("image", models.ImageField(upload_to="upload/%Y/%m/%d")), + ( + "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( diff --git a/core/models.py b/core/models.py index 883d21f4..7169435d 100644 --- a/core/models.py +++ b/core/models.py @@ -1110,12 +1110,10 @@ class QuickUploadImage(models.Model): """Images uploaded by user outside of the SithFile mechanism""" IMAGE_NAME_SIZE = 100 - UUID_4_SIZE = 36 - uuid = models.CharField(max_length=UUID_4_SIZE, blank=False, primary_key=True) + uuid = models.UUIDField(unique=True, db_index=True) name = models.CharField(max_length=IMAGE_NAME_SIZE, blank=False) - image = models.ImageField(upload_to="upload") - content_type = models.CharField(max_length=50, blank=False) + image = models.ImageField(upload_to="upload/%Y/%m/%d") uploader = models.ForeignKey( "User", related_name="quick_uploads", @@ -1123,13 +1121,16 @@ class QuickUploadImage(models.Model): blank=True, on_delete=models.SET_NULL, ) - date = models.DateTimeField(_("date"), auto_now=True) + 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 f"{self.name}{Path(self.image.path).suffix}" + return str(self.image.path) def get_absolute_url(self): - return reverse("core:uploaded_image", kwargs={"image_uuid": self.uuid}) + return self.image.url @classmethod def create_from_uploaded( @@ -1145,13 +1146,16 @@ class QuickUploadImage(models.Model): identifier = str(uuid4()) name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1] file = File(convert_image(image), name=f"{identifier}.webp") + image = Image.open(file) return cls.objects.create( uuid=identifier, name=name, image=file, - content_type="image/webp", uploader=uploader, + width=image.width, + height=image.height, + size=file.size, ) diff --git a/core/schemas.py b/core/schemas.py index f532d676..b5f5991f 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -63,25 +63,10 @@ class UserProfileSchema(ModelSchema): class UploadedFileSchema(ModelSchema): class Meta: model = QuickUploadImage - fields = ["uuid", "name", "content_type"] + fields = ["uuid", "name", "width", "height", "size"] - width: int - height: int - size: int href: str - @staticmethod - def resolve_width(obj: QuickUploadImage): - return obj.image.width - - @staticmethod - def resolve_height(obj: QuickUploadImage): - return obj.image.height - - @staticmethod - def resolve_size(obj: QuickUploadImage): - return obj.image.size - @staticmethod def resolve_href(obj: QuickUploadImage) -> str: return obj.get_absolute_url() diff --git a/core/static/bundled/core/components/easymde-index.ts b/core/static/bundled/core/components/easymde-index.ts index 10ad06a5..39539409 100644 --- a/core/static/bundled/core/components/easymde-index.ts +++ b/core/static/bundled/core/components/easymde-index.ts @@ -26,13 +26,14 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => { return; } onSuccess(response.data.href); - // Workaround function to add ! and image name to uploaded image - // Without this, you get [](url) instead of ![name](url) + // 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 - 1 }); - easymde.codemirror.replaceSelection("!"); - easymde.codemirror.setSelection({ line: cursor.line, ch: cursor.ch + 1 }); + 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 diff --git a/core/tests/test_files.py b/core/tests/test_files.py index 37557db1..6c4c30e8 100644 --- a/core/tests/test_files.py +++ b/core/tests/test_files.py @@ -1,5 +1,6 @@ from io import BytesIO from itertools import cycle +from pathlib import Path from typing import Callable from uuid import uuid4 @@ -291,6 +292,15 @@ def test_apply_rights_recursively(): ), 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( @@ -329,4 +339,8 @@ def test_quick_upload_image( if expected_status != 200: return - assert QuickUploadImage.objects.filter(pk=resp.json()["uuid"]).exists() + 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/urls.py b/core/urls.py index 10810458..23fa9f11 100644 --- a/core/urls.py +++ b/core/urls.py @@ -30,7 +30,6 @@ from core.converters import ( FourDigitYearConverter, TwoDigitMonthConverter, ) -from core.models import QuickUploadImage from core.views import ( FileDeleteView, FileEditPropView, @@ -214,16 +213,6 @@ urlpatterns = [ "file//moderate/", FileModerateView.as_view(), name="file_moderate" ), path("file//download/", send_file, name="download"), - path( - "file//uploads/", - lambda request, image_uuid: send_file( - request=request, - file_id=image_uuid, - file_class=QuickUploadImage, - file_attr="image", - ), - name="uploaded_image", - ), # Page views path("page/", PageListView.as_view(), name="page_list"), path("page/create/", PageCreateView.as_view(), name="page_new"), diff --git a/core/views/files.py b/core/views/files.py index 5848666f..cd9103d9 100644 --- a/core/views/files.py +++ b/core/views/files.py @@ -39,7 +39,7 @@ from core.auth.mixins import ( CanViewMixin, can_view, ) -from core.models import Notification, QuickUploadImage, SithFile, User +from core.models import Notification, SithFile, User from core.views.mixins import AllowFragment from core.views.widgets.ajax_select import ( AutoCompleteSelectMultipleGroup, @@ -87,7 +87,7 @@ def send_raw_file(path: Path) -> HttpResponse: def send_file( request: HttpRequest, file_id: int | str, - file_class: type[SithFile | QuickUploadImage] = SithFile, + file_class: type[SithFile] = SithFile, file_attr: str = "file", ) -> HttpResponse: """Send a protected file, if the user can see it. 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" From 6e39b59dd506ed149f973c89a876becfb59d371a Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 9 Apr 2025 22:15:12 +0200 Subject: [PATCH 06/10] Use UploadedImage to check image correctness and better error responses --- core/api.py | 31 +++++++------------ .../bundled/core/components/easymde-index.ts | 20 ++++++++++-- core/tests/test_files.py | 6 ++-- locale/fr/LC_MESSAGES/djangojs.po | 14 ++++++--- 4 files changed, 42 insertions(+), 29 deletions(-) diff --git a/core/api.py b/core/api.py index e038e0f3..fb8ec29d 100644 --- a/core/api.py +++ b/core/api.py @@ -1,15 +1,14 @@ -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, UploadedFile +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 PIL import UnidentifiedImageError from club.models import Mailing from core.auth.api_permissions import CanAccessLookup, CanView, IsOldSubscriber @@ -20,6 +19,7 @@ from core.schemas import ( MarkdownSchema, SithFileSchema, UploadedFileSchema, + UploadedImage, UserFamilySchema, UserFilterSchema, UserProfileSchema, @@ -39,25 +39,18 @@ class MarkdownController(ControllerBase): class UploadController(ControllerBase): @route.post( "/image", - response=UploadedFileSchema, + response={ + 200: UploadedFileSchema, + 422: dict[Literal["detail"], list[dict[str, Any]]], + 403: dict[Literal["detail"], str], + }, permissions=[IsOldSubscriber], url_name="quick_upload_image", ) - def upload_image(self, file: UploadedFile): - if file.content_type.split("/")[0] != "image": - return self.create_response( - message=f"{file.name} isn't a file image", status_code=415 - ) - - try: - image = QuickUploadImage.create_from_uploaded( - file, uploader=self.context.request.user - ) - except UnidentifiedImageError: - return self.create_response( - message=f"{file.name} can't be processed", status_code=415 - ) - + def upload_image(self, file: File[UploadedImage]): + image = QuickUploadImage.create_from_uploaded( + file, uploader=self.context.request.user + ) return image diff --git a/core/static/bundled/core/components/easymde-index.ts b/core/static/bundled/core/components/easymde-index.ts index 39539409..af531236 100644 --- a/core/static/bundled/core/components/easymde-index.ts +++ b/core/static/bundled/core/components/easymde-index.ts @@ -6,7 +6,11 @@ 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, uploadUploadImage } from "#openapi"; +import { + type UploadUploadImageErrors, + markdownRenderMarkdown, + uploadUploadImage, +} from "#openapi"; const loadEasyMde = (textarea: HTMLTextAreaElement) => { const easymde = new EasyMDE({ @@ -21,8 +25,18 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => { file: file, }, }); - if (response.response.status !== 200) { - onError(gettext("Invalid 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("Not authorized, you need to have subscribed at least once")); + } else { + onError(gettext("Could not upload image")); + } return; } onSuccess(response.data.href); diff --git a/core/tests/test_files.py b/core/tests/test_files.py index 6c4c30e8..4afc4583 100644 --- a/core/tests/test_files.py +++ b/core/tests/test_files.py @@ -306,19 +306,19 @@ def test_apply_rights_recursively(): SimpleUploadedFile( "test.jpg", content=b"invalid", content_type="image/jpg" ), - 415, + 422, ), ( lambda: old_subscriber_user.make(), SimpleUploadedFile( "test.jpg", content=RED_PIXEL_PNG, content_type="invalid" ), - 415, + 200, # PIL can guess ), ( lambda: old_subscriber_user.make(), SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"), - 415, + 422, ), ], ) diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 70c4bd4b..9bb0f79b 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 22:12+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -64,12 +64,18 @@ msgid "No results found" msgstr "Aucun résultat trouvé" #: core/static/bundled/core/components/easymde-index.ts -msgid "Invalid file" -msgstr "Fichier invalide" +msgid "Not authorized, you need to have subscribed at least once" +msgstr "" + +#: 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." +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." From 744223b76fbd3919476d868b9b14a7619c61a205 Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 9 Apr 2025 22:50:51 +0200 Subject: [PATCH 07/10] Auto rescale quick upload image sizes --- core/models.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/core/models.py b/core/models.py index 7169435d..8aedceec 100644 --- a/core/models.py +++ b/core/models.py @@ -54,7 +54,7 @@ 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 @@ -1110,6 +1110,7 @@ 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) @@ -1138,9 +1139,10 @@ class QuickUploadImage(models.Model): ) -> Self: def convert_image(file: UploadedFile) -> ContentFile: content = BytesIO() - Image.open(BytesIO(file.read())).save( - fp=content, format="webp", optimize=True - ) + 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()) From 3e615608754229de6abdd0483fea284d37eba738 Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 9 Apr 2025 23:24:15 +0200 Subject: [PATCH 08/10] Use group permissions --- core/api.py | 4 ++-- core/management/commands/populate.py | 1 + core/models.py | 6 +++--- core/static/bundled/core/components/easymde-index.ts | 2 +- locale/fr/LC_MESSAGES/djangojs.po | 6 +++--- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/core/api.py b/core/api.py index fb8ec29d..830e06e9 100644 --- a/core/api.py +++ b/core/api.py @@ -11,7 +11,7 @@ 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, IsOldSubscriber +from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm from core.models import Group, QuickUploadImage, SithFile, User from core.schemas import ( FamilyGodfatherSchema, @@ -44,7 +44,7 @@ class UploadController(ControllerBase): 422: dict[Literal["detail"], list[dict[str, Any]]], 403: dict[Literal["detail"], str], }, - permissions=[IsOldSubscriber], + permissions=[HasPerm("core.add_quickuploadimage")], url_name="quick_upload_image", ) def upload_image(self, file: File[UploadedImage]): 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/models.py b/core/models.py index 8aedceec..d69fc92c 100644 --- a/core/models.py +++ b/core/models.py @@ -1148,15 +1148,15 @@ class QuickUploadImage(models.Model): identifier = str(uuid4()) name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1] file = File(convert_image(image), name=f"{identifier}.webp") - image = Image.open(file) + width, height = Image.open(file).size return cls.objects.create( uuid=identifier, name=name, image=file, uploader=uploader, - width=image.width, - height=image.height, + width=width, + height=height, size=file.size, ) diff --git a/core/static/bundled/core/components/easymde-index.ts b/core/static/bundled/core/components/easymde-index.ts index af531236..09f08626 100644 --- a/core/static/bundled/core/components/easymde-index.ts +++ b/core/static/bundled/core/components/easymde-index.ts @@ -33,7 +33,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => { .join(" ; "), ); } else if (response.response.status === 403) { - onError(gettext("Not authorized, you need to have subscribed at least once")); + onError(gettext("You are not authorized to use this feature")); } else { onError(gettext("Could not upload image")); } diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 9bb0f79b..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-09 22:12+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" @@ -64,8 +64,8 @@ msgid "No results found" msgstr "Aucun résultat trouvé" #: core/static/bundled/core/components/easymde-index.ts -msgid "Not authorized, you need to have subscribed at least once" -msgstr "" +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" From 0f961c71e0bbcdb2408ae808e20326463c24e506 Mon Sep 17 00:00:00 2001 From: Sli Date: Thu, 10 Apr 2025 10:35:17 +0200 Subject: [PATCH 09/10] Auto delete image files when object has been deleted --- core/migrations/0045_quickuploadimage.py | 11 +++++++++-- core/models.py | 10 +++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/core/migrations/0045_quickuploadimage.py b/core/migrations/0045_quickuploadimage.py index 83edcc65..30091c72 100644 --- a/core/migrations/0045_quickuploadimage.py +++ b/core/migrations/0045_quickuploadimage.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.20 on 2025-04-06 09:55 +# Generated by Django 4.2.20 on 2025-04-10 08:14 import django.db.models.deletion from django.conf import settings @@ -25,7 +25,14 @@ class Migration(migrations.Migration): ), ("uuid", models.UUIDField(db_index=True, unique=True)), ("name", models.CharField(max_length=100)), - ("image", models.ImageField(upload_to="upload/%Y/%m/%d")), + ( + "image", + models.ImageField( + height_field="height", + upload_to="upload/%Y/%m/%d", + width_field="width", + ), + ), ( "created_at", models.DateTimeField(auto_now_add=True, verbose_name="created at"), diff --git a/core/models.py b/core/models.py index d69fc92c..198a38b6 100644 --- a/core/models.py +++ b/core/models.py @@ -1114,7 +1114,9 @@ class QuickUploadImage(models.Model): 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") + image = models.ImageField( + upload_to="upload/%Y/%m/%d", width_field="width", height_field="height" + ) uploader = models.ForeignKey( "User", related_name="quick_uploads", @@ -1155,11 +1157,13 @@ class QuickUploadImage(models.Model): name=name, image=file, uploader=uploader, - width=width, - height=height, 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.""" From 6128b6564c5274ba550f381b06f127f2ca8225a0 Mon Sep 17 00:00:00 2001 From: Sli Date: Thu, 10 Apr 2025 11:38:33 +0200 Subject: [PATCH 10/10] Ensure quickupload image field uniqueness --- core/migrations/0045_quickuploadimage.py | 3 ++- core/models.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/core/migrations/0045_quickuploadimage.py b/core/migrations/0045_quickuploadimage.py index 30091c72..9464848c 100644 --- a/core/migrations/0045_quickuploadimage.py +++ b/core/migrations/0045_quickuploadimage.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.20 on 2025-04-10 08:14 +# Generated by Django 4.2.20 on 2025-04-10 09:34 import django.db.models.deletion from django.conf import settings @@ -29,6 +29,7 @@ class Migration(migrations.Migration): "image", models.ImageField( height_field="height", + unique=True, upload_to="upload/%Y/%m/%d", width_field="width", ), diff --git a/core/models.py b/core/models.py index 198a38b6..5c873c6c 100644 --- a/core/models.py +++ b/core/models.py @@ -1115,7 +1115,10 @@ class QuickUploadImage(models.Model): 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" + upload_to="upload/%Y/%m/%d", + width_field="width", + height_field="height", + unique=True, ) uploader = models.ForeignKey( "User",