Get edx-platform working with external completion lib, add integration tests.
This commit is contained in:
committed by
Alex Dusenbery
parent
88130b67bc
commit
7e51f02884
@@ -1082,7 +1082,7 @@ INSTALLED_APPS = [
|
||||
'courseware',
|
||||
'survey',
|
||||
'lms.djangoapps.verify_student.apps.VerifyStudentConfig',
|
||||
'lms.djangoapps.completion.apps.CompletionAppConfig',
|
||||
'completion',
|
||||
|
||||
# Microsite configuration application
|
||||
'microsite_configuration',
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""
|
||||
Completion App
|
||||
"""
|
||||
|
||||
default_app_config = 'lms.djangoapps.completion.apps.CompletionAppConfig'
|
||||
@@ -1,8 +0,0 @@
|
||||
"""
|
||||
Api URLs.
|
||||
"""
|
||||
from django.conf.urls import include, url
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^v1/', include('lms.djangoapps.completion.api.v1.urls', namespace='v1')),
|
||||
]
|
||||
@@ -1,10 +0,0 @@
|
||||
"""
|
||||
API v1 URLs.
|
||||
"""
|
||||
from django.conf.urls import include, url
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^completion-batch', views.CompletionBatchView.as_view(), name='completion-batch'),
|
||||
]
|
||||
@@ -1,137 +0,0 @@
|
||||
""" API v1 views. """
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError, ObjectDoesNotExist
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.db import DatabaseError
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import permissions
|
||||
from rest_framework import status
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
from six import text_type
|
||||
|
||||
from lms.djangoapps.completion.models import BlockCompletion
|
||||
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
|
||||
from openedx.core.lib.api.permissions import IsStaffOrOwner
|
||||
from student.models import CourseEnrollment
|
||||
from completion import waffle
|
||||
|
||||
|
||||
class CompletionBatchView(APIView):
|
||||
"""
|
||||
Handles API requests to submit batch completions.
|
||||
"""
|
||||
permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner,)
|
||||
REQUIRED_KEYS = ['username', 'course_key', 'blocks']
|
||||
|
||||
def _validate_and_parse(self, batch_object):
|
||||
"""
|
||||
Performs validation on the batch object to make sure it is in the proper format.
|
||||
|
||||
Parameters:
|
||||
* batch_object: The data provided to a POST. The expected format is the following:
|
||||
{
|
||||
"username": "username",
|
||||
"course_key": "course-key",
|
||||
"blocks": {
|
||||
"block_key1": 0.0,
|
||||
"block_key2": 1.0,
|
||||
"block_key3": 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Return Value:
|
||||
* tuple: (User, CourseKey, List of tuples (UsageKey, completion_float)
|
||||
|
||||
Raises:
|
||||
|
||||
django.core.exceptions.ValidationError:
|
||||
If any aspect of validation fails a ValidationError is raised.
|
||||
|
||||
ObjectDoesNotExist:
|
||||
If a database object cannot be found an ObjectDoesNotExist is raised.
|
||||
"""
|
||||
if not waffle.waffle().is_enabled(waffle.ENABLE_COMPLETION_TRACKING):
|
||||
raise ValidationError(
|
||||
_("BlockCompletion.objects.submit_batch_completion should not be called when the feature is disabled.")
|
||||
)
|
||||
|
||||
for key in self.REQUIRED_KEYS:
|
||||
if key not in batch_object:
|
||||
raise ValidationError(_("Key '{key}' not found.".format(key=key)))
|
||||
|
||||
username = batch_object['username']
|
||||
user = User.objects.get(username=username)
|
||||
|
||||
course_key = batch_object['course_key']
|
||||
try:
|
||||
course_key_obj = CourseKey.from_string(course_key)
|
||||
except InvalidKeyError:
|
||||
raise ValidationError(_("Invalid course key: {}").format(course_key))
|
||||
course_structure = CourseStructure.objects.get(course_id=course_key_obj)
|
||||
|
||||
if not CourseEnrollment.is_enrolled(user, course_key_obj):
|
||||
raise ValidationError(_('User is not enrolled in course.'))
|
||||
|
||||
blocks = batch_object['blocks']
|
||||
block_objs = []
|
||||
for block_key in blocks:
|
||||
if block_key not in course_structure.structure['blocks'].keys():
|
||||
raise ValidationError(_("Block with key: '{key}' is not in course {course}")
|
||||
.format(key=block_key, course=course_key))
|
||||
|
||||
block_key_obj = UsageKey.from_string(block_key)
|
||||
completion = float(blocks[block_key])
|
||||
block_objs.append((block_key_obj, completion))
|
||||
|
||||
return user, course_key_obj, block_objs
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Inserts a batch of completions.
|
||||
|
||||
REST Endpoint Format:
|
||||
{
|
||||
"username": "username",
|
||||
"course_key": "course-key",
|
||||
"blocks": {
|
||||
"block_key1": 0.0,
|
||||
"block_key2": 1.0,
|
||||
"block_key3": 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
**Returns**
|
||||
|
||||
A Response object, with an appropriate status code.
|
||||
|
||||
If successful, status code is 200.
|
||||
{
|
||||
"detail" : _("ok")
|
||||
}
|
||||
|
||||
Otherwise, a 400 or 404 may be returned, and the "detail" content will explain the error.
|
||||
|
||||
"""
|
||||
batch_object = request.data or {}
|
||||
try:
|
||||
user, course_key, blocks = self._validate_and_parse(batch_object)
|
||||
BlockCompletion.objects.submit_batch_completion(user, course_key, blocks)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return Response({
|
||||
"detail": exc.message,
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except ObjectDoesNotExist as exc:
|
||||
return Response({
|
||||
"detail": text_type(exc),
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
except DatabaseError as exc:
|
||||
return Response({
|
||||
"detail": text_type(exc),
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
return Response({"detail": _("ok")}, status=status.HTTP_200_OK)
|
||||
@@ -1,17 +0,0 @@
|
||||
"""
|
||||
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'
|
||||
|
||||
def ready(self):
|
||||
from . import handlers # pylint: disable=unused-variable
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
Signal handlers to trigger completion updates.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.dispatch import receiver
|
||||
|
||||
from lms.djangoapps.grades.signals.signals import PROBLEM_WEIGHTED_SCORE_CHANGED
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from xblock.completable import XBlockCompletionMode
|
||||
from xblock.core import XBlock
|
||||
|
||||
from .models import BlockCompletion
|
||||
from . import waffle
|
||||
|
||||
|
||||
@receiver(PROBLEM_WEIGHTED_SCORE_CHANGED)
|
||||
def scorable_block_completion(sender, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
When a problem is scored, submit a new BlockCompletion for that block.
|
||||
"""
|
||||
if not waffle.waffle().is_enabled(waffle.ENABLE_COMPLETION_TRACKING):
|
||||
return
|
||||
course_key = CourseKey.from_string(kwargs['course_id'])
|
||||
block_key = UsageKey.from_string(kwargs['usage_id'])
|
||||
block_cls = XBlock.load_class(block_key.block_type)
|
||||
if getattr(block_cls, 'completion_mode', XBlockCompletionMode.COMPLETABLE) != XBlockCompletionMode.COMPLETABLE:
|
||||
return
|
||||
if getattr(block_cls, 'has_custom_completion', False):
|
||||
return
|
||||
user = User.objects.get(id=kwargs['user_id'])
|
||||
if kwargs.get('score_deleted'):
|
||||
completion = 0.0
|
||||
else:
|
||||
completion = 1.0
|
||||
BlockCompletion.objects.submit_completion(
|
||||
user=user,
|
||||
course_key=course_key,
|
||||
block_key=block_key,
|
||||
completion=completion,
|
||||
)
|
||||
@@ -1,47 +0,0 @@
|
||||
# -*- 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
|
||||
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
|
||||
|
||||
import lms.djangoapps.completion.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', CourseKeyField(max_length=255)),
|
||||
('block_key', 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')]),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('completion', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='blockcompletion',
|
||||
options={'get_latest_by': 'modified'},
|
||||
),
|
||||
]
|
||||
@@ -1,233 +0,0 @@
|
||||
"""
|
||||
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, transaction
|
||||
from django.utils.translation import ugettext as _
|
||||
from model_utils.models import TimeStampedModel
|
||||
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from . import waffle
|
||||
|
||||
# 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 and submit_batch_completion methods.
|
||||
"""
|
||||
|
||||
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
|
||||
object was newly created by this call.
|
||||
|
||||
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))
|
||||
)
|
||||
|
||||
if waffle.waffle().is_enabled(waffle.ENABLE_COMPLETION_TRACKING):
|
||||
obj, is_new = self.get_or_create(
|
||||
user=user,
|
||||
course_key=course_key,
|
||||
block_type=block_type,
|
||||
block_key=block_key,
|
||||
defaults={'completion': completion},
|
||||
)
|
||||
if not is_new and obj.completion != completion:
|
||||
obj.completion = completion
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
else:
|
||||
# If the feature is not enabled, this method should not be called. Error out with a RuntimeError.
|
||||
raise RuntimeError(
|
||||
"BlockCompletion.objects.submit_completion should not be called when the feature is disabled."
|
||||
)
|
||||
return obj, is_new
|
||||
|
||||
@transaction.atomic()
|
||||
def submit_batch_completion(self, user, course_key, blocks):
|
||||
"""
|
||||
Performs a batch insertion of completion objects.
|
||||
|
||||
Parameters:
|
||||
* user (django.contrib.auth.models.User): The user for whom the
|
||||
completions are being submitted.
|
||||
* course_key (opaque_keys.edx.keys.CourseKey): The course in
|
||||
which the submitted blocks are found.
|
||||
* blocks: A list of tuples of UsageKey to float completion values.
|
||||
(float in range [0.0, 1.0]): The fractional completion
|
||||
value of the block (0.0 = incomplete, 1.0 = complete).
|
||||
|
||||
Return Value:
|
||||
Dict of (BlockCompletion, bool): A dictionary with a
|
||||
BlockCompletion object key and a value of bool. The boolean value
|
||||
indicates whether the object was newly created by this call.
|
||||
|
||||
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.
|
||||
"""
|
||||
block_completions = {}
|
||||
for block, completion in blocks:
|
||||
(block_completion, is_new) = self.submit_completion(user, course_key, block, completion)
|
||||
block_completions[block_completion] = is_new
|
||||
return block_completions
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# note: this usage key may not have the run filled in for
|
||||
# old mongo courses. Use the full_block_key property
|
||||
# instead when you want to use/compare the usage_key.
|
||||
block_key = UsageKeyField(max_length=255)
|
||||
block_type = models.CharField(max_length=64)
|
||||
completion = models.FloatField(validators=[validate_percent])
|
||||
|
||||
objects = BlockCompletionManager()
|
||||
|
||||
@property
|
||||
def full_block_key(self):
|
||||
"""
|
||||
Returns the "correct" usage key value with the run filled in.
|
||||
"""
|
||||
if self.block_key.run is None:
|
||||
return self.block_key.replace(course_key=self.course_key) # pylint: disable=unexpected-keyword-arg, no-value-for-parameter
|
||||
else:
|
||||
return self.block_key
|
||||
|
||||
@classmethod
|
||||
def get_course_completions(cls, user, course_key):
|
||||
"""
|
||||
query all completions for course/user pair
|
||||
|
||||
Return value:
|
||||
dict[BlockKey] = float
|
||||
"""
|
||||
course_block_completions = cls.objects.filter(
|
||||
user=user,
|
||||
course_key=course_key,
|
||||
)
|
||||
# will not return if <= 0.0
|
||||
return {completion.block_key: completion.completion for completion in course_block_completions}
|
||||
|
||||
@classmethod
|
||||
def get_latest_block_completed(cls, user, course_key):
|
||||
"""
|
||||
query latest completion for course/user pair
|
||||
|
||||
Return value:
|
||||
obj: block completion
|
||||
"""
|
||||
try:
|
||||
latest_modified_block_completion = cls.objects.filter(
|
||||
user=user,
|
||||
course_key=course_key,
|
||||
).latest()
|
||||
except cls.DoesNotExist:
|
||||
return
|
||||
return latest_modified_block_completion
|
||||
|
||||
class Meta(object):
|
||||
index_together = [
|
||||
('course_key', 'block_type', 'user'),
|
||||
('user', 'course_key', 'modified'),
|
||||
]
|
||||
|
||||
unique_together = [
|
||||
('course_key', 'block_key', 'user')
|
||||
]
|
||||
get_latest_by = 'modified'
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -1,99 +0,0 @@
|
||||
"""
|
||||
Runtime service for communicating completion information to the xblock system.
|
||||
"""
|
||||
|
||||
from .models import BlockCompletion
|
||||
from . import waffle
|
||||
|
||||
|
||||
class CompletionService(object):
|
||||
"""
|
||||
Service for handling completions for a user within a course.
|
||||
|
||||
Exposes
|
||||
|
||||
* self.completion_tracking_enabled() -> bool
|
||||
* self.visual_progress_enabled() -> bool
|
||||
* self.get_completions(candidates)
|
||||
* self.vertical_is_complete(vertical_item)
|
||||
|
||||
Constructor takes a user object and course_key as arguments.
|
||||
"""
|
||||
def __init__(self, user, course_key):
|
||||
self._user = user
|
||||
self._course_key = course_key
|
||||
|
||||
def completion_tracking_enabled(self):
|
||||
"""
|
||||
Exposes ENABLE_COMPLETION_TRACKING waffle switch to XModule runtime
|
||||
|
||||
Return value:
|
||||
|
||||
bool -> True if completion tracking is enabled.
|
||||
"""
|
||||
return waffle.waffle().is_enabled(waffle.ENABLE_COMPLETION_TRACKING)
|
||||
|
||||
def visual_progress_enabled(self):
|
||||
"""
|
||||
Exposes VISUAL_PROGRESS_ENABLED waffle switch to XModule runtime
|
||||
|
||||
Return value:
|
||||
|
||||
bool -> True if VISUAL_PROGRESS flag is enabled.
|
||||
"""
|
||||
return waffle.visual_progress_enabled(self._course_key)
|
||||
|
||||
def get_completions(self, candidates):
|
||||
"""
|
||||
Given an iterable collection of block_keys in the course, returns a
|
||||
mapping of the block_keys to the present completion values of their
|
||||
associated blocks.
|
||||
|
||||
If a completion is not found for a given block in the current course,
|
||||
0.0 is returned. The service does not attempt to verify that the block
|
||||
exists within the course.
|
||||
|
||||
Parameters:
|
||||
|
||||
candidates: collection of BlockKeys within the current course.
|
||||
|
||||
Return value:
|
||||
|
||||
dict[BlockKey] -> float: Mapping blocks to their completion value.
|
||||
"""
|
||||
completion_queryset = BlockCompletion.objects.filter(
|
||||
user=self._user,
|
||||
course_key=self._course_key,
|
||||
block_key__in=candidates,
|
||||
)
|
||||
completions = {
|
||||
block.full_block_key: block.completion for block in completion_queryset # pylint: disable=not-an-iterable
|
||||
}
|
||||
for candidate in candidates:
|
||||
if candidate not in completions:
|
||||
completions[candidate] = 0.0
|
||||
return completions
|
||||
|
||||
def vertical_is_complete(self, item):
|
||||
"""
|
||||
Calculates and returns whether a particular vertical is complete.
|
||||
The logic in this method is temporary, and will go away once the
|
||||
completion API is able to store a first-order notion of completeness
|
||||
for parent blocks (right now it just stores completion for leaves-
|
||||
problems, HTML, video, etc.).
|
||||
"""
|
||||
if item.location.block_type != 'vertical':
|
||||
raise ValueError('The passed in xblock is not a vertical type!')
|
||||
|
||||
if not self.completion_tracking_enabled():
|
||||
return None
|
||||
|
||||
# this is temporary local logic and will be removed when the whole course tree is included in completion
|
||||
child_locations = [
|
||||
child.location for child in item.get_children() if child.location.block_type != 'discussion'
|
||||
]
|
||||
completions = self.get_completions(child_locations)
|
||||
for child_location in child_locations:
|
||||
if completions[child_location] < 1.0:
|
||||
return False
|
||||
return True
|
||||
@@ -1,22 +0,0 @@
|
||||
"""
|
||||
Common functionality to support writing tests around completion.
|
||||
"""
|
||||
|
||||
from . import waffle
|
||||
|
||||
|
||||
class CompletionWaffleTestMixin(object):
|
||||
"""
|
||||
Common functionality for completion waffle tests.
|
||||
"""
|
||||
def override_waffle_switch(self, override):
|
||||
"""
|
||||
Override the setting of the ENABLE_COMPLETION_TRACKING waffle switch
|
||||
for the course of the test.
|
||||
|
||||
Parameters:
|
||||
override (bool): True if tracking should be enabled.
|
||||
"""
|
||||
_waffle_overrider = waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, override)
|
||||
_waffle_overrider.__enter__()
|
||||
self.addCleanup(_waffle_overrider.__exit__, None, None, None)
|
||||
@@ -1,85 +0,0 @@
|
||||
"""
|
||||
This module contains various configuration settings via
|
||||
waffle switches for the completion app.
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
|
||||
from openedx.core.djangoapps.theming.helpers import get_current_site
|
||||
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace, WaffleSwitchNamespace
|
||||
|
||||
# Namespace
|
||||
WAFFLE_NAMESPACE = 'completion'
|
||||
|
||||
# Switches
|
||||
# Full name: completion.enable_completion_tracking
|
||||
# Indicates whether or not to track completion of individual blocks. Keeping
|
||||
# this disabled will prevent creation of BlockCompletion objects in the
|
||||
# database, as well as preventing completion-related network access by certain
|
||||
# xblocks.
|
||||
ENABLE_COMPLETION_TRACKING = 'enable_completion_tracking'
|
||||
|
||||
# Full name completion.enable_visual_progress
|
||||
# Overrides completion.enable_course_visual_progress
|
||||
# Acts as a global override -- enable visual progress indicators
|
||||
# sitewide.
|
||||
ENABLE_VISUAL_PROGRESS = 'enable_visual_progress'
|
||||
|
||||
# Full name completion.enable_course_visual_progress
|
||||
# Acts as a course-by-course enabling of visual progress
|
||||
# indicators, e.g. updated 'resume button' functionality
|
||||
ENABLE_COURSE_VISUAL_PROGRESS = 'enable_course_visual_progress'
|
||||
|
||||
# SiteConfiguration visual progress enablement
|
||||
ENABLE_SITE_VISUAL_PROGRESS = 'enable_site_visual_progress'
|
||||
|
||||
|
||||
def waffle():
|
||||
"""
|
||||
Returns the namespaced, cached, audited Waffle class for completion.
|
||||
"""
|
||||
return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix='completion: ')
|
||||
|
||||
|
||||
def waffle_flag():
|
||||
"""
|
||||
Returns the namespaced, cached, audited Waffle flags dictionary for Completion.
|
||||
"""
|
||||
namespace = WaffleFlagNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'completion: ')
|
||||
return {
|
||||
# By default, disable visual progress. Can be enabled on a course-by-course basis.
|
||||
# And overridden site-globally by ENABLE_VISUAL_PROGRESS
|
||||
ENABLE_COURSE_VISUAL_PROGRESS: CourseWaffleFlag(
|
||||
namespace,
|
||||
ENABLE_COURSE_VISUAL_PROGRESS,
|
||||
flag_undefined_default=False
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def visual_progress_enabled(course_key):
|
||||
"""
|
||||
Exposes varia of visual progress feature.
|
||||
ENABLE_COMPLETION_TRACKING, current_site.configuration, AND
|
||||
enable_course_visual_progress OR enable_visual_progress
|
||||
|
||||
:return:
|
||||
|
||||
bool -> True if site/course/global enabled for visual progress tracking
|
||||
"""
|
||||
if not waffle().is_enabled(ENABLE_COMPLETION_TRACKING):
|
||||
return
|
||||
|
||||
try:
|
||||
current_site = get_current_site()
|
||||
if not current_site.configuration.get_value(ENABLE_SITE_VISUAL_PROGRESS, False):
|
||||
return
|
||||
except SiteConfiguration.DoesNotExist:
|
||||
return
|
||||
|
||||
# Site-aware global override
|
||||
if not waffle().is_enabled(ENABLE_VISUAL_PROGRESS):
|
||||
# Course enabled
|
||||
return waffle_flag()[ENABLE_COURSE_VISUAL_PROGRESS].is_enabled(course_key)
|
||||
|
||||
return True
|
||||
@@ -3,8 +3,8 @@ Block Completion Transformer
|
||||
"""
|
||||
|
||||
from xblock.completable import XBlockCompletionMode as CompletionMode
|
||||
from completion.models import BlockCompletion
|
||||
|
||||
from lms.djangoapps.completion.models import BlockCompletion
|
||||
from openedx.core.djangoapps.content.block_structure.transformer import BlockStructureTransformer
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""
|
||||
Tests for BlockCompletionTransformer.
|
||||
"""
|
||||
from completion.models import BlockCompletion
|
||||
from completion.test_utils import CompletionWaffleTestMixin
|
||||
from xblock.core import XBlock
|
||||
from xblock.completable import CompletableXBlockMixin, XBlockCompletionMode
|
||||
|
||||
from lms.djangoapps.completion.models import BlockCompletion
|
||||
from lms.djangoapps.completion.test_utils import CompletionWaffleTestMixin
|
||||
from lms.djangoapps.course_api.blocks.transformers.block_completion import BlockCompletionTransformer
|
||||
from lms.djangoapps.course_blocks.transformers.tests.helpers import ModuleStoreTestCase, TransformerRegistryTestMixin
|
||||
from student.tests.factories import UserFactory
|
||||
@@ -39,7 +39,7 @@ class StubCompletableXBlock(XBlock, CompletableXBlockMixin):
|
||||
pass
|
||||
|
||||
|
||||
class BlockCompletionTransformerTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase, CompletionWaffleTestMixin):
|
||||
class BlockCompletionTransformerTestCase(TransformerRegistryTestMixin, CompletionWaffleTestMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Tests behaviour of BlockCompletionTransformer
|
||||
"""
|
||||
@@ -49,6 +49,7 @@ class BlockCompletionTransformerTestCase(TransformerRegistryTestMixin, ModuleSto
|
||||
def setUp(self):
|
||||
super(BlockCompletionTransformerTestCase, self).setUp()
|
||||
self.user = UserFactory.create(password='test')
|
||||
# Set ENABLE_COMPLETION_TRACKING waffle switch to True
|
||||
self.override_waffle_switch(True)
|
||||
|
||||
@XBlock.register_temp_plugin(StubAggregatorXBlock, identifier='aggregator')
|
||||
|
||||
@@ -8,6 +8,8 @@ import logging
|
||||
from collections import OrderedDict
|
||||
from functools import partial
|
||||
|
||||
from completion.models import BlockCompletion
|
||||
from completion import waffle as completion_waffle
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.cache import cache
|
||||
@@ -40,8 +42,6 @@ from courseware.masquerade import (
|
||||
from courseware.model_data import DjangoKeyValueStore, FieldDataCache
|
||||
from edxmako.shortcuts import render_to_string
|
||||
from eventtracking import tracker
|
||||
from lms.djangoapps.completion.models import BlockCompletion
|
||||
from lms.djangoapps.completion import waffle as completion_waffle
|
||||
from lms.djangoapps.grades.signals.signals import SCORE_PUBLISHED
|
||||
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
|
||||
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
|
||||
|
||||
@@ -11,6 +11,8 @@ import ddt
|
||||
import pytest
|
||||
import pytz
|
||||
from bson import ObjectId
|
||||
from completion.models import BlockCompletion
|
||||
from completion import waffle as completion_waffle
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.urlresolvers import reverse
|
||||
@@ -46,8 +48,6 @@ from courseware.module_render import get_module_for_descriptor, hash_resource
|
||||
from courseware.tests.factories import GlobalStaffFactory, StudentModuleFactory, UserFactory
|
||||
from courseware.tests.test_submitting_problems import TestSubmittingProblems
|
||||
from courseware.tests.tests import LoginEnrollmentTestCase
|
||||
from lms.djangoapps.completion.models import BlockCompletion
|
||||
from lms.djangoapps.completion import waffle as completion_waffle
|
||||
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
|
||||
from openedx.core.djangoapps.credit.api import set_credit_requirement_status, set_credit_requirements
|
||||
from openedx.core.djangoapps.credit.models import CreditCourse
|
||||
@@ -664,7 +664,7 @@ class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCas
|
||||
content_type='application/json',
|
||||
)
|
||||
request.user = self.mock_user
|
||||
with patch('lms.djangoapps.completion.models.BlockCompletionManager.submit_completion') as mock_complete:
|
||||
with patch('completion.models.BlockCompletionManager.submit_completion') as mock_complete:
|
||||
render.handle_xblock_callback(
|
||||
request,
|
||||
unicode(course.id),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""
|
||||
Module implementing `xblock.runtime.Runtime` functionality for the LMS
|
||||
"""
|
||||
import xblock.reference.plugins
|
||||
from completion.services import CompletionService
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
import xblock.reference.plugins
|
||||
|
||||
from badges.service import BadgingService
|
||||
from badges.utils import badges_enabled
|
||||
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
|
||||
from lms.djangoapps.completion.services import CompletionService
|
||||
from openedx.core.djangoapps.user_api.course_tag import api as user_course_tag_api
|
||||
from openedx.core.lib.url_utils import quote_slashes
|
||||
from openedx.core.lib.xblock_utils import xblock_local_resource_url
|
||||
|
||||
@@ -2347,9 +2347,6 @@ INSTALLED_APPS = [
|
||||
# Course Goals
|
||||
'lms.djangoapps.course_goals',
|
||||
|
||||
# Completion
|
||||
'lms.djangoapps.completion.apps.CompletionAppConfig',
|
||||
|
||||
# Features
|
||||
'openedx.features.course_bookmarks',
|
||||
'openedx.features.course_experience',
|
||||
|
||||
@@ -4,6 +4,9 @@ Tests for the Course Outline view and supporting views.
|
||||
import datetime
|
||||
import json
|
||||
|
||||
from completion import waffle
|
||||
from completion.models import BlockCompletion
|
||||
from completion.test_utils import CompletionWaffleTestMixin
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.urlresolvers import reverse
|
||||
from mock import Mock, patch
|
||||
@@ -11,9 +14,6 @@ from six import text_type
|
||||
|
||||
from courseware.tests.factories import StaffFactory
|
||||
from gating import api as lms_gating_api
|
||||
from lms.djangoapps.completion import waffle
|
||||
from lms.djangoapps.completion.models import BlockCompletion
|
||||
from lms.djangoapps.completion.test_utils import CompletionWaffleTestMixin
|
||||
from lms.djangoapps.course_api.blocks.transformers.milestones import MilestonesAndSpecialExamsTransformer
|
||||
from milestones.tests.utils import MilestonesTestCaseMixin
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
@@ -325,7 +325,6 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase, CompletionWaffleT
|
||||
"""
|
||||
super(TestCourseOutlineResumeCourse, self).setUp()
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
self.override_waffle_switch(False)
|
||||
|
||||
def visit_sequential(self, course, chapter, sequential):
|
||||
"""
|
||||
@@ -393,7 +392,7 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase, CompletionWaffleT
|
||||
),
|
||||
active=True
|
||||
)
|
||||
@patch('lms.djangoapps.completion.waffle.get_current_site')
|
||||
@patch('completion.waffle.get_current_site')
|
||||
def test_resume_course_with_completion_api(self, get_patched_current_site):
|
||||
"""
|
||||
Tests completion API resume button functionality
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Common utilities for the course experience, including course outline.
|
||||
"""
|
||||
from lms.djangoapps.completion.models import BlockCompletion
|
||||
from lms.djangoapps.completion.waffle import visual_progress_enabled
|
||||
from completion.models import BlockCompletion
|
||||
from completion.waffle import visual_progress_enabled
|
||||
|
||||
from lms.djangoapps.course_api.blocks.api import get_blocks
|
||||
from lms.djangoapps.course_blocks.utils import get_student_module_as_dict
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from opaque_keys.edx.locator import BlockUsageLocator
|
||||
from openedx.core.djangoapps.request_cache.middleware import request_cached
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
@@ -50,7 +50,6 @@ def get_course_outline_block_tree(request, course_id):
|
||||
Mark 'most recent completed block as 'resume_block'
|
||||
|
||||
"""
|
||||
|
||||
last_completed_child_position = BlockCompletion.get_latest_block_completed(user, course_key)
|
||||
|
||||
if last_completed_child_position:
|
||||
@@ -76,11 +75,11 @@ def get_course_outline_block_tree(request, course_id):
|
||||
:return:
|
||||
block: course_outline_root_block block object or child block
|
||||
"""
|
||||
locatable_block_string = BlockUsageLocator.from_string(block['id'])
|
||||
block_key = block.serializer.instance
|
||||
|
||||
if course_block_completions.get(locatable_block_string):
|
||||
if course_block_completions.get(block_key):
|
||||
block['complete'] = True
|
||||
if locatable_block_string == latest_completion.block_key:
|
||||
if block_key == latest_completion.full_block_key:
|
||||
block['resume_block'] = True
|
||||
|
||||
if block.get('children'):
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
"""
|
||||
Test signal handlers.
|
||||
Test signal handlers for completion.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from completion import handlers
|
||||
from completion.models import BlockCompletion
|
||||
from completion.test_utils import CompletionSetUpMixin
|
||||
import ddt
|
||||
from django.test import TestCase
|
||||
from mock import patch
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from pytz import utc
|
||||
import six
|
||||
from xblock.completable import XBlockCompletionMode
|
||||
from xblock.core import XBlock
|
||||
|
||||
from lms.djangoapps.grades.signals.signals import PROBLEM_WEIGHTED_SCORE_CHANGED
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
from .. import handlers
|
||||
from ..models import BlockCompletion
|
||||
from ..test_utils import CompletionWaffleTestMixin
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
|
||||
|
||||
class CustomScorableBlock(XBlock):
|
||||
@@ -40,17 +38,16 @@ class ExcludedScorableBlock(XBlock):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ScorableCompletionHandlerTestCase(CompletionWaffleTestMixin, TestCase):
|
||||
@skip_unless_lms
|
||||
class ScorableCompletionHandlerTestCase(CompletionSetUpMixin, TestCase):
|
||||
"""
|
||||
Test the signal handler
|
||||
"""
|
||||
COMPLETION_SWITCH_ENABLED = True
|
||||
|
||||
def setUp(self):
|
||||
super(ScorableCompletionHandlerTestCase, self).setUp()
|
||||
self.course_key = CourseKey.from_string('edx/course/beta')
|
||||
self.scorable_block_key = self.course_key.make_usage_key(block_type='problem', block_id='red')
|
||||
self.user = UserFactory.create()
|
||||
self.override_waffle_switch(True)
|
||||
self.block_key = self.course_key.make_usage_key(block_type='problem', block_id='red')
|
||||
|
||||
def call_scorable_block_completion_handler(self, block_key, score_deleted=None):
|
||||
"""
|
||||
@@ -81,11 +78,11 @@ class ScorableCompletionHandlerTestCase(CompletionWaffleTestMixin, TestCase):
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_handler_submits_completion(self, score_deleted, expected_completion):
|
||||
self.call_scorable_block_completion_handler(self.scorable_block_key, score_deleted)
|
||||
self.call_scorable_block_completion_handler(self.block_key, score_deleted)
|
||||
completion = BlockCompletion.objects.get(
|
||||
user=self.user,
|
||||
course_key=self.course_key,
|
||||
block_key=self.scorable_block_key,
|
||||
block_key=self.block_key,
|
||||
)
|
||||
self.assertEqual(completion.completion, expected_completion)
|
||||
|
||||
@@ -112,14 +109,12 @@ class ScorableCompletionHandlerTestCase(CompletionWaffleTestMixin, TestCase):
|
||||
self.assertFalse(completion.exists())
|
||||
|
||||
def test_signal_calls_handler(self):
|
||||
user = UserFactory.create()
|
||||
|
||||
with patch('lms.djangoapps.completion.handlers.scorable_block_completion') as mock_handler:
|
||||
with patch('completion.handlers.scorable_block_completion') as mock_handler:
|
||||
PROBLEM_WEIGHTED_SCORE_CHANGED.send_robust(
|
||||
sender=self,
|
||||
user_id=user.id,
|
||||
user_id=self.user.id,
|
||||
course_id=six.text_type(self.course_key),
|
||||
usage_id=six.text_type(self.scorable_block_key),
|
||||
usage_id=six.text_type(self.block_key),
|
||||
weighted_earned=0.0,
|
||||
weighted_possible=3.0,
|
||||
modified=datetime.utcnow().replace(tzinfo=utc),
|
||||
@@ -128,17 +123,17 @@ class ScorableCompletionHandlerTestCase(CompletionWaffleTestMixin, TestCase):
|
||||
mock_handler.assert_called()
|
||||
|
||||
|
||||
class DisabledCompletionHandlerTestCase(CompletionWaffleTestMixin, TestCase):
|
||||
@skip_unless_lms
|
||||
class DisabledCompletionHandlerTestCase(CompletionSetUpMixin, TestCase):
|
||||
"""
|
||||
Test that disabling the ENABLE_COMPLETION_TRACKING waffle switch prevents
|
||||
the signal handler from submitting a completion.
|
||||
"""
|
||||
COMPLETION_SWITCH_ENABLED = False
|
||||
|
||||
def setUp(self):
|
||||
super(DisabledCompletionHandlerTestCase, self).setUp()
|
||||
self.user = UserFactory.create()
|
||||
self.course_key = CourseKey.from_string("course-v1:a+valid+course")
|
||||
self.block_key = self.course_key.make_usage_key(block_type="video", block_id="mah-video")
|
||||
self.override_waffle_switch(False)
|
||||
self.block_key = self.course_key.make_usage_key(block_type='problem', block_id='red')
|
||||
|
||||
def test_disabled_handler_does_not_submit_completion(self):
|
||||
handlers.scorable_block_completion(
|
||||
@@ -4,15 +4,17 @@ Test models, managers, and validators.
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from completion import models, waffle
|
||||
from completion.test_utils import CompletionWaffleTestMixin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
|
||||
from .. import models, waffle
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class PercentValidatorTestCase(TestCase):
|
||||
"""
|
||||
Test that validate_percent only allows floats (and ints) between 0.0 and 1.0.
|
||||
@@ -26,7 +28,10 @@ class PercentValidatorTestCase(TestCase):
|
||||
self.assertRaises(ValidationError, models.validate_percent, value)
|
||||
|
||||
|
||||
class CompletionSetUpMixin(object):
|
||||
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(u'block-v1:edx+test+run+type@video+block@doggos')
|
||||
@@ -39,6 +44,7 @@ class CompletionSetUpMixin(object):
|
||||
)
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class SubmitCompletionTestCase(CompletionSetUpMixin, TestCase):
|
||||
"""
|
||||
Test that BlockCompletion.objects.submit_completion has the desired
|
||||
@@ -46,9 +52,7 @@ class SubmitCompletionTestCase(CompletionSetUpMixin, TestCase):
|
||||
"""
|
||||
def setUp(self):
|
||||
super(SubmitCompletionTestCase, self).setUp()
|
||||
_overrider = waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, True)
|
||||
_overrider.__enter__()
|
||||
self.addCleanup(_overrider.__exit__, None, None, None)
|
||||
self.override_waffle_switch(True)
|
||||
self.set_up_completion()
|
||||
|
||||
def test_changed_value(self):
|
||||
@@ -114,22 +118,17 @@ class SubmitCompletionTestCase(CompletionSetUpMixin, TestCase):
|
||||
self.assertEqual(models.BlockCompletion.objects.count(), 1)
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class CompletionDisabledTestCase(CompletionSetUpMixin, TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CompletionDisabledTestCase, cls).setUpClass()
|
||||
cls.overrider = waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, False)
|
||||
cls.overrider.__enter__()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.overrider.__exit__(None, None, None)
|
||||
super(CompletionDisabledTestCase, cls).tearDownClass()
|
||||
|
||||
"""
|
||||
Tests that completion API is not called when the feature is disabled.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(CompletionDisabledTestCase, self).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):
|
||||
self.assertEqual(models.BlockCompletion.objects.count(), 1)
|
||||
@@ -143,17 +142,15 @@ class CompletionDisabledTestCase(CompletionSetUpMixin, TestCase):
|
||||
self.assertEqual(models.BlockCompletion.objects.count(), 1)
|
||||
|
||||
|
||||
class SubmitBatchCompletionTestCase(TestCase):
|
||||
@skip_unless_lms
|
||||
class SubmitBatchCompletionTestCase(CompletionWaffleTestMixin, TestCase):
|
||||
"""
|
||||
Test that BlockCompletion.objects.submit_batch_completion has the desired
|
||||
semantics.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(SubmitBatchCompletionTestCase, self).setUp()
|
||||
_overrider = waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, True)
|
||||
_overrider.__enter__()
|
||||
self.addCleanup(_overrider.__exit__, None, None, None)
|
||||
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')
|
||||
@@ -188,13 +185,14 @@ class SubmitBatchCompletionTestCase(TestCase):
|
||||
self.assertEqual(model.completion, 1.0)
|
||||
|
||||
|
||||
class BatchCompletionMethodTests(TestCase):
|
||||
|
||||
@skip_unless_lms
|
||||
class BatchCompletionMethodTests(CompletionWaffleTestMixin, TestCase):
|
||||
"""
|
||||
Tests for the classmethods that retrieve course/block completion data.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(BatchCompletionMethodTests, self).setUp()
|
||||
_overrider = waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, True)
|
||||
_overrider.__enter__()
|
||||
self.addCleanup(_overrider.__exit__, None, None, None)
|
||||
self.override_waffle_switch(True)
|
||||
|
||||
self.user = UserFactory.create()
|
||||
self.other_user = UserFactory.create()
|
||||
@@ -202,11 +200,13 @@ class BatchCompletionMethodTests(TestCase):
|
||||
self.other_course_key = CourseKey.from_string("course-v1:ReedX+Hum110+1904")
|
||||
self.block_keys = [UsageKey.from_string("i4x://edX/MOOC101/video/{}".format(number)) for number in xrange(5)]
|
||||
|
||||
self.submit_faux_completions()
|
||||
self.submit_fake_completions()
|
||||
|
||||
def submit_faux_completions(self):
|
||||
# Proper completions for the given runtime
|
||||
for idx, block_key in enumerate(self.block_keys[0:3]):
|
||||
def submit_fake_completions(self):
|
||||
"""
|
||||
Submit completions for given runtime, run at setup
|
||||
"""
|
||||
for idx, block_key in enumerate(self.block_keys[:3]):
|
||||
models.BlockCompletion.objects.submit_completion(
|
||||
user=self.user,
|
||||
course_key=self.course_key,
|
||||
@@ -214,8 +214,7 @@ class BatchCompletionMethodTests(TestCase):
|
||||
completion=1.0 - (0.2 * idx),
|
||||
)
|
||||
|
||||
# Wrong user
|
||||
for idx, block_key in enumerate(self.block_keys[2:]):
|
||||
for idx, block_key in enumerate(self.block_keys[2:]): # Wrong user
|
||||
models.BlockCompletion.objects.submit_completion(
|
||||
user=self.other_user,
|
||||
course_key=self.course_key,
|
||||
@@ -223,23 +222,23 @@ class BatchCompletionMethodTests(TestCase):
|
||||
completion=0.9 - (0.2 * idx),
|
||||
)
|
||||
|
||||
# Wrong course
|
||||
models.BlockCompletion.objects.submit_completion(
|
||||
models.BlockCompletion.objects.submit_completion( # Wrong course
|
||||
user=self.user,
|
||||
course_key=self.other_course_key,
|
||||
block_key=self.block_keys[4],
|
||||
completion=0.75,
|
||||
)
|
||||
|
||||
def test_get_course_completions(self):
|
||||
def test_get_course_completions_missing_runs(self):
|
||||
actual_completions = models.BlockCompletion.get_course_completions(self.user, self.course_key)
|
||||
expected_block_keys = [key.replace(course_key=self.course_key) for key in self.block_keys[:3]]
|
||||
expected_completions = dict(zip(expected_block_keys, [1.0, 0.8, 0.6]))
|
||||
self.assertEqual(expected_completions, actual_completions)
|
||||
|
||||
def test_get_course_completions_empty_result_set(self):
|
||||
self.assertEqual(
|
||||
models.BlockCompletion.get_course_completions(self.user, self.course_key),
|
||||
{
|
||||
self.block_keys[0]: 1.0,
|
||||
self.block_keys[1]: 0.8,
|
||||
self.block_keys[2]: 0.6,
|
||||
},
|
||||
models.BlockCompletion.get_course_completions(self.other_user, self.other_course_key),
|
||||
{}
|
||||
)
|
||||
|
||||
def test_get_latest_block_completed(self):
|
||||
@@ -247,3 +246,6 @@ class BatchCompletionMethodTests(TestCase):
|
||||
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):
|
||||
self.assertIsNone(models.BlockCompletion.get_latest_block_completed(self.other_user, self.other_course_key))
|
||||
@@ -1,18 +1,20 @@
|
||||
"""
|
||||
Tests of completion xblock runtime services
|
||||
"""
|
||||
from completion.models import BlockCompletion
|
||||
from completion.services import CompletionService
|
||||
from completion.test_utils import CompletionWaffleTestMixin
|
||||
import ddt
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
from ..models import BlockCompletion
|
||||
from ..services import CompletionService
|
||||
from ..test_utils import CompletionWaffleTestMixin
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@skip_unless_lms
|
||||
class CompletionServiceTestCase(CompletionWaffleTestMixin, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test the data returned by the CompletionService.
|
||||
@@ -2,20 +2,22 @@
|
||||
"""
|
||||
Test models, managers, and validators.
|
||||
"""
|
||||
|
||||
from completion import waffle
|
||||
from completion.test_utils import CompletionWaffleTestMixin
|
||||
import ddt
|
||||
from django.core.urlresolvers import reverse
|
||||
from rest_framework.test import APIClient, force_authenticate
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from completion import waffle
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from openedx.core.djangoapps.content.course_structures.tasks import update_course_structure
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CompletionBatchTestCase(ModuleStoreTestCase):
|
||||
@skip_unless_lms
|
||||
class CompletionBatchTestCase(CompletionWaffleTestMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Test that BlockCompletion.objects.submit_batch_completion has the desired
|
||||
semantics.
|
||||
@@ -33,9 +35,7 @@ class CompletionBatchTestCase(ModuleStoreTestCase):
|
||||
self.url = reverse('completion_api:v1:completion-batch')
|
||||
|
||||
# Enable the waffle flag for all tests
|
||||
_overrider = waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, True)
|
||||
_overrider.__enter__()
|
||||
self.addCleanup(_overrider.__exit__, None, None, None)
|
||||
self.override_waffle_switch(True)
|
||||
|
||||
# Create course
|
||||
self.course = CourseFactory.create(org='TestX', number='101', display_name='Test')
|
||||
@@ -75,7 +75,9 @@ INSTALLED_APPS = (
|
||||
'openedx.core.djangoapps.self_paced',
|
||||
'milestones',
|
||||
'celery_utils',
|
||||
'lms.djangoapps.completion.apps.CompletionAppConfig',
|
||||
|
||||
# Django 1.11 demands to have imported models supported by installed apps.
|
||||
'completion',
|
||||
)
|
||||
|
||||
LMS_ROOT_URL = 'http://localhost:8000'
|
||||
|
||||
@@ -56,6 +56,7 @@ git+https://github.com/cpennington/pylint-django@fix-field-inference-during-monk
|
||||
enum34==1.1.6
|
||||
edx-django-oauth2-provider==1.2.5
|
||||
edx-django-sites-extensions==2.3.0
|
||||
edx-completion==0.0.6
|
||||
edx-enterprise==0.65.7
|
||||
edx-milestones==0.1.13
|
||||
edx-oauth2-provider==1.2.2
|
||||
|
||||
@@ -98,7 +98,6 @@ git+https://github.com/edx/xblock-lti-consumer.git@v1.1.7#egg=lti_consumer-xbloc
|
||||
# This is here because all of the other XBlocks are located here. However, it is published to PyPI and will be installed that way
|
||||
xblock-review==1.1.4
|
||||
|
||||
|
||||
# Third Party XBlocks
|
||||
|
||||
git+https://github.com/mitodl/edx-sga.git@d019b8a050c056db535e3ff13c93096145a932de#egg=edx-sga==0.7.1
|
||||
|
||||
Reference in New Issue
Block a user