Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions backend/account/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,17 @@ def _check_permission(*args, **kwargs):
contest_id = request.data["contest_id"]
else:
contest_id = request.GET.get("contest_id")
if not contest_id and getattr(self, "contest", None):
contest_id = self.contest.id
if not contest_id:
return self.error("Parameter error, contest_id is required")

try:
# use self.contest to avoid query contest again in view.
self.contest = Contest.objects.select_related("created_by").get(id=contest_id, visible=True)
except Contest.DoesNotExist:
return self.error("Contest %s doesn't exist" % contest_id)
if not getattr(self, "contest", None):
try:
# use self.contest to avoid query contest again in view.
self.contest = Contest.objects.select_related("created_by").get(id=contest_id, visible=True)
Comment on lines +116 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-check visibility for preloaded contests

When a view preloads self.contest, this branch skips the only lookup that enforces visible=True. PostDetailAPIView now sets self.contest = post.contest, so posts belonging to a contest later hidden/deleted in admin can still be read by any user who otherwise passes the public/password/status checks; please either validate self.contest.visible here or reload by id with visible=True.

Useful? React with 👍 / 👎.

except Contest.DoesNotExist:
return self.error("Contest %s doesn't exist" % contest_id)

# Anonymous
if not user.is_authenticated:
Expand Down
25 changes: 25 additions & 0 deletions backend/community/migrations/0004_post_visibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated manually for contest community post visibility.

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("community", "0003_alter_post_question_status"),
]

operations = [
migrations.AddField(
model_name="post",
name="visibility",
field=models.CharField(
choices=[
("CONTEST_PARTICIPANTS", "대회 참여자 전체"),
("CONTEST_HOSTS", "주최자만"),
],
default="CONTEST_PARTICIPANTS",
max_length=30,
),
),
]
12 changes: 12 additions & 0 deletions backend/community/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ class SortType(models.TextChoices):
OLDEST = "OLDEST", "오래된순"
COMMENT = "COMMENT", "댓글많은순"

class Visibility(models.TextChoices):
"""대회 게시글 열람 범위"""

CONTEST_PARTICIPANTS = "CONTEST_PARTICIPANTS", "대회 참여자 전체"
CONTEST_HOSTS = "CONTEST_HOSTS", "주최자만"

title = models.CharField(max_length=200)
content = RichTextField()
author = models.ForeignKey(User, on_delete=models.CASCADE)
Expand All @@ -68,6 +74,12 @@ class SortType(models.TextChoices):
blank=True,
)

visibility = models.CharField(
max_length=30,
choices=Visibility.choices,
default=Visibility.CONTEST_PARTICIPANTS,
)

created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

Expand Down
22 changes: 21 additions & 1 deletion backend/community/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class ReplySerializer(serializers.ModelSerializer):

author_name = serializers.CharField(source="author.username", read_only=True)
author_avatar = serializers.CharField(source="author.userprofile.avatar", read_only=True)
is_contest_host = serializers.SerializerMethodField()

class Meta:
model = Comment
Expand All @@ -19,19 +20,25 @@ class Meta:
"author",
"author_name",
"author_avatar",
"is_contest_host",
"content",
"parent_comment",
"created_at",
"updated_at",
]

def get_is_contest_host(self, obj):
contest = self.context.get("contest")
return bool(contest and obj.author.is_contest_admin(contest))


class CommentSerializer(serializers.ModelSerializer):
"""댓글과 대댓글을 포함하는 Serializer"""

author_name = serializers.CharField(source="author.username", read_only=True)
author_avatar = serializers.CharField(source="author.userprofile.avatar", read_only=True)
replies = ReplySerializer(many=True, read_only=True)
is_contest_host = serializers.SerializerMethodField()

class Meta:
model = Comment
Expand All @@ -41,6 +48,7 @@ class Meta:
"author",
"author_name",
"author_avatar",
"is_contest_host",
"content",
"parent_comment",
"created_at",
Expand All @@ -49,6 +57,10 @@ class Meta:
]
read_only_fields = ["post", "author"]

def get_is_contest_host(self, obj):
contest = self.context.get("contest")
return bool(contest and obj.author.is_contest_admin(contest))


class PostListSerializer(serializers.ModelSerializer):
"""게시글 목록을 위한 Serializer"""
Expand All @@ -58,6 +70,8 @@ class PostListSerializer(serializers.ModelSerializer):
author_avatar = serializers.CharField(source="author.userprofile.avatar", read_only=True)
community_type = serializers.SerializerMethodField()
comment_count = serializers.IntegerField(read_only=True)
is_mine = serializers.BooleanField(read_only=True)
can_view = serializers.BooleanField(read_only=True)

class Meta:
model = Post
Expand All @@ -71,11 +85,14 @@ class Meta:
"community_type",
"post_type",
"question_status",
"visibility",
"created_at",
"updated_at",
"problem",
"contest",
"comment_count",
"is_mine",
"can_view",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress previews for locked posts

Adding can_view does not prevent the list serializer from still returning content_preview for rows where can_view is false. The UI hides that preview, but any contest participant can inspect the API response and read the first 100 characters of a host-only question despite being denied detail access; the server should blank or omit preview/content fields for non-viewable posts.

Useful? React with 👍 / 👎.

]

def get_content_preview(self, obj):
Expand Down Expand Up @@ -112,6 +129,7 @@ class Meta:
"community_type",
"post_type",
"question_status",
"visibility",
"created_at",
"updated_at",
"problem",
Expand All @@ -130,7 +148,7 @@ def get_comments(self, obj):
"""게시글에 달린 댓글 중 최상위 댓글(대댓글이 아닌)만 필터링하여 반환합니다."""
# view에서 prefetch_related를 사용했기 때문에 추가 쿼리가 발생하지 않습니다.
top_level_comments = [comment for comment in obj.comments.all() if comment.parent_comment_id is None]
serializer = CommentSerializer(top_level_comments, many=True)
serializer = CommentSerializer(top_level_comments, many=True, context={"contest": obj.contest})
return serializer.data


Expand All @@ -142,6 +160,7 @@ class CreatePostSerializer(serializers.Serializer):
problem_id = serializers.IntegerField(required=False, allow_null=True)
contest_id = serializers.IntegerField(required=False, allow_null=True)
post_type = serializers.ChoiceField(choices=Post.PostType.choices, default=Post.PostType.ARTICLE)
visibility = serializers.ChoiceField(choices=Post.Visibility.choices, required=False)


class PostUpdateSerializer(serializers.Serializer):
Expand All @@ -151,3 +170,4 @@ class PostUpdateSerializer(serializers.Serializer):
content = serializers.CharField(required=False)
post_type = serializers.ChoiceField(choices=Post.PostType.choices, required=False)
question_status = serializers.ChoiceField(choices=Post.QuestionStatus.choices, required=False)
visibility = serializers.ChoiceField(choices=Post.Visibility.choices, required=False)
125 changes: 125 additions & 0 deletions backend/community/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,94 @@ def test_get_contest_post_list(self):
response = self.client.get(self.post_list_url, {"contest_id": self.contest["id"]})
self.assertSuccess(response)
self.assertEqual(response.data["data"]["total"], 1)
self.assertTrue(response.data["data"]["results"][0]["is_mine"])
self.client.logout()

self.client.force_login(self.other_user)
response = self.client.get(self.post_list_url, {"contest_id": self.contest["id"]})
self.assertSuccess(response)
self.assertEqual(response.data["data"]["total"], 1)
self.assertFalse(response.data["data"]["results"][0]["is_mine"])

def test_get_contest_host_only_post_list_visibility(self):
"""주최자 전용 대회 게시글은 목록에 노출되지만 열람 가능 여부가 표시된다."""
self.client.force_login(self.admin)
response = self.client.post(
self.post_list_url,
{
"title": "Host Only Contest Post",
"content": "Content",
"post_type": "QUESTION",
"contest_id": self.contest["id"],
"visibility": "CONTEST_HOSTS",
},
)
self.assertSuccess(response)
self.client.logout()

self.client.force_login(self.other_user)
response = self.client.get(self.post_list_url, {"contest_id": self.contest["id"]})
self.assertSuccess(response)
self.assertEqual(response.data["data"]["total"], 1)
self.assertEqual(response.data["data"]["results"][0]["visibility"], "CONTEST_HOSTS")
self.assertFalse(response.data["data"]["results"][0]["can_view"])
self.client.logout()

self.client.force_login(self.admin)
response = self.client.get(self.post_list_url, {"contest_id": self.contest["id"]})
self.assertSuccess(response)
self.assertEqual(response.data["data"]["total"], 1)
self.assertEqual(response.data["data"]["results"][0]["visibility"], "CONTEST_HOSTS")
self.assertTrue(response.data["data"]["results"][0]["can_view"])

def test_get_contest_host_only_post_detail_visibility(self):
"""주최자 전용 대회 게시글 상세는 주최자만 조회할 수 있다."""
self.client.force_login(self.admin)
response = self.client.post(
self.post_list_url,
{
"title": "Host Only Contest Post",
"content": "Content",
"post_type": "QUESTION",
"contest_id": self.contest["id"],
"visibility": "CONTEST_HOSTS",
},
)
self.assertSuccess(response)
post_id = response.data["data"]["id"]
url = self.reverse("community_post_detail", kwargs={"post_id": post_id})
self.client.logout()

self.client.force_login(self.other_user)
response = self.client.get(url)
self.assertFailed(response, "Only contest hosts or the author can view this post")
self.client.logout()

self.client.force_login(self.admin)
response = self.client.get(url)
self.assertSuccess(response)
self.assertEqual(response.data["data"]["visibility"], "CONTEST_HOSTS")

def test_get_contest_host_only_post_detail_by_author(self):
"""주최자 전용 대회 게시글은 작성자도 상세 조회할 수 있다."""
self.client.force_login(self.user)
response = self.client.post(
self.post_list_url,
{
"title": "Host Only Contest Post",
"content": "Content",
"post_type": "QUESTION",
"contest_id": self.contest["id"],
"visibility": "CONTEST_HOSTS",
},
)
self.assertSuccess(response)
post_id = response.data["data"]["id"]
url = self.reverse("community_post_detail", kwargs={"post_id": post_id})

response = self.client.get(url)
self.assertSuccess(response)
self.assertEqual(response.data["data"]["visibility"], "CONTEST_HOSTS")

def test_get_contest_post_list_no_permission(self):
"""대회에 대한 접근 권한이 없는 사용자는 대회 게시글 목록을 조회할 수 없다."""
Expand Down Expand Up @@ -536,6 +624,43 @@ def test_delete_post_by_other_user(self):
response = self.client.delete(self.post_detail_url)
self.assertFailed(response, "No permission to delete this post")

def test_contest_host_badge_on_comments_in_post_detail(self):
"""대회 운영자가 단 댓글과 대댓글은 상세 응답에서 운영자 표시를 포함한다."""
self.client.force_login(self.user)
post_response = self.client.post(
self.post_list_url,
{
"title": "Contest Question",
"content": "Content",
"post_type": "QUESTION",
"contest_id": self.contest["id"],
},
)
self.assertSuccess(post_response)
post_id = post_response.data["data"]["id"]
post = Post.objects.get(id=post_id)

host_comment = Comment.objects.create(post=post, author=self.admin, content="Host answer")
user_comment = Comment.objects.create(post=post, author=self.other_user, content="User answer")
host_reply = Comment.objects.create(
post=post,
author=self.admin,
content="Host reply",
parent_comment=user_comment,
)

detail_url = self.reverse("community_post_detail", kwargs={"post_id": post_id})
response = self.client.get(detail_url)
self.assertSuccess(response)
comments = response.data["data"]["comments"]
host_comment_data = next(comment for comment in comments if comment["id"] == host_comment.id)
user_comment_data = next(comment for comment in comments if comment["id"] == user_comment.id)

self.assertTrue(host_comment_data["is_contest_host"])
self.assertFalse(user_comment_data["is_contest_host"])
self.assertEqual(user_comment_data["replies"][0]["id"], host_reply.id)
self.assertTrue(user_comment_data["replies"][0]["is_contest_host"])

def test_get_comment_list(self):
"""게시글의 댓글 목록을 조회할 수 있다."""
# 댓글 생성
Expand Down
32 changes: 29 additions & 3 deletions backend/community/views/oj.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.db.models import Count, Q
from django.db.models import BooleanField, Case, Count, Q, Value, When
from account.decorators import check_contest_permission, login_required
from contest.models import Contest
from problem.models import Problem
Expand Down Expand Up @@ -37,6 +37,7 @@ def post(self, request):
"content": data["content"],
"author": request.user,
"post_type": data["post_type"],
"visibility": data.get("visibility", Post.Visibility.CONTEST_PARTICIPANTS),
}

# 질문 게시글인 경우 기본 상태를 'OPEN'으로 설정
Expand All @@ -53,6 +54,8 @@ def post(self, request):
post_data["contest_id"] = contest_id
except Contest.DoesNotExist:
return self.error("Contest does not exist")
else:
post_data["visibility"] = Post.Visibility.CONTEST_PARTICIPANTS

# problem_id가 주어진 경우 문제 존재 여부 확인
if problem_id:
Expand All @@ -74,8 +77,27 @@ def get(self, request):
keyword = request.GET.get("keyword", "").strip()
sort_type = request.GET.get("sort_type")

posts = Post.objects.select_related("author__userprofile").annotate(
comment_count=Count('comments')).all().order_by("-created_at")
is_mine_condition = Q(author_id=request.user.id) if request.user.is_authenticated else Q(pk__isnull=True)
can_view_condition = Q(visibility=Post.Visibility.CONTEST_PARTICIPANTS) | Q(contest__isnull=True)
if request.user.is_authenticated:
can_view_condition |= Q(author_id=request.user.id)
can_view_condition |= Q(contest__created_by_id=request.user.id)
can_view_annotation = Value(True, output_field=BooleanField()) if (
request.user.is_authenticated and request.user.is_super_admin()
) else Case(
When(can_view_condition, then=Value(True)),
default=Value(False),
output_field=BooleanField(),
)
posts = Post.objects.select_related("author__userprofile", "contest__created_by").annotate(
comment_count=Count('comments'),
is_mine=Case(
When(is_mine_condition, then=Value(True)),
default=Value(False),
output_field=BooleanField(),
),
can_view=can_view_annotation,
).all().order_by("-created_at")
if contest_id:
try:
self.contest = Contest.objects.get(id=contest_id, visible=True)
Expand Down Expand Up @@ -138,6 +160,10 @@ def get(self, request, post_id):
error = self._check_contest_permission(request)
if error:
return self.error("No permission to access this contest's community")
if (post.visibility == Post.Visibility.CONTEST_HOSTS and
post.author != request.user and
not request.user.is_contest_admin(post.contest)):
Comment on lines +163 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce host-only access on comment endpoints

This only blocks the post detail response for CONTEST_HOSTS; the /community/posts/<id>/comments endpoints still fetch by post_id without the same visibility check, and the list response exposes locked post ids. A non-host contest participant can therefore call the comments API directly to read or add comments on a host-only question, so the same author/host/super-admin guard should be applied to comment list/create/update/delete.

Useful? React with 👍 / 👎.

return self.error("Only contest hosts or the author can view this post")

return self.success(PostDetailSerializer(post).data)

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/oj/en-US.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ export const m = {
Submissions: "제출물",
Rankings: "랭킹",
Contest_Overview: "대회 개요",
Contest_Community: "커뮤니티",
Contest_All_Posts: "전체 글 보기",
Contest_My_Questions: "내 질문 보기",
Admin_Helper: "채점 현황",
StartAt: "StartAt",
EndAt: "EndAt",
Expand Down
Loading