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
22 changes: 22 additions & 0 deletions lms/djangoapps/discussion/rest_api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
get_course_staff_users_list,
get_moderator_users_list,
get_course_ta_users_list,
get_user_learner_status,
)
from openedx.core.djangoapps.discussions.models import DiscussionTopicLink
from openedx.core.djangoapps.discussions.utils import get_group_names_by_id
Expand Down Expand Up @@ -182,6 +183,7 @@ class _ContentSerializer(serializers.Serializer):
id = serializers.CharField(read_only=True) # pylint: disable=invalid-name
author = serializers.SerializerMethodField()
author_label = serializers.SerializerMethodField()
learner_status = serializers.SerializerMethodField()
created_at = serializers.CharField(read_only=True)
updated_at = serializers.CharField(read_only=True)
raw_body = serializers.CharField(source="body", validators=[validate_not_blank])
Expand Down Expand Up @@ -275,6 +277,26 @@ def get_author_label(self, obj):
user_id = int(obj["user_id"])
return self._get_user_label(user_id)

def get_learner_status(self, obj):
"""
Get the learner status for the discussion post author.
Returns one of: "anonymous", "staff", "new", "regular"
"""
# Skip for anonymous content
if self._is_anonymous(obj) or obj.get("user_id") is None:
return "anonymous"

try:
user = User.objects.get(id=int(obj["user_id"]))
except (User.DoesNotExist, ValueError):
return "anonymous"

course = self.context.get("course")
if not course:
return "anonymous"

return get_user_learner_status(user, course.id)

def get_rendered_body(self, obj):
"""
Returns the rendered body content.
Expand Down
6 changes: 6 additions & 0 deletions lms/djangoapps/discussion/rest_api/tests/test_api_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit):
"course_id": str(self.course.id),
"comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_id",
"read": True,
"learner_status": "staff",
"editable_fields": [
"abuse_flagged",
"anonymous",
Expand Down Expand Up @@ -689,6 +690,7 @@ def test_success(self, parent_id, mock_emit):
"parent_id": parent_id,
"author": self.user.username,
"author_label": None,
"learner_status": "new",
"created_at": "2015-05-27T00:00:00Z",
"updated_at": "2015-05-27T00:00:00Z",
"raw_body": "Test body",
Expand Down Expand Up @@ -796,6 +798,7 @@ def test_success_in_black_out_with_user_access(self, parent_id, mock_emit):
"parent_id": parent_id,
"author": self.user.username,
"author_label": "Moderator",
"learner_status": "staff",
"created_at": "2015-05-27T00:00:00Z",
"updated_at": "2015-05-27T00:00:00Z",
"raw_body": "Test body",
Expand Down Expand Up @@ -1799,6 +1802,7 @@ def test_basic(self, parent_id):
"parent_id": parent_id,
"author": self.user.username,
"author_label": None,
"learner_status": "new",
"created_at": "2015-06-03T00:00:00Z",
"updated_at": "2015-06-03T00:00:00Z",
"raw_body": "Edited body",
Expand Down Expand Up @@ -3737,6 +3741,7 @@ def get_source_and_expected_comments(self):
"parent_id": None,
"author": self.author.username,
"author_label": None,
"learner_status": "new",
"created_at": "2015-05-11T00:00:00Z",
"updated_at": "2015-05-11T11:11:11Z",
"raw_body": "Test body",
Expand Down Expand Up @@ -3771,6 +3776,7 @@ def get_source_and_expected_comments(self):
"parent_id": None,
"author": None,
"author_label": None,
"learner_status": "anonymous",
"created_at": "2015-05-11T22:22:22Z",
"updated_at": "2015-05-11T33:33:33Z",
"raw_body": "More content",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ def test_basic(self):
"can_delete": False,
"last_edit": None,
"edit_by_label": None,
"learner_status": "new",
"profile_image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
Expand Down
1 change: 1 addition & 0 deletions lms/djangoapps/discussion/rest_api/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,7 @@ def setUp(self):
{"key": "group_name", "value": None},
{"key": "has_endorsed", "value": False},
{"key": "last_edit", "value": None},
{"key": "learner_status", "value": "new"},
{"key": "non_endorsed_comment_list_url", "value": None},
{"key": "preview_body", "value": "Test body"},
{"key": "raw_body", "value": "Test body"},
Expand Down
1 change: 1 addition & 0 deletions lms/djangoapps/discussion/rest_api/tests/test_views_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ def expected_response_data(self, overrides=None):
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
},
"learner_status": "new",
}
response_data.update(overrides or {})
return response_data
Expand Down
2 changes: 2 additions & 0 deletions lms/djangoapps/discussion/rest_api/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ def expected_thread_data(self, overrides=None):
"closed_by_label": None,
"close_reason": None,
"close_reason_code": None,
"learner_status": "new",
}
response_data.update(overrides or {})
return response_data
Expand Down Expand Up @@ -816,6 +817,7 @@ def expected_thread_data(self, overrides=None):
"closed_by_label": None,
"close_reason": None,
"close_reason_code": None,
"learner_status": "new",
}
response_data.update(overrides or {})
return response_data
Expand Down
84 changes: 84 additions & 0 deletions lms/djangoapps/discussion/rest_api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
from common.djangoapps.student.models import CourseAccessRole
from completion.models import BlockCompletion
from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread

from lms.djangoapps.discussion.config.settings import ENABLE_CAPTCHA_IN_DISCUSSION
Expand Down Expand Up @@ -496,3 +497,86 @@ def get_captcha_site_key_by_platform(platform: str) -> str | None:
Get reCAPTCHA site key based on the platform.
"""
return settings.RECAPTCHA_SITE_KEYS.get(platform, None)


def _is_privileged_user(user, course_id):
"""
Check if a user has privileged roles (staff, moderator, TA, etc.) in the course.

This helper function checks both forum roles and course access roles to determine
if a user should be considered privileged.

Args:
user: User object to check
course_id: Course key to check roles in

Returns:
bool: True if user has any privileged role, False otherwise
"""
# Check forum-specific privileged roles
user_roles = get_user_role_names(user, course_id)
privileged_roles = {
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_GROUP_MODERATOR
}

if any(role in privileged_roles for role in user_roles):
return True

# Check for staff roles using CourseAccessRole
# Include limited_staff for consistency with is_only_student check
return CourseAccessRole.objects.filter(
user=user,
course_id=course_id,
role__in=['instructor', 'staff', 'limited_staff']
).exists()


def _check_user_engagement(user, course_id):
"""
Returns True if the user shows meaningful engagement:
- Completed ≥ 2 blocks, or
- Completed at least 1 video or 1 problem.
"""
try:
completed = BlockCompletion.objects.filter(
user=user, context_key=course_id, completion=1.0
)
return (
completed.count() >= 2
or completed.filter(block_type__in=["video", "problem"]).exists()
)
except (AttributeError, TypeError, ValueError):
return False


def get_user_learner_status(user, course_id):
"""
Determine a user's learner status in the given course.

Possible return values:
- "anonymous" → User not logged in
- "staff" → Staff/moderator/TA
- "new" → Enrolled but no engagement
- "regular" → Enrolled and has engaged with course content

Args:
user (User): Django user object
course_id (CourseKey): Course key to check engagement in

Returns:
str: One of ["anonymous", "staff", "new", "regular"]
"""
# Anonymous user
if not user or not user.is_authenticated:
return "anonymous"

# Privileged user (staff/moderator/TA)
if _is_privileged_user(user, course_id):
return "staff"

# Engagement-based learner type
has_engagement = _check_user_engagement(user, course_id)
return "regular" if has_engagement else "new"
Loading