Create dedicated image upload model

This commit is contained in:
Antoine Bartuccio 2025-02-28 15:06:47 +01:00
parent d65cabe4f3
commit 8088536ad6
7 changed files with 142 additions and 55 deletions

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

@ -856,10 +856,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,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. # 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,15 +32,19 @@ 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, ContentType, UserManager
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
from django.contrib.auth.models import Group as AuthGroup 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.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 +58,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
@ -1108,6 +1113,54 @@ 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
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): class LockError(Exception):
"""There was a lock error on the object.""" """There was a lock error on the object."""

View File

@ -9,9 +9,8 @@ from django.utils.text import slugify
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import AliasChoices, Field from pydantic import AliasChoices, Field
from pydantic_core import Url
from core.models import Group, SithFile, User from core.models import Group, QuickUploadImage, SithFile, User
class SimpleUserSchema(ModelSchema): class SimpleUserSchema(ModelSchema):
@ -50,14 +49,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 = ["id", "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 reverse("core:uploaded_image", kwargs={"image_id": obj.id})
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/<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 # 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"),