Merge branch 'master' into HarryRein/LEARNER-2471-safari-sock-issue
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -95,6 +95,7 @@ lms/static/css/
|
||||
lms/static/certificates/css/
|
||||
cms/static/css/
|
||||
common/static/common/js/vendor/
|
||||
common/static/common/css/vendor/
|
||||
common/static/bundles
|
||||
webpack-stats.json
|
||||
|
||||
|
||||
@@ -2,10 +2,30 @@
|
||||
Admin site bindings for contentstore
|
||||
"""
|
||||
|
||||
from config_models.admin import ConfigurationModelAdmin
|
||||
from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin
|
||||
from django.contrib import admin
|
||||
|
||||
from contentstore.config.forms import CourseNewAssetsPageAdminForm
|
||||
from contentstore.config.models import NewAssetsPageFlag, CourseNewAssetsPageFlag
|
||||
from contentstore.models import PushNotificationConfig, VideoUploadConfig
|
||||
|
||||
|
||||
class CourseNewAssetsPageAdmin(KeyedConfigurationModelAdmin):
|
||||
"""
|
||||
Admin for enabling new asset page on a course-by-course basis.
|
||||
Allows searching by course id.
|
||||
"""
|
||||
form = CourseNewAssetsPageAdminForm
|
||||
search_fields = ['course_id']
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('course_id', 'enabled'),
|
||||
'description': 'Enter a valid course id. If it is invalid, an error message will display.'
|
||||
}),
|
||||
)
|
||||
|
||||
admin.site.register(NewAssetsPageFlag, ConfigurationModelAdmin)
|
||||
admin.site.register(CourseNewAssetsPageFlag, CourseNewAssetsPageAdmin)
|
||||
|
||||
admin.site.register(VideoUploadConfig, ConfigurationModelAdmin)
|
||||
admin.site.register(PushNotificationConfig, ConfigurationModelAdmin)
|
||||
|
||||
0
cms/djangoapps/contentstore/config/__init__.py
Normal file
0
cms/djangoapps/contentstore/config/__init__.py
Normal file
37
cms/djangoapps/contentstore/config/forms.py
Normal file
37
cms/djangoapps/contentstore/config/forms.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Defines a form for providing validation.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
|
||||
from contentstore.config.models import CourseNewAssetsPageFlag
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CourseNewAssetsPageAdminForm(forms.ModelForm):
|
||||
"""Input form for new asset page enablment, allowing us to verify user input."""
|
||||
|
||||
class Meta(object):
|
||||
model = CourseNewAssetsPageFlag
|
||||
fields = '__all__'
|
||||
|
||||
def clean_course_id(self):
|
||||
"""Validate the course id"""
|
||||
cleaned_id = self.cleaned_data["course_id"]
|
||||
try:
|
||||
course_key = CourseLocator.from_string(cleaned_id)
|
||||
except InvalidKeyError:
|
||||
msg = u'Course id invalid. Entered course id was: "{0}."'.format(cleaned_id)
|
||||
raise forms.ValidationError(msg)
|
||||
|
||||
if not modulestore().has_course(course_key):
|
||||
msg = u'Course not found. Entered course id was: "{0}". '.format(course_key.to_deprecated_string())
|
||||
raise forms.ValidationError(msg)
|
||||
|
||||
return course_key
|
||||
77
cms/djangoapps/contentstore/config/models.py
Normal file
77
cms/djangoapps/contentstore/config/models.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Models for configuration of the feature flags
|
||||
controlling the new assets page.
|
||||
"""
|
||||
from config_models.models import ConfigurationModel
|
||||
from django.db.models import BooleanField
|
||||
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
|
||||
|
||||
|
||||
class NewAssetsPageFlag(ConfigurationModel):
|
||||
"""
|
||||
Enables the in-development new assets page from studio-frontend.
|
||||
|
||||
Defaults to False platform-wide, but can be overriden via a course-specific
|
||||
flag. The idea is that we can use this to do a gradual rollout, and remove
|
||||
the flag entirely once generally released to everyone.
|
||||
"""
|
||||
# this field overrides course-specific settings to enable the feature for all courses
|
||||
enabled_for_all_courses = BooleanField(default=False)
|
||||
|
||||
@classmethod
|
||||
def feature_enabled(cls, course_id=None):
|
||||
"""
|
||||
Looks at the currently active configuration model to determine whether
|
||||
the new assets page feature is available.
|
||||
|
||||
There are 2 booleans to be concerned with - enabled_for_all_courses,
|
||||
and the implicit is_enabled(). They interact in the following ways:
|
||||
- is_enabled: False, enabled_for_all_courses: True or False
|
||||
- no one can use the feature.
|
||||
- is_enabled: True, enabled_for_all_courses: False
|
||||
- check for a CourseNewAssetsPageFlag, use that value (default False)
|
||||
- if no course_id provided, return False
|
||||
- is_enabled: True, enabled_for_all_courses: True
|
||||
- everyone can use the feature
|
||||
"""
|
||||
if not NewAssetsPageFlag.is_enabled():
|
||||
return False
|
||||
elif not NewAssetsPageFlag.current().enabled_for_all_courses:
|
||||
if course_id:
|
||||
effective = CourseNewAssetsPageFlag.objects.filter(course_id=course_id).order_by('-change_date').first()
|
||||
return effective.enabled if effective is not None else False
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
class Meta(object):
|
||||
app_label = "contentstore"
|
||||
|
||||
def __unicode__(self):
|
||||
current_model = NewAssetsPageFlag.current()
|
||||
return u"NewAssetsPageFlag: enabled {}".format(
|
||||
current_model.is_enabled()
|
||||
)
|
||||
|
||||
|
||||
class CourseNewAssetsPageFlag(ConfigurationModel):
|
||||
"""
|
||||
Enables new assets page for a specific
|
||||
course. Only has an effect if the general
|
||||
flag above is set to True.
|
||||
"""
|
||||
KEY_FIELDS = ('course_id',)
|
||||
|
||||
class Meta(object):
|
||||
app_label = "contentstore"
|
||||
|
||||
# The course that these features are attached to.
|
||||
course_id = CourseKeyField(max_length=255, db_index=True)
|
||||
|
||||
def __unicode__(self):
|
||||
not_en = "Not "
|
||||
if self.enabled:
|
||||
not_en = ""
|
||||
# pylint: disable=no-member
|
||||
return u"Course '{}': Persistent Grades {}Enabled".format(self.course_id.to_deprecated_string(), not_en)
|
||||
92
cms/djangoapps/contentstore/config/tests/test_models.py
Normal file
92
cms/djangoapps/contentstore/config/tests/test_models.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Tests for the models that control the
|
||||
persistent grading feature.
|
||||
"""
|
||||
import itertools
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from mock import patch
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
|
||||
from contentstore.config.models import NewAssetsPageFlag
|
||||
from contentstore.config.tests.utils import new_assets_page_feature_flags
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class NewAssetsPageFlagTests(TestCase):
|
||||
"""
|
||||
Tests the behavior of the feature flags for the new assets page.
|
||||
These are set via Django admin settings.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(NewAssetsPageFlagTests, self).setUp()
|
||||
self.course_id_1 = CourseLocator(org="edx", course="course", run="run")
|
||||
self.course_id_2 = CourseLocator(org="edx", course="course2", run="run")
|
||||
|
||||
@ddt.data(*itertools.product(
|
||||
(True, False),
|
||||
(True, False),
|
||||
(True, False),
|
||||
))
|
||||
@ddt.unpack
|
||||
def test_new_assets_page_feature_flags(self, global_flag, enabled_for_all_courses, enabled_for_course_1):
|
||||
with new_assets_page_feature_flags(
|
||||
global_flag=global_flag,
|
||||
enabled_for_all_courses=enabled_for_all_courses,
|
||||
course_id=self.course_id_1,
|
||||
enabled_for_course=enabled_for_course_1
|
||||
):
|
||||
self.assertEqual(NewAssetsPageFlag.feature_enabled(), global_flag and enabled_for_all_courses)
|
||||
self.assertEqual(
|
||||
NewAssetsPageFlag.feature_enabled(self.course_id_1),
|
||||
global_flag and (enabled_for_all_courses or enabled_for_course_1)
|
||||
)
|
||||
self.assertEqual(
|
||||
NewAssetsPageFlag.feature_enabled(self.course_id_2),
|
||||
global_flag and enabled_for_all_courses
|
||||
)
|
||||
|
||||
def test_enable_disable_course_flag(self):
|
||||
"""
|
||||
Ensures that the flag, once enabled for a course, can also be disabled.
|
||||
"""
|
||||
with new_assets_page_feature_flags(
|
||||
global_flag=True,
|
||||
enabled_for_all_courses=False,
|
||||
course_id=self.course_id_1,
|
||||
enabled_for_course=True
|
||||
):
|
||||
self.assertTrue(NewAssetsPageFlag.feature_enabled(self.course_id_1))
|
||||
with new_assets_page_feature_flags(
|
||||
global_flag=True,
|
||||
enabled_for_all_courses=False,
|
||||
course_id=self.course_id_1,
|
||||
enabled_for_course=False
|
||||
):
|
||||
self.assertFalse(NewAssetsPageFlag.feature_enabled(self.course_id_1))
|
||||
|
||||
def test_enable_disable_globally(self):
|
||||
"""
|
||||
Ensures that the flag, once enabled globally, can also be disabled.
|
||||
"""
|
||||
with new_assets_page_feature_flags(
|
||||
global_flag=True,
|
||||
enabled_for_all_courses=True,
|
||||
):
|
||||
self.assertTrue(NewAssetsPageFlag.feature_enabled())
|
||||
self.assertTrue(NewAssetsPageFlag.feature_enabled(self.course_id_1))
|
||||
with new_assets_page_feature_flags(
|
||||
global_flag=True,
|
||||
enabled_for_all_courses=False,
|
||||
course_id=self.course_id_1,
|
||||
enabled_for_course=True
|
||||
):
|
||||
self.assertFalse(NewAssetsPageFlag.feature_enabled())
|
||||
self.assertTrue(NewAssetsPageFlag.feature_enabled(self.course_id_1))
|
||||
with new_assets_page_feature_flags(
|
||||
global_flag=False,
|
||||
):
|
||||
self.assertFalse(NewAssetsPageFlag.feature_enabled())
|
||||
self.assertFalse(NewAssetsPageFlag.feature_enabled(self.course_id_1))
|
||||
27
cms/djangoapps/contentstore/config/tests/utils.py
Normal file
27
cms/djangoapps/contentstore/config/tests/utils.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Provides helper functions for tests that want
|
||||
to configure flags related to persistent grading.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
|
||||
from contentstore.config.models import NewAssetsPageFlag, CourseNewAssetsPageFlag
|
||||
from request_cache.middleware import RequestCache
|
||||
|
||||
|
||||
@contextmanager
|
||||
def new_assets_page_feature_flags(
|
||||
global_flag,
|
||||
enabled_for_all_courses=False,
|
||||
course_id=None,
|
||||
enabled_for_course=False
|
||||
):
|
||||
"""
|
||||
Most test cases will use a single call to this manager,
|
||||
as they need to set the global setting and the course-specific
|
||||
setting for a single course.
|
||||
"""
|
||||
RequestCache.clear_request_cache()
|
||||
NewAssetsPageFlag.objects.create(enabled=global_flag, enabled_for_all_courses=enabled_for_all_courses)
|
||||
if course_id:
|
||||
CourseNewAssetsPageFlag.objects.create(course_id=course_id, enabled=enabled_for_course)
|
||||
yield
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.conf import settings
|
||||
import django.db.models.deletion
|
||||
import openedx.core.djangoapps.xmodule_django.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('contentstore', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CourseNewAssetsPageFlag',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
|
||||
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
|
||||
('course_id', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(max_length=255, db_index=True)),
|
||||
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NewAssetsPageFlag',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
|
||||
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
|
||||
('enabled_for_all_courses', models.BooleanField(default=False)),
|
||||
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -156,7 +156,7 @@ class TestSaveSubsToStore(SharedModuleStoreTestCase):
|
||||
|
||||
def test_save_unjsonable_subs_to_store(self):
|
||||
"""
|
||||
Assures that subs, that can't be dumped, can't be found later.
|
||||
Ensures that subs, that can't be dumped, can't be found later.
|
||||
"""
|
||||
with self.assertRaises(NotFoundError):
|
||||
contentstore().find(self.content_location_unjsonable)
|
||||
|
||||
@@ -18,6 +18,7 @@ from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from contentstore.config.models import NewAssetsPageFlag
|
||||
from contentstore.utils import reverse_course_url
|
||||
from contentstore.views.exception import AssetNotFoundException, AssetSizeTooLargeException
|
||||
from edxmako.shortcuts import render_to_response
|
||||
@@ -95,6 +96,7 @@ def _asset_index(course_key):
|
||||
course_module = modulestore().get_course(course_key)
|
||||
|
||||
return render_to_response('asset_index.html', {
|
||||
'waffle_flag_enabled': NewAssetsPageFlag.feature_enabled(course_key),
|
||||
'context_course': course_module,
|
||||
'max_file_size_in_mbs': settings.MAX_ASSET_UPLOAD_FILE_SIZE_IN_MB,
|
||||
'chunk_size_in_mbs': settings.UPLOAD_CHUNK_SIZE_IN_MB,
|
||||
|
||||
@@ -13,13 +13,19 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<%block name="header_extras">
|
||||
% for template_name in ["asset"]:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
% if waffle_flag_enabled:
|
||||
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/studio-frontend.min.css')}" />
|
||||
<script type="text/javascript" src="${static.url('common/js/vendor/assets.min.js')}" defer></script>
|
||||
% else:
|
||||
% for template_name in ["asset"]:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
% endif
|
||||
</%block>
|
||||
|
||||
% if not waffle_flag_enabled:
|
||||
<%block name="requirejs">
|
||||
require(["js/factories/asset_index"], function (AssetIndexFactory) {
|
||||
AssetIndexFactory({
|
||||
@@ -30,6 +36,7 @@
|
||||
});
|
||||
});
|
||||
</%block>
|
||||
% endif
|
||||
|
||||
<%block name="content">
|
||||
|
||||
@@ -53,7 +60,11 @@
|
||||
<div class="wrapper-content wrapper">
|
||||
<div class="content">
|
||||
<div class="content-primary">
|
||||
<div class="wrapper-assets"> </div>
|
||||
% if waffle_flag_enabled:
|
||||
<div id="root"></div>
|
||||
% else:
|
||||
<div class="wrapper-assets"></div>
|
||||
% endif
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><span class="icon fa fa-refresh" aria-hidden="true"></span></span> <span class="copy">${_("Loading")}</span></p>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,7 @@ from certificates.models import GeneratedCertificate
|
||||
from course_modes.models import CourseMode
|
||||
from courseware.models import DynamicUpgradeDeadlineConfiguration, CourseDynamicUpgradeDeadlineConfiguration
|
||||
from enrollment.api import _default_course_mode
|
||||
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.schedules.models import ScheduleConfig
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
@@ -62,6 +63,8 @@ from util.milestones_helpers import is_entrance_exams_enabled
|
||||
from util.model_utils import emit_field_changed_events, get_changed_fields_dict
|
||||
from util.query import use_read_replica_if_available
|
||||
|
||||
from .signals.signals import ENROLLMENT_TRACK_UPDATED
|
||||
|
||||
UNENROLL_DONE = Signal(providing_args=["course_enrollment", "skip_refund"])
|
||||
ENROLL_STATUS_CHANGE = Signal(providing_args=["event", "user", "course_id", "mode", "cost", "currency"])
|
||||
REFUND_ORDER = Signal(providing_args=["course_enrollment"])
|
||||
@@ -1191,6 +1194,7 @@ class CourseEnrollment(models.Model):
|
||||
# Only emit mode change events when the user's enrollment
|
||||
# mode has changed from its previous setting
|
||||
self.emit_event(EVENT_NAME_ENROLLMENT_MODE_CHANGED)
|
||||
ENROLLMENT_TRACK_UPDATED.send(sender=None, user=self.user, course_key=self.course_id)
|
||||
|
||||
def send_signal(self, event, cost=None, currency=None):
|
||||
"""
|
||||
|
||||
0
common/djangoapps/student/signals/__init__.py
Normal file
0
common/djangoapps/student/signals/__init__.py
Normal file
6
common/djangoapps/student/signals/signals.py
Normal file
6
common/djangoapps/student/signals/signals.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Enrollment track related signals.
|
||||
"""
|
||||
from django.dispatch import Signal
|
||||
|
||||
ENROLLMENT_TRACK_UPDATED = Signal(providing_args=['user', 'course_key'])
|
||||
@@ -228,8 +228,15 @@ class MigrationTests(TestCase):
|
||||
|
||||
The test is set up to override MIGRATION_MODULES to ensure migrations are
|
||||
enabled for purposes of this test regardless of the overall test settings.
|
||||
|
||||
TODO: Find a general way of handling the case where if we're trying to
|
||||
make a migrationless release that'll require a separate migration
|
||||
release afterwards, this test doesn't fail.
|
||||
"""
|
||||
out = StringIO()
|
||||
call_command('makemigrations', dry_run=True, verbosity=3, stdout=out)
|
||||
output = out.getvalue()
|
||||
self.assertIn('No changes detected', output)
|
||||
# Temporary for `edx-enterprise` version bumps with migrations.
|
||||
# Please delete when `edx-enterprise==0.47.0`.
|
||||
if 'Remove field' not in output and 'Delete model' not in output:
|
||||
self.assertIn('No changes detected', output)
|
||||
|
||||
0
lms/djangoapps/course_goals/__init__.py
Normal file
0
lms/djangoapps/course_goals/__init__.py
Normal file
76
lms/djangoapps/course_goals/api.py
Normal file
76
lms/djangoapps/course_goals/api.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Course Goals Python API
|
||||
"""
|
||||
from enum import Enum
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from django.utils.translation import ugettext as _
|
||||
from openedx.core.djangolib.markup import Text
|
||||
|
||||
from .models import CourseGoal
|
||||
|
||||
|
||||
def add_course_goal(user, course_id, goal_key):
|
||||
"""
|
||||
Add a new course goal for the provided user and course.
|
||||
|
||||
Arguments:
|
||||
user: The user that is setting the goal
|
||||
course_id (string): The id for the course the goal refers to
|
||||
goal_key (string): The goal key that maps to one of the
|
||||
enumerated goal keys from CourseGoalOption.
|
||||
|
||||
"""
|
||||
# Create and save a new course goal
|
||||
course_key = CourseKey.from_string(str(course_id))
|
||||
new_goal = CourseGoal(user=user, course_key=course_key, goal_key=goal_key)
|
||||
new_goal.save()
|
||||
|
||||
|
||||
def get_course_goal(user, course_key):
|
||||
"""
|
||||
Given a user and a course_key, return their course goal.
|
||||
|
||||
If a course goal does not exist, returns None.
|
||||
"""
|
||||
course_goals = CourseGoal.objects.filter(user=user, course_key=course_key)
|
||||
return course_goals[0] if course_goals else None
|
||||
|
||||
|
||||
def remove_course_goal(user, course_key):
|
||||
"""
|
||||
Given a user and a course_key, remove the course goal.
|
||||
"""
|
||||
course_goal = get_course_goal(user, course_key)
|
||||
if course_goal:
|
||||
course_goal.delete()
|
||||
|
||||
|
||||
class CourseGoalOption(Enum):
|
||||
"""
|
||||
Types of goals that a user can select.
|
||||
|
||||
These options are set to a string goal key so that they can be
|
||||
referenced elsewhere in the code when necessary.
|
||||
"""
|
||||
CERTIFY = 'certify'
|
||||
COMPLETE = 'complete'
|
||||
EXPLORE = 'explore'
|
||||
UNSURE = 'unsure'
|
||||
|
||||
@classmethod
|
||||
def get_course_goal_keys(self):
|
||||
return [key.value for key in self]
|
||||
|
||||
|
||||
def get_goal_text(goal_option):
|
||||
"""
|
||||
This function is used to translate the course goal option into
|
||||
a translated, user-facing string to be used to represent that
|
||||
particular goal.
|
||||
"""
|
||||
return {
|
||||
CourseGoalOption.CERTIFY.value: Text(_('Earn a certificate')),
|
||||
CourseGoalOption.COMPLETE.value: Text(_('Complete the course')),
|
||||
CourseGoalOption.EXPLORE.value: Text(_('Explore the course')),
|
||||
CourseGoalOption.UNSURE.value: Text(_('Not sure yet')),
|
||||
}[goal_option]
|
||||
29
lms/djangoapps/course_goals/migrations/0001_initial.py
Normal file
29
lms/djangoapps/course_goals/migrations/0001_initial.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import openedx.core.djangoapps.xmodule_django.models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CourseGoal',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('course_key', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(max_length=255, db_index=True)),
|
||||
('goal_key', models.CharField(default=b'unsure', max_length=100, choices=[(b'certify', 'Earn a certificate.'), (b'complete', 'Complete the course.'), (b'explore', 'Explore the course.'), (b'unsure', 'Not sure yet.')])),
|
||||
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='coursegoal',
|
||||
unique_together=set([('user', 'course_key')]),
|
||||
),
|
||||
]
|
||||
0
lms/djangoapps/course_goals/migrations/__init__.py
Normal file
0
lms/djangoapps/course_goals/migrations/__init__.py
Normal file
35
lms/djangoapps/course_goals/models.py
Normal file
35
lms/djangoapps/course_goals/models.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Course Goals Models
|
||||
"""
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
|
||||
|
||||
|
||||
class CourseGoal(models.Model):
|
||||
"""
|
||||
Represents a course goal set by a user on the course home page.
|
||||
|
||||
The goal_key represents the goal key that maps to a translated
|
||||
string through using the CourseGoalOption class.
|
||||
"""
|
||||
GOAL_KEY_CHOICES = (
|
||||
('certify', 'Earn a certificate.'),
|
||||
('complete', 'Complete the course.'),
|
||||
('explore', 'Explore the course.'),
|
||||
('unsure', 'Not sure yet.'),
|
||||
)
|
||||
|
||||
user = models.ForeignKey(User, blank=False)
|
||||
course_key = CourseKeyField(max_length=255, db_index=True)
|
||||
goal_key = models.CharField(max_length=100, choices=GOAL_KEY_CHOICES, default='unsure')
|
||||
|
||||
def __unicode__(self):
|
||||
return 'CourseGoal: {user} set goal to {goal} for course {course}'.format(
|
||||
user=self.user.username,
|
||||
course=self.course_key,
|
||||
goal_key=self.goal_key,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "course_key")
|
||||
19
lms/djangoapps/course_goals/signals.py
Normal file
19
lms/djangoapps/course_goals/signals.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Course Goals Signals
|
||||
"""
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from eventtracking import tracker
|
||||
|
||||
from .models import CourseGoal
|
||||
|
||||
|
||||
@receiver(post_save, sender=CourseGoal, dispatch_uid="emit_course_goal_event")
|
||||
def emit_course_goal_event(sender, instance, **kwargs):
|
||||
name = 'edx.course.goal.added' if kwargs.get('created', False) else 'edx.course.goal.updated'
|
||||
tracker.emit(
|
||||
name,
|
||||
{
|
||||
'goal_key': instance.goal_key,
|
||||
}
|
||||
)
|
||||
0
lms/djangoapps/course_goals/tests/__init__.py
Normal file
0
lms/djangoapps/course_goals/tests/__init__.py
Normal file
62
lms/djangoapps/course_goals/tests/test_api.py
Normal file
62
lms/djangoapps/course_goals/tests/test_api.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Unit tests for course_goals.api methods.
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from lms.djangoapps.course_goals.models import CourseGoal
|
||||
from rest_framework.test import APIClient
|
||||
from student.models import CourseEnrollment
|
||||
from track.tests import EventTrackingTestCase
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
TEST_PASSWORD = 'test'
|
||||
|
||||
|
||||
class TestCourseGoalsAPI(EventTrackingTestCase, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Testing the Course Goals API.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# Create a course with a verified track
|
||||
super(TestCourseGoalsAPI, self).setUp()
|
||||
self.course = CourseFactory.create(emit_signals=True)
|
||||
|
||||
self.user = User.objects.create_user('john', 'lennon@thebeatles.com', 'password')
|
||||
CourseEnrollment.enroll(self.user, self.course.id)
|
||||
|
||||
self.client = APIClient(enforce_csrf_checks=True)
|
||||
self.client.login(username=self.user.username, password=self.user.password)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.apiUrl = reverse('course_goals_api:v0:course_goal-list')
|
||||
|
||||
def test_add_valid_goal(self):
|
||||
""" Ensures a correctly formatted post succeeds. """
|
||||
response = self.post_course_goal(valid=True)
|
||||
self.assert_events_emitted()
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(len(CourseGoal.objects.filter(user=self.user, course_key=self.course.id)), 1)
|
||||
|
||||
def test_add_invalid_goal(self):
|
||||
""" Ensures a correctly formatted post succeeds. """
|
||||
response = self.post_course_goal(valid=False)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(len(CourseGoal.objects.filter(user=self.user, course_key=self.course.id)), 0)
|
||||
|
||||
def post_course_goal(self, valid=True, goal_key='certify'):
|
||||
"""
|
||||
Sends a post request to set a course goal and returns the response.
|
||||
"""
|
||||
goal_key = goal_key if valid else 'invalid'
|
||||
response = self.client.post(
|
||||
self.apiUrl,
|
||||
{
|
||||
'goal_key': goal_key,
|
||||
'course_key': self.course.id,
|
||||
'user': self.user.username,
|
||||
},
|
||||
)
|
||||
return response
|
||||
15
lms/djangoapps/course_goals/urls.py
Normal file
15
lms/djangoapps/course_goals/urls.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Course Goals URLs
|
||||
"""
|
||||
from django.conf.urls import include, patterns, url
|
||||
from rest_framework import routers
|
||||
|
||||
from .views import CourseGoalViewSet
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register(r'course_goals', CourseGoalViewSet, base_name='course_goal')
|
||||
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
url(r'^v0/', include(router.urls, namespace='v0')),
|
||||
)
|
||||
92
lms/djangoapps/course_goals/views.py
Normal file
92
lms/djangoapps/course_goals/views.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Course Goals Views - includes REST API
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from edx_rest_framework_extensions.authentication import JwtAuthentication
|
||||
from eventtracking import tracker
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from openedx.core.lib.api.permissions import IsStaffOrOwner
|
||||
from rest_framework import permissions, serializers, viewsets
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
|
||||
from .api import CourseGoalOption
|
||||
from .models import CourseGoal
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CourseGoalSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializes CourseGoal models.
|
||||
"""
|
||||
user = serializers.SlugRelatedField(slug_field='username', queryset=User.objects.all())
|
||||
|
||||
class Meta:
|
||||
model = CourseGoal
|
||||
fields = ('user', 'course_key', 'goal_key')
|
||||
|
||||
def validate_goal_key(self, value):
|
||||
"""
|
||||
Ensure that the goal_key is valid.
|
||||
"""
|
||||
if value not in CourseGoalOption.get_course_goal_keys():
|
||||
raise serializers.ValidationError(
|
||||
'Provided goal key, {goal_key}, is not a valid goal key (options= {goal_options}).'.format(
|
||||
goal_key=value,
|
||||
goal_options=[option.value for option in CourseGoalOption],
|
||||
)
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_course_key(self, value):
|
||||
"""
|
||||
Ensure that the course_key is valid.
|
||||
"""
|
||||
course_key = CourseKey.from_string(value)
|
||||
if not course_key:
|
||||
raise serializers.ValidationError(
|
||||
'Provided course_key ({course_key}) does not map to a course.'.format(
|
||||
course_key=course_key
|
||||
)
|
||||
)
|
||||
return course_key
|
||||
|
||||
|
||||
class CourseGoalViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API calls to create and retrieve a course goal.
|
||||
|
||||
**Use Case**
|
||||
* Create a new goal for a user.
|
||||
|
||||
Http400 is returned if the format of the request is not correct,
|
||||
the course_id or goal is invalid or cannot be found.
|
||||
|
||||
* Retrieve goal for a user and a particular course.
|
||||
|
||||
Http400 is returned if the format of the request is not correct,
|
||||
or the course_id is invalid or cannot be found.
|
||||
|
||||
**Example Requests**
|
||||
GET /api/course_goals/v0/course_goals/
|
||||
POST /api/course_goals/v0/course_goals/
|
||||
Request data: {"course_key": <course-key>, "goal_key": "<goal-key>", "user": "<username>"}
|
||||
|
||||
"""
|
||||
authentication_classes = (JwtAuthentication, SessionAuthentication,)
|
||||
permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner,)
|
||||
queryset = CourseGoal.objects.all()
|
||||
serializer_class = CourseGoalSerializer
|
||||
|
||||
|
||||
@receiver(post_save, sender=CourseGoal, dispatch_uid="emit_course_goals_event")
|
||||
def emit_course_goal_event(sender, instance, **kwargs):
|
||||
name = 'edx.course.goal.added' if kwargs.get('created', False) else 'edx.course.goal.updated'
|
||||
tracker.emit(
|
||||
name,
|
||||
{
|
||||
'goal_key': instance.goal_key,
|
||||
}
|
||||
)
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
Management command to generate a list of grades for
|
||||
all students that are enrolled in a course.
|
||||
"""
|
||||
import csv
|
||||
import datetime
|
||||
import os
|
||||
from optparse import make_option
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.handlers.base import BaseHandler
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.test.client import RequestFactory
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from lms.djangoapps.courseware import courses
|
||||
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
|
||||
|
||||
|
||||
class RequestMock(RequestFactory):
|
||||
"""
|
||||
Class to create a mock request.
|
||||
"""
|
||||
def request(self, **request):
|
||||
"Construct a generic request object."
|
||||
request = RequestFactory.request(self, **request)
|
||||
handler = BaseHandler()
|
||||
handler.load_middleware()
|
||||
for middleware_method in handler._request_middleware: # pylint: disable=protected-access
|
||||
if middleware_method(request):
|
||||
raise Exception("Couldn't create request mock object - "
|
||||
"request middleware returned a response")
|
||||
return request
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Management command for get_grades
|
||||
"""
|
||||
|
||||
help = """
|
||||
Generate a list of grades for all students
|
||||
that are enrolled in a course.
|
||||
|
||||
CSV will include the following:
|
||||
- username
|
||||
- email
|
||||
- grade in the certificate table if it exists
|
||||
- computed grade
|
||||
- grade breakdown
|
||||
|
||||
Outputs grades to a csv file.
|
||||
|
||||
Example:
|
||||
sudo -u www-data SERVICE_VARIANT=lms /opt/edx/bin/django-admin.py get_grades \
|
||||
-c MITx/Chi6.00intro/A_Taste_of_Python_Programming -o /tmp/20130813-6.00x.csv \
|
||||
--settings=lms.envs.aws --pythonpath=/opt/wwc/edx-platform
|
||||
"""
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('-c', '--course',
|
||||
metavar='COURSE_ID',
|
||||
dest='course',
|
||||
default=False,
|
||||
help='Course ID for grade distribution'),
|
||||
make_option('-o', '--output',
|
||||
metavar='FILE',
|
||||
dest='output',
|
||||
default=False,
|
||||
help='Filename for grade output'))
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if os.path.exists(options['output']):
|
||||
raise CommandError("File {0} already exists".format(
|
||||
options['output']))
|
||||
|
||||
status_interval = 100
|
||||
|
||||
# parse out the course into a coursekey
|
||||
if options['course']:
|
||||
course_key = CourseKey.from_string(options['course'])
|
||||
|
||||
print "Fetching enrolled students for {0}".format(course_key)
|
||||
enrolled_students = User.objects.filter(
|
||||
courseenrollment__course_id=course_key
|
||||
)
|
||||
factory = RequestMock()
|
||||
request = factory.get('/')
|
||||
|
||||
total = enrolled_students.count()
|
||||
print "Total enrolled: {0}".format(total)
|
||||
course = courses.get_course_by_id(course_key)
|
||||
total = enrolled_students.count()
|
||||
start = datetime.datetime.now()
|
||||
rows = []
|
||||
header = None
|
||||
print "Fetching certificate data"
|
||||
cert_grades = {
|
||||
cert.user.username: cert.grade
|
||||
for cert in list(
|
||||
GeneratedCertificate.objects.filter( # pylint: disable=no-member
|
||||
course_id=course_key
|
||||
).prefetch_related('user')
|
||||
)
|
||||
}
|
||||
print "Grading students"
|
||||
for count, student in enumerate(enrolled_students):
|
||||
count += 1
|
||||
if count % status_interval == 0:
|
||||
# Print a status update with an approximation of
|
||||
# how much time is left based on how long the last
|
||||
# interval took
|
||||
diff = datetime.datetime.now() - start
|
||||
timeleft = diff * (total - count) / status_interval
|
||||
hours, remainder = divmod(timeleft.seconds, 3600)
|
||||
minutes, __ = divmod(remainder, 60)
|
||||
print "{0}/{1} completed ~{2:02}:{3:02}m remaining".format(
|
||||
count, total, hours, minutes)
|
||||
start = datetime.datetime.now()
|
||||
request.user = student
|
||||
grade = CourseGradeFactory().create(student, course)
|
||||
if not header:
|
||||
header = [section['label'] for section in grade.summary[u'section_breakdown']]
|
||||
rows.append(["email", "username", "certificate-grade", "grade"] + header)
|
||||
percents = {section['label']: section['percent'] for section in grade.summary[u'section_breakdown']}
|
||||
row_percents = [percents[label] for label in header]
|
||||
if student.username in cert_grades:
|
||||
rows.append(
|
||||
[student.email, student.username, cert_grades[student.username], grade.percent] + row_percents,
|
||||
)
|
||||
else:
|
||||
rows.append([student.email, student.username, "N/A", grade.percent] + row_percents)
|
||||
with open(options['output'], 'wb') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerows(rows)
|
||||
@@ -1,144 +0,0 @@
|
||||
"""
|
||||
Reset persistent grades for learners.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from textwrap import dedent
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db.models import Count
|
||||
from pytz import utc
|
||||
|
||||
from lms.djangoapps.grades.models import PersistentCourseGrade, PersistentSubsectionGrade
|
||||
from openedx.core.lib.command_utils import get_mutually_exclusive_required_option, parse_course_keys
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DATE_FORMAT = "%Y-%m-%d %H:%M"
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Reset persistent grades for learners.
|
||||
"""
|
||||
help = dedent(__doc__).strip()
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""
|
||||
Add arguments to the command parser.
|
||||
"""
|
||||
parser.add_argument(
|
||||
'--dry_run',
|
||||
action='store_true',
|
||||
default=False,
|
||||
dest='dry_run',
|
||||
help="Output what we're going to do, but don't actually do it. To actually delete, use --delete instead."
|
||||
)
|
||||
parser.add_argument(
|
||||
'--delete',
|
||||
action='store_true',
|
||||
default=False,
|
||||
dest='delete',
|
||||
help="Actually perform the deletions of the course. For a Dry Run, use --dry_run instead."
|
||||
)
|
||||
parser.add_argument(
|
||||
'--courses',
|
||||
dest='courses',
|
||||
nargs='+',
|
||||
help='Reset persistent grades for the list of courses provided.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--all_courses',
|
||||
action='store_true',
|
||||
dest='all_courses',
|
||||
default=False,
|
||||
help='Reset persistent grades for all courses.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--modified_start',
|
||||
dest='modified_start',
|
||||
help='Starting range for modified date (inclusive): e.g. "2016-08-23 16:43"; expected in UTC.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--modified_end',
|
||||
dest='modified_end',
|
||||
help='Ending range for modified date (inclusive): e.g. "2016-12-23 16:43"; expected in UTC.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--db_table',
|
||||
dest='db_table',
|
||||
help='Specify "subsection" to reset subsection grades or "course" to reset course grades. If absent, both '
|
||||
'are reset.',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
course_keys = None
|
||||
modified_start = None
|
||||
modified_end = None
|
||||
|
||||
run_mode = get_mutually_exclusive_required_option(options, 'delete', 'dry_run')
|
||||
courses_mode = get_mutually_exclusive_required_option(options, 'courses', 'all_courses')
|
||||
db_table = options.get('db_table')
|
||||
if db_table not in {'subsection', 'course', None}:
|
||||
raise CommandError('Invalid value for db_table. Valid options are "subsection" or "course" only.')
|
||||
|
||||
if options.get('modified_start'):
|
||||
modified_start = utc.localize(datetime.strptime(options['modified_start'], DATE_FORMAT))
|
||||
|
||||
if options.get('modified_end'):
|
||||
if not modified_start:
|
||||
raise CommandError('Optional value for modified_end provided without a value for modified_start.')
|
||||
modified_end = utc.localize(datetime.strptime(options['modified_end'], DATE_FORMAT))
|
||||
|
||||
if courses_mode == 'courses':
|
||||
course_keys = parse_course_keys(options['courses'])
|
||||
|
||||
log.info("reset_grade: Started in %s mode!", run_mode)
|
||||
|
||||
operation = self._query_grades if run_mode == 'dry_run' else self._delete_grades
|
||||
|
||||
if db_table == 'subsection' or db_table is None:
|
||||
operation(PersistentSubsectionGrade, course_keys, modified_start, modified_end)
|
||||
|
||||
if db_table == 'course' or db_table is None:
|
||||
operation(PersistentCourseGrade, course_keys, modified_start, modified_end)
|
||||
|
||||
log.info("reset_grade: Finished in %s mode!", run_mode)
|
||||
|
||||
def _delete_grades(self, grade_model_class, *args, **kwargs):
|
||||
"""
|
||||
Deletes the requested grades in the given model, filtered by the provided args and kwargs.
|
||||
"""
|
||||
grades_query_set = grade_model_class.query_grades(*args, **kwargs)
|
||||
num_rows_to_delete = grades_query_set.count()
|
||||
|
||||
log.info("reset_grade: Deleting %s: %d row(s).", grade_model_class.__name__, num_rows_to_delete)
|
||||
|
||||
grade_model_class.delete_grades(*args, **kwargs)
|
||||
|
||||
log.info("reset_grade: Deleted %s: %d row(s).", grade_model_class.__name__, num_rows_to_delete)
|
||||
|
||||
def _query_grades(self, grade_model_class, *args, **kwargs):
|
||||
"""
|
||||
Queries the requested grades in the given model, filtered by the provided args and kwargs.
|
||||
"""
|
||||
total_for_all_courses = 0
|
||||
|
||||
grades_query_set = grade_model_class.query_grades(*args, **kwargs)
|
||||
grades_stats = grades_query_set.values('course_id').order_by().annotate(total=Count('course_id'))
|
||||
|
||||
for stat in grades_stats:
|
||||
total_for_all_courses += stat['total']
|
||||
log.info(
|
||||
"reset_grade: Would delete %s for COURSE %s: %d row(s).",
|
||||
grade_model_class.__name__,
|
||||
stat['course_id'],
|
||||
stat['total'],
|
||||
)
|
||||
|
||||
log.info(
|
||||
"reset_grade: Would delete %s in TOTAL: %d row(s).",
|
||||
grade_model_class.__name__,
|
||||
total_for_all_courses,
|
||||
)
|
||||
@@ -1,319 +0,0 @@
|
||||
"""
|
||||
Tests for reset_grades management command.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import ddt
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import TestCase
|
||||
from freezegun import freeze_time
|
||||
from mock import MagicMock, patch
|
||||
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
|
||||
|
||||
from lms.djangoapps.grades.management.commands import reset_grades
|
||||
from lms.djangoapps.grades.models import PersistentCourseGrade, PersistentSubsectionGrade
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestResetGrades(TestCase):
|
||||
"""
|
||||
Tests generate course blocks management command.
|
||||
"""
|
||||
num_users = 3
|
||||
num_courses = 5
|
||||
num_subsections = 7
|
||||
|
||||
def setUp(self):
|
||||
super(TestResetGrades, self).setUp()
|
||||
self.command = reset_grades.Command()
|
||||
|
||||
self.user_ids = [user_id for user_id in range(self.num_users)]
|
||||
|
||||
self.course_keys = []
|
||||
for course_index in range(self.num_courses):
|
||||
self.course_keys.append(
|
||||
CourseLocator(
|
||||
org='some_org',
|
||||
course='some_course',
|
||||
run=unicode(course_index),
|
||||
)
|
||||
)
|
||||
|
||||
self.subsection_keys_by_course = {}
|
||||
for course_key in self.course_keys:
|
||||
subsection_keys_in_course = []
|
||||
for subsection_index in range(self.num_subsections):
|
||||
subsection_keys_in_course.append(
|
||||
BlockUsageLocator(
|
||||
course_key=course_key,
|
||||
block_type='sequential',
|
||||
block_id=unicode(subsection_index),
|
||||
)
|
||||
)
|
||||
self.subsection_keys_by_course[course_key] = subsection_keys_in_course
|
||||
|
||||
def _update_or_create_grades(self, courses_keys=None):
|
||||
"""
|
||||
Creates grades for all courses and subsections.
|
||||
"""
|
||||
if courses_keys is None:
|
||||
courses_keys = self.course_keys
|
||||
|
||||
course_grade_params = {
|
||||
"course_version": "JoeMcEwing",
|
||||
"course_edited_timestamp": datetime(
|
||||
year=2016,
|
||||
month=8,
|
||||
day=1,
|
||||
hour=18,
|
||||
minute=53,
|
||||
second=24,
|
||||
microsecond=354741,
|
||||
),
|
||||
"percent_grade": 77.7,
|
||||
"letter_grade": "Great job",
|
||||
"passed": True,
|
||||
}
|
||||
subsection_grade_params = {
|
||||
"course_version": "deadbeef",
|
||||
"subtree_edited_timestamp": "2016-08-01 18:53:24.354741",
|
||||
"earned_all": 6.0,
|
||||
"possible_all": 12.0,
|
||||
"earned_graded": 6.0,
|
||||
"possible_graded": 8.0,
|
||||
"visible_blocks": MagicMock(),
|
||||
"first_attempted": datetime.now(),
|
||||
}
|
||||
|
||||
for course_key in courses_keys:
|
||||
for user_id in self.user_ids:
|
||||
course_grade_params['user_id'] = user_id
|
||||
course_grade_params['course_id'] = course_key
|
||||
PersistentCourseGrade.update_or_create(**course_grade_params)
|
||||
for subsection_key in self.subsection_keys_by_course[course_key]:
|
||||
subsection_grade_params['user_id'] = user_id
|
||||
subsection_grade_params['usage_key'] = subsection_key
|
||||
PersistentSubsectionGrade.update_or_create_grade(**subsection_grade_params)
|
||||
|
||||
def _assert_grades_exist_for_courses(self, course_keys, db_table=None):
|
||||
"""
|
||||
Assert grades for given courses exist.
|
||||
"""
|
||||
for course_key in course_keys:
|
||||
if db_table == "course" or db_table is None:
|
||||
self.assertIsNotNone(PersistentCourseGrade.read(self.user_ids[0], course_key))
|
||||
if db_table == "subsection" or db_table is None:
|
||||
for subsection_key in self.subsection_keys_by_course[course_key]:
|
||||
self.assertIsNotNone(PersistentSubsectionGrade.read_grade(self.user_ids[0], subsection_key))
|
||||
|
||||
def _assert_grades_absent_for_courses(self, course_keys, db_table=None):
|
||||
"""
|
||||
Assert grades for given courses do not exist.
|
||||
"""
|
||||
for course_key in course_keys:
|
||||
if db_table == "course" or db_table is None:
|
||||
with self.assertRaises(PersistentCourseGrade.DoesNotExist):
|
||||
PersistentCourseGrade.read(self.user_ids[0], course_key)
|
||||
|
||||
if db_table == "subsection" or db_table is None:
|
||||
for subsection_key in self.subsection_keys_by_course[course_key]:
|
||||
with self.assertRaises(PersistentSubsectionGrade.DoesNotExist):
|
||||
PersistentSubsectionGrade.read_grade(self.user_ids[0], subsection_key)
|
||||
|
||||
def _assert_stat_logged(self, mock_log, num_rows, grade_model_class, message_substring, log_offset):
|
||||
self.assertIn('reset_grade: ' + message_substring, mock_log.info.call_args_list[log_offset][0][0])
|
||||
self.assertEqual(grade_model_class.__name__, mock_log.info.call_args_list[log_offset][0][1])
|
||||
self.assertEqual(num_rows, mock_log.info.call_args_list[log_offset][0][2])
|
||||
|
||||
def _assert_course_delete_stat_logged(self, mock_log, num_rows):
|
||||
self._assert_stat_logged(mock_log, num_rows, PersistentCourseGrade, 'Deleted', log_offset=4)
|
||||
|
||||
def _assert_subsection_delete_stat_logged(self, mock_log, num_rows):
|
||||
self._assert_stat_logged(mock_log, num_rows, PersistentSubsectionGrade, 'Deleted', log_offset=2)
|
||||
|
||||
def _assert_course_query_stat_logged(self, mock_log, num_rows, num_courses=None):
|
||||
if num_courses is None:
|
||||
num_courses = self.num_courses
|
||||
log_offset = num_courses + 1 + num_courses + 1
|
||||
self._assert_stat_logged(mock_log, num_rows, PersistentCourseGrade, 'Would delete', log_offset)
|
||||
|
||||
def _assert_subsection_query_stat_logged(self, mock_log, num_rows, num_courses=None):
|
||||
if num_courses is None:
|
||||
num_courses = self.num_courses
|
||||
log_offset = num_courses + 1
|
||||
self._assert_stat_logged(mock_log, num_rows, PersistentSubsectionGrade, 'Would delete', log_offset)
|
||||
|
||||
def _date_from_now(self, days=None):
|
||||
return datetime.now() + timedelta(days=days)
|
||||
|
||||
def _date_str_from_now(self, days=None):
|
||||
future_date = self._date_from_now(days=days)
|
||||
return future_date.strftime(reset_grades.DATE_FORMAT)
|
||||
|
||||
@patch('lms.djangoapps.grades.management.commands.reset_grades.log')
|
||||
def test_reset_all_courses(self, mock_log):
|
||||
self._update_or_create_grades()
|
||||
self._assert_grades_exist_for_courses(self.course_keys)
|
||||
|
||||
with self.assertNumQueries(7):
|
||||
self.command.handle(delete=True, all_courses=True)
|
||||
|
||||
self._assert_grades_absent_for_courses(self.course_keys)
|
||||
self._assert_subsection_delete_stat_logged(
|
||||
mock_log,
|
||||
num_rows=self.num_users * self.num_courses * self.num_subsections,
|
||||
)
|
||||
self._assert_course_delete_stat_logged(
|
||||
mock_log,
|
||||
num_rows=self.num_users * self.num_courses,
|
||||
)
|
||||
|
||||
@patch('lms.djangoapps.grades.management.commands.reset_grades.log')
|
||||
@ddt.data(1, 2, 3)
|
||||
def test_reset_some_courses(self, num_courses_to_reset, mock_log):
|
||||
self._update_or_create_grades()
|
||||
self._assert_grades_exist_for_courses(self.course_keys)
|
||||
|
||||
with self.assertNumQueries(6):
|
||||
self.command.handle(
|
||||
delete=True,
|
||||
courses=[unicode(course_key) for course_key in self.course_keys[:num_courses_to_reset]]
|
||||
)
|
||||
|
||||
self._assert_grades_absent_for_courses(self.course_keys[:num_courses_to_reset])
|
||||
self._assert_grades_exist_for_courses(self.course_keys[num_courses_to_reset:])
|
||||
self._assert_subsection_delete_stat_logged(
|
||||
mock_log,
|
||||
num_rows=self.num_users * num_courses_to_reset * self.num_subsections,
|
||||
)
|
||||
self._assert_course_delete_stat_logged(
|
||||
mock_log,
|
||||
num_rows=self.num_users * num_courses_to_reset,
|
||||
)
|
||||
|
||||
def test_reset_by_modified_start_date(self):
|
||||
self._update_or_create_grades()
|
||||
self._assert_grades_exist_for_courses(self.course_keys)
|
||||
|
||||
num_courses_with_updated_grades = 2
|
||||
with freeze_time(self._date_from_now(days=4)):
|
||||
self._update_or_create_grades(self.course_keys[:num_courses_with_updated_grades])
|
||||
|
||||
with self.assertNumQueries(6):
|
||||
self.command.handle(delete=True, modified_start=self._date_str_from_now(days=2), all_courses=True)
|
||||
|
||||
self._assert_grades_absent_for_courses(self.course_keys[:num_courses_with_updated_grades])
|
||||
self._assert_grades_exist_for_courses(self.course_keys[num_courses_with_updated_grades:])
|
||||
|
||||
def test_reset_by_modified_start_end_date(self):
|
||||
self._update_or_create_grades()
|
||||
self._assert_grades_exist_for_courses(self.course_keys)
|
||||
|
||||
with freeze_time(self._date_from_now(days=3)):
|
||||
self._update_or_create_grades(self.course_keys[:2])
|
||||
with freeze_time(self._date_from_now(days=5)):
|
||||
self._update_or_create_grades(self.course_keys[2:4])
|
||||
|
||||
with self.assertNumQueries(6):
|
||||
self.command.handle(
|
||||
delete=True,
|
||||
modified_start=self._date_str_from_now(days=2),
|
||||
modified_end=self._date_str_from_now(days=4),
|
||||
all_courses=True,
|
||||
)
|
||||
|
||||
# Only grades for courses modified within the 2->4 days
|
||||
# should be deleted.
|
||||
self._assert_grades_absent_for_courses(self.course_keys[:2])
|
||||
self._assert_grades_exist_for_courses(self.course_keys[2:])
|
||||
|
||||
@ddt.data('subsection', 'course')
|
||||
def test_specify_db_table(self, db_table):
|
||||
self._update_or_create_grades()
|
||||
self._assert_grades_exist_for_courses(self.course_keys)
|
||||
self.command.handle(delete=True, all_courses=True, db_table=db_table)
|
||||
self._assert_grades_absent_for_courses(self.course_keys, db_table=db_table)
|
||||
if db_table == "subsection":
|
||||
self._assert_grades_exist_for_courses(self.course_keys, db_table='course')
|
||||
else:
|
||||
self._assert_grades_exist_for_courses(self.course_keys, db_table='subsection')
|
||||
|
||||
@patch('lms.djangoapps.grades.management.commands.reset_grades.log')
|
||||
def test_dry_run_all_courses(self, mock_log):
|
||||
self._update_or_create_grades()
|
||||
self._assert_grades_exist_for_courses(self.course_keys)
|
||||
|
||||
with self.assertNumQueries(2):
|
||||
self.command.handle(dry_run=True, all_courses=True)
|
||||
|
||||
self._assert_grades_exist_for_courses(self.course_keys)
|
||||
self._assert_subsection_query_stat_logged(
|
||||
mock_log,
|
||||
num_rows=self.num_users * self.num_courses * self.num_subsections,
|
||||
)
|
||||
self._assert_course_query_stat_logged(
|
||||
mock_log,
|
||||
num_rows=self.num_users * self.num_courses,
|
||||
)
|
||||
|
||||
@patch('lms.djangoapps.grades.management.commands.reset_grades.log')
|
||||
@ddt.data(1, 2, 3)
|
||||
def test_dry_run_some_courses(self, num_courses_to_query, mock_log):
|
||||
self._update_or_create_grades()
|
||||
self._assert_grades_exist_for_courses(self.course_keys)
|
||||
|
||||
with self.assertNumQueries(2):
|
||||
self.command.handle(
|
||||
dry_run=True,
|
||||
courses=[unicode(course_key) for course_key in self.course_keys[:num_courses_to_query]]
|
||||
)
|
||||
|
||||
self._assert_grades_exist_for_courses(self.course_keys)
|
||||
self._assert_subsection_query_stat_logged(
|
||||
mock_log,
|
||||
num_rows=self.num_users * num_courses_to_query * self.num_subsections,
|
||||
num_courses=num_courses_to_query,
|
||||
)
|
||||
self._assert_course_query_stat_logged(
|
||||
mock_log,
|
||||
num_rows=self.num_users * num_courses_to_query,
|
||||
num_courses=num_courses_to_query,
|
||||
)
|
||||
|
||||
@patch('lms.djangoapps.grades.management.commands.reset_grades.log')
|
||||
def test_reset_no_existing_grades(self, mock_log):
|
||||
self._assert_grades_absent_for_courses(self.course_keys)
|
||||
|
||||
with self.assertNumQueries(4):
|
||||
self.command.handle(delete=True, all_courses=True)
|
||||
|
||||
self._assert_grades_absent_for_courses(self.course_keys)
|
||||
self._assert_subsection_delete_stat_logged(mock_log, num_rows=0)
|
||||
self._assert_course_delete_stat_logged(mock_log, num_rows=0)
|
||||
|
||||
def test_invalid_key(self):
|
||||
with self.assertRaisesRegexp(CommandError, 'Invalid key specified.*invalid/key'):
|
||||
self.command.handle(dry_run=True, courses=['invalid/key'])
|
||||
|
||||
def test_invalid_db_table(self):
|
||||
with self.assertRaisesMessage(
|
||||
CommandError,
|
||||
'Invalid value for db_table. Valid options are "subsection" or "course" only.'
|
||||
):
|
||||
self.command.handle(delete=True, all_courses=True, db_table="not course or subsection")
|
||||
|
||||
def test_no_run_mode(self):
|
||||
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --delete, --dry_run'):
|
||||
self.command.handle(all_courses=True)
|
||||
|
||||
def test_both_run_modes(self):
|
||||
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --delete, --dry_run'):
|
||||
self.command.handle(all_courses=True, dry_run=True, delete=True)
|
||||
|
||||
def test_no_course_mode(self):
|
||||
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --courses, --all_courses'):
|
||||
self.command.handle(dry_run=True)
|
||||
|
||||
def test_both_course_modes(self):
|
||||
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --courses, --all_courses'):
|
||||
self.command.handle(dry_run=True, all_courses=True, courses=['some/course/key'])
|
||||
@@ -39,38 +39,6 @@ BLOCK_RECORD_LIST_VERSION = 1
|
||||
BlockRecord = namedtuple('BlockRecord', ['locator', 'weight', 'raw_possible', 'graded'])
|
||||
|
||||
|
||||
class DeleteGradesMixin(object):
|
||||
"""
|
||||
A Mixin class that provides functionality to delete grades.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def query_grades(cls, course_ids=None, modified_start=None, modified_end=None):
|
||||
"""
|
||||
Queries all the grades in the table, filtered by the provided arguments.
|
||||
"""
|
||||
kwargs = {}
|
||||
|
||||
if course_ids:
|
||||
kwargs['course_id__in'] = [course_id for course_id in course_ids]
|
||||
|
||||
if modified_start:
|
||||
if modified_end:
|
||||
kwargs['modified__range'] = (modified_start, modified_end)
|
||||
else:
|
||||
kwargs['modified__gt'] = modified_start
|
||||
|
||||
return cls.objects.filter(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def delete_grades(cls, *args, **kwargs):
|
||||
"""
|
||||
Deletes all the grades in the table, filtered by the provided arguments.
|
||||
"""
|
||||
query = cls.query_grades(*args, **kwargs)
|
||||
query.delete()
|
||||
|
||||
|
||||
class BlockRecordList(tuple):
|
||||
"""
|
||||
An immutable ordered list of BlockRecord objects.
|
||||
@@ -285,7 +253,7 @@ class VisibleBlocks(models.Model):
|
||||
return u"visible_blocks_cache.{}".format(course_key)
|
||||
|
||||
|
||||
class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
|
||||
class PersistentSubsectionGrade(TimeStampedModel):
|
||||
"""
|
||||
A django model tracking persistent grades at the subsection level.
|
||||
"""
|
||||
@@ -546,7 +514,7 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
|
||||
)
|
||||
|
||||
|
||||
class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel):
|
||||
class PersistentCourseGrade(TimeStampedModel):
|
||||
"""
|
||||
A django model tracking persistent course grades.
|
||||
"""
|
||||
|
||||
@@ -11,8 +11,10 @@ from xblock.scorable import ScorableXBlockMixin, Score
|
||||
from courseware.model_data import get_score, set_score
|
||||
from eventtracking import tracker
|
||||
from lms.djangoapps.instructor_task.tasks_helper.module_state import GRADES_OVERRIDE_EVENT_TYPE
|
||||
from openedx.core.djangoapps.course_groups.signals.signals import COHORT_MEMBERSHIP_UPDATED
|
||||
from openedx.core.lib.grade_utils import is_score_higher_or_equal
|
||||
from student.models import user_by_anonymous_id
|
||||
from student.signals.signals import ENROLLMENT_TRACK_UPDATED
|
||||
from submissions.models import score_reset, score_set
|
||||
from track.event_transaction_utils import (
|
||||
create_new_event_transaction_id,
|
||||
@@ -22,6 +24,7 @@ from track.event_transaction_utils import (
|
||||
)
|
||||
from util.date_utils import to_timestamp
|
||||
|
||||
from ..config.waffle import waffle, WRITE_ONLY_IF_ENGAGED
|
||||
from ..constants import ScoreDatabaseTableEnum
|
||||
from ..new.course_grade_factory import CourseGradeFactory
|
||||
from ..scores import weighted_score
|
||||
@@ -31,7 +34,7 @@ from .signals import (
|
||||
PROBLEM_WEIGHTED_SCORE_CHANGED,
|
||||
SCORE_PUBLISHED,
|
||||
SUBSECTION_SCORE_CHANGED,
|
||||
SUBSECTION_OVERRIDE_CHANGED
|
||||
SUBSECTION_OVERRIDE_CHANGED,
|
||||
)
|
||||
|
||||
log = getLogger(__name__)
|
||||
@@ -237,13 +240,28 @@ def enqueue_subsection_update(sender, **kwargs): # pylint: disable=unused-argum
|
||||
|
||||
|
||||
@receiver(SUBSECTION_SCORE_CHANGED)
|
||||
def recalculate_course_grade(sender, course, course_structure, user, **kwargs): # pylint: disable=unused-argument
|
||||
def recalculate_course_grade_only(sender, course, course_structure, user, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Updates a saved course grade.
|
||||
Updates a saved course grade, but does not update the subsection
|
||||
grades the user has in this course.
|
||||
"""
|
||||
CourseGradeFactory().update(user, course=course, course_structure=course_structure)
|
||||
|
||||
|
||||
@receiver(ENROLLMENT_TRACK_UPDATED)
|
||||
@receiver(COHORT_MEMBERSHIP_UPDATED)
|
||||
def force_recalculate_course_and_subsection_grades(sender, user, course_key, **kwargs):
|
||||
"""
|
||||
Updates a saved course grade, forcing the subsection grades
|
||||
from which it is calculated to update along the way.
|
||||
|
||||
Does not create a grade if the user has never attempted a problem,
|
||||
even if the WRITE_ONLY_IF_ENGAGED waffle switch is off.
|
||||
"""
|
||||
if waffle().is_enabled(WRITE_ONLY_IF_ENGAGED) or CourseGradeFactory().read(user, course_key=course_key):
|
||||
CourseGradeFactory().update(user=user, course_key=course_key, force_update_subsections=True)
|
||||
|
||||
|
||||
def _emit_event(kwargs):
|
||||
"""
|
||||
Emits a problem submitted event only if there is no current event
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Tests for the score change signals defined in the courseware models module.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
@@ -10,15 +10,20 @@ import pytz
|
||||
from django.test import TestCase
|
||||
from mock import MagicMock, patch
|
||||
|
||||
from opaque_keys.edx.locations import CourseLocator
|
||||
from openedx.core.djangoapps.course_groups.signals.signals import COHORT_MEMBERSHIP_UPDATED
|
||||
from student.signals.signals import ENROLLMENT_TRACK_UPDATED
|
||||
from student.tests.factories import UserFactory
|
||||
from submissions.models import score_reset, score_set
|
||||
from util.date_utils import to_timestamp
|
||||
|
||||
from ..config.waffle import waffle, WRITE_ONLY_IF_ENGAGED
|
||||
from ..constants import ScoreDatabaseTableEnum
|
||||
from ..signals.handlers import (
|
||||
disconnect_submissions_signal_receiver,
|
||||
problem_raw_score_changed_handler,
|
||||
submissions_score_reset_handler,
|
||||
submissions_score_set_handler
|
||||
submissions_score_set_handler,
|
||||
)
|
||||
from ..signals.signals import PROBLEM_RAW_SCORE_CHANGED
|
||||
|
||||
@@ -251,3 +256,32 @@ class ScoreChangedSignalRelayTest(TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
with disconnect_submissions_signal_receiver(PROBLEM_RAW_SCORE_CHANGED):
|
||||
pass
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class RecalculateUserGradeSignalsTest(TestCase):
|
||||
def setUp(self):
|
||||
super(RecalculateUserGradeSignalsTest, self).setUp()
|
||||
self.user = UserFactory()
|
||||
self.course_key = CourseLocator("test_org", "test_course_num", "test_run")
|
||||
|
||||
@patch('lms.djangoapps.grades.signals.handlers.CourseGradeFactory.update')
|
||||
@patch('lms.djangoapps.grades.signals.handlers.CourseGradeFactory.read')
|
||||
@ddt.data(*itertools.product((COHORT_MEMBERSHIP_UPDATED, ENROLLMENT_TRACK_UPDATED), (True, False), (True, False)))
|
||||
@ddt.unpack
|
||||
def test_recalculate_on_signal(self, signal, write_only_if_engaged, has_grade, read_mock, update_mock):
|
||||
"""
|
||||
Tests the grades handler for signals that trigger regrading.
|
||||
The handler should call CourseGradeFactory.update() with the
|
||||
args below, *except* if the WRITE_ONLY_IF_ENGAGED waffle flag
|
||||
is inactive and the user does not have a grade.
|
||||
"""
|
||||
if not has_grade:
|
||||
read_mock.return_value = None
|
||||
with waffle().override(WRITE_ONLY_IF_ENGAGED, active=write_only_if_engaged):
|
||||
signal.send(sender=None, user=self.user, course_key=self.course_key)
|
||||
|
||||
if not write_only_if_engaged and not has_grade:
|
||||
update_mock.assert_not_called()
|
||||
else:
|
||||
update_mock.assert_called_with(course_key=self.course_key, user=self.user, force_update_subsections=True)
|
||||
|
||||
@@ -401,6 +401,9 @@ FEATURES = {
|
||||
|
||||
# Whether the bulk enrollment view is enabled.
|
||||
'ENABLE_BULK_ENROLLMENT_VIEW': False,
|
||||
|
||||
# Whether course goals is enabled.
|
||||
'ENABLE_COURSE_GOALS': True,
|
||||
}
|
||||
|
||||
# Settings for the course reviews tool template and identification key, set either to None to disable course reviews
|
||||
@@ -2245,6 +2248,9 @@ INSTALLED_APPS = [
|
||||
'openedx.core.djangoapps.waffle_utils',
|
||||
'openedx.core.djangoapps.schedules.apps.SchedulesConfig',
|
||||
|
||||
# Course Goals
|
||||
'lms.djangoapps.course_goals',
|
||||
|
||||
# Features
|
||||
'openedx.features.course_bookmarks',
|
||||
'openedx.features.course_experience',
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
}
|
||||
|
||||
.message-content {
|
||||
@include margin(0, 0, $baseline, $baseline);
|
||||
position: relative;
|
||||
border: 1px solid $lms-border-color;
|
||||
margin: 0 $baseline $baseline/2;
|
||||
padding: $baseline/2 $baseline;
|
||||
padding: $baseline;
|
||||
border-radius: $baseline/4;
|
||||
width: calc(100% - 90px);
|
||||
|
||||
@media (max-width: $grid-breakpoints-md) {
|
||||
width: 100%;
|
||||
@@ -30,7 +31,7 @@
|
||||
&::before {
|
||||
@include left(0);
|
||||
|
||||
bottom: 35%;
|
||||
top: 25px;
|
||||
border: solid transparent;
|
||||
height: 0;
|
||||
width: 0;
|
||||
@@ -58,13 +59,49 @@
|
||||
|
||||
.message-header {
|
||||
font-weight: $font-semibold;
|
||||
margin-bottom: $baseline/4;
|
||||
margin-bottom: $baseline/2;
|
||||
width: calc(100% - 40px)
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: $font-semibold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dismiss {
|
||||
@include right($baseline/4);
|
||||
top: $baseline/4;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
color: $black-t3;
|
||||
|
||||
&:hover {
|
||||
color: $black-t2;
|
||||
}
|
||||
}
|
||||
// Course Goal Styling
|
||||
.goal-options-container {
|
||||
margin-top: $baseline;
|
||||
text-align: center;
|
||||
|
||||
.goal-option {
|
||||
text-decoration: none;
|
||||
font-size: font-size(x-small);
|
||||
padding: $baseline/2;
|
||||
|
||||
&.dismissible {
|
||||
@include right($baseline/4);
|
||||
position: absolute;
|
||||
top: $baseline/2;
|
||||
font-size: font-size(small);
|
||||
color: $uxpl-blue-base;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $black-t2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ site_status_msg = get_site_status_msg(course_id)
|
||||
</%block>
|
||||
|
||||
% if uses_bootstrap:
|
||||
<header class="navigation-container header-global ${"slim" if course else ""}">
|
||||
<header class="navigation-container header-global ${'slim' if course else ''}">
|
||||
<nav class="navbar navbar-expand-lg navbar-light">
|
||||
<%include file="bootstrap/navbar-logo-header.html" args="online_help_token=online_help_token"/>
|
||||
<button class="navbar-toggler navbar-toggler-right mt-2" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
|
||||
@@ -829,6 +829,11 @@ urlpatterns += (
|
||||
url(r'^commerce/', include('commerce.urls', namespace='commerce')),
|
||||
)
|
||||
|
||||
# Course goals
|
||||
urlpatterns += (
|
||||
url(r'^api/course_goals/', include('lms.djangoapps.course_goals.urls', namespace='course_goals_api')),
|
||||
)
|
||||
|
||||
# Embargo
|
||||
if settings.FEATURES.get('EMBARGO'):
|
||||
urlpatterns += (
|
||||
|
||||
@@ -106,7 +106,10 @@ class TestGenerateCourseOverview(ModuleStoreTestCase):
|
||||
self.command.handle(all_courses=True, force_update=True, routing_key='my-routing-key', chunk_size=10000)
|
||||
|
||||
called_kwargs = mock_async_task.apply_async.call_args_list[0][1]
|
||||
self.assertEquals(sorted([unicode(self.course_key_1), unicode(self.course_key_2)]), called_kwargs.pop('args'))
|
||||
self.assertEquals(
|
||||
sorted([unicode(self.course_key_1), unicode(self.course_key_2)]),
|
||||
sorted(called_kwargs.pop('args'))
|
||||
)
|
||||
self.assertEquals({
|
||||
'kwargs': {'force_update': True},
|
||||
'routing_key': 'my-routing-key'
|
||||
|
||||
@@ -28,6 +28,7 @@ from .models import (
|
||||
CourseUserGroupPartitionGroup,
|
||||
UnregisteredLearnerCohortAssignments
|
||||
)
|
||||
from .signals.signals import COHORT_MEMBERSHIP_UPDATED
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -424,7 +425,9 @@ def remove_user_from_cohort(cohort, username_or_email):
|
||||
|
||||
try:
|
||||
membership = CohortMembership.objects.get(course_user_group=cohort, user=user)
|
||||
course_key = membership.course_id
|
||||
membership.delete()
|
||||
COHORT_MEMBERSHIP_UPDATED.send(sender=None, user=user, course_key=course_key)
|
||||
except CohortMembership.DoesNotExist:
|
||||
raise ValueError("User {} was not present in cohort {}".format(username_or_email, cohort))
|
||||
|
||||
@@ -454,7 +457,7 @@ def add_user_to_cohort(cohort, username_or_email):
|
||||
|
||||
membership = CohortMembership(course_user_group=cohort, user=user)
|
||||
membership.save() # This will handle both cases, creation and updating, of a CohortMembership for this user.
|
||||
|
||||
COHORT_MEMBERSHIP_UPDATED.send(sender=None, user=user, course_key=membership.course_id)
|
||||
tracker.emit(
|
||||
"edx.cohort.user_add_requested",
|
||||
{
|
||||
|
||||
6
openedx/core/djangoapps/course_groups/signals/signals.py
Normal file
6
openedx/core/djangoapps/course_groups/signals/signals.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Cohorts related signals.
|
||||
"""
|
||||
from django.dispatch import Signal
|
||||
|
||||
COHORT_MEMBERSHIP_UPDATED = Signal(providing_args=['user', 'course_key'])
|
||||
@@ -591,7 +591,8 @@ class TestCohorts(ModuleStoreTestCase):
|
||||
)
|
||||
|
||||
@patch("openedx.core.djangoapps.course_groups.cohorts.tracker")
|
||||
def test_add_user_to_cohort(self, mock_tracker):
|
||||
@patch("openedx.core.djangoapps.course_groups.cohorts.COHORT_MEMBERSHIP_UPDATED")
|
||||
def test_add_user_to_cohort(self, mock_signal, mock_tracker):
|
||||
"""
|
||||
Make sure cohorts.add_user_to_cohort() properly adds a user to a cohort and
|
||||
handles errors.
|
||||
@@ -603,6 +604,10 @@ class TestCohorts(ModuleStoreTestCase):
|
||||
first_cohort = CohortFactory(course_id=course.id, name="FirstCohort")
|
||||
second_cohort = CohortFactory(course_id=course.id, name="SecondCohort")
|
||||
|
||||
def check_and_reset_signal():
|
||||
mock_signal.send.assert_called_with(sender=None, user=course_user, course_key=self.toy_course_key)
|
||||
mock_signal.reset_mock()
|
||||
|
||||
# Success cases
|
||||
# We shouldn't get back a previous cohort, since the user wasn't in one
|
||||
self.assertEqual(
|
||||
@@ -619,6 +624,8 @@ class TestCohorts(ModuleStoreTestCase):
|
||||
"previous_cohort_name": None,
|
||||
}
|
||||
)
|
||||
check_and_reset_signal()
|
||||
|
||||
# Should get (user, previous_cohort_name) when moved from one cohort to
|
||||
# another
|
||||
self.assertEqual(
|
||||
@@ -635,6 +642,8 @@ class TestCohorts(ModuleStoreTestCase):
|
||||
"previous_cohort_name": first_cohort.name,
|
||||
}
|
||||
)
|
||||
check_and_reset_signal()
|
||||
|
||||
# Should preregister email address for a cohort if an email address
|
||||
# not associated with a user is added
|
||||
(user, previous_cohort, prereg) = cohorts.add_user_to_cohort(first_cohort, "new_email@example.com")
|
||||
@@ -650,6 +659,7 @@ class TestCohorts(ModuleStoreTestCase):
|
||||
"cohort_name": first_cohort.name,
|
||||
}
|
||||
)
|
||||
|
||||
# Error cases
|
||||
# Should get ValueError if user already in cohort
|
||||
self.assertRaises(
|
||||
|
||||
@@ -158,7 +158,8 @@ class CreditApiTestBase(ModuleStoreTestCase):
|
||||
|
||||
def setUp(self, **kwargs):
|
||||
super(CreditApiTestBase, self).setUp()
|
||||
self.course_key = CourseKey.from_string("edX/DemoX/Demo_Course")
|
||||
self.course = CourseFactory.create(org="edx", course="DemoX", run="Demo_Course")
|
||||
self.course_key = self.course.id
|
||||
|
||||
def add_credit_course(self, course_key=None, enabled=True):
|
||||
"""Mark the course as a credit """
|
||||
@@ -631,8 +632,6 @@ class CreditRequirementApiTests(CreditApiTestBase):
|
||||
# Configure a course with two credit requirements
|
||||
self.add_credit_course()
|
||||
user = self.create_and_enroll_user(username=self.USER_INFO['username'], password=self.USER_INFO['password'])
|
||||
CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course')
|
||||
|
||||
requirements = [
|
||||
{
|
||||
"namespace": "grade",
|
||||
@@ -664,7 +663,7 @@ class CreditRequirementApiTests(CreditApiTestBase):
|
||||
self.assertFalse(api.is_user_eligible_for_credit(user.username, self.course_key))
|
||||
|
||||
# Satisfy the other requirement
|
||||
with self.assertNumQueries(24):
|
||||
with self.assertNumQueries(23):
|
||||
api.set_credit_requirement_status(
|
||||
user,
|
||||
self.course_key,
|
||||
@@ -822,7 +821,6 @@ class CreditRequirementApiTests(CreditApiTestBase):
|
||||
# Configure a course with two credit requirements
|
||||
self.add_credit_course()
|
||||
user = self.create_and_enroll_user(username=self.USER_INFO['username'], password=self.USER_INFO['password'])
|
||||
CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course')
|
||||
requirements = [
|
||||
{
|
||||
"namespace": "grade",
|
||||
|
||||
@@ -166,4 +166,5 @@ class IsStaffOrOwner(permissions.BasePermission):
|
||||
return user.is_staff \
|
||||
or (user.username == request.GET.get('username')) \
|
||||
or (user.username == getattr(request, 'data', {}).get('username')) \
|
||||
or (user.username == getattr(request, 'data', {}).get('user')) \
|
||||
or (user.username == getattr(view, 'kwargs', {}).get('username'))
|
||||
|
||||
@@ -16,15 +16,18 @@ COURSE_OUTLINE_PAGE_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'course_outli
|
||||
# Waffle flag to enable a single unified "Course" tab.
|
||||
UNIFIED_COURSE_TAB_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'unified_course_tab')
|
||||
|
||||
# Waffle flag to enable the sock on the footer of the home and courseware pages
|
||||
# Waffle flag to enable the sock on the footer of the home and courseware pages.
|
||||
DISPLAY_COURSE_SOCK_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'display_course_sock')
|
||||
|
||||
# Waffle flag to let learners access a course before its start date
|
||||
# Waffle flag to let learners access a course before its start date.
|
||||
COURSE_PRE_START_ACCESS_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'pre_start_access')
|
||||
|
||||
# Waffle flag to enable a review page link from the unified home page
|
||||
# Waffle flag to enable a review page link from the unified home page.
|
||||
SHOW_REVIEWS_TOOL_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'show_reviews_tool')
|
||||
|
||||
# Waffle flag to enable the setting of course goals.
|
||||
ENABLE_COURSE_GOALS = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'enable_course_goals')
|
||||
|
||||
SHOW_UPGRADE_MSG_ON_COURSE_HOME = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'show_upgrade_msg_on_course_home')
|
||||
|
||||
# Waffle flag to switch between the 'welcome message' and 'latest update' on the course home page.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/* globals gettext */
|
||||
|
||||
export class CourseGoals { // eslint-disable-line import/prefer-default-export
|
||||
|
||||
constructor(options) {
|
||||
$('.goal-option').click((e) => {
|
||||
const goalKey = $(e.target).data().choice;
|
||||
$.ajax({
|
||||
method: 'POST',
|
||||
url: options.goalApiUrl,
|
||||
headers: { 'X-CSRFToken': $.cookie('csrftoken') },
|
||||
data: {
|
||||
goal_key: goalKey,
|
||||
course_key: options.courseId,
|
||||
user: options.username,
|
||||
},
|
||||
dataType: 'json',
|
||||
success: () => {
|
||||
// LEARNER-2522 will address the success message
|
||||
const successMsg = gettext('Thank you for setting your course goal!');
|
||||
// xss-lint: disable=javascript-jquery-html
|
||||
$('.message-content').html(`<div class="success-message">${successMsg}</div>`);
|
||||
},
|
||||
error: () => {
|
||||
// LEARNER-2522 will address the error message
|
||||
const errorMsg = gettext('There was an error in setting your goal, please reload the page and try again.'); // eslint-disable-line max-len
|
||||
// xss-lint: disable=javascript-jquery-html
|
||||
$('.message-content').html(`<div class="error-message"> ${errorMsg} </div>`);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Allow goal selection with an enter press for accessibility purposes
|
||||
$('.goal-option').keyup((e) => {
|
||||
if (e.which === 13) {
|
||||
$(e.target).trigger('click');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,18 @@ export class CourseHome { // eslint-disable-line import/prefer-default-export
|
||||
);
|
||||
});
|
||||
|
||||
// Dismissibility for in course messages
|
||||
$(document.body).on('click', '.course-message .dismiss', (event) => {
|
||||
$(event.target).closest('.course-message').hide();
|
||||
});
|
||||
|
||||
// Allow dismiss on enter press for accessibility purposes
|
||||
$(document.body).on('keyup', '.course-message .dismiss', (event) => {
|
||||
if (event.which === 13) {
|
||||
$(event.target).trigger('click');
|
||||
}
|
||||
});
|
||||
|
||||
$(document).ready(() => {
|
||||
this.configureUpgradeMessage();
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
<%!
|
||||
from django.utils.translation import get_language_bidi
|
||||
from django.utils.translation import ugettext as _
|
||||
from openedx.core.djangolib.js_utils import js_escaped_string
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
from openedx.features.course_experience import CourseHomeMessages
|
||||
%>
|
||||
@@ -17,14 +19,22 @@ is_rtl = get_language_bidi()
|
||||
% for message in course_home_messages:
|
||||
<div class="course-message grid-manual">
|
||||
% if not is_rtl:
|
||||
<img class="message-author col col-2" src="${static.url(image_src)}"/>
|
||||
<img class="message-author" alt="${_('Course message author')}" role="none" src="${static.url(image_src)}"/>
|
||||
% endif
|
||||
<div class="message-content col col-9">
|
||||
<div class="message-content">
|
||||
${HTML(message.message_html)}
|
||||
</div>
|
||||
% if is_rtl:
|
||||
<img class="message-author col col-2" src="${static.url(image_src)}"/>
|
||||
<img class="message-author" alt="${_('Course message author')}" role="none" src="${static.url(image_src)}"/>
|
||||
% endif
|
||||
</div>
|
||||
% endfor
|
||||
% endif
|
||||
|
||||
<%static:webpack entry="CourseGoals">
|
||||
new CourseGoals({
|
||||
goalApiUrl: "${goal_api_url | n, js_escaped_string}",
|
||||
courseId: "${course_id | n, js_escaped_string}",
|
||||
username: "${username | n, js_escaped_string}",
|
||||
});
|
||||
</%static:webpack>
|
||||
|
||||
@@ -16,6 +16,7 @@ from waffle.testutils import override_flag
|
||||
|
||||
from commerce.models import CommerceConfiguration
|
||||
from commerce.utils import EcommerceService
|
||||
from lms.djangoapps.course_goals.api import add_course_goal, remove_course_goal
|
||||
from course_modes.models import CourseMode
|
||||
from courseware.tests.factories import StaffFactory
|
||||
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES, override_waffle_flag
|
||||
@@ -25,14 +26,14 @@ from openedx.features.course_experience import (
|
||||
UNIFIED_COURSE_TAB_FLAG
|
||||
)
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from util.date_utils import strftime_localized
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase, SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
|
||||
from .helpers import add_course_mode
|
||||
from .test_course_updates import create_course_update, remove_course_updates
|
||||
from ... import COURSE_PRE_START_ACCESS_FLAG
|
||||
from ... import COURSE_PRE_START_ACCESS_FLAG, ENABLE_COURSE_GOALS
|
||||
|
||||
TEST_PASSWORD = 'test'
|
||||
TEST_CHAPTER_NAME = 'Test Chapter'
|
||||
@@ -43,6 +44,8 @@ TEST_COURSE_HOME_MESSAGE = 'course-message'
|
||||
TEST_COURSE_HOME_MESSAGE_ANONYMOUS = '/login'
|
||||
TEST_COURSE_HOME_MESSAGE_UNENROLLED = 'Enroll now'
|
||||
TEST_COURSE_HOME_MESSAGE_PRE_START = 'Course starts in'
|
||||
TEST_COURSE_GOAL_OPTIONS = 'goal-options-container'
|
||||
COURSE_GOAL_DISMISS_OPTION = 'unsure'
|
||||
|
||||
QUERY_COUNT_TABLE_BLACKLIST = WAFFLE_TABLES
|
||||
|
||||
@@ -170,7 +173,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
|
||||
course_home_url(self.course)
|
||||
|
||||
# Fetch the view and verify the query counts
|
||||
with self.assertNumQueries(41, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with self.assertNumQueries(44, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with check_mongo_calls(4):
|
||||
url = course_home_url(self.course)
|
||||
self.client.get(url)
|
||||
@@ -375,11 +378,13 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
|
||||
self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
|
||||
self.assertContains(response, TEST_COURSE_HOME_MESSAGE_UNENROLLED)
|
||||
|
||||
# Verify that enrolled users are not shown a message when enrolled and course has begun
|
||||
# Verify that enrolled users are not shown any state warning message when enrolled and course has begun.
|
||||
CourseEnrollment.enroll(user, self.course.id)
|
||||
url = course_home_url(self.course)
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE)
|
||||
self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE_ANONYMOUS)
|
||||
self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE_UNENROLLED)
|
||||
self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE_PRE_START)
|
||||
|
||||
# Verify that enrolled users are shown 'days until start' message before start date
|
||||
future_course = self.create_future_course()
|
||||
@@ -389,6 +394,50 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
|
||||
self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
|
||||
self.assertContains(response, TEST_COURSE_HOME_MESSAGE_PRE_START)
|
||||
|
||||
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
|
||||
@override_waffle_flag(COURSE_PRE_START_ACCESS_FLAG, active=True)
|
||||
@override_waffle_flag(ENABLE_COURSE_GOALS, active=True)
|
||||
def test_course_goals(self):
|
||||
"""
|
||||
Ensure that the following five use cases work as expected.
|
||||
|
||||
1) Unenrolled users are not shown the set course goal message.
|
||||
2) Enrolled users are shown the set course goal message if they have not yet set a course goal.
|
||||
3) Enrolled users are not shown the set course goal message if they have set a course goal.
|
||||
4) Enrolled and verified users are not shown the set course goal message.
|
||||
5) Enrolled users are not shown the set course goal message in a course that cannot be verified.
|
||||
"""
|
||||
# Create a course with a verified track.
|
||||
verifiable_course = CourseFactory.create()
|
||||
add_course_mode(verifiable_course, upgrade_deadline_expired=False)
|
||||
|
||||
# Verify that unenrolled users are not shown the set course goal message.
|
||||
user = self.create_user_for_course(verifiable_course, CourseUserType.UNENROLLED)
|
||||
response = self.client.get(course_home_url(verifiable_course))
|
||||
self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
|
||||
|
||||
# Verify that enrolled users are shown the set course goal message in a verified course.
|
||||
CourseEnrollment.enroll(user, verifiable_course.id)
|
||||
response = self.client.get(course_home_url(verifiable_course))
|
||||
self.assertContains(response, TEST_COURSE_GOAL_OPTIONS)
|
||||
|
||||
# Verify that enrolled users that have set a course goal are not shown the set course goal message.
|
||||
add_course_goal(user, verifiable_course.id, COURSE_GOAL_DISMISS_OPTION)
|
||||
response = self.client.get(course_home_url(verifiable_course))
|
||||
self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
|
||||
|
||||
# Verify that enrolled and verified users are not shown the set course goal message.
|
||||
remove_course_goal(user, verifiable_course.id)
|
||||
CourseEnrollment.enroll(user, verifiable_course.id, CourseMode.VERIFIED)
|
||||
response = self.client.get(course_home_url(verifiable_course))
|
||||
self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
|
||||
|
||||
# Verify that enrolled users are not shown the set course goal message in an audit only course.
|
||||
audit_only_course = CourseFactory.create()
|
||||
CourseEnrollment.enroll(user, audit_only_course.id)
|
||||
response = self.client.get(course_home_url(audit_only_course))
|
||||
self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
|
||||
|
||||
|
||||
class CourseHomeFragmentViewTests(ModuleStoreTestCase):
|
||||
CREATE_USER = False
|
||||
|
||||
@@ -56,7 +56,7 @@ class TestCourseSockView(SharedModuleStoreTestCase):
|
||||
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
|
||||
def test_standard_course(self):
|
||||
"""
|
||||
Assure that a course that cannot be verified does
|
||||
Ensure that a course that cannot be verified does
|
||||
not have a visible verification sock.
|
||||
"""
|
||||
response = self.client.get(course_home_url(self.standard_course))
|
||||
@@ -65,7 +65,7 @@ class TestCourseSockView(SharedModuleStoreTestCase):
|
||||
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
|
||||
def test_verified_course(self):
|
||||
"""
|
||||
Assure that a course that can be verified has a
|
||||
Ensure that a course that can be verified has a
|
||||
visible verification sock.
|
||||
"""
|
||||
response = self.client.get(course_home_url(self.verified_course))
|
||||
@@ -74,7 +74,7 @@ class TestCourseSockView(SharedModuleStoreTestCase):
|
||||
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
|
||||
def test_verified_course_updated_expired(self):
|
||||
"""
|
||||
Assure that a course that has an expired upgrade
|
||||
Ensure that a course that has an expired upgrade
|
||||
date does not display the verification sock.
|
||||
"""
|
||||
response = self.client.get(course_home_url(self.verified_course_update_expired))
|
||||
@@ -83,7 +83,7 @@ class TestCourseSockView(SharedModuleStoreTestCase):
|
||||
@override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True)
|
||||
def test_verified_course_user_already_upgraded(self):
|
||||
"""
|
||||
Assure that a user that has already upgraded to a
|
||||
Ensure that a user that has already upgraded to a
|
||||
verified status cannot see the verification sock.
|
||||
"""
|
||||
response = self.client.get(course_home_url(self.verified_course_already_enrolled))
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
"""
|
||||
View logic for handling course messages.
|
||||
"""
|
||||
|
||||
from babel.dates import format_date, format_timedelta
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from courseware.courses import get_course_with_access
|
||||
from babel.dates import format_date, format_timedelta
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.http import urlquote_plus
|
||||
from django.utils.timezone import UTC
|
||||
from django.utils.translation import get_language, to_locale
|
||||
from django.utils.translation import ugettext as _
|
||||
from openedx.core.djangolib.markup import Text, HTML
|
||||
from django.utils.translation import get_language, to_locale
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework.reverse import reverse
|
||||
from web_fragments.fragment import Fragment
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from courseware.courses import get_course_with_access
|
||||
from lms.djangoapps.course_goals.api import CourseGoalOption, get_course_goal, get_goal_text
|
||||
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from openedx.features.course_experience import CourseHomeMessages
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
from .. import ENABLE_COURSE_GOALS
|
||||
|
||||
|
||||
class CourseHomeMessageFragmentView(EdxFragmentView):
|
||||
@@ -55,69 +63,140 @@ class CourseHomeMessageFragmentView(EdxFragmentView):
|
||||
}
|
||||
|
||||
# Register the course home messages to be loaded on the page
|
||||
self.register_course_home_messages(request, course, user_access, course_start_data)
|
||||
_register_course_home_messages(request, course_id, user_access, course_start_data)
|
||||
|
||||
# Grab the relevant messages
|
||||
course_home_messages = list(CourseHomeMessages.user_messages(request))
|
||||
|
||||
# Return None if user is enrolled and course has begun
|
||||
if user_access['is_enrolled'] and already_started:
|
||||
return None
|
||||
# Pass in the url used to set a course goal
|
||||
goal_api_url = reverse('course_goals_api:v0:course_goal-list', request=request)
|
||||
|
||||
# Grab the logo
|
||||
image_src = "course_experience/images/home_message_author.png"
|
||||
|
||||
context = {
|
||||
'course_home_messages': course_home_messages,
|
||||
'goal_api_url': goal_api_url,
|
||||
'image_src': image_src,
|
||||
'course_id': course_id,
|
||||
'username': request.user.username,
|
||||
}
|
||||
|
||||
html = render_to_string('course_experience/course-messages-fragment.html', context)
|
||||
return Fragment(html)
|
||||
|
||||
@staticmethod
|
||||
def register_course_home_messages(request, course, user_access, course_start_data):
|
||||
"""
|
||||
Register messages to be shown in the course home content page.
|
||||
"""
|
||||
if user_access['is_anonymous']:
|
||||
CourseHomeMessages.register_info_message(
|
||||
request,
|
||||
Text(_(
|
||||
" {sign_in_link} or {register_link} and then enroll in this course."
|
||||
)).format(
|
||||
sign_in_link=HTML("<a href='/login?next={current_url}'>{sign_in_label}</a>").format(
|
||||
sign_in_label=_("Sign in"),
|
||||
current_url=urlquote_plus(request.path),
|
||||
|
||||
def _register_course_home_messages(request, course_id, user_access, course_start_data):
|
||||
"""
|
||||
Register messages to be shown in the course home content page.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course = get_course_with_access(request.user, 'load', course_key)
|
||||
if user_access['is_anonymous']:
|
||||
CourseHomeMessages.register_info_message(
|
||||
request,
|
||||
Text(_(
|
||||
" {sign_in_link} or {register_link} and then enroll in this course."
|
||||
)).format(
|
||||
sign_in_link=HTML("<a href='/login?next={current_url}'>{sign_in_label}</a>").format(
|
||||
sign_in_label=_("Sign in"),
|
||||
current_url=urlquote_plus(request.path),
|
||||
),
|
||||
register_link=HTML("<a href='/register?next={current_url}'>{register_label}</a>").format(
|
||||
register_label=_("register"),
|
||||
current_url=urlquote_plus(request.path),
|
||||
)
|
||||
),
|
||||
title=Text(_('You must be enrolled in the course to see course content.'))
|
||||
)
|
||||
if not user_access['is_anonymous'] and not user_access['is_staff'] and not user_access['is_enrolled']:
|
||||
CourseHomeMessages.register_info_message(
|
||||
request,
|
||||
Text(_(
|
||||
"{open_enroll_link} Enroll now{close_enroll_link} to access the full course."
|
||||
)).format(
|
||||
open_enroll_link='',
|
||||
close_enroll_link=''
|
||||
),
|
||||
title=Text(_('Welcome to {course_display_name}')).format(
|
||||
course_display_name=course.display_name
|
||||
)
|
||||
)
|
||||
if user_access['is_enrolled'] and not course_start_data['already_started']:
|
||||
CourseHomeMessages.register_info_message(
|
||||
request,
|
||||
Text(_(
|
||||
"Don't forget to add a calendar reminder!"
|
||||
)),
|
||||
title=Text(_("Course starts in {days_until_start_string} on {course_start_date}.")).format(
|
||||
days_until_start_string=course_start_data['days_until_start_string'],
|
||||
course_start_date=course_start_data['course_start_date']
|
||||
)
|
||||
)
|
||||
|
||||
# Only show the set course goal message for enrolled, unverified
|
||||
# users that have not yet set a goal in a course that allows for
|
||||
# verified statuses.
|
||||
has_verified_mode = CourseMode.has_verified_mode(CourseMode.modes_for_course_dict(unicode(course.id)))
|
||||
is_already_verified = CourseEnrollment.is_enrolled_as_verified(request.user, course_key)
|
||||
user_goal = get_course_goal(auth.get_user(request), course_key) if not request.user.is_anonymous() else None
|
||||
if user_access['is_enrolled'] and has_verified_mode and not is_already_verified and not user_goal \
|
||||
and ENABLE_COURSE_GOALS.is_enabled(course_key) and settings.FEATURES.get('ENABLE_COURSE_GOALS'):
|
||||
goal_choices_html = Text(_(
|
||||
'To start, set a course goal by selecting the option below that best describes '
|
||||
'your learning plan. {goal_options_container}'
|
||||
)).format(
|
||||
goal_options_container=HTML('<div class="row goal-options-container">')
|
||||
)
|
||||
|
||||
# Add the dismissible option for users that are unsure of their goal
|
||||
goal_choices_html += Text(
|
||||
'{initial_tag}{choice}{closing_tag}'
|
||||
).format(
|
||||
initial_tag=HTML(
|
||||
'<div tabindex="0" aria-label="{aria_label_choice}" class="goal-option dismissible" '
|
||||
'data-choice="{goal_key}">'
|
||||
).format(
|
||||
goal_key=CourseGoalOption.UNSURE.value,
|
||||
aria_label_choice=Text(_("Set goal to: {choice}")).format(
|
||||
choice=get_goal_text(CourseGoalOption.UNSURE.value)
|
||||
),
|
||||
),
|
||||
choice=Text(_('{choice}')).format(
|
||||
choice=get_goal_text(CourseGoalOption.UNSURE.value),
|
||||
),
|
||||
closing_tag=HTML('</div>'),
|
||||
)
|
||||
|
||||
# Add the option to set a goal to earn a certificate,
|
||||
# complete the course or explore the course
|
||||
goal_options = [CourseGoalOption.CERTIFY.value, CourseGoalOption.COMPLETE.value, CourseGoalOption.EXPLORE.value]
|
||||
for goal_key in goal_options:
|
||||
goal_text = get_goal_text(goal_key)
|
||||
goal_choices_html += HTML(
|
||||
'{initial_tag}{goal_text}{closing_tag}'
|
||||
).format(
|
||||
initial_tag=HTML(
|
||||
'<div tabindex="0" aria-label="{aria_label_choice}" class="goal-option {col_sel} btn" '
|
||||
'data-choice="{goal_key}">'
|
||||
).format(
|
||||
goal_key=goal_key,
|
||||
aria_label_choice=Text(_("Set goal to: {goal_text}")).format(
|
||||
goal_text=Text(_(goal_text))
|
||||
),
|
||||
register_link=HTML("<a href='/register?next={current_url}'>{register_label}</a>").format(
|
||||
register_label=_("register"),
|
||||
current_url=urlquote_plus(request.path),
|
||||
)
|
||||
col_sel='col-' + str(int(math.floor(12 / len(goal_options))))
|
||||
),
|
||||
title='You must be enrolled in the course to see course content.'
|
||||
goal_text=goal_text,
|
||||
closing_tag=HTML('</div>')
|
||||
)
|
||||
if not user_access['is_anonymous'] and not user_access['is_staff'] and not user_access['is_enrolled']:
|
||||
CourseHomeMessages.register_info_message(
|
||||
request,
|
||||
Text(_(
|
||||
"{open_enroll_link} Enroll now{close_enroll_link} to access the full course."
|
||||
)).format(
|
||||
open_enroll_link='',
|
||||
close_enroll_link=''
|
||||
),
|
||||
title=Text('Welcome to {course_display_name}').format(
|
||||
course_display_name=course.display_name
|
||||
)
|
||||
)
|
||||
if user_access['is_enrolled'] and not course_start_data['already_started']:
|
||||
CourseHomeMessages.register_info_message(
|
||||
request,
|
||||
Text(_(
|
||||
"Don't forget to add a calendar reminder!"
|
||||
)),
|
||||
title=Text("Course starts in {days_until_start_string} on {course_start_date}.").format(
|
||||
days_until_start_string=course_start_data['days_until_start_string'],
|
||||
course_start_date=course_start_data['course_start_date']
|
||||
)
|
||||
|
||||
CourseHomeMessages.register_info_message(
|
||||
request,
|
||||
HTML('{goal_choices_html}{closing_tag}').format(
|
||||
goal_choices_html=goal_choices_html,
|
||||
closing_tag=HTML('</div>')
|
||||
),
|
||||
title=Text(_('Welcome to {course_display_name}')).format(
|
||||
course_display_name=course.display_name
|
||||
)
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"backbone.paginator": "~2.0.3",
|
||||
"coffee-loader": "^0.7.3",
|
||||
"coffee-script": "1.6.1",
|
||||
"@edx/studio-frontend": "0.1.0",
|
||||
"edx-bootstrap": "^0.2.1",
|
||||
"edx-pattern-library": "0.18.1",
|
||||
"edx-ui-toolkit": "1.5.2",
|
||||
|
||||
@@ -64,6 +64,10 @@ NPM_INSTALLED_LIBRARIES = [
|
||||
'requirejs/require.js',
|
||||
'underscore.string/dist/underscore.string.js',
|
||||
'underscore/underscore.js',
|
||||
'@edx/studio-frontend/dist/assets.min.js',
|
||||
'@edx/studio-frontend/dist/assets.min.js.map',
|
||||
'@edx/studio-frontend/dist/studio-frontend.min.css',
|
||||
'@edx/studio-frontend/dist/studio-frontend.min.css.map'
|
||||
]
|
||||
|
||||
# A list of NPM installed developer libraries that should be copied into the common
|
||||
@@ -74,7 +78,9 @@ NPM_INSTALLED_DEVELOPER_LIBRARIES = [
|
||||
]
|
||||
|
||||
# Directory to install static vendor files
|
||||
NPM_VENDOR_DIRECTORY = path('common/static/common/js/vendor')
|
||||
NPM_JS_VENDOR_DIRECTORY = path('common/static/common/js/vendor')
|
||||
NPM_CSS_VENDOR_DIRECTORY = path("common/static/common/css/vendor")
|
||||
NPM_CSS_DIRECTORY = path("common/static/common/css")
|
||||
|
||||
# system specific lookup path additions, add sass dirs if one system depends on the sass files for other systems
|
||||
SASS_LOOKUP_DEPENDENCIES = {
|
||||
@@ -604,10 +610,14 @@ def process_npm_assets():
|
||||
Copies a vendor library to the shared vendor directory.
|
||||
"""
|
||||
library_path = 'node_modules/{library}'.format(library=library)
|
||||
if library.endswith('.css') or library.endswith('.css.map'):
|
||||
vendor_dir = NPM_CSS_VENDOR_DIRECTORY
|
||||
else:
|
||||
vendor_dir = NPM_JS_VENDOR_DIRECTORY
|
||||
if os.path.exists(library_path):
|
||||
sh('/bin/cp -rf {library_path} {vendor_dir}'.format(
|
||||
library_path=library_path,
|
||||
vendor_dir=NPM_VENDOR_DIRECTORY,
|
||||
vendor_dir=vendor_dir,
|
||||
))
|
||||
elif not skip_if_missing:
|
||||
raise Exception('Missing vendor file {library_path}'.format(library_path=library_path))
|
||||
@@ -618,7 +628,9 @@ def process_npm_assets():
|
||||
return
|
||||
|
||||
# Ensure that the vendor directory exists
|
||||
NPM_VENDOR_DIRECTORY.mkdir_p()
|
||||
NPM_JS_VENDOR_DIRECTORY.mkdir_p()
|
||||
NPM_CSS_DIRECTORY.mkdir_p()
|
||||
NPM_CSS_VENDOR_DIRECTORY.mkdir_p()
|
||||
|
||||
# Copy each file to the vendor directory, overwriting any existing file.
|
||||
print("Copying vendor files into static directory")
|
||||
|
||||
@@ -49,7 +49,7 @@ edx-lint==0.4.3
|
||||
astroid==1.3.8
|
||||
edx-django-oauth2-provider==1.2.5
|
||||
edx-django-sites-extensions==2.3.0
|
||||
edx-enterprise==0.46.4
|
||||
edx-enterprise==0.46.7
|
||||
edx-oauth2-provider==1.2.2
|
||||
edx-opaque-keys==0.4.0
|
||||
edx-organizations==0.4.6
|
||||
|
||||
@@ -23,6 +23,7 @@ var wpconfig = {
|
||||
StudioIndex: './cms/static/js/features_jsx/studio/index.jsx',
|
||||
|
||||
// Features
|
||||
CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js',
|
||||
CourseHome: './openedx/features/course_experience/static/course_experience/js/CourseHome.js',
|
||||
CourseOutline: './openedx/features/course_experience/static/course_experience/js/CourseOutline.js',
|
||||
CourseSock: './openedx/features/course_experience/static/course_experience/js/CourseSock.js',
|
||||
|
||||
Reference in New Issue
Block a user