Skip to content

fix: exclude soft-deleted comments from abuse count when Forum v1 is enabled#157

Open
Alam-2U wants to merge 1 commit intorelease-ulmofrom
ealam/COSMO2-817
Open

fix: exclude soft-deleted comments from abuse count when Forum v1 is enabled#157
Alam-2U wants to merge 1 commit intorelease-ulmofrom
ealam/COSMO2-817

Conversation

@Alam-2U
Copy link

@Alam-2U Alam-2U commented Mar 3, 2026

Description

Posts with abuse-flagged soft-deleted comments were incorrectly displaying the "reported" tag in learner profiles when Forum v1 (Ruby service) is enabled.

The Ruby forum service returns an abuse_flagged_count that includes deleted comments, which caused posts to appear as reported even when the abuse-flagged comments had already been soft deleted.

Solution

Refactored get_learner_active_thread_list to ensure soft-deleted comments are excluded from abuse flag counts when fetching data from the Ruby forum service.

Key Changes

  • _recalculate_abuse_flagged_count
    Recalculates abuse_flagged_count by filtering out comments marked with is_deleted: true.

  • _filter_active_threads
    Applies deletion filtering and triggers abuse count recalculation.

Ticket

COSMO2-817

Copilot AI review requested due to automatic review settings March 3, 2026 05:11
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR changes learner profile “active threads” retrieval to always use the Forum v2 (Python) API so that abuse-flag counts correctly exclude soft-deleted items, avoiding the legacy Ruby service behavior that can incorrectly surface “reported” tags.

Changes:

  • Removed waffle-flag-based routing to the legacy cs_comments_service for User.active_threads.
  • Normalized/coerced query params (user_id, page, per_page, count_flagged) before calling forum_api.get_user_active_threads.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +124 to +126
if not params.get("course_id"):
params["course_id"] = str(course_key)
response = forum_api.get_user_active_threads(**params)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

Since the legacy cs_comments_service branch was removed, callers/tests that previously mocked the /users/{id}/active_threads HTTP endpoint when ENABLE_FORUM_V2 is off will no longer see any request. Please update/add tests to assert the Forum v2 forum_api.get_user_active_threads call even when the course waffle flag is disabled, to prevent regressions in learner profile thread listings.

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +126
course_key = utils.get_course_key(self.attributes.get("course_id"))
if is_forum_v2_enabled(course_key):
if user_id := params.get("user_id"):
params["user_id"] = str(user_id)
if page := params.get("page"):
params["page"] = int(page)
if per_page := params.get("per_page"):
params["per_page"] = int(per_page)
if count_flagged := params.get("count_flagged", False):
params["count_flagged"] = str_to_bool(count_flagged)
if not params.get("course_id"):
params["course_id"] = str(course_key)
response = forum_api.get_user_active_threads(**params)
else:
response = utils.perform_request(
'get',
url,
params,
metric_action='user.active_threads',
metric_tags=self._metric_tags,
paged_results=True,
)
if not params.get("course_id"):
params["course_id"] = str(course_key)
response = forum_api.get_user_active_threads(**params)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

active_threads now always calls Forum v2, but it no longer honors the global override settings.DISABLE_FORUM_V2 (which is_forum_v2_enabled() checked). If an operator has Forum v2 disabled globally, this will still call forum_api.get_user_active_threads and can break environments relying on the legacy service. Consider explicitly checking is_forum_v2_disabled_globally() here and either (a) keep the legacy perform_request fallback or (b) raise a clear CommentClientRequestError explaining Forum v2 is disabled.

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +120
if page := params.get("page"):
params["page"] = int(page)
if per_page := params.get("per_page"):
params["per_page"] = int(per_page)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

Casting page / per_page with int(...) can raise ValueError for non-numeric query params (e.g., page=abc). Previously, when Forum v2 was disabled, these values were sent through to the legacy service which could return a 4xx; now this can bubble up as a 500 from the LMS. Consider validating these inputs and raising a CommentClientRequestError (or normalizing to defaults) when conversion fails.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 3, 2026 08:55
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

patcher = mock.patch(
"openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled",
return_value=False,
return_value=True,
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

This test class still uses CommentsServiceMockMixin/httpretty mocks (e.g., register_get_user_response()), but ENABLE_FORUM_V2.is_enabled is now patched to return True. That will route CommentClientUser.retrieve() to forum_api.get_user() instead of the httpretty-mocked /api/v1/users/... endpoint, so these tests will start making unmocked Forum v2 calls and likely fail. Either keep this patch False for this class, or migrate the class to ForumMockUtilsMixin + setUpClassAndForumMock() and use the forum-api mock helpers.

Suggested change
return_value=True,
return_value=False,

Copilot uses AI. Check for mistakes.
Comment on lines +1184 to +1189
@ddt.ddt
@httpretty.activate
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class LearnerThreadViewAPITest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
class LearnerThreadViewAPITest(
ForumsEnableMixin,
ForumMockUtilsMixin,
UrlResetMixin,
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

LearnerThreadView calls forum_api.get_thread(...) inside get_learner_active_thread_list to check deletion status, but this test class no longer has @httpretty.activate and also doesn’t patch lms.djangoapps.discussion.rest_api.api.forum_api. That means the request path will execute the real forum_api.get_thread implementation (network/DB) during tests. Consider explicitly mocking rest_api.api.forum_api.get_thread to return a deterministic payload (e.g., is_deleted=False) so the tests don’t rely on external behavior and remain stable.

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +128
if count_flagged := params.get("count_flagged", False):
params["count_flagged"] = str_to_bool(count_flagged)
if not params.get("course_id"):
params["course_id"] = str(course_key)
response = forum_api.get_user_active_threads(**params)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

active_threads() now unconditionally calls forum_api.get_user_active_threads(), which bypasses the global DISABLE_FORUM_V2 kill switch used by is_forum_v2_enabled() (see openedx/core/djangoapps/discussions/config/waffle.py). If ops relies on DISABLE_FORUM_V2 to force a fallback during incidents, this method will still hit Forum v2. Consider explicitly respecting the global override (e.g., guard on is_forum_v2_disabled_globally()), or document that this path intentionally ignores it.

Copilot uses AI. Check for mistakes.
@Alam-2U Alam-2U force-pushed the ealam/COSMO2-817 branch from b1301e3 to 25e8c25 Compare March 4, 2026 06:31
Copilot AI review requested due to automatic review settings March 4, 2026 06:36
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

return thread

def test_inactive(self):
self.user.is_active = False
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

test_inactive sets self.user.is_active = False but never saves the user. Since self.client.get(...) will typically re-load the authenticated user from the database, this test may not actually exercise the inactive-user behavior. Save the user (and/or re-authenticate) after toggling is_active so the request sees the inactive state.

Suggested change
self.user.is_active = False
self.user.is_active = False
self.user.save()

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +121
# Always use Forum v2 API (has correct is_deleted filtering for abuse_flagged_count)
if user_id := params.get("user_id"):
params["user_id"] = str(user_id)
else:
response = utils.perform_request(
'get',
url,
params,
metric_action='user.active_threads',
metric_tags=self._metric_tags,
paged_results=True,
)
# Use the user's own ID if not provided in query_params
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

active_threads now unconditionally calls forum_api.get_user_active_threads, bypassing both the course waffle flag and the global kill-switch (settings.DISABLE_FORUM_V2 via is_forum_v2_disabled_globally). If Forum v2 is disabled globally (or temporarily taken out of service), this method will still attempt to call it and can break learner profile/thread views. Consider respecting the global disable (and optionally falling back to the legacy request path) while still bypassing only the course-level waffle flag as intended by this change.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 4, 2026 13:55
@Alam-2U Alam-2U force-pushed the ealam/COSMO2-817 branch from fdcae23 to 265b3f3 Compare March 4, 2026 13:55
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1339 to +1345
abuse_count = 0
for response_type in ["endorsed_responses", "non_endorsed_responses", "children"]:
if response_type in forum_thread:
for child in forum_thread.get(response_type, []):
if child.get("abuse_flaggers", []) and not child.get("is_deleted", False):
abuse_count += 1
thread["abuse_flagged_count"] = abuse_count
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The recalculation of abuse_flagged_count doesn’t match the API’s documented semantics (“flags/reports on and within the thread”): it (1) only inspects first-level response lists and ignores nested comments when recursive=False, (2) doesn’t include thread-level flags, and (3) increments by 1 per flagged child rather than counting the actual number of reports/flaggers. This can undercount and potentially clear the “reported” state when only nested comments or the thread itself is flagged, or when multiple users flag the same comment. Consider using Forum v2’s server-provided aggregate count (or computing a recursive sum of len(abuse_flaggers) across the thread + all descendants, excluding is_deleted=True) instead of this partial traversal.

Copilot uses AI. Check for mistakes.
params = {}
if count_flagged and "abuse_flagged_count" in thread:
params = {"with_responses": True, "recursive": False}

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

There’s trailing whitespace on this blank line, which can cause lint failures in repos that enforce whitespace checks. Remove the extra spaces so the line is truly empty.

Suggested change

Copilot uses AI. Check for mistakes.
Comment on lines 1319 to 1326
# Fetch thread with responses to recalculate abuse_flagged_count if needed
params = {}
if count_flagged and "abuse_flagged_count" in thread:
params = {"with_responses": True, "recursive": False}

forum_thread = forum_api.get_thread(
thread.get("id"), course_id=str(course_key)
thread.get("id"), params=params, course_id=str(course_key)
)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The PR description says the conditional routing is removed and Forum v2’s get_user_active_threads() is always used. This code still calls comment_client_user.active_threads(...) (which can hit the legacy Ruby service when Forum v2 is disabled) and then tries to patch results per-thread. If the goal is to fully avoid the legacy abuse_flagged_count behavior, this should route the learner threads list through the Forum v2 active-threads API directly instead of relying on legacy output + per-thread fixups.

Copilot uses AI. Check for mistakes.
Comment on lines 1319 to 1326
# Fetch thread with responses to recalculate abuse_flagged_count if needed
params = {}
if count_flagged and "abuse_flagged_count" in thread:
params = {"with_responses": True, "recursive": False}

forum_thread = forum_api.get_thread(
thread.get("id"), course_id=str(course_key)
thread.get("id"), params=params, course_id=str(course_key)
)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

This introduces an N+1 pattern for the learner profile endpoint: for each thread returned by active_threads, you fetch the full thread again, and when count_flagged is enabled you also request responses (with_responses=True). That can significantly increase latency and payload size for profiles with many threads. Prefer getting the needed fields (deleted status + correct abuse counts) from a single Forum v2 active-threads call or a bulk/thread-list endpoint rather than per-thread get_thread calls.

Copilot uses AI. Check for mistakes.
@Alam-2U Alam-2U force-pushed the ealam/COSMO2-817 branch from 265b3f3 to ff842e2 Compare March 4, 2026 14:27
Copilot AI review requested due to automatic review settings March 5, 2026 06:55
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1344 to +1348
def _build_comment_client_user(user_id, course_key, group_id):
if group_id is None:
return comment_client.User(id=user_id, course_id=course_key)
return comment_client.User(id=user_id, course_id=course_key, group_id=group_id)

Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

All five new private helper functions (_build_comment_client_user, _forum_thread_fetch_params, _augment_deleted_thread_fields, _recalculate_abuse_flagged_count, _filter_active_threads) lack docstrings. Other private helper functions in this file consistently have docstrings (e.g., _get_course at line 192, _get_comment_and_context at line 276, _is_user_author_or_privileged at line 292, _check_fields at line 1520). Adding docstrings would be consistent with the codebase convention and improve maintainability.

Copilot uses AI. Check for mistakes.
Comment on lines +1362 to +1368
def _recalculate_abuse_flagged_count(thread, forum_thread):
abuse_count = 0
for response_type in ["endorsed_responses", "non_endorsed_responses", "children"]:
for child in forum_thread.get(response_type, []):
if child.get("abuse_flaggers", []) and not child.get("is_deleted", False):
abuse_count += 1
thread["abuse_flagged_count"] = abuse_count
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

The _recalculate_abuse_flagged_count function only counts abuse flags on direct children (responses) of the thread. With recursive=False in _forum_thread_fetch_params, nested replies (children of children) are not included in the response, so their abuse flags won't be counted. Additionally, abuse flags on the thread itself are not counted, even though the API documentation for abuse_flagged_count states it represents "The number of flags(reports) on and within the thread" (see views.py line 645).

Consider either:

  1. Using recursive=True to include nested replies, and also checking the thread's own abuse_flaggers, or
  2. Documenting this as a known limitation if a partial count is acceptable.

Copilot uses AI. Check for mistakes.


def _forum_thread_fetch_params(thread, count_flagged):
if count_flagged and "abuse_flagged_count" in thread:
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

When count_flagged is true and a thread has abuse_flagged_count, _forum_thread_fetch_params requests with_responses=True, which causes the per-thread forum_api.get_thread call to fetch all direct responses. Combined with the existing N+1 pattern (one call per thread in the list), this can be expensive for threads with many responses. Consider whether the abuse_flagged_count could be recalculated server-side in the Forum v2 backend (e.g., via an updated get_user_active_threads that correctly filters deleted items from the count), rather than re-fetching and re-counting on the client side.

Suggested change
if count_flagged and "abuse_flagged_count" in thread:
if (
count_flagged
and "abuse_flagged_count" in thread
and thread.get("abuse_flagged_count", 0) > 0
):

Copilot uses AI. Check for mistakes.
@Alam-2U Alam-2U changed the title fix: use Forum v2 for learner threads to exclude deleted items from a… fix: use Forum v2 for learner threads to exclude deleted items from abuse count Mar 9, 2026
Copilot AI review requested due to automatic review settings March 9, 2026 10:58
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@Alam-2U Alam-2U changed the title fix: use Forum v2 for learner threads to exclude deleted items from abuse count fix: exclude soft-deleted comments from abuse count when Forum v1 is enabled Mar 9, 2026
@Alam-2U Alam-2U force-pushed the ealam/COSMO2-817 branch from 29bd28a to d9b97a9 Compare March 9, 2026 11:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants