Description This is a follow up to #29058 and #29413. This is the next step in moving part of the modulestore data (the course indexes / "active versions" table) from MongoDB to MySQL. There are four steps planned in moving course index data to MySQL: Step 1: create the tables in MySQL, start writing to MySQL + MongoDB ✅ done Step 2: migrate all remaining courses to MySQL ✅ done Step 3: switch reads from MongoDB to MySQL (this PR) Step 4 (much later, once we know this is working well): stop writing to MongoDB altogether. Supporting information OpenCraft Jira ticket: MNG-2557 Status ✅ Tested with a large Open edX instance is in progress. Testing instructions Try making changes in Studio and verify that they work fine. Deadline None
228 lines
9.0 KiB
Python
228 lines
9.0 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 + 2 * OTHER):
|
|
# OTHER = user exists, completion exists
|
|
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):
|
|
_, 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):
|
|
_, 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
|