# 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 django.db.models import Q 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): user = self.request.user if user.is_authenticated: # 로그인 사용자: 공개 게시글 + 자신의 비공개 게시글 user_id = str(getattr(user, 'id', '') or getattr(user, 'email', '')) username = getattr(user, 'username', '') or getattr(user, 'email', '') # 자신의 비공개 게시글 조건 (빈 문자열은 제외) private_conditions = Q() if user_id: private_conditions |= Q(is_private=True, author_id=user_id) if username: private_conditions |= Q(is_private=True, author_name=username) queryset = Post.objects.filter(Q(is_private=False) | private_conditions) else: # 비로그인 사용자: 공개 게시글만 queryset = Post.objects.filter(is_private=False) 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): instance = self.get_object() # 비공개 게시글은 작성자만 조회 가능 if instance.is_private: user = request.user if not user.is_authenticated: raise PermissionDenied("비공개 게시글입니다.") 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 = self.get_serializer(instance) logger.info(f"Post detail requested. ID: {kwargs.get('pk')}, Title: {serializer.data.get('title')}") return Response(serializer.data) 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 )