Files
edx-platform/lms/djangoapps/discussion/views.py
salmannawaz d20b87b180 Discussion service to enable permission and access provider (#37912)
* chore: discussion service to enable permission and access provider
2026-02-11 19:37:16 +05:00

1060 lines
44 KiB
Python

"""
Views handling read (GET) requests for the Discussion tab and inline discussions.
"""
import logging
from functools import wraps
from urllib.parse import urljoin
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles.storage import staticfiles_storage
from django.http import Http404, HttpResponseForbidden, HttpResponseServerError
from django.shortcuts import redirect, render
from django.template.context_processors import csrf
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import get_language_bidi
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_GET, require_http_methods
from edx_django_utils.monitoring import function_trace
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
from web_fragments.fragment import Fragment
from xmodule.modulestore.django import modulestore
import lms.djangoapps.discussion.django_comment_client.utils as utils
import openedx.core.djangoapps.django_comment_common.comment_client as cc
from openedx.core.djangoapps.django_comment_common.models import has_permission
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff
from common.djangoapps.util.json_request import JsonResponse, expect_json
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.courses import get_course_with_access
from lms.djangoapps.courseware.views.views import CourseTabView
from lms.djangoapps.discussion.config.settings import is_forum_daily_digest_enabled
from lms.djangoapps.discussion.django_comment_client.base.views import track_thread_viewed_event
from lms.djangoapps.discussion.django_comment_client.constants import TYPE_ENTRY
from lms.djangoapps.discussion.django_comment_client.utils import (
add_courseware_context,
course_discussion_division_enabled,
extract,
get_group_id_for_comments_service,
get_group_id_for_user,
is_commentable_divided,
strip_none
)
from lms.djangoapps.discussion.exceptions import TeamDiscussionHiddenFromUserException
from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
from lms.djangoapps.experiments.utils import get_experiment_user_metadata_context
from lms.djangoapps.teams import api as team_api
from openedx.core.djangoapps.discussions.utils import (
available_division_schemes,
get_discussion_categories_ids,
get_divided_discussions,
get_group_names_by_id
)
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_MODERATOR,
CourseDiscussionSettings,
Role
)
from openedx.core.djangoapps.django_comment_common.utils import ThreadContext
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.features.course_duration_limits.access import generate_course_expired_fragment
User = get_user_model()
log = logging.getLogger("edx.discussions")
THREADS_PER_PAGE = 20
INLINE_THREADS_PER_PAGE = 20
PAGES_NEARBY_DELTA = 2
BOOTSTRAP_DISCUSSION_CSS_PATH = 'css/discussion/lms-discussion-bootstrap.css'
TEAM_PERMISSION_MESSAGE = _("Access to this discussion is restricted to team members and staff.")
def make_course_settings(course, user, include_category_map=True):
"""
Generate a JSON-serializable model for course settings, which will be used to initialize a
DiscussionCourseSettings object on the client.
"""
course_discussion_settings = CourseDiscussionSettings.get(course.id)
group_names_by_id = get_group_names_by_id(course_discussion_settings)
course_setting = {
'is_discussion_division_enabled': course_discussion_division_enabled(course_discussion_settings),
'allow_anonymous': course.allow_anonymous,
'allow_anonymous_to_peers': course.allow_anonymous_to_peers,
'groups': [
{"id": str(group_id), "name": group_name} for group_id, group_name in group_names_by_id.items()
]
}
if include_category_map:
course_setting['category_map'] = utils.get_discussion_category_map(course, user)
return course_setting
def get_threads(request, course, user_info, discussion_id=None, per_page=THREADS_PER_PAGE):
"""
This may raise an appropriate subclass of cc.utils.CommentClientError
if something goes wrong, or ValueError if the group_id is invalid.
Arguments:
request (WSGIRequest): The user request.
course (CourseBlockWithMixins): The course object.
user_info (dict): The comment client User object as a dict.
discussion_id (unicode): Optional discussion id/commentable id for context.
per_page (int): Optional number of threads per page.
Returns:
(tuple of list, dict): A tuple of the list of threads and a dict of the
query parameters used for the search.
"""
default_query_params = {
'page': 1,
'per_page': per_page,
'sort_key': 'activity',
'text': '',
'course_id': str(course.id),
'user_id': request.user.id,
'context': ThreadContext.COURSE,
'group_id': get_group_id_for_comments_service(request, course.id, discussion_id), # may raise ValueError
}
# If provided with a discussion id, filter by discussion id in the
# comments_service.
if discussion_id is not None:
default_query_params['commentable_id'] = discussion_id
# Use the discussion id/commentable id to determine the context we are going to pass through to the backend.
if team_api.get_team_by_discussion(discussion_id) is not None:
default_query_params['context'] = ThreadContext.STANDALONE
_check_team_discussion_access(request, course, discussion_id)
if not request.GET.get('sort_key'):
# If the user did not select a sort key, use their last used sort key
default_query_params['sort_key'] = user_info.get('default_sort_key') or default_query_params['sort_key']
elif request.GET.get('sort_key') != user_info.get('default_sort_key'):
# If the user clicked a sort key, update their default sort key
cc_user = cc.User.from_django_user(request.user)
cc_user.default_sort_key = request.GET.get('sort_key')
cc_user.save(params={"course_id": str(course.id)})
#there are 2 dimensions to consider when executing a search with respect to group id
#is user a moderator
#did the user request a group
query_params = default_query_params.copy()
query_params.update(
strip_none(
extract(
request.GET,
[
'page',
'sort_key',
'text',
'commentable_ids',
'flagged',
'unread',
'unanswered',
]
)
)
)
paginated_results = cc.Thread.search(query_params)
threads = paginated_results.collection
# If not provided with a discussion id, filter threads by commentable ids
# which are accessible to the current user.
if discussion_id is None:
discussion_category_ids = set(get_discussion_categories_ids(course, request.user))
threads = [
thread for thread in threads
if thread.get('commentable_id') in discussion_category_ids
]
for thread in threads:
# patch for backward compatibility to comments service
if 'pinned' not in thread:
thread['pinned'] = False
query_params['page'] = paginated_results.page
query_params['num_pages'] = paginated_results.num_pages
query_params['corrected_text'] = paginated_results.corrected_text
return threads, query_params
def use_bulk_ops(view_func):
"""
Wraps internal request handling inside a modulestore bulk op, significantly
reducing redundant database calls. Also converts the course_id parsed from
the request uri to a CourseKey before passing to the view.
"""
@wraps(view_func)
def wrapped_view(request, course_id, *args, **kwargs):
course_key = CourseKey.from_string(course_id)
if course_key.deprecated:
raise Http404
with modulestore().bulk_operations(course_key):
return view_func(request, course_key, *args, **kwargs)
return wrapped_view
@login_required
@use_bulk_ops
def inline_discussion(request, course_key, discussion_id):
"""
Renders JSON for DiscussionModules
"""
with function_trace('get_course_and_user_info'):
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
cc_user = cc.User.from_django_user(request.user)
user_info = cc_user.to_dict(course_key=str(course_key))
try:
with function_trace('get_threads'):
threads, query_params = get_threads(
request, course, user_info, discussion_id, per_page=INLINE_THREADS_PER_PAGE
)
except ValueError:
return HttpResponseServerError('Invalid group_id')
except TeamDiscussionHiddenFromUserException:
return HttpResponseForbidden(TEAM_PERMISSION_MESSAGE)
with function_trace('get_metadata_for_threads'):
annotated_content_info = utils.get_metadata_for_threads(course_key, threads, request.user, user_info)
with function_trace('determine_group_permissions'):
is_staff = has_permission(request.user, 'openclose_thread', course.id)
is_community_ta = utils.is_user_community_ta(request.user, course.id)
course_discussion_settings = CourseDiscussionSettings.get(course.id)
group_names_by_id = get_group_names_by_id(course_discussion_settings)
course_is_divided = course_discussion_settings.division_scheme is not CourseDiscussionSettings.NONE
with function_trace('prepare_content'):
threads = [
utils.prepare_content(
thread,
course_key,
is_staff,
is_community_ta,
course_is_divided,
group_names_by_id
) for thread in threads
]
return utils.JsonResponse({
'is_commentable_divided': is_commentable_divided(course_key, discussion_id),
'discussion_data': threads,
'user_info': user_info,
'user_group_id': get_group_id_for_user(request.user, course_discussion_settings),
'annotated_content_info': annotated_content_info,
'page': query_params['page'],
'num_pages': query_params['num_pages'],
'roles': utils.get_role_ids(course_key),
'course_settings': make_course_settings(course, request.user, False)
})
def redirect_forum_url_to_new_mfe(request, course_id):
"""
Returns the redirect link when user opens default discussion homepage
"""
course_key = CourseKey.from_string(course_id)
discussions_mfe_enabled = ENABLE_DISCUSSIONS_MFE.is_enabled(course_key)
redirect_url = None
if discussions_mfe_enabled:
mfe_base_url = settings.DISCUSSIONS_MICROFRONTEND_URL
redirect_url = f"{mfe_base_url}/{str(course_key)}"
return redirect_url
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@login_required
@use_bulk_ops
def forum_form_discussion(request, course_key):
"""
Renders the main Discussion page, potentially filtered by a search query
"""
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
request.user.is_community_ta = utils.is_user_community_ta(request.user, course.id)
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
user = cc.User.from_django_user(request.user)
user_info = user.to_dict()
try:
unsafethreads, query_params = get_threads(request, course, user_info) # This might process a search query
is_staff = has_permission(request.user, 'openclose_thread', course.id)
threads = [utils.prepare_content(
thread, course_key, is_staff, request.user.is_community_ta
) for thread in unsafethreads]
except cc.utils.CommentClientMaintenanceError:
return HttpResponseServerError('Forum is in maintenance mode', status=status.HTTP_503_SERVICE_UNAVAILABLE)
except ValueError:
return HttpResponseServerError("Invalid group_id")
with function_trace("get_metadata_for_threads"):
annotated_content_info = utils.get_metadata_for_threads(course_key, threads, request.user, user_info)
with function_trace("add_courseware_context"):
add_courseware_context(threads, course, request.user)
return utils.JsonResponse({
'discussion_data': threads, # TODO: Standardize on 'discussion_data' vs 'threads'
'annotated_content_info': annotated_content_info,
'num_pages': query_params['num_pages'],
'page': query_params['page'],
'corrected_text': query_params['corrected_text'],
})
else:
redirect_url = redirect_forum_url_to_new_mfe(request, str(course.id))
if redirect_url:
return redirect(redirect_url)
course_id = str(course.id)
tab_view = CourseTabView()
return tab_view.get(request, course_id, 'discussion')
def redirect_thread_url_to_new_mfe(request, course_id, thread_id):
"""
Returns MFE url of the thread if the user is not privileged
"""
course_key = CourseKey.from_string(course_id)
discussions_mfe_enabled = ENABLE_DISCUSSIONS_MFE.is_enabled(course_key)
redirect_url = None
if discussions_mfe_enabled:
mfe_base_url = settings.DISCUSSIONS_MICROFRONTEND_URL
if thread_id:
redirect_url = f"{mfe_base_url}/{str(course_key)}/posts/{thread_id}"
return redirect_url
@require_GET
@login_required
@use_bulk_ops
def single_thread(request, course_key, discussion_id, thread_id):
"""
Renders a response to display a single discussion thread. This could either be a page refresh
after navigating to a single thread, a direct link to a single thread, or an AJAX call from the
discussions UI loading the responses/comments for a single thread.
Depending on the HTTP headers, we'll adjust our response accordingly.
"""
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
request.user.is_community_ta = utils.is_user_community_ta(request.user, course.id)
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
cc_user = cc.User.from_django_user(request.user)
user_info = cc_user.to_dict(course_key=str(course_key))
is_staff = has_permission(request.user, 'openclose_thread', course.id)
try:
_check_team_discussion_access(request, course, discussion_id)
except TeamDiscussionHiddenFromUserException:
return HttpResponseForbidden(TEAM_PERMISSION_MESSAGE)
thread = _load_thread_for_viewing(
request,
course,
discussion_id=discussion_id,
thread_id=thread_id,
raise_event=True,
)
with function_trace("get_annotated_content_infos"):
annotated_content_info = utils.get_annotated_content_infos(
course_key,
thread,
request.user,
user_info=user_info
)
content = utils.prepare_content(thread.to_dict(), course_key, is_staff, request.user.is_community_ta)
with function_trace("add_courseware_context"):
add_courseware_context([content], course, request.user)
return utils.JsonResponse({
'content': content,
'annotated_content_info': annotated_content_info,
})
else:
redirect_url = redirect_thread_url_to_new_mfe(request, str(course.id), thread_id)
if redirect_url:
return redirect(redirect_url)
course_id = str(course.id)
tab_view = CourseTabView()
return tab_view.get(request, course_id, 'discussion', discussion_id=discussion_id, thread_id=thread_id)
def _find_thread(request, course, discussion_id, thread_id):
"""
Finds the discussion thread with the specified ID.
Args:
request: The Django request.
course_id: The ID of the owning course.
discussion_id: The ID of the owning discussion.
thread_id: The ID of the thread.
Returns:
The thread in question if the user can see it, else None.
"""
try:
thread = cc.Thread.find(thread_id).retrieve(
with_responses=request.headers.get('x-requested-with') == 'XMLHttpRequest',
recursive=request.headers.get('x-requested-with') == 'XMLHttpRequest',
user_id=request.user.id,
response_skip=request.GET.get("resp_skip"),
response_limit=request.GET.get("resp_limit")
)
except cc.utils.CommentClientRequestError:
return None
# Verify that the student has access to this thread if belongs to a course discussion block
thread_context = getattr(thread, "context", "course")
if thread_context == "course" and not utils.discussion_category_id_access(course, request.user, discussion_id):
return None
# verify that the thread belongs to the requesting student's group
is_moderator = has_permission(request.user, "see_all_cohorts", course.id)
course_discussion_settings = CourseDiscussionSettings.get(course.id)
if is_commentable_divided(course.id, discussion_id, course_discussion_settings) and not is_moderator:
user_group_id = get_group_id_for_user(request.user, course_discussion_settings)
if getattr(thread, "group_id", None) is not None and user_group_id != thread.group_id:
return None
return thread
def _load_thread_for_viewing(request, course, discussion_id, thread_id, raise_event):
"""
Loads the discussion thread with the specified ID and fires an
edx.forum.thread.viewed event.
Args:
request: The Django request.
course_id: The ID of the owning course.
discussion_id: The ID of the owning discussion.
thread_id: The ID of the thread.
raise_event: Whether an edx.forum.thread.viewed tracking event should
be raised
Returns:
The thread in question if the user can see it.
Raises:
Http404 if the thread does not exist or the user cannot
see it.
"""
thread = _find_thread(request, course, discussion_id=discussion_id, thread_id=thread_id)
if not thread:
raise Http404
if raise_event:
track_thread_viewed_event(request, course, thread)
return thread
def _create_base_discussion_view_context(request, course_key):
"""
Returns the default template context for rendering any discussion view.
"""
user = request.user
cc_user = cc.User.from_django_user(user)
user_info = cc_user.to_dict(course_key=str(course_key))
course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True)
course_settings = make_course_settings(course, user)
return {
'csrf': csrf(request)['csrf_token'],
'course': course,
'user': user,
'user_info': user_info,
'staff_access': bool(has_access(user, 'staff', course)),
'roles': utils.get_role_ids(course_key),
'can_create_comment': has_permission(user, "create_comment", course.id),
'can_create_subcomment': has_permission(user, "create_sub_comment", course.id),
'can_create_thread': has_permission(user, "create_thread", course.id),
'flag_moderator': bool(
has_permission(user, 'openclose_thread', course.id) or
has_access(user, 'staff', course)
),
'course_settings': course_settings,
'disable_courseware_js': True,
'uses_bootstrap': True,
}
def _get_discussion_default_topic_id(course):
for topic, entry in course.discussion_topics.items(): # lint-amnesty, pylint: disable=unused-variable
if entry.get('default') is True:
return entry['id']
def _create_discussion_board_context(request, base_context, thread=None):
"""
Returns the template context for rendering the discussion board.
"""
context = base_context.copy()
course = context['course']
course_key = course.id
thread_id = thread.id if thread else None
discussion_id = thread.commentable_id if thread else None
course_settings = context['course_settings']
user = context['user']
cc_user = cc.User.from_django_user(user)
user_info = context['user_info']
if thread:
_check_team_discussion_access(request, course, discussion_id)
# Since we're in page render mode, and the discussions UI will request the thread list itself,
# we need only return the thread information for this one.
threads = [thread.to_dict()]
for thread in threads: # lint-amnesty, pylint: disable=redefined-argument-from-local
# patch for backward compatibility with comments service
if "pinned" not in thread:
thread["pinned"] = False
thread_pages = 1
root_url = reverse('forum_form_discussion', args=[str(course.id)])
else:
threads, query_params = get_threads(request, course, user_info) # This might process a search query
thread_pages = query_params['num_pages']
root_url = request.path
is_staff = has_permission(user, 'openclose_thread', course.id)
is_community_ta = utils.is_user_community_ta(request.user, course.id)
threads = [utils.prepare_content(thread, course_key, is_staff, is_community_ta) for thread in threads]
with function_trace("get_metadata_for_threads"):
annotated_content_info = utils.get_metadata_for_threads(course_key, threads, user, user_info)
with function_trace("add_courseware_context"):
add_courseware_context(threads, course, user)
with function_trace("get_cohort_info"):
course_discussion_settings = CourseDiscussionSettings.get(course_key)
user_group_id = get_group_id_for_user(user, course_discussion_settings)
context.update({
'root_url': root_url,
'discussion_id': discussion_id,
'thread_id': thread_id,
'threads': threads,
'thread_pages': thread_pages,
'annotated_content_info': annotated_content_info,
'is_moderator': has_permission(user, "see_all_cohorts", course_key),
'groups': course_settings["groups"], # still needed to render _thread_list_template
'user_group_id': user_group_id, # read from container in NewPostView
'sort_preference': cc_user.default_sort_key,
'category_map': course_settings["category_map"],
'course_settings': course_settings,
'is_commentable_divided': is_commentable_divided(course_key, discussion_id, course_discussion_settings),
# If the default topic id is None the front-end code will look for a topic that contains "General"
'discussion_default_topic_id': _get_discussion_default_topic_id(course),
'enable_daily_digest': is_forum_daily_digest_enabled(),
'PLATFORM_NAME': settings.PLATFORM_NAME
})
context.update(
get_experiment_user_metadata_context(
course,
user,
)
)
return context
def create_user_profile_context(request, course_key, user_id):
""" Generate a context dictionary for the user profile. """
user = cc.User.from_django_user(request.user)
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
# If user is not enrolled in the course, do not proceed.
django_user = User.objects.get(id=user_id)
if not CourseEnrollment.is_enrolled(django_user, course.id):
raise Http404
query_params = {
'page': request.GET.get('page', 1),
'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities
}
group_id = get_group_id_for_comments_service(request, course_key)
if group_id is not None:
query_params['group_id'] = group_id
profiled_user = cc.User(id=user_id, course_id=course_key, group_id=group_id)
else:
profiled_user = cc.User(id=user_id, course_id=course_key)
threads, page, num_pages = profiled_user.active_threads(query_params)
query_params['page'] = page
query_params['num_pages'] = num_pages
with function_trace("get_metadata_for_threads"):
user_info = cc.User.from_django_user(request.user).to_dict()
annotated_content_info = utils.get_metadata_for_threads(course_key, threads, request.user, user_info)
is_staff = has_permission(request.user, 'openclose_thread', course.id)
is_community_ta = utils.is_user_community_ta(request.user, course.id)
threads = [utils.prepare_content(thread, course_key, is_staff, is_community_ta) for thread in threads]
with function_trace("add_courseware_context"):
add_courseware_context(threads, course, request.user)
# TODO: LEARNER-3854: If we actually implement Learner Analytics code, this
# code was original protected to not run in user_profile() if is_ajax().
# Someone should determine if that is still necessary (i.e. was that ever
# called as is_ajax()) and clean this up as necessary.
user_roles = django_user.roles.filter(
course_id=course.id
).order_by("name").values_list("name", flat=True).distinct()
with function_trace("get_cohort_info"):
course_discussion_settings = CourseDiscussionSettings.get(course_key)
user_group_id = get_group_id_for_user(request.user, course_discussion_settings)
context = _create_base_discussion_view_context(request, course_key)
context.update({
'django_user': django_user,
'django_user_roles': user_roles,
'profiled_user': profiled_user.to_dict(),
'threads': threads,
'user_group_id': user_group_id,
'annotated_content_info': annotated_content_info,
'page': query_params['page'],
'num_pages': query_params['num_pages'],
'sort_preference': user.default_sort_key,
'learner_profile_page_url': urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{django_user.username}'),
})
return context
@require_GET
@login_required
@use_bulk_ops
def user_profile(request, course_key, user_id):
"""
Renders a response to display the user profile page (shown after clicking
on a post author's username).
"""
try:
context = create_user_profile_context(request, course_key, user_id)
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
return utils.JsonResponse({
'discussion_data': context['threads'],
'page': context['page'],
'num_pages': context['num_pages'],
'annotated_content_info': context['annotated_content_info'],
})
else:
discussions_mfe_enabled = ENABLE_DISCUSSIONS_MFE.is_enabled(course_key)
if discussions_mfe_enabled:
mfe_base_url = settings.DISCUSSIONS_MICROFRONTEND_URL
return redirect(f"{mfe_base_url}/{str(course_key)}/learners")
tab_view = CourseTabView()
# To avoid mathjax loading from 'mathjax_include.html'
# as that file causes multiple loadings of Mathjax on
# 'user_profile' page
context['load_mathjax'] = False
return tab_view.get(request, str(course_key), 'discussion', profile_page_context=context)
except User.DoesNotExist:
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
except ValueError:
return HttpResponseServerError("Invalid group_id")
@login_required
@use_bulk_ops
def followed_threads(request, course_key, user_id):
"""
Ajax-only endpoint retrieving the threads followed by a specific user.
"""
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
try:
profiled_user = cc.User(id=user_id, course_id=course_key)
query_params = {
'page': 1,
'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities
'sort_key': 'date',
}
query_params.update(
strip_none(
extract(
request.GET,
[
'page',
'sort_key',
'flagged',
'unread',
'unanswered',
]
)
)
)
try:
group_id = get_group_id_for_comments_service(request, course_key)
except ValueError:
return HttpResponseServerError("Invalid group_id")
if group_id is not None:
query_params['group_id'] = group_id
paginated_results = profiled_user.subscribed_threads(query_params)
print("\n \n \n paginated results \n \n \n ")
print(paginated_results)
query_params['page'] = paginated_results.page
query_params['num_pages'] = paginated_results.num_pages
user_info = cc.User.from_django_user(request.user).to_dict()
with function_trace("get_metadata_for_threads"):
annotated_content_info = utils.get_metadata_for_threads(
course_key,
paginated_results.collection,
request.user, user_info
)
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
is_staff = has_permission(request.user, 'openclose_thread', course.id)
is_community_ta = utils.is_user_community_ta(request.user, course.id)
return utils.JsonResponse({
'annotated_content_info': annotated_content_info,
'discussion_data': [
utils.prepare_content(
thread, course_key, is_staff, is_community_ta
) for thread in paginated_results.collection
],
'page': query_params['page'],
'num_pages': query_params['num_pages'],
})
#TODO remove non-AJAX support, it does not appear to be used and does not appear to work.
else:
context = {
'course': course,
'user': request.user,
'django_user': User.objects.get(id=user_id),
'profiled_user': profiled_user.to_dict(),
'threads': paginated_results.collection,
'user_info': user_info,
'annotated_content_info': annotated_content_info,
# 'content': content,
}
return render(request, 'discussion/user_profile.html', context)
except User.DoesNotExist:
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
def is_course_staff(course_key: CourseKey, user: User):
"""
Check if user has course instructor or course staff role.
"""
return CourseInstructorRole(course_key).has_user(user) or CourseStaffRole(course_key).has_user(user)
def is_privileged_user(course_key: CourseKey, user: User):
"""
Returns True if user has one of following course role
Administrator, Moderator, Group Moderator, Community TA,
or Global Staff.
"""
forum_roles = [
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_ADMINISTRATOR,
]
has_course_role = Role.user_has_role_for_course(user, course_key, forum_roles)
return GlobalStaff().has_user(user) or has_course_role
class DiscussionBoardFragmentView(EdxFragmentView):
"""
Component implementation of the discussion board.
"""
def render_to_fragment( # lint-amnesty, pylint: disable=arguments-differ
self,
request,
course_id=None,
discussion_id=None,
thread_id=None,
profile_page_context=None,
**kwargs
):
"""
Render the discussion board to a fragment.
Args:
request: The Django request.
course_id: The id of the course in question.
discussion_id: An optional discussion ID to be focused upon.
thread_id: An optional ID of the thread to be shown.
Returns:
Fragment: The fragment representing the discussion board
"""
course_key = CourseKey.from_string(course_id)
# Force using the legacy view if a user profile is requested or the URL contains a specific topic or thread
force_legacy_view = (profile_page_context or thread_id or discussion_id)
is_educator_or_staff = is_course_staff(course_key, request.user) or GlobalStaff().has_user(request.user)
try:
base_context = _create_base_discussion_view_context(request, course_key)
# Note:
# After the thread is rendered in this fragment, an AJAX
# request is made and the thread is completely loaded again
# (yes, this is something to fix). Because of this, we pass in
# raise_event=False to _load_thread_for_viewing avoid duplicate
# tracking events.
thread = (
_load_thread_for_viewing(
request,
base_context['course'],
discussion_id=discussion_id,
thread_id=thread_id,
raise_event=False,
)
if thread_id
else None
)
context = _create_discussion_board_context(request, base_context, thread=thread)
course_expiration_fragment = generate_course_expired_fragment(request.user, context['course'])
context.update({
'course_expiration_fragment': course_expiration_fragment,
})
if profile_page_context:
# EDUCATOR-2119: styles are hard to reconcile if the profile page isn't also a fragment
html = render_to_string('discussion/discussion_profile_page.html', profile_page_context)
else:
html = render_to_string('discussion/discussion_board_fragment.html', context)
fragment = Fragment(html)
self.add_fragment_resource_urls(fragment)
inline_js = render_to_string('discussion/discussion_board_js.template', context)
fragment.add_javascript(inline_js)
if not settings.REQUIRE_DEBUG:
fragment.add_javascript_url(staticfiles_storage.url('discussion/js/discussion_board_factory.js'))
return fragment
except cc.utils.CommentClientMaintenanceError:
log.warning('Forum is in maintenance mode')
html = render_to_string('discussion/maintenance_fragment.html', {
'disable_courseware_js': True,
'uses_bootstrap': True,
})
fragment = Fragment(html)
self.add_fragment_resource_urls(fragment)
return fragment
except TeamDiscussionHiddenFromUserException:
log.warning(
'User with id={user_id} tried to view private discussion with id={discussion_id}'.format(
user_id=request.user.id,
discussion_id=discussion_id
)
)
html = render_to_string('discussion/discussion_private_fragment.html', {
'disable_courseware_js': True,
'uses_bootstrap': True,
})
fragment = Fragment(html)
self.add_fragment_resource_urls(fragment)
return fragment
def vendor_js_dependencies(self):
"""
Returns list of vendor JS files that this view depends on.
The helper function that it uses to obtain the list of vendor JS files
works in conjunction with the Django pipeline to ensure that in development mode
the files are loaded individually, but in production just the single bundle is loaded.
"""
return list(dict.fromkeys(self.get_js_dependencies('discussion_vendor')))
def js_dependencies(self):
"""
Returns list of JS files that this view depends on.
The helper function that it uses to obtain the list of JS files
works in conjunction with the Django pipeline to ensure that in development mode
the files are loaded individually, but in production just the single bundle is loaded.
"""
return self.get_js_dependencies('discussion')
def css_dependencies(self):
"""
Returns list of CSS files that this view depends on.
The helper function that it uses to obtain the list of CSS files
works in conjunction with the Django pipeline to ensure that in development mode
the files are loaded individually, but in production just the single bundle is loaded.
"""
is_right_to_left = get_language_bidi()
css_file = BOOTSTRAP_DISCUSSION_CSS_PATH
if is_right_to_left:
css_file = css_file.replace('.css', '-rtl.css')
return [css_file]
@expect_json
@login_required
def discussion_topics(request, course_key_string):
"""
The handler for divided discussion categories requests.
This will raise 404 if user is not staff.
Returns the JSON representation of discussion topics w.r.t categories for the course.
Example:
>>> example = {
>>> "course_wide_discussions": {
>>> "entries": {
>>> "General": {
>>> "sort_key": "General",
>>> "is_divided": True,
>>> "id": "i4x-edx-eiorguegnru-course-foobarbaz"
>>> }
>>> }
>>> "children": ["General", "entry"]
>>> },
>>> "inline_discussions" : {
>>> "subcategories": {
>>> "Getting Started": {
>>> "subcategories": {},
>>> "children": [
>>> ["Working with Videos", "entry"],
>>> ["Videos on edX", "entry"]
>>> ],
>>> "entries": {
>>> "Working with Videos": {
>>> "sort_key": None,
>>> "is_divided": False,
>>> "id": "d9f970a42067413cbb633f81cfb12604"
>>> },
>>> "Videos on edX": {
>>> "sort_key": None,
>>> "is_divided": False,
>>> "id": "98d8feb5971041a085512ae22b398613"
>>> }
>>> }
>>> },
>>> "children": ["Getting Started", "subcategory"]
>>> },
>>> }
>>> }
"""
course_key = CourseKey.from_string(course_key_string)
course = get_course_with_access(request.user, 'staff', course_key)
discussion_topics = {} # lint-amnesty, pylint: disable=redefined-outer-name
discussion_category_map = utils.get_discussion_category_map(
course, request.user, divided_only_if_explicit=True, exclude_unstarted=False
)
# We extract the data for the course wide discussions from the category map.
course_wide_entries = discussion_category_map.pop('entries')
course_wide_children = []
inline_children = []
for name, c_type in discussion_category_map['children']:
if name in course_wide_entries and c_type == TYPE_ENTRY:
course_wide_children.append([name, c_type])
else:
inline_children.append([name, c_type])
discussion_topics['course_wide_discussions'] = {
'entries': course_wide_entries,
'children': course_wide_children
}
discussion_category_map['children'] = inline_children
discussion_topics['inline_discussions'] = discussion_category_map
return JsonResponse(discussion_topics)
@require_http_methods(("GET", "PATCH"))
@ensure_csrf_cookie
@expect_json
@login_required
def course_discussions_settings_handler(request, course_key_string):
"""
The restful handler for divided discussion setting requests. Requires JSON.
This will raise 404 if user is not staff.
GET
Returns the JSON representation of divided discussion settings for the course.
PATCH
Updates the divided discussion settings for the course. Returns the JSON representation of updated settings.
"""
course_key = CourseKey.from_string(course_key_string)
course = get_course_with_access(request.user, 'staff', course_key)
discussion_settings = CourseDiscussionSettings.get(course_key)
if request.method == 'PATCH':
divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
course, discussion_settings
)
settings_to_change = {}
if 'divided_course_wide_discussions' in request.json or 'divided_inline_discussions' in request.json:
divided_course_wide_discussions = request.json.get(
'divided_course_wide_discussions', divided_course_wide_discussions
)
divided_inline_discussions = request.json.get(
'divided_inline_discussions', divided_inline_discussions
)
settings_to_change['divided_discussions'] = divided_course_wide_discussions + divided_inline_discussions
if 'always_divide_inline_discussions' in request.json:
settings_to_change['always_divide_inline_discussions'] = request.json.get(
'always_divide_inline_discussions'
)
if 'division_scheme' in request.json:
settings_to_change['division_scheme'] = request.json.get(
'division_scheme'
)
if not settings_to_change:
return JsonResponse({"error": "Bad Request"}, 400)
try:
if settings_to_change:
discussion_settings.update(settings_to_change)
except ValueError as err:
# Note: error message not translated because it is not exposed to the user (UI prevents this state).
return JsonResponse({"error": str(err)}, 400)
divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
course, discussion_settings
)
return JsonResponse({
'id': discussion_settings.id,
'divided_inline_discussions': divided_inline_discussions,
'divided_course_wide_discussions': divided_course_wide_discussions,
'always_divide_inline_discussions': discussion_settings.always_divide_inline_discussions,
'division_scheme': discussion_settings.division_scheme,
'available_division_schemes': available_division_schemes(course_key)
})
def _check_team_discussion_access(request, course, discussion_id):
"""
Helper function to check if the discussion is visible to the user,
if the user is on a team, which has the discussion set to private.
"""
user_is_course_staff = has_access(request.user, "staff", course)
if not user_is_course_staff and not team_api.discussion_visible_by_user(discussion_id, request.user):
raise TeamDiscussionHiddenFromUserException()