diff --git a/src/config/settings.py b/src/config/settings.py index 8635c39..44488d9 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -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": { diff --git a/src/config/urls.py b/src/config/urls.py index f1c5d77..0c2ef10 100644 --- a/src/config/urls.py +++ b/src/config/urls.py @@ -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")), diff --git a/src/core/storage.py b/src/core/storage.py index 5b34999..0add405 100644 --- a/src/core/storage.py +++ b/src/core/storage.py @@ -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}") diff --git a/src/notice/apps.py b/src/notice/apps.py index 4c848c4..742266c 100644 --- a/src/notice/apps.py +++ b/src/notice/apps.py @@ -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 diff --git a/src/notice/signals.py b/src/notice/signals.py new file mode 100644 index 0000000..e6bad89 --- /dev/null +++ b/src/notice/signals.py @@ -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}") diff --git a/src/public/__init__.py b/src/public/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/public/admin.py b/src/public/admin.py index 0f1eb52..cc07768 100644 --- a/src/public/admin.py +++ b/src/public/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin +from .image_models import TemporaryImage from .tag_models import Tag @@ -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() diff --git a/src/public/image_models.py b/src/public/image_models.py new file mode 100644 index 0000000..dc3e760 --- /dev/null +++ b/src/public/image_models.py @@ -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) diff --git a/src/public/migrations/0008_temporaryimage.py b/src/public/migrations/0008_temporaryimage.py new file mode 100644 index 0000000..24e22e2 --- /dev/null +++ b/src/public/migrations/0008_temporaryimage.py @@ -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")], + }, + ), + ] diff --git a/src/public/urls.py b/src/public/urls.py new file mode 100644 index 0000000..d93cff3 --- /dev/null +++ b/src/public/urls.py @@ -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"), +] diff --git a/src/public/views.py b/src/public/views.py new file mode 100644 index 0000000..044eded --- /dev/null +++ b/src/public/views.py @@ -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)