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