feat: Attachment 모델 추가 및 관련 기능 구현
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:
2026-01-20 23:39:05 +09:00
parent 362412f0c9
commit dfecaa7654
9 changed files with 447 additions and 7 deletions

View File

@ -1,16 +1,49 @@
# blog/views.py
import os
import re
from rest_framework import generics, viewsets, permissions, status
from rest_framework.exceptions import PermissionDenied
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 .models import Post, Comment, Tag
from .serializers import PostSerializer, PostListSerializer, CommentSerializer, TagSerializer
from .models import Post, Comment, Tag, Attachment
from .serializers import PostSerializer, PostListSerializer, CommentSerializer, TagSerializer, AttachmentSerializer
import logging
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):
"""태그 목록 조회"""
queryset = Tag.objects.all()
@ -172,4 +205,170 @@ class CommentViewSet(viewsets.ModelViewSet):
comment_id = instance.id
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)