diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 41fbabe..a7574aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,19 +10,23 @@ repos: hooks: - id: black args: ['-l', '140', '-t', 'py311'] + - repo: https://github.com/myint/autoflake rev: v1.4 hooks: - id: autoflake args: - - --in-place - - --remove-unused-variables - --remove-all-unused-imports + - --remove-unused-variables + - --ignore-init-module-imports + - --in-place + - --recursive + - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - id: isort - args: ['--line-length', '140'] + args: ['--profile', 'black', '--filter-files', 'true'] default_language_version: python: python3.11 diff --git a/common/dacorator.py b/common/decorator.py similarity index 100% rename from common/dacorator.py rename to common/decorator.py diff --git a/common/views.py b/common/views.py index 872c310..4845097 100644 --- a/common/views.py +++ b/common/views.py @@ -1,7 +1,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from common.dacorator import mandatories, optionals +from common.decorator import mandatories, optionals class QueryTestView(APIView): diff --git a/posts/migrations/0001_initial.py b/posts/migrations/0001_initial.py index 240e33a..304c1c9 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-28 12:59 +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 = [ @@ -18,17 +20,17 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=32)), - ('hashtag_count', models.IntegerField(default=0)), ('created_at', models.DateTimeField(auto_now_add=True)), ], options={ - 'db_table': 'hashtag', + 'db_table': 'hashtags', }, ), migrations.CreateModel( 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()), @@ -37,11 +39,32 @@ class Migration(migrations.Migration): ('share_count', models.IntegerField(default=0)), ('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')), ], options={ 'db_table': 'posts', }, ), + migrations.CreateModel( + name='PostHashtag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('hashtag', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='posts.hashtag')), + ('post', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='posts.post')), + ], + options={ + 'db_table': 'posts_hashtags', + 'unique_together': {('post', 'hashtag')}, + }, + ), + migrations.AddField( + model_name='post', + name='hashtag_set', + field=models.ManyToManyField(through='posts.PostHashtag', to='posts.hashtag'), + ), + migrations.AddField( + model_name='post', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), ] diff --git a/posts/migrations/0002_post_content_id.py b/posts/migrations/0002_post_content_id.py deleted file mode 100644 index 4fd24d0..0000000 --- a/posts/migrations/0002_post_content_id.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.6 on 2023-10-26 11:12 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('posts', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='post', - name='content_id', - field=models.UUIDField(default=uuid.uuid4, editable=False), - ), - ] diff --git a/posts/models.py b/posts/models.py index 5c66c5c..cb659bb 100644 --- a/posts/models.py +++ b/posts/models.py @@ -2,8 +2,6 @@ from django.db import models -from users.models import User - class Post(models.Model): class PostType(models.TextChoices): @@ -21,23 +19,35 @@ class PostType(models.TextChoices): share_count = models.IntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) - hashtag = models.ManyToManyField("HashTag", related_name="posts") + user = models.ForeignKey("users.User", on_delete=models.SET_NULL, null=True) + hashtag_set = models.ManyToManyField("HashTag", through="PostHashtag", through_fields=("post", "hashtag")) class Meta: db_table = "posts" def __str__(self): - return f"{self.title} by {self.user.email}" + return f"[{self.user.email}]: {self.title}" class HashTag(models.Model): name = models.CharField(max_length=32) - hashtag_count = models.IntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) class Meta: - db_table = "hashtag" + db_table = "hashtags" def __str__(self): return self.name + + +class PostHashtag(models.Model): + post = models.ForeignKey("Post", on_delete=models.SET_NULL, null=True) + hashtag = models.ForeignKey("HashTag", on_delete=models.SET_NULL, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "posts_hashtags" + unique_together = ("post", "hashtag") + + def __str__(self): + return f"{self.post.title} - {self.hashtag.name}" diff --git a/posts/serializers.py b/posts/serializers.py index 1eddc1b..9c2a2e5 100644 --- a/posts/serializers.py +++ b/posts/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from posts.models import HashTag + class StatisticsQuerySerializer(serializers.Serializer): type = serializers.ChoiceField(choices=["date", "hour"]) @@ -12,3 +14,12 @@ class StatisticsQuerySerializer(serializers.Serializer): class StatisticsListSerializer(serializers.Serializer): datetime = serializers.DateTimeField() count = serializers.IntegerField() + + +class HashTagRecommendListSerializer(serializers.ModelSerializer): + class Meta: + model = HashTag + fields = ( + "id", + "name", + ) diff --git a/posts/tests/views/test_hashtag_recommend_list_view.py b/posts/tests/views/test_hashtag_recommend_list_view.py new file mode 100644 index 0000000..ea26a3c --- /dev/null +++ b/posts/tests/views/test_hashtag_recommend_list_view.py @@ -0,0 +1,55 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from posts.models import HashTag, Post +from users.models import User + + +class HashTagRecommendListViewTest(APITestCase): + @classmethod + def setUpTestData(cls): + cls.hasgtag1 = HashTag.objects.create( + name="hashtag1", + ) + cls.hasgtag2 = HashTag.objects.create( + name="hashtag2", + ) + for i in range(1, 10): + cls.users = User.objects.create_user( + email=f"user{i}@example.com", + password="testpassword", + ) + cls.posts = Post.objects.create( + post_type="facebook", + title=f"title {i}", + content=f"content {i}", + view_count=i, + like_count=i, + share_count=i, + user=cls.users, + ) + if i % 2 == 0: + cls.posts.hashtag_set.set([cls.hasgtag2]) + else: + cls.posts.hashtag_set.set([cls.hasgtag1, cls.hasgtag2]) + + def setUp(self): + pass + # self.access_token = self.client.post(reverse("token_obtain_pair"), self.user_data).data["access"] + + def test_get_hash_tag_recommend_list_success(self): + response = self.client.get( + path=reverse("hashtag_recommend_list"), + # HTTP_AUTHORIZATION=f"Bearer {self.access_token}", + ) + self.assertEqual(response.data[0]["id"], 2) + self.assertEqual(response.data[0]["name"], "hashtag2") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # @TODO: 로그인 로직 구현 후 주석 풀기 @SaJH + # def test_get_hash_tag_recommend_list_fail_unauthenticated(self): + # response = self.client.get( + # path=reverse("hashtag_recommend_list"), + # ) + # self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/posts/tests/views/test_statics_list_view.py b/posts/tests/views/test_statics_list_view.py index be321e4..0f69d81 100644 --- a/posts/tests/views/test_statics_list_view.py +++ b/posts/tests/views/test_statics_list_view.py @@ -26,7 +26,7 @@ def setUpTestData(cls): share_count=i, user=cls.users, ) - cls.posts.hashtag.set([cls.hasgtag]) + cls.posts.hashtag_set.set([cls.hasgtag]) def setUp(self): pass @@ -34,7 +34,7 @@ def setUp(self): def test_get_statistics_list_date_success(self): response = self.client.get( - path=reverse("statistics"), + path=reverse("statistics_list"), data={ "type": "date", "hashtag": "hashtag", @@ -46,7 +46,7 @@ def test_get_statistics_list_date_success(self): def test_get_statistics_list_hour_success(self): response = self.client.get( - path=reverse("statistics"), + path=reverse("statistics_list"), data={ "type": "hour", "hashtag": "hashtag", @@ -58,7 +58,7 @@ def test_get_statistics_list_hour_success(self): def test_get_statistics_list_not_found(self): response = self.client.get( - path=reverse("statistics"), + path=reverse("statistics_list"), data={ "type": "date", "hashtag": "hashtag1", @@ -72,7 +72,7 @@ def test_get_statistics_list_not_found(self): # @TODO: 로그인 로직 구현 후 주석 풀기 @SaJH # def test_get_statistics_list_fail_unauthenticated(self): # response = self.client.get( - # path=reverse("statistics"), + # path=reverse("statistics_list"), # data={ # "type": "date", # "hashtag": "hashtag", @@ -83,7 +83,7 @@ def test_get_statistics_list_not_found(self): def test_get_statistics_list_fail_missing_parameter(self): response = self.client.get( - path=reverse("statistics"), + path=reverse("statistics_list"), data={ "hashtag": "hashtag", }, @@ -93,7 +93,7 @@ def test_get_statistics_list_fail_missing_parameter(self): def test_get_statistics_list_fail_invalid_parameter_type(self): response = self.client.get( - path=reverse("statistics"), + path=reverse("statistics_list"), data={ "type": "test", "hashtag": "hashtag", @@ -105,7 +105,7 @@ def test_get_statistics_list_fail_invalid_parameter_type(self): def test_get_statistics_list_fail_invalid_parameter_max_days(self): response = self.client.get( - path=reverse("statistics"), + path=reverse("statistics_list"), data={ "type": "date", "start": "2023-10-1", @@ -119,7 +119,7 @@ def test_get_statistics_list_fail_invalid_parameter_max_days(self): def test_get_statistics_list_fail_invalid_parameter_value(self): response = self.client.get( - path=reverse("statistics"), + path=reverse("statistics_list"), data={ "type": "date", "hashtag": "hashtag", diff --git a/posts/urls.py b/posts/urls.py index ca0f3f7..ecc212e 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 HashTagRecommendListView, StatisticsListView urlpatterns = [ - path("statistics/", StatisticsListView.as_view(), name="statistics"), + path("statistics/", StatisticsListView.as_view(), name="statistics_list"), + path("hashtag/recommend/", HashTagRecommendListView.as_view(), name="hashtag_recommend_list"), ] diff --git a/posts/views.py b/posts/views.py index edd32db..c9a3362 100644 --- a/posts/views.py +++ b/posts/views.py @@ -10,11 +10,15 @@ 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.models import HashTag, Post, PostHashtag +from posts.serializers import ( + HashTagRecommendListSerializer, + StatisticsListSerializer, + StatisticsQuerySerializer, +) class StatisticsListView(APIView): @@ -93,8 +97,8 @@ def get_dates(self, date_type: str, max_days: int, start_date: str, end_date: st return start_date, end_date def get_filtered_queryset(self, start_date: str, end_date: str, hashtag: str) -> Post: - q = Q(created_at__range=(start_date, end_date)) & Q(hashtag__name=hashtag) - return Post.objects.prefetch_related("hashtag").filter(q) + q = Q(created_at__range=(start_date, end_date)) & Q(hashtag_set__name=hashtag) + return Post.objects.prefetch_related("hashtag_set").filter(q) def get_statistics(self, queryset: Post, aggregation_field: str, value: str, aggregation_type) -> QuerySet[dict]: if value == "count": @@ -104,3 +108,36 @@ 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 HashTagRecommendListView(APIView): + # @TODO: IsAuthenticated로 변경 @SaJH + permission_classes = [AllowAny] + + @swagger_auto_schema( + operation_summary="최근 3시간 동안 가장 많이 사용된 Tag 조회", + responses={ + status.HTTP_200_OK: HashTagRecommendListSerializer, + }, + ) + def get(self, request: Request) -> Response: + """ + 최근 3시간 동안 가장 많이 사용된 Tag를 조회하는 API 입니다. + + Returns: + hashtag_id: 해시태그 id + hashtag_name: 해시태그 이름 + """ + try: + today = datetime.now() + three_hours_ago = today - timedelta(hours=3) + queryset = self.get_filtered_queryset(today, three_hours_ago) + serializer = HashTagRecommendListSerializer(queryset, many=True) + except Exception as e: + raise UnknownServerErrorException(e) + return Response(serializer.data, status=status.HTTP_200_OK) + + def get_filtered_queryset(self, today: str, three_hours_ago: str) -> PostHashtag: + q = Q(posthashtag__created_at__range=(three_hours_ago, today)) + queryset = HashTag.objects.prefetch_related("posthashtag_set").filter(q).annotate(count=Count("posthashtag")).order_by("-count") + return queryset