diff --git a/Makefile b/Makefile index 2cdd285c..14048a9f 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,9 @@ superuser: flush: mysql -u root -e 'DROP DATABASE new_ara;' +flush_test: + mysql -u root -e 'DROP DATABASE test_new_ara;' + reset: flush init run: diff --git a/apps/core/admin.py b/apps/core/admin.py index ec6fcdfe..53d929ab 100644 --- a/apps/core/admin.py +++ b/apps/core/admin.py @@ -17,6 +17,7 @@ BestSearch, Report, Comment, + CommunicationArticle ) @@ -86,7 +87,7 @@ class FAQAdmin(MetaDataModelAdmin): @admin.register(Article) class ArticleAdmin(MetaDataModelAdmin): list_filter = ( - 'is_anonymous', + 'name_type', 'is_content_sexual', 'is_content_social', 'parent_topic', @@ -98,7 +99,7 @@ class ArticleAdmin(MetaDataModelAdmin): 'hit_count', 'positive_vote_count', 'negative_vote_count', - 'is_anonymous', + 'name_type', 'is_content_sexual', 'is_content_social', 'report_count', @@ -128,14 +129,14 @@ def restore_hidden_articles(self, request, queryset): @admin.register(Comment) class CommentAdmin(MetaDataModelAdmin): list_filter = ( - 'is_anonymous', + 'name_type', HiddenContentListFilter, ) list_display = ( 'content', 'positive_vote_count', 'negative_vote_count', - 'is_anonymous', + 'name_type', 'report_count', 'hidden_at', ) @@ -224,3 +225,20 @@ class ReportAdmin(MetaDataModelAdmin): 'type', 'content', ) + + +@admin.register(CommunicationArticle) +class CommunicationArticleAdmin(MetaDataModelAdmin): + list_filter = ( + 'response_deadline', + 'answered_at', + ) + list_display = ( + 'article', + 'get_status_string', + 'response_deadline', + 'answered_at', + ) + raw_id_fields = ( + 'article', + ) diff --git a/apps/core/filters/article.py b/apps/core/filters/article.py index 4faecc33..985bc782 100644 --- a/apps/core/filters/article.py +++ b/apps/core/filters/article.py @@ -19,7 +19,7 @@ class Meta: 'content_text': [ 'contains', ], - 'is_anonymous': [ + 'name_type': [ 'exact', ], 'is_content_sexual': [ @@ -47,6 +47,10 @@ class Meta: 'in', 'exact', ], + 'communication_article__school_response_status': [ + 'exact', + 'lt' + ] } main_search__contains = filters.CharFilter( diff --git a/apps/core/filters/comment.py b/apps/core/filters/comment.py index dfbeb438..69c824dc 100644 --- a/apps/core/filters/comment.py +++ b/apps/core/filters/comment.py @@ -16,7 +16,7 @@ class Meta: 'parent_comment': [ 'exact', ], - 'is_anonymous': [ + 'name_type': [ 'exact', ], 'created_by': [ diff --git a/apps/core/filters/notification.py b/apps/core/filters/notification.py index 2cd1806c..37ecdf25 100644 --- a/apps/core/filters/notification.py +++ b/apps/core/filters/notification.py @@ -29,13 +29,13 @@ class Meta: } is_read = filters.BooleanFilter( - name='is_read', + field_name='is_read', label='조회 여부', method='get_is_read', ) @staticmethod - def get_is_read(queryset, name, value): + def get_is_read(queryset, field_name, value): return queryset.filter( notification_read_log_set__is_read=value, ) diff --git a/apps/core/migrations/0033_board_group_id.py b/apps/core/migrations/0033_board_group_id.py new file mode 100644 index 00000000..6158e131 --- /dev/null +++ b/apps/core/migrations/0033_board_group_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.9 on 2022-02-26 11:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0032_board_is_anonymous'), + ] + + operations = [ + migrations.AddField( + model_name='board', + name='group_id', + field=models.IntegerField(default=1, verbose_name='그룹 ID'), + ), + ] diff --git a/apps/core/migrations/0034_board_banner_image.py b/apps/core/migrations/0034_board_banner_image.py new file mode 100644 index 00000000..10a039bb --- /dev/null +++ b/apps/core/migrations/0034_board_banner_image.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2022-03-09 14:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0033_board_group_id'), + ] + + operations = [ + migrations.AddField( + model_name='board', + name='banner_image', + field=models.ImageField(blank=True, default=None, null=True, upload_to='board/banner_images', verbose_name='게시판 배너 이미지'), + ), + ] diff --git a/apps/core/migrations/0035_board_banner_description.py b/apps/core/migrations/0035_board_banner_description.py new file mode 100644 index 00000000..c66a646f --- /dev/null +++ b/apps/core/migrations/0035_board_banner_description.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2 on 2022-03-09 14:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0034_board_banner_image'), + ] + + operations = [ + migrations.AddField( + model_name='board', + name='en_banner_description', + field=models.TextField(blank=True, default=None, null=True, verbose_name='게시판 배너에 삽입되는 영문 소개'), + ), + migrations.AddField( + model_name='board', + name='ko_banner_description', + field=models.TextField(blank=True, default=None, null=True, verbose_name='게시판 배너에 삽입되는 국문 소개'), + ), + ] diff --git a/apps/core/migrations/0036_alter_board_banner_image.py b/apps/core/migrations/0036_alter_board_banner_image.py new file mode 100644 index 00000000..a9439d13 --- /dev/null +++ b/apps/core/migrations/0036_alter_board_banner_image.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2022-03-09 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0035_board_banner_description'), + ] + + operations = [ + migrations.AlterField( + model_name='board', + name='banner_image', + field=models.ImageField(default='default_banner.png', upload_to='board_banner_images', verbose_name='게시판 배너 이미지'), + ), + ] diff --git a/apps/core/migrations/0037_board_banner_url.py b/apps/core/migrations/0037_board_banner_url.py new file mode 100644 index 00000000..dc6aee84 --- /dev/null +++ b/apps/core/migrations/0037_board_banner_url.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.9 on 2022-03-14 15:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0036_alter_board_banner_image'), + ] + + operations = [ + migrations.AddField( + model_name='board', + name='banner_url', + field=models.TextField(blank=True, default=None, null=True, verbose_name='게시판 배너를 클릭 시에 이동하는 링크'), + ), + ] diff --git a/apps/core/migrations/0038_rename_is_anonymous_to_name_type.py b/apps/core/migrations/0038_rename_is_anonymous_to_name_type.py new file mode 100644 index 00000000..96381655 --- /dev/null +++ b/apps/core/migrations/0038_rename_is_anonymous_to_name_type.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.9 on 2022-04-04 13:49 + +import apps.core.models.board +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0037_board_banner_url'), + ] + + operations = [ + migrations.RenameField( + model_name='article', + old_name='is_anonymous', + new_name='name_type', + ), + migrations.AlterField( + model_name='article', + name='name_type', + field=models.SmallIntegerField(default=apps.core.models.board.BoardNameType['REGULAR'], verbose_name='익명 혹은 실명 여부'), + ), + migrations.RenameField( + model_name='board', + old_name='is_anonymous', + new_name='name_type', + ), + migrations.AlterField( + model_name='board', + name='name_type', + field=models.SmallIntegerField(db_index=True, default=apps.core.models.board.BoardNameType['REGULAR'], help_text='게시판의 글과 댓글들이 익명 혹은 실명이도록 합니다.', verbose_name='익명/실명 게시판'), + ), + migrations.RenameField( + model_name='comment', + old_name='is_anonymous', + new_name='name_type', + ), + migrations.AlterField( + model_name='comment', + name='name_type', + field=models.SmallIntegerField(default=apps.core.models.board.BoardNameType['REGULAR'], verbose_name='익명 혹은 실명'), + ), + ] diff --git a/apps/core/migrations/0039_add_communication_article.py b/apps/core/migrations/0039_add_communication_article.py new file mode 100644 index 00000000..aa2279e2 --- /dev/null +++ b/apps/core/migrations/0039_add_communication_article.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.9 on 2022-05-05 16:34 + +import apps.core.models.communication_article +import datetime +from django.db import migrations, models +import django.db.models.deletion +from django.utils.timezone import utc +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0038_rename_is_anonymous_to_name_type'), + ] + + operations = [ + migrations.AddField( + model_name='board', + name='is_school_communication', + field=models.BooleanField(db_index=True, default=False, help_text='학교 소통 게시판 글임을 표시', verbose_name='학교와의 소통 게시판'), + ), + migrations.CreateModel( + name='CommunicationArticle', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='생성 시간')), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='수정 시간')), + ('deleted_at', models.DateTimeField(db_index=True, default=datetime.datetime(1, 1, 1, 0, 0, tzinfo=utc), verbose_name='삭제 시간')), + ('response_deadline', models.DateTimeField(default=datetime.datetime(1, 1, 1, 0, 0, tzinfo=utc), verbose_name='답변 요청 기한')), + ('confirmed_by_school_at', models.DateTimeField(default=datetime.datetime(1, 1, 1, 0, 0, tzinfo=utc), verbose_name='학교측 문의 확인 시각')), + ('answered_at', models.DateTimeField(default=datetime.datetime(1, 1, 1, 0, 0, tzinfo=utc), verbose_name='학교측 답변을 받은 시각')), + ('school_response_status', models.SmallIntegerField(default=apps.core.models.communication_article.SchoolResponseStatus['BEFORE_UPVOTE_THRESHOLD'], verbose_name='답변 진행 상황')), + ('article', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='communication_article', to='core.article', verbose_name='게시물')), + ], + options={ + 'verbose_name': '소통 게시물', + 'verbose_name_plural': '소통 게시물 목록', + 'ordering': ('-created_at',), + 'abstract': False, + }, + ), + ] diff --git a/apps/core/migrations/0040_alter_communication_article_field.py b/apps/core/migrations/0040_alter_communication_article_field.py new file mode 100644 index 00000000..6d6b3feb --- /dev/null +++ b/apps/core/migrations/0040_alter_communication_article_field.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.9 on 2022-05-05 17:37 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0039_add_communication_article'), + ] + + operations = [ + migrations.RemoveField( + model_name='communicationarticle', + name='id', + ), + migrations.AlterField( + model_name='board', + name='access_mask', + field=models.IntegerField(default=222, verbose_name='접근 권한 값'), + ), + migrations.AlterField( + model_name='communicationarticle', + name='article', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='communication_article', serialize=False, to='core.article', verbose_name='게시물'), + ), + ] diff --git a/apps/core/migrations/0041_remove_communicationarticle_confirmed_by_school_at.py b/apps/core/migrations/0041_remove_communicationarticle_confirmed_by_school_at.py new file mode 100644 index 00000000..aaf8e9f7 --- /dev/null +++ b/apps/core/migrations/0041_remove_communicationarticle_confirmed_by_school_at.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.9 on 2022-05-12 16:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0040_alter_communication_article_field'), + ] + + operations = [ + migrations.RemoveField( + model_name='communicationarticle', + name='confirmed_by_school_at', + ), + ] diff --git a/apps/core/migrations/0042_add_read_write_access_masks.py b/apps/core/migrations/0042_add_read_write_access_masks.py new file mode 100644 index 00000000..92fc69ca --- /dev/null +++ b/apps/core/migrations/0042_add_read_write_access_masks.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.9 on 2022-05-26 12:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0041_remove_communicationarticle_confirmed_by_school_at'), + ] + + operations = [ + migrations.RemoveField( + model_name='board', + name='access_mask', + ), + migrations.AddField( + model_name='board', + name='read_access_mask', + field=models.SmallIntegerField(default=222, verbose_name='읽기 권한'), + ), + migrations.AddField( + model_name='board', + name='write_access_mask', + field=models.SmallIntegerField(default=218, verbose_name='쓰기 권한'), + ), + ] diff --git a/apps/core/migrations/0043_board_comment_access_mask.py b/apps/core/migrations/0043_board_comment_access_mask.py new file mode 100644 index 00000000..3e735c2c --- /dev/null +++ b/apps/core/migrations/0043_board_comment_access_mask.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.9 on 2022-08-09 17:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0042_add_read_write_access_masks'), + ] + + operations = [ + migrations.AddField( + model_name='board', + name='comment_access_mask', + field=models.SmallIntegerField(default=254, verbose_name='댓글 권한'), + ), + ] diff --git a/apps/core/models/__init__.py b/apps/core/models/__init__.py index e5954f2d..5f019bec 100644 --- a/apps/core/models/__init__.py +++ b/apps/core/models/__init__.py @@ -15,5 +15,5 @@ from .block import * from .scrap import * from .faq import * - +from .communication_article import * from .signals import * diff --git a/apps/core/models/article.py b/apps/core/models/article.py index 642c22d3..76939f19 100644 --- a/apps/core/models/article.py +++ b/apps/core/models/article.py @@ -2,6 +2,7 @@ import typing from enum import Enum from typing import Dict, Union +import json from django.core.files.storage import default_storage @@ -11,14 +12,16 @@ from django.utils.functional import cached_property from django.utils.translation import gettext -from apps.user.views.viewsets import make_random_profile_picture, hashlib +from apps.user.views.viewsets import get_profile_picture, hashlib from ara.classes.decorator import cache_by_user from ara.db.models import MetaDataModel from ara.sanitizer import sanitize -from ara.settings import HASH_SECRET_VALUE +from ara.settings import HASH_SECRET_VALUE, SCHOOL_RESPONSE_VOTE_THRESHOLD, ANSWER_PERIOD from .block import Block from .report import Report from .comment import Comment +from .communication_article import SchoolResponseStatus +from .board import BoardNameType, BoardAccessPermissionType class ArticleHiddenReason(str, Enum): @@ -46,9 +49,9 @@ class Meta(MetaDataModel.Meta): verbose_name='text 형식 본문', ) - is_anonymous = models.BooleanField( - default=False, - verbose_name='익명', + name_type = models.SmallIntegerField( + default=BoardNameType.REGULAR, + verbose_name='익명 혹은 실명 여부', ) is_content_sexual = models.BooleanField( default=False, @@ -69,7 +72,7 @@ class Meta(MetaDataModel.Meta): ) report_count = models.IntegerField( default=0, - verbose_name ='신고 수', + verbose_name='신고 수', ) positive_vote_count = models.IntegerField( default=0, @@ -168,25 +171,27 @@ def save(self, force_insert=False, force_update=False, using=None, update_fields super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) def update_hit_count(self): - self.hit_count = self.article_read_log_set.values('read_by').annotate(models.Count('read_by')).order_by('read_by__count',).count() + self.migrated_hit_count + self.hit_count = self.article_read_log_set.values('read_by').annotate(models.Count('read_by')).order_by( + 'read_by__count', ).count() + self.migrated_hit_count self.save() def update_comment_count(self): - self.comment_count = Comment.objects.filter(deleted_at=timezone.datetime.min.replace(tzinfo=timezone.utc)).filter( + self.comment_count = Comment.objects.filter( + deleted_at=timezone.datetime.min.replace(tzinfo=timezone.utc)).filter( models.Q(parent_article=self) | models.Q(parent_comment__parent_article=self) ).count() self.save() - + @transaction.atomic def update_report_count(self): count = Report.objects.filter(parent_article=self).count() self.report_count = count if count >= int(settings.REPORT_THRESHOLD): - self.hidden_at = timezone.now() + self.hidden_at = timezone.now() self.save() @@ -194,6 +199,13 @@ def update_vote_status(self): self.positive_vote_count = self.vote_set.filter(is_positive=True).count() + self.migrated_positive_vote_count self.negative_vote_count = self.vote_set.filter(is_positive=False).count() + self.migrated_negative_vote_count + if self.parent_board.is_school_communication and \ + self.positive_vote_count >= SCHOOL_RESPONSE_VOTE_THRESHOLD and \ + self.communication_article.school_response_status == SchoolResponseStatus.BEFORE_UPVOTE_THRESHOLD: + self.communication_article.school_response_status = SchoolResponseStatus.BEFORE_SCHOOL_CONFIRM + self.communication_article.response_deadline = (timezone.localtime() + timezone.timedelta(days=ANSWER_PERIOD + 1)).date() + self.communication_article.save() + self.save() def is_hidden_by_reported(self) -> bool: @@ -206,15 +218,16 @@ def created_by_nickname(self): # API 상에서 보이는 사용자 (익명일 경우 익명화된 글쓴이, 그 외는 그냥 글쓴이) @cached_property def postprocessed_created_by(self) -> Union[settings.AUTH_USER_MODEL, Dict]: - if not self.is_anonymous: + if self.name_type == BoardNameType.REGULAR: return self.created_by - else: - user_unique_num = self.created_by.id + self.id + HASH_SECRET_VALUE - user_unique_encoding = str(hex(user_unique_num)).encode('utf-8') - user_hash = hashlib.sha224(user_unique_encoding).hexdigest() - user_hash_int = int(user_hash[-4:], 16) - user_profile_picture = make_random_profile_picture(user_hash_int) + user_unique_num = self.created_by.id + self.id + HASH_SECRET_VALUE + user_unique_encoding = str(hex(user_unique_num)).encode('utf-8') + user_hash = hashlib.sha224(user_unique_encoding).hexdigest() + user_hash_int = int(user_hash[-4:], 16) + user_profile_picture = get_profile_picture(user_hash_int) + + if self.name_type == BoardNameType.ANONYMOUS: return { 'id': user_hash, 'username': gettext('anonymous'), @@ -225,6 +238,18 @@ def postprocessed_created_by(self) -> Union[settings.AUTH_USER_MODEL, Dict]: }, } + if self.name_type == BoardNameType.REALNAME: + user_realname = self.created_by.profile.realname + return { + 'id': user_unique_num, + 'username': user_realname, + 'profile': { + 'picture': default_storage.url(user_profile_picture), + 'nickname': user_realname, + 'user': user_realname + }, + } + @cache_by_user def hidden_reasons(self, user: settings.AUTH_USER_MODEL) -> typing.List: reasons = [] @@ -237,7 +262,8 @@ def hidden_reasons(self, user: settings.AUTH_USER_MODEL) -> typing.List: if self.is_content_social and not user.profile.see_social: reasons.append(ArticleHiddenReason.SOCIAL_CONTENT) # 혹시 몰라 여기 두기는 하는데 여기 오기전에 Permission에서 막혀야 함 - if not self.parent_board.group_has_access(user.profile.group): + if not self.parent_board.group_has_access_permission( + BoardAccessPermissionType.READ, user.profile.group): reasons.append(ArticleHiddenReason.ACCESS_DENIED_CONTENT) return reasons diff --git a/apps/core/models/board.py b/apps/core/models/board.py index c088eda3..a589ad93 100644 --- a/apps/core/models/board.py +++ b/apps/core/models/board.py @@ -1,10 +1,22 @@ from django.db import models - +from enum import IntEnum from django_extensions.db.fields import AutoSlugField from ara.db.models import MetaDataModel +class BoardNameType(IntEnum): + REGULAR = 0 + ANONYMOUS = 1 + REALNAME = 2 + + +class BoardAccessPermissionType(IntEnum): + READ = 0 + WRITE = 1 + COMMENT = 2 + + class Board(MetaDataModel): class Meta(MetaDataModel.Meta): verbose_name = '게시판' @@ -35,13 +47,27 @@ class Meta(MetaDataModel.Meta): ) # 사용자 그룹에 대해 접근 권한을 제어하는 bit mask 입니다. - # access_mask & (1< 0 일 때 접근이 가능합니다. + # access_mask & (1 << user.group) > 0 일 때 접근이 가능합니다. # 사용자 그룹의 값들은 `UserGroup`을 참고하세요. - access_mask = models.IntegerField( - default=2, # 카이스트 구성원만 사용 가능 + read_access_mask = models.SmallIntegerField( + # UNAUTHORIZED, EXTERNAL_ORG 제외 모든 사용자 읽기 권한 부여 + default=0b11011110, + null=False, + verbose_name='읽기 권한' + ) + write_access_mask = models.SmallIntegerField( + # UNAUTHORIZED, STORE_EMPLOYEE, EXTERNAL_ORG 제외 모든 사용자 쓰기 권한 부여 + default=0b11011010, null=False, - verbose_name='접근 권한 값' + verbose_name='쓰기 권한' + ) + comment_access_mask = models.SmallIntegerField( + # UNAUTHORIZED 제외 모든 사용자 댓글 권한 부여 + default=0b11111110, + null=False, + verbose_name='댓글 권한' ) + is_readonly = models.BooleanField( verbose_name='읽기 전용 게시판', help_text='활성화했을 때 관리자만 글을 쓸 수 있습니다. (ex. 포탈공지)', @@ -54,15 +80,68 @@ class Meta(MetaDataModel.Meta): db_index=True, ) - is_anonymous = models.BooleanField( - verbose_name='익명 게시판', - help_text='게시판의 글과 댓글들이 익명이도록 합니다.', + name_type = models.SmallIntegerField( + verbose_name='익명/실명 게시판', + help_text='게시판의 글과 댓글들이 익명 혹은 실명이도록 합니다.', + default=BoardNameType.REGULAR, + db_index=True + ) + + is_school_communication = models.BooleanField( + verbose_name='학교와의 소통 게시판', + help_text='학교 소통 게시판 글임을 표시', default=False, db_index=True ) + group_id = models.IntegerField( + verbose_name='그룹 ID', + default=1 + ) + + banner_image = models.ImageField( + default='default_banner.png', + upload_to='board_banner_images', + verbose_name='게시판 배너 이미지', + ) + + ko_banner_description = models.TextField( + null=True, + blank=True, + default=None, + verbose_name='게시판 배너에 삽입되는 국문 소개', + ) + + en_banner_description = models.TextField( + null=True, + blank=True, + default=None, + verbose_name='게시판 배너에 삽입되는 영문 소개', + ) + + banner_url = models.TextField( + null=True, + blank=True, + default=None, + verbose_name='게시판 배너를 클릭 시에 이동하는 링크', + ) + def __str__(self) -> str: return self.ko_name - - def group_has_access(self, group: int) -> bool: - return (self.access_mask & (1 << group)) > 0 + + def group_has_access_permission( + self, + access_type: BoardAccessPermissionType, + group: int) -> bool: + mask = None + if access_type == BoardAccessPermissionType.READ: + mask = self.read_access_mask + elif access_type == BoardAccessPermissionType.WRITE: + mask = self.write_access_mask + elif access_type == BoardAccessPermissionType.COMMENT: + mask = self.comment_access_mask + else: + # TODO: Handle error + return False + + return (mask & (1 << group)) > 0 diff --git a/apps/core/models/comment.py b/apps/core/models/comment.py index 50f600cf..c7908e7d 100644 --- a/apps/core/models/comment.py +++ b/apps/core/models/comment.py @@ -2,6 +2,7 @@ from enum import Enum from typing import Dict, Union import hashlib +import json from django.core.files.storage import default_storage from django.db import models, IntegrityError @@ -11,13 +12,14 @@ from django.utils.functional import cached_property from django.utils.translation import gettext -from apps.user.views.viewsets import NOUNS, make_random_profile_picture +from apps.user.views.viewsets import NOUNS, get_profile_picture from ara.classes.decorator import cache_by_user from ara.db.models import MetaDataModel, MetaDataQuerySet from ara.sanitizer import sanitize from ara.settings import HASH_SECRET_VALUE from .block import Block from .report import Report +from .board import BoardNameType class CommentHiddenReason(Enum): @@ -38,9 +40,9 @@ class Meta(MetaDataModel.Meta): verbose_name='본문', ) - is_anonymous = models.BooleanField( - default=False, - verbose_name='익명', + name_type = models.SmallIntegerField( + default=BoardNameType.REGULAR, + verbose_name='익명 혹은 실명', ) report_count = models.IntegerField( @@ -155,21 +157,22 @@ def update_report_count(self): # API 상에서 보이는 사용자 (익명일 경우 익명화된 글쓴이, 그 외는 그냥 글쓴이) @cached_property def postprocessed_created_by(self) -> Union[settings.AUTH_USER_MODEL, Dict]: - if not self.is_anonymous: + if self.name_type == BoardNameType.REGULAR: return self.created_by - else: - parent_article = self.get_parent_article() - parent_article_id = parent_article.id - parent_article_created_by_id = parent_article.created_by.id - comment_created_by_id = self.created_by.id - - # 댓글 작성자는 (작성자 id + parent article id + 시크릿)을 해시한 값으로 구별합니다. - user_unique_num = comment_created_by_id + parent_article_id + HASH_SECRET_VALUE - user_unique_encoding = str(hex(user_unique_num)).encode('utf-8') - user_hash = hashlib.sha224(user_unique_encoding).hexdigest() - user_hash_int = int(user_hash[-4:], 16) - user_profile_picture = make_random_profile_picture(user_hash_int) - + + parent_article = self.get_parent_article() + parent_article_id = parent_article.id + parent_article_created_by_id = parent_article.created_by.id + comment_created_by_id = self.created_by.id + + # 댓글 작성자는 (작성자 id + parent article id + 시크릿)을 해시한 값으로 구별합니다. + user_unique_num = comment_created_by_id + parent_article_id + HASH_SECRET_VALUE + user_unique_encoding = str(hex(user_unique_num)).encode('utf-8') + user_hash = hashlib.sha224(user_unique_encoding).hexdigest() + user_hash_int = int(user_hash[-4:], 16) + user_profile_picture = get_profile_picture(user_hash_int) + + if self.name_type == BoardNameType.ANONYMOUS: if parent_article_created_by_id == comment_created_by_id: user_name = gettext('author') else: @@ -181,9 +184,30 @@ def postprocessed_created_by(self) -> Union[settings.AUTH_USER_MODEL, Dict]: 'profile': { 'picture': default_storage.url(user_profile_picture), 'nickname': user_name, - 'user': user_hash + 'user': user_hash, + 'is_official': None, + 'is_school_admin': None, } } + + if self.name_type == BoardNameType.REALNAME: + if parent_article_created_by_id == comment_created_by_id: + user_realname = gettext('author') + else: + user_realname = self.created_by.profile.realname + + return { + 'id': user_unique_num, + 'username': user_realname, + 'profile': { + 'picture': default_storage.url(user_profile_picture), + 'nickname': user_realname, + 'user': user_realname, + 'is_official': None, + 'is_school_admin': self.created_by.profile.is_school_admin if parent_article.parent_board.is_school_communication else None + }, + } + @cache_by_user def hidden_reasons(self, user: settings.AUTH_USER_MODEL) -> typing.List: diff --git a/apps/core/models/communication_article.py b/apps/core/models/communication_article.py new file mode 100644 index 00000000..fa50c0ca --- /dev/null +++ b/apps/core/models/communication_article.py @@ -0,0 +1,61 @@ +import sys + +from cached_property import cached_property +from django.db import models +from django.utils import timezone +from django.contrib import admin + +from ara.db.models import MetaDataModel + +from enum import IntEnum + + +class SchoolResponseStatus(IntEnum): + BEFORE_UPVOTE_THRESHOLD = 0 + BEFORE_SCHOOL_CONFIRM = 1 + ANSWER_DONE = 2 + + +class CommunicationArticle(MetaDataModel): + class Meta(MetaDataModel.Meta): + verbose_name = '소통 게시물' + verbose_name_plural = '소통 게시물 목록' + + article = models.OneToOneField( + to='core.Article', + on_delete=models.CASCADE, + related_name='communication_article', + db_index=True, + verbose_name='게시물', + primary_key=True, + ) + + response_deadline = models.DateTimeField( + default=timezone.datetime.min.replace(tzinfo=timezone.utc), + verbose_name='답변 요청 기한', + ) + + answered_at = models.DateTimeField( + default=timezone.datetime.min.replace(tzinfo=timezone.utc), + verbose_name='학교측 답변을 받은 시각', + ) + + school_response_status = models.SmallIntegerField( + default=SchoolResponseStatus.BEFORE_UPVOTE_THRESHOLD, + verbose_name='답변 진행 상황', + ) + + @admin.display(description='진행 상황') + def get_status_string(self) -> str: + status_list = ['소통 중', '답변 대기 중', '답변 완료'] + return status_list[self.school_response_status] + + @cached_property + def days_left(self) -> int: + if self.response_deadline == timezone.datetime.min.replace(tzinfo=timezone.utc): + return sys.maxsize + else: + return (self.response_deadline.astimezone(timezone.localtime().tzinfo) - timezone.localtime()).days + + def __str__(self): + return self.article.title diff --git a/apps/core/models/signals/comment.py b/apps/core/models/signals/comment.py index 8be6354b..1cab8f9c 100644 --- a/apps/core/models/signals/comment.py +++ b/apps/core/models/signals/comment.py @@ -3,6 +3,8 @@ from django.utils import timezone from apps.core.models import Comment, Notification +from apps.core.models.communication_article import SchoolResponseStatus +from apps.user.models import UserProfile @receiver(models.signals.post_save, sender=Comment) @@ -12,7 +14,6 @@ def notify_commented(comment): def update_article_comment_count(comment): parent_article = comment.get_parent_article() - parent_article.update_comment_count() def update_article_commented_at(comment): @@ -20,10 +21,20 @@ def update_article_commented_at(comment): article.commented_at = timezone.now() article.save() + def update_communication_article_status(comment): + article = comment.parent_article if comment.parent_article else comment.parent_comment.parent_article + if article.parent_board.is_school_communication and \ + comment.created_by.profile.group == UserProfile.UserGroup.COMMUNICATION_BOARD_ADMIN and \ + article.communication_article.school_response_status != SchoolResponseStatus.ANSWER_DONE : + article.communication_article.answered_at = timezone.now() + article.communication_article.school_response_status = SchoolResponseStatus.ANSWER_DONE + article.communication_article.save() + if created: notify_commented(instance) update_article_comment_count(instance) update_article_commented_at(instance) + update_communication_article_status(instance) else: # in case of MetaDataModel's instance deleted diff --git a/apps/core/permissions/article.py b/apps/core/permissions/article.py index 748845c5..60e9f56a 100644 --- a/apps/core/permissions/article.py +++ b/apps/core/permissions/article.py @@ -1,19 +1,20 @@ from rest_framework import permissions from apps.core.models import Article +from apps.core.models.board import BoardAccessPermissionType class ArticlePermission(permissions.IsAuthenticated): def has_object_permission(self, request, view, obj): if request.method not in permissions.SAFE_METHODS: return request.user.is_staff or request.user == obj.created_by - return super().has_object_permission(request, view, obj) -# TODO: Rename to ArticleGroupPermission -class ArticleKAISTPermission(permissions.BasePermission): - message = 'KAIST 구성원만 읽을 수 있는 게시물입니다.' +class ArticleReadPermission(permissions.BasePermission): + message = '해당 게시물에 대한 읽기 권한이 없습니다.' def has_object_permission(self, request, view, obj: Article): - return obj.parent_board.group_has_access(request.user.profile.group) + return obj.parent_board.group_has_access_permission( + BoardAccessPermissionType.READ, + request.user.profile.group) diff --git a/apps/core/permissions/communication_article.py b/apps/core/permissions/communication_article.py new file mode 100644 index 00000000..ad5d863d --- /dev/null +++ b/apps/core/permissions/communication_article.py @@ -0,0 +1,11 @@ +from rest_framework import permissions + +from apps.user.models import UserProfile + + +class CommunicationArticleAdminPermission(permissions.IsAuthenticated): + message = 'You are not authorized to access this feature' + def has_permission(self, request, view): + return request.user.is_staff or \ + request.user.profile.group == UserProfile.UserGroup.COMMUNICATION_BOARD_ADMIN + diff --git a/apps/core/serializers/article.py b/apps/core/serializers/article.py index 3fa2e92b..918abb31 100644 --- a/apps/core/serializers/article.py +++ b/apps/core/serializers/article.py @@ -2,9 +2,11 @@ from enum import Enum from django.utils.translation import gettext -from rest_framework import serializers +from rest_framework import serializers, exceptions + from apps.core.documents import ArticleDocument -from apps.core.models import Article, Board, Block, Scrap, ArticleHiddenReason +from apps.core.models import Article, Board, Block, Scrap, ArticleHiddenReason, Comment +from apps.core.models.board import BoardNameType, BoardAccessPermissionType from apps.core.serializers.board import BoardSerializer from apps.core.serializers.mixins.hidden import HiddenSerializerMixin, HiddenSerializerFieldMixin from apps.core.serializers.topic import TopicSerializer @@ -57,7 +59,7 @@ def get_content(self, obj) -> typing.Optional[str]: return None def get_created_by(self, obj) -> dict: - if obj.is_anonymous: + if obj.name_type in (BoardNameType.ANONYMOUS, BoardNameType.REALNAME): return obj.postprocessed_created_by else: data = PublicUserSerializer(obj.postprocessed_created_by).data @@ -115,7 +117,12 @@ class Meta(BaseArticleSerializer.Meta): class ArticleSerializer(HiddenSerializerFieldMixin, BaseArticleSerializer): class Meta(BaseArticleSerializer.Meta): - exclude = ('migrated_hit_count', 'migrated_positive_vote_count', 'migrated_negative_vote_count', 'content_text',) + exclude = ( + 'migrated_hit_count', + 'migrated_positive_vote_count', + 'migrated_negative_vote_count', + 'content_text' + ) @staticmethod def search_articles(queryset, search): @@ -196,7 +203,7 @@ def get_side_articles(self, obj) -> dict: after = after_scrap.parent_article else: after = None - + else: before = articles.filter(created_at__lte=obj.created_at).first() after = articles.filter(created_at__gte=obj.created_at).last() @@ -265,11 +272,19 @@ def get_side_articles_of_recent_article(self, obj, request): after = None if len(after) == 0 else after[0] return after, before - def get_attachments(self, obj): # -> typing.Optional[list]: + def get_attachments(self, obj): # -> typing.Optional[list]: if self.visible_verdict(obj): return obj.attachments.all().values_list('id') return None + def get_my_comment_profile(self, obj): + fake_comment = Comment(created_by=self.context['request'].user, name_type=obj.name_type, parent_article=obj) + if obj.name_type in (BoardNameType.ANONYMOUS, BoardNameType.REALNAME): + return fake_comment.postprocessed_created_by + else: + data = PublicUserSerializer(fake_comment.postprocessed_created_by).data + return data + parent_topic = TopicSerializer( read_only=True, ) @@ -281,6 +296,10 @@ def get_attachments(self, obj): # -> typing.Optional[list]: read_only=True, ) + my_comment_profile = serializers.SerializerMethodField( + read_only=True + ) + from apps.core.serializers.comment import ArticleNestedCommentListActionSerializer comments = ArticleNestedCommentListActionSerializer( many=True, @@ -312,6 +331,25 @@ def get_attachments(self, obj): # -> typing.Optional[list]: side_articles = serializers.SerializerMethodField( read_only=True, ) + communication_article_status = serializers.SerializerMethodField( + read_only=True, + ) + + days_left = serializers.SerializerMethodField( + read_only=True, + ) + + @staticmethod + def get_days_left(obj): + if hasattr(obj, 'communication_article'): + return obj.communication_article.days_left + return None + + @staticmethod + def get_communication_article_status(obj): + if hasattr(obj, 'communication_article'): + return obj.communication_article.school_response_status + return None class ArticleAttachmentType(Enum): @@ -342,6 +380,13 @@ class ArticleListActionSerializer(HiddenSerializerFieldMixin, BaseArticleSeriali read_only=True, ) + communication_article_status = serializers.SerializerMethodField( + read_only=True, + ) + + days_left = serializers.SerializerMethodField( + read_only=True, + ) def get_attachment_type(self, obj) -> str: if not self.visible_verdict(obj): return ArticleAttachmentType.NONE.value @@ -361,6 +406,18 @@ def get_attachment_type(self, obj) -> str: return ArticleAttachmentType.NON_IMAGE.value return ArticleAttachmentType.NONE.value + @staticmethod + def get_communication_article_status(obj): + if hasattr(obj, 'communication_article'): + return obj.communication_article.school_response_status + return None + + @staticmethod + def get_days_left(obj): + if hasattr(obj, 'communication_article'): + return obj.communication_article.days_left + return None + class BestArticleListActionSerializer(HiddenSerializerFieldMixin, BaseArticleSerializer): title = serializers.SerializerMethodField( @@ -373,7 +430,8 @@ class BestArticleListActionSerializer(HiddenSerializerFieldMixin, BaseArticleSer class ArticleCreateActionSerializer(BaseArticleSerializer): class Meta(BaseArticleSerializer.Meta): - exclude = ('migrated_hit_count', 'migrated_positive_vote_count', 'migrated_negative_vote_count', 'content_text',) + exclude = ( + 'migrated_hit_count', 'migrated_positive_vote_count', 'migrated_negative_vote_count', 'content_text',) read_only_fields = ( 'hit_count', 'comment_count', @@ -385,19 +443,22 @@ class Meta(BaseArticleSerializer.Meta): def validate_parent_board(self, board: Board): user_is_superuser = self.context['request'].user.is_superuser - user_has_perm = board.group_has_access(self.context['request'].user.profile.group) if not user_is_superuser and board.is_readonly: raise serializers.ValidationError(gettext('This board is read only.')) - if not user_has_perm: - raise serializers.ValidationError(gettext('This board is only for KAIST members.')) + user_has_write_permission = board.group_has_access_permission( + BoardAccessPermissionType.WRITE, + self.context['request'].user.profile.group) + if not user_has_write_permission: + raise exceptions.PermissionDenied() return board class ArticleUpdateActionSerializer(BaseArticleSerializer): class Meta(BaseArticleSerializer.Meta): - exclude = ('migrated_hit_count', 'migrated_positive_vote_count', 'migrated_negative_vote_count', 'content_text',) + exclude = ( + 'migrated_hit_count', 'migrated_positive_vote_count', 'migrated_negative_vote_count', 'content_text',) read_only_fields = ( - 'is_anonymous', + 'name_type', 'hit_count', 'comment_count', 'positive_vote_count', diff --git a/apps/core/serializers/board.py b/apps/core/serializers/board.py index 125c30ae..f4d3da19 100644 --- a/apps/core/serializers/board.py +++ b/apps/core/serializers/board.py @@ -1,8 +1,7 @@ from rest_framework import serializers from ara.classes.serializers import MetaDataModelSerializer - -from apps.core.models import Board +from apps.core.models.board import Board, BoardAccessPermissionType class BaseBoardSerializer(MetaDataModelSerializer): @@ -22,3 +21,17 @@ class BoardDetailActionSerializer(BaseBoardSerializer): read_only=True, source='topic_set', ) + user_readable = serializers.SerializerMethodField() + user_writable = serializers.SerializerMethodField() + + def get_user_readable(self, obj): + user = self.context['request'].user + return obj.group_has_access_permission( + BoardAccessPermissionType.READ, + user.profile.group) + + def get_user_writable(self, obj): + user = self.context['request'].user + return obj.group_has_access_permission( + BoardAccessPermissionType.WRITE, + user.profile.group) diff --git a/apps/core/serializers/comment.py b/apps/core/serializers/comment.py index 5409f4bb..c792b90d 100644 --- a/apps/core/serializers/comment.py +++ b/apps/core/serializers/comment.py @@ -1,5 +1,6 @@ from rest_framework import serializers import typing +from apps.core.models.board import BoardNameType from apps.core.serializers.mixins.hidden import HiddenSerializerFieldMixin, HiddenSerializerMixin from ara.classes.serializers import MetaDataModelSerializer @@ -33,7 +34,7 @@ def get_content(self, obj) -> typing.Optional[str]: return None def get_created_by(self, obj) -> dict: - if obj.is_anonymous: + if obj.name_type in (BoardNameType.ANONYMOUS, BoardNameType.REALNAME): return obj.postprocessed_created_by else: data = PublicUserSerializer(obj.postprocessed_created_by).data @@ -43,9 +44,6 @@ def get_created_by(self, obj) -> dict: class CommentSerializer(HiddenSerializerFieldMixin, BaseCommentSerializer): from apps.user.serializers.user import PublicUserSerializer - created_by = PublicUserSerializer( - read_only=True, - ) my_vote = serializers.SerializerMethodField( read_only=True, ) @@ -62,9 +60,6 @@ class CommentSerializer(HiddenSerializerFieldMixin, BaseCommentSerializer): class CommentListActionSerializer(HiddenSerializerFieldMixin, BaseCommentSerializer): from apps.user.serializers.user import PublicUserSerializer - created_by = PublicUserSerializer( - read_only=True, - ) my_vote = serializers.SerializerMethodField( read_only=True, ) @@ -99,8 +94,7 @@ class Meta(BaseCommentSerializer.Meta): 'created_by', ) - from apps.user.serializers.user import PublicUserSerializer - created_by = PublicUserSerializer( + created_by = serializers.SerializerMethodField( read_only=True, ) @@ -108,7 +102,7 @@ class Meta(BaseCommentSerializer.Meta): class CommentUpdateActionSerializer(BaseCommentSerializer): class Meta(BaseCommentSerializer.Meta): read_only_fields = ( - 'is_anonymous', + 'name_type', 'positive_vote_count', 'negative_vote_count', 'created_by', diff --git a/apps/core/views/router.py b/apps/core/views/router.py index 8afe7d11..1ec37c6e 100644 --- a/apps/core/views/router.py +++ b/apps/core/views/router.py @@ -52,16 +52,14 @@ viewset=viewsets.NotificationViewSet, ) - # FAQViewSet router.register( prefix=r'faqs', viewset=viewsets.FAQViewSet, ) - # BestSearchViewSet router.register( - prefix=f'best_searches', + prefix=r'best_searches', viewset=viewsets.BestSearchViewSet, ) diff --git a/apps/core/views/viewsets/article.py b/apps/core/views/viewsets/article.py index 2d2e1257..01070933 100644 --- a/apps/core/views/viewsets/article.py +++ b/apps/core/views/viewsets/article.py @@ -5,9 +5,11 @@ from django.utils.translation import gettext from rest_framework import status, viewsets, response, decorators, serializers, permissions from rest_framework.response import Response +from apps.core.models.board import BoardNameType from ara import redis from ara.classes.viewset import ActionAPIViewSet +from ara.settings import SCHOOL_RESPONSE_VOTE_THRESHOLD from apps.core.models import ( Article, @@ -17,10 +19,13 @@ Board, Comment, Vote, - Scrap, + Scrap, CommunicationArticle, ) from apps.core.filters.article import ArticleFilter -from apps.core.permissions.article import ArticlePermission, ArticleKAISTPermission +from apps.core.permissions.article import ( + ArticlePermission, + ArticleReadPermission +) from apps.core.serializers.article import ( ArticleSerializer, ArticleListActionSerializer, @@ -33,7 +38,10 @@ class ArticleViewSet(viewsets.ModelViewSet, ActionAPIViewSet): queryset = Article.objects.all() + filterset_class = ArticleFilter + ordering_fields = ['created_at', 'positive_vote_count'] + serializer_class = ArticleSerializer action_serializer_class = { 'list': ArticleListActionSerializer, @@ -46,20 +54,24 @@ class ArticleViewSet(viewsets.ModelViewSet, ActionAPIViewSet): } permission_classes = ( ArticlePermission, - ArticleKAISTPermission + ArticleReadPermission, ) action_permission_classes = { + 'create': ( + permissions.IsAuthenticated, + # Check WritePermission in ArticleCreateActionSerializer + ), 'vote_cancel': ( permissions.IsAuthenticated, - ArticleKAISTPermission + ArticleReadPermission, ), 'vote_positive': ( permissions.IsAuthenticated, - ArticleKAISTPermission + ArticleReadPermission, ), 'vote_negative': ( permissions.IsAuthenticated, - ArticleKAISTPermission + ArticleReadPermission, ), } @@ -72,11 +84,14 @@ def filter_queryset(self, queryset): elif self.action == 'list': created_by = self.request.query_params.get('created_by') if created_by and int(created_by) != self.request.user.id: - queryset = queryset.exclude(is_anonymous=True) + # exclude someone's anonymous or realname article in one's profile + exclude_list = [BoardNameType.ANONYMOUS, BoardNameType.REALNAME] + queryset = queryset.exclude(name_type__in=exclude_list) + # exclude article written by blocked users in anonymous board queryset = queryset.exclude( created_by__id__in=self.request.user.block_set.values('user'), - is_anonymous=True + name_type=BoardNameType.ANONYMOUS ) # optimizing queryset for list action @@ -130,9 +145,16 @@ def filter_queryset(self, queryset): def perform_create(self, serializer): serializer.save( created_by=self.request.user, - is_anonymous=Board.objects.get(pk=self.request.data['parent_board']).is_anonymous + name_type=Board.objects.get(pk=self.request.data['parent_board']).name_type ) + instance = serializer.instance + if Board.objects.get(pk=self.request.data['parent_board']).is_school_communication: + communication_article = CommunicationArticle.objects.create( + article=instance, + ) + communication_article.save() + def update(self, request, *args, **kwargs): article = self.get_object() if article.is_hidden_by_reported(): @@ -193,6 +215,9 @@ def retrieve(self, request, *args, **kwargs): pipe.execute(raise_on_error=True) serialized = ArticleSerializer(article, context={'request': request, 'override_hidden': override_hidden}) + + if not request.user.profile.is_school_admin: + serialized.days_left = None return Response(serialized.data) @decorators.action(detail=True, methods=['post']) @@ -203,6 +228,10 @@ def vote_cancel(self, request, *args, **kwargs): return response.Response({'message': gettext('Cannot cancel vote on articles hidden by reports')}, status=status.HTTP_403_FORBIDDEN) + if article.name_type == BoardNameType.REALNAME and article.positive_vote_count >= SCHOOL_RESPONSE_VOTE_THRESHOLD: + return response.Response({'message': gettext('It is not available to cancel a vote for a real name article with more than 30 votes.')}, + status=status.HTTP_405_METHOD_NOT_ALLOWED) + if not Vote.objects.filter( voted_by=request.user, parent_article=article, @@ -299,7 +328,8 @@ def recent(self, request, *args, **kwargs): self.paginate_queryset(count_queryset) - query_params = [self.request.user.id, self.paginator.get_page_size(request), max(0, self.paginator.page.start_index()-1)] + query_params = [self.request.user.id, self.paginator.get_page_size(request), + max(0, self.paginator.page.start_index() - 1)] if search_keyword: query_params.insert(1, id_set) @@ -315,7 +345,7 @@ def recent(self, request, *args, **kwargs): LIMIT %s OFFSET %s ) recents ON recents.article_id = `core_article`.id ORDER BY recents.my_last_read_at desc - ''', + ''', query_params ).prefetch_related( 'created_by', diff --git a/apps/core/views/viewsets/comment.py b/apps/core/views/viewsets/comment.py index 340cea6d..cd33d868 100644 --- a/apps/core/views/viewsets/comment.py +++ b/apps/core/views/viewsets/comment.py @@ -1,7 +1,10 @@ from django.conf import settings +from django.utils import timezone +from django.shortcuts import get_object_or_404 from django.utils.translation import gettext from rest_framework import mixins, status, response, decorators, serializers, permissions +from apps.core.models.board import BoardAccessPermissionType from ara.classes.viewset import ActionAPIViewSet @@ -54,20 +57,34 @@ class CommentViewSet(mixins.CreateModelMixin, } def create(self, request, *args, **kwargs): - return super().create(request, *args, **kwargs) + if self.request.data.get('parent_article') is None: + comment_queryset = Comment.objects.all() + parent_comment = get_object_or_404(comment_queryset, pk=self.request.data['parent_comment']) + parent_article = parent_comment.parent_article + else: + article_queryset = Article.objects.all() + parent_article = get_object_or_404(article_queryset, pk=self.request.data['parent_article']) + # TODO: Use CommentPermission for permission checking logic + # self.check_object_permissions(request, parent_article) + + # Check permission + user_group = request.user.profile.group + if parent_article.parent_board.group_has_access_permission(BoardAccessPermissionType.COMMENT, user_group): + return super().create(request, *args, **kwargs) + return response.Response({'message': gettext('Permission denied')}, + status=status.HTTP_403_FORBIDDEN) def perform_create(self, serializer): parent_article_id = self.request.data.get('parent_article') - parent_article = parent_article_id and Article.objects.filter(id=parent_article_id).first() - parent_article_is_anonymous = (parent_article and parent_article.is_anonymous) or False - + parent_article = parent_article_id and Article.objects.get(id=parent_article_id) parent_comment_id = self.request.data.get('parent_comment') - parent_comment = parent_comment_id and Comment.objects.filter(id=parent_comment_id).first() - parent_comment_is_anonymous = (parent_comment and parent_comment.is_anonymous) or False + parent_comment = parent_comment_id and Comment.objects.get(id=parent_comment_id) + + name_type = parent_article.name_type if parent_article else parent_comment.name_type serializer.save( created_by=self.request.user, - is_anonymous=parent_article_is_anonymous or parent_comment_is_anonymous, + name_type=name_type, ) def retrieve(self, request, *args, **kwargs): diff --git a/apps/user/migrations/0017_add_user_group_options.py b/apps/user/migrations/0017_add_user_group_options.py new file mode 100644 index 00000000..80abcbee --- /dev/null +++ b/apps/user/migrations/0017_add_user_group_options.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.9 on 2022-04-25 11:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0016_set_created_at_to_timezone_now'), + ] + + operations = [ + migrations.AlterField( + model_name='manualuser', + name='org_type', + field=models.IntegerField(choices=[(0, 'Unauthorized user'), (1, 'KAIST member'), (2, 'Store employee'), (3, 'Other member'), (4, 'KAIST organization'), (5, 'External organization'), (6, 'Communication board admin'), (7, 'News board admin')], default=0), + ), + migrations.AlterField( + model_name='userprofile', + name='group', + field=models.IntegerField(choices=[(0, 'Unauthorized user'), (1, 'KAIST member'), (2, 'Store employee'), (3, 'Other member'), (4, 'KAIST organization'), (5, 'External organization'), (6, 'Communication board admin'), (7, 'News board admin')], default=0), + ), + ] diff --git a/apps/user/models/user_profile.py b/apps/user/models/user_profile.py index ead98e37..b4424919 100644 --- a/apps/user/models/user_profile.py +++ b/apps/user/models/user_profile.py @@ -1,3 +1,5 @@ +import json + from cached_property import cached_property from dateutil.relativedelta import relativedelta from django.db import models @@ -20,10 +22,16 @@ class Meta(MetaDataModel.Meta): ) class UserGroup(models.IntegerChoices): - UNAUTHORIZED = 0, ugettext_lazy('Unauthorized user') - KAIST_MEMBER = 1, ugettext_lazy('KAIST member') - FOOD_EMPLOYEE = 2, ugettext_lazy('Restaurant employee') - OTHER_EMPLOYEE = 3, ugettext_lazy('Other employee') + UNAUTHORIZED = 0, ugettext_lazy('Unauthorized user') # 뉴아라 계정을 만들지 않은 사람들 + KAIST_MEMBER = 1, ugettext_lazy('KAIST member') # 카이스트 메일을 가진 사람 (학생, 교직원) + STORE_EMPLOYEE = 2, ugettext_lazy('Store employee') # 교내 입주 업체 직원 + OTHER_MEMBER = 3, ugettext_lazy('Other member') # 카이스트 메일이 없는 개인 (특수한 관련자 등) + KAIST_ORG = 4, ugettext_lazy('KAIST organization') # 교내 학생 단체들 + EXTERNAL_ORG = 5, ugettext_lazy('External organization') # 외부인 (홍보 계정 등) + COMMUNICATION_BOARD_ADMIN = 6, ugettext_lazy('Communication board admin') # 소통게시판 관리인 + NEWS_BOARD_ADMIN = 7, ugettext_lazy('News board admin') # 뉴스게시판 관리인 + + OFFICIAL_GROUPS = [UserGroup.STORE_EMPLOYEE, UserGroup.KAIST_ORG] uid = models.CharField( null=True, @@ -122,3 +130,17 @@ def can_change_nickname(self) -> bool: def email(self) -> str: return self.user.email + @cached_property + def realname(self) -> str: + sso_info = self.sso_user_info + user_realname = json.loads(sso_info["kaist_info"])["ku_kname"] if sso_info["kaist_info"] else sso_info["last_name"] + sso_info["first_name"] + + return user_realname + + @cached_property + def is_official(self) -> bool: + return self.group in UserProfile.OFFICIAL_GROUPS + + @cached_property + def is_school_admin(self) -> bool: + return self.group == UserProfile.UserGroup.COMMUNICATION_BOARD_ADMIN diff --git a/apps/user/serializers/user_profile.py b/apps/user/serializers/user_profile.py index ee133ca5..e28e8705 100644 --- a/apps/user/serializers/user_profile.py +++ b/apps/user/serializers/user_profile.py @@ -10,6 +10,7 @@ class BaseUserProfileSerializer(MetaDataModelSerializer): email = serializers.SerializerMethodField() + is_official = serializers.SerializerMethodField() class Meta: model = UserProfile @@ -21,6 +22,10 @@ def get_email(obj) -> typing.Optional[str]: return None return obj.email + @staticmethod + def get_is_official(obj) -> bool: + return obj.is_official + class UserProfileSerializer(BaseUserProfileSerializer): extra_preferences = serializers.JSONField( @@ -60,7 +65,8 @@ class Meta(BaseUserProfileSerializer.Meta): fields = ( 'picture', 'nickname', - 'user' + 'user', + 'is_official', ) diff --git a/apps/user/views/viewsets/user.py b/apps/user/views/viewsets/user.py index d72d1089..9fe99b5a 100644 --- a/apps/user/views/viewsets/user.py +++ b/apps/user/views/viewsets/user.py @@ -46,22 +46,19 @@ def _make_random_name() -> str: return temp_nickname -def make_random_profile_picture(hash_val=None) -> str: - colors = ['blue', 'red', 'gray'] - numbers = ['1', '2', '3'] - - if hash_val: - col = hash_val % len(colors) - num = (hash_val // 3) % len(numbers) - else: - col = random.randrange(len(colors)) - num = random.randrange(len(numbers)) - - temp_color = colors[col] - temp_num = numbers[num] - default_picture = f'user_profiles/default_pictures/{temp_color}-default{temp_num}.png' - - return default_picture +def get_profile_picture(hash_val=None) -> str: + colors = ['blue', 'red', 'gray'] + numbers = ['1', '2', '3'] + + if hash_val is None: + col = random.choice(colors) + num = random.choice(numbers) + else: + nidx, cidx = divmod(hash_val, len(colors)) + col = colors[cidx] + num = numbers[nidx % len(numbers)] + + return f'user_profiles/default_pictures/{col}-default{num}.png' class UserViewSet(ActionAPIViewSet): @@ -165,7 +162,7 @@ def sso_login_callback(self, request, *args, **kwargs): except UserProfile.DoesNotExist: # 회원가입 user_nickname = _make_random_name() - user_profile_picture = make_random_profile_picture() + user_profile_picture = get_profile_picture() email = user_info['email'] if email.split('@')[-1] == 'sso.sparcs.org': # sso에서 random하게 만든 이메일인 경우 diff --git a/ara/locale/ko/LC_MESSAGES/django.po b/ara/locale/ko/LC_MESSAGES/django.po index d40f869e..b64d63ce 100644 --- a/ara/locale/ko/LC_MESSAGES/django.po +++ b/ara/locale/ko/LC_MESSAGES/django.po @@ -165,8 +165,7 @@ msgstr "기타 직원" #: apps/user/serializers/user_profile.py:43 #, python-format -msgid "" -"Nicknames can only be changed every 3 months. (can't change until %(date)s)" +msgid "Nicknames can only be changed every 3 months. (can't change until %(date)s)" msgstr "닉네임은 3개월마다 변경할 수 있습니다. (%(date)s까지 변경 불가)" #: ara/settings/django.py:96 diff --git a/ara/settings/dev/__init__.py b/ara/settings/dev/__init__.py index 42f58c7e..765d84d2 100644 --- a/ara/settings/dev/__init__.py +++ b/ara/settings/dev/__init__.py @@ -30,6 +30,8 @@ LOGGING['disable_existing_loggers'] = False REPORT_THRESHOLD = 4 +SCHOOL_RESPONSE_VOTE_THRESHOLD = 3 +ANSWER_PERIOD = 14 try: from .local_settings import * diff --git a/ara/settings/django.py b/ara/settings/django.py index 35a99082..f9dc6dcc 100644 --- a/ara/settings/django.py +++ b/ara/settings/django.py @@ -31,6 +31,7 @@ 'drf_yasg', 'cacheops', 'django_elasticsearch_dsl', + 'django_filters', 'apps.core', 'apps.user', diff --git a/ara/settings/djangorestframework.py b/ara/settings/djangorestframework.py index c1038c53..bb92c239 100644 --- a/ara/settings/djangorestframework.py +++ b/ara/settings/djangorestframework.py @@ -4,6 +4,7 @@ 'DEFAULT_PAGINATION_CLASS': 'ara.classes.pagination.PageNumberPagination', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_filters.backends.RestFrameworkFilterBackend', + 'rest_framework.filters.OrderingFilter', ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.AllowAny', diff --git a/ara/settings/prod/__init__.py b/ara/settings/prod/__init__.py index 3cd16906..0028c42a 100644 --- a/ara/settings/prod/__init__.py +++ b/ara/settings/prod/__init__.py @@ -26,3 +26,4 @@ ) REPORT_THRESHOLD = 4 +SCHOOL_RESPONSE_VOTE_THRESHOLD = 30 diff --git a/tests/conftest.py b/tests/conftest.py index 7ea2e47b..bbf362d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,10 +25,22 @@ def set_admin_client(request): @pytest.fixture(scope='class') def set_user_client(request): - request.cls.user, _ = User.objects.get_or_create(username='User', email='user@sparcs.org') + request.cls.user, _ = User.objects.get_or_create( + username='User', + email='user@sparcs.org', + ) if not hasattr(request.cls.user, 'profile'): - UserProfile.objects.get_or_create(user=request.cls.user, nickname='User', - group=UserProfile.UserGroup.KAIST_MEMBER, agree_terms_of_service_at=timezone.now()) + UserProfile.objects.get_or_create( + user=request.cls.user, + nickname='User', + group=UserProfile.UserGroup.KAIST_MEMBER, + agree_terms_of_service_at=timezone.now(), + sso_user_info={ + 'kaist_info': '{\"ku_kname\": \"\\ud669\"}', + 'first_name': 'FirstName', + 'last_name': 'LastName' + } + ) client = APIClient() request.cls.api_client = client @@ -51,6 +63,7 @@ def set_user_client3(request): request.cls.api_client = APIClient() + @pytest.fixture(scope='class') def set_user_client4(request): request.cls.user4, _ = User.objects.get_or_create(username='User4', email='user4@sparcs.org') @@ -61,6 +74,28 @@ def set_user_client4(request): request.cls.api_client = APIClient() +@pytest.fixture(scope='class') +def set_user_with_kaist_info(request): + request.cls.user_with_kaist_info, _ = User.objects.get_or_create(username='User_with_kaist_info', email='user_with_kaist_info@sparcs.org') + if not hasattr(request.cls.user_with_kaist_info, 'profile'): + UserProfile.objects.get_or_create(user=request.cls.user_with_kaist_info, nickname='user_with_kinfo', + sso_user_info={"kaist_info": "{\"ku_kname\": \"user_with_kaist_info\"}"}, + group=UserProfile.UserGroup.KAIST_MEMBER, agree_terms_of_service_at=timezone.now()) + + request.cls.api_client = APIClient() + + +@pytest.fixture(scope='class') +def set_user_without_kaist_info(request): + request.cls.user_without_kaist_info, _ = User.objects.get_or_create(username='User_without_kaist_info', email='user_without_kaist_info@sparcs.org') + if not hasattr(request.cls.user_without_kaist_info, 'profile'): + UserProfile.objects.get_or_create(user=request.cls.user_without_kaist_info, nickname='user_without_kinfo', + sso_user_info={"kaist_info": None, "last_name": "user_", "first_name": "without_kaist_info"}, + group=UserProfile.UserGroup.KAIST_MEMBER, agree_terms_of_service_at=timezone.now()) + + request.cls.api_client = APIClient() + + class RequestSetting: def http_request(self, user, method, path, data=None, querystring='', **kwargs): self.api_client.force_authenticate(user=user) diff --git a/tests/test_article_search.py b/tests/test_article_search.py index bc2b7896..400e45d3 100644 --- a/tests/test_article_search.py +++ b/tests/test_article_search.py @@ -3,6 +3,7 @@ from django.core.management import call_command from apps.core.models import Board, Article +from apps.core.models.board import BoardNameType from apps.user.models import UserProfile from tests.conftest import RequestSetting, TestCase @@ -50,7 +51,7 @@ def set_posts(request): title=f'게시물 {i+1}', content=article_content, content_text=article_content, - is_anonymous=False, + name_type=BoardNameType.REGULAR, is_content_sexual=False, is_content_social=False, created_by=request.cls.authors[i % 4], diff --git a/tests/test_articles.py b/tests/test_articles.py index af753907..9e352970 100644 --- a/tests/test_articles.py +++ b/tests/test_articles.py @@ -1,39 +1,97 @@ import pytest from django.contrib.auth.models import User from django.utils import timezone - -from django.conf import settings +from rest_framework import status +from rest_framework.test import APIClient +from rest_framework import status from apps.core.models import Article, Topic, Board, Block, Vote, Comment +from apps.core.models.board import BoardNameType, BoardAccessPermissionType from apps.user.models import UserProfile +from ara.settings import SCHOOL_RESPONSE_VOTE_THRESHOLD from tests.conftest import RequestSetting, TestCase @pytest.fixture(scope='class') -def set_board(request): +def set_boards(request): request.cls.board = Board.objects.create( slug="test board", ko_name="테스트 게시판", en_name="Test Board", ko_description="테스트 게시판입니다", - en_description="This is a board for testing" + en_description="This is a board for testing", + name_type=BoardNameType.REGULAR, ) - -@pytest.fixture(scope='class') -def set_anon_board(request): request.cls.anon_board = Board.objects.create( slug="anonymous", ko_name="익명 게시판", en_name="Anonymous", ko_description="익명 게시판", en_description="Anonymous", - is_anonymous=True + name_type=BoardNameType.ANONYMOUS + ) + + request.cls.realname_board = Board.objects.create( + slug="test realname board", + ko_name="테스트 실명 게시판", + en_name="Test realname Board", + ko_description="테스트 실명 게시판입니다", + en_description="This is a realname board for testing", + name_type=BoardNameType.REALNAME, + ) + + request.cls.regular_access_board = Board.objects.create( + slug='regular access', + ko_name='일반 접근 권한 게시판', + en_name='Regular Access Board', + ko_description='일반 접근 권한 게시판', + en_description='Regular Access Board', + read_access_mask=0b11011110, + write_access_mask=0b11011010 + ) + + # Though its name is 'advertiser accessible', enterprise is also accessible + request.cls.advertiser_accessible_board = Board.objects.create( + slug='advertiser accessible', + ko_name='외부인(홍보 계정) 접근 가능 게시판', + en_name='Advertiser Accessible Board', + ko_description='외부인(홍보 계정) 접근 가능 게시판', + en_description='Advertiser Accessible Board', + read_access_mask=0b11111110, + write_access_mask=0b11111110 + ) + + request.cls.nonwritable_board = Board.objects.create( + slug='nonwritable', + ko_name='글 작성 불가 게시판', + en_name='Nonwritable Board', + ko_description='글 작성 불가 게시판', + en_description='Nonwritable Board', + write_access_mask=0b00000000 + ) + + request.cls.newsadmin_writable_board = Board.objects.create( + slug='newsadmin writable', + ko_name='뉴스게시판 관리인 글 작성 가능 게시판', + en_name='Newsadmin Writable Board', + ko_description='뉴스게시판 관리인 글 작성 가능 게시판', + en_description='Newsadmin Writable Board', + write_access_mask=0b10000000 + ) + + request.cls.enterprise_writable_board = Board.objects.create( + slug='enterprise writable', + ko_name='입주업체 글 작성 가능 게시판', + en_name='Enterprise Writable Board', + ko_description='입주업체 글 작성 가능 게시판', + en_description='Enterprise Writable Board', + write_access_mask=0b11011110 ) @pytest.fixture(scope='class') -def set_topic(request): +def set_topics(request): """set_board 먼저 적용""" request.cls.topic = Topic.objects.create( slug="test topic", @@ -44,36 +102,94 @@ def set_topic(request): parent_board=request.cls.board ) + request.cls.realname_topic = Topic.objects.create( + slug="test realname topic", + ko_name="테스트 실명 토픽", + en_name="Test realname Topic", + ko_description="테스트용 실명 토픽입니다", + en_description="This is realname topic for testing", + parent_board=request.cls.realname_board + ) + @pytest.fixture(scope='class') -def set_article(request): +def set_articles(request): """set_board 먼저 적용""" request.cls.article = Article.objects.create( - title="example article", - content="example content", - content_text="example content text", - is_anonymous=False, - is_content_sexual=False, - is_content_social=False, - hit_count=0, - positive_vote_count=0, - negative_vote_count=0, - created_by=request.cls.user, - parent_topic=request.cls.topic, - parent_board=request.cls.board, - commented_at=timezone.now() + title="example article", + content="example content", + content_text="example content text", + name_type=BoardNameType.REGULAR, + is_content_sexual=False, + is_content_social=False, + hit_count=0, + positive_vote_count=0, + negative_vote_count=0, + created_by=request.cls.user, + parent_topic=request.cls.topic, + parent_board=request.cls.board, + commented_at=timezone.now() + ) + + request.cls.regular_access_article = Article.objects.create( + title='regular access article', + content='regular access article content', + content_text='regular access article content', + created_by=request.cls.user, + parent_board=request.cls.regular_access_board + ) + + request.cls.advertiser_accessible_article = Article.objects.create( + title='advertiser readable article', + content='advertiser readable article content', + content_text='advertiser readable article content', + created_by=request.cls.user, + parent_board=request.cls.advertiser_accessible_board + ) + +@pytest.fixture(scope='class') +def set_realname_article(request): + """set_user_with_kaist_info,, set_realname_topic, set_realname_board 먼저 적용""" + request.cls.realname_article = Article.objects.create( + title='Realname Test Article', + content='Content of test realname article', + content_text='Content of test article in text', + name_type=BoardNameType.REALNAME, + is_content_sexual=False, + is_content_social=False, + hit_count=0, + positive_vote_count=0, + negative_vote_count=0, + created_by=request.cls.user_with_kaist_info, + parent_topic=request.cls.realname_topic, + parent_board=request.cls.realname_board, + commented_at=timezone.now() ) @pytest.fixture(scope='function') def set_kaist_articles(request): - request.cls.non_kaist_user, _ = User.objects.get_or_create(username='NonKaistUser', email='non-kaist-user@sparcs.org') + request.cls.non_kaist_user, _ = User.objects.get_or_create( + username='NonKaistUser', + email='non-kaist-user@sparcs.org' + ) if not hasattr(request.cls.non_kaist_user, 'profile'): - UserProfile.objects.get_or_create(user=request.cls.non_kaist_user, nickname='Not a KAIST User', agree_terms_of_service_at=timezone.now()) - request.cls.kaist_user, _ = User.objects.get_or_create(username='KaistUser', email='kaist-user@sparcs.org') + UserProfile.objects.get_or_create( + user=request.cls.non_kaist_user, + nickname='Not a KAIST User', + agree_terms_of_service_at=timezone.now() + ) + request.cls.kaist_user, _ = User.objects.get_or_create( + username='KaistUser', + email='kaist-user@sparcs.org' + ) if not hasattr(request.cls.kaist_user, 'profile'): - UserProfile.objects.get_or_create(user=request.cls.kaist_user, nickname='KAIST User', - group=UserProfile.UserGroup.KAIST_MEMBER, agree_terms_of_service_at=timezone.now()) + UserProfile.objects.get_or_create( + user=request.cls.kaist_user, + nickname='KAIST User', + agree_terms_of_service_at=timezone.now(), + group=UserProfile.UserGroup.KAIST_MEMBER + ) request.cls.kaist_board, _ = Board.objects.get_or_create( slug="kaist-only", @@ -81,13 +197,14 @@ def set_kaist_articles(request): en_name="KAIST Board", ko_description="KAIST Board", en_description="KAIST Board", - access_mask=2 + read_access_mask=0b00000010, + write_access_mask=0b00000010 ) request.cls.kaist_article, _ = Article.objects.get_or_create( title="example article", content="example content", content_text="example content text", - is_anonymous=False, + name_type=BoardNameType.REGULAR, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -116,18 +233,38 @@ def set_readonly_board(request): request.cls.readonly_board.delete() -@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_board', 'set_anon_board', 'set_topic', 'set_article') +@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_user_client3', 'set_user_client4', 'set_user_with_kaist_info', + 'set_boards', 'set_topics', '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') - assert res.data.get('num_items') == 1 + assert res.data.get('num_items') == Article.objects.count() Article.objects.create( title="example article", content="example content", content_text="example content text", - is_anonymous=False, + name_type=BoardNameType.REGULAR, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -143,7 +280,7 @@ def test_list(self): title="example article", content="example content", content_text="example content text", - is_anonymous=False, + name_type=BoardNameType.REGULAR, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -156,14 +293,14 @@ def test_list(self): ) res = self.http_request(self.user, 'get', 'articles') - assert res.data.get('num_items') == 3 + assert res.data.get('num_items') == Article.objects.count() def test_get(self): # article 조회가 잘 되는지 확인 res = self.http_request(self.user, 'get', f'articles/{self.article.id}').data assert res.get('title') == self.article.title assert res.get('content') == self.article.content - assert res.get('is_anonymous') == self.article.is_anonymous + assert res.get('name_type') == self.article.name_type assert res.get('is_content_sexual') == self.article.is_content_sexual assert res.get('is_content_social') == self.article.is_content_social assert res.get('positive_vote_count') == self.article.positive_vote_count @@ -179,7 +316,7 @@ def test_anonymous_article(self): title="example anonymous article", content="example anonymous content", content_text="example anonymous content text", - is_anonymous=True, + name_type=BoardNameType.ANONYMOUS, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -193,11 +330,11 @@ def test_anonymous_article(self): # 익명 게시글을 GET할 때, 작성자의 정보가 전달되지 않는 것 확인 res = self.http_request(self.user, 'get', f'articles/{anon_article.id}').data - assert res.get('is_anonymous') + assert res.get('name_type') == BoardNameType.ANONYMOUS assert res.get('created_by')['username'] != anon_article.created_by.username res2 = self.http_request(self.user2, 'get', f'articles/{anon_article.id}').data - assert res2.get('is_anonymous') + assert res2.get('name_type') == BoardNameType.ANONYMOUS assert res2.get('created_by')['username'] != anon_article.created_by.username def test_create(self): @@ -207,7 +344,7 @@ def test_create(self): "title": "article for test_create", "content": "content for test_create", "content_text": "content_text for test_create", - "is_anonymous": False, + "name_type": BoardNameType.REGULAR, "is_content_sexual": False, "is_content_social": False, "parent_topic": self.topic.id, @@ -216,7 +353,54 @@ def test_create(self): # convert user data to JSON self.http_request(self.user, 'post', 'articles', user_data) assert Article.objects.filter(title='article for test_create') - + + # 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] + articles = [ + self.regular_access_article, + self.advertiser_accessible_article + ] + + for user in group_users: + for article in articles: + res = self.http_request(user, 'get', f'articles/{article.id}') + + if article.parent_board.group_has_access_permission( + BoardAccessPermissionType.READ, + user.profile.group): + assert res.status_code == status.HTTP_200_OK + assert res.data['id'] == article.id + else: + assert res.status_code == status.HTTP_403_FORBIDDEN + + # create 단계에서 user의 write 권한 확인 테스트 + def test_check_write_permission_when_create(self): + group_users = [self._create_user_by_group(group) for group in UserProfile.UserGroup] + boards = [ + self.regular_access_board, + self.nonwritable_board, + self.newsadmin_writable_board, + self.enterprise_writable_board, + self.advertiser_accessible_board + ] + + for user in group_users: + for board in boards: + res = self.http_request(user, 'post', 'articles', { + 'title': 'title in write permission test', + 'content': 'content in write permission test', + 'content_text': 'content_text in write permission test', + 'parent_board': board.id + }) + + if board.group_has_access_permission( + BoardAccessPermissionType.WRITE, + user.profile.group): + assert res.status_code == status.HTTP_201_CREATED + else: + assert res.status_code == status.HTTP_403_FORBIDDEN + def test_create_anonymous(self): user_data = { "title": "article for test_create", @@ -230,14 +414,14 @@ def test_create_anonymous(self): result = self.http_request(self.user, 'post', 'articles', user_data) - assert result.data['is_anonymous'] + assert result.data['name_type'] == BoardNameType.ANONYMOUS user_data.update({ "parent_topic": self.topic.id, "parent_board": self.board.id }) result = self.http_request(self.user, 'post', 'articles', user_data) - assert not result.data['is_anonymous'] + assert not result.data['name_type'] == BoardNameType.ANONYMOUS def test_update_cache_sync(self): new_title = 'title changed!' @@ -246,7 +430,7 @@ def test_update_cache_sync(self): title="example article", content="example content", content_text="example content text", - is_anonymous=False, + name_type=BoardNameType.REGULAR, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -269,11 +453,18 @@ def test_update_cache_sync(self): assert response.data.get('title') == new_title assert response.data.get('content') == new_content + @pytest.mark.usefixtures('set_kaist_articles') def test_update_hit_counts(self): - previous_hit_count = self.article.hit_count + updated_hit_count = self.article.hit_count + 1 + res = self.http_request(self.user2, 'get', f'articles/{self.article.id}').data + assert res.get('hit_count') == updated_hit_count + assert Article.objects.get(id=self.article.id).hit_count == updated_hit_count + + # 권한 없는 사용자가 get + self.http_request(self.non_kaist_user, 'get', f'articles/{self.article.id}') + res = self.http_request(self.user2, 'get', f'articles/{self.article.id}').data - assert res.get('hit_count') == previous_hit_count + 1 - assert Article.objects.get(id=self.article.id).hit_count == previous_hit_count + 1 + assert res.get('hit_count') == updated_hit_count def test_delete_by_non_writer(self): # 글쓴이가 아닌 사람은 글을 지울 수 없음 @@ -366,32 +557,13 @@ def test_self_vote(self): assert article.positive_vote_count == 0 assert article.negative_vote_count == 0 - @pytest.mark.usefixtures('set_kaist_articles') - def test_kaist_permission(self): - # 카이스트 구성원만 볼 수 있는 게시판에 대한 테스트 - def check_kaist_error(response): - assert response.status_code == 403 - assert 'KAIST' in response.data['detail'] # 에러 메세지 체크 - # 게시물 읽기 테스트 - check_kaist_error(self.http_request(self.non_kaist_user, 'get', f'articles/{self.kaist_article.id}')) - # 투표 테스트 - check_kaist_error( - self.http_request(self.non_kaist_user, 'post', f'articles/{self.kaist_article.id}/vote_positive') - ) - check_kaist_error( - self.http_request(self.non_kaist_user, 'post', f'articles/{self.kaist_article.id}/vote_negative') - ) - check_kaist_error( - self.http_request(self.non_kaist_user, 'post', f'articles/{self.kaist_article.id}/vote_cancel') - ) - @pytest.mark.usefixtures('set_readonly_board') def test_readonly_board(self): user_data = { "title": "article for test_create", "content": "content for test_create", "content_text": "content_text for test_create", - "is_anonymous": False, + "name_type": BoardNameType.REGULAR, "is_content_sexual": False, "is_content_social": False, "parent_board": self.readonly_board.id @@ -406,20 +578,22 @@ def test_read_status(self): assert res1.data['results'][0]['read_status'] == 'N' assert res2.data['results'][0]['read_status'] == 'N' + article_id = res1.data['results'][0]['id'] + # user2만 읽음 - self.http_request(self.user2, 'get', f'articles/{self.article.id}') + self.http_request(self.user2, 'get', f'articles/{article_id}') res1 = self.http_request(self.user, 'get', 'articles') res2 = self.http_request(self.user2, 'get', 'articles') assert res1.data['results'][0]['read_status'] == 'N' assert res2.data['results'][0]['read_status'] == '-' # user1이 업데이트 (user2은 아직 변경사항 확인못함) - self.http_request(self.user, 'get', f'articles/{self.article.id}') - self.http_request(self.user, 'patch', f'articles/{self.article.id}', {'content': 'update!'}) + self.http_request(self.user, 'get', f'articles/{article_id}') + self.http_request(self.user, 'patch', f'articles/{article_id}', {'content': 'update!'}) # TODO: 현재는 프론트 구현상 게시물을 수정하면 바로 다시 GET을 호출하기 때문에 '-' 로 나옴. # 추후 websocket 등으로 게시물 수정이 실시간으로 이루어진다면, 'U'로 나오기 때문에 수정 필요. - self.http_request(self.user, 'get', f'articles/{self.article.id}') + self.http_request(self.user, 'get', f'articles/{article_id}') res1 = self.http_request(self.user, 'get', 'articles') assert res1.data['results'][0]['read_status'] == '-' @@ -432,7 +606,7 @@ def test_deleting_with_comments(self): self.article.save() Comment.objects.create( content='this is a test comment', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=self.user, parent_article=self.article ) @@ -446,8 +620,116 @@ def test_deleting_with_comments(self): ).count() == 0 assert self.article.comment_count == 0 +@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_user_with_kaist_info', 'set_user_without_kaist_info', + 'set_boards', 'set_topics', 'set_articles', 'set_realname_article') +class TestRealnameArticle(TestCase, RequestSetting): + def test_get_realname_article(self): + # kaist info가 있는 유저가 생성한 게시글 + realname_article_with_kinfo = Article.objects.create( + title='example realname article with kinfo', + content='example realname content with kinfo', + content_text='example realname content text with kinfo', + name_type=BoardNameType.REALNAME, + is_content_sexual=False, + is_content_social=False, + hit_count=0, + positive_vote_count=0, + negative_vote_count=0, + created_by=self.user_with_kaist_info, + parent_topic=self.realname_topic, + parent_board=self.realname_board, + commented_at=timezone.now() + ) + + # kaist info가 없는 유저가 생성한 게시글 + realname_article_without_kinfo = Article.objects.create( + title='example realname article without_kinfo', + content='example realname content without_kinfo', + content_text='example realname content text without_kinfo', + name_type=BoardNameType.REALNAME, + is_content_sexual=False, + is_content_social=False, + hit_count=0, + positive_vote_count=0, + negative_vote_count=0, + created_by=self.user_without_kaist_info, + parent_topic=self.realname_topic, + parent_board=self.realname_board, + commented_at=timezone.now() + ) + + res = self.http_request(self.user_with_kaist_info, 'get', f'articles/{realname_article_with_kinfo.id}').data + assert res.get('name_type') == BoardNameType.REALNAME + assert res.get('created_by')['username'] == realname_article_with_kinfo.created_by.profile.realname + + res2 = self.http_request(self.user_without_kaist_info, 'get', f'articles/{realname_article_without_kinfo.id}').data + assert res2.get('name_type') == BoardNameType.REALNAME + assert res2.get('created_by')['username'] == realname_article_without_kinfo.created_by.profile.realname + + def test_create_realname_article(self): + article_title = 'realname article for test_create' + article_data = { + 'title': article_title, + 'content': 'realname content for test_create', + 'content_text': 'realname content_text for test_create', + 'is_content_sexual': False, + 'is_content_social': False, + 'parent_topic': self.realname_topic.id, + 'parent_board': self.realname_board.id + } + + result = self.http_request(self.user_with_kaist_info, 'post', 'articles', article_data).data + + assert result.get('name_type') == BoardNameType.REALNAME + assert Article.objects.get(title=article_title).name_type == BoardNameType.REALNAME + + def test_update_realname_article(self): + article = Article.objects.create( + title='realname article for test_create', + content='realname content for test_create', + content_text='realname content_text for test_create', + name_type=BoardNameType.REALNAME, + is_content_sexual=False, + is_content_social=False, + hit_count=0, + positive_vote_count=0, + negative_vote_count=0, + created_by=self.user_with_kaist_info, + parent_topic=self.realname_topic, + parent_board=self.realname_board, + commented_at=timezone.now() + ) + article.save() + + new_title = 'realname article for test_update' + new_content = 'realname content for test_update' + result = self.http_request(self.user_with_kaist_info, 'put', f'articles/{article.id}', { + 'title': new_title, + 'content': new_content + }).data + + assert result.get('name_type') == BoardNameType.REALNAME + assert Article.objects.get(title=new_title).name_type == BoardNameType.REALNAME + + def test_ban_vote_cancellation_after_30(self): + # SCHOOL_RESPONSE_VOTE_THRESHOLD is 3 in test + users = [self.user, self.user2] + for user in users: + self.http_request(user, 'post', f'articles/{self.realname_article.id}/vote_positive') + + res1 = self.http_request(self.user_without_kaist_info, 'post', f'articles/{self.realname_article.id}/vote_positive') + article = Article.objects.get(id=self.realname_article.id) + assert res1.status_code == status.HTTP_200_OK + assert article.positive_vote_count == SCHOOL_RESPONSE_VOTE_THRESHOLD + + res2 = self.http_request(self.user_without_kaist_info, 'post', f'articles/{self.realname_article.id}/vote_cancel') + article = Article.objects.get(id=self.realname_article.id) + assert res2.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + assert article.positive_vote_count == SCHOOL_RESPONSE_VOTE_THRESHOLD + -@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_board', 'set_topic', 'set_article') +@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_user_with_kaist_info', 'set_user_without_kaist_info', + 'set_boards', 'set_topics', 'set_articles') class TestHiddenArticles(TestCase, RequestSetting): @staticmethod def _user_factory(user_kwargs, profile_kwargs): @@ -478,7 +760,7 @@ def _article_factory(self, is_content_sexual=False, is_content_social=False, **a title="example article", content="example content", content_text="example content text", - is_anonymous=False, + name_type=BoardNameType.REGULAR, hit_count=0, is_content_sexual=is_content_sexual, is_content_social=is_content_social, @@ -731,25 +1013,3 @@ def test_report_already_hidden_article(self): }) assert res.status_code == 403 - - def test_comment_on_deleted_article(self): - target_article = self._create_deleted_article() - - res = self.http_request(self.user, 'post', 'comments', { - 'content': 'This is a comment', - 'parent_article': target_article.id, - 'is_anonymous': False, - }) - - assert res.status_code == 400 - - def test_comment_on_report_hidden_article(self): - target_article = self._create_report_hidden_article() - - res = self.http_request(self.user, 'post', 'comments', { - 'content': 'This is a comment', - 'parent_article': target_article.id, - 'is_anonymous': False, - }) - - assert res.status_code == 201 diff --git a/tests/test_block.py b/tests/test_block.py index 6dbf611d..12dd0d8f 100644 --- a/tests/test_block.py +++ b/tests/test_block.py @@ -5,6 +5,7 @@ from django.conf import settings from apps.core.models import Article, Topic, Board, Comment, Block +from apps.core.models.board import BoardNameType from tests.conftest import RequestSetting, TestCase from dateutil.relativedelta import relativedelta @@ -28,7 +29,7 @@ def set_anon_board(request): en_name="Anonymous", ko_description="익명 게시판", en_description="Anonymous", - is_anonymous=True + name_type=BoardNameType.ANONYMOUS ) @@ -52,7 +53,7 @@ def set_articles(request): title='Test Article', content='Content of test article', content_text='Content of test article in text', - is_anonymous=False, + name_type=BoardNameType.REGULAR, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -68,7 +69,7 @@ def set_articles(request): title='Test Article 2', content='Content of test article 2', content_text='Content of test article in text 2', - is_anonymous=False, + name_type=BoardNameType.REGULAR, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -84,7 +85,7 @@ def set_articles(request): title='Test Article 3', content='Content of test article 3', content_text='Content of test article in text 3', - is_anonymous=False, + name_type=BoardNameType.REGULAR, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -225,7 +226,7 @@ def test_get_anonymous_articles_by_blocked_user(self): title='Test Article', content='Content of test article', content_text='Content of test article in text', - is_anonymous=True, + name_type=BoardNameType.ANONYMOUS, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -257,14 +258,14 @@ def test_comment_by_blocked_user(self): # user2가 댓글을 씀 blocked_comment = Comment.objects.create( content='this is a test comment', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=self.user2, parent_article=self.article, ) not_blocked_comment = Comment.objects.create( content='this is a test comment', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=self.user3, parent_article=self.article, ) diff --git a/tests/test_comments.py b/tests/test_comments.py index e5ff2579..f1f53e7e 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -3,23 +3,39 @@ import pytest from django.contrib.auth.models import User from django.utils import timezone +from django.utils.translation import gettext +from rest_framework import status +from rest_framework.test import APIClient + from apps.core.models import Article, Topic, Board, Comment, Block, Vote +from apps.core.models.board import BoardNameType +from apps.user.models import UserProfile from tests.conftest import RequestSetting, TestCase @pytest.fixture(scope='class') -def set_board(request): +def set_boards(request): request.cls.board = Board.objects.create( slug='test board', ko_name='테스트 게시판', en_name='Test Board', + name_type=BoardNameType.REGULAR, ko_description='테스트 게시판입니다', en_description='This is a board for testing' ) + request.cls.realname_board = Board.objects.create( + slug='test realname board', + ko_name='테스트 실명 게시판', + en_name='Test realname Board', + name_type=BoardNameType.REALNAME, + ko_description='테스트 실명 게시판입니다', + en_description='This is a realname board for testing' + ) + @pytest.fixture(scope='class') -def set_topic(request): +def set_topics(request): """set_board 먼저 적용""" request.cls.topic = Topic.objects.create( slug='test topic', @@ -30,15 +46,24 @@ def set_topic(request): parent_board=request.cls.board ) + request.cls.realname_topic = Topic.objects.create( + slug='test realname topic', + ko_name='테스트 실명 토픽', + en_name='Test realname Topic', + ko_description='테스트용 실명 토픽입니다', + en_description='This is realname topic for testing', + parent_board=request.cls.realname_board + ) + @pytest.fixture(scope='class') def set_articles(request): """set_topic, set_user_client 먼저 적용""" - request.cls.article = Article.objects.create( + request.cls.article_regular = Article.objects.create( title='Test Article', content='Content of test article', content_text='Content of test article in text', - is_anonymous=False, + name_type=BoardNameType.REGULAR, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -55,7 +80,7 @@ def set_articles(request): title='Anonymous Test Article', content='Content of test article', content_text='Content of test article in text', - is_anonymous=True, + name_type=BoardNameType.ANONYMOUS, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -67,47 +92,90 @@ def set_articles(request): commented_at=timezone.now() ) + """set_realname_topic, set_user_with_kaist_info 먼저 적용""" + request.cls.realname_article = Article.objects.create( + title='Realname Test Article', + content='Content of test realname article', + content_text='Content of test article in text', + name_type=BoardNameType.REALNAME, + is_content_sexual=False, + is_content_social=False, + hit_count=0, + positive_vote_count=0, + negative_vote_count=0, + created_by=request.cls.user_with_kaist_info, + parent_topic=request.cls.realname_topic, + parent_board=request.cls.realname_board, + commented_at=timezone.now() + ) + + """set_realname_topic, set_user_without_kaist_info 먼저 적용""" + request.cls.realname_article_without_kinfo = Article.objects.create( + title='Realname Test Article', + content='Content of test realname article', + content_text='Content of test article in text', + name_type=BoardNameType.REALNAME, + is_content_sexual=False, + is_content_social=False, + hit_count=0, + positive_vote_count=0, + negative_vote_count=0, + created_by=request.cls.user_without_kaist_info, + parent_topic=request.cls.realname_topic, + parent_board=request.cls.realname_board, + commented_at=timezone.now() + ) + @pytest.fixture(scope='class') def set_comments(request): """set_article 먼저 적용""" request.cls.comment = Comment.objects.create( content='this is a test comment', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=request.cls.user, - parent_article=request.cls.article, + parent_article=request.cls.article_regular, ) request.cls.comment_anonymous = Comment.objects.create( content='this is an anonymous test comment', - is_anonymous=True, + name_type=BoardNameType.ANONYMOUS, created_by=request.cls.user, parent_article=request.cls.article_anonymous, ) + request.cls.realname_comment = Comment.objects.create( + content='this is an realname test comment', + name_type=BoardNameType.REALNAME, + created_by=request.cls.user_with_kaist_info, + parent_article=request.cls.realname_article, + ) + -@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_user_client3', 'set_board', 'set_topic', 'set_articles', 'set_comments') +@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_user_client3', 'set_user_client4', + 'set_user_with_kaist_info', 'set_user_without_kaist_info', + 'set_boards', 'set_topics', 'set_articles', 'set_comments') class TestComments(TestCase, RequestSetting): # comment 개수를 확인하는 테스트 def test_comment_list(self): # number of comments is initially 0 - res = self.http_request(self.user, 'get', f'articles/{self.article.id}') + res = self.http_request(self.user, 'get', f'articles/{self.article_regular.id}') assert res.data.get('comment_count') == 1 comment2 = Comment.objects.create( content='Test comment 2', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=self.user, - parent_article=self.article + parent_article=self.article_regular ) comment3 = Comment.objects.create( content='Test comment 3', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=self.user, - parent_article=self.article + parent_article=self.article_regular ) - res = self.http_request(self.user, 'get', f'articles/{self.article.id}') + res = self.http_request(self.user, 'get', f'articles/{self.article_regular.id}') assert res.data.get('comment_count') == 3 # post로 댓글 생성됨을 확인 @@ -115,7 +183,7 @@ def test_create_comment(self): comment_str = 'this is a test comment for test_create_comment' comment_data = { 'content': comment_str, - 'parent_article': self.article.id, + 'parent_article': self.article_regular.id, 'parent_comment': None, 'attachment': None, } @@ -136,7 +204,7 @@ def test_create_subcomment(self): # 댓글의 생성과 삭제에 따라서 article의 comment_count가 맞게 바뀌는지 확인 def test_article_comment_count(self): - article = Article.objects.get(id=self.article.id) + article = Article.objects.get(id=self.article_regular.id) assert article.comment_count == 1 comment = Comment.objects.get(id=self.comment.id) comment.delete() @@ -146,29 +214,29 @@ def test_article_comment_count(self): # 대댓글의 생성과 삭제에 따라서 article의 comment_count가 맞게 바뀌는지 확인 def test_article_comment_count_with_subcomments(self): - article = Article.objects.get(id=self.article.id) + article = Article.objects.get(id=self.article_regular.id) print('comment set: ', article.comment_set) assert article.comment_count == 1 subcomment1 = Comment.objects.create( content='Test comment 2', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=self.user, parent_comment=self.comment ) - article = Article.objects.get(id=self.article.id) + article = Article.objects.get(id=self.article_regular.id) print('comment set: ', article.comment_set) assert article.comment_count == 2 subcomment2 = Comment.objects.create( content='Test comment 3', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=self.user, parent_comment=self.comment ) - article = Article.objects.get(id=self.article.id) + article = Article.objects.get(id=self.article_regular.id) print('comment set: ', article.comment_set) assert article.comment_count == 3 @@ -200,7 +268,7 @@ def test_delete_comment_by_not_comment_writer(self): def test_delete_comment_with_subcomment(self): subcomment = Comment.objects.create( content='Test subcomment', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=self.user, parent_comment=self.comment ) @@ -232,18 +300,18 @@ def test_anonymous_comment(self): # 익명 댓글 생성 anon_comment = Comment.objects.create( content='Anonymous test comment', - is_anonymous=True, + name_type=BoardNameType.ANONYMOUS, created_by=self.user, - parent_article=self.article + parent_article=self.article_regular ) # 익명 댓글을 GET할 때, 작성자의 정보가 전달되지 않는 것 확인 res = self.http_request(self.user, 'get', f'comments/{anon_comment.id}').data - assert res.get('is_anonymous') + assert res.get('name_type') == BoardNameType.ANONYMOUS assert res.get('created_by')['username'] != anon_comment.created_by.username res2 = self.http_request(self.user2, 'get', f'comments/{anon_comment.id}').data - assert res2.get('is_anonymous') + assert res2.get('name_type') == BoardNameType.ANONYMOUS assert res2.get('created_by')['username'] != anon_comment.created_by.username # 익명글의 글쓴이가 본인의 글에 남긴 댓글에 대해, user_id가 같은지 확인 @@ -251,67 +319,101 @@ def test_anonymous_comment_by_article_writer(self): # 익명 댓글 생성 Comment.objects.create( content='Anonymous test comment', - is_anonymous=True, + name_type=BoardNameType.ANONYMOUS, created_by=self.user, - parent_article=self.article + parent_article=self.article_anonymous ) r_article = self.http_request(self.user, 'get', f'articles/{self.article_anonymous.id}').data - article_auther_id = r_article.get('created_by')['id'] + article_writer_id = r_article.get('created_by')['id'] r_comment = self.http_request(self.user, 'get', f'comments/{self.comment_anonymous.id}').data - comment_auther_id = r_comment.get('created_by')['id'] - assert article_auther_id == comment_auther_id + comment_writer_id = r_comment.get('created_by')['id'] + assert article_writer_id == comment_writer_id r_article2 = self.http_request(self.user2, 'get', f'articles/{self.article_anonymous.id}').data - article_auther_id2 = r_article2.get('created_by')['id'] + article_writer_id2 = r_article2.get('created_by')['id'] r_comment2 = self.http_request(self.user2, 'get', f'comments/{self.comment_anonymous.id}').data - comment_auther_id2 = r_comment2.get('created_by')['id'] - assert article_auther_id2 == comment_auther_id2 + comment_writer_id2 = r_comment2.get('created_by')['id'] + assert article_writer_id2 == comment_writer_id2 - def test_comment_on_anonymous_parent_article(self): - comment_str = 'This is a comment on an anonymous parent article' + def test_comment_on_regular_parent_article(self): comment_data = { - 'content': comment_str, - 'parent_article': self.article_anonymous.id, + 'content': 'This is a comment on a regular parent article', + 'parent_article': self.article_regular.id, 'parent_comment': None, 'attachment': None, } - self.http_request(self.user, 'post', 'comments', comment_data) - assert Comment.objects.filter(content=comment_str).first().is_anonymous + res = self.http_request(self.user, 'post', 'comments', comment_data) + + assert res.data['name_type'] == BoardNameType.REGULAR + assert Comment.objects.get(pk=res.data['id']).name_type == BoardNameType.REGULAR - def test_comment_on_anonymous_parent_comment(self): - comment_str = 'This is a comment on an anonymous parent comment' + def test_comment_on_regular_parent_comment(self): + comment_str = 'This is a comment on a regular parent comment' comment_data = { 'content': comment_str, 'parent_article': None, - 'parent_comment': self.comment_anonymous.id, + 'parent_comment': self.comment.id, 'attachment': None, } self.http_request(self.user, 'post', 'comments', comment_data) - assert Comment.objects.filter(content=comment_str).first().is_anonymous + assert Comment.objects.filter(content=comment_str).first().name_type == BoardNameType.REGULAR - def test_comment_on_nonanonymous_parent_article(self): - comment_str = 'This is a comment on an non-anonymous parent article' + def test_comment_on_anonymous_parent_article(self): + comment_str = 'This is a comment on an anonymous parent article' comment_data = { 'content': comment_str, - 'parent_article': self.article.id, + 'parent_article': self.article_anonymous.id, 'parent_comment': None, 'attachment': None, } self.http_request(self.user, 'post', 'comments', comment_data) - Comment.objects.filter(content=comment_str).first() - assert not Comment.objects.filter(content=comment_str).first().is_anonymous + assert Comment.objects.filter(content=comment_str).first().name_type == BoardNameType.ANONYMOUS - def test_comment_on_nonanonymous_parent_comment(self): - comment_str = 'This is a comment on an non-anonymous parent comment' + def test_comment_on_anonymous_parent_comment(self): comment_data = { - 'content': comment_str, + 'content': 'This is a comment on an anonymous parent comment', 'parent_article': None, - 'parent_comment': self.comment.id, + 'parent_comment': self.comment_anonymous.id, 'attachment': None, } - self.http_request(self.user, 'post', 'comments', comment_data) - assert not Comment.objects.filter(content=comment_str).first().is_anonymous + res = self.http_request(self.user, 'post', 'comments', comment_data) + + assert res.data['name_type'] == BoardNameType.ANONYMOUS + assert Comment.objects.get(pk=res.data['id']).name_type == BoardNameType.ANONYMOUS + + def test_comment_on_deleted_article(self): + deleted_article = Article.objects.create( + title='deleted article', + content='deleted article content', + content_text='deleted article content text', + created_by=self.user, + parent_board=self.board, + deleted_at=timezone.now() + ) + res = self.http_request(self.user, 'post', 'comments', { + 'content': 'deleted article comment content', + 'parent_article': deleted_article.id, + 'name_type': BoardNameType.REGULAR + }) + assert res.status_code == status.HTTP_404_NOT_FOUND + + def test_comment_on_report_hidden_article(self): + report_hidden_article = Article.objects.create( + title='report hidden article', + content='report hidden article content', + content_text='report hidden article content text', + created_by=self.user, + parent_board=self.board, + report_count=1_000_000, + hidden_at=timezone.now() + ) + res = self.http_request(self.user, 'post', 'comments', { + 'content': 'report hidden article comment content', + 'parent_article': report_hidden_article.id, + 'name_type': BoardNameType.REGULAR + }) + assert res.status_code == status.HTTP_201_CREATED # 댓글 좋아요 확인 def test_positive_vote(self): @@ -358,14 +460,74 @@ def test_cannot_vote_both(self): assert comment.negative_vote_count == 1 -@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_board', 'set_topic', 'set_articles', 'set_comments') +@pytest.mark.usefixtures('set_user_client', 'set_user_with_kaist_info', 'set_user_without_kaist_info', + 'set_boards', 'set_topics', 'set_articles', 'set_comments') +class TestRealnameComments(TestCase, RequestSetting): + def test_get_realname_comment(self): + comment1 = Comment.objects.create( + content='Realname test comment1', + name_type=BoardNameType.REALNAME, + created_by=self.user_without_kaist_info, + parent_article=self.realname_article + ) + + comment2 = Comment.objects.create( + content='Realname test comment2', + name_type=BoardNameType.REALNAME, + created_by=self.user_with_kaist_info, + parent_article=self.realname_article_without_kinfo + ) + + res1 = self.http_request(self.user_without_kaist_info, 'get', f'comments/{comment1.id}').data + assert res1.get('name_type') == BoardNameType.REALNAME + assert res1.get('created_by')['username'] == comment1.created_by.profile.realname + + res2 = self.http_request(self.user_with_kaist_info, 'get', f'comments/{comment2.id}').data + assert res2.get('name_type') == BoardNameType.REALNAME + assert res2.get('created_by')['username'] == comment2.created_by.profile.realname + + def test_get_realname_comment_by_article_writer(self): + res = self.http_request(self.user_with_kaist_info, 'get', f'comments/{self.realname_comment.id}').data + assert res.get('name_type') == BoardNameType.REALNAME + assert res.get('created_by')['username'] == gettext('author') + + def test_create_realname_comment(self): + comment_str = 'This is a comment on a realname article' + comment_data = { + 'content': comment_str, + 'parent_article': self.realname_article.id, + 'parent_comment': None, + 'attachment': None, + } + + res = self.http_request(self.user_with_kaist_info, 'post', 'comments', comment_data).data + assert res.get('name_type') == BoardNameType.REALNAME + assert Comment.objects.get(content=comment_str).name_type == BoardNameType.REALNAME + + def test_create_realname_subcomment(self): + comment_str = 'This is a subcomment on a realname comment' + comment_data = { + 'content': comment_str, + 'parent_article': None, + 'parent_comment': self.realname_comment.id, + 'attachment': None, + } + + res = self.http_request(self.user_with_kaist_info, 'post', 'comments', comment_data).data + assert res.get('name_type') == BoardNameType.REALNAME + assert Comment.objects.get(content=comment_str).name_type == BoardNameType.REALNAME + + +@pytest.mark.usefixtures('set_user_client', 'set_user_client2', + 'set_user_with_kaist_info', 'set_user_without_kaist_info', + 'set_boards', 'set_topics', 'set_articles', 'set_comments') class TestHiddenComments(TestCase, RequestSetting): def _comment_factory(self, **comment_kwargs): return Comment.objects.create( content='example comment', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=self.user, - parent_article=self.article, + parent_article=self.article_regular, **comment_kwargs ) @@ -387,9 +549,9 @@ def test_blocked_user_block(self): ) comment2 = Comment.objects.create( content='example comment', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=self.user2, - parent_article=self.article + parent_article=self.article_regular ) res = self.http_request(self.user, 'get', f'comments/{comment2.id}').data diff --git a/tests/test_communication_article.py b/tests/test_communication_article.py new file mode 100644 index 00000000..937018af --- /dev/null +++ b/tests/test_communication_article.py @@ -0,0 +1,389 @@ +import sys +from datetime import timedelta +from unittest.mock import patch + +import pytest +from datetime import datetime + +from django.utils import timezone +from django.contrib.auth.models import User + +from rest_framework.test import APIClient +from rest_framework.status import HTTP_200_OK + +from ara.settings.dev import SCHOOL_RESPONSE_VOTE_THRESHOLD + +from apps.core.models import Article, Board +from apps.core.models.board import BoardNameType +from apps.core.models.communication_article import CommunicationArticle, SchoolResponseStatus +from apps.user.models import UserProfile +from ara.settings import ANSWER_PERIOD + +from tests.conftest import RequestSetting, TestCase + + +@pytest.fixture(scope='class') +def set_school_admin(request): + request.cls.school_admin, _ = User.objects.get_or_create( + username='School Admin', + email='schooladmin@sparcs.org' + ) + if not hasattr(request.cls.school_admin, 'profile'): + UserProfile.objects.get_or_create( + user=request.cls.school_admin, + nickname='School Admin', + agree_terms_of_service_at=timezone.now(), + group=UserProfile.UserGroup.COMMUNICATION_BOARD_ADMIN, + sso_user_info={ + 'kaist_info': '{\"ku_kname\": \"\\ud669\"}', + 'first_name': 'FirstName', + 'last_name': 'LastName' + }, + ) + request.cls.api_client = APIClient() + + +@pytest.fixture(scope='class') +def set_communication_board(request): + request.cls.communication_board = Board.objects.create( + slug='with-school', + ko_name='학교와의 게시판 (테스트)', + en_name='With School (Test)', + ko_description='학교와의 게시판 (테스트)', + en_description='With School (Test)', + is_school_communication=True, + name_type=BoardNameType.REALNAME, + read_access_mask=0b11011110, + write_access_mask=0b11011010 + ) + + +@pytest.fixture(scope='class') +def set_non_communication_board(request): + request.cls.non_communication_board = Board.objects.create( + slug='not with-school', + ko_name='학교 아닌 게시판 (테스트)', + en_name='Not With School (Test)', + ko_description='학교 아닌 게시판 (테스트)', + en_description='Not With School (Test)', + is_school_communication=False + ) + + +@pytest.fixture(scope='class') +def set_article(request): + # After defining set_communication_board + request.cls.article = Article.objects.create( + title='Communication Article Title', + content='Communication Article Content', + content_text='Communication Article Content Text', + created_by=request.cls.user, + parent_board=request.cls.communication_board, + name_type=BoardNameType.REALNAME + ) + + +@pytest.fixture(scope='class') +def set_communication_article(request): + # After defining set_article + request.cls.communication_article = CommunicationArticle.objects.create( + article=request.cls.article + ) + + +@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_user_client3', 'set_user_client4', + 'set_school_admin', 'set_communication_board', 'set_non_communication_board', + 'set_article', 'set_communication_article') +class TestCommunicationArticle(TestCase, RequestSetting): + + # ======================================================================= # + # Helper Functions # + # ======================================================================= # + + 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) + for user in dummy_users: + self.http_request(user, 'post', f'articles/{article.id}/vote_positive') + + def _add_admin_comment(self, article): + comment_data = { + 'content': 'Comment made in factory', + 'created_by': self.school_admin.id, + 'parent_article': article.id + } + res = self.http_request(self.school_admin, 'post', 'comments', comment_data) + assert res.data.get('created_by').get('profile').get('is_school_admin') + + # status를 가지는 communication_article 반환 + def _create_article_with_status(self, status=SchoolResponseStatus.BEFORE_UPVOTE_THRESHOLD): + article_title = f'Factory: Article Status {status} created at {timezone.now()}' + article_data = { + 'title': article_title, + 'content': 'Content made in factory', + 'content_text': 'Content Text made in factory', + 'created_by': self.user.id, + 'parent_board': self.communication_board.id, + 'name_type': BoardNameType.REALNAME + } + res = self.http_request(self.user, 'post', 'articles', article_data) + + article = Article.objects.get(pk=res.data.get('id')) + + if status == SchoolResponseStatus.BEFORE_UPVOTE_THRESHOLD: + pass + elif status == SchoolResponseStatus.BEFORE_SCHOOL_CONFIRM: + self._add_positive_votes(article, SCHOOL_RESPONSE_VOTE_THRESHOLD) + elif status == SchoolResponseStatus.ANSWER_DONE: + self._add_admin_comment(article) + + return article + + # ======================================================================= # + # Creation Test # + # ======================================================================= # + + # communication_article 생성 확인 + def test_create_communication_article(self): + # 소통 게시물 생성 + article_title = 'Article for test_create_communication_article' + user_data = { + 'title': article_title, + 'content': 'Content for test_create_communication_article', + 'content_text': 'test_create_communication_article', + 'creted_by': self.user.id, + 'parent_board': self.communication_board.id + } + self.http_request(self.user, 'post', 'articles', user_data) + + article = Article.objects.get(title=article_title) + + # 소통 게시물이 생성될 때 communication_article이 함께 생성되는지 확인 + article_id = article.id + communication_article = CommunicationArticle.objects.get(article_id=article_id) + assert communication_article + + # 필드 default 값 확인 + min_time = timezone.datetime.min.replace(tzinfo=timezone.utc) + assert all([ + communication_article.response_deadline == min_time, + communication_article.answered_at == min_time, + communication_article.school_response_status == SchoolResponseStatus.BEFORE_UPVOTE_THRESHOLD + ]) + + # 소통 게시물이 아닌 게시물에 communication_article 없는지 확인 + def test_non_communication_article(self): + # 비소통 게시물 생성 + article_title = 'Article for test_non_communication_article' + user_data = { + 'title': article_title, + 'content': 'Content for test_non_communication_article', + 'content_text': 'test_non_communication_article', + 'creted_by': self.user.id, + 'parent_board': self.non_communication_board.id + } + self.http_request(self.user, 'post', 'articles', user_data) + + article_id = Article.objects.get(title=article_title).id + communication_article = CommunicationArticle.objects.filter(article_id=article_id).first() + assert communication_article is None + + # ======================================================================= # + # SchoolResponseStatus Update Test # + # ======================================================================= # + + # 0 -> 1 + def test_BEFORE_UPVOTE_THRESHOLD_to_BEFORE_SCHOOL_CONFIRM(self): + from_status = SchoolResponseStatus.BEFORE_UPVOTE_THRESHOLD + to_status = SchoolResponseStatus.BEFORE_SCHOOL_CONFIRM + + article = self._create_article_with_status(from_status) + assert self._get_communication_article_status(article) == from_status + + self._add_positive_votes(article, SCHOOL_RESPONSE_VOTE_THRESHOLD) + assert self._get_communication_article_status(article) == to_status + + # 0 -> 2 + def test_BEFORE_UPVOTE_THRESHOLD_to_ANSWER_DONE(self): + from_status = SchoolResponseStatus.BEFORE_UPVOTE_THRESHOLD + to_status = SchoolResponseStatus.ANSWER_DONE + + article = self._create_article_with_status(from_status) + assert self._get_communication_article_status(article) == from_status + + self._add_admin_comment(article) + assert self._get_communication_article_status(article) == to_status + + # 1 -> 2 + def test_BEFORE_SCHOOL_CONFIRM_to_ANSWER_DONE(self): + from_status = SchoolResponseStatus.BEFORE_SCHOOL_CONFIRM + to_status = SchoolResponseStatus.ANSWER_DONE + + article = self._create_article_with_status(from_status) + assert self._get_communication_article_status(article) == from_status + + self._add_admin_comment(article) + assert self._get_communication_article_status(article) == to_status + + # 2 -> 1 + def test_ANSWER_DONE_to_BEFORE_SCHOOL_CONFIRM(self): + status = SchoolResponseStatus.ANSWER_DONE + + article = self._create_article_with_status(status) + assert self._get_communication_article_status(article) == status + + self._add_positive_votes(article, SCHOOL_RESPONSE_VOTE_THRESHOLD) + assert self._get_communication_article_status(article) == status + + def test_days_left(self): + status = SchoolResponseStatus.BEFORE_UPVOTE_THRESHOLD + article = self._create_article_with_status(status) + + # days_left가 설정되기 전 + res = self.http_request(self.school_admin, 'get', f'articles/{article.id}') + assert res.data.get('days_left') == sys.maxsize + # + self._add_positive_votes(article, SCHOOL_RESPONSE_VOTE_THRESHOLD) + res = self.http_request(self.school_admin, 'get', f'articles/{article.id}') + assert res.data.get('days_left') == ANSWER_PERIOD + + def test_days_left_in_local_timezone(self): + status = SchoolResponseStatus.BEFORE_SCHOOL_CONFIRM + article = self._create_article_with_status(status) + + today = timezone.localtime().replace(hour=23, minute=59, second=0) + tomorrow = (timezone.localtime() + timedelta(days=1)).replace(hour=0, minute=1, second=0) + + # localtime 기준으로 오늘에서 내일로 넘어갈 때 D-day 변경되는지 확인 + with patch.object(timezone, 'localtime', return_value=today): + res = self.http_request(self.school_admin, 'get', f'articles/{article.id}') + assert res.data.get('days_left') == ANSWER_PERIOD + + with patch.object(timezone, 'localtime', return_value=tomorrow): + res = self.http_request(self.school_admin, 'get', f'articles/{article.id}') + assert res.data.get('days_left') == ANSWER_PERIOD - 1 + + + + # ======================================================================= # + # Ordering & Filtering # + # ======================================================================= # + + # 좋아요 개수 내림차순 정렬 확인 + def test_descending_ordering_by_positive_vote_count(self): + vote_counts = (0, 1, 2, 2, 3, 4) + articles = [self._create_article_with_status() for _ in vote_counts] + + for vote_cnt, article in zip(vote_counts, articles): + self._add_positive_votes(article, vote_cnt) + + res = self.http_request(self.user, 'get', 'articles', + querystring='ordering=-positive_vote_count,-created_at') + assert res.status_code == HTTP_200_OK + + res_result = res.data.get('results') + + # 좋아요 개수 내림차순 정렬 확인 + res_positive_votes = [el.get('positive_vote_count') for el in res_result] + assert res_positive_votes == sorted(res_positive_votes, reverse=True) + + # 좋아요 개수 같은 경우 최신 글이 앞에 있는지 확인 + res_vote_cnt_eq = [el.get('created_at') for el in res_result if el.get('positive_vote_count') == 2] + assert res_vote_cnt_eq == sorted(res_vote_cnt_eq, reverse=True, key=lambda date_str: datetime.fromisoformat(date_str)) + + # 좋아요 개수 오름차순 정렬 확인 + def test_ascending_ordering_by_positive_vote_count(self): + vote_counts = (4, 3, 2, 2, 1, 0) + articles = [self._create_article_with_status() for _ in vote_counts] + + for vote_cnt, article in zip(vote_counts, articles): + self._add_positive_votes(article, vote_cnt) + + res = self.http_request(self.user, 'get', 'articles', + querystring='ordering=positive_vote_count,-created_at') + assert res.status_code == HTTP_200_OK + + res_result = res.data.get('results') + assert Article.objects.count() == len(res_result) + + # 좋아요 개수 오름차순 정렬 확인 + res_positive_votes = [el.get('positive_vote_count') for el in res_result] + assert res_positive_votes == sorted(res_positive_votes) + + # 좋아요 개수 같은 경우 최신 글이 앞에 있는지 확인 + res_vote_cnt_eq = [el.get('created_at') for el in res_result if el.get('positive_vote_count') == 2] + assert res_vote_cnt_eq == sorted(res_vote_cnt_eq, reverse=True, key=lambda date_str: datetime.fromisoformat(date_str)) + + # 답변 진행 상황 필터링 확인 + def test_filtering_by_status(self): + # 모든 status의 article 하나씩 생성 + for status in SchoolResponseStatus: + self._create_article_with_status(status) + + # 답변 완료(status=3) + res = self.http_request(self.user, 'get', 'articles', + querystring=f'communication_article__school_response_status={SchoolResponseStatus.ANSWER_DONE}') + assert res.status_code == HTTP_200_OK + res_status_set = {el.get('communication_article_status') for el in res.data.get('results')} + assert res_status_set == {SchoolResponseStatus.ANSWER_DONE} + + # 답변 전(status<3) + res = self.http_request(self.user, 'get', 'articles', + querystring=f'communication_article__school_response_status__lt={SchoolResponseStatus.ANSWER_DONE}') + assert res.status_code == HTTP_200_OK + res_status_set = {el.get('communication_article_status') for el in res.data.get('results')} + assert res_status_set == {*SchoolResponseStatus} - {SchoolResponseStatus.ANSWER_DONE} + + # ======================================================================= # + # Anonymous # + # ======================================================================= # + + # 익명 게시물 작성 불가 확인 + def test_anonymous_article(self): + article_title = 'Anonymous article' + article_data = { + 'title': article_title, + 'content': 'Content of anonymous article', + 'content_text': 'Content text of anonymous article', + 'created_by': self.user.id, + 'parent_board': self.communication_board.id, + 'name_type': BoardNameType.ANONYMOUS + } + res = self.http_request(self.user, 'post', 'articles', article_data).data + assert res.get('name_type') == BoardNameType.REALNAME + + # 익명 댓글 작성 불가 확인 + def test_anonymous_comment(self): + comment_data = { + 'content': 'Anonymous comment', + 'created_by': self.user.id, + 'parent_article': self.article.id, + 'name_type': BoardNameType.ANONYMOUS + } + res = self.http_request(self.user, 'post', 'comments', comment_data).data + assert res.get('name_type') == BoardNameType.REALNAME diff --git a/tests/test_recent.py b/tests/test_recent.py index 72695b24..89ab9d5d 100644 --- a/tests/test_recent.py +++ b/tests/test_recent.py @@ -4,6 +4,7 @@ from django.core.management import call_command from apps.core.models import Article, Topic, Board +from apps.core.models.board import BoardNameType from tests.conftest import RequestSetting, TestCase from django.utils import timezone @@ -40,7 +41,7 @@ def create_article(n): title=f'Test Article{n}', content=f'Content of test article {n}', content_text=f'Content_text of test article {n}', - is_anonymous=False, + name_type=BoardNameType.REGULAR, is_content_sexual=False, is_content_social=False, hit_count=0, diff --git a/tests/test_report.py b/tests/test_report.py index 14f453f7..b281f99e 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -1,6 +1,7 @@ import pytest from apps.core.models import Article, Topic, Board, Comment, Report from django.db.utils import IntegrityError +from apps.core.models.board import BoardNameType from tests.conftest import RequestSetting, TestCase from django.utils import timezone @@ -36,7 +37,7 @@ def set_article(request): title='Test Article', content='Content of test article', content_text='Content of test article in text', - is_anonymous=False, + name_type=BoardNameType.REGULAR, is_content_sexual=False, is_content_social=False, hit_count=0, @@ -54,7 +55,7 @@ def set_comment(request): """set_article 먼저 적용""" request.cls.comment = Comment.objects.create( content='this is a test comment', - is_anonymous=False, + name_type=BoardNameType.REGULAR, created_by=request.cls.user, parent_article=request.cls.article, ) diff --git a/tests/test_user.py b/tests/test_user.py index 5c9de477..bf9287a8 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -6,11 +6,12 @@ from django.core.validators import URLValidator from apps.core.models import Board, Article, Comment +from apps.core.models.board import BoardNameType from tests.conftest import RequestSetting, TestCase @pytest.fixture(scope='class') -def set_board(request): +def set_boards(request): request.cls.board = Board.objects.create( slug="test board", ko_name="테스트 게시판", @@ -19,6 +20,15 @@ def set_board(request): en_description="This is a board for testing" ) + request.cls.realname_board = Board.objects.create( + slug="realname", + ko_name="실명 게시판", + en_name="Realname Board", + ko_description="실명 게시판", + en_description="Realname Board", + name_type=BoardNameType.REALNAME + ) + @pytest.fixture(scope='class') def set_articles(request): @@ -26,7 +36,7 @@ def set_articles(request): common_kwargs = { 'content': 'example content', 'content_text': 'example content text', - 'is_anonymous': False, + 'name_type': BoardNameType.REGULAR, 'created_by': request.cls.user2, 'parent_board': request.cls.board, 'hit_count': 0, @@ -75,7 +85,7 @@ def set_anonymous_article(request): title='익명글', is_content_sexual=False, is_content_social=False, - is_anonymous=True, + name_type=BoardNameType.ANONYMOUS, content='example content', content_text='example content text', created_by=request.cls.user2, @@ -84,18 +94,46 @@ def set_anonymous_article(request): ) +@pytest.fixture(scope='class') +def set_realname_article(request): + """set_realname_board, set_user_with_kaist_info 먼저 적용""" + request.cls.realname_article = Article.objects.create( + title='실명글', + is_content_sexual=False, + is_content_social=False, + name_type=BoardNameType.REALNAME, + content='example content', + content_text='example content text', + created_by=request.cls.user_with_kaist_info, + parent_board=request.cls.realname_board, + hit_count=0, + ) + + @pytest.fixture(scope='class') def set_anonymous_comment(request): """set_anonymous_articles 먼저 적용""" request.cls.comment_anonymous = Comment.objects.create( content='example comment', - is_anonymous=True, + name_type=BoardNameType.ANONYMOUS, created_by=request.cls.user, parent_article=request.cls.article_anonymous, ) -@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_board', 'set_articles') +@pytest.fixture(scope='class') +def set_realname_comment(request): + """set_realname_article, set_user_with_kaist_info 먼저 적용""" + request.cls.realname_comment = Comment.objects.create( + content='example comment', + name_type=BoardNameType.REALNAME, + created_by=request.cls.user_with_kaist_info, + parent_article=request.cls.realname_article, + ) + + +@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_user_with_kaist_info', + 'set_boards', 'set_articles') class TestUser(TestCase, RequestSetting): def test_profile_edit(self): # 프로필 (ie. 사용자 설정)이 잘 변경되는지 테스트합니다. @@ -196,13 +234,14 @@ def test_nickname_update(self): assert (timezone.now() - self.user.profile.nickname_updated_at).total_seconds() < 5 -@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_board', 'set_anonymous_article', 'set_anonymous_comment') +@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_user_with_kaist_info', + 'set_boards', 'set_anonymous_article', 'set_anonymous_comment') class TestAnonymousUser(TestCase, RequestSetting): def test_anonymous_article_profile_picture(self): r = self.http_request(self.user, 'get', f'articles/{self.article_anonymous.id}').data profile_picture_url = r.get('created_by')['profile']['picture'] try: - URLValidator(profile_picture_url) + URLValidator()(profile_picture_url) except ValidationError: assert False, 'Bad URL for anonymous article profile picture' @@ -210,6 +249,26 @@ def test_anonymous_comment_profile_picture(self): r = self.http_request(self.user, 'get', f'comments/{self.comment_anonymous.id}').data profile_picture_url = r.get('created_by')['profile']['picture'] try: - URLValidator(profile_picture_url) + URLValidator()(profile_picture_url) + except ValidationError: + assert False, 'Bad URL for anonymous comment profile picture' + + +@pytest.mark.usefixtures('set_user_client', 'set_user_client2', 'set_user_with_kaist_info', + 'set_boards', 'set_realname_article', 'set_realname_comment') +class TestRealnameUser(TestCase, RequestSetting): + def test_realname_article_profile_picture(self): + r = self.http_request(self.user_with_kaist_info, 'get', f'articles/{self.realname_article.id}').data + profile_picture_url = r.get('created_by')['profile']['picture'] + try: + URLValidator()(profile_picture_url) + except ValidationError: + assert False, 'Bad URL for anonymous article profile picture' + + def test_realname_comment_profile_picture(self): + r = self.http_request(self.user_with_kaist_info, 'get', f'comments/{self.realname_comment.id}').data + profile_picture_url = r.get('created_by')['profile']['picture'] + try: + URLValidator()(profile_picture_url) except ValidationError: assert False, 'Bad URL for anonymous comment profile picture'