diff --git a/.docker/run.sh b/.docker/run.sh index 1af60357..d9eded06 100644 --- a/.docker/run.sh +++ b/.docker/run.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash set -ex @@ -21,6 +21,15 @@ done if [ "$1" = "test" ]; then python3 manage.py compilemessages -l en -l ko pytest tests --verbose +elif [ "$1" = "dx" ]; then + if [ ! -f .init.lock.log ]; then + venv/bin/python manage.py collectstatic --noinput + venv/bin/python manage.py migrate --no-input + venv/bin/python manage.py compilemessages -l en -l ko + touch .init.lock.log + fi + source venv/bin/activate; + sleep infinity else python3 manage.py collectstatic --noinput python3 manage.py migrate --no-input 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/migrations/0049_article_topped_at.py b/apps/core/migrations/0049_article_topped_at.py new file mode 100644 index 00000000..e16feeac --- /dev/null +++ b/apps/core/migrations/0049_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", "0048_update_board_name_type_flag"), + ] + + 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/migrations/0050_board_top_threshold.py b/apps/core/migrations/0050_board_top_threshold.py new file mode 100644 index 00000000..7d1fb418 --- /dev/null +++ b/apps/core/migrations/0050_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", "0049_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/article.py b/apps/core/models/article.py index 30b85d68..3834bdb3 100644 --- a/apps/core/models/article.py +++ b/apps/core/models/article.py @@ -37,128 +37,125 @@ 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=NameType.REGULAR, verbose_name="익명 혹은 실명 여부", + default=NameType.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( + verbose_name="숨김 시간", + blank=True, null=True, + default=None, + ) + topped_at = models.DateTimeField( + 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 @@ -223,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 @@ -233,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 diff --git a/apps/core/models/board.py b/apps/core/models/board.py index d4760f89..7ed9372d 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,82 @@ 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=NameType.REGULAR, db_index=True, + default=NameType.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="게시판 배너를 클릭 시에 이동하는 링크", ) + top_threshold = models.SmallIntegerField( + verbose_name="인기글 달성 기준 좋아요 개수", + default=10, + ) + + 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 diff --git a/apps/core/views/viewsets/article.py b/apps/core/views/viewsets/article.py index 9be881bb..a5a0bd59 100644 --- a/apps/core/views/viewsets/article.py +++ b/apps/core/views/viewsets/article.py @@ -441,3 +441,17 @@ 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): + # 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) + 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) diff --git a/tests/conftest.py b/tests/conftest.py index d60d27f6..dcbaac85 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): @@ -196,3 +199,45 @@ def setUpClass(cls): def tearDown(self): redis.flushall() super().tearDown() + + +class Utils: + @staticmethod + 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", + }, + ) + 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 788d170b..a00c2042 100644 --- a/tests/test_articles.py +++ b/tests/test_articles.py @@ -7,7 +7,7 @@ from apps.core.models.board import BoardAccessPermissionType, NameType 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") @@ -252,24 +252,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") @@ -371,9 +353,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: @@ -390,9 +375,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, @@ -531,9 +519,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 @@ -646,7 +634,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 @@ -655,7 +643,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 @@ -673,7 +661,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 모두 아직 안읽음 @@ -728,6 +716,59 @@ 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", @@ -1101,7 +1142,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() @@ -1118,7 +1159,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() @@ -1138,12 +1179,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() @@ -1151,17 +1192,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() @@ -1169,12 +1210,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, @@ -1186,7 +1227,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): @@ -1202,7 +1243,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() @@ -1217,4 +1258,4 @@ def test_report_already_hidden_article(self): }, ) - assert res.status_code == 403 + assert res.status_code == status.HTTP_403_FORBIDDEN diff --git a/tests/test_communication_article.py b/tests/test_communication_article.py index 4b3c17c7..13119fb7 100644 --- a/tests/test_communication_article.py +++ b/tests/test_communication_article.py @@ -3,7 +3,6 @@ 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, HTTP_400_BAD_REQUEST from rest_framework.test import APIClient @@ -17,7 +16,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") @@ -88,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")