Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@
{"color": "hsl(231, 48%, 48%)", "label": "Indigo"},
{"color": "hsl(207, 90%, 54%)", "label": "Blue"},
]

CKEDITOR_5_UPLOAD_DIRECTORY_PREFIX = "editor/"
CK_EDITOR_5_UPLOAD_FILE_VIEW_NAME = "custom_upload_file"
CKEDITOR_5_CONFIGS = {
"default": {
"toolbar": {
Expand Down
2 changes: 1 addition & 1 deletion src/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

urlpatterns = [
path("admin/", admin.site.urls),
path("ckeditor5/", include("django_ckeditor_5.urls")), # ckeditor5
path("api/public/", include("public.urls")),
path("api/faqs/", include("faq.urls")),
path("api/merchandise/", include("merchandise.urls")),
path("api/notice/", include("notice.urls")),
Expand Down
16 changes: 10 additions & 6 deletions src/core/storage.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import boto3
from botocore.exceptions import ClientError
from django.conf import settings


def s3_delete_file(file_name: str) -> None:
storage = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
storage.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
try:
storage = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
storage.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
except ClientError as e:
print(f"Failed to delete {file_name} from S3: {e}")
3 changes: 3 additions & 0 deletions src/notice/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
class NoticeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "notice"

def ready(self) -> None:
import notice.signals # noqa: F401
61 changes: 61 additions & 0 deletions src/notice/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
from django_ckeditor_5.signals import extract_image_paths

from core.storage import s3_delete_file
from notice.models import Notice
from public.image_models import TemporaryImage


@receiver(post_save, sender=Notice)
def delete_temporary_images_on_notice_save(sender, instance, **kwargs):
images_to_delete = extract_image_paths(instance.content)

try:
for img_path in images_to_delete:
file_path = img_path.removeprefix(f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/")
delete_images = TemporaryImage.objects.filter(path=file_path).all()
for image in delete_images:
image.soft_delete()
except Exception as e:
raise ValidationError(f"이미지 삭제 오류: {e}")


@receiver(pre_save, sender=Notice)
def cleanup_unused_ckeditor_images_on_update(sender, instance, **kwargs):
"""
Removes unused images when an object is updated.
If any unexpected error occurs, it will be logged, but the deletion process won't break the update.
"""
try:
try:
old_instance = sender.objects.get(pk=instance.pk)
old_images = set(extract_image_paths(old_instance.content))
except sender.DoesNotExist:
old_images = set()

new_images = set(extract_image_paths(instance.content))
unused_images = old_images - new_images

for image in unused_images:
# CKEditor5Field의 이미지 경로는 상대 경로이므로, 절대 경로로 변환
file_path = image.removeprefix(f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/")
s3_delete_file(file_path)
except Exception as e:
print(f"Error in cleanup_unused_ckeditor_images_on_update: {e}")


@receiver(pre_delete, sender=Notice)
def cleanup_ckeditor_images_on_delete(sender, instance, **kwargs):
"""
Removes images from disk when an object is deleted.
If an error occurs, it is logged, but the deletion process continues.
"""
try:
for image in extract_image_paths(instance.content):
file_path = image.removeprefix(f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/")
s3_delete_file(file_path)
except Exception as e:
print(f"Error in cleanup_ckeditor_images_on_delete: {e}")
Empty file added src/public/__init__.py
Empty file.
20 changes: 20 additions & 0 deletions src/public/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.contrib import admin

from .image_models import TemporaryImage
from .tag_models import Tag


Expand All @@ -9,3 +10,22 @@ class TagAdmin(admin.ModelAdmin):

list_display = ("id", "name")
search_fields = ("name",)


@admin.register(TemporaryImage)
class TemporaryImageAdmin(admin.ModelAdmin):
"""임시 이미지 관리자 페이지"""

list_display = ("id", "path", "expiry_at")
search_fields = ("path",)

def has_add_permission(self, request):
return False # 추가 비활성화

def has_change_permission(self, request, obj=None):
return False # 수정 비활성화

def delete_queryset(self, request, queryset):
# Admin에서 여러 객체 삭제 시 호출됨
for obj in queryset:
obj.delete()
30 changes: 30 additions & 0 deletions src/public/image_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from datetime import timedelta

from django.db import models

from core.storage import s3_delete_file


class TemporaryImage(models.Model):
path = models.CharField("임시 저장된 이미지 경로", max_length=255)
created_at = models.DateTimeField("임시 저장 일시", auto_now_add=True)
expiry_at = models.DateTimeField(
"만료 일시",
db_default=models.functions.TruncDate(
models.functions.Now() + timedelta(days=1), output_field=models.DateField()
),
)

class Meta:
indexes = (models.Index(fields=["path"]),)
verbose_name = "임시 이미지"
verbose_name_plural = "임시 이미지들"

def delete(self, *args, **kwargs) -> None:
s3_delete_file(self.path)

super().delete(*args, **kwargs)

def soft_delete(self, *args, **kwargs) -> None:
"""임시 이미지 삭제"""
super().delete(*args, **kwargs)
51 changes: 51 additions & 0 deletions src/public/migrations/0008_temporaryimage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Generated by Django 5.2.3 on 2025-07-05 14:30

import datetime

import django.db.models.expressions
import django.db.models.functions.datetime
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("public", "0007_alter_noticetag_notice_action"),
]

operations = [
migrations.CreateModel(
name="TemporaryImage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("path", models.CharField(max_length=255, verbose_name="임시 저장된 이미지 경로")),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="임시 저장 일시"),
),
(
"expiry_at",
models.DateTimeField(
db_default=django.db.models.functions.datetime.TruncDate(
django.db.models.expressions.CombinedExpression(
django.db.models.functions.datetime.Now(),
"+",
models.Value(datetime.timedelta(days=1)),
),
output_field=models.DateField(),
),
verbose_name="만료 일시",
),
),
],
options={
"verbose_name": "임시 이미지",
"verbose_name_plural": "임시 이미지들",
"indexes": [models.Index(fields=["path"], name="public_temp_path_a3ab43_idx")],
},
),
]
8 changes: 8 additions & 0 deletions src/public/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.urls import path

from .views import upload_file

urlpatterns = [
# Custom upload file endpoint
path("custom_upload_file/", upload_file, name="custom_upload_file"),
]
50 changes: 50 additions & 0 deletions src/public/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from django.conf import settings
from django.http import HttpRequest, JsonResponse
from django.views.decorators.http import require_POST
from django_ckeditor_5.exceptions import NoImageException
from django_ckeditor_5.forms import UploadFileForm
from django_ckeditor_5.permissions import check_upload_permission
from django_ckeditor_5.storage_utils import get_django_storage_class, image_verify

from .image_models import TemporaryImage


def handle_uploaded_file(f, path):
storage = get_django_storage_class()
fs = storage()
filename = fs.save(path, f)
return fs.url(filename)


@require_POST
@check_upload_permission
def upload_file(request: HttpRequest) -> JsonResponse:
form = UploadFileForm(request.POST, request.FILES)
allow_all_file_types = getattr(settings, "CKEDITOR_5_ALLOW_ALL_FILE_TYPES", False)
upload_image = request.FILES.get("upload", None)

if not allow_all_file_types:
try:
image_verify(upload_image)
except NoImageException as ex:
return JsonResponse({"error": {"message": f"{ex}"}}, status=400)

if form.is_valid():
# ckeditor5를 통해 업로드한 파일이 특정 디렉토리에 저장되도록 설정
directory_prefix = getattr(settings, "CKEDITOR_5_UPLOAD_DIRECTORY_PREFIX", "editor/")
directory_prefix = directory_prefix.removeprefix("/")
if not directory_prefix.endswith("/"):
directory_prefix += "/"
print(f"Upload image name: {upload_image.name}")
url = handle_uploaded_file(upload_image, directory_prefix + upload_image.name)
saved_image_path = url.removeprefix(f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/")
TemporaryImage.objects.create(path=saved_image_path)
return JsonResponse({"url": url})

if form.errors["upload"]:
return JsonResponse(
{"error": {"message": form.errors["upload"][0]}},
status=400,
)

return JsonResponse({"error": {"message": "Invalid form data"}}, status=400)