diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index 9c2668d0b226..8a7ab16e0903 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -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 @@ -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]) @@ -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. diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index f5b15b905639..53c12454aec9 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index 0cbcc0bebdd1..a1443252a1ce 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -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", diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index be8a793abc92..e4d46168c46d 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -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"}, diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py index 4247cbcab06c..431304a9a2b5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -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 diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 342afb0ada5e..8c1615690ad5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -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 @@ -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 diff --git a/lms/djangoapps/discussion/rest_api/utils.py b/lms/djangoapps/discussion/rest_api/utils.py index 0f02a0dcdcf2..8914527f1b6a 100644 --- a/lms/djangoapps/discussion/rest_api/utils.py +++ b/lms/djangoapps/discussion/rest_api/utils.py @@ -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 @@ -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"