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. 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.
228 lines
9.1 KiB
Python
228 lines
9.1 KiB
Python
"""
|
|
Test models, managers, and validators.
|
|
"""
|
|
|
|
import pytest
|
|
from completion import models
|
|
from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing
|
|
from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH
|
|
from django.core.exceptions import ValidationError
|
|
from django.test import TestCase
|
|
from edx_toggles.toggles.testutils import override_waffle_switch
|
|
from opaque_keys.edx.keys import CourseKey, UsageKey
|
|
|
|
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
|
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
|
|
|
SELECT = 1
|
|
UPDATE = 1
|
|
SAVEPOINT = 1
|
|
OTHER = 1
|
|
|
|
|
|
@skip_unless_lms
|
|
class PercentValidatorTestCase(TestCase):
|
|
"""
|
|
Test that validate_percent only allows floats (and ints) between 0.0 and 1.0.
|
|
"""
|
|
def test_valid_percents(self):
|
|
for value in [1.0, 0.0, 1, 0, 0.5, 0.333081348071397813987230871]:
|
|
models.validate_percent(value)
|
|
|
|
def test_invalid_percent(self):
|
|
for value in [-0.00000000001, 1.0000000001, 47.1, 1000, None, float('inf'), float('nan')]:
|
|
self.assertRaises(ValidationError, models.validate_percent, value)
|
|
|
|
|
|
class CompletionSetUpMixin(CompletionWaffleTestMixin):
|
|
"""
|
|
Mixin that provides helper to create test BlockCompletion object.
|
|
"""
|
|
def set_up_completion(self):
|
|
self.user = UserFactory()
|
|
self.block_key = UsageKey.from_string('block-v1:edx+test+run+type@video+block@doggos')
|
|
self.completion = models.BlockCompletion.objects.create(
|
|
user=self.user,
|
|
context_key=self.block_key.context_key,
|
|
block_type=self.block_key.block_type,
|
|
block_key=self.block_key,
|
|
completion=0.5,
|
|
)
|
|
|
|
|
|
@skip_unless_lms
|
|
class SubmitCompletionTestCase(CompletionSetUpMixin, TestCase):
|
|
"""
|
|
Test that BlockCompletion.objects.submit_completion has the desired
|
|
semantics.
|
|
"""
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.override_waffle_switch(True)
|
|
self.set_up_completion()
|
|
|
|
def test_changed_value(self):
|
|
with self.assertNumQueries(SELECT + UPDATE + 2 * SAVEPOINT + 4 * OTHER):
|
|
# OTHER = user exists, completion exists, 2x look up course in splitmodulestorecourseindex
|
|
completion, isnew = models.BlockCompletion.objects.submit_completion(
|
|
user=self.user,
|
|
block_key=self.block_key,
|
|
completion=0.9,
|
|
)
|
|
completion.refresh_from_db()
|
|
assert completion.completion == 0.9
|
|
assert not isnew
|
|
assert models.BlockCompletion.objects.count() == 1
|
|
|
|
def test_unchanged_value(self):
|
|
with self.assertNumQueries(SELECT + 2 * SAVEPOINT):
|
|
completion, isnew = models.BlockCompletion.objects.submit_completion(
|
|
user=self.user,
|
|
block_key=self.block_key,
|
|
completion=0.5,
|
|
)
|
|
completion.refresh_from_db()
|
|
assert completion.completion == 0.5
|
|
assert not isnew
|
|
assert models.BlockCompletion.objects.count() == 1
|
|
|
|
def test_new_user(self):
|
|
newuser = UserFactory()
|
|
with self.assertNumQueries(SELECT + UPDATE + 4 * SAVEPOINT + 2 * OTHER):
|
|
_, isnew = models.BlockCompletion.objects.submit_completion(
|
|
user=newuser,
|
|
block_key=self.block_key,
|
|
completion=0.0,
|
|
)
|
|
assert isnew
|
|
assert models.BlockCompletion.objects.count() == 2
|
|
|
|
def test_new_block(self):
|
|
newblock = UsageKey.from_string('block-v1:edx+test+run+type@video+block@puppers')
|
|
with self.assertNumQueries(SELECT + UPDATE + 4 * SAVEPOINT + 2 * OTHER):
|
|
_, isnew = models.BlockCompletion.objects.submit_completion(
|
|
user=self.user,
|
|
block_key=newblock,
|
|
completion=1.0,
|
|
)
|
|
assert isnew
|
|
assert models.BlockCompletion.objects.count() == 2
|
|
|
|
def test_invalid_completion(self):
|
|
with pytest.raises(ValidationError):
|
|
models.BlockCompletion.objects.submit_completion(
|
|
user=self.user,
|
|
block_key=self.block_key,
|
|
completion=1.2
|
|
)
|
|
completion = models.BlockCompletion.objects.get(user=self.user, block_key=self.block_key)
|
|
assert completion.completion == 0.5
|
|
assert models.BlockCompletion.objects.count() == 1
|
|
|
|
|
|
@skip_unless_lms
|
|
class CompletionDisabledTestCase(CompletionSetUpMixin, TestCase):
|
|
"""
|
|
Tests that completion API is not called when the feature is disabled.
|
|
"""
|
|
def setUp(self):
|
|
super().setUp()
|
|
# insert one completion record...
|
|
self.set_up_completion()
|
|
# ...then disable the feature.
|
|
self.override_waffle_switch(False)
|
|
|
|
def test_cannot_call_submit_completion(self):
|
|
assert models.BlockCompletion.objects.count() == 1
|
|
with pytest.raises(RuntimeError):
|
|
models.BlockCompletion.objects.submit_completion(
|
|
user=self.user,
|
|
block_key=self.block_key,
|
|
completion=0.9,
|
|
)
|
|
assert models.BlockCompletion.objects.count() == 1
|
|
|
|
|
|
@skip_unless_lms
|
|
class SubmitBatchCompletionTestCase(CompletionWaffleTestMixin, TestCase):
|
|
"""
|
|
Test that BlockCompletion.objects.submit_batch_completion has the desired
|
|
semantics.
|
|
"""
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.override_waffle_switch(True)
|
|
|
|
self.block_key = UsageKey.from_string('block-v1:edx+test+run+type@video+block@doggos')
|
|
self.course_key_obj = CourseKey.from_string('course-v1:edx+test+run')
|
|
self.user = UserFactory()
|
|
CourseEnrollmentFactory.create(user=self.user, course_id=str(self.course_key_obj))
|
|
|
|
def test_submit_batch_completion(self):
|
|
blocks = [(self.block_key, 1.0)]
|
|
models.BlockCompletion.objects.submit_batch_completion(self.user, blocks)
|
|
assert models.BlockCompletion.objects.count() == 1
|
|
assert models.BlockCompletion.objects.last().completion == 1.0
|
|
|
|
def test_submit_batch_completion_without_waffle(self):
|
|
with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, False):
|
|
with pytest.raises(RuntimeError):
|
|
blocks = [(self.block_key, 1.0)]
|
|
models.BlockCompletion.objects.submit_batch_completion(self.user, blocks)
|
|
|
|
def test_submit_batch_completion_with_same_block_new_completion_value(self):
|
|
blocks = [(self.block_key, 0.0)]
|
|
assert models.BlockCompletion.objects.count() == 0
|
|
models.BlockCompletion.objects.submit_batch_completion(self.user, blocks)
|
|
assert models.BlockCompletion.objects.count() == 1
|
|
model = models.BlockCompletion.objects.first()
|
|
assert model.completion == 0.0
|
|
blocks = [
|
|
(UsageKey.from_string('block-v1:edx+test+run+type@video+block@doggos'), 1.0),
|
|
]
|
|
models.BlockCompletion.objects.submit_batch_completion(self.user, blocks)
|
|
assert models.BlockCompletion.objects.count() == 1
|
|
model = models.BlockCompletion.objects.first()
|
|
assert model.completion == 1.0
|
|
|
|
|
|
@skip_unless_lms
|
|
class BatchCompletionMethodTests(CompletionWaffleTestMixin, TestCase):
|
|
"""
|
|
Tests for the classmethods that retrieve course/block completion data.
|
|
"""
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.override_waffle_switch(True)
|
|
|
|
self.user = UserFactory.create()
|
|
self.other_user = UserFactory.create()
|
|
self.course_key = CourseKey.from_string("edX/MOOC101/2049_T2")
|
|
self.other_course_key = CourseKey.from_string("course-v1:ReedX+Hum110+1904")
|
|
self.block_keys = [UsageKey.from_string(f"i4x://edX/MOOC101/video/{number}") for number in range(5)]
|
|
self.block_keys_with_runs = [key.replace(course_key=self.course_key) for key in self.block_keys]
|
|
self.other_course_block_keys = [self.other_course_key.make_usage_key('html', '1')]
|
|
|
|
# Submit completions for the main course:
|
|
submit_completions_for_testing(self.user, self.block_keys_with_runs[:3])
|
|
# Different user:
|
|
submit_completions_for_testing(self.other_user, self.block_keys_with_runs[2:])
|
|
# Different course:
|
|
submit_completions_for_testing(self.user, self.other_course_block_keys)
|
|
|
|
def test_get_learning_context_completions_missing_runs(self):
|
|
actual_completions = models.BlockCompletion.get_learning_context_completions(self.user, self.course_key)
|
|
expected_block_keys = self.block_keys_with_runs[:3]
|
|
expected_completions = dict(list(zip(expected_block_keys, [1.0, 0.8, 0.6])))
|
|
assert expected_completions == actual_completions
|
|
|
|
def test_get_learning_context_completions_empty_result_set(self):
|
|
assert models.BlockCompletion.get_learning_context_completions(self.other_user, self.other_course_key) == {}
|
|
|
|
def test_get_latest_block_completed(self):
|
|
assert models.BlockCompletion.get_latest_block_completed(self.user, self.course_key).block_key == \
|
|
self.block_keys[2]
|
|
|
|
def test_get_latest_completed_none_exist(self):
|
|
assert models.BlockCompletion.get_latest_block_completed(self.other_user, self.other_course_key) is None
|