Create dedicated image upload model

This commit is contained in:
Antoine Bartuccio 2025-02-28 15:06:47 +01:00
parent 7b23196071
commit c236092c4f
9 changed files with 149 additions and 59 deletions

View File

@ -17,7 +17,16 @@ from django.contrib import admin
from django.contrib.auth.models import Group as AuthGroup from django.contrib.auth.models import 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,10 @@ 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", "name", "uploader", "content_type", "date")
search_fields = ("uuid", "name", "uploader")
autocomplete_fields = ("uploader",)

View File

@ -1,12 +1,7 @@
from io import BytesIO
from pathlib import Path
from typing import Annotated from typing import Annotated
from uuid import uuid4
import annotated_types import annotated_types
from django.conf import settings 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.db.models import F
from django.http import HttpResponse from django.http import HttpResponse
from ninja import Query, UploadedFile 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.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 PIL import Image, UnidentifiedImageError from PIL import UnidentifiedImageError
from club.models import Mailing from club.models import Mailing
from core.auth.api_permissions import CanAccessLookup, CanView, IsOldSubscriber 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 ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,
GroupSchema, GroupSchema,
@ -49,47 +44,15 @@ class UploadController(ControllerBase):
message=f"{file.name} isn't a file image", status_code=415 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: try:
converted = convert_image(file) image = QuickUploadImage.create_from_uploaded(
file, uploader=self.context.request.user
)
except UnidentifiedImageError: except UnidentifiedImageError:
return self.create_response( return self.create_response(
message=f"{file.name} can't be processed", status_code=415 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 return image

View File

@ -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): def _create_profile_pict(self, user: User):
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg" path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
file = resize_image(Image.open(path), 400, "WEBP") file = resize_image(Image.open(path), 400, "WEBP")

View File

@ -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,
),
),
],
),
]

View File

@ -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
@ -54,6 +57,7 @@ from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image from PIL import Image
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,55 @@ 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
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): class LockError(Exception):
"""There was a lock error on the object.""" """There was a lock error on the object."""

View File

@ -10,10 +10,9 @@ from django.utils.translation import gettext as _
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema, UploadedFile from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field from pydantic import AliasChoices, Field
from pydantic_core import Url
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
@ -63,14 +62,29 @@ class UserProfileSchema(ModelSchema):
class UploadedFileSchema(ModelSchema): class UploadedFileSchema(ModelSchema):
class Meta: class Meta:
model = SithFile model = QuickUploadImage
fields = ["id", "name", "mime_type", "size"] fields = ["uuid", "name", "content_type"]
width: int
height: int
size: int
href: str href: str
@staticmethod @staticmethod
def resolve_href(obj: SithFile) -> Url: def resolve_width(obj: QuickUploadImage):
return reverse("core:download", kwargs={"file_id": obj.id}) 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): class SithFileSchema(ModelSchema):

View File

@ -33,7 +33,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
easymde.codemirror.replaceSelection("!"); easymde.codemirror.replaceSelection("!");
easymde.codemirror.setSelection({ line: cursor.line, ch: cursor.ch + 1 }); 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 // Move cursor at the end of the url and add a new line
cursor = easymde.codemirror.getCursor(); cursor = easymde.codemirror.getCursor();

View File

@ -30,6 +30,7 @@ from core.converters import (
FourDigitYearConverter, FourDigitYearConverter,
TwoDigitMonthConverter, TwoDigitMonthConverter,
) )
from core.models import QuickUploadImage
from core.views import ( from core.views import (
FileDeleteView, FileDeleteView,
FileEditPropView, FileEditPropView,
@ -213,6 +214,16 @@ urlpatterns = [
"file/<int:file_id>/moderate/", FileModerateView.as_view(), name="file_moderate" "file/<int:file_id>/moderate/", FileModerateView.as_view(), name="file_moderate"
), ),
path("file/<int:file_id>/download/", send_file, name="download"), path("file/<int:file_id>/download/", send_file, name="download"),
path(
"file/<str:image_uuid>/uploads/",
lambda request, image_uuid: send_file(
request=request,
file_id=image_uuid,
file_class=QuickUploadImage,
file_attr="image",
),
name="uploaded_image",
),
# Page views # Page views
path("page/", PageListView.as_view(), name="page_list"), path("page/", PageListView.as_view(), name="page_list"),
path("page/create/", PageCreateView.as_view(), name="page_new"), path("page/create/", PageCreateView.as_view(), name="page_new"),

View File

@ -39,7 +39,7 @@ from core.auth.mixins import (
CanViewMixin, CanViewMixin,
can_view, 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.mixins import AllowFragment
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
@ -86,8 +86,8 @@ 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 | QuickUploadImage] = SithFile,
file_attr: str = "file", file_attr: str = "file",
) -> HttpResponse: ) -> HttpResponse:
"""Send a protected file, if the user can see it. """Send a protected file, if the user can see it.
@ -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