* feat: adds SearchAccess model Stores a numeric ID for each course + library, which will generally be shorter than the full context_key, so we can pack more of them into the the Meilisearch search filter. Also: * Adds data migration pre-populates the SearchAccess model from the existing CourseOverview and ContentLibrary records * Adds signal handlers to add/remove SearchAccess entries when content is created or deleted. * Adds get_access_ids_for_request() helper method for use in views. * Adds tests. * test: can't import content.search in lms tests * feat: use SearchAccess in documents and views * Adds an access_id field to the document, which stores the SearchAccess.id for the block's context. * Use the requesting user's allowed access_ids to filter search results to documents with those access_ids. * Since some users have a lot of individual access granted, limit the number of access_ids in the filter to a large number (1_000) * Updates tests to demonstrate. * test: can't import content.search or content_staging in lms tests * fix: make access_id field filterable * fix: use SearchAccess.get_or_create in signal handlers In theory, we shouldn't have to do this, because the CREATE and DELETE events should keep the SearchAccess table up-to-date. But in practice, signals can be missed (or in tests, they may be disabled). So we assume that it's ok to re-use a SearchAccess.id created for a given course or library context_key. * refactor: refactors the view tests to make them clearer Uses helper methods and decorators to wrap the settings and patches used by multiple view tests. * feat: adds org filters to meilisearch filter * Uses content_tagging.rules.get_user_orgs to fetch the user's content-related orgs for use in the meilisearch filter. * Limits the number of orgs used to 1_000 to keep token size down * refactor: removes data migration Users should use the reindex_studio management command to populate SearchAccess. * refactor: adds functions to common.djangoapps.student.role_helpers to allow general access to the user's RoleCache without having to access private attributes of User or RoleCache. Related changes: * Moves some functionality from openedx.core.djangoapps.enrollments.data.get_user_roles to this new helper method. * Use these new helper method in content_tagging.rules * fix: get_access_ids_for_request only returns individual access instead of all course keys that the user can read. Org- and GlobalStaff access checks will handle the rest. * fix: use org-level permissions when generating search filter Also refactors tests to demonstrate this change for OrgStaff and OrgInstructor users. * refactor: remove SearchAccess creation signal handlers Lets SearchAccess entries be created on demand during search indexing. * feat: omit access_ids from the search filter that are covered by the user's org roles --------- Co-authored-by: Rômulo Penido <romulo.penido@gmail.com>
363 lines
12 KiB
Python
363 lines
12 KiB
Python
"""
|
|
Data Aggregation Layer of the Enrollment API. Collects all enrollment specific data into a single
|
|
source to be used throughout the API.
|
|
"""
|
|
|
|
|
|
import logging
|
|
|
|
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
|
from django.db import transaction
|
|
from opaque_keys.edx.keys import CourseKey
|
|
|
|
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
|
from openedx.core.djangoapps.enrollments.errors import (
|
|
CourseEnrollmentClosedError,
|
|
CourseEnrollmentExistsError,
|
|
CourseEnrollmentFullError,
|
|
InvalidEnrollmentAttribute,
|
|
UserNotFoundError
|
|
)
|
|
from openedx.core.djangoapps.enrollments.serializers import CourseEnrollmentSerializer, CourseSerializer
|
|
from openedx.core.lib.exceptions import CourseNotFoundError
|
|
from common.djangoapps.student.models import (
|
|
AlreadyEnrolledError,
|
|
CourseEnrollment,
|
|
CourseEnrollmentAttribute,
|
|
CourseFullError,
|
|
EnrollmentClosedError,
|
|
NonExistentCourseError
|
|
)
|
|
from common.djangoapps.student.role_helpers import get_course_roles
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def get_course_enrollments(username, include_inactive=False):
|
|
"""Retrieve a list representing all aggregated data for a user's course enrollments.
|
|
|
|
Construct a representation of all course enrollment data for a specific user.
|
|
|
|
Args:
|
|
username: The name of the user to retrieve course enrollment information for.
|
|
include_inactive (bool): Determines whether inactive enrollments will be included
|
|
|
|
|
|
Returns:
|
|
A serializable list of dictionaries of all aggregated enrollment data for a user.
|
|
|
|
"""
|
|
qset = CourseEnrollment.objects.filter(
|
|
user__username=username,
|
|
).order_by('created')
|
|
|
|
if not include_inactive:
|
|
qset = qset.filter(is_active=True)
|
|
|
|
enrollments = CourseEnrollmentSerializer(qset, many=True).data
|
|
|
|
# Find deleted courses and filter them out of the results
|
|
deleted = []
|
|
valid = []
|
|
for enrollment in enrollments:
|
|
if enrollment.get("course_details") is not None:
|
|
valid.append(enrollment)
|
|
else:
|
|
deleted.append(enrollment)
|
|
|
|
if deleted:
|
|
log.warning(
|
|
(
|
|
"Course enrollments for user %s reference "
|
|
"courses that do not exist (this can occur if a course is deleted)."
|
|
), username,
|
|
)
|
|
|
|
return valid
|
|
|
|
|
|
def get_course_enrollment(username, course_id):
|
|
"""Retrieve an object representing all aggregated data for a user's course enrollment.
|
|
|
|
Get the course enrollment information for a specific user and course.
|
|
|
|
Args:
|
|
username (str): The name of the user to retrieve course enrollment information for.
|
|
course_id (str): The course to retrieve course enrollment information for.
|
|
|
|
Returns:
|
|
A serializable dictionary representing the course enrollment.
|
|
|
|
"""
|
|
course_key = CourseKey.from_string(course_id)
|
|
try:
|
|
enrollment = CourseEnrollment.objects.get(
|
|
user__username=username, course_id=course_key
|
|
)
|
|
return CourseEnrollmentSerializer(enrollment).data
|
|
except CourseEnrollment.DoesNotExist:
|
|
return None
|
|
|
|
|
|
def get_user_enrollments(course_key):
|
|
"""Based on the course id, return all user enrollments in the course
|
|
Args:
|
|
course_key (CourseKey): Identifier of the course
|
|
from which to retrieve enrollments.
|
|
Returns:
|
|
A course's user enrollments as a queryset
|
|
Raises:
|
|
CourseEnrollment.DoesNotExist
|
|
"""
|
|
return CourseEnrollment.objects.filter(
|
|
course_id=course_key,
|
|
is_active=True
|
|
).order_by('created')
|
|
|
|
|
|
def create_course_enrollment(username, course_id, mode, is_active, enterprise_uuid=None, force_enrollment=False):
|
|
"""Create a new course enrollment for the given user.
|
|
|
|
Creates a new course enrollment for the specified user username.
|
|
|
|
Args:
|
|
username (str): The name of the user to create a new course enrollment for.
|
|
course_id (str): The course to create the course enrollment for.
|
|
mode (str): (Optional) The mode for the new enrollment.
|
|
is_active (boolean): (Optional) Determines if the enrollment is active.
|
|
enterprise_uuid (str): Add course enterprise uuid
|
|
force_enrollment (bool): Enroll user even if course enrollment_end date is expired
|
|
|
|
Returns:
|
|
A serializable dictionary representing the new course enrollment.
|
|
|
|
Raises:
|
|
CourseNotFoundError
|
|
CourseEnrollmentFullError
|
|
EnrollmentClosedError
|
|
CourseEnrollmentExistsError
|
|
|
|
"""
|
|
course_key = CourseKey.from_string(course_id)
|
|
|
|
try:
|
|
user = User.objects.get(username=username)
|
|
except User.DoesNotExist:
|
|
msg = f"Not user with username '{username}' found."
|
|
log.warning(msg)
|
|
raise UserNotFoundError(msg) # lint-amnesty, pylint: disable=raise-missing-from
|
|
|
|
try:
|
|
enrollment = CourseEnrollment.enroll(
|
|
user, course_key, check_access=True, can_upgrade=force_enrollment, enterprise_uuid=enterprise_uuid
|
|
)
|
|
return _update_enrollment(enrollment, is_active=is_active, mode=mode)
|
|
except NonExistentCourseError as err:
|
|
raise CourseNotFoundError(str(err)) # lint-amnesty, pylint: disable=raise-missing-from
|
|
except EnrollmentClosedError as err:
|
|
raise CourseEnrollmentClosedError(str(err)) # lint-amnesty, pylint: disable=raise-missing-from
|
|
except CourseFullError as err:
|
|
raise CourseEnrollmentFullError(str(err)) # lint-amnesty, pylint: disable=raise-missing-from
|
|
except AlreadyEnrolledError as err:
|
|
enrollment = get_course_enrollment(username, course_id)
|
|
raise CourseEnrollmentExistsError(str(err), enrollment) # lint-amnesty, pylint: disable=raise-missing-from
|
|
|
|
|
|
def update_course_enrollment(username, course_id, mode=None, is_active=None):
|
|
"""Modify a course enrollment for a user.
|
|
|
|
Allows updates to a specific course enrollment.
|
|
|
|
Args:
|
|
username (str): The name of the user to retrieve course enrollment information for.
|
|
course_id (str): The course to retrieve course enrollment information for.
|
|
mode (str): (Optional) If specified, modify the mode for this enrollment.
|
|
is_active (boolean): (Optional) Determines if the enrollment is active.
|
|
|
|
Returns:
|
|
A serializable dictionary representing the modified course enrollment.
|
|
|
|
"""
|
|
course_key = CourseKey.from_string(course_id)
|
|
|
|
try:
|
|
user = User.objects.get(username=username)
|
|
except User.DoesNotExist:
|
|
msg = f"Not user with username '{username}' found."
|
|
log.warning(msg)
|
|
raise UserNotFoundError(msg) # lint-amnesty, pylint: disable=raise-missing-from
|
|
|
|
try:
|
|
enrollment = CourseEnrollment.objects.get(user=user, course_id=course_key)
|
|
return _update_enrollment(enrollment, is_active=is_active, mode=mode)
|
|
except CourseEnrollment.DoesNotExist:
|
|
return None
|
|
|
|
|
|
def add_or_update_enrollment_attr(username, course_id, attributes):
|
|
"""Set enrollment attributes for the enrollment of given user in the
|
|
course provided.
|
|
|
|
Args:
|
|
course_id (str): The Course to set enrollment attributes for.
|
|
username: The User to set enrollment attributes for.
|
|
attributes (list): Attributes to be set.
|
|
|
|
Example:
|
|
>>>add_or_update_enrollment_attr(
|
|
"Bob",
|
|
"course-v1-edX-DemoX-1T2015",
|
|
[
|
|
{
|
|
"namespace": "credit",
|
|
"name": "provider_id",
|
|
"value": "hogwarts",
|
|
},
|
|
]
|
|
)
|
|
"""
|
|
course_key = CourseKey.from_string(course_id)
|
|
user = _get_user(username)
|
|
enrollment = CourseEnrollment.get_enrollment(user, course_key)
|
|
if not _invalid_attribute(attributes) and enrollment is not None:
|
|
CourseEnrollmentAttribute.add_enrollment_attr(enrollment, attributes)
|
|
|
|
|
|
def get_enrollment_attributes(username, course_id):
|
|
"""Retrieve enrollment attributes for given user for provided course.
|
|
|
|
Args:
|
|
username: The User to get enrollment attributes for
|
|
course_id (str): The Course to get enrollment attributes for.
|
|
|
|
Example:
|
|
>>>get_enrollment_attributes("Bob", "course-v1-edX-DemoX-1T2015")
|
|
[
|
|
{
|
|
"namespace": "credit",
|
|
"name": "provider_id",
|
|
"value": "hogwarts",
|
|
},
|
|
]
|
|
|
|
Returns: list
|
|
"""
|
|
course_key = CourseKey.from_string(course_id)
|
|
user = _get_user(username)
|
|
enrollment = CourseEnrollment.get_enrollment(user, course_key)
|
|
return CourseEnrollmentAttribute.get_enrollment_attributes(enrollment)
|
|
|
|
|
|
def unenroll_user_from_all_courses(username):
|
|
"""
|
|
Set all of a user's enrollments to inactive.
|
|
:param username: The user being unenrolled.
|
|
:return: A list of all courses from which the user was unenrolled.
|
|
"""
|
|
user = _get_user(username)
|
|
enrollments = CourseEnrollment.objects.filter(user=user)
|
|
with transaction.atomic():
|
|
for enrollment in enrollments:
|
|
_update_enrollment(enrollment, is_active=False)
|
|
|
|
return {str(enrollment.course_id.org) for enrollment in enrollments} # lint-amnesty, pylint: disable=consider-using-set-comprehension
|
|
|
|
|
|
def _get_user(username):
|
|
"""Retrieve user with provided username
|
|
|
|
Args:
|
|
username: username of the user for which object is to retrieve
|
|
|
|
Returns: obj
|
|
"""
|
|
try:
|
|
return User.objects.get(username=username)
|
|
except User.DoesNotExist:
|
|
msg = f"Not user with username '{username}' found."
|
|
log.warning(msg)
|
|
raise UserNotFoundError(msg) # lint-amnesty, pylint: disable=raise-missing-from
|
|
|
|
|
|
def _update_enrollment(enrollment, is_active=None, mode=None):
|
|
enrollment.update_enrollment(is_active=is_active, mode=mode)
|
|
enrollment.save()
|
|
return CourseEnrollmentSerializer(enrollment).data
|
|
|
|
|
|
def _invalid_attribute(attributes):
|
|
"""Validate enrollment attribute
|
|
|
|
Args:
|
|
attributes(List): List of attribute dicts
|
|
|
|
Return:
|
|
list of invalid attributes
|
|
"""
|
|
invalid_attributes = []
|
|
for attribute in attributes:
|
|
if "namespace" not in attribute:
|
|
msg = "'namespace' not in enrollment attribute"
|
|
log.warning(msg)
|
|
invalid_attributes.append("namespace")
|
|
raise InvalidEnrollmentAttribute(msg)
|
|
if "name" not in attribute:
|
|
msg = "'name' not in enrollment attribute"
|
|
log.warning(msg)
|
|
invalid_attributes.append("name")
|
|
raise InvalidEnrollmentAttribute(msg)
|
|
if "value" not in attribute:
|
|
msg = "'value' not in enrollment attribute"
|
|
log.warning(msg)
|
|
invalid_attributes.append("value")
|
|
raise InvalidEnrollmentAttribute(msg)
|
|
|
|
return invalid_attributes
|
|
|
|
|
|
def get_course_enrollment_info(course_id, include_expired=False):
|
|
"""Returns all course enrollment information for the given course.
|
|
|
|
Based on the course id, return all related course information.
|
|
|
|
Args:
|
|
course_id (str): The course to retrieve enrollment information for.
|
|
|
|
include_expired (bool): Boolean denoting whether expired course modes
|
|
should be included in the returned JSON data.
|
|
|
|
Returns:
|
|
A serializable dictionary representing the course's enrollment information.
|
|
|
|
Raises:
|
|
CourseNotFoundError
|
|
|
|
"""
|
|
course_key = CourseKey.from_string(course_id)
|
|
|
|
try:
|
|
course = CourseOverview.get_from_id(course_key)
|
|
except CourseOverview.DoesNotExist:
|
|
msg = f"Requested enrollment information for unknown course {course_id}"
|
|
log.warning(msg)
|
|
raise CourseNotFoundError(msg) # lint-amnesty, pylint: disable=raise-missing-from
|
|
else:
|
|
return CourseSerializer(course, include_expired=include_expired).data
|
|
|
|
|
|
def get_user_roles(username):
|
|
"""
|
|
Returns a set of all roles that this user has.
|
|
:param username: The id of the selected user.
|
|
:return: All roles for all courses that this user has.
|
|
"""
|
|
user = _get_user(username)
|
|
return set(get_course_roles(user))
|
|
|
|
|
|
def serialize_enrollments(enrollments):
|
|
"""
|
|
Take CourseEnrollment objects and return them in a serialized list.
|
|
"""
|
|
return CourseEnrollmentSerializer(enrollments, many=True).data
|