- Post 모델 개선 (author_id, updated_at, view_count 등) - Tag 모델 및 태그 기능 추가 - 댓글 기능 추가 - API 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 4.2.14 on 2026-01-14 12:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('blog', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='post',
|
||||||
|
options={'ordering': ['-created_at']},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='post',
|
||||||
|
name='author_id',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=150),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='post',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Comment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('content', models.TextField()),
|
||||||
|
('author_id', models.CharField(blank=True, default='', max_length=150)),
|
||||||
|
('author_name', models.CharField(max_length=150)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='blog.comment')),
|
||||||
|
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='blog.post')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
29
blog/migrations/0003_tag_post_tags.py
Normal file
29
blog/migrations/0003_tag_post_tags.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 4.2.14 on 2026-01-14 13:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('blog', '0002_alter_post_options_post_author_id_post_updated_at_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Tag',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=50, unique=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='post',
|
||||||
|
name='tags',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='posts', to='blog.tag'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -2,11 +2,57 @@ from django.db import models
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(models.Model):
|
||||||
|
"""태그 모델"""
|
||||||
|
name = models.CharField(max_length=50, unique=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Post(models.Model):
|
class Post(models.Model):
|
||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
content = models.TextField()
|
content = models.TextField()
|
||||||
|
tags = models.ManyToManyField(Tag, related_name='posts', blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
author_id = models.CharField(max_length=150, blank=True, default='')
|
||||||
author_name = models.CharField(max_length=150)
|
author_name = models.CharField(max_length=150)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(models.Model):
|
||||||
|
"""댓글/대댓글 모델"""
|
||||||
|
post = models.ForeignKey(
|
||||||
|
Post,
|
||||||
|
related_name="comments",
|
||||||
|
on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
'self',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="replies",
|
||||||
|
on_delete=models.CASCADE
|
||||||
|
) # 대댓글 지원
|
||||||
|
content = models.TextField()
|
||||||
|
author_id = models.CharField(max_length=150, blank=True, default='')
|
||||||
|
author_name = models.CharField(max_length=150)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Comment by {self.author_name} on {self.post.title}"
|
||||||
|
|||||||
@ -1,10 +1,110 @@
|
|||||||
# blog/serializers.py
|
# blog/serializers.py
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Post
|
from .models import Post, Comment, Tag
|
||||||
|
|
||||||
|
|
||||||
|
class TagSerializer(serializers.ModelSerializer):
|
||||||
|
"""태그 시리얼라이저"""
|
||||||
|
post_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Tag
|
||||||
|
fields = ['id', 'name', 'post_count']
|
||||||
|
read_only_fields = ['id']
|
||||||
|
|
||||||
|
def get_post_count(self, obj):
|
||||||
|
return obj.posts.count()
|
||||||
|
|
||||||
|
|
||||||
|
class CommentSerializer(serializers.ModelSerializer):
|
||||||
|
"""댓글/대댓글 시리얼라이저"""
|
||||||
|
replies = serializers.SerializerMethodField()
|
||||||
|
reply_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Comment
|
||||||
|
fields = [
|
||||||
|
'id', 'post', 'parent', 'content',
|
||||||
|
'author_id', 'author_name',
|
||||||
|
'created_at', 'updated_at',
|
||||||
|
'reply_count', 'replies'
|
||||||
|
]
|
||||||
|
read_only_fields = ['author_id', 'author_name', 'post', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_replies(self, obj):
|
||||||
|
# 최상위 댓글의 대댓글만 반환
|
||||||
|
if obj.parent is None:
|
||||||
|
replies = obj.replies.all()
|
||||||
|
return CommentSerializer(replies, many=True, context=self.context).data
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_reply_count(self, obj):
|
||||||
|
if obj.parent is None:
|
||||||
|
return obj.replies.count()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
class PostSerializer(serializers.ModelSerializer):
|
class PostSerializer(serializers.ModelSerializer):
|
||||||
|
comment_count = serializers.SerializerMethodField()
|
||||||
|
tags = TagSerializer(many=True, read_only=True)
|
||||||
|
tag_names = serializers.ListField(
|
||||||
|
child=serializers.CharField(max_length=50),
|
||||||
|
write_only=True,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Post
|
model = Post
|
||||||
fields = ['id', 'title', 'content', 'author_name', 'created_at']
|
fields = [
|
||||||
read_only_fields = ['author_name', 'created_at']
|
'id', 'title', 'content',
|
||||||
|
'author_id', 'author_name',
|
||||||
|
'created_at', 'updated_at',
|
||||||
|
'comment_count', 'tags', 'tag_names'
|
||||||
|
]
|
||||||
|
read_only_fields = ['author_id', 'author_name', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_comment_count(self, obj):
|
||||||
|
return obj.comments.count()
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
tag_names = validated_data.pop('tag_names', [])
|
||||||
|
post = Post.objects.create(**validated_data)
|
||||||
|
self._set_tags(post, tag_names)
|
||||||
|
return post
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
tag_names = validated_data.pop('tag_names', None)
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
instance.save()
|
||||||
|
if tag_names is not None:
|
||||||
|
self._set_tags(instance, tag_names)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def _set_tags(self, post, tag_names):
|
||||||
|
"""태그 설정 (없으면 생성)"""
|
||||||
|
tags = []
|
||||||
|
for name in tag_names:
|
||||||
|
name = name.strip().lower()
|
||||||
|
if name:
|
||||||
|
tag, _ = Tag.objects.get_or_create(name=name)
|
||||||
|
tags.append(tag)
|
||||||
|
post.tags.set(tags)
|
||||||
|
|
||||||
|
|
||||||
|
class PostListSerializer(serializers.ModelSerializer):
|
||||||
|
"""목록 조회용 간소화된 시리얼라이저"""
|
||||||
|
comment_count = serializers.SerializerMethodField()
|
||||||
|
tags = TagSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Post
|
||||||
|
fields = [
|
||||||
|
'id', 'title',
|
||||||
|
'author_name', 'created_at', 'updated_at',
|
||||||
|
'comment_count', 'tags'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_comment_count(self, obj):
|
||||||
|
return obj.comments.count()
|
||||||
|
|||||||
16
blog/urls.py
16
blog/urls.py
@ -1,10 +1,22 @@
|
|||||||
# blog/urls.py
|
# blog/urls.py
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import path, include
|
||||||
from .views import PostListView, PostListCreateView, PostDetailView
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import PostListView, PostListCreateView, PostDetailView, CommentViewSet, TagListView
|
||||||
|
|
||||||
|
# 댓글 라우터
|
||||||
|
comment_router = DefaultRouter()
|
||||||
|
comment_router.register(r'comments', CommentViewSet, basename='comment')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# 태그 관련
|
||||||
|
path('tags/', TagListView.as_view(), name='tag-list'),
|
||||||
|
|
||||||
|
# 포스트 관련
|
||||||
path('posts/', PostListView.as_view(), name='post-list'),
|
path('posts/', PostListView.as_view(), name='post-list'),
|
||||||
path('create/', PostListCreateView.as_view(), name='post-list-create'),
|
path('create/', PostListCreateView.as_view(), name='post-list-create'),
|
||||||
path('posts/<int:pk>/', PostDetailView.as_view(), name='post-detail'),
|
path('posts/<int:pk>/', PostDetailView.as_view(), name='post-detail'),
|
||||||
|
|
||||||
|
# 댓글 관련 (포스트 하위 리소스)
|
||||||
|
path('posts/<int:post_pk>/', include(comment_router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
152
blog/views.py
152
blog/views.py
@ -1,40 +1,53 @@
|
|||||||
# blog/views.py
|
# blog/views.py
|
||||||
|
|
||||||
from rest_framework import generics, permissions
|
from rest_framework import generics, viewsets, permissions, status
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from .models import Post
|
from rest_framework.response import Response
|
||||||
from .serializers import PostSerializer
|
from django.shortcuts import get_object_or_404
|
||||||
# from .utils import verify_token_with_auth_server
|
from .models import Post, Comment, Tag
|
||||||
import logging # 2025-04-29
|
from .serializers import PostSerializer, PostListSerializer, CommentSerializer, TagSerializer
|
||||||
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__) # 2025-04-29
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class PostListView(generics.ListAPIView):
|
|
||||||
queryset = Post.objects.all().order_by('-created_at')
|
class TagListView(generics.ListAPIView):
|
||||||
serializer_class = PostSerializer
|
"""태그 목록 조회"""
|
||||||
|
queryset = Tag.objects.all()
|
||||||
|
serializer_class = TagSerializer
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
class PostListCreateView(generics.ListCreateAPIView):
|
class PostListCreateView(generics.ListCreateAPIView):
|
||||||
queryset = Post.objects.all().order_by('-created_at')
|
"""인증 사용자용 포스트 목록/생성"""
|
||||||
|
queryset = Post.objects.all()
|
||||||
serializer_class = PostSerializer
|
serializer_class = PostSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
# token = self.request.headers.get("Authorization", "").replace("Bearer ", "")
|
user = self.request.user
|
||||||
# verify_token_with_auth_server(token)
|
author_id = getattr(user, 'id', '') or getattr(user, 'email', '')
|
||||||
# verify_result = verify_token_with_auth_server(token)
|
author_name = getattr(user, 'username', '') or getattr(user, 'email', '')
|
||||||
# 2025-04-14 로그 등록 (콘솔+FluentBit용)
|
instance = serializer.save(author_id=str(author_id), author_name=author_name)
|
||||||
# if verify_result == None:
|
logger.info(f"Post titled '{instance.title}' has been created by {author_name}.")
|
||||||
# logger.info(f"Token verified")
|
|
||||||
# else:
|
|
||||||
# logger.info(f"Token error")
|
|
||||||
|
|
||||||
serializer.save(author_name=self.request.user.username)
|
|
||||||
post_title=serializer.save(author_name=self.request.user.username)
|
|
||||||
logger.info(f"Post titled '{post_title}' has been created.")
|
|
||||||
|
|
||||||
# ✅ 조회, 수정, 삭제 전부 처리
|
|
||||||
class PostDetailView(generics.RetrieveUpdateDestroyAPIView):
|
class PostDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
"""포스트 상세 조회/수정/삭제"""
|
||||||
queryset = Post.objects.all()
|
queryset = Post.objects.all()
|
||||||
serializer_class = PostSerializer
|
serializer_class = PostSerializer
|
||||||
|
|
||||||
@ -44,20 +57,97 @@ class PostDetailView(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
return [permissions.AllowAny()]
|
return [permissions.AllowAny()]
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
# token = self.request.headers.get("Authorization", "").replace("Bearer ", "")
|
instance = serializer.instance
|
||||||
# verify_token_with_auth_server(token)
|
user = self.request.user
|
||||||
|
user_id = str(getattr(user, 'id', '') or getattr(user, 'email', ''))
|
||||||
|
username = getattr(user, 'username', '') or getattr(user, 'email', '')
|
||||||
|
|
||||||
if serializer.instance.author_name != self.request.user.username:
|
# 작성자 확인
|
||||||
|
if instance.author_id != user_id and instance.author_name != username:
|
||||||
raise PermissionDenied("작성자만 수정할 수 있습니다.")
|
raise PermissionDenied("작성자만 수정할 수 있습니다.")
|
||||||
|
|
||||||
serializer.save()
|
serializer.save()
|
||||||
post_title=serializer.save(author_name=self.request.user.username)
|
logger.info(f"Post titled '{instance.title}' has been updated by {username}.")
|
||||||
logger.info(f"Post titled '{post_title}' has been updated.")
|
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
# token = self.request.headers.get("Authorization", "").replace("Bearer ", "")
|
user = self.request.user
|
||||||
# verify_token_with_auth_server(token)
|
user_id = str(getattr(user, 'id', '') or getattr(user, 'email', ''))
|
||||||
|
username = getattr(user, 'username', '') or getattr(user, 'email', '')
|
||||||
|
|
||||||
if instance.author_name != self.request.user.username:
|
# 작성자 확인
|
||||||
|
if instance.author_id != user_id and instance.author_name != username:
|
||||||
raise PermissionDenied("작성자만 삭제할 수 있습니다.")
|
raise PermissionDenied("작성자만 삭제할 수 있습니다.")
|
||||||
|
|
||||||
|
title = instance.title
|
||||||
instance.delete()
|
instance.delete()
|
||||||
logger.info(f"Post titled '{instance}' has been deleted.")
|
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 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}.")
|
||||||
Reference in New Issue
Block a user