From 6f89157d62ff945bc86c55326f9d70d57b41d07e Mon Sep 17 00:00:00 2001 From: J. Cliff Dyer <jcd@sdf.org> Date: Fri, 15 Sep 2017 11:06:14 -0400 Subject: [PATCH] Introduce BlockCompletion model. * Includes custom manager. * Includes percent validation. * Includes useful indices. * Subclasses TimeStampedModel OC-3086 --- lms/djangoapps/completion/__init__.py | 5 +++++ lms/djangoapps/completion/apps.py | 15 +++++++++++++++ lms/djangoapps/completion/migrations/0001_initial.py | 43 +++++++++++++++++++++++++++++++++++++++++++ lms/djangoapps/completion/migrations/__init__.py | 0 lms/djangoapps/completion/models.py | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lms/djangoapps/completion/tests/__init__.py | 0 lms/djangoapps/completion/tests/test_models.py | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lms/envs/common.py | 3 +++ 8 files changed, 303 insertions(+) create mode 100644 lms/djangoapps/completion/__init__.py create mode 100644 lms/djangoapps/completion/apps.py create mode 100644 lms/djangoapps/completion/migrations/0001_initial.py create mode 100644 lms/djangoapps/completion/migrations/__init__.py create mode 100644 lms/djangoapps/completion/models.py create mode 100644 lms/djangoapps/completion/tests/__init__.py create mode 100644 lms/djangoapps/completion/tests/test_models.py diff --git a/lms/djangoapps/completion/__init__.py b/lms/djangoapps/completion/__init__.py new file mode 100644 index 0000000..e6065f2 --- /dev/null +++ b/lms/djangoapps/completion/__init__.py @@ -0,0 +1,5 @@ +""" +Completion App +""" + +default_app_config = 'lms.djangoapps.completion.apps.CompletionAppConfig' diff --git a/lms/djangoapps/completion/apps.py b/lms/djangoapps/completion/apps.py new file mode 100644 index 0000000..c419c69 --- /dev/null +++ b/lms/djangoapps/completion/apps.py @@ -0,0 +1,15 @@ +""" +App Configuration for Completion +""" + +from __future__ import absolute_import, division, print_function, unicode_literals +from django.apps import AppConfig + + +class CompletionAppConfig(AppConfig): + """ + App Configuration for Completion + """ + name = 'lms.djangoapps.completion' + verbose_name = 'Completion' + pass diff --git a/lms/djangoapps/completion/migrations/0001_initial.py b/lms/djangoapps/completion/migrations/0001_initial.py new file mode 100644 index 0000000..7683f80 --- /dev/null +++ b/lms/djangoapps/completion/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import lms.djangoapps.completion.models +import django.utils.timezone +from django.conf import settings +import model_utils.fields +import openedx.core.djangoapps.xmodule_django.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BlockCompletion', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), + ('course_key', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(max_length=255)), + ('block_key', openedx.core.djangoapps.xmodule_django.models.UsageKeyField(max_length=255)), + ('block_type', models.CharField(max_length=64)), + ('completion', models.FloatField(validators=[lms.djangoapps.completion.models.validate_percent])), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterUniqueTogether( + name='blockcompletion', + unique_together=set([('course_key', 'block_key', 'user')]), + ), + migrations.AlterIndexTogether( + name='blockcompletion', + index_together=set([ + ('course_key', 'block_type', 'user'), + ('user', 'course_key', 'modified'), + ]), + ), + ] diff --git a/lms/djangoapps/completion/migrations/__init__.py b/lms/djangoapps/completion/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lms/djangoapps/completion/migrations/__init__.py diff --git a/lms/djangoapps/completion/models.py b/lms/djangoapps/completion/models.py new file mode 100644 index 0000000..a914879 --- /dev/null +++ b/lms/djangoapps/completion/models.py @@ -0,0 +1,133 @@ +""" +Completion tracking and aggregation models. +""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import ugettext as _ +from model_utils.models import TimeStampedModel +from opaque_keys.edx.keys import CourseKey + +from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, UsageKeyField + + +def validate_percent(value): + """ + Verify that the passed value is between 0.0 and 1.0. + """ + if not 0.0 <= value <= 1.0: + raise ValidationError(_('{value} must be between 0.0 and 1.0').format(value=value)) + + +class BlockCompletionManager(models.Manager): + """ + Custom manager for BlockCompletion model. + + Adds submit_completion method. + """ + + def submit_completion(self, user, course_key, block_key, completion): + """ + Update the completion value for the specified record. + + Parameters: + * user (django.contrib.auth.models.User): The user for whom the + completion is being submitted. + * course_key (opaque_keys.edx.keys.CourseKey): The course in + which the submitted block is found. + * block_key (opaque_keys.edx.keys.UsageKey): The block that has had + its completion changed. + * completion (float in range [0.0, 1.0]): The fractional completion + value of the block (0.0 = incomplete, 1.0 = complete). + + Return Value: + (BlockCompletion, bool): A tuple comprising the created or updated + BlockCompletion object and a boolean value indicating whether the value + + Raises: + + ValueError: + If the wrong type is passed for one of the parameters. + + django.core.exceptions.ValidationError: + If a float is passed that is not between 0.0 and 1.0. + + django.db.DatabaseError: + If there was a problem getting, creating, or updating the + BlockCompletion record in the database. + + This will also be a more specific error, as described here: + https://docs.djangoproject.com/en/1.11/ref/exceptions/#database-exceptions. + IntegrityError and OperationalError are relatively common + subclasses. + """ + + # Raise ValueError to match normal django semantics for wrong type of field. + if not isinstance(course_key, CourseKey): + raise ValueError( + "course_key must be an instance of `opaque_keys.edx.keys.CourseKey`. Got {}".format(type(course_key)) + ) + try: + block_type = block_key.block_type + except AttributeError: + raise ValueError( + "block_key must be an instance of `opaque_keys.edx.keys.UsageKey`. Got {}".format(type(block_key)) + ) + + obj, isnew = self.get_or_create( + user=user, + course_key=course_key, + block_type=block_type, + block_key=block_key, + defaults={'completion': completion}, + ) + if not isnew and obj.completion != completion: + obj.completion = completion + obj.full_clean() + obj.save() + return obj, isnew + + +class BlockCompletion(TimeStampedModel, models.Model): + """ + Track completion of completable blocks. + + A completion is unique for each (user, course_key, block_key). + + The block_type field is included separately from the block_key to + facilitate distinct aggregations of the completion of particular types of + block. + + The completion value is stored as a float in the range [0.0, 1.0], and all + calculations are performed on this float, though current practice is to + only track binary completion, where 1.0 indicates that the block is + complete, and 0.0 indicates that the block is incomplete. + """ + user = models.ForeignKey(User) + course_key = CourseKeyField(max_length=255) + block_key = UsageKeyField(max_length=255) + block_type = models.CharField(max_length=64) + completion = models.FloatField(validators=[validate_percent]) + + objects = BlockCompletionManager() + + class Meta(object): + index_together = [ + ('course_key', 'block_type', 'user'), + ('user', 'course_key', 'modified'), + ] + + unique_together = [ + ('course_key', 'block_key', 'user') + ] + + def __unicode__(self): + return 'BlockCompletion: {username}, {course_key}, {block_key}: {completion}'.format( + username=self.user.username, + course_key=self.course_key, + block_key=self.block_key, + completion=self.completion, + ) diff --git a/lms/djangoapps/completion/tests/__init__.py b/lms/djangoapps/completion/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lms/djangoapps/completion/tests/__init__.py diff --git a/lms/djangoapps/completion/tests/test_models.py b/lms/djangoapps/completion/tests/test_models.py new file mode 100644 index 0000000..6bc6101 --- /dev/null +++ b/lms/djangoapps/completion/tests/test_models.py @@ -0,0 +1,104 @@ +""" +Test models, managers, and validators. +""" + +from django.core.exceptions import ValidationError +from django.test import TestCase +from opaque_keys.edx.keys import UsageKey + +from student.tests.factories import UserFactory + +from .. import models + + +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 SubmitCompletionTestCase(TestCase): + """ + Test that BlockCompletion.objects.submit_completion has the desired + semantics. + """ + def setUp(self): + super(SubmitCompletionTestCase, self).setUp() + self.user = UserFactory() + self.block_key = UsageKey.from_string(u'block-v1:edx+test+run+type@video+block@doggos') + self.completion = models.BlockCompletion.objects.create( + user=self.user, + course_key=self.block_key.course_key, + block_type=self.block_key.block_type, + block_key=self.block_key, + completion=0.5, + ) + + def test_changed_value(self): + with self.assertNumQueries(4): # Get, update, 2 * savepoints + completion, isnew = models.BlockCompletion.objects.submit_completion( + user=self.user, + course_key=self.block_key.course_key, + block_key=self.block_key, + completion=0.9, + ) + completion.refresh_from_db() + self.assertEqual(completion.completion, 0.9) + self.assertFalse(isnew) + self.assertEqual(models.BlockCompletion.objects.count(), 1) + + def test_unchanged_value(self): + with self.assertNumQueries(1): # Get + completion, isnew = models.BlockCompletion.objects.submit_completion( + user=self.user, + course_key=self.block_key.course_key, + block_key=self.block_key, + completion=0.5, + ) + completion.refresh_from_db() + self.assertEqual(completion.completion, 0.5) + self.assertFalse(isnew) + self.assertEqual(models.BlockCompletion.objects.count(), 1) + + def test_new_user(self): + newuser = UserFactory() + with self.assertNumQueries(4): # Get, update, 2 * savepoints + _, isnew = models.BlockCompletion.objects.submit_completion( + user=newuser, + course_key=self.block_key.course_key, + block_key=self.block_key, + completion=0.0, + ) + self.assertTrue(isnew) + self.assertEqual(models.BlockCompletion.objects.count(), 2) + + def test_new_block(self): + newblock = UsageKey.from_string(u'block-v1:edx+test+run+type@video+block@puppers') + with self.assertNumQueries(4): # Get, update, 2 * savepoints + _, isnew = models.BlockCompletion.objects.submit_completion( + user=self.user, + course_key=newblock.course_key, + block_key=newblock, + completion=1.0, + ) + self.assertTrue(isnew) + self.assertEqual(models.BlockCompletion.objects.count(), 2) + + def test_invalid_completion(self): + with self.assertRaises(ValidationError): + models.BlockCompletion.objects.submit_completion( + user=self.user, + course_key=self.block_key.course_key, + block_key=self.block_key, + completion=1.2 + ) + completion = models.BlockCompletion.objects.get(user=self.user, block_key=self.block_key) + self.assertEqual(completion.completion, 0.5) + self.assertEqual(models.BlockCompletion.objects.count(), 1) diff --git a/lms/envs/common.py b/lms/envs/common.py index c7192da..1234b4f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2256,6 +2256,9 @@ INSTALLED_APPS = [ # Course Goals 'lms.djangoapps.course_goals', + # Completion + 'lms.djangoapps.completion.apps.CompletionAppConfig', + # Features 'openedx.features.course_bookmarks', 'openedx.features.course_experience', -- libgit2 0.26.0