Skip to content

Commit 4e34981

Browse files
authored
Merge pull request #126 from HelloPy-Korea/feature/image-upload-api
Feat: 이미지 업로드 API 수정
2 parents 28ed7c7 + 6585697 commit 4e34981

11 files changed

Lines changed: 237 additions & 7 deletions

File tree

src/config/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@
137137
{"color": "hsl(231, 48%, 48%)", "label": "Indigo"},
138138
{"color": "hsl(207, 90%, 54%)", "label": "Blue"},
139139
]
140+
141+
CKEDITOR_5_UPLOAD_DIRECTORY_PREFIX = "editor/"
142+
CK_EDITOR_5_UPLOAD_FILE_VIEW_NAME = "custom_upload_file"
140143
CKEDITOR_5_CONFIGS = {
141144
"default": {
142145
"toolbar": {

src/config/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
urlpatterns = [
1414
path("admin/", admin.site.urls),
15-
path("ckeditor5/", include("django_ckeditor_5.urls")), # ckeditor5
15+
path("api/public/", include("public.urls")),
1616
path("api/faqs/", include("faq.urls")),
1717
path("api/merchandise/", include("merchandise.urls")),
1818
path("api/notice/", include("notice.urls")),

src/core/storage.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import boto3
2+
from botocore.exceptions import ClientError
23
from django.conf import settings
34

45

56
def s3_delete_file(file_name: str) -> None:
6-
storage = boto3.client(
7-
"s3",
8-
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
9-
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
10-
)
11-
storage.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
7+
try:
8+
storage = boto3.client(
9+
"s3",
10+
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
11+
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
12+
)
13+
storage.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
14+
except ClientError as e:
15+
print(f"Failed to delete {file_name} from S3: {e}")

src/notice/apps.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
class NoticeConfig(AppConfig):
55
default_auto_field = "django.db.models.BigAutoField"
66
name = "notice"
7+
8+
def ready(self) -> None:
9+
import notice.signals # noqa: F401

src/notice/signals.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from django.conf import settings
2+
from django.core.exceptions import ValidationError
3+
from django.db.models.signals import post_save, pre_delete, pre_save
4+
from django.dispatch import receiver
5+
from django_ckeditor_5.signals import extract_image_paths
6+
7+
from core.storage import s3_delete_file
8+
from notice.models import Notice
9+
from public.image_models import TemporaryImage
10+
11+
12+
@receiver(post_save, sender=Notice)
13+
def delete_temporary_images_on_notice_save(sender, instance, **kwargs):
14+
images_to_delete = extract_image_paths(instance.content)
15+
16+
try:
17+
for img_path in images_to_delete:
18+
file_path = img_path.removeprefix(f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/")
19+
delete_images = TemporaryImage.objects.filter(path=file_path).all()
20+
for image in delete_images:
21+
image.soft_delete()
22+
except Exception as e:
23+
raise ValidationError(f"이미지 삭제 오류: {e}")
24+
25+
26+
@receiver(pre_save, sender=Notice)
27+
def cleanup_unused_ckeditor_images_on_update(sender, instance, **kwargs):
28+
"""
29+
Removes unused images when an object is updated.
30+
If any unexpected error occurs, it will be logged, but the deletion process won't break the update.
31+
"""
32+
try:
33+
try:
34+
old_instance = sender.objects.get(pk=instance.pk)
35+
old_images = set(extract_image_paths(old_instance.content))
36+
except sender.DoesNotExist:
37+
old_images = set()
38+
39+
new_images = set(extract_image_paths(instance.content))
40+
unused_images = old_images - new_images
41+
42+
for image in unused_images:
43+
# CKEditor5Field의 이미지 경로는 상대 경로이므로, 절대 경로로 변환
44+
file_path = image.removeprefix(f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/")
45+
s3_delete_file(file_path)
46+
except Exception as e:
47+
print(f"Error in cleanup_unused_ckeditor_images_on_update: {e}")
48+
49+
50+
@receiver(pre_delete, sender=Notice)
51+
def cleanup_ckeditor_images_on_delete(sender, instance, **kwargs):
52+
"""
53+
Removes images from disk when an object is deleted.
54+
If an error occurs, it is logged, but the deletion process continues.
55+
"""
56+
try:
57+
for image in extract_image_paths(instance.content):
58+
file_path = image.removeprefix(f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/")
59+
s3_delete_file(file_path)
60+
except Exception as e:
61+
print(f"Error in cleanup_ckeditor_images_on_delete: {e}")

src/public/__init__.py

Whitespace-only changes.

src/public/admin.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.contrib import admin
22

3+
from .image_models import TemporaryImage
34
from .tag_models import Tag
45

56

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

1011
list_display = ("id", "name")
1112
search_fields = ("name",)
13+
14+
15+
@admin.register(TemporaryImage)
16+
class TemporaryImageAdmin(admin.ModelAdmin):
17+
"""임시 이미지 관리자 페이지"""
18+
19+
list_display = ("id", "path", "expiry_at")
20+
search_fields = ("path",)
21+
22+
def has_add_permission(self, request):
23+
return False # 추가 비활성화
24+
25+
def has_change_permission(self, request, obj=None):
26+
return False # 수정 비활성화
27+
28+
def delete_queryset(self, request, queryset):
29+
# Admin에서 여러 객체 삭제 시 호출됨
30+
for obj in queryset:
31+
obj.delete()

src/public/image_models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from datetime import timedelta
2+
3+
from django.db import models
4+
5+
from core.storage import s3_delete_file
6+
7+
8+
class TemporaryImage(models.Model):
9+
path = models.CharField("임시 저장된 이미지 경로", max_length=255)
10+
created_at = models.DateTimeField("임시 저장 일시", auto_now_add=True)
11+
expiry_at = models.DateTimeField(
12+
"만료 일시",
13+
db_default=models.functions.TruncDate(
14+
models.functions.Now() + timedelta(days=1), output_field=models.DateField()
15+
),
16+
)
17+
18+
class Meta:
19+
indexes = (models.Index(fields=["path"]),)
20+
verbose_name = "임시 이미지"
21+
verbose_name_plural = "임시 이미지들"
22+
23+
def delete(self, *args, **kwargs) -> None:
24+
s3_delete_file(self.path)
25+
26+
super().delete(*args, **kwargs)
27+
28+
def soft_delete(self, *args, **kwargs) -> None:
29+
"""임시 이미지 삭제"""
30+
super().delete(*args, **kwargs)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Generated by Django 5.2.3 on 2025-07-05 14:30
2+
3+
import datetime
4+
5+
import django.db.models.expressions
6+
import django.db.models.functions.datetime
7+
from django.db import migrations, models
8+
9+
10+
class Migration(migrations.Migration):
11+
dependencies = [
12+
("public", "0007_alter_noticetag_notice_action"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="TemporaryImage",
18+
fields=[
19+
(
20+
"id",
21+
models.BigAutoField(
22+
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
23+
),
24+
),
25+
("path", models.CharField(max_length=255, verbose_name="임시 저장된 이미지 경로")),
26+
(
27+
"created_at",
28+
models.DateTimeField(auto_now_add=True, verbose_name="임시 저장 일시"),
29+
),
30+
(
31+
"expiry_at",
32+
models.DateTimeField(
33+
db_default=django.db.models.functions.datetime.TruncDate(
34+
django.db.models.expressions.CombinedExpression(
35+
django.db.models.functions.datetime.Now(),
36+
"+",
37+
models.Value(datetime.timedelta(days=1)),
38+
),
39+
output_field=models.DateField(),
40+
),
41+
verbose_name="만료 일시",
42+
),
43+
),
44+
],
45+
options={
46+
"verbose_name": "임시 이미지",
47+
"verbose_name_plural": "임시 이미지들",
48+
"indexes": [models.Index(fields=["path"], name="public_temp_path_a3ab43_idx")],
49+
},
50+
),
51+
]

src/public/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django.urls import path
2+
3+
from .views import upload_file
4+
5+
urlpatterns = [
6+
# Custom upload file endpoint
7+
path("custom_upload_file/", upload_file, name="custom_upload_file"),
8+
]

0 commit comments

Comments
 (0)