mirror of
https://github.com/ae-utbm/sith.git
synced 2025-04-16 02:50:22 +00:00
Merge pull request #1035 from ae-utbm/picture-upload
Picture upload from markdown editor
This commit is contained in:
commit
e96d224a8d
@ -17,7 +17,16 @@ from django.contrib import admin
|
|||||||
from django.contrib.auth.models import Group as AuthGroup
|
from django.contrib.auth.models import Group as AuthGroup
|
||||||
from django.contrib.auth.models import Permission
|
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)
|
admin.site.unregister(AuthGroup)
|
||||||
|
|
||||||
@ -89,3 +98,11 @@ class OperationLogAdmin(admin.ModelAdmin):
|
|||||||
list_display = ("label", "operator", "operation_type", "date")
|
list_display = ("label", "operator", "operation_type", "date")
|
||||||
search_fields = ("label", "date", "operation_type")
|
search_fields = ("label", "date", "operation_type")
|
||||||
autocomplete_fields = ("operator",)
|
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")
|
||||||
|
29
core/api.py
29
core/api.py
@ -1,23 +1,25 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
import annotated_types
|
import annotated_types
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.http import HttpResponse
|
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 import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.exceptions import PermissionDenied
|
from ninja_extra.exceptions import PermissionDenied
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
from club.models import Mailing
|
from club.models import Mailing
|
||||||
from core.auth.api_permissions import CanAccessLookup, CanView
|
from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm
|
||||||
from core.models import Group, SithFile, User
|
from core.models import Group, QuickUploadImage, SithFile, User
|
||||||
from core.schemas import (
|
from core.schemas import (
|
||||||
FamilyGodfatherSchema,
|
FamilyGodfatherSchema,
|
||||||
GroupSchema,
|
GroupSchema,
|
||||||
MarkdownSchema,
|
MarkdownSchema,
|
||||||
SithFileSchema,
|
SithFileSchema,
|
||||||
|
UploadedFileSchema,
|
||||||
|
UploadedImage,
|
||||||
UserFamilySchema,
|
UserFamilySchema,
|
||||||
UserFilterSchema,
|
UserFilterSchema,
|
||||||
UserProfileSchema,
|
UserProfileSchema,
|
||||||
@ -33,6 +35,25 @@ class MarkdownController(ControllerBase):
|
|||||||
return HttpResponse(markdown(body.text), content_type="text/html")
|
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")
|
@api_controller("/mailings")
|
||||||
class MailingListController(ControllerBase):
|
class MailingListController(ControllerBase):
|
||||||
@route.get("", response=str)
|
@route.get("", response=str)
|
||||||
|
@ -828,6 +828,7 @@ Welcome to the wiki page!
|
|||||||
"view_peoplepicturerelation",
|
"view_peoplepicturerelation",
|
||||||
"add_peoplepicturerelation",
|
"add_peoplepicturerelation",
|
||||||
"add_page",
|
"add_page",
|
||||||
|
"add_quickuploadimage",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
56
core/migrations/0045_quickuploadimage.py
Normal file
56
core/migrations/0045_quickuploadimage.py
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -17,7 +17,7 @@
|
|||||||
# details.
|
# details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# 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.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
@ -32,6 +32,7 @@ from datetime import timedelta
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Optional, Self
|
from typing import TYPE_CHECKING, Optional, Self
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractUser, UserManager
|
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 import validators
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
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.core.mail import send_mail
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Exists, F, OuterRef, Q
|
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.timezone import localdate, now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from phonenumber_field.modelfields import PhoneNumberField
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
from PIL import Image
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from pydantic import NonNegativeInt
|
from pydantic import NonNegativeInt
|
||||||
|
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
@ -1102,6 +1106,68 @@ class SithFile(models.Model):
|
|||||||
return reverse("core:download", kwargs={"file_id": self.id})
|
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):
|
class LockError(Exception):
|
||||||
"""There was a lock error on the object."""
|
"""There was a lock error on the object."""
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
|
|||||||
from pydantic import AliasChoices, Field
|
from pydantic import AliasChoices, Field
|
||||||
from pydantic_core.core_schema import ValidationInfo
|
from 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
|
from core.utils import is_image
|
||||||
|
|
||||||
|
|
||||||
@ -60,6 +60,18 @@ class UserProfileSchema(ModelSchema):
|
|||||||
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
|
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 SithFileSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SithFile
|
model = SithFile
|
||||||
|
@ -6,13 +6,58 @@ import { inheritHtmlElement, registerComponent } from "#core:utils/web-component
|
|||||||
import type CodeMirror from "codemirror";
|
import type CodeMirror from "codemirror";
|
||||||
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
|
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
|
||||||
import EasyMDE from "easymde";
|
import EasyMDE from "easymde";
|
||||||
import { markdownRenderMarkdown } from "#openapi";
|
import {
|
||||||
|
type UploadUploadImageErrors,
|
||||||
|
markdownRenderMarkdown,
|
||||||
|
uploadUploadImage,
|
||||||
|
} from "#openapi";
|
||||||
|
|
||||||
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||||
new EasyMDE({
|
const easymde = new EasyMDE({
|
||||||
element: textarea,
|
element: textarea,
|
||||||
spellChecker: false,
|
spellChecker: false,
|
||||||
autoDownloadFontAwesome: 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  instead of 
|
||||||
|
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) => {
|
previewRender: (plainText: string, preview: MarkdownInput) => {
|
||||||
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
|
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
|
||||||
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
|
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
|
||||||
@ -30,6 +75,14 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
|||||||
}, 300)(plainText, preview);
|
}, 300)(plainText, preview);
|
||||||
},
|
},
|
||||||
forceSync: true, // Avoid validation error on generic create view
|
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: [
|
toolbar: [
|
||||||
{
|
{
|
||||||
name: "heading-smaller",
|
name: "heading-smaller",
|
||||||
@ -120,6 +173,12 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
|||||||
className: "fa-regular fa-image",
|
className: "fa-regular fa-image",
|
||||||
title: gettext("Insert image"),
|
title: gettext("Insert image"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "upload-image",
|
||||||
|
action: EasyMDE.drawUploadedImage,
|
||||||
|
className: "fa-solid fa-file-arrow-up",
|
||||||
|
title: gettext("Upload image"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "table",
|
name: "table",
|
||||||
action: EasyMDE.drawTable,
|
action: EasyMDE.drawTable,
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from itertools import cycle
|
from itertools import cycle
|
||||||
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.core.cache import cache
|
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.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
@ -14,7 +15,8 @@ from PIL import Image
|
|||||||
from pytest_django.asserts import assertNumQueries
|
from pytest_django.asserts import assertNumQueries
|
||||||
|
|
||||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
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 sas.models import Picture
|
||||||
from sith import settings
|
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.view_groups.all()) == set(groups[:3])
|
||||||
assert set(file.edit_groups.all()) == set(groups[2:6])
|
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]
|
||||||
|
)
|
||||||
|
@ -86,7 +86,7 @@ def send_raw_file(path: Path) -> HttpResponse:
|
|||||||
|
|
||||||
def send_file(
|
def send_file(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
file_id: int,
|
file_id: int | str,
|
||||||
file_class: type[SithFile] = SithFile,
|
file_class: type[SithFile] = SithFile,
|
||||||
file_attr: str = "file",
|
file_attr: str = "file",
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
@ -97,7 +97,7 @@ def send_file(
|
|||||||
deal with it.
|
deal with it.
|
||||||
In debug mode, the server will directly send the file.
|
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):
|
if not can_view(f, request.user) and not is_logged_in_counter(request):
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
name = getattr(f, file_attr).name
|
name = getattr(f, file_attr).name
|
||||||
|
@ -224,7 +224,7 @@ server {
|
|||||||
location /static/;
|
location /static/;
|
||||||
root /repertoire/du/projet;
|
root /repertoire/du/projet;
|
||||||
}
|
}
|
||||||
location ~ ^/data/(products|com|club_logos)/ {
|
location ~ ^/data/(products|com|club_logos|upload)/ {
|
||||||
root /repertoire/du/projet;
|
root /repertoire/du/projet;
|
||||||
}
|
}
|
||||||
location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ {
|
location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ {
|
||||||
|
@ -1572,6 +1572,14 @@ msgstr "Ceci n'est pas une miniature de dossier valide"
|
|||||||
msgid "You must provide a file"
|
msgid "You must provide a file"
|
||||||
msgstr "Vous devez fournir un fichier"
|
msgstr "Vous devez fournir un fichier"
|
||||||
|
|
||||||
|
#: core/models.py
|
||||||
|
msgid "width"
|
||||||
|
msgstr "largeur"
|
||||||
|
|
||||||
|
#: core/models.py
|
||||||
|
msgid "height"
|
||||||
|
msgstr "hauteur"
|
||||||
|
|
||||||
#: core/models.py
|
#: core/models.py
|
||||||
msgid "page unix name"
|
msgid "page unix name"
|
||||||
msgstr "nom unix de la page"
|
msgstr "nom unix de la page"
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"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"
|
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.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"
|
msgid "No results found"
|
||||||
msgstr "Aucun résultat trouvé"
|
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
|
#: core/static/bundled/core/components/easymde-index.ts
|
||||||
msgid "Heading"
|
msgid "Heading"
|
||||||
msgstr "Titre"
|
msgstr "Titre"
|
||||||
@ -115,6 +149,10 @@ msgstr "Insérer lien"
|
|||||||
msgid "Insert image"
|
msgid "Insert image"
|
||||||
msgstr "Insérer 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
|
#: core/static/bundled/core/components/easymde-index.ts
|
||||||
msgid "Insert table"
|
msgid "Insert table"
|
||||||
msgstr "Insérer tableau"
|
msgstr "Insérer tableau"
|
||||||
|
12
package-lock.json
generated
12
package-lock.json
generated
@ -27,7 +27,7 @@
|
|||||||
"cytoscape-cxtmenu": "^3.5.0",
|
"cytoscape-cxtmenu": "^3.5.0",
|
||||||
"cytoscape-klay": "^3.1.4",
|
"cytoscape-klay": "^3.1.4",
|
||||||
"d3-force-3d": "^3.0.5",
|
"d3-force-3d": "^3.0.5",
|
||||||
"easymde": "^2.18.0",
|
"easymde": "^2.19.0",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
"htmx.org": "^2.0.3",
|
"htmx.org": "^2.0.3",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
@ -3663,14 +3663,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/easymde": {
|
"node_modules/easymde": {
|
||||||
"version": "2.18.0",
|
"version": "2.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.19.0.tgz",
|
||||||
"integrity": "sha512-IxVVUxNWIoXLeqtBU4BLc+eS/ScYhT1Dcb6yF5Wchoj1iXAV+TIIDWx+NCaZhY7RcSHqDPKllbYq7nwGKILnoA==",
|
"integrity": "sha512-4F1aNImqse+9xIjLh9ttfpOVenecjFPxUmKbl1tGp72Z+OyIqLZPE/SgNyy88c/xU0mOy0WC3+tfbZDQ5PDWhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/codemirror": "^5.60.4",
|
"@types/codemirror": "^5.60.10",
|
||||||
"@types/marked": "^4.0.7",
|
"@types/marked": "^4.0.7",
|
||||||
"codemirror": "^5.63.1",
|
"codemirror": "^5.65.15",
|
||||||
"codemirror-spell-checker": "1.1.2",
|
"codemirror-spell-checker": "1.1.2",
|
||||||
"marked": "^4.1.0"
|
"marked": "^4.1.0"
|
||||||
}
|
}
|
||||||
|
@ -54,7 +54,7 @@
|
|||||||
"cytoscape-cxtmenu": "^3.5.0",
|
"cytoscape-cxtmenu": "^3.5.0",
|
||||||
"cytoscape-klay": "^3.1.4",
|
"cytoscape-klay": "^3.1.4",
|
||||||
"d3-force-3d": "^3.0.5",
|
"d3-force-3d": "^3.0.5",
|
||||||
"easymde": "^2.18.0",
|
"easymde": "^2.19.0",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
"htmx.org": "^2.0.3",
|
"htmx.org": "^2.0.3",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user