From ab0d39ca476d173fd8b92f30dcb57c09d2b2453f Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Thu, 4 May 2023 10:04:52 +0000 Subject: [PATCH 01/14] Format article model following Django coding style --- apps/core/models/article.py | 79 ++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 44 deletions(-) diff --git a/apps/core/models/article.py b/apps/core/models/article.py index c80b4de3..5f6f7b4a 100644 --- a/apps/core/models/article.py +++ b/apps/core/models/article.py @@ -37,128 +37,119 @@ class ArticleHiddenReason(str, Enum): class Article(MetaDataModel): - class Meta(MetaDataModel.Meta): - verbose_name = "게시물" - verbose_name_plural = "게시물 목록" - title = models.CharField( - max_length=256, verbose_name="제목", + max_length=256, ) content = models.TextField( verbose_name="본문", ) content_text = models.TextField( - editable=False, verbose_name="text 형식 본문", + editable=False, ) - name_type = models.SmallIntegerField( - default=BoardNameType.REGULAR, verbose_name="익명 혹은 실명 여부", + default=BoardNameType.REGULAR, ) is_content_sexual = models.BooleanField( - default=False, verbose_name="성인/음란성 내용", + default=False, ) is_content_social = models.BooleanField( - default=False, verbose_name="정치/사회성 내용", + default=False, ) - hit_count = models.IntegerField( - default=0, verbose_name="조회수", + default=0, ) comment_count = models.IntegerField( - default=0, verbose_name="댓글 수", + default=0, ) report_count = models.IntegerField( - default=0, verbose_name="신고 수", + default=0, ) positive_vote_count = models.IntegerField( - default=0, verbose_name="좋아요 수", + default=0, ) negative_vote_count = models.IntegerField( - default=0, verbose_name="싫어요 수", + default=0, ) - migrated_hit_count = models.IntegerField( - default=0, verbose_name="이전된 조회수", + default=0, ) migrated_positive_vote_count = models.IntegerField( - default=0, verbose_name="이전된 좋아요 수", + default=0, ) migrated_negative_vote_count = models.IntegerField( - default=0, verbose_name="이전된 싫어요 수", + default=0, ) - created_by = models.ForeignKey( - on_delete=models.CASCADE, + verbose_name="작성자", to=settings.AUTH_USER_MODEL, - db_index=True, + on_delete=models.CASCADE, related_name="article_set", - verbose_name="작성자", + db_index=True, ) parent_topic = models.ForeignKey( - on_delete=models.CASCADE, + verbose_name="말머리", to="core.Topic", - null=True, + on_delete=models.CASCADE, + related_name="article_set", blank=True, - default=None, + null=True, db_index=True, - related_name="article_set", - verbose_name="말머리", + default=None, ) parent_board = models.ForeignKey( - on_delete=models.CASCADE, + verbose_name="게시판", to="core.Board", - db_index=True, + on_delete=models.CASCADE, related_name="article_set", - verbose_name="게시판", + db_index=True, ) - attachments = models.ManyToManyField( + verbose_name="첨부 파일(들)", to="core.Attachment", blank=True, db_index=True, - verbose_name="첨부 파일(들)", ) - commented_at = models.DateTimeField( + verbose_name="마지막 댓글 시간", null=True, default=None, - verbose_name="마지막 댓글 시간", ) - url = models.URLField( - null=True, + verbose_name="포탈 링크", max_length=200, blank=True, + null=True, default=None, - verbose_name="포탈 링크", ) - content_updated_at = models.DateTimeField( + verbose_name="제목/본문/첨부파일 수정 시간", null=True, default=None, - verbose_name="제목/본문/첨부파일 수정 시간", ) - hidden_at = models.DateTimeField( - null=True, + verbose_name="숨김 시간", blank=True, + null=True, default=None, - verbose_name="숨김 시간", ) + class Meta(MetaDataModel.Meta): + verbose_name = "게시물" + verbose_name_plural = "게시물 목록" + def __str__(self): return self.title From 5578d1dcd3d4123ffc4948b98c7971b29476f7a7 Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Thu, 11 May 2023 11:39:42 +0000 Subject: [PATCH 02/14] Add Article.topped_at field --- .../core/migrations/0047_article_topped_at.py | 20 +++++++++++++++++++ apps/core/models/article.py | 6 ++++++ 2 files changed, 26 insertions(+) create mode 100644 apps/core/migrations/0047_article_topped_at.py diff --git a/apps/core/migrations/0047_article_topped_at.py b/apps/core/migrations/0047_article_topped_at.py new file mode 100644 index 00000000..8d2cb4d4 --- /dev/null +++ b/apps/core/migrations/0047_article_topped_at.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.16 on 2023-05-11 11:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0046_update_hidden_at_min_time_to_null"), + ] + + operations = [ + migrations.AddField( + model_name="article", + name="topped_at", + field=models.DateTimeField( + blank=True, default=None, null=True, verbose_name="인기글 달성 시각" + ), + ), + ] diff --git a/apps/core/models/article.py b/apps/core/models/article.py index 5f6f7b4a..bf7a7986 100644 --- a/apps/core/models/article.py +++ b/apps/core/models/article.py @@ -145,6 +145,12 @@ class Article(MetaDataModel): null=True, default=None, ) + topped_at = models.DateTimeField( + verbose_name="인기글 달성 시각", + blank=True, + null=True, + default=None, + ) class Meta(MetaDataModel.Meta): verbose_name = "게시물" From 086a9ddc00e7fd19c3a3e136c568099c2dad02a9 Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Thu, 11 May 2023 11:43:26 +0000 Subject: [PATCH 03/14] Format board model following Django coding style --- apps/core/models/board.py | 64 +++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/apps/core/models/board.py b/apps/core/models/board.py index e42766ad..5a32b591 100644 --- a/apps/core/models/board.py +++ b/apps/core/models/board.py @@ -19,26 +19,18 @@ class BoardAccessPermissionType(IntEnum): class Board(MetaDataModel): - class Meta(MetaDataModel.Meta): - verbose_name = "게시판" - verbose_name_plural = "게시판 목록" - unique_together = ( - ("ko_name", "deleted_at"), - ("en_name", "deleted_at"), - ) - slug = AutoSlugField( populate_from=[ "en_name", ], ) ko_name = models.CharField( - max_length=32, verbose_name="게시판 국문 이름", + max_length=32, ) en_name = models.CharField( - max_length=32, verbose_name="게시판 영문 이름", + max_length=32, ) ko_description = models.TextField( verbose_name="게시판 국문 소개", @@ -51,70 +43,78 @@ class Meta(MetaDataModel.Meta): # 사용자 그룹의 값들은 `UserGroup`을 참고하세요. read_access_mask = models.SmallIntegerField( # UNAUTHORIZED, EXTERNAL_ORG 제외 모든 사용자 읽기 권한 부여 - default=0b011011110, - null=False, verbose_name="읽기 권한", + default=0b011011110, ) write_access_mask = models.SmallIntegerField( # UNAUTHORIZED, STORE_EMPLOYEE, EXTERNAL_ORG 제외 모든 사용자 쓰기 권한 부여 - default=0b011011010, - null=False, verbose_name="쓰기 권한", + default=0b011011010, ) comment_access_mask = models.SmallIntegerField( # UNAUTHORIZED 제외 모든 사용자 댓글 권한 부여 - default=0b011111110, - null=False, verbose_name="댓글 권한", + default=0b011111110, ) is_readonly = models.BooleanField( verbose_name="읽기 전용 게시판", - help_text="활성화했을 때 관리자만 글을 쓸 수 있습니다. (ex. 포탈공지)", default=False, + help_text="활성화했을 때 관리자만 글을 쓸 수 있습니다. (ex. 포탈공지)", ) is_hidden = models.BooleanField( verbose_name="리스트 숨김 게시판", - help_text="활성화했을 때 메인페이지 상단바 리스트에 나타나지 않습니다. (ex. 뉴아라공지)", - default=False, db_index=True, + default=False, + help_text="활성화했을 때 메인페이지 상단바 리스트에 나타나지 않습니다. (ex. 뉴아라공지)", ) name_type = models.SmallIntegerField( verbose_name="익명/실명 게시판", - help_text="게시판의 글과 댓글들이 익명 혹은 실명이도록 합니다.", - default=BoardNameType.REGULAR, db_index=True, + default=BoardNameType.REGULAR, + help_text="게시판의 글과 댓글들이 익명 혹은 실명이도록 합니다.", ) is_school_communication = models.BooleanField( verbose_name="학교와의 소통 게시판", - help_text="학교 소통 게시판 글임을 표시", - default=False, db_index=True, + default=False, + help_text="학교 소통 게시판 글임을 표시", + ) + group_id = models.IntegerField( + verbose_name="그룹 ID", + default=1, ) - group_id = models.IntegerField(verbose_name="그룹 ID", default=1) banner_image = models.ImageField( - default="default_banner.png", - upload_to="board_banner_images", verbose_name="게시판 배너 이미지", + upload_to="board_banner_images", + default="default_banner.png", ) ko_banner_description = models.TextField( - null=True, + verbose_name="게시판 배너에 삽입되는 국문 소개", blank=True, + null=True, default=None, - verbose_name="게시판 배너에 삽입되는 국문 소개", ) en_banner_description = models.TextField( - null=True, + verbose_name="게시판 배너에 삽입되는 영문 소개", blank=True, + null=True, default=None, - verbose_name="게시판 배너에 삽입되는 영문 소개", ) banner_url = models.TextField( - null=True, + verbose_name="게시판 배너를 클릭 시에 이동하는 링크", blank=True, + null=True, default=None, - verbose_name="게시판 배너를 클릭 시에 이동하는 링크", ) + class Meta(MetaDataModel.Meta): + verbose_name = "게시판" + verbose_name_plural = "게시판 목록" + unique_together = ( + ("ko_name", "deleted_at"), + ("en_name", "deleted_at"), + ) + def __str__(self) -> str: return self.ko_name From e93a3dd322913e31d81cfacedfe6ac6978d14e09 Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Thu, 11 May 2023 13:29:53 +0000 Subject: [PATCH 04/14] Add Board.top_threshold field --- .../migrations/0048_board_top_threshold.py | 18 ++++++++++++++++++ apps/core/models/board.py | 4 ++++ 2 files changed, 22 insertions(+) create mode 100644 apps/core/migrations/0048_board_top_threshold.py diff --git a/apps/core/migrations/0048_board_top_threshold.py b/apps/core/migrations/0048_board_top_threshold.py new file mode 100644 index 00000000..7bc751b7 --- /dev/null +++ b/apps/core/migrations/0048_board_top_threshold.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2023-05-11 13:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0047_article_topped_at"), + ] + + operations = [ + migrations.AddField( + model_name="board", + name="top_threshold", + field=models.SmallIntegerField(default=10, verbose_name="인기글 달성 기준 좋아요 개수"), + ), + ] diff --git a/apps/core/models/board.py b/apps/core/models/board.py index 5a32b591..7783a2fa 100644 --- a/apps/core/models/board.py +++ b/apps/core/models/board.py @@ -106,6 +106,10 @@ class Board(MetaDataModel): null=True, default=None, ) + top_threshold = models.SmallIntegerField( + verbose_name="인기글 달성 기준 좋아요 개수", + default=10, + ) class Meta(MetaDataModel.Meta): verbose_name = "게시판" From 4a3774efd1e00bbcc8e6a8d1f3e3a4cd56cc8f3a Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Thu, 11 May 2023 13:34:24 +0000 Subject: [PATCH 05/14] Top an article when positive count > threshold --- apps/core/models/article.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/core/models/article.py b/apps/core/models/article.py index bf7a7986..eaea7034 100644 --- a/apps/core/models/article.py +++ b/apps/core/models/article.py @@ -220,7 +220,7 @@ def update_report_count(self): self.save() - def update_vote_status(self): + def update_vote_status(self) -> None: self.positive_vote_count = ( self.vote_set.filter(is_positive=True).count() + self.migrated_positive_vote_count @@ -230,6 +230,12 @@ def update_vote_status(self): + self.migrated_negative_vote_count ) + if ( + self.topped_at is None + and self.positive_vote_count >= self.parent_board.top_threshold + ): + self.topped_at = timezone.now() + if ( self.parent_board.is_school_communication and self.positive_vote_count >= SCHOOL_RESPONSE_VOTE_THRESHOLD From da8a28f44e17a066f901490c34a21a982a036b8c Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Thu, 11 May 2023 14:46:22 +0000 Subject: [PATCH 06/14] Add endpoints & Edit admin view --- apps/core/admin.py | 2 +- apps/core/views/viewsets/article.py | 27 +++++++++++++++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/core/admin.py b/apps/core/admin.py index d6d29883..19b4cc20 100644 --- a/apps/core/admin.py +++ b/apps/core/admin.py @@ -123,7 +123,7 @@ class ArticleAdmin(MetaDataModelAdmin): "content_updated_at", "commented_at", "url", - "hidden_at", + ("hidden_at", "topped_at"), ) readonly_fields = ( "hit_count", diff --git a/apps/core/views/viewsets/article.py b/apps/core/views/viewsets/article.py index 37b11fdb..44ee3369 100644 --- a/apps/core/views/viewsets/article.py +++ b/apps/core/views/viewsets/article.py @@ -98,8 +98,8 @@ def filter_queryset(self, queryset): ) queryset = queryset.prefetch_related( - 'attachments', - 'communication_article', + "attachments", + "communication_article", ) # optimizing queryset for list action @@ -406,12 +406,12 @@ def recent(self, request, *args, **kwargs): """, query_params, ).prefetch_related( - 'created_by', - 'created_by__profile', - 'parent_board', - 'parent_topic', - 'attachments', - 'communication_article', + "created_by", + "created_by__profile", + "parent_board", + "parent_topic", + "attachments", + "communication_article", ArticleReadLog.prefetch_my_article_read_log(self.request.user), ) @@ -419,3 +419,14 @@ def recent(self, request, *args, **kwargs): [v for v in queryset], many=True, context={"request": request} ) return self.paginator.get_paginated_response(serializer.data) + + @decorators.action(detail=False, methods=["get"]) + def top(self, request): + top_articles = Article.objects.exclude(topped_at__isnull=True) + page = self.paginate_queryset(top_articles) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(top_articles, many=True) + return response.Response(data=serializer.data, status=status.HTTP_200_OK) From 443bc45480233ae6793584d4496cb174f4e5d826 Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Wed, 21 Jun 2023 10:12:24 +0000 Subject: [PATCH 07/14] Order top articles --- apps/core/views/viewsets/article.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/core/views/viewsets/article.py b/apps/core/views/viewsets/article.py index 44ee3369..2cc448b9 100644 --- a/apps/core/views/viewsets/article.py +++ b/apps/core/views/viewsets/article.py @@ -422,7 +422,10 @@ def recent(self, request, *args, **kwargs): @decorators.action(detail=False, methods=["get"]) def top(self, request): - top_articles = Article.objects.exclude(topped_at__isnull=True) + # The most recent article at the top + top_articles = Article.objects.exclude(topped_at__isnull=True).order_by( + "-topped_at", "-pk" + ) page = self.paginate_queryset(top_articles) if page is not None: serializer = self.get_serializer(page, many=True) From fb4d0ecdfc442133593e705c2f68ec23d55aa0c4 Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Wed, 21 Jun 2023 10:57:09 +0000 Subject: [PATCH 08/14] Update magic numbers to http status codes --- tests/test_articles.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/test_articles.py b/tests/test_articles.py index fee03443..4849c68d 100644 --- a/tests/test_articles.py +++ b/tests/test_articles.py @@ -456,9 +456,9 @@ def test_update_cache_sync(self): f"articles/{article.id}", {"title": new_title, "content": new_content}, ) - assert response.status_code == 200 + assert response.status_code == status.HTTP_200_OK response = self.http_request(self.user, "get", f"articles/{article.id}") - assert response.status_code == 200 + assert response.status_code == status.HTTP_200_OK assert response.data.get("title") == new_title assert response.data.get("content") == new_content @@ -571,7 +571,7 @@ def test_self_vote(self): resp = self.http_request( self.user, "post", f"articles/{self.article.id}/vote_positive" ) - assert resp.status_code == 403 + assert resp.status_code == status.HTTP_403_FORBIDDEN assert resp.data["message"] is not None article = Article.objects.get(id=self.article.id) assert article.positive_vote_count == 0 @@ -580,7 +580,7 @@ def test_self_vote(self): resp = self.http_request( self.user, "post", f"articles/{self.article.id}/vote_negative" ) - assert resp.status_code == 403 + assert resp.status_code == status.HTTP_403_FORBIDDEN assert resp.data["message"] is not None article = Article.objects.get(id=self.article.id) assert article.positive_vote_count == 0 @@ -598,7 +598,7 @@ def test_readonly_board(self): "parent_board": self.readonly_board.id, } response = self.http_request(self.user, "post", "articles", user_data) - assert response.status_code == 400 + assert response.status_code == status.HTTP_400_BAD_REQUEST def test_read_status(self): # user1, user2 모두 아직 안읽음 @@ -1016,7 +1016,7 @@ def test_modify_deleted_article(self): }, ) - assert res.status_code == 404 + assert res.status_code == status.HTTP_404_NOT_FOUND def test_modify_report_hidden_article(self): target_article = self._create_report_hidden_article() @@ -1033,7 +1033,7 @@ def test_modify_report_hidden_article(self): }, ) - assert res.status_code == 403 + assert res.status_code == status.HTTP_403_FORBIDDEN def test_get_deleted_article(self): target_article = self._create_deleted_article() @@ -1053,12 +1053,12 @@ def test_exclude_deleted_article_from_list(self): def test_delete_already_deleted_article(self): target_article = self._create_deleted_article() res = self.http_request(self.user, "delete", f"articles/{target_article.id}") - assert res.status_code == 404 + assert res.status_code == status.HTTP_404_NOT_FOUND def test_delete_report_hidden_article(self): target_article = self._create_report_hidden_article() res = self.http_request(self.user, "delete", f"articles/{target_article.id}") - assert res.status_code == 403 + assert res.status_code == status.HTTP_403_FORBIDDEN def test_vote_deleted_article(self): target_article = self._create_deleted_article() @@ -1066,17 +1066,17 @@ def test_vote_deleted_article(self): positive_vote_result = self.http_request( self.user2, "post", f"articles/{target_article.id}/vote_positive" ) - assert positive_vote_result.status_code == 404 + assert positive_vote_result.status_code == status.HTTP_404_NOT_FOUND negative_vote_result = self.http_request( self.user2, "post", f"articles/{target_article.id}/vote_negative" ) - assert negative_vote_result.status_code == 404 + assert negative_vote_result.status_code == status.HTTP_404_NOT_FOUND cancel_vote_result = self.http_request( self.user2, "post", f"articles/{target_article.id}/vote_positive" ) - assert cancel_vote_result.status_code == 404 + assert cancel_vote_result.status_code == status.HTTP_404_NOT_FOUND def test_vote_report_hidden_article(self): target_article = self._create_report_hidden_article() @@ -1084,12 +1084,12 @@ def test_vote_report_hidden_article(self): positive_vote_result = self.http_request( self.user2, "post", f"articles/{target_article.id}/vote_positive" ) - assert positive_vote_result.status_code == 403 + assert positive_vote_result.status_code == status.HTTP_403_FORBIDDEN negative_vote_result = self.http_request( self.user2, "post", f"articles/{target_article.id}/vote_negative" ) - assert negative_vote_result.status_code == 403 + assert negative_vote_result.status_code == status.HTTP_403_FORBIDDEN Vote.objects.create( voted_by=self.user2, @@ -1101,7 +1101,7 @@ def test_vote_report_hidden_article(self): cancel_vote_result = self.http_request( self.user2, "post", f"articles/{target_article.id}/vote_cancel" ) - assert cancel_vote_result.status_code == 403 + assert cancel_vote_result.status_code == status.HTTP_403_FORBIDDEN assert Article.objects.get(id=target_article.id).positive_vote_count == 1 def test_report_deleted_article(self): @@ -1117,7 +1117,7 @@ def test_report_deleted_article(self): }, ) - assert res.status_code == 403 + assert res.status_code == status.HTTP_403_FORBIDDEN def test_report_already_hidden_article(self): target_article = self._create_report_hidden_article() @@ -1132,4 +1132,4 @@ def test_report_already_hidden_article(self): }, ) - assert res.status_code == 403 + assert res.status_code == status.HTTP_403_FORBIDDEN From eff817b1716ab4caea2a9b480f1066f285af10ff Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Wed, 21 Jun 2023 12:31:16 +0000 Subject: [PATCH 09/14] Move the test user generator function to utils --- tests/conftest.py | 29 ++++++++++++++++++++++++++++- tests/test_communication_article.py | 28 +++------------------------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 34dc8e42..569b5b62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,10 @@ Python 3.5 이후로는 pytest-django를 쓸 때 module-scope fixture에서 DB접근이 안되기 때문에 class-scope fixture 사용 https://github.com/pytest-dev/pytest-django/issues/53#issuecomment-407073682 """ +from typing import List import pytest -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import TestCase as DjangoTestCase from django.utils import timezone from rest_framework.test import APIClient @@ -12,6 +13,8 @@ from apps.user.models import UserProfile from ara import redis +User = get_user_model() + @pytest.fixture(scope="class") def set_admin_client(request): @@ -181,3 +184,27 @@ def setUpClass(cls): def tearDown(self): redis.flushall() super().tearDown() + + +class Utils: + @staticmethod + def create_users(num: int) -> List[User]: + users: List[User] = [] + for idx in range(num): + user, created = User.objects.get_or_create( + username=f"User{idx}", email=f"user{idx}@sparcs.org" + ) + if created: + UserProfile.objects.create( + user=user, + nickname=f"Nickname{idx}", + group=UserProfile.UserGroup.KAIST_MEMBER, + agree_terms_of_service_at=timezone.now(), + sso_user_info={ + "kaist_info": '{"ku_kname": "\\ud669"}', + "first_name": f"User{idx}_First", + "last_name": f"User{idx}_Last", + }, + ) + users.append(user) + return users diff --git a/tests/test_communication_article.py b/tests/test_communication_article.py index 3b38e7c3..e0a8be49 100644 --- a/tests/test_communication_article.py +++ b/tests/test_communication_article.py @@ -3,10 +3,8 @@ from unittest.mock import patch import pytest -from django.contrib.auth.models import User from django.utils import timezone from rest_framework.status import HTTP_200_OK -from rest_framework.test import APIClient from apps.core.models import Article, Board from apps.core.models.board import BoardNameType @@ -17,7 +15,8 @@ from apps.user.models import UserProfile from ara.settings import ANSWER_PERIOD, MIN_TIME from ara.settings.dev import SCHOOL_RESPONSE_VOTE_THRESHOLD -from tests.conftest import RequestSetting, TestCase + +from .conftest import RequestSetting, TestCase, Utils @pytest.fixture(scope="class") @@ -89,29 +88,8 @@ def _get_communication_article_status(self, article): res = self.http_request(self.user, "get", f"articles/{article.id}") return res.data.get("communication_article_status") - def _create_dummy_users(self, num): - dummy_users = [] - for i in range(num): - user, created = User.objects.get_or_create( - username=f"DummyUser{i}", email=f"dummy_user{i}@sparcs.org" - ) - if created: - UserProfile.objects.create( - user=user, - nickname=f"User{i} created at {timezone.now()}", - group=UserProfile.UserGroup.KAIST_MEMBER, - agree_terms_of_service_at=timezone.now(), - sso_user_info={ - "kaist_info": '{"ku_kname": "\\ud669"}', - "first_name": f"DummyUser{i}_FirstName", - "last_name": f"DummyUser{i}_LastName", - }, - ) - dummy_users.append(user) - return dummy_users - def _add_positive_votes(self, article, num): - dummy_users = self._create_dummy_users(num) + dummy_users = Utils.create_users(num) for user in dummy_users: self.http_request(user, "post", f"articles/{article.id}/vote_positive") From e44946234a4ebf8aac39372ee74fc64c26aa764a Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Wed, 21 Jun 2023 13:09:15 +0000 Subject: [PATCH 10/14] Add single test user generator --- tests/conftest.py | 56 ++++++++++++++++++++++++++++-------------- tests/test_articles.py | 38 ++++++++++------------------ 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 569b5b62..c17c87dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -188,23 +188,41 @@ def tearDown(self): class Utils: @staticmethod - def create_users(num: int) -> List[User]: - users: List[User] = [] - for idx in range(num): - user, created = User.objects.get_or_create( - username=f"User{idx}", email=f"user{idx}@sparcs.org" + def create_user( + username: str = "User", + email: str = "user@sparcs.org", + nickname: str = "Nickname", + group: UserProfile.UserGroup = UserProfile.UserGroup.KAIST_MEMBER, + ) -> User: + user, created = User.objects.get_or_create(username=username, email=email) + if created: + UserProfile.objects.create( + user=user, + nickname=nickname, + group=group, + agree_terms_of_service_at=timezone.now(), + sso_user_info={ + "kaist_info": '{"ku_kname": "\\ud669"}', + "first_name": f"Firstname", + "last_name": f"Lastname", + }, ) - if created: - UserProfile.objects.create( - user=user, - nickname=f"Nickname{idx}", - group=UserProfile.UserGroup.KAIST_MEMBER, - agree_terms_of_service_at=timezone.now(), - sso_user_info={ - "kaist_info": '{"ku_kname": "\\ud669"}', - "first_name": f"User{idx}_First", - "last_name": f"User{idx}_Last", - }, - ) - users.append(user) - return users + return user + + @classmethod + def create_user_with_index(cls, idx: int, group: UserProfile.UserGroup) -> User: + user = cls.create_user( + username=f"User{idx}", + email=f"user{idx}@sparcs.org", + nickname=f"Nickname{idx}", + group=group, + ) + return user + + @classmethod + def create_users( + cls, + num: int, + group: UserProfile.UserGroup = UserProfile.UserGroup.KAIST_MEMBER, + ) -> List[User]: + return [cls.create_user_with_index(idx, group) for idx in range(num)] diff --git a/tests/test_articles.py b/tests/test_articles.py index 4849c68d..2aba74d2 100644 --- a/tests/test_articles.py +++ b/tests/test_articles.py @@ -7,7 +7,7 @@ from apps.core.models.board import BoardAccessPermissionType, BoardNameType from apps.user.models import UserProfile from ara.settings import MIN_TIME, SCHOOL_RESPONSE_VOTE_THRESHOLD -from tests.conftest import RequestSetting, TestCase +from tests.conftest import RequestSetting, TestCase, Utils @pytest.fixture(scope="class") @@ -241,24 +241,6 @@ def set_readonly_board(request): "set_articles", ) class TestArticle(TestCase, RequestSetting): - def _create_user_by_group(self, group): - user, created = User.objects.get_or_create( - username=f"User in group {group}", email=f"group{group}user@sparcs.org" - ) - if created: - UserProfile.objects.create( - user=user, - nickname=f"Nickname in group {group}", - group=group, - agree_terms_of_service_at=timezone.now(), - sso_user_info={ - "kaist_info": '{"ku_kname": "\\ud669"}', - "first_name": f"Group{group}User_FirstName", - "last_name": f"Group{group}User_LastName", - }, - ) - return user - def test_list(self): # article 개수를 확인하는 테스트 res = self.http_request(self.user, "get", "articles") @@ -360,9 +342,12 @@ def test_create(self): # get request 시 user의 read 권한 확인 테스트 def test_check_read_permission_when_get(self): - group_users = [ - self._create_user_by_group(group) for group in UserProfile.UserGroup - ] + group_users = [] + for idx, group in enumerate(UserProfile.UserGroup): + user = Utils.create_user_with_index(idx, group) + group_users.append(user) + assert len(group_users) == len(UserProfile.UserGroup) + articles = [self.regular_access_article, self.advertiser_accessible_article] for user in group_users: @@ -379,9 +364,12 @@ def test_check_read_permission_when_get(self): # create 단계에서 user의 write 권한 확인 테스트 def test_check_write_permission_when_create(self): - group_users = [ - self._create_user_by_group(group) for group in UserProfile.UserGroup - ] + group_users = [] + for idx, group in enumerate(UserProfile.UserGroup): + user = Utils.create_user_with_index(idx, group) + group_users.append(user) + assert len(group_users) == len(UserProfile.UserGroup) + boards = [ self.regular_access_board, self.nonwritable_board, From eaa3db8f177ee2c150a7d13bceb7eebc380ab09c Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Wed, 28 Jun 2023 15:00:56 +0000 Subject: [PATCH 11/14] Add tests for top articles --- tests/test_articles.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_articles.py b/tests/test_articles.py index 2aba74d2..1fefa297 100644 --- a/tests/test_articles.py +++ b/tests/test_articles.py @@ -641,6 +641,58 @@ def test_deleting_with_comments(self): ) assert self.article.comment_count == 0 + def test_being_topped(self): + """ + `Article.topped_at` is set when `Article.positive_vote_count >= Board.top_threshold` + """ + THRESHOLD = 5 + board = Board.objects.create(top_threshold=THRESHOLD) + article = Article.objects.create(created_by=self.user, parent_board=board) + pk = article.pk + + users = Utils.create_users(THRESHOLD) + *users_ex_one, last_user = users + + for user in users_ex_one: + self.http_request(user, "post", f"articles/{pk}/vote_positive") + + article = Article.objects.get(pk=pk) + assert article.positive_vote_count == THRESHOLD - 1 + assert article.topped_at is None + + self.http_request(last_user, "post", f"articles/{pk}/vote_positive") + article = Article.objects.get(pk=pk) + assert article.positive_vote_count == THRESHOLD + assert article.topped_at is not None + + def test_top_ordered(self): + """ + The most recently topped article must come first. If the same, then + the most recent article must come first. + """ + board = Board.objects.create() + articles = [ + Article.objects.create(created_by=self.user, parent_board=board) + for _ in range(3) + ] + + time_early = timezone.datetime(2001, 10, 18) # retro's birthday + time_late = timezone.datetime(2003, 6, 17) # yuwol's birthday + + articles[0].topped_at = time_early + articles[1].topped_at = time_early + articles[2].topped_at = time_late + for article in articles: + article.save() + + response = self.http_request(self.user, "get", "articles/top") + assert response.status_code == status.HTTP_200_OK + assert response.data["num_items"] == 3 + + oracle_indices = [2, 1, 0] + for idx, res in zip(oracle_indices, response.data["results"]): + assert articles[idx].pk == res["id"] + @pytest.mark.usefixtures( "set_user_client", From b0410065aaecc181319f82253e1c2f069967031a Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Wed, 28 Jun 2023 15:06:18 +0000 Subject: [PATCH 12/14] Wrap docstring --- tests/test_articles.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_articles.py b/tests/test_articles.py index 1fefa297..142f8f61 100644 --- a/tests/test_articles.py +++ b/tests/test_articles.py @@ -643,7 +643,8 @@ def test_deleting_with_comments(self): def test_being_topped(self): """ - `Article.topped_at` is set when `Article.positive_vote_count >= Board.top_threshold` + `Article.topped_at` is set when `Article.positive_vote_count >= + Board.top_threshold` """ THRESHOLD = 5 board = Board.objects.create(top_threshold=THRESHOLD) From 7f51f7e3ec779c9d2c03f2f72153bbfd7734042b Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Sun, 2 Jul 2023 13:07:23 +0000 Subject: [PATCH 13/14] Fix BoardNameType to NameType --- apps/core/models/article.py | 3 +-- apps/core/models/board.py | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/core/models/article.py b/apps/core/models/article.py index 2b7d4143..74656c3d 100644 --- a/apps/core/models/article.py +++ b/apps/core/models/article.py @@ -49,9 +49,8 @@ class Article(MetaDataModel): editable=False, ) name_type = models.SmallIntegerField( - default=NameType.REGULAR, verbose_name="익명 혹은 실명 여부", - default=BoardNameType.REGULAR, + default=NameType.REGULAR, ) is_content_sexual = models.BooleanField( verbose_name="성인/음란성 내용", diff --git a/apps/core/models/board.py b/apps/core/models/board.py index 8a73c17c..7ed9372d 100644 --- a/apps/core/models/board.py +++ b/apps/core/models/board.py @@ -70,11 +70,9 @@ class Board(MetaDataModel): name_type = models.SmallIntegerField( verbose_name="닉네임/익명/실명글 허용 여부 설정", - help_text="글과 댓글을 어떤 이름 설정(닉네임/익명/실명)으로 작성할 수 있는지 정의합니다.", - default=NameType.REGULAR, db_index=True, - default=BoardNameType.REGULAR, - help_text="게시판의 글과 댓글들이 익명 혹은 실명이도록 합니다.", + default=NameType.REGULAR, + help_text="글과 댓글을 어떤 이름 설정(닉네임/익명/실명)으로 작성할 수 있는지 정의합니다.", ) is_school_communication = models.BooleanField( verbose_name="학교와의 소통 게시판", From f53e72dcebf1eb319c1eaf44823348d02208ca77 Mon Sep 17 00:00:00 2001 From: Injoon Hwang Date: Sun, 2 Jul 2023 13:18:08 +0000 Subject: [PATCH 14/14] Fix migration numberings --- .../{0047_article_topped_at.py => 0049_article_topped_at.py} | 2 +- ...{0048_board_top_threshold.py => 0050_board_top_threshold.py} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename apps/core/migrations/{0047_article_topped_at.py => 0049_article_topped_at.py} (87%) rename apps/core/migrations/{0048_board_top_threshold.py => 0050_board_top_threshold.py} (90%) diff --git a/apps/core/migrations/0047_article_topped_at.py b/apps/core/migrations/0049_article_topped_at.py similarity index 87% rename from apps/core/migrations/0047_article_topped_at.py rename to apps/core/migrations/0049_article_topped_at.py index 8d2cb4d4..e16feeac 100644 --- a/apps/core/migrations/0047_article_topped_at.py +++ b/apps/core/migrations/0049_article_topped_at.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("core", "0046_update_hidden_at_min_time_to_null"), + ("core", "0048_update_board_name_type_flag"), ] operations = [ diff --git a/apps/core/migrations/0048_board_top_threshold.py b/apps/core/migrations/0050_board_top_threshold.py similarity index 90% rename from apps/core/migrations/0048_board_top_threshold.py rename to apps/core/migrations/0050_board_top_threshold.py index 7bc751b7..7d1fb418 100644 --- a/apps/core/migrations/0048_board_top_threshold.py +++ b/apps/core/migrations/0050_board_top_threshold.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("core", "0047_article_topped_at"), + ("core", "0049_article_topped_at"), ] operations = [