Split modulestore persists data in three MongoDB "collections": course_index (list of courses and the current version of each), structure (outline of the courses, and some XBlock fields), and definition (other XBlock fields). While "structure" and "definition" data can get very large, which is one of the reasons MongoDB was chosen for modulestore, the course index data is very small. This commit starts writing course indexes (active_versions) to both MySQL and Mongo, but continues to read from MongoDB only. By moving course index data to MySQL / a django model, we get these advantages: * Full history of changes to the course index data is now preserved * Includes a django admin view to inspect the list of courses and libraries * It's much easier to "reset" a corrupted course to a known working state, by using the simple-history revert tools from the django admin. * The remaining MongoDB collections (structure and definition) are essentially just used as key-value stores of large JSON data structures. This paves the way for future changes that allow migrating courses one at a time from MongoDB to S3, and thus eliminating any use of MongoDB by split modulestore, simplifying the stack.
637 lines
23 KiB
Python
637 lines
23 KiB
Python
"""
|
|
Tests for the Bulk Enrollment views.
|
|
"""
|
|
|
|
|
|
import json
|
|
|
|
import ddt
|
|
from django.conf import settings
|
|
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
|
from django.core import mail
|
|
from django.test.utils import override_settings
|
|
from django.urls import reverse
|
|
from opaque_keys.edx.keys import CourseKey
|
|
from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate
|
|
|
|
from common.djangoapps.student.models import ( # lint-amnesty, pylint: disable=line-too-long
|
|
ENROLLED_TO_UNENROLLED,
|
|
UNENROLLED_TO_ENROLLED,
|
|
CourseEnrollment,
|
|
ManualEnrollmentAudit
|
|
)
|
|
from common.djangoapps.student.tests.factories import UserFactory
|
|
from lms.djangoapps.bulk_enroll.serializers import BulkEnrollmentSerializer
|
|
from lms.djangoapps.bulk_enroll.views import BulkEnrollView
|
|
from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase
|
|
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_id
|
|
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
|
|
from openedx.core.djangoapps.site_configuration.helpers import get_value as get_site_value
|
|
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
|
from xmodule.modulestore.tests.factories import CourseFactory
|
|
|
|
|
|
@override_settings(ENABLE_BULK_ENROLLMENT_VIEW=True)
|
|
@ddt.ddt
|
|
class BulkEnrollmentTest(ModuleStoreTestCase, LoginEnrollmentTestCase, APITestCase):
|
|
"""
|
|
Test the bulk enrollment endpoint
|
|
"""
|
|
|
|
USERNAME = "Bob"
|
|
EMAIL = "bob@example.com"
|
|
PASSWORD = "edx"
|
|
|
|
def setUp(self):
|
|
""" Create a course and user, then log in. """
|
|
super().setUp()
|
|
|
|
self.view = BulkEnrollView.as_view()
|
|
self.request_factory = APIRequestFactory()
|
|
self.url = reverse('bulk_enroll')
|
|
|
|
self.staff = UserFactory.create(
|
|
username=self.USERNAME,
|
|
email=self.EMAIL,
|
|
password=self.PASSWORD,
|
|
is_staff=True,
|
|
)
|
|
|
|
self.course = CourseFactory.create()
|
|
self.course_key = str(self.course.id)
|
|
self.enrolled_student = UserFactory(username='EnrolledStudent', first_name='Enrolled', last_name='Student')
|
|
CourseEnrollment.enroll(
|
|
self.enrolled_student,
|
|
self.course.id
|
|
)
|
|
self.notenrolled_student = UserFactory(username='NotEnrolledStudent', first_name='NotEnrolled',
|
|
last_name='Student')
|
|
|
|
# Email URL values
|
|
self.site_name = get_site_value(
|
|
'SITE_NAME',
|
|
settings.SITE_NAME
|
|
)
|
|
self.about_path = f'/courses/{self.course.id}/about'
|
|
self.course_path = f'/courses/{self.course.id}/'
|
|
|
|
def request_bulk_enroll(self, data=None, use_json=False, **extra):
|
|
""" Make an authenticated request to the bulk enrollment API. """
|
|
content_type = None
|
|
if use_json:
|
|
content_type = 'application/json'
|
|
data = json.dumps(data)
|
|
request = self.request_factory.post(self.url, data=data, content_type=content_type, **extra)
|
|
force_authenticate(request, user=self.staff)
|
|
response = self.view(request)
|
|
response.render()
|
|
return response
|
|
|
|
def test_course_list_serializer(self):
|
|
"""
|
|
Test that the course serializer will work when passed a string or list.
|
|
|
|
Internally, DRF passes the data into the value conversion method as a list instead of
|
|
a string, so StringListField needs to work with both.
|
|
"""
|
|
for key in [self.course_key, [self.course_key]]:
|
|
serializer = BulkEnrollmentSerializer(data={
|
|
'identifiers': 'percivaloctavius',
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'courses': key,
|
|
})
|
|
assert serializer.is_valid()
|
|
|
|
def test_non_staff(self):
|
|
""" Test that non global staff users are forbidden from API use. """
|
|
self.staff.is_staff = False
|
|
self.staff.save()
|
|
with self.allow_transaction_exception():
|
|
response = self.request_bulk_enroll()
|
|
assert response.status_code == 403
|
|
|
|
def test_missing_params(self):
|
|
""" Test the response when missing all query parameters. """
|
|
response = self.request_bulk_enroll()
|
|
assert response.status_code == 400
|
|
|
|
def test_bad_action(self):
|
|
""" Test the response given an invalid action """
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': self.enrolled_student.email,
|
|
'action': 'invalid-action',
|
|
'courses': self.course_key,
|
|
})
|
|
assert response.status_code == 400
|
|
|
|
def test_invalid_email(self):
|
|
""" Test the response given an invalid email. """
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': 'percivaloctavius@',
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'courses': self.course_key,
|
|
})
|
|
assert response.status_code == 200
|
|
|
|
# test the response data
|
|
expected = {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
'email_students': False,
|
|
"courses": {
|
|
self.course_key: {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"results": [
|
|
{
|
|
"identifier": 'percivaloctavius@',
|
|
"invalidIdentifier": True,
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
res_json = json.loads(response.content.decode('utf-8'))
|
|
assert res_json == expected
|
|
|
|
def test_invalid_username(self):
|
|
""" Test the response given an invalid username. """
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': 'percivaloctavius',
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'courses': self.course_key,
|
|
})
|
|
assert response.status_code == 200
|
|
|
|
# test the response data
|
|
expected = {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
'email_students': False,
|
|
"courses": {
|
|
self.course_key: {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"results": [
|
|
{
|
|
"identifier": 'percivaloctavius',
|
|
"invalidIdentifier": True,
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
res_json = json.loads(response.content.decode('utf-8'))
|
|
assert res_json == expected
|
|
|
|
def test_enroll_with_username(self):
|
|
""" Test enrolling using a username as the identifier. """
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': self.notenrolled_student.username,
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'courses': self.course_key,
|
|
})
|
|
assert response.status_code == 200
|
|
|
|
# test the response data
|
|
expected = {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"email_students": False,
|
|
"courses": {
|
|
self.course_key: {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"results": [
|
|
{
|
|
"identifier": self.notenrolled_student.username,
|
|
"before": {
|
|
"enrollment": False,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
},
|
|
"after": {
|
|
"enrollment": True,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
manual_enrollments = ManualEnrollmentAudit.objects.all()
|
|
assert manual_enrollments.count() == 1
|
|
assert manual_enrollments[0].state_transition == UNENROLLED_TO_ENROLLED
|
|
res_json = json.loads(response.content.decode('utf-8'))
|
|
assert res_json == expected
|
|
|
|
@ddt.data(False, True)
|
|
def test_enroll_with_email(self, use_json):
|
|
""" Test enrolling using a username as the identifier. """
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': self.notenrolled_student.email,
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'courses': self.course_key,
|
|
}, use_json=use_json)
|
|
assert response.status_code == 200
|
|
|
|
# test that the user is now enrolled
|
|
user = User.objects.get(email=self.notenrolled_student.email)
|
|
assert CourseEnrollment.is_enrolled(user, self.course.id)
|
|
|
|
# test the response data
|
|
expected = {
|
|
"action": "enroll",
|
|
"auto_enroll": False,
|
|
"email_students": False,
|
|
"courses": {
|
|
self.course_key: {
|
|
"action": "enroll",
|
|
"auto_enroll": False,
|
|
"results": [
|
|
{
|
|
"identifier": self.notenrolled_student.email,
|
|
"before": {
|
|
"enrollment": False,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
},
|
|
"after": {
|
|
"enrollment": True,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
manual_enrollments = ManualEnrollmentAudit.objects.all()
|
|
assert manual_enrollments.count() == 1
|
|
assert manual_enrollments[0].state_transition == UNENROLLED_TO_ENROLLED
|
|
res_json = json.loads(response.content.decode('utf-8'))
|
|
assert res_json == expected
|
|
|
|
# Check the outbox
|
|
assert len(mail.outbox) == 0
|
|
|
|
@ddt.data(False, True)
|
|
def test_unenroll(self, use_json):
|
|
""" Test unenrolling a user. """
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': self.enrolled_student.email,
|
|
'action': 'unenroll',
|
|
'email_students': False,
|
|
'courses': self.course_key,
|
|
}, use_json=use_json)
|
|
assert response.status_code == 200
|
|
|
|
# test that the user is now unenrolled
|
|
user = User.objects.get(email=self.enrolled_student.email)
|
|
assert not CourseEnrollment.is_enrolled(user, self.course.id)
|
|
|
|
# test the response data
|
|
expected = {
|
|
"action": "unenroll",
|
|
"auto_enroll": False,
|
|
"email_students": False,
|
|
"courses": {
|
|
self.course_key: {
|
|
"action": "unenroll",
|
|
"auto_enroll": False,
|
|
"results": [
|
|
{
|
|
"identifier": self.enrolled_student.email,
|
|
"before": {
|
|
"enrollment": True,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
},
|
|
"after": {
|
|
"enrollment": False,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
manual_enrollments = ManualEnrollmentAudit.objects.all()
|
|
assert manual_enrollments.count() == 1
|
|
assert manual_enrollments[0].state_transition == ENROLLED_TO_UNENROLLED
|
|
res_json = json.loads(response.content.decode('utf-8'))
|
|
assert res_json == expected
|
|
|
|
# Check the outbox
|
|
assert len(mail.outbox) == 0
|
|
|
|
def test_fail_on_unequal_cohorts(self):
|
|
"""
|
|
Test unequal items in cohorts and courses.
|
|
"""
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': self.notenrolled_student.username,
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'courses': self.course_key,
|
|
'cohorts': "cohort1,cohort2"
|
|
})
|
|
self.assertContains(
|
|
response,
|
|
'If provided, the cohorts and courses should have equal number of items.',
|
|
status_code=400,
|
|
)
|
|
|
|
def test_fail_on_missing_cohorts(self):
|
|
"""
|
|
Test cohorts don't exist in the course.
|
|
"""
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': self.notenrolled_student.username,
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'cohorts': 'cohort1',
|
|
'courses': self.course_key
|
|
})
|
|
self.assertContains(
|
|
response,
|
|
'cohort {cohort_name} not found in course {course_id}.'.format(
|
|
cohort_name='cohort1', course_id=self.course_key
|
|
),
|
|
status_code=400,
|
|
)
|
|
|
|
def test_allow_cohorts_when_enrolling(self):
|
|
"""
|
|
Test if the cohorts are given but the action is unenroll.
|
|
"""
|
|
config_course_cohorts(self.course, is_cohorted=True, manual_cohorts=["cohort1", "cohort2"])
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': self.notenrolled_student.username,
|
|
'action': 'unenroll',
|
|
'email_students': False,
|
|
'cohorts': 'cohort1',
|
|
'courses': self.course_key
|
|
})
|
|
self.assertContains(response, 'Cohorts can only be used for enrollments.', status_code=400)
|
|
|
|
def test_add_to_valid_cohort(self):
|
|
config_course_cohorts(self.course, is_cohorted=True, manual_cohorts=["cohort1", "cohort2"])
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': self.notenrolled_student.username,
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'courses': self.course_key,
|
|
'cohorts': "cohort1"
|
|
})
|
|
|
|
assert response.status_code == 200
|
|
|
|
# test the response data
|
|
expected = {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"email_students": False,
|
|
"courses": {
|
|
self.course_key: {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"results": [
|
|
{
|
|
"identifier": self.notenrolled_student.username,
|
|
"before": {
|
|
"enrollment": False,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
"cohort": None,
|
|
},
|
|
"after": {
|
|
"enrollment": True,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
"cohort": 'cohort1',
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
manual_enrollments = ManualEnrollmentAudit.objects.all()
|
|
assert manual_enrollments.count() == 1
|
|
assert manual_enrollments[0].state_transition == UNENROLLED_TO_ENROLLED
|
|
res_json = json.loads(response.content.decode('utf-8'))
|
|
assert get_cohort_id(self.notenrolled_student, CourseKey.from_string(self.course_key)) is not None
|
|
|
|
assert res_json == expected
|
|
|
|
def test_readd_to_different_cohort(self):
|
|
config_course_cohorts(self.course, is_cohorted=True, manual_cohorts=["cohort1", "cohort2"])
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': self.notenrolled_student.username,
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'courses': self.course_key,
|
|
'cohorts': "cohort1"
|
|
})
|
|
|
|
assert response.status_code == 200
|
|
|
|
# test the response data
|
|
expected = {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"email_students": False,
|
|
"courses": {
|
|
self.course_key: {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"results": [
|
|
{
|
|
"identifier": self.notenrolled_student.username,
|
|
"before": {
|
|
"enrollment": False,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
"cohort": None,
|
|
},
|
|
"after": {
|
|
"enrollment": True,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
"cohort": 'cohort1',
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
manual_enrollments = ManualEnrollmentAudit.objects.all()
|
|
assert manual_enrollments.count() == 1
|
|
assert manual_enrollments[0].state_transition == UNENROLLED_TO_ENROLLED
|
|
res_json = json.loads(response.content.decode('utf-8'))
|
|
assert get_cohort_id(self.notenrolled_student, CourseKey.from_string(self.course_key)) is not None
|
|
assert res_json == expected
|
|
|
|
response2 = self.request_bulk_enroll({
|
|
'identifiers': self.notenrolled_student.username,
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'courses': self.course_key,
|
|
'cohorts': "cohort2"
|
|
})
|
|
|
|
assert response2.status_code == 200
|
|
|
|
# test the response data
|
|
expected2 = {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"email_students": False,
|
|
"courses": {
|
|
self.course_key: {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"results": [
|
|
{
|
|
"identifier": self.notenrolled_student.username,
|
|
"before": {
|
|
"enrollment": True,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
"cohort": 'cohort1',
|
|
},
|
|
"after": {
|
|
"enrollment": True,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
"cohort": 'cohort2',
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
res2_json = json.loads(response2.content.decode('utf-8'))
|
|
assert get_cohort_id(self.notenrolled_student, CourseKey.from_string(self.course_key)) is not None
|
|
assert res2_json == expected2
|
|
|
|
def test_readd_to_same_cohort(self):
|
|
config_course_cohorts(self.course, is_cohorted=True, manual_cohorts=["cohort1", "cohort2"])
|
|
response = self.request_bulk_enroll({
|
|
'identifiers': self.notenrolled_student.username,
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'courses': self.course_key,
|
|
'cohorts': "cohort1"
|
|
})
|
|
|
|
assert response.status_code == 200
|
|
|
|
# test the response data
|
|
expected = {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"email_students": False,
|
|
"courses": {
|
|
self.course_key: {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"results": [
|
|
{
|
|
"identifier": self.notenrolled_student.username,
|
|
"before": {
|
|
"enrollment": False,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
"cohort": None,
|
|
},
|
|
"after": {
|
|
"enrollment": True,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
"cohort": 'cohort1',
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
manual_enrollments = ManualEnrollmentAudit.objects.all()
|
|
assert manual_enrollments.count() == 1
|
|
assert manual_enrollments[0].state_transition == UNENROLLED_TO_ENROLLED
|
|
res_json = json.loads(response.content.decode('utf-8'))
|
|
assert get_cohort_id(self.notenrolled_student, CourseKey.from_string(self.course_key)) is not None
|
|
|
|
assert res_json == expected
|
|
|
|
response2 = self.request_bulk_enroll({
|
|
'identifiers': self.notenrolled_student.username,
|
|
'action': 'enroll',
|
|
'email_students': False,
|
|
'courses': self.course_key,
|
|
'cohorts': "cohort1"
|
|
})
|
|
|
|
assert response2.status_code == 200
|
|
|
|
# test the response data
|
|
expected2 = {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"email_students": False,
|
|
"courses": {
|
|
self.course_key: {
|
|
"action": "enroll",
|
|
'auto_enroll': False,
|
|
"results": [
|
|
{
|
|
"identifier": self.notenrolled_student.username,
|
|
"before": {
|
|
"enrollment": True,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
"cohort": 'cohort1',
|
|
},
|
|
"after": {
|
|
"enrollment": True,
|
|
"auto_enroll": False,
|
|
"user": True,
|
|
"allowed": False,
|
|
"cohort": 'cohort1',
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
res2_json = json.loads(response2.content.decode('utf-8'))
|
|
assert get_cohort_id(self.notenrolled_student, CourseKey.from_string(self.course_key)) is not None
|
|
assert res2_json == expected2
|