Files
msa-django-blog/blog/views.py
icurfer 63c126077a
Some checks failed
Build And Test / build-and-push (push) Has been cancelled
feat: Presigned URL API 및 OpenTelemetry trace 추가
- Presigned URL API 추가 (MinIO/S3 private 버킷 지원)
- OpenTelemetry trace 설정 추가 (DEBUG=False 시 활성화)
- requirements.txt에 opentelemetry 패키지 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 02:08:21 +09:00

413 lines
15 KiB
Python

# blog/views.py
import os
import re
import boto3
from botocore.client import Config
from django.conf import settings
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, 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()
serializer_class = TagSerializer
permission_classes = [permissions.AllowAny]
def list(self, request, *args, **kwargs):
response = super().list(request, *args, **kwargs)
logger.info(f"Tags list requested. Count: {len(response.data)}")
return response
class PostListView(generics.ListAPIView):
"""공개 포스트 목록 조회"""
queryset = Post.objects.all()
serializer_class = PostListSerializer
permission_classes = [permissions.AllowAny]
def get_queryset(self):
queryset = Post.objects.all()
tag = self.request.query_params.get('tag')
if tag:
queryset = queryset.filter(tags__name=tag)
return queryset
def list(self, request, *args, **kwargs):
response = super().list(request, *args, **kwargs)
tag = request.query_params.get('tag', 'all')
logger.info(f"Posts list requested. Tag: {tag}, Count: {len(response.data)}")
return response
class PostListCreateView(generics.ListCreateAPIView):
"""인증 사용자용 포스트 목록/생성"""
queryset = Post.objects.all()
serializer_class = PostSerializer
permission_classes = [permissions.IsAuthenticated]
def perform_create(self, serializer):
user = self.request.user
author_id = getattr(user, 'id', '') or getattr(user, 'email', '')
author_name = getattr(user, 'username', '') or getattr(user, 'email', '')
instance = serializer.save(author_id=str(author_id), author_name=author_name)
logger.info(f"Post titled '{instance.title}' has been created by {author_name}.")
class PostDetailView(generics.RetrieveUpdateDestroyAPIView):
"""포스트 상세 조회/수정/삭제"""
queryset = Post.objects.all()
serializer_class = PostSerializer
def get_permissions(self):
if self.request.method in ["PUT", "PATCH", "DELETE"]:
return [permissions.IsAuthenticated()]
return [permissions.AllowAny()]
def retrieve(self, request, *args, **kwargs):
response = super().retrieve(request, *args, **kwargs)
logger.info(f"Post detail requested. ID: {kwargs.get('pk')}, Title: {response.data.get('title')}")
return response
def perform_update(self, serializer):
instance = serializer.instance
user = self.request.user
user_id = str(getattr(user, 'id', '') or getattr(user, 'email', ''))
username = getattr(user, 'username', '') or getattr(user, 'email', '')
# 작성자 확인
if instance.author_id != user_id and instance.author_name != username:
raise PermissionDenied("작성자만 수정할 수 있습니다.")
serializer.save()
logger.info(f"Post titled '{instance.title}' has been updated by {username}.")
def perform_destroy(self, instance):
user = self.request.user
user_id = str(getattr(user, 'id', '') or getattr(user, 'email', ''))
username = getattr(user, 'username', '') or getattr(user, 'email', '')
# 작성자 확인
if instance.author_id != user_id and instance.author_name != username:
raise PermissionDenied("작성자만 삭제할 수 있습니다.")
title = instance.title
instance.delete()
logger.info(f"Post titled '{title}' has been deleted by {username}.")
class CommentViewSet(viewsets.ModelViewSet):
"""댓글/대댓글 CRUD ViewSet"""
serializer_class = CommentSerializer
def get_permissions(self):
if self.action in ['list', 'retrieve']:
return [permissions.AllowAny()]
return [permissions.IsAuthenticated()]
def get_post(self):
"""현재 포스트 가져오기"""
post_pk = self.kwargs.get('post_pk')
return get_object_or_404(Post, pk=post_pk)
def get_queryset(self):
post_pk = self.kwargs.get('post_pk')
if post_pk:
# list 액션에서만 최상위 댓글 반환, 나머지는 모든 댓글 접근 가능
if self.action == 'list':
return Comment.objects.filter(post_id=post_pk, parent__isnull=True)
return Comment.objects.filter(post_id=post_pk)
return Comment.objects.none()
def list(self, request, *args, **kwargs):
response = super().list(request, *args, **kwargs)
post_pk = kwargs.get('post_pk')
logger.info(f"Comments list requested. Post ID: {post_pk}, Count: {len(response.data)}")
return response
def perform_create(self, serializer):
post = self.get_post()
user = self.request.user
author_id = str(getattr(user, 'id', '') or getattr(user, 'email', ''))
author_name = getattr(user, 'username', '') or getattr(user, 'email', '')
# parent 검증 (대댓글인 경우)
parent_id = self.request.data.get('parent')
parent = None
if parent_id:
parent = get_object_or_404(Comment, pk=parent_id, post=post)
instance = serializer.save(
post=post,
parent=parent,
author_id=author_id,
author_name=author_name
)
logger.info(f"Comment created on post '{post.title}' by {author_name}.")
def perform_update(self, serializer):
instance = serializer.instance
user = self.request.user
user_id = str(getattr(user, 'id', '') or getattr(user, 'email', ''))
username = getattr(user, 'username', '') or getattr(user, 'email', '')
# 작성자 확인
if instance.author_id != user_id and instance.author_name != username:
raise PermissionDenied("작성자만 수정할 수 있습니다.")
serializer.save()
logger.info(f"Comment {instance.id} updated by {username}.")
def perform_destroy(self, instance):
user = self.request.user
user_id = str(getattr(user, 'id', '') or getattr(user, 'email', ''))
username = getattr(user, 'username', '') or getattr(user, 'email', '')
# 작성자 확인
if instance.author_id != user_id and instance.author_name != username:
raise PermissionDenied("작성자만 삭제할 수 있습니다.")
comment_id = instance.id
instance.delete()
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)
class PresignedUrlView(APIView):
"""Presigned URL 생성 (MinIO/S3)"""
permission_classes = [permissions.AllowAny]
def get_s3_client(self):
"""S3 클라이언트 생성"""
return boto3.client(
's3',
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version='s3v4'),
region_name=settings.AWS_S3_REGION_NAME,
)
def get(self, request, object_key):
"""GET presigned URL 생성"""
try:
s3_client = self.get_s3_client()
presigned_url = s3_client.generate_presigned_url(
'get_object',
Params={
'Bucket': settings.AWS_STORAGE_BUCKET_NAME,
'Key': object_key,
},
ExpiresIn=3600, # 1시간 유효
)
return Response({'url': presigned_url})
except Exception as e:
logger.error(f"Presigned URL 생성 실패: {e}")
return Response(
{'detail': 'Presigned URL 생성 실패'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)