diff --git a/blog/migrations/0002_alter_post_options_post_author_id_post_updated_at_and_more.py b/blog/migrations/0002_alter_post_options_post_author_id_post_updated_at_and_more.py new file mode 100644 index 0000000..55a9a76 --- /dev/null +++ b/blog/migrations/0002_alter_post_options_post_author_id_post_updated_at_and_more.py @@ -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'], + }, + ), + ] diff --git a/blog/migrations/0003_tag_post_tags.py b/blog/migrations/0003_tag_post_tags.py new file mode 100644 index 0000000..831f104 --- /dev/null +++ b/blog/migrations/0003_tag_post_tags.py @@ -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'), + ), + ] diff --git a/blog/models.py b/blog/models.py index d261a80..659c4ab 100644 --- a/blog/models.py +++ b/blog/models.py @@ -1,12 +1,58 @@ from django.db import models 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): title = models.CharField(max_length=255) content = models.TextField() + tags = models.ManyToManyField(Tag, related_name='posts', blank=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) + class Meta: + ordering = ['-created_at'] + def __str__(self): 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}" diff --git a/blog/serializers.py b/blog/serializers.py index 18c88f8..6e58bc3 100644 --- a/blog/serializers.py +++ b/blog/serializers.py @@ -1,10 +1,110 @@ # blog/serializers.py 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): + 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: model = Post - fields = ['id', 'title', 'content', 'author_name', 'created_at'] - read_only_fields = ['author_name', 'created_at'] + fields = [ + '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() diff --git a/blog/urls.py b/blog/urls.py index f00fcc6..386f1b7 100644 --- a/blog/urls.py +++ b/blog/urls.py @@ -1,10 +1,22 @@ # blog/urls.py -from django.urls import path -from .views import PostListView, PostListCreateView, PostDetailView +from django.urls import path, include +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 = [ + # 태그 관련 + path('tags/', TagListView.as_view(), name='tag-list'), + + # 포스트 관련 path('posts/', PostListView.as_view(), name='post-list'), path('create/', PostListCreateView.as_view(), name='post-list-create'), path('posts//', PostDetailView.as_view(), name='post-detail'), + + # 댓글 관련 (포스트 하위 리소스) + path('posts//', include(comment_router.urls)), ] diff --git a/blog/views.py b/blog/views.py index 896543d..4055f4d 100644 --- a/blog/views.py +++ b/blog/views.py @@ -1,40 +1,53 @@ # blog/views.py -from rest_framework import generics, permissions +from rest_framework import generics, viewsets, permissions, status from rest_framework.exceptions import PermissionDenied -from .models import Post -from .serializers import PostSerializer -# from .utils import verify_token_with_auth_server -import logging # 2025-04-29 +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from .models import Post, Comment, Tag +from .serializers import PostSerializer, PostListSerializer, CommentSerializer, TagSerializer +import logging + +logger = logging.getLogger(__name__) + + +class TagListView(generics.ListAPIView): + """태그 목록 조회""" + queryset = Tag.objects.all() + serializer_class = TagSerializer + permission_classes = [permissions.AllowAny] -logger = logging.getLogger(__name__) # 2025-04-29 class PostListView(generics.ListAPIView): - queryset = Post.objects.all().order_by('-created_at') - serializer_class = PostSerializer + """공개 포스트 목록 조회""" + 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): - queryset = Post.objects.all().order_by('-created_at') + """인증 사용자용 포스트 목록/생성""" + queryset = Post.objects.all() serializer_class = PostSerializer permission_classes = [permissions.IsAuthenticated] def perform_create(self, serializer): - # token = self.request.headers.get("Authorization", "").replace("Bearer ", "") - # verify_token_with_auth_server(token) - # verify_result = verify_token_with_auth_server(token) - # 2025-04-14 로그 등록 (콘솔+FluentBit용) - # if verify_result == None: - # 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.") + 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 @@ -44,20 +57,97 @@ class PostDetailView(generics.RetrieveUpdateDestroyAPIView): return [permissions.AllowAny()] def perform_update(self, serializer): - # token = self.request.headers.get("Authorization", "").replace("Bearer ", "") - # verify_token_with_auth_server(token) + 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 serializer.instance.author_name != self.request.user.username: + # 작성자 확인 + if instance.author_id != user_id and instance.author_name != username: raise PermissionDenied("작성자만 수정할 수 있습니다.") + serializer.save() - post_title=serializer.save(author_name=self.request.user.username) - logger.info(f"Post titled '{post_title}' has been updated.") + logger.info(f"Post titled '{instance.title}' has been updated by {username}.") def perform_destroy(self, instance): - # token = self.request.headers.get("Authorization", "").replace("Bearer ", "") - # 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 instance.author_name != self.request.user.username: + # 작성자 확인 + if instance.author_id != user_id and instance.author_name != username: raise PermissionDenied("작성자만 삭제할 수 있습니다.") + + title = instance.title instance.delete() - logger.info(f"Post titled '{instance}' has been deleted.") \ No newline at end of file + 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}.") \ No newline at end of file diff --git a/version b/version index a2f7a80..41a2819 100644 --- a/version +++ b/version @@ -1 +1 @@ -v0.0.6_r2 \ No newline at end of file +v0.0.7