Files
edx-platform/openedx/tests/completion_integration/test_models.py
Braden MacDonald 96e5ff8dce feat: store split modulestore's course indexes in Django/MySQL
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.
2021-10-07 10:59:47 -04:00

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