Merge 8088536ad63035bd5a133c18d0f6b18bdb789e3a into bb3dfb7e8a87e4c4ca61d2ee095bb6c3f7ffc115

This commit is contained in:
Bartuccio Antoine 2025-03-18 01:17:47 +01:00 committed by GitHub
commit 164eab9ef9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 257 additions and 16 deletions

View File

@ -4,20 +4,22 @@ 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 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 UnidentifiedImageError
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, IsOldSubscriber
from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema,
SithFileSchema,
UploadedFileSchema,
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=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=415
)
try:
image = QuickUploadImage.create_from_uploaded(file)
except UnidentifiedImageError:
return self.create_response(
message=f"{file.name} can't be processed", status_code=415
)
return image
@api_controller("/mailings")
class MailingListController(ControllerBase):
@route.get("", response=str)

View File

@ -0,0 +1,52 @@
# Generated by Django 4.2.17 on 2025-02-28 10:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("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",
),
),
("name", models.CharField(max_length=100)),
("image", models.ImageField(upload_to="upload")),
("content_type", models.CharField(max_length=50)),
(
"related_model_id",
models.PositiveIntegerField(blank=True, null=True),
),
(
"related_model_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
],
options={
"indexes": [
models.Index(
fields=["related_model_type", "related_model_id"],
name="core_quicku_related_23899b_idx",
)
],
},
),
]

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,15 +32,19 @@ 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
from django.contrib.auth.models import AbstractUser, ContentType, UserManager
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
from django.contrib.auth.models import Group as AuthGroup
from django.contrib.contenttypes.fields import GenericForeignKey
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 +58,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
@ -1108,6 +1113,54 @@ 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
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)
related_model = GenericForeignKey("related_model_type", "related_model_id")
related_model_id = models.PositiveIntegerField(null=True, blank=True)
related_model_type = models.ForeignKey(
ContentType, on_delete=models.CASCADE, null=True, blank=True
)
class Meta:
indexes = [models.Index(fields=["related_model_type", "related_model_id"])]
def __str__(self) -> str:
return f"{self.name}{Path(self.image.path).suffix}"
@classmethod
def create_from_uploaded(
cls, image: UploadedFile, related_model: models.Model | 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())
name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1]
file = File(convert_image(image), name=f"{name}_{uuid4()}.webp")
return cls.objects.create(
name=name,
image=file,
content_type="image/webp",
related_model=related_model,
)
def can_be_viewed_by(self, user: User) -> bool:
if not self.related_model:
return True
return user.can_view(self.related_model)
class LockError(Exception):
"""There was a lock error on the object."""

View File

@ -10,7 +10,7 @@ from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import AliasChoices, Field
from core.models import Group, SithFile, User
from core.models import Group, QuickUploadImage, SithFile, User
class SimpleUserSchema(ModelSchema):
@ -47,6 +47,33 @@ class UserProfileSchema(ModelSchema):
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
class UploadedFileSchema(ModelSchema):
class Meta:
model = QuickUploadImage
fields = ["id", "name", "content_type"]
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 reverse("core:uploaded_image", kwargs={"image_id": obj.id})
class SithFileSchema(ModelSchema):
class Meta:
model = SithFile

View File

@ -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(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 +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,

View File

@ -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/<int:file_id>/moderate/", FileModerateView.as_view(), name="file_moderate"
),
path("file/<int:file_id>/download/", send_file, name="download"),
path(
"file/<int:image_id>/uploads/",
lambda request, image_id: send_file(
request=request,
file_id=image_id,
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"),

View File

@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-25 16:10+0100\n"
"POT-Creation-Date: 2025-02-27 00:27+0100\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"
@ -34,6 +34,7 @@ msgid "Delete"
msgstr "Supprimer"
#: com/static/bundled/com/components/moderation-alert-index.ts
#, javascript-format
msgid ""
"This event will take place every week for %s weeks. If you publish or delete "
"this event, it will also be published (or deleted) for the following weeks."
@ -54,6 +55,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"
@ -106,6 +135,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",