Get edx-platform working with external completion lib, add integration tests.

This commit is contained in:
Alex Dusenbery
2018-02-05 14:22:31 -05:00
committed by Alex Dusenbery
parent 88130b67bc
commit 7e51f02884
33 changed files with 105 additions and 832 deletions

View File

@@ -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',

View File

@@ -1,5 +0,0 @@
"""
Completion App
"""
default_app_config = 'lms.djangoapps.completion.apps.CompletionAppConfig'

View File

@@ -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')),
]

View File

@@ -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'),
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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')]),
),
]

View File

@@ -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'},
),
]

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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

View File

@@ -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),

View File

@@ -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

View File

@@ -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',

View File

@@ -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

View File

@@ -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'):

View File

@@ -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(

View File

@@ -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))

View File

@@ -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.

View File

@@ -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')

View File

@@ -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'

View File

@@ -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

View File

@@ -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