From 70f6b127f9c67788fda06c2ae97ba5dae434ea46 Mon Sep 17 00:00:00 2001 From: simseulnyang Date: Fri, 27 Oct 2023 07:43:55 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:Feat=20:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=AC=BC=20=EB=AA=A9=EB=A1=9D=20API=20=EA=B5=AC=ED=98=84=20#9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/settings.py | 2 + posts/migrations/0001_initial.py | 9 ++- posts/models.py | 3 + posts/paginations.py | 20 ++++++ posts/serializers.py | 30 +++++++++ posts/tests.py | 8 +++ posts/urls.py | 3 +- posts/views.py | 110 ++++++++++++++++++++++++++++++- users/migrations/0001_initial.py | 6 +- users/models.py | 1 + 10 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 posts/paginations.py create mode 100644 posts/tests.py diff --git a/config/settings.py b/config/settings.py index deec84b..9943df7 100644 --- a/config/settings.py +++ b/config/settings.py @@ -135,6 +135,8 @@ ], "DEFAULT_RENDERER_CLASSES": ("djangorestframework_camel_case.render.CamelCaseJSONRenderer",), "DEFAULT_PARSER_CLASSES": ("djangorestframework_camel_case.parser.CamelCaseJSONParser",), + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 10, } diff --git a/posts/migrations/0001_initial.py b/posts/migrations/0001_initial.py index 240e33a..5307350 100644 --- a/posts/migrations/0001_initial.py +++ b/posts/migrations/0001_initial.py @@ -1,7 +1,9 @@ -# Generated by Django 4.2.6 on 2023-10-25 07:12 +# Generated by Django 4.2.6 on 2023-10-27 00:14 +from django.conf import settings from django.db import migrations, models import django.db.models.deletion +import uuid class Migration(migrations.Migration): @@ -9,7 +11,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('users', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -29,6 +31,7 @@ class Migration(migrations.Migration): name='Post', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content_id', models.UUIDField(default=uuid.uuid4, editable=False)), ('post_type', models.CharField(choices=[('facebook', 'Facebook'), ('twitter', 'Twitter'), ('instagram', 'Instagram'), ('threads', 'Threads')], max_length=16)), ('title', models.CharField(max_length=32)), ('content', models.TextField()), @@ -38,7 +41,7 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('hashtag', models.ManyToManyField(related_name='posts', to='posts.hashtag')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='users.user')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), ], options={ 'db_table': 'posts', diff --git a/posts/models.py b/posts/models.py index bcd9eb9..5c66c5c 100644 --- a/posts/models.py +++ b/posts/models.py @@ -1,3 +1,5 @@ +import uuid + from django.db import models from users.models import User @@ -10,6 +12,7 @@ class PostType(models.TextChoices): INSTAGRAM = "instagram" THREADS = "threads" + content_id = models.UUIDField(default=uuid.uuid4, editable=False) post_type = models.CharField(max_length=16, choices=PostType.choices) title = models.CharField(max_length=32) content = models.TextField() diff --git a/posts/paginations.py b/posts/paginations.py new file mode 100644 index 0000000..4640d55 --- /dev/null +++ b/posts/paginations.py @@ -0,0 +1,20 @@ +class PaginationHandlerMixin(object): + @property + def paginator(self): + if not hasattr(self, "_paginator"): + if self.pagination_class is None: + self._paginator = None + else: + self._paginator = self.pagination_class() + else: + pass + return self._paginator + + def paginate_queryset(self, queryset): + if self.paginator is None: + return None + return self.paginator.paginate_queryset(queryset, self.request, view=self) + + def get_paginated_response(self, data): + assert self.paginator is not None + return self.paginator.get_paginated_response(data) diff --git a/posts/serializers.py b/posts/serializers.py index 1eddc1b..3467f19 100644 --- a/posts/serializers.py +++ b/posts/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from .models import HashTag, Post + class StatisticsQuerySerializer(serializers.Serializer): type = serializers.ChoiceField(choices=["date", "hour"]) @@ -12,3 +14,31 @@ class StatisticsQuerySerializer(serializers.Serializer): class StatisticsListSerializer(serializers.Serializer): datetime = serializers.DateTimeField() count = serializers.IntegerField() + + +class HashTagSerializer(serializers.ModelSerializer): + class Meta: + model = HashTag + fields = [ + "name", + ] + + +class PostListSerializer(serializers.ModelSerializer): + hashtag = HashTagSerializer(many=True, read_only=True) + + class Meta: + model = Post + fields = [ + "content_id", + "post_type", + "title", + "content", + "view_count", + "like_count", + "share_count", + "created_at", + "updated_at", + "hashtag", + "user", + ] diff --git a/posts/tests.py b/posts/tests.py new file mode 100644 index 0000000..f9ddb3a --- /dev/null +++ b/posts/tests.py @@ -0,0 +1,8 @@ +from rest_framework.test import APITestCase + + +class PostListViewTest(APITestCase): + @classmethod + def setUpTestData(cls): + cls + return super().setUpTestData()() diff --git a/posts/urls.py b/posts/urls.py index ca0f3f7..edd1ca3 100644 --- a/posts/urls.py +++ b/posts/urls.py @@ -1,7 +1,8 @@ from django.urls import path -from posts.views import StatisticsListView +from posts.views import PostListView, StatisticsListView urlpatterns = [ path("statistics/", StatisticsListView.as_view(), name="statistics"), + path("list/", PostListView.as_view(), name="list"), ] diff --git a/posts/views.py b/posts/views.py index edd32db..5eab6c5 100644 --- a/posts/views.py +++ b/posts/views.py @@ -1,20 +1,23 @@ from datetime import datetime, timedelta -from django.db.models import Count, Q, Sum +from django.db.models import Count, F, Q, Sum from django.db.models.functions import TruncDay, TruncHour from django.db.models.query import QuerySet +from django_filters.rest_framework import DjangoFilterBackend from drf_yasg.utils import swagger_auto_schema from rest_framework import status +from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from common.dacorator import mandatories, optionals +from common.decorator import mandatories, optionals from common.exceptions import InvalidParameterException, UnknownServerErrorException from common.utils import get_before_week, get_now from posts.models import Post -from posts.serializers import StatisticsListSerializer, StatisticsQuerySerializer +from posts.paginations import PaginationHandlerMixin +from posts.serializers import PostListSerializer, StatisticsListSerializer, StatisticsQuerySerializer class StatisticsListView(APIView): @@ -104,3 +107,104 @@ def get_statistics(self, queryset: Post, aggregation_field: str, value: str, agg else: raise InvalidParameterException("value는 count, view_count, share_count, like_count 중 선택 가능합니다.") return statistics + + +class PostListView(PaginationHandlerMixin, APIView): + # @TODO: IsAuthenticated로 변경 @simseulnyang + permission_classes = [AllowAny] + filter_backends = [DjangoFilterBackend] + pagination_class = LimitOffsetPagination + + @swagger_auto_schema( + operation_summary="게시물 리스트를 조회", + query_serializer=PostListSerializer, + responses={ + status.HTTP_200_OK: PostListSerializer, + }, + ) + @mandatories("type") + @optionals({"search": "search_by"}, {"ordering": "orderby"}, {"hashtag": None}) + def get(self, request: Request, m: dict, o: dict) -> Response: + """ + query parameter로 type, search, ordering, hashtag를 받아 게시물 목록을 조회합니다. + + Args: + type: 게시물 타입으로 facebook, twitter, instagram, threads 중에 1개를 선택하여 조회 가능합니다. (default : 모든 게시물 타입) + search: title, content, title + content 검색이 가능합니다. + ordering: created_at, updated_at, view_count, share_count, like_count 기준으로 목록을 정렬합니다. (default: created_at) + hashtag: 조회할 해시태그입니다. (default: 본인계정) + + Returns: + content_id : 게시물 id + hashtag : 해시태그 + user : 게시글 작성 유저 + post_type : 게시물 타입 + title : 게시글 제목 + content : 게시글 내용 + view_count : 조회수 + like_count : 좋아요 수 + share_count : 공유 수 + created_at : 작성일자 + updated_at : 업데이트 일자 + """ + try: + # 쿼리 매개변수 받기 + post_type = m["type"] + search_query = o.get("search", "") + ordering = o.get("ordering", "created_at") + hashtag = request.user.username if o["hashtag"] is None else o["hashtag"] + + # 유저 계정의 해시태그로 초기 필터링한 게시물 목록 가져오기 + postlist = Post.objects.filter(user__username=hashtag) + + # 검색어로 필터링 + if search_query: + postlist = postlist.filter(Q(title__icontains=search_query) | Q(content__icontains=search_query)) + + # 정렬 기준 적용 + postlist = self.apply_ordering(postlist, ordering) + + # post_type에 따라 필터링 된 게시물 목록 가져오기 + post_type_list = self.get_post_type_list(postlist, post_type) + + # 게시물 목록 serialize + serializer = PostListSerializer(post_type_list, many=True) + except Exception as e: + raise UnknownServerErrorException(e) + return Response(serializer.data, status=status.HTTP_200_OK) + + def apply_ordering(self, postlist, ordering): + """ + 정렬 기준을 적용하여 postlist 정렬합니다. + ordering에 "asc" 또는 "desc"를 추가하여 오름차순 또는 내림차순 정렬 가능합니다. + + Args: + postlist: 정렬할 postlist + ordering: 정렬 기준과 방향 (예: created_at:desc) + + Returns: + 정렬된 postlist + """ + ordering_parts = ordering.split(":") + field_name = ordering_parts[0] + direction = ordering_parts[1] if len(ordering_parts) > 1 else "desc" + + if direction == "asc": + field_name = F(field_name).asc() + else: + field_name = F(field_name).desc() + + return postlist.order_by(field_name) + + def get_post_type_list(self, postlist: Post, post_type: str): + if post_type == "facebook": + post_type_list = postlist.filter(post_type="facebook") + elif post_type == "twitter": + post_type_list = postlist.filter(post_type="twitter") + elif post_type == "instagram": + post_type_list = postlist.filter(post_type="instagram") + elif post_type == "threads": + post_type_list = postlist.filter(post_type="threads") + else: + raise InvalidParameterException("post_type 값을 잘못 선택하셨습니다.") + return post_type_list diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index a80b749..f675fa3 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 4.2.6 on 2023-10-25 07:12 +# Generated by Django 4.2.6 on 2023-10-27 00:14 +from django.conf import settings from django.db import migrations, models import django.db.models.deletion @@ -17,6 +18,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('username', models.CharField(max_length=128, unique=True)), ('email', models.EmailField(max_length=128, unique=True)), ('password', models.CharField(max_length=128)), ('is_confirmed', models.BooleanField(default=False)), @@ -35,7 +37,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('code', models.CharField(max_length=32)), ('created_at', models.DateTimeField(auto_now_add=True)), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='users.user')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), ], options={ 'db_table': 'user_confirm_codes', diff --git a/users/models.py b/users/models.py index ab7f7a0..f4c83c2 100644 --- a/users/models.py +++ b/users/models.py @@ -5,6 +5,7 @@ class User(AbstractBaseUser): + username = models.CharField(max_length=128, unique=True) email = models.EmailField(max_length=128, unique=True) password = models.CharField(max_length=128) is_confirmed = models.BooleanField(default=False)