Commit 2fa8fa22 by Harry Rein Committed by GitHub

Merge branch 'master' into HarryRein/LEARNER-2471-safari-sock-issue

parents afe35acf 990a8cbf
......@@ -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)
"""
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
"""
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)
"""
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))
"""
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
# -*- 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):
"""
......
"""
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)
"""
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]
# -*- 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')]),
),
]
"""
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")
"""
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,
}
)
"""
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
"""
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')),
)
"""
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,
}
)
"""
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)
"""
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,
)
......@@ -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
......
"""
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",
{
......
"""
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.
......
/* 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))
......
......@@ -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',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment