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

@ -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')),
],
),
]

View File

@ -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'),
),
]

View 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),
),
]

View File

@ -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)

View File

@ -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

View File

@ -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'),
] ]

View File

@ -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)

View File

@ -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

View File

@ -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