Files
edx-platform/lms/djangoapps/courseware/masquerade.py
2023-04-26 17:10:53 +02:00

481 lines
19 KiB
Python

'''
---------------------------------------- Masquerade ----------------------------------------
Allow course staff to see a student or staff view of courseware.
Which kind of view has been selected is stored in the session state.
'''
import logging
from datetime import datetime
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.db.models import Q
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views import View
from opaque_keys.edx.keys import CourseKey
from pytz import utc
from web_fragments.fragment import Fragment
from xblock.runtime import KeyValueStore
from common.djangoapps.course_modes.models import CourseMode
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.markup import HTML
from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITION_ID
from openedx.features.content_type_gating.helpers import FULL_ACCESS
from openedx.features.content_type_gating.helpers import LIMITED_ACCESS
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.role_helpers import has_staff_roles
from common.djangoapps.util.json_request import JsonResponse, expect_json
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.partitions.partitions import NoSuchUserPartitionGroupError # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.partitions.partitions_service import get_all_partitions_for_course # lint-amnesty, pylint: disable=wrong-import-order
log = logging.getLogger(__name__)
# The key used to store a user's course-level masquerade information in the Django session.
# The value is a dict from course keys to CourseMasquerade objects.
MASQUERADE_SETTINGS_KEY = 'masquerade_settings'
# The key used to store temporary XBlock field data in the Django session. This is where field
# data is stored to avoid modifying the state of the user we are masquerading as.
MASQUERADE_DATA_KEY = 'masquerade_data'
class CourseMasquerade:
"""
Masquerade settings for a particular course.
"""
def __init__(self, course_key, role='student', user_partition_id=None, group_id=None, user_name=None):
# All parameters to this function must be named identically to the corresponding attribute.
# If you remove or rename an attribute, also update the __setstate__() method to migrate
# old data from users' sessions.
self.course_key = course_key
self.role = role
self.user_partition_id = user_partition_id
self.group_id = group_id
self.user_name = user_name
def __setstate__(self, state):
"""
Ensure that all attributes are initialised when unpickling CourseMasquerade objects.
Users might still have CourseMasquerade objects from older versions of the code in their
session. These old objects might not have all attributes set, possibly resulting in
AttributeErrors.
"""
self.__init__(**state)
def get_active_group_name(self, available):
"""
Lookup the active group name, from available options
Returns: the corresponding group name, if exists,
else, return None
"""
if not (self.group_id and self.user_partition_id):
return None
for group in available:
if (
self.group_id == group.get('group_id') and
self.user_partition_id == group.get('user_partition_id')
):
return group.get('name')
return None
@method_decorator(login_required, name='dispatch')
class MasqueradeView(View):
"""
Create an HTTP endpoint to manage masquerade settings
"""
def get(self, request, course_key_string):
"""
Retrieve data on the active and available masquerade options
"""
course_key = CourseKey.from_string(course_key_string)
is_staff = has_staff_roles(request.user, course_key)
if not is_staff:
return JsonResponse({
'success': False,
})
masquerade_settings = request.session.get(MASQUERADE_SETTINGS_KEY, {})
course = masquerade_settings.get(course_key, None)
course = course or CourseMasquerade(
course_key,
role='staff',
user_partition_id=None,
group_id=None,
user_name=None,
)
block = modulestore().get_course(course_key)
partitions = get_all_partitions_for_course(block, active_only=True)
data = {
'success': True,
'active': {
'course_key': course_key_string,
'group_id': course.group_id,
'role': course.role,
'user_name': course.user_name or None,
'user_partition_id': course.user_partition_id,
},
'available': [
{
'name': 'Staff',
'role': 'staff',
},
],
}
if len(partitions) == 0:
data['available'].append({
'name': 'Learner',
'role': 'student',
})
data['available'].append({
'name': 'Specific Student...',
'role': 'student',
'user_name': course.user_name or '',
})
for partition in partitions:
# "random" scheme implies a split_test content group, not a cohort
# and masquerading only cares about user cohorts
if partition.active and partition.scheme.name != "random":
data['available'].extend([
{
'group_id': group.id,
'name': group.name,
'role': 'student',
'user_partition_id': partition.id,
}
for group in partition.groups
])
data['active']['group_name'] = course.get_active_group_name(data['available'])
return JsonResponse(data)
@method_decorator(expect_json)
def post(self, request, course_key_string):
"""
Handle AJAX posts to update the current user's masquerade for the specified course.
The masquerade settings are stored in the Django session as a dict from course keys
to CourseMasquerade objects.
"""
course_key = CourseKey.from_string(course_key_string)
is_staff = has_staff_roles(request.user, course_key)
if not is_staff:
return JsonResponse({
'success': False,
})
masquerade_settings = request.session.get(MASQUERADE_SETTINGS_KEY, {})
request_json = request.json
role = request_json.get('role', 'student')
group_id = request_json.get('group_id', None)
user_partition_id = request_json.get('user_partition_id', None) if group_id is not None else None
user_name = request_json.get('user_name', None)
found_user_name = None
if user_name:
users_in_course = CourseEnrollment.objects.users_enrolled_in(course_key)
try:
found_user_name = users_in_course.get(Q(email=user_name) | Q(username=user_name)).username
except User.DoesNotExist:
return JsonResponse({
'success': False,
'error': _(
'There is no user with the username or email address "{user_identifier}" '
'enrolled in this course.'
).format(
user_identifier=user_name,
),
})
masquerade_settings[course_key] = CourseMasquerade(
course_key,
role=role,
user_partition_id=user_partition_id,
group_id=group_id,
user_name=found_user_name,
)
request.session[MASQUERADE_SETTINGS_KEY] = masquerade_settings
return JsonResponse({'success': True})
def setup_masquerade(request, course_key, staff_access=False, reset_masquerade_data=False):
"""
Sets up masquerading for the current user within the current request. The request's user is
updated to have a 'masquerade_settings' attribute with the dict of all masqueraded settings if
called from within a request context. The function then returns a pair (CourseMasquerade, User)
with the masquerade settings for the specified course key or None if there isn't one, and the
user we are masquerading as or request.user if masquerading as a specific user is not active.
If the reset_masquerade_data flag is set, the field data stored in the session will be cleared.
"""
if (
request.user is None or
not settings.FEATURES.get('ENABLE_MASQUERADE', False) or
not staff_access
):
return None, request.user
if reset_masquerade_data:
request.session.pop(MASQUERADE_DATA_KEY, None)
masquerade_settings = request.session.setdefault(MASQUERADE_SETTINGS_KEY, {})
# Store the masquerade settings on the user so it can be accessed without the request
request.user.masquerade_settings = masquerade_settings
course_masquerade = masquerade_settings.get(course_key, None)
masquerade_user = None
if course_masquerade and course_masquerade.user_name:
try:
masquerade_user = CourseEnrollment.objects.users_enrolled_in(course_key).get(
username=course_masquerade.user_name
)
except User.DoesNotExist:
# This can only happen if the user was unenrolled from the course since masquerading
# was enabled. We silently reset the masquerading configuration in this case.
course_masquerade = None
del masquerade_settings[course_key]
request.session.modified = True
else:
# Store the masquerading settings on the masquerade_user as well, since this user will
# be used in some places instead of request.user.
masquerade_user.masquerade_settings = request.user.masquerade_settings
masquerade_user.real_user = request.user
return course_masquerade, masquerade_user or request.user
def get_course_masquerade(user, course_key):
"""
Returns the masquerade for the current user for the specified course. If no masquerade has
been installed, then a default no-op masquerade is returned.
"""
masquerade_settings = getattr(user, 'masquerade_settings', {})
return masquerade_settings.get(course_key, None)
def get_masquerade_role(user, course_key):
"""
Returns the role that the user is masquerading as, or None if no masquerade is in effect.
"""
course_masquerade = get_course_masquerade(user, course_key)
return course_masquerade.role if course_masquerade else None
def _get_masquerade_group_id(target_user_partition_id, user, course_key, course_masquerade=None):
"""
Return the masqueraded track's group ID
if it's in the specified user partition,
otherwise, return None
"""
course_masquerade = course_masquerade or get_course_masquerade(user, course_key)
if course_masquerade is not None:
user_partition_id = course_masquerade.user_partition_id
if user_partition_id == target_user_partition_id:
group_id = course_masquerade.group_id
if group_id:
return group_id
return None
def is_masquerading(user, course_key, course_masquerade=None):
"""
Return if the user is masquerading at all
"""
course_masquerade = course_masquerade or get_course_masquerade(user, course_key)
_is_masquerading = course_masquerade is not None
return _is_masquerading
def is_masquerading_as_non_audit_enrollment(user, course_key, course_masquerade=None):
"""
Return if the user is a staff member masquerading as a user
in _any_ enrollment track _except_ audit
"""
group_id = _get_masquerade_group_id(ENROLLMENT_TRACK_PARTITION_ID, user, course_key, course_masquerade)
audit_mode_id = settings.COURSE_ENROLLMENT_MODES.get(CourseMode.AUDIT, {}).get('id')
if group_id is not None:
if group_id != audit_mode_id:
return True
return False
def is_masquerading_as_audit_enrollment(user, course_key, course_masquerade=None):
"""
Return if the user is a staff member masquerading as a user
in the audit enrollment track
"""
group_id = _get_masquerade_group_id(ENROLLMENT_TRACK_PARTITION_ID, user, course_key, course_masquerade)
audit_mode_id = settings.COURSE_ENROLLMENT_MODES.get(CourseMode.AUDIT, {}).get('id')
_is_masquerading = group_id == audit_mode_id
return _is_masquerading
def is_masquerading_as_full_access(user, course_key, course_masquerade=None):
"""
Return if the user is a staff member masquerading as a user
in the Full-Access track
"""
group_id = _get_masquerade_group_id(CONTENT_GATING_PARTITION_ID, user, course_key, course_masquerade)
_is_masquerading = group_id == FULL_ACCESS.id
return _is_masquerading
def is_masquerading_as_limited_access(user, course_key, course_masquerade=None):
"""
Return if the user is a staff member masquerading as a user
in the Limited-Access track
"""
group_id = _get_masquerade_group_id(CONTENT_GATING_PARTITION_ID, user, course_key, course_masquerade)
_is_masquerading = group_id == LIMITED_ACCESS.id
return _is_masquerading
def is_masquerading_as_staff(user, course_key):
"""
Return if the user is a staff member masquerading as user
that is itself a staff user
"""
return get_masquerade_role(user, course_key) == 'staff'
def is_masquerading_as_student(user, course_key):
"""
Returns true if the user is a staff member masquerading as a student.
"""
return get_masquerade_role(user, course_key) == 'student'
def is_masquerading_as_specific_student(user, course_key):
"""
Returns whether the user is a staff member masquerading as a specific student.
"""
course_masquerade = get_course_masquerade(user, course_key)
return bool(course_masquerade and course_masquerade.user_name)
def get_specific_masquerading_user(user, course_key):
"""
Return the specific user that a staff member is masquerading as, or None if they aren't.
"""
course_masquerade = get_course_masquerade(user, course_key)
is_specific_user = bool(course_masquerade and course_masquerade.user_name)
if is_specific_user:
return User.objects.get(username=course_masquerade.user_name)
else:
return None
def get_masquerading_user_group(course_key, user, user_partition):
"""
If the current user is masquerading as a generic learner in a specific group, return that group.
If the user is not masquerading as a group, then None is returned.
"""
course_masquerade = get_course_masquerade(user, course_key)
if course_masquerade:
if course_masquerade.user_partition_id == user_partition.id and course_masquerade.group_id is not None:
try:
return user_partition.get_group(course_masquerade.group_id)
except NoSuchUserPartitionGroupError:
return None
# The user is masquerading as a generic student or not masquerading as a group return None
return None
def check_content_start_date_for_masquerade_user(course_key, user, request, course_start,
chapter_start=None, section_start=None):
"""
Add a warning message if the masquerade user would not have access to this content
due to the content start date being in the future.
"""
now = datetime.now(utc)
most_future_date = course_start
if chapter_start and section_start:
most_future_date = max(course_start, chapter_start, section_start)
_is_masquerading = get_course_masquerade(user, course_key)
if now < most_future_date and _is_masquerading:
group_masquerade = is_masquerading_as_student(user, course_key)
specific_student_masquerade = is_masquerading_as_specific_student(user, course_key)
is_staff = has_staff_roles(user, course_key)
if group_masquerade or (specific_student_masquerade and not is_staff):
PageLevelMessages.register_warning_message(
request,
HTML(_('This user does not have access to this content because \
the content start date is in the future')),
once_only=True
)
# Sentinel object to mark deleted objects in the session cache
_DELETED_SENTINEL = object()
class MasqueradingKeyValueStore(KeyValueStore):
"""
A `KeyValueStore` to avoid affecting the user state when masquerading.
This `KeyValueStore` wraps an underlying `KeyValueStore`. Reads are forwarded to the underlying
store, but writes go to a Django session (or other dictionary-like object).
"""
def __init__(self, kvs, session): # lint-amnesty, pylint: disable=super-init-not-called
"""
Arguments:
kvs: The KeyValueStore to wrap.
session: The Django session used to store temporary data in.
"""
self.kvs = kvs
self.session = session
self.session_data = session.setdefault(MASQUERADE_DATA_KEY, {})
def _serialize_key(self, key):
"""
Convert the key of Type KeyValueStore.Key to a string.
Keys are not JSON-serializable, so we can't use them as keys for the Django session.
The implementation is taken from cms/djangoapps/contentstore/views/session_kv_store.py.
"""
return repr(tuple(key))
def get(self, key):
key_str = self._serialize_key(key)
try:
value = self.session_data[key_str]
except KeyError:
return self.kvs.get(key)
else:
if value is _DELETED_SENTINEL:
raise KeyError(key_str)
return value
def set(self, key, value):
self.session_data[self._serialize_key(key)] = value
self.session.modified = True
def delete(self, key):
# We can't simply delete the key from the session, since it might still exist in the kvs,
# which we are not allowed to modify, so we mark it as deleted by setting it to
# _DELETED_SENTINEL in the session.
self.set(key, _DELETED_SENTINEL)
def has(self, key):
try:
value = self.session_data[self._serialize_key(key)]
except KeyError:
return self.kvs.has(key)
else:
return value != _DELETED_SENTINEL
def filter_displayed_blocks(block, unused_view, frag, unused_context): # lint-amnesty, pylint: disable=unused-argument
"""
A wrapper to only show XBlocks that set `show_in_read_only_mode` when masquerading as a specific user.
We don't want to modify the state of the user we are masquerading as, so we can't show XBlocks
that store information outside of the XBlock fields API.
"""
if getattr(block, 'show_in_read_only_mode', False):
return frag
return Fragment(
_('This type of component cannot be shown while viewing the course as a specific student.')
)