feat: Attachment 모델 추가 및 관련 기능 구현
All checks were successful
Build And Test / build-and-push (push) Successful in 2m5s
All checks were successful
Build And Test / build-and-push (push) Successful in 2m5s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
25
blog/migrations/0004_attachment.py
Normal file
25
blog/migrations/0004_attachment.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.2.14 on 2026-01-20 13:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('blog', '0003_tag_post_tags'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Attachment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('file', models.FileField(upload_to='attachments/%Y/%m/%d/')),
|
||||||
|
('original_name', models.CharField(max_length=255)),
|
||||||
|
('file_size', models.PositiveIntegerField()),
|
||||||
|
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='blog.post')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 4.2.14 on 2026-01-20 14:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('blog', '0004_attachment'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='attachment',
|
||||||
|
name='uploader_id',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=150),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='attachment',
|
||||||
|
name='uploader_name',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=150),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='attachment',
|
||||||
|
name='post',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='blog.post'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
blog/migrations/0006_attachment_batch_id.py
Normal file
18
blog/migrations/0006_attachment_batch_id.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.14 on 2026-01-20 14:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('blog', '0005_attachment_uploader_id_attachment_uploader_name_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='attachment',
|
||||||
|
name='batch_id',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=36),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,4 +1,6 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import pre_delete
|
||||||
|
from django.dispatch import receiver
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
@ -56,3 +58,31 @@ class Comment(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Comment by {self.author_name} on {self.post.title}"
|
return f"Comment by {self.author_name} on {self.post.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class Attachment(models.Model):
|
||||||
|
"""첨부파일 모델"""
|
||||||
|
post = models.ForeignKey(
|
||||||
|
Post,
|
||||||
|
related_name="attachments",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
) # null이면 임시 파일 (아직 게시글에 연결되지 않음)
|
||||||
|
file = models.FileField(upload_to='attachments/%Y/%m/%d/')
|
||||||
|
original_name = models.CharField(max_length=255) # 원본 파일명
|
||||||
|
file_size = models.PositiveIntegerField() # 파일 크기 (bytes)
|
||||||
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
uploader_id = models.CharField(max_length=150, blank=True, default='') # 업로더 ID
|
||||||
|
uploader_name = models.CharField(max_length=150, blank=True, default='') # 업로더 이름
|
||||||
|
batch_id = models.CharField(max_length=36, blank=True, default='') # 업로드 배치 ID (같은 게시글 그룹)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.original_name
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_delete, sender=Attachment)
|
||||||
|
def delete_attachment_file(sender, instance, **kwargs):
|
||||||
|
"""Attachment 삭제 시 MinIO에서 파일도 삭제"""
|
||||||
|
if instance.file:
|
||||||
|
instance.file.delete(save=False)
|
||||||
|
|||||||
@ -1,7 +1,33 @@
|
|||||||
# blog/serializers.py
|
# blog/serializers.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Post, Comment, Tag
|
from .models import Post, Comment, Tag, Attachment
|
||||||
|
|
||||||
|
|
||||||
|
def get_unique_filename(original_name, existing_names):
|
||||||
|
"""
|
||||||
|
중복 파일명이 있으면 (2), (3) 등의 번호를 추가하여 고유한 파일명 반환
|
||||||
|
"""
|
||||||
|
if original_name not in existing_names:
|
||||||
|
return original_name
|
||||||
|
|
||||||
|
name, ext = os.path.splitext(original_name)
|
||||||
|
match = re.match(r'^(.+)\((\d+)\)$', name)
|
||||||
|
if match:
|
||||||
|
base_name = match.group(1)
|
||||||
|
start_num = int(match.group(2)) + 1
|
||||||
|
else:
|
||||||
|
base_name = name
|
||||||
|
start_num = 2
|
||||||
|
|
||||||
|
counter = start_num
|
||||||
|
while True:
|
||||||
|
new_name = f"{base_name}({counter}){ext}"
|
||||||
|
if new_name not in existing_names:
|
||||||
|
return new_name
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
class TagSerializer(serializers.ModelSerializer):
|
class TagSerializer(serializers.ModelSerializer):
|
||||||
@ -45,6 +71,21 @@ class CommentSerializer(serializers.ModelSerializer):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentSerializer(serializers.ModelSerializer):
|
||||||
|
"""첨부파일 시리얼라이저"""
|
||||||
|
file_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Attachment
|
||||||
|
fields = ['id', 'file', 'file_url', 'original_name', 'file_size', 'uploaded_at']
|
||||||
|
read_only_fields = ['original_name', 'file_size', 'uploaded_at', 'file_url']
|
||||||
|
|
||||||
|
def get_file_url(self, obj):
|
||||||
|
if obj.file:
|
||||||
|
return obj.file.url
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class PostSerializer(serializers.ModelSerializer):
|
class PostSerializer(serializers.ModelSerializer):
|
||||||
comment_count = serializers.SerializerMethodField()
|
comment_count = serializers.SerializerMethodField()
|
||||||
tags = TagSerializer(many=True, read_only=True)
|
tags = TagSerializer(many=True, read_only=True)
|
||||||
@ -53,6 +94,12 @@ class PostSerializer(serializers.ModelSerializer):
|
|||||||
write_only=True,
|
write_only=True,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
attachments = AttachmentSerializer(many=True, read_only=True)
|
||||||
|
attachment_ids = serializers.ListField(
|
||||||
|
child=serializers.IntegerField(),
|
||||||
|
write_only=True,
|
||||||
|
required=False
|
||||||
|
) # 임시 업로드된 파일 ID 목록
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Post
|
model = Post
|
||||||
@ -60,7 +107,8 @@ class PostSerializer(serializers.ModelSerializer):
|
|||||||
'id', 'title', 'content',
|
'id', 'title', 'content',
|
||||||
'author_id', 'author_name',
|
'author_id', 'author_name',
|
||||||
'created_at', 'updated_at',
|
'created_at', 'updated_at',
|
||||||
'comment_count', 'tags', 'tag_names'
|
'comment_count', 'tags', 'tag_names',
|
||||||
|
'attachments', 'attachment_ids'
|
||||||
]
|
]
|
||||||
read_only_fields = ['author_id', 'author_name', 'created_at', 'updated_at']
|
read_only_fields = ['author_id', 'author_name', 'created_at', 'updated_at']
|
||||||
|
|
||||||
@ -69,17 +117,22 @@ class PostSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
tag_names = validated_data.pop('tag_names', [])
|
tag_names = validated_data.pop('tag_names', [])
|
||||||
|
attachment_ids = validated_data.pop('attachment_ids', [])
|
||||||
post = Post.objects.create(**validated_data)
|
post = Post.objects.create(**validated_data)
|
||||||
self._set_tags(post, tag_names)
|
self._set_tags(post, tag_names)
|
||||||
|
self._link_attachments(post, attachment_ids)
|
||||||
return post
|
return post
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
tag_names = validated_data.pop('tag_names', None)
|
tag_names = validated_data.pop('tag_names', None)
|
||||||
|
attachment_ids = validated_data.pop('attachment_ids', None)
|
||||||
for attr, value in validated_data.items():
|
for attr, value in validated_data.items():
|
||||||
setattr(instance, attr, value)
|
setattr(instance, attr, value)
|
||||||
instance.save()
|
instance.save()
|
||||||
if tag_names is not None:
|
if tag_names is not None:
|
||||||
self._set_tags(instance, tag_names)
|
self._set_tags(instance, tag_names)
|
||||||
|
if attachment_ids is not None:
|
||||||
|
self._link_attachments(instance, attachment_ids)
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def _set_tags(self, post, tag_names):
|
def _set_tags(self, post, tag_names):
|
||||||
@ -92,19 +145,54 @@ class PostSerializer(serializers.ModelSerializer):
|
|||||||
tags.append(tag)
|
tags.append(tag)
|
||||||
post.tags.set(tags)
|
post.tags.set(tags)
|
||||||
|
|
||||||
|
def _link_attachments(self, post, attachment_ids):
|
||||||
|
"""임시 업로드된 첨부파일을 게시글에 연결 (중복 파일명 자동 번호 추가)"""
|
||||||
|
if not attachment_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 게시글의 기존 첨부파일명 목록
|
||||||
|
existing_names = set(post.attachments.values_list('original_name', flat=True))
|
||||||
|
|
||||||
|
# 임시 파일들을 가져와서 중복 체크 후 연결
|
||||||
|
temp_attachments = Attachment.objects.filter(
|
||||||
|
id__in=attachment_ids,
|
||||||
|
post__isnull=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for attachment in temp_attachments:
|
||||||
|
unique_name = get_unique_filename(attachment.original_name, existing_names)
|
||||||
|
attachment.original_name = unique_name
|
||||||
|
attachment.post = post
|
||||||
|
attachment.save()
|
||||||
|
existing_names.add(unique_name)
|
||||||
|
|
||||||
|
|
||||||
class PostListSerializer(serializers.ModelSerializer):
|
class PostListSerializer(serializers.ModelSerializer):
|
||||||
"""목록 조회용 간소화된 시리얼라이저"""
|
"""목록 조회용 간소화된 시리얼라이저"""
|
||||||
comment_count = serializers.SerializerMethodField()
|
comment_count = serializers.SerializerMethodField()
|
||||||
tags = TagSerializer(many=True, read_only=True)
|
tags = TagSerializer(many=True, read_only=True)
|
||||||
|
thumbnail = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Post
|
model = Post
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'title',
|
'id', 'title',
|
||||||
'author_name', 'created_at', 'updated_at',
|
'author_name', 'created_at', 'updated_at',
|
||||||
'comment_count', 'tags'
|
'comment_count', 'tags', 'thumbnail'
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_comment_count(self, obj):
|
def get_comment_count(self, obj):
|
||||||
return obj.comments.count()
|
return obj.comments.count()
|
||||||
|
|
||||||
|
def get_thumbnail(self, obj):
|
||||||
|
"""첫 번째 이미지 파일을 섬네일로 반환"""
|
||||||
|
image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||||
|
for attachment in obj.attachments.all():
|
||||||
|
original_name = attachment.original_name.lower()
|
||||||
|
if any(original_name.endswith(ext) for ext in image_extensions):
|
||||||
|
return {
|
||||||
|
'id': attachment.id,
|
||||||
|
'file_url': attachment.file.url if attachment.file else None,
|
||||||
|
'original_name': attachment.original_name
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|||||||
19
blog/urls.py
19
blog/urls.py
@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import PostListView, PostListCreateView, PostDetailView, CommentViewSet, TagListView
|
from .views import (
|
||||||
|
PostListView, PostListCreateView, PostDetailView,
|
||||||
|
CommentViewSet, TagListView,
|
||||||
|
AttachmentListCreateView, AttachmentDeleteView,
|
||||||
|
TempAttachmentUploadView, TempAttachmentDeleteView
|
||||||
|
)
|
||||||
|
|
||||||
# 댓글 라우터
|
# 댓글 라우터
|
||||||
comment_router = DefaultRouter()
|
comment_router = DefaultRouter()
|
||||||
@ -19,4 +24,16 @@ urlpatterns = [
|
|||||||
|
|
||||||
# 댓글 관련 (포스트 하위 리소스)
|
# 댓글 관련 (포스트 하위 리소스)
|
||||||
path('posts/<int:post_pk>/', include(comment_router.urls)),
|
path('posts/<int:post_pk>/', include(comment_router.urls)),
|
||||||
|
|
||||||
|
# 첨부파일 관련
|
||||||
|
path('posts/<int:post_pk>/attachments/',
|
||||||
|
AttachmentListCreateView.as_view(), name='attachment-list'),
|
||||||
|
path('posts/<int:post_pk>/attachments/<int:pk>/',
|
||||||
|
AttachmentDeleteView.as_view(), name='attachment-delete'),
|
||||||
|
|
||||||
|
# 임시 첨부파일 (게시글 작성 전 업로드)
|
||||||
|
path('attachments/upload/',
|
||||||
|
TempAttachmentUploadView.as_view(), name='temp-attachment-upload'),
|
||||||
|
path('attachments/<int:pk>/',
|
||||||
|
TempAttachmentDeleteView.as_view(), name='temp-attachment-delete'),
|
||||||
]
|
]
|
||||||
|
|||||||
203
blog/views.py
203
blog/views.py
@ -1,16 +1,49 @@
|
|||||||
# blog/views.py
|
# blog/views.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from rest_framework import generics, viewsets, permissions, status
|
from rest_framework import generics, viewsets, permissions, status
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from .models import Post, Comment, Tag
|
from .models import Post, Comment, Tag, Attachment
|
||||||
from .serializers import PostSerializer, PostListSerializer, CommentSerializer, TagSerializer
|
from .serializers import PostSerializer, PostListSerializer, CommentSerializer, TagSerializer, AttachmentSerializer
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_unique_filename(original_name, existing_names):
|
||||||
|
"""
|
||||||
|
중복 파일명이 있으면 (2), (3) 등의 번호를 추가하여 고유한 파일명 반환
|
||||||
|
예: test.png -> test(2).png -> test(3).png
|
||||||
|
"""
|
||||||
|
if original_name not in existing_names:
|
||||||
|
return original_name
|
||||||
|
|
||||||
|
# 파일명과 확장자 분리
|
||||||
|
name, ext = os.path.splitext(original_name)
|
||||||
|
|
||||||
|
# 기존 번호 패턴 확인 (예: test(2) -> test, 2)
|
||||||
|
match = re.match(r'^(.+)\((\d+)\)$', name)
|
||||||
|
if match:
|
||||||
|
base_name = match.group(1)
|
||||||
|
start_num = int(match.group(2)) + 1
|
||||||
|
else:
|
||||||
|
base_name = name
|
||||||
|
start_num = 2
|
||||||
|
|
||||||
|
# 고유한 번호 찾기
|
||||||
|
counter = start_num
|
||||||
|
while True:
|
||||||
|
new_name = f"{base_name}({counter}){ext}"
|
||||||
|
if new_name not in existing_names:
|
||||||
|
return new_name
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
class TagListView(generics.ListAPIView):
|
class TagListView(generics.ListAPIView):
|
||||||
"""태그 목록 조회"""
|
"""태그 목록 조회"""
|
||||||
queryset = Tag.objects.all()
|
queryset = Tag.objects.all()
|
||||||
@ -173,3 +206,169 @@ class CommentViewSet(viewsets.ModelViewSet):
|
|||||||
comment_id = instance.id
|
comment_id = instance.id
|
||||||
instance.delete()
|
instance.delete()
|
||||||
logger.info(f"Comment {comment_id} deleted by {username}.")
|
logger.info(f"Comment {comment_id} deleted by {username}.")
|
||||||
|
|
||||||
|
|
||||||
|
class TempAttachmentUploadView(APIView):
|
||||||
|
"""임시 첨부파일 업로드 (게시글 작성 전)"""
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
"""임시 파일 업로드 - 게시글 없이 파일만 먼저 업로드"""
|
||||||
|
user = request.user
|
||||||
|
user_id = str(getattr(user, 'id', '') or getattr(user, 'email', ''))
|
||||||
|
username = getattr(user, 'username', '') or getattr(user, 'email', '')
|
||||||
|
|
||||||
|
file = request.FILES.get('file')
|
||||||
|
if not file:
|
||||||
|
return Response(
|
||||||
|
{'detail': '파일이 필요합니다.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# 파일 크기 제한 (10MB)
|
||||||
|
if file.size > 10 * 1024 * 1024:
|
||||||
|
return Response(
|
||||||
|
{'detail': '파일 크기는 10MB를 초과할 수 없습니다.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# batch_id로 같은 배치 내 중복 파일명 체크
|
||||||
|
batch_id = request.data.get('batch_id', '')
|
||||||
|
original_name = file.name
|
||||||
|
|
||||||
|
if batch_id:
|
||||||
|
# 같은 배치 내 기존 파일명 목록
|
||||||
|
existing_names = list(
|
||||||
|
Attachment.objects.filter(
|
||||||
|
post__isnull=True,
|
||||||
|
batch_id=batch_id
|
||||||
|
).values_list('original_name', flat=True)
|
||||||
|
)
|
||||||
|
original_name = get_unique_filename(file.name, existing_names)
|
||||||
|
|
||||||
|
# MinIO에 파일 저장 (post=None으로 임시 저장)
|
||||||
|
attachment = Attachment.objects.create(
|
||||||
|
post=None,
|
||||||
|
file=file,
|
||||||
|
original_name=original_name,
|
||||||
|
file_size=file.size,
|
||||||
|
uploader_id=user_id,
|
||||||
|
uploader_name=username,
|
||||||
|
batch_id=batch_id
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = AttachmentSerializer(attachment)
|
||||||
|
logger.info(f"Temp attachment '{original_name}' uploaded by {username}. batch_id={batch_id}")
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
class TempAttachmentDeleteView(APIView):
|
||||||
|
"""임시 첨부파일 삭제"""
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def delete(self, request, pk):
|
||||||
|
attachment = get_object_or_404(Attachment, pk=pk, post__isnull=True)
|
||||||
|
|
||||||
|
# 업로더만 삭제 가능
|
||||||
|
user = request.user
|
||||||
|
user_id = str(getattr(user, 'id', '') or getattr(user, 'email', ''))
|
||||||
|
username = getattr(user, 'username', '') or getattr(user, 'email', '')
|
||||||
|
|
||||||
|
if attachment.uploader_id != user_id and attachment.uploader_name != username:
|
||||||
|
return Response(
|
||||||
|
{'detail': '삭제 권한이 없습니다.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
original_name = attachment.original_name
|
||||||
|
attachment.file.delete(save=False)
|
||||||
|
attachment.delete()
|
||||||
|
|
||||||
|
logger.info(f"Temp attachment '{original_name}' deleted by {username}.")
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentListCreateView(APIView):
|
||||||
|
"""첨부파일 목록/업로드"""
|
||||||
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|
||||||
|
def get_post(self, post_pk):
|
||||||
|
return get_object_or_404(Post, pk=post_pk)
|
||||||
|
|
||||||
|
def get(self, request, post_pk):
|
||||||
|
"""첨부파일 목록 조회"""
|
||||||
|
post = self.get_post(post_pk)
|
||||||
|
attachments = post.attachments.all()
|
||||||
|
serializer = AttachmentSerializer(attachments, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def post(self, request, post_pk):
|
||||||
|
"""첨부파일 업로드"""
|
||||||
|
post = self.get_post(post_pk)
|
||||||
|
|
||||||
|
# 작성자만 첨부파일 추가 가능
|
||||||
|
user = request.user
|
||||||
|
user_id = str(getattr(user, 'id', '') or getattr(user, 'email', ''))
|
||||||
|
username = getattr(user, 'username', '') or getattr(user, 'email', '')
|
||||||
|
|
||||||
|
if post.author_id != user_id and post.author_name != username:
|
||||||
|
return Response(
|
||||||
|
{'detail': '첨부파일을 추가할 권한이 없습니다.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
file = request.FILES.get('file')
|
||||||
|
if not file:
|
||||||
|
return Response(
|
||||||
|
{'detail': '파일이 필요합니다.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# 같은 게시글 내 중복 파일명 체크
|
||||||
|
existing_names = list(
|
||||||
|
post.attachments.values_list('original_name', flat=True)
|
||||||
|
)
|
||||||
|
unique_name = get_unique_filename(file.name, existing_names)
|
||||||
|
|
||||||
|
# MinIO에 파일 저장
|
||||||
|
attachment = Attachment.objects.create(
|
||||||
|
post=post,
|
||||||
|
file=file,
|
||||||
|
original_name=unique_name,
|
||||||
|
file_size=file.size,
|
||||||
|
uploader_id=user_id,
|
||||||
|
uploader_name=username
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = AttachmentSerializer(attachment)
|
||||||
|
logger.info(f"Attachment '{unique_name}' uploaded to post {post_pk} by {username}.")
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentDeleteView(APIView):
|
||||||
|
"""첨부파일 삭제"""
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def delete(self, request, post_pk, pk):
|
||||||
|
attachment = get_object_or_404(Attachment, pk=pk, post_id=post_pk)
|
||||||
|
|
||||||
|
# 작성자만 삭제 가능
|
||||||
|
user = request.user
|
||||||
|
user_id = str(getattr(user, 'id', '') or getattr(user, 'email', ''))
|
||||||
|
username = getattr(user, 'username', '') or getattr(user, 'email', '')
|
||||||
|
|
||||||
|
if attachment.post.author_id != user_id and attachment.post.author_name != username:
|
||||||
|
return Response(
|
||||||
|
{'detail': '삭제 권한이 없습니다.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# MinIO에서 파일 삭제
|
||||||
|
original_name = attachment.original_name
|
||||||
|
attachment.file.delete(save=False)
|
||||||
|
attachment.delete()
|
||||||
|
|
||||||
|
logger.info(f"Attachment '{original_name}' deleted from post {post_pk} by {username}.")
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -111,6 +111,7 @@ INSTALLED_APPS = [
|
|||||||
'rest_framework_simplejwt',
|
'rest_framework_simplejwt',
|
||||||
'drf_yasg',
|
'drf_yasg',
|
||||||
'corsheaders',
|
'corsheaders',
|
||||||
|
'storages', # MinIO/S3 스토리지
|
||||||
# create by.sdjo 2025-04-22
|
# create by.sdjo 2025-04-22
|
||||||
'blog', # 2025-04-22 custom app create
|
'blog', # 2025-04-22 custom app create
|
||||||
]
|
]
|
||||||
@ -253,3 +254,29 @@ STATIC_URL = 'static/'
|
|||||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# MinIO (S3 호환) 스토리지 설정
|
||||||
|
MINIO_ENDPOINT = os.environ.get('MINIO_ENDPOINT', 'minio.icurfer.com:9000')
|
||||||
|
MINIO_ACCESS_KEY = os.environ.get('MINIO_ACCESS_KEY', '')
|
||||||
|
MINIO_SECRET_KEY = os.environ.get('MINIO_SECRET_KEY', '')
|
||||||
|
MINIO_BUCKET = os.environ.get('MINIO_BUCKET', 'icurfer.com-posts')
|
||||||
|
MINIO_USE_SSL = os.environ.get('MINIO_USE_SSL', '1') == '1'
|
||||||
|
|
||||||
|
# django-storages S3 설정 (MinIO 호환)
|
||||||
|
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID = MINIO_ACCESS_KEY
|
||||||
|
AWS_SECRET_ACCESS_KEY = MINIO_SECRET_KEY
|
||||||
|
AWS_STORAGE_BUCKET_NAME = MINIO_BUCKET
|
||||||
|
AWS_S3_ENDPOINT_URL = f"{'https' if MINIO_USE_SSL else 'http'}://{MINIO_ENDPOINT}"
|
||||||
|
AWS_S3_USE_SSL = MINIO_USE_SSL
|
||||||
|
AWS_S3_VERIFY = MINIO_USE_SSL
|
||||||
|
AWS_DEFAULT_ACL = None
|
||||||
|
AWS_S3_FILE_OVERWRITE = False
|
||||||
|
AWS_QUERYSTRING_AUTH = True
|
||||||
|
AWS_S3_SIGNATURE_VERSION = 's3v4'
|
||||||
|
AWS_S3_REGION_NAME = os.environ.get('MINIO_REGION_NAME', 'ap-northeast-2')
|
||||||
|
|
||||||
|
# 최대 업로드 파일 크기 (10MB)
|
||||||
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
|
||||||
|
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
asgiref==3.8.1
|
asgiref==3.8.1
|
||||||
|
boto3==1.42.30
|
||||||
|
botocore==1.42.30
|
||||||
certifi==2025.1.31
|
certifi==2025.1.31
|
||||||
cffi==2.0.0
|
cffi==2.0.0
|
||||||
charset-normalizer==3.4.1
|
charset-normalizer==3.4.1
|
||||||
@ -7,6 +9,7 @@ coreschema==0.0.4
|
|||||||
cryptography==46.0.1
|
cryptography==46.0.1
|
||||||
Django==4.2.14
|
Django==4.2.14
|
||||||
django-cors-headers==4.7.0
|
django-cors-headers==4.7.0
|
||||||
|
django-storages==1.14.6
|
||||||
djangorestframework==3.16.0
|
djangorestframework==3.16.0
|
||||||
djangorestframework_simplejwt==5.5.0
|
djangorestframework_simplejwt==5.5.0
|
||||||
drf-yasg==1.21.10
|
drf-yasg==1.21.10
|
||||||
@ -15,15 +18,19 @@ idna==3.10
|
|||||||
inflection==0.5.1
|
inflection==0.5.1
|
||||||
itypes==1.2.0
|
itypes==1.2.0
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
|
jmespath==1.0.1
|
||||||
MarkupSafe==3.0.2
|
MarkupSafe==3.0.2
|
||||||
mysqlclient==2.2.7
|
mysqlclient==2.2.7
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
pycparser==2.23
|
pycparser==2.23
|
||||||
PyJWT==2.9.0
|
PyJWT==2.9.0
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
pytz==2025.2
|
pytz==2025.2
|
||||||
PyYAML==6.0.2
|
PyYAML==6.0.2
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
|
s3transfer==0.16.0
|
||||||
|
six==1.17.0
|
||||||
sqlparse==0.5.3
|
sqlparse==0.5.3
|
||||||
typing_extensions==4.13.2
|
typing_extensions==4.13.2
|
||||||
uritemplate==4.1.1
|
uritemplate==4.1.1
|
||||||
|
|||||||
Reference in New Issue
Block a user