diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..3491a9e4 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "pilmate-frontend", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "/Users/itaegyeong/Desktop/naveraiagent/hub/frontend", + "port": 5173 + } + ] +} diff --git a/backend/account/decorators.py b/backend/account/decorators.py index 55719bb0..9b2e3707 100644 --- a/backend/account/decorators.py +++ b/backend/account/decorators.py @@ -113,12 +113,11 @@ def _check_permission(*args, **kwargs): if not contest_id: return self.error("Parameter error, contest_id is required") - 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) - except Contest.DoesNotExist: - return self.error("Contest %s doesn't exist" % contest_id) + try: + # Always reload with visible=True so preloaded contests cannot bypass soft-delete visibility. + 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) # Anonymous if not user.is_authenticated: diff --git a/backend/community/serializers.py b/backend/community/serializers.py index 8b88d303..087705ac 100644 --- a/backend/community/serializers.py +++ b/backend/community/serializers.py @@ -97,6 +97,8 @@ class Meta: def get_content_preview(self, obj): """게시글 내용의 미리보기(HTML 태그를 제거한 처음 100자)를 반환합니다.""" + if not getattr(obj, "can_view", True): + return "" text = strip_tags(obj.content) return text[:100] + ("..." if len(text) > 100 else "") diff --git a/backend/community/tests.py b/backend/community/tests.py index 2d621903..107a6fa4 100644 --- a/backend/community/tests.py +++ b/backend/community/tests.py @@ -248,6 +248,7 @@ def test_get_contest_host_only_post_list_visibility(self): 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.assertEqual(response.data["data"]["results"][0]["content_preview"], "") self.client.logout() self.client.force_login(self.admin) @@ -256,6 +257,7 @@ def test_get_contest_host_only_post_list_visibility(self): 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"]) + self.assertEqual(response.data["data"]["results"][0]["content_preview"], "Content") def test_get_contest_host_only_post_detail_visibility(self): """주최자 전용 대회 게시글 상세는 주최자만 조회할 수 있다.""" @@ -306,6 +308,70 @@ def test_get_contest_host_only_post_detail_by_author(self): self.assertSuccess(response) self.assertEqual(response.data["data"]["visibility"], "CONTEST_HOSTS") + def test_get_contest_mine_question_filter_is_server_side(self): + """내 질문 필터는 서버에서 필터링되어 페이지네이션 total도 내 질문 기준으로 계산된다.""" + self.client.force_login(self.other_user) + for index in range(12): + response = self.client.post( + self.post_list_url, + { + "title": f"Other Contest Post {index}", + "content": "Content", + "post_type": "ARTICLE", + "contest_id": self.contest["id"], + }, + ) + self.assertSuccess(response) + self.client.logout() + + self.client.force_login(self.user) + response = self.client.post( + self.post_list_url, + { + "title": "My Contest Question", + "content": "Content", + "post_type": "QUESTION", + "contest_id": self.contest["id"], + }, + ) + self.assertSuccess(response) + + response = self.client.get( + self.post_list_url, + { + "contest_id": self.contest["id"], + "post_type": "QUESTION", + "is_mine": "true", + "offset": 0, + "limit": 10, + }, + ) + self.assertSuccess(response) + self.assertEqual(response.data["data"]["total"], 1) + self.assertEqual(response.data["data"]["results"][0]["title"], "My Contest Question") + + def test_get_contest_post_detail_hidden_contest_denied(self): + """숨김 처리된 대회의 게시글은 preloaded contest를 통해 상세 조회할 수 없다.""" + self.client.force_login(self.user) + response = self.client.post( + self.post_list_url, + { + "title": "Contest Post", + "content": "Content", + "post_type": "ARTICLE", + "contest_id": self.contest["id"], + }, + ) + self.assertSuccess(response) + post_id = response.data["data"]["id"] + contest = Contest.objects.get(id=self.contest["id"]) + contest.visible = False + contest.save() + + detail_url = self.reverse("community_post_detail", kwargs={"post_id": post_id}) + response = self.client.get(detail_url) + self.assertFailed(response, "No permission to access this contest's community") + def test_get_contest_post_list_no_permission(self): """대회에 대한 접근 권한이 없는 사용자는 대회 게시글 목록을 조회할 수 없다.""" # 비공개 대회 게시글 생성 @@ -661,6 +727,46 @@ def test_contest_host_badge_on_comments_in_post_detail(self): self.assertEqual(user_comment_data["replies"][0]["id"], host_reply.id) self.assertTrue(user_comment_data["replies"][0]["is_contest_host"]) + def test_host_only_post_comments_require_view_permission(self): + """주최자 전용 게시글 댓글 API는 상세 조회와 같은 열람 권한을 요구한다.""" + 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"] + post = Post.objects.get(id=post_id) + comment = Comment.objects.create(post=post, author=self.admin, content="Host comment") + comment_url = self.reverse("community_post_comments", kwargs={"post_id": post_id}) + comment_detail_url = self.reverse( + "community_comment_detail", + kwargs={"post_id": post_id, "comment_id": comment.id}, + ) + self.client.logout() + + self.client.force_login(self.other_user) + response = self.client.get(comment_url) + self.assertFailed(response, "Only contest hosts or the author can view this post") + response = self.client.post(comment_url, {"content": "Direct comment"}) + self.assertFailed(response, "Only contest hosts or the author can view this post") + response = self.client.put(comment_detail_url, {"content": "Updated"}) + self.assertFailed(response, "Only contest hosts or the author can view this post") + response = self.client.delete(comment_detail_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(comment_url) + self.assertSuccess(response) + self.assertEqual(response.data["data"]["total"], 1) + def test_get_comment_list(self): """게시글의 댓글 목록을 조회할 수 있다.""" # 댓글 생성 diff --git a/backend/community/views/oj.py b/backend/community/views/oj.py index 6a7db86f..78e47b3e 100644 --- a/backend/community/views/oj.py +++ b/backend/community/views/oj.py @@ -9,6 +9,12 @@ PostUpdateSerializer) +def can_view_post(user, post): + if post.visibility != Post.Visibility.CONTEST_HOSTS: + return True + return user.is_authenticated and (post.author == user or user.is_contest_admin(post.contest)) + + class PostAPIView(APIView): """게시글 생성 및 목록 조회를 위한 API""" @@ -76,6 +82,7 @@ def get(self, request): question_status = request.GET.get("question_status") keyword = request.GET.get("keyword", "").strip() sort_type = request.GET.get("sort_type") + is_mine = request.GET.get("is_mine") in ["1", "true", "True"] 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) @@ -115,6 +122,12 @@ def get(self, request): if post_type: posts = posts.filter(post_type=post_type) + if is_mine: + if request.user.is_authenticated: + posts = posts.filter(author=request.user) + else: + posts = posts.none() + if question_status: posts = posts.filter(question_status=question_status) @@ -160,9 +173,7 @@ 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)): + if not can_view_post(request.user, post): return self.error("Only contest hosts or the author can view this post") return self.success(PostDetailSerializer(post).data) @@ -217,28 +228,47 @@ def delete(self, request, post_id): class CommentAPIView(APIView): """특정 게시글의 댓글 생성 및 목록 조회를 위한 API""" - def get(self, request, post_id): - """댓글 목록을 조회합니다.""" + @check_contest_permission(check_type="community") + def _check_contest_permission(self, request): + return None + + def get_post(self, request, post_id): try: - Post.objects.get(id=post_id) + post = Post.objects.select_related("author", "contest__created_by").get(id=post_id) except Post.DoesNotExist: - return self.error("Post does not exist") + return None, self.error("Post does not exist") + + if post.contest: + self.contest = post.contest + error = self._check_contest_permission(request) + if error: + return None, self.error("No permission to access this contest's community") + if not can_view_post(request.user, post): + return None, self.error("Only contest hosts or the author can view this post") + return post, None + + def get(self, request, post_id): + """댓글 목록을 조회합니다.""" + post, error = self.get_post(request, post_id) + if error: + return error comments = ( Comment.objects.filter( - post_id=post_id).select_related("author").prefetch_related("replies__author").order_by("created_at")) + post=post).select_related("author", "post__contest").prefetch_related( + "replies__author", "replies__post__contest").order_by("created_at")) root_comments = comments.filter(parent_comment__isnull=True) - data = self.paginate_data(request, root_comments, CommentSerializer) + data = self.paginate_data(request, root_comments, lambda results, many: CommentSerializer( + results, many=many, context={"contest": post.contest})) return self.success(data) @login_required def post(self, request, post_id): """댓글을 생성합니다.""" - try: - post = Post.objects.get(id=post_id) - except Post.DoesNotExist: - return self.error("Post does not exist") + post, error = self.get_post(request, post_id) + if error: + return error content = request.data.get("content") parent_comment_id = request.data.get("parent_comment_id") @@ -259,19 +289,39 @@ def post(self, request, post_id): content=content, parent_comment=parent_comment, ) - return self.success(CommentSerializer(comment).data) + return self.success(CommentSerializer(comment, context={"contest": post.contest}).data) class CommentDetailAPIView(APIView): """특정 댓글 수정 및 삭제를 위한 API""" + @check_contest_permission(check_type="community") + def _check_contest_permission(self, request): + return None + + def get_comment(self, request, post_id, comment_id): + try: + comment = Comment.objects.select_related( + "author", "post__author", "post__contest__created_by").get(id=comment_id, post_id=post_id) + except Comment.DoesNotExist: + return None, self.error("Comment does not exist") + + post = comment.post + if post.contest: + self.contest = post.contest + error = self._check_contest_permission(request) + if error: + return None, self.error("No permission to access this contest's community") + if not can_view_post(request.user, post): + return None, self.error("Only contest hosts or the author can view this post") + return comment, None + @login_required def put(self, request, post_id, comment_id): """댓글을 수정합니다.""" - try: - comment = Comment.objects.get(id=comment_id, post_id=post_id) - except Comment.DoesNotExist: - return self.error("Comment does not exist") + comment, error = self.get_comment(request, post_id, comment_id) + if error: + return error if comment.author != request.user and not request.user.is_super_admin(): return self.error("No permission to edit this comment") @@ -282,15 +332,14 @@ def put(self, request, post_id, comment_id): comment.content = content comment.save() - return self.success(CommentSerializer(comment).data) + return self.success(CommentSerializer(comment, context={"contest": comment.post.contest}).data) @login_required def delete(self, request, post_id, comment_id): """댓글을 삭제합니다.""" - try: - comment = Comment.objects.get(id=comment_id, post_id=post_id) - except Comment.DoesNotExist: - return self.error("Comment does not exist") + comment, error = self.get_comment(request, post_id, comment_id) + if error: + return error if comment.author != request.user and not request.user.is_super_admin(): return self.error("No permission to delete this comment") diff --git a/frontend/src/pages/oj/api.js b/frontend/src/pages/oj/api.js index 16d66656..7bd130dc 100644 --- a/frontend/src/pages/oj/api.js +++ b/frontend/src/pages/oj/api.js @@ -443,6 +443,7 @@ export default { contest_id = null, keyword = null, sort_type = null, + is_mine = null, ) { const params = { offset, @@ -455,6 +456,7 @@ export default { if (contest_id) params.contest_id = contest_id if (keyword) params.keyword = keyword if (sort_type) params.sort_type = sort_type + if (is_mine !== null) params.is_mine = is_mine return ajax("community/posts", "get", { params, diff --git a/frontend/src/pages/oj/views/contest/ContestHistory.vue b/frontend/src/pages/oj/views/contest/ContestHistory.vue index 31dd62fd..2d9428cd 100644 --- a/frontend/src/pages/oj/views/contest/ContestHistory.vue +++ b/frontend/src/pages/oj/views/contest/ContestHistory.vue @@ -77,6 +77,7 @@