Skip to content
Open
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
55 changes: 55 additions & 0 deletions lms/djangoapps/discussion/rest_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2016,3 +2016,58 @@ def add_stats_for_users_with_null_values(course_stats, users_in_course):
})
updated_course_stats = sorted(updated_course_stats, key=lambda d: len(d['username']))
return updated_course_stats

def _get_user_label_function(course_id, course_staff_user_ids, moderator_user_ids, ta_user_ids):
"""
Create and return a function that determines user labels based on role.

Args:
course_id: Course key/id used for discussion Role lookups
course_staff_user_ids: List of user IDs for course staff
moderator_user_ids: List of user IDs for moderators
ta_user_ids: List of user IDs for TAs

Returns:
A function that takes a user_id and returns the appropriate label or None
"""

# Pre-fetch discussion role names for all relevant users to avoid per-user queries.
relevant_user_ids = set(moderator_user_ids) | set(ta_user_ids)
role_names_by_user_id = {}
if relevant_user_ids:
for user_id_val, role_name in Role.objects.filter(
course_id=course_id,
users__id__in=relevant_user_ids,
name__in=[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_GROUP_MODERATOR,
],
).values_list('users__id', 'name'):
role_names_by_user_id.setdefault(int(user_id_val), set()).add(role_name)

def get_user_label(user_id):
"""Get role label for a user ID."""
try:
user_id_int = int(user_id)
# Platform course roles (collapsed to "Course Staff" for deleted content lists)
if user_id_int in course_staff_user_ids:
return "Course Staff"

# Discussion-specific roles (distinguish admin vs moderator)
role_names = role_names_by_user_id.get(user_id_int, set())
if FORUM_ROLE_ADMINISTRATOR in role_names:
return "Discussion Administrator"
if FORUM_ROLE_MODERATOR in role_names:
return "Moderator"
if FORUM_ROLE_COMMUNITY_TA in role_names:
return "Community TA"
if FORUM_ROLE_GROUP_MODERATOR in role_names:
return "Group Moderator"
except (ValueError, TypeError):
# If user_id has any issues, there's no label to return
pass
return None

return get_user_label
152 changes: 138 additions & 14 deletions lms/djangoapps/discussion/rest_api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from rest_framework import serializers

from common.djangoapps.student.models import get_user_by_username_or_email
from common.djangoapps.student.roles import GlobalStaff
from common.djangoapps.student.roles import GlobalStaff, CourseStaffRole, CourseInstructorRole
from lms.djangoapps.discussion.django_comment_client.base.views import (
track_comment_edited_event,
track_forum_response_mark_event,
Expand Down Expand Up @@ -45,9 +45,20 @@
from openedx.core.djangoapps.discussions.utils import get_group_names_by_id
from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread
from openedx.core.djangoapps.django_comment_common.comment_client.user import User as CommentClientUser
from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings
from openedx.core.djangoapps.django_comment_common.comment_client.user import (
User as CommentClientUser,
)
from openedx.core.djangoapps.django_comment_common.comment_client.utils import (
CommentClientRequestError,
)
from openedx.core.djangoapps.django_comment_common.models import (
CourseDiscussionSettings,
Role,
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_GROUP_MODERATOR,
)
from openedx.core.djangoapps.user_api.accounts.api import get_profile_images
from openedx.core.lib.api.serializers import CourseKeyField

Expand Down Expand Up @@ -185,6 +196,8 @@ class _ContentSerializer(serializers.Serializer):
id = serializers.CharField(read_only=True) # pylint: disable=invalid-name
author = serializers.SerializerMethodField()
author_label = serializers.SerializerMethodField()
author_labels = 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 @@ -243,20 +256,119 @@ def get_author(self, obj):

def _get_user_label(self, user_id):
"""
Returns the role label (i.e. "Staff", "Moderator" or "Community TA") for the user
with the given id.
Returns a single legacy role label for the user.
Used by edit_by_label, closed_by_label, endorsed_by_label, deleted_by_label
to preserve backward compatibility.
Returns one of: "Staff", "Administrator", "Moderator", "Community TA", or None.
"""
is_staff = user_id in self.context["course_staff_user_ids"]
is_moderator = user_id in self.context["moderator_user_ids"]
is_ta = user_id in self.context["ta_user_ids"]

is_global_staff = False
if not (is_moderator or is_ta):
try:
user = User.objects.get(id=user_id)
is_global_staff = GlobalStaff().has_user(user)
except User.DoesNotExist:
pass

is_administrator = False
if is_moderator:
course_id = self.context.get("course_id")
if course_id:
user_roles = Role.objects.filter(
users__id=user_id,
course_id=course_id,
name__in=[FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR],
).values_list("name", flat=True)
is_administrator = FORUM_ROLE_ADMINISTRATOR in user_roles

return (
"Staff" if is_staff else
"Moderator" if is_moderator else
"Community TA" if is_ta else
None
"Staff"
if is_global_staff
else "Administrator"
if is_administrator
else "Moderator" if is_moderator else "Community TA" if is_ta else None
)

def _get_user_labels_all(self, user_id):
"""
Returns an array of ALL roles assigned to the user.
Used exclusively by get_author_labels to support multi-role display.
Examples: ["Global Staff", "Course Staff"], ["Administrator", "Community TA"]
"""
roles = []

# Check GlobalStaff (platform-wide)
try:
user = User.objects.get(id=user_id)
if GlobalStaff().has_user(user):
roles.append("Global Staff")
except User.DoesNotExist:
user = None

# Check CourseStaff and CourseInstructor (platform course roles)
if user and user_id in self.context.get("course_staff_user_ids", []):
course_id = self.context.get("course_id")
if course_id:
if CourseInstructorRole(course_id).has_user(user):
roles.append("Course Instructor")
if CourseStaffRole(course_id).has_user(user):
roles.append("Course Staff")

# Check discussion-specific moderator roles
if user_id in self.context.get("moderator_user_ids", []):
course_id = self.context.get("course_id")
if course_id:
user_roles = Role.objects.filter(
users__id=user_id,
course_id=course_id,
name__in=[FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR]
).values_list('name', flat=True)

if FORUM_ROLE_ADMINISTRATOR in user_roles:
roles.append("Administrator")
if FORUM_ROLE_MODERATOR in user_roles:
roles.append("Moderator")

# Check discussion-specific TA roles
if user_id in self.context.get("ta_user_ids", []):
course_id = self.context.get("course_id")
if course_id:
user_roles = Role.objects.filter(
users__id=user_id,
course_id=course_id,
name__in=[FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR]
).values_list('name', flat=True)

if FORUM_ROLE_COMMUNITY_TA in user_roles:
roles.append("Community TA")
if FORUM_ROLE_GROUP_MODERATOR in user_roles:
roles.append("Group Moderator")

return roles if roles else None

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_user_label_from_username(self, username):
"""
Returns role label of user from username
Expand All @@ -270,13 +382,25 @@ def _get_user_label_from_username(self, username):

def get_author_label(self, obj):
"""
Returns the role label for the content author.
Returns the primary role label for the content author as a string.
Returns None for posts that are anonymous to the viewer.
For anonymous_to_peers posts, staff/moderators/admins can see the label.
"""
if self._is_anonymous(obj) or obj["user_id"] is None:
return None
else:
user_id = int(obj["user_id"])
return self._get_user_label(user_id)
return self._get_user_label(int(obj["user_id"]))

def get_author_labels(self, obj):
"""
Returns all role labels for the content author as an array.
New additive field for multi-role display in the frontend.
Existing legacy fields (edit_by_label, closed_by_label, endorsed_by_label,
deleted_by_label) are unaffected and continue to use _get_user_label.
Returns None for anonymous posts or users with no recognized roles.
"""
if self._is_anonymous(obj) or obj["user_id"] is None:
return None
return self._get_user_labels_all(int(obj["user_id"]))

def get_rendered_body(self, obj):
"""
Expand Down
11 changes: 11 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 @@ -349,6 +349,7 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit):
expected = self.expected_thread_data(
{
"author_label": "Moderator",
"author_labels": ["Moderator"],
"id": "test_id",
"course_id": str(self.course.id),
"comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_id",
Expand Down Expand Up @@ -680,6 +681,8 @@ def test_success(self, parent_id, mock_emit):
"parent_id": parent_id,
"author": self.user.username,
"author_label": None,
"author_labels": 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 @@ -788,6 +791,8 @@ 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",
"author_labels": ["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 @@ -1791,6 +1796,8 @@ def test_basic(self, parent_id):
"parent_id": parent_id,
"author": self.user.username,
"author_label": None,
"author_labels": 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 @@ -3729,6 +3736,8 @@ def get_source_and_expected_comments(self):
"parent_id": None,
"author": self.author.username,
"author_label": None,
"author_labels": 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 @@ -3763,6 +3772,8 @@ def get_source_and_expected_comments(self):
"parent_id": None,
"author": None,
"author_label": None,
"author_labels": None,
"learner_status": "anonymous",
"created_at": "2015-05-11T22:22:22Z",
"updated_at": "2015-05-11T33:33:33Z",
"raw_body": "More content",
Expand Down
Loading
Loading