From 4d352a84c9dcfdc35292618bc3621652872b09e4 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Thu, 26 Jun 2025 13:20:34 -0500 Subject: [PATCH] feat(poc): add support for divided discussions with user groups --- .../discussion/django_comment_client/utils.py | 28 +++++++++++++++++++ lms/djangoapps/discussion/rest_api/api.py | 17 +++++++++-- .../discussion/rest_api/serializers.py | 1 + lms/djangoapps/discussion/rest_api/views.py | 8 +++++- openedx/core/djangoapps/discussions/utils.py | 3 ++ .../comment_client/models.py | 1 + .../comment_client/thread.py | 2 ++ .../comment_client/user.py | 2 +- ...oursediscussionsettings_division_scheme.py | 18 ++++++++++++ .../django_comment_common/models.py | 8 +++++- 10 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 openedx/core/djangoapps/django_comment_common/migrations/0010_alter_coursediscussionsettings_division_scheme.py diff --git a/lms/djangoapps/discussion/django_comment_client/utils.py b/lms/djangoapps/discussion/django_comment_client/utils.py index e26b748270e3..7137cfd1d11f 100644 --- a/lms/djangoapps/discussion/django_comment_client/utils.py +++ b/lms/djangoapps/discussion/django_comment_client/utils.py @@ -16,6 +16,7 @@ from django.urls import reverse from django.utils.deprecation import MiddlewareMixin from opaque_keys.edx.keys import CourseKey, UsageKey, i4xEncoder +from openedx_user_groups.models import UserGroupMembership from pytz import UTC from common.djangoapps.student.models import get_user_by_username_or_email @@ -920,6 +921,33 @@ def get_group_id_for_user(user, course_discussion_settings): return None +def get_user_group_ids_for_user(user: User, course_discussion_settings: CourseDiscussionSettings) -> list[int] | None: + """ + Get the group ids for the user in the given course. + + Args: + user (User): The user to get the group ids for + course_discussion_settings (CourseDiscussionSettings): The course discussion settings + + Returns: + list[int] | None: The group ids for the user in the given course. + None if the division scheme is not USER_GROUP. + """ + division_scheme = get_course_division_scheme(course_discussion_settings) + if division_scheme == CourseDiscussionSettings.USER_GROUP: + return list(UserGroupMembership.objects.filter(user=user, is_active=True).values_list("group__id", flat=True)) + return None + + +@request_cached() +def get_user_group_ids_for_user_from_cache(user: User, course_id: CourseKey) -> list[int] | None: + """ + Caches the results of get_group_id_for_user, but serializes the course_id + instead of the course_discussions_settings object as cache keys. + """ + return get_user_group_ids_for_user(user, CourseDiscussionSettings.get(course_id)) + + def is_comment_too_deep(parent): """ Determine whether a comment with the given parent violates MAX_COMMENT_DEPTH diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index b87852c16cfa..d0979758a69c 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -98,6 +98,7 @@ ) from ..django_comment_client.utils import ( get_group_id_for_user, + get_user_group_ids_for_user, get_user_role_names, has_discussion_privileges, is_commentable_divided @@ -1012,6 +1013,8 @@ def get_thread_list( ): group_id = get_group_id_for_user(request.user, CourseDiscussionSettings.get(course.id)) + user_group_ids = get_user_group_ids_for_user(request.user, CourseDiscussionSettings.get(course.id)) + query_params = { "user_id": str(request.user.id), "group_id": group_id, @@ -1023,6 +1026,7 @@ def get_thread_list( "flagged": flagged, "thread_type": thread_type, "count_flagged": count_flagged, + "user_group_ids": user_group_ids, } if view: @@ -1155,6 +1159,7 @@ def get_learner_active_thread_list(request, course_key, query_params): context = get_context(course, request) group_id = query_params.get('group_id', None) + user_group_ids = query_params.get('user_group_ids', None) user_id = query_params.get('user_id', None) count_flagged = query_params.get('count_flagged', None) if user_id is None: @@ -1165,10 +1170,12 @@ def get_learner_active_thread_list(request, course_key, query_params): if "flagged" in query_params.keys() and not context["has_moderation_privilege"]: raise PermissionDenied("Flagged filter is only available for moderators") - if group_id is None: - comment_client_user = comment_client.User(id=user_id, course_id=course_key) - else: + if group_id is not None: comment_client_user = comment_client.User(id=user_id, course_id=course_key, group_id=group_id) + elif user_group_ids is not None: + comment_client_user = comment_client.User(id=user_id, course_id=course_key, user_group_ids=user_group_ids) + else: + comment_client_user = comment_client.User(id=user_id, course_id=course_key) try: threads, page, num_pages = comment_client_user.active_threads(query_params) @@ -1496,6 +1503,10 @@ def create_thread(request, thread_data): ): thread_data = thread_data.copy() thread_data["group_id"] = get_group_id_for_user(user, discussion_settings) + + if "user_group_ids" not in thread_data: + thread_data["user_group_ids"] = get_user_group_ids_for_user(user, discussion_settings) + serializer = ThreadSerializer(data=thread_data, context=context) actions_form = ThreadActionsForm(thread_data) if not (serializer.is_valid() and actions_form.is_valid()): diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index 9c2668d0b226..8fb6559f9d71 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -362,6 +362,7 @@ class ThreadSerializer(_ContentSerializer): course_id = serializers.CharField() topic_id = serializers.CharField(source="commentable_id", validators=[validate_not_blank]) group_id = serializers.IntegerField(required=False, allow_null=True) + user_group_ids = serializers.ListField(required=False, allow_null=True) group_name = serializers.SerializerMethodField() type = serializers.ChoiceField( source="thread_type", diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index ba9818124e08..51320bc0b98a 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -32,7 +32,10 @@ from lms.djangoapps.discussion.rest_api.tasks import delete_course_post_for_user from lms.djangoapps.discussion.toggles import ONLY_VERIFIED_USERS_CAN_POST from lms.djangoapps.discussion.django_comment_client import settings as cc_settings -from lms.djangoapps.discussion.django_comment_client.utils import get_group_id_for_comments_service +from lms.djangoapps.discussion.django_comment_client.utils import ( + get_group_id_for_comments_service, + get_user_group_ids_for_user_from_cache, +) from lms.djangoapps.instructor.access import update_forum_role from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider @@ -783,6 +786,8 @@ def get(self, request, course_id=None): except ValueError: pass + user_group_ids = get_user_group_ids_for_user_from_cache(request.user, course_key) + query_params = { "page": page_num, "per_page": threads_per_page, @@ -792,6 +797,7 @@ def get(self, request, course_id=None): "count_flagged": count_flagged, "thread_type": thread_type, "sort_key": order_by, + "user_group_ids": user_group_ids, } if post_status: if post_status not in ['flagged', 'unanswered', 'unread', 'unresponded']: diff --git a/openedx/core/djangoapps/discussions/utils.py b/openedx/core/djangoapps/discussions/utils.py index 7c26a4e482e6..0465203ab8ec 100644 --- a/openedx/core/djangoapps/discussions/utils.py +++ b/openedx/core/djangoapps/discussions/utils.py @@ -5,6 +5,7 @@ from typing import Dict, List, Optional, Tuple from opaque_keys.edx.keys import CourseKey +from openedx_user_groups.toggles import is_user_groups_enabled from lms.djangoapps.courseware.access import has_access from openedx.core.djangoapps.course_groups.cohorts import get_cohort_names, is_course_cohorted @@ -106,6 +107,8 @@ def available_division_schemes(course_key: CourseKey) -> List[str]: available_schemes.append(CourseDiscussionSettings.COHORT) if enrollment_track_group_count(course_key) > 1: available_schemes.append(CourseDiscussionSettings.ENROLLMENT_TRACK) + if is_user_groups_enabled(course_key): + available_schemes.append(CourseDiscussionSettings.USER_GROUP) return available_schemes diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/models.py b/openedx/core/djangoapps/django_comment_common/comment_client/models.py index 4544a463ed80..a70ae0aa59cd 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py @@ -340,6 +340,7 @@ def handle_create_thread(self, course_id): "anonymous_to_peers": request_data.get("anonymous_to_peers", False), "commentable_id": request_data.get("commentable_id", "course"), "thread_type": request_data.get("thread_type", "discussion"), + "user_group_ids": request_data.get("user_group_ids", None), } if group_id := request_data.get("group_id"): params["group_id"] = group_id diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index b884352ce340..0cf729801a41 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -33,6 +33,7 @@ class Thread(models.Model): 'abuse_flaggers', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type', 'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total', 'context', 'last_activity_at', 'closed_by', 'close_reason_code', 'edit_history', + 'user_group_ids', ] # updateable_fields are sent in PUT requests @@ -40,6 +41,7 @@ class Thread(models.Model): 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'read', 'closed', 'user_id', 'commentable_id', 'group_id', 'group_name', 'pinned', 'thread_type', 'close_reason_code', 'edit_reason_code', 'closing_user_id', 'editing_user_id', + 'user_group_ids', ] # initializable_fields are sent in POST requests diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/user.py b/openedx/core/djangoapps/django_comment_common/comment_client/user.py index 187593e70717..5fd5e3210b2a 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -14,7 +14,7 @@ class User(models.Model): 'id', 'external_id', 'subscribed_user_ids', 'children', 'course_id', 'group_id', 'subscribed_thread_ids', 'subscribed_commentable_ids', 'subscribed_course_ids', 'threads_count', 'comments_count', - 'default_sort_key' + 'default_sort_key', 'user_group_ids', ] updatable_fields = ['username', 'external_id', 'default_sort_key'] diff --git a/openedx/core/djangoapps/django_comment_common/migrations/0010_alter_coursediscussionsettings_division_scheme.py b/openedx/core/djangoapps/django_comment_common/migrations/0010_alter_coursediscussionsettings_division_scheme.py new file mode 100644 index 000000000000..78511c84e928 --- /dev/null +++ b/openedx/core/djangoapps/django_comment_common/migrations/0010_alter_coursediscussionsettings_division_scheme.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.20 on 2025-06-19 03:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_comment_common', '0009_coursediscussionsettings_reported_content_email_notifications'), + ] + + operations = [ + migrations.AlterField( + model_name='coursediscussionsettings', + name='division_scheme', + field=models.CharField(choices=[('none', 'None'), ('cohort', 'Cohort'), ('enrollment_track', 'Enrollment Track'), ('user_group', 'User Group')], default='none', max_length=20), + ), + ] diff --git a/openedx/core/djangoapps/django_comment_common/models.py b/openedx/core/djangoapps/django_comment_common/models.py index bd7b8fe66e67..9480fa7136d2 100644 --- a/openedx/core/djangoapps/django_comment_common/models.py +++ b/openedx/core/djangoapps/django_comment_common/models.py @@ -244,8 +244,14 @@ class CourseDiscussionSettings(models.Model): COHORT = 'cohort' ENROLLMENT_TRACK = 'enrollment_track' + USER_GROUP = 'user_group' NONE = 'none' - ASSIGNMENT_TYPE_CHOICES = ((NONE, 'None'), (COHORT, 'Cohort'), (ENROLLMENT_TRACK, 'Enrollment Track')) + ASSIGNMENT_TYPE_CHOICES = ( + (NONE, "None"), + (COHORT, "Cohort"), + (ENROLLMENT_TRACK, "Enrollment Track"), + (USER_GROUP, "User Group"), + ) division_scheme = models.CharField(max_length=20, choices=ASSIGNMENT_TYPE_CHOICES, default=NONE) class Meta: