Skip to content
Merged
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
26 changes: 26 additions & 0 deletions common/djangoapps/student/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2193,6 +2193,20 @@ def _update_enrollment(cls, cache, user_id, course_key, enrollment_state): # li
"""
cache[(user_id, course_key)] = enrollment_state

@classmethod
def get_active_enrollments_in_course(cls, course_key):
"""
Retrieves active CourseEnrollments for a given course and
prefetches user information.
"""
return cls.objects.filter(
course_id=course_key,
is_active=True,
).select_related(
'user',
'user__profile',
)


@python_2_unicode_compatible
class FBEEnrollmentExclusion(models.Model):
Expand Down Expand Up @@ -2402,6 +2416,18 @@ def _key(self):
"""
return (self.role, self.org, self.course_id, self.user_id)

@classmethod
def access_roles_in_course(cls, course_key):
"""
Returns all CourseAccessRole for a given course and prefetches user information.
"""
return cls.objects.filter(
course_id=course_key,
).select_related(
'user',
'user__profile'
)

def __eq__(self, other):
"""
Overriding eq b/c the django impl relies on the primary key which requires fetch. sometimes we
Expand Down
102 changes: 101 additions & 1 deletion lms/djangoapps/course_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Course API
"""
import logging
from collections import defaultdict

import search
from django.conf import settings
Expand All @@ -12,7 +13,7 @@
from opaque_keys.edx.django.models import CourseKeyField
from rest_framework.exceptions import PermissionDenied

from common.djangoapps.student.models import CourseAccessRole
from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment
from common.djangoapps.student.roles import GlobalStaff
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.courses import (
Expand All @@ -25,6 +26,7 @@
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError

from .exceptions import OverEnrollmentLimitException
from .permissions import can_view_courses_for_username

logger = logging.getLogger(__name__) # pylint: disable=invalid-name
Expand Down Expand Up @@ -266,3 +268,101 @@ def get_course_run_url(request, course_id):
"""
course_run_url = reverse('openedx.course_experience.course_home', args=[course_id])
return request.build_absolute_uri(course_run_url)


def get_course_members(course_key):
"""
Returns a dict containing all users with access to a course through CourseEnrollment
and CourseAccessRole models.

User information includes id, email, username, name, enrollment mode and role list.

This API is limited and will only work for courses with less than a configurable number
of active enrollments (managed through `settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT`,
and the default value is 1000). More than that and the method will raise a
`OverEnrollmentLimitException` exception.

This method works by querying the `CourseEnrollment` and `CourseAccessRole` models,
prefetching user information and *then joining results in Python using dictionaries*.
This approach was choosen to avoid database heavy queries (such as DISTINCT and COUNT) in
the `CourseEnrollment` table, which would take too long to complete in a request lifecycle.

The main concern with this approach is the dataset size and resource usage since this method
returns all enrollments without pagination. We're using a conservative number on the
`COURSE_MEMBER_API_ENROLLMENT_LIMIT` setting to avoid any issues.

Examples:
- Get all course members:
get_course_members(course_key)

Arguments:
course_key (CourseKey): CourseKey to retrieve student data.

Returns:
dict: A dictionary with the following format:
{
"user_id": {
"id": 12,
"username": "jonh5000",
"email": "jonh@example.com",
"name": "Jonh Doe",
"enrollment_mode": "verified",
"roles": [
"student",
"instructor",
"staff",
]
}
}

@ormsbee ormsbee May 19, 2021

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring should clearly indicate the too-large-course error case, and answer the following:

  • Where is the config value set?
  • What exception is raised?

"""
def make_user_info_dict(user, enrollment_mode=None):
"""
Utility function to extract user information from model.
"""
return {
"id": user.id,
"username": user.username,
"email": user.email,
"name": user.profile.name,
"enrollment_mode": enrollment_mode,
}

# Raise error if trying to retrieve user list from a course with more than
# settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT active enrollments. The fastest way
# to do this is to query for the 1st item after `COURSE_MEMBER_API_ENROLLMENT_LIMIT`.
over_limit = CourseEnrollment.get_active_enrollments_in_course(
course_key
)[settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT:][:1]
if over_limit.exists():
raise OverEnrollmentLimitException(
f"Can't retrieve course members for {course_key} since it has more than "
f"{settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT} active enrollments. "
f"This limit is stored on `settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT`."
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Say I got paged for this because a certain course that was dependent on this API grew beyond a certain size and suddenly started breaking... and then I looked in the logs and saw this message. Since the immediate short-term fix is going to be to bump up the limit, I think it'd be helpful to not just give the number, but also log which setting it's pulled from (so that the word COURSE_MEMBER_API_ENROLLMENT_LIMIT is in the log string and I can go from that log message to searching my config).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... not that this sort of thing has ever happened to us... 🤧

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add the course key to this error message? Or will it be obvious from some other source in the logs?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nedbat Yes, good idea! The log output now look like this:

Can't retrieve course members for COURSE_KEY since it has more than 1000 active enrollments. This limit is stored on `settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT`


# Python dicts where we're going to manually combine the data from the two querysets
user_roles = defaultdict(list)
user_info = {}

# Retrieve all active enrollments in course and prefetch user information
enrollments = CourseEnrollment.get_active_enrollments_in_course(course_key)

# Retrieve all course access roles and prefetch user information
access_roles = CourseAccessRole.access_roles_in_course(course_key)

# Evaluates querysets and parses data from the two querysets
# into `user_info` and `user_roles` dictionaries.
for enrollment in enrollments:
user_roles[enrollment.user_id].append('student')
user_info[enrollment.user_id] = make_user_info_dict(enrollment.user, enrollment.mode)

for access_role in access_roles:
user_roles[access_role.user_id].append(access_role.role)
if access_role.user_id not in user_info:
user_info[access_role.user_id] = make_user_info_dict(access_role.user)

# Merge user role information with `user_info`
for user_id in user_info:
user_info[user_id]['roles'] = user_roles[user_id]

return user_info
11 changes: 11 additions & 0 deletions lms/djangoapps/course_api/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""
Course API custom exceptions
"""


class OverEnrollmentLimitException(Exception):
"""
Exception used by `get_course_members` to signal when a
course has more enrollments than the limit specified on
`settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT`.
"""
16 changes: 15 additions & 1 deletion lms/djangoapps/course_api/tests/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from datetime import datetime

from common.djangoapps.student.tests.factories import UserFactory
from common.djangoapps.student.tests.factories import UserFactory, CourseEnrollmentFactory, CourseAccessRoleFactory
from xmodule.modulestore.tests.factories import ToyCourseFactory

TEST_PASSWORD = 'edx'
Expand Down Expand Up @@ -41,3 +41,17 @@ def create_user(username, is_staff):
password=TEST_PASSWORD,
is_staff=is_staff
)

@staticmethod
def create_enrollment(**kwargs):
"""
Create a CourseEnrollment to use in tests.
"""
return CourseEnrollmentFactory(**kwargs)

@staticmethod
def create_courseaccessrole(**kwargs):
"""
Create a CourseAccessRole to use in tests.
"""
return CourseAccessRoleFactory(**kwargs)
90 changes: 89 additions & 1 deletion lms/djangoapps/course_api/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest
from django.contrib.auth.models import AnonymousUser
from django.http import Http404
from django.test import override_settings
from opaque_keys.edx.keys import CourseKey
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
Expand All @@ -19,7 +20,8 @@
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import ItemFactory, check_mongo_calls

from ..api import UNKNOWN_BLOCK_DISPLAY_NAME, course_detail, get_due_dates, list_courses
from ..api import UNKNOWN_BLOCK_DISPLAY_NAME, course_detail, get_due_dates, list_courses, get_course_members
from ..exceptions import OverEnrollmentLimitException
from .mixins import CourseApiFactoryMixin


Expand Down Expand Up @@ -305,3 +307,89 @@ def test_get_due_dates_error_fetching_block(self):
]
actual_due_dates = get_due_dates(request, self.course.id, self.staff_user)
assert expected_due_dates == actual_due_dates


class TestGetCourseMembers(CourseApiTestMixin, SharedModuleStoreTestCase):
"""
Test get_course_members function
"""
@classmethod
def setUpClass(cls):
super(TestGetCourseMembers, cls).setUpClass()
cls.course = cls.create_course()
cls.honor = cls.create_user('honor', is_staff=False)
cls.staff = cls.create_user('staff', is_staff=True)
cls.instructor = cls.create_user('instructor', is_staff=True)

# Attach honor to course with enrollment
cls.create_enrollment(user=cls.honor, course_id=cls.course.id)
# Attach instructor to course with both enrollment and course access role
cls.create_enrollment(user=cls.instructor, course_id=cls.course.id)
cls.create_courseaccessrole(user=cls.instructor, course_id=cls.course.id, role='instructor')
# Attach staff to course using only course access role
cls.create_courseaccessrole(user=cls.staff, course_id=cls.course.id, role='staff')

def test_get_course_members(self):
"""
Test all different possible filtering
"""
with self.assertNumQueries(3):
members = get_course_members(self.course.id)

self.assertEqual(len(members), 3)

# Check parameters for all users
expected_properties = ['id', 'username', 'email', 'name', 'enrollment_mode', 'roles']
for user_id in members:
self.assertCountEqual(members[user_id], expected_properties)

# Check that users have correct roles
# Honor should be only a student and have the enrollment mode set
self.assertEqual(members[self.honor.id]['roles'], ['student'])
self.assertEqual(members[self.honor.id]['enrollment_mode'], 'audit')
# Instructor should have both roles and enrollment_mode set
self.assertEqual(members[self.instructor.id]['roles'], ['student', 'instructor'])
self.assertEqual(members[self.instructor.id]['enrollment_mode'], 'audit')
# Staff should only have the staff role
self.assertEqual(members[self.staff.id]['roles'], ['staff'])
self.assertEqual(members[self.staff.id]['enrollment_mode'], None)

def test_same_result_with_csa_or_enrollment(self):
"""
Checks that the API returns the same result regardless if a user
comes from CourseAccessRoles or CourseEnrollments table.
"""
# Create new user
user = TestGetCourseMembers.create_user('test_use', is_staff=True)

# Attach with course enrollment
enrollment = TestGetCourseMembers.create_enrollment(
user=user,
course_id=self.course.id
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to test that the combining of data works properly, so I think it would be useful to have test cases for people who exist only in enrollments (students), people who exist in both (course staff, beta testing students), and people who exist only in access_roles (data_researcher).

members_enrollments = get_course_members(self.course.id)
enrollment.delete()

# Attach with course enrollment
enrollment = TestGetCourseMembers.create_courseaccessrole(
user=user,
course_id=self.course.id,
role='staff',
)
members_courseaccessroles = get_course_members(self.course.id)

# Check properties (except the ones that change depending on role)
for item in ['id', 'username', 'email', 'name']:
self.assertEqual(
members_courseaccessroles[user.id][item],
members_enrollments[user.id][item]
)

@override_settings(COURSE_MEMBER_API_ENROLLMENT_LIMIT=1)
def test_course_members_fails_overlimit(self):
"""
Check if trying to retrieve more than settings.COURSE_MEMBER_API_ENROLLMENT_LIMIT
fails.
"""
with self.assertRaises(OverEnrollmentLimitException):
get_course_members(self.course.id)
11 changes: 11 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3995,6 +3995,17 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
# If set to None, all courses will be listed on the homepage
HOMEPAGE_COURSE_MAX = None

# .. setting_name: COURSE_MEMBER_API_ENROLLMENT_LIMIT
# .. setting_implementation: DjangoSetting
# .. setting_default: 1000
# .. setting_description: This limits the response size of the `get_course_members` API, throwing an exception
# if the number of Enrolled users is greater than this number. This is needed to limit the dataset size
# since the API does most of the calculation in Python to avoid expensive database queries.
# .. setting_use_cases: open_edx
# .. setting_creation_date: 2021-05-18
# .. setting_tickets: https://openedx.atlassian.net/browse/TNL-7330
COURSE_MEMBER_API_ENROLLMENT_LIMIT = 1000

################################ Settings for Credit Courses ################################
# Initial delay used for retrying tasks.
# Additional retries use longer delays.
Expand Down