Merge pull request #16047 from open-craft/cliff/block-completion-model
Block completion model
This commit is contained in:
5
lms/djangoapps/completion/__init__.py
Normal file
5
lms/djangoapps/completion/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Completion App
|
||||
"""
|
||||
|
||||
default_app_config = 'lms.djangoapps.completion.apps.CompletionAppConfig'
|
||||
14
lms/djangoapps/completion/apps.py
Normal file
14
lms/djangoapps/completion/apps.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
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'
|
||||
47
lms/djangoapps/completion/migrations/0001_initial.py
Normal file
47
lms/djangoapps/completion/migrations/0001_initial.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
import model_utils.fields
|
||||
|
||||
import lms.djangoapps.completion.models
|
||||
import openedx.core.djangoapps.xmodule_django.models
|
||||
|
||||
# pylint: disable=ungrouped-imports
|
||||
try:
|
||||
from django.models import BigAutoField # New in django 1.10
|
||||
except ImportError:
|
||||
from openedx.core.djangolib.fields import BigAutoField
|
||||
# pylint: enable=ungrouped-imports
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BlockCompletion',
|
||||
fields=[
|
||||
('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)),
|
||||
('id', BigAutoField(serialize=False, primary_key=True)),
|
||||
('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')]),
|
||||
),
|
||||
]
|
||||
0
lms/djangoapps/completion/migrations/__init__.py
Normal file
0
lms/djangoapps/completion/migrations/__init__.py
Normal file
141
lms/djangoapps/completion/models.py
Normal file
141
lms/djangoapps/completion/models.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# pylint: disable=ungrouped-imports
|
||||
try:
|
||||
from django.models import BigAutoField # New in django 1.10
|
||||
except ImportError:
|
||||
from openedx.core.djangolib.fields import BigAutoField
|
||||
# pylint: enable=ungrouped-imports
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
id = BigAutoField(primary_key=True) # pylint: disable=invalid-name
|
||||
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,
|
||||
)
|
||||
0
lms/djangoapps/completion/tests/__init__.py
Normal file
0
lms/djangoapps/completion/tests/__init__.py
Normal file
104
lms/djangoapps/completion/tests/test_models.py
Normal file
104
lms/djangoapps/completion/tests/test_models.py
Normal file
@@ -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)
|
||||
@@ -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',
|
||||
|
||||
@@ -5,7 +5,9 @@ from django.db import models
|
||||
|
||||
|
||||
class CharNullField(models.CharField):
|
||||
"""CharField that stores NULL but returns ''"""
|
||||
"""
|
||||
CharField that stores NULL but returns ''
|
||||
"""
|
||||
|
||||
description = "CharField that stores NULL but returns ''"
|
||||
|
||||
@@ -26,3 +28,31 @@ class CharNullField(models.CharField):
|
||||
return None
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
class BigAutoField(models.AutoField):
|
||||
"""
|
||||
AutoField that uses BigIntegers.
|
||||
|
||||
This exists in Django as of version 1.10.
|
||||
"""
|
||||
|
||||
def db_type(self, connection):
|
||||
"""
|
||||
The type of the field to insert into the database.
|
||||
"""
|
||||
conn_module = type(connection).__module__
|
||||
if "mysql" in conn_module:
|
||||
return "bigint AUTO_INCREMENT"
|
||||
elif "postgres" in conn_module:
|
||||
return "bigserial"
|
||||
else:
|
||||
return super(BigAutoField, self).db_type(connection)
|
||||
|
||||
def rel_db_type(self, connection):
|
||||
"""
|
||||
The type to be used by relations pointing to this field.
|
||||
|
||||
Not used until Django 1.10.
|
||||
"""
|
||||
return "bigint"
|
||||
|
||||
Reference in New Issue
Block a user