Commit f73b68d6 by zubair-arbi

Merge pull request #8382 from edx/zub/story/ecom-1591-studio-display-credit-eligibility-table

show credit eligibility requirements in studio
parents 233aba74 3204c8b3
......@@ -24,6 +24,8 @@ from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xmodule.tabs import CourseTab
from openedx.core.lib.course_tabs import CourseTabPluginManager
from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
from xmodule.modulestore import EdxJSONEncoder
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError
from opaque_keys import InvalidKeyError
......@@ -842,6 +844,7 @@ def settings_handler(request, course_key_string):
"""
course_key = CourseKey.from_string(course_key_string)
prerequisite_course_enabled = settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False)
credit_eligibility_enabled = settings.FEATURES.get('ENABLE_CREDIT_ELIGIBILITY', False)
with modulestore().bulk_operations(course_key):
course_module = get_course_and_check_access(course_key, request.user)
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
......@@ -867,6 +870,9 @@ def settings_handler(request, course_key_string):
'upload_asset_url': upload_asset_url,
'course_handler_url': reverse_course_url('course_handler', course_key),
'language_options': settings.ALL_LANGUAGES,
'credit_eligibility_enabled': credit_eligibility_enabled,
'is_credit_course': False,
'show_min_grade_warning': False,
}
if prerequisite_course_enabled:
courses, in_process_course_actions = get_courses_accessible_to_user(request)
......@@ -876,6 +882,27 @@ def settings_handler(request, course_key_string):
courses = _remove_in_process_courses(courses, in_process_course_actions)
settings_context.update({'possible_pre_requisite_courses': courses})
if credit_eligibility_enabled:
if is_credit_course(course_key):
# get and all credit eligibility requirements
credit_requirements = get_credit_requirements(course_key)
# pair together requirements with same 'namespace' values
paired_requirements = {}
for requirement in credit_requirements:
namespace = requirement.pop("namespace")
paired_requirements.setdefault(namespace, []).append(requirement)
# if 'minimum_grade_credit' of a course is not set or 0 then
# show warning message to course author.
show_min_grade_warning = False if course_module.minimum_grade_credit > 0 else True
settings_context.update(
{
'is_credit_course': True,
'credit_requirements': paired_requirements,
'show_min_grade_warning': show_min_grade_warning,
}
)
return render_to_response('settings.html', settings_context)
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''):
if request.method == 'GET':
......@@ -961,6 +988,7 @@ def grading_handler(request, course_key_string, grader_index=None):
'course_locator': course_key,
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder),
'grading_url': reverse_course_url('grading_handler', course_key),
'is_credit_course': is_credit_course(course_key),
})
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''):
if request.method == 'GET':
......@@ -973,6 +1001,11 @@ def grading_handler(request, course_key_string, grader_index=None):
else:
return JsonResponse(CourseGradingModel.fetch_grader(course_key, grader_index))
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
# update credit course requirements if 'minimum_grade_credit'
# field value is changed
if 'minimum_grade_credit' in request.json:
update_credit_course_requirements.delay(unicode(course_key))
# None implies update the whole model (cutoffs, graceperiod, and graders) not a specific grader
if grader_index is None:
return JsonResponse(
......
"""
Unit tests for credit eligibility UI in Studio.
"""
import mock
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import reverse_course_url
from xmodule.modulestore.tests.factories import CourseFactory
from openedx.core.djangoapps.credit.api import get_credit_requirements
from openedx.core.djangoapps.credit.models import CreditCourse
from openedx.core.djangoapps.credit.signals import listen_for_course_publish
class CreditEligibilityTest(CourseTestCase):
"""Base class to test the course settings details view in Studio for credit
eligibility requirements.
"""
def setUp(self):
super(CreditEligibilityTest, self).setUp()
self.course = CourseFactory.create(org='edX', number='dummy', display_name='Credit Course')
self.course_details_url = reverse_course_url('settings_handler', unicode(self.course.id))
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_CREDIT_ELIGIBILITY': False})
def test_course_details_with_disabled_setting(self):
"""Test that user don't see credit eligibility requirements in response
if the feature flag 'ENABLE_CREDIT_ELIGIBILITY' is not enabled.
"""
response = self.client.get_html(self.course_details_url)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "Credit Eligibility Requirements")
self.assertNotContains(response, "Steps needed for credit eligibility")
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_CREDIT_ELIGIBILITY': True})
def test_course_details_with_enabled_setting(self):
"""Test that credit eligibility requirements are present in
response if the feature flag 'ENABLE_CREDIT_ELIGIBILITY' is enabled.
"""
# verify that credit eligibility requirements block don't show if the
# course is not set as credit course
response = self.client.get_html(self.course_details_url)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "Credit Eligibility Requirements")
self.assertNotContains(response, "Steps needed for credit eligibility")
# verify that credit eligibility requirements block shows if the
# course is set as credit course and it has eligibility requirements
credit_course = CreditCourse(course_key=unicode(self.course.id), enabled=True)
credit_course.save()
self.assertEqual(len(get_credit_requirements(self.course.id)), 0)
# test that after publishing course, minimum grade requirement is added
listen_for_course_publish(self, self.course.id)
self.assertEqual(len(get_credit_requirements(self.course.id)), 1)
response = self.client.get_html(self.course_details_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Credit Eligibility Requirements")
self.assertContains(response, "Steps needed for credit eligibility")
......@@ -14,6 +14,7 @@ class CourseGradingModel(object):
] # weights transformed to ints [0..100]
self.grade_cutoffs = course_descriptor.grade_cutoffs
self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor)
self.minimum_grade_credit = course_descriptor.minimum_grade_credit
@classmethod
def fetch(cls, course_key):
......@@ -62,6 +63,8 @@ class CourseGradingModel(object):
CourseGradingModel.update_grace_period_from_json(course_key, jsondict['grace_period'], user)
CourseGradingModel.update_minimum_grade_credit_from_json(course_key, jsondict['minimum_grade_credit'], user)
return CourseGradingModel.fetch(course_key)
@staticmethod
......@@ -119,6 +122,25 @@ class CourseGradingModel(object):
modulestore().update_item(descriptor, user.id)
@staticmethod
def update_minimum_grade_credit_from_json(course_key, minimum_grade_credit, user):
"""Update the course's default minimum grade requirement for credit.
Args:
course_key(CourseKey): The course identifier
minimum_grade_json(Float): Minimum grade value
user(User): The user object
"""
descriptor = modulestore().get_course(course_key)
# 'minimum_grade_credit' cannot be set to None
if minimum_grade_credit is not None:
minimum_grade_credit = minimum_grade_credit
descriptor.minimum_grade_credit = minimum_grade_credit
modulestore().update_item(descriptor, user.id)
@staticmethod
def delete_grader(course_key, index, user):
"""
Delete the grader of the given type from the given course.
......
......@@ -44,7 +44,8 @@ class CourseMetadata(object):
'is_entrance_exam',
'in_entrance_exam',
'language',
'certificates'
'certificates',
'minimum_grade_credit',
]
@classmethod
......
......@@ -173,6 +173,8 @@ FEATURES = {
# How many seconds to show the bumper again, default is 7 days:
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
# Enable credit eligibility feature
'ENABLE_CREDIT_ELIGIBILITY': False,
}
ENABLE_JASMINE = False
......
......@@ -2,7 +2,7 @@ define([
'jquery', 'js/models/settings/course_details', 'js/views/settings/main'
], function($, CourseDetailsModel, MainView) {
'use strict';
return function (detailsUrl) {
return function (detailsUrl, showMinGradeWarning) {
var model;
// highlighting labels when fields are focused in
$('form :input')
......@@ -19,7 +19,8 @@ define([
success: function(model) {
var editor = new MainView({
el: $('.settings-details'),
model: model
model: model,
showMinGradeWarning: showMinGradeWarning
});
editor.render();
},
......
......@@ -5,7 +5,8 @@ var CourseGradingPolicy = Backbone.Model.extend({
defaults : {
graders : null, // CourseGraderCollection
grade_cutoffs : null, // CourseGradeCutoff model
grace_period : null // either null or { hours: n, minutes: m, ...}
grace_period : null, // either null or { hours: n, minutes: m, ...}
minimum_grade_credit : null // either null or percentage
},
parse: function(attributes) {
if (attributes['graders']) {
......@@ -28,6 +29,11 @@ var CourseGradingPolicy = Backbone.Model.extend({
minutes: 0
}
}
// If minimum_grade_credit is unset or equal to 0 on the server,
// it's received as 0
if (attributes.minimum_grade_credit === null) {
attributes.minimum_grade_credit = 0;
}
return attributes;
},
gracePeriodToDate : function() {
......@@ -55,6 +61,13 @@ var CourseGradingPolicy = Backbone.Model.extend({
minutes: parseInt(pieces[1], 10)
}
},
parseMinimumGradeCredit : function(minimum_grade_credit) {
// get the value of minimum grade credit value in percentage
if (isNaN(minimum_grade_credit)) {
return 0;
}
return parseInt(minimum_grade_credit);
},
validate : function(attrs) {
if(_.has(attrs, 'grace_period')) {
if(attrs['grace_period'] === null) {
......@@ -63,6 +76,18 @@ var CourseGradingPolicy = Backbone.Model.extend({
}
}
}
if(_.has(attrs, 'minimum_grade_credit')) {
var minimum_grade_cutoff = _.values(attrs.grade_cutoffs).pop();
if(isNaN(attrs.minimum_grade_credit) || attrs.minimum_grade_credit === null || attrs.minimum_grade_credit < minimum_grade_cutoff) {
return {
'minimum_grade_credit': interpolate(
gettext('Not able to set passing grade to less than %(minimum_grade_cutoff)s%.'),
{minimum_grade_cutoff: minimum_grade_cutoff * 100},
true
)
};
}
}
}
});
......
......@@ -42,6 +42,7 @@ var GradingView = ValidatingView.extend({
this.clearValidationErrors();
this.renderGracePeriod();
this.renderMinimumGradeCredit();
// Create and render the grading type subs
var self = this;
......@@ -86,7 +87,8 @@ var GradingView = ValidatingView.extend({
this.model.get('graders').push({});
},
fieldToSelectorMap : {
'grace_period' : 'course-grading-graceperiod'
'grace_period' : 'course-grading-graceperiod',
'minimum_grade_credit' : 'course-minimum_grade_credit'
},
renderGracePeriod: function() {
var format = function(time) {
......@@ -97,11 +99,23 @@ var GradingView = ValidatingView.extend({
format(grace_period.hours) + ':' + format(grace_period.minutes)
);
},
renderMinimumGradeCredit: function() {
var minimum_grade_credit = this.model.get('minimum_grade_credit');
this.$el.find('#course-minimum_grade_credit').val(
parseFloat(minimum_grade_credit) * 100 + '%'
);
},
setGracePeriod : function(event) {
this.clearValidationErrors();
var newVal = this.model.parseGracePeriod($(event.currentTarget).val());
this.model.set('grace_period', newVal, {validate: true});
},
setMinimumGradeCredit : function(event) {
this.clearValidationErrors();
// get field value in float
var newVal = this.model.parseMinimumGradeCredit($(event.currentTarget).val()) / 100;
this.model.set('minimum_grade_credit', newVal, {validate: true});
},
updateModel : function(event) {
if (!this.selectorToField[event.currentTarget.id]) return;
......@@ -110,6 +124,10 @@ var GradingView = ValidatingView.extend({
this.setGracePeriod(event);
break;
case 'minimum_grade_credit':
this.setMinimumGradeCredit(event);
break;
default:
this.setField(event);
break;
......
define(["js/views/validation", "codemirror", "underscore", "jquery", "jquery.ui", "js/utils/date_utils", "js/models/uploads",
"js/views/uploads", "js/utils/change_on_enter", "js/views/license", "js/models/license", "jquery.timepicker", "date"],
"js/views/uploads", "js/utils/change_on_enter", "js/views/license", "js/models/license",
"js/views/feedback_notification", "jquery.timepicker", "date"],
function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel,
FileUploadDialog, TriggerChangeEventOnEnter, LicenseView, LicenseModel) {
FileUploadDialog, TriggerChangeEventOnEnter, LicenseView, LicenseModel, NotificationView) {
var DetailsView = ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseDetails
......@@ -21,7 +22,8 @@ var DetailsView = ValidatingView.extend({
'click .action-upload-image': "uploadImage"
},
initialize : function() {
initialize : function(options) {
options = options || {};
this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="icon fa fa-file"></i><%= filename %></a>');
// fill in fields
this.$el.find("#course-language").val(this.model.get('language'));
......@@ -49,6 +51,14 @@ var DetailsView = ValidatingView.extend({
showPreview: true
});
this.listenTo(this.licenseModel, 'change', this.handleLicenseChange);
if (options.showMinGradeWarning || false) {
new NotificationView.Warning({
title: gettext("Credit Eligibility Requirements"),
message: gettext("Minimum passing grade for credit is not set."),
closeIcon: true
}).show();
}
},
render: function() {
......
......@@ -326,6 +326,24 @@
width: flex-grid(5, 9);
}
// Credit eligibility requirements
#credit-minimum-passing-grade {
float: left;
width: flex-grid(3, 9);
margin-right: flex-gutter();
}
#credit-proctoring-requirements {
float: left;
width: flex-grid(3, 9);
margin-right: flex-gutter();
}
#credit-reverification-requirements {
float: left;
width: flex-grid(3, 9);
}
// course link note
.note-promotion-courseURL {
box-shadow: 0 2px 1px $shadow-l1;
......@@ -731,6 +749,11 @@
#field-course-grading-graceperiod {
width: flex-grid(3, 9);
}
#field-course-minimum_grade_credit {
width: flex-grid(4, 9);
}
}
&.assignment-types {
......@@ -985,4 +1008,4 @@
}
}
}
}
\ No newline at end of file
}
......@@ -5,9 +5,10 @@
<%namespace name='static' file='static_content.html'/>
<%!
import json
import urllib
from django.utils.translation import ugettext as _
from contentstore import utils
import urllib
%>
<%block name="header_extras">
......@@ -27,9 +28,10 @@ CMS.URL = CMS.URL || {};
CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
</script>
</%block>
<%block name="requirejs">
require(["js/factories/settings"], function(SettingsFactory) {
SettingsFactory("${details_url}");
SettingsFactory("${details_url}", ${json.dumps(show_min_grade_warning)});
});
</%block>
......@@ -116,9 +118,54 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
</div>
% endif
</section>
<hr class="divide" />
% if credit_eligibility_enabled and is_credit_course:
<section class="group-settings basic">
<header>
<h2 class="title-2">${_("Credit Eligibility Requirements")}</h2>
<span class="tip">${_("Steps needed for credit eligibility")}</span>
</header>
% if credit_requirements:
<ol class="list-input">
% if 'grade' in credit_requirements:
<li class="field text is-not-editable" id="credit-minimum-passing-grade">
<label for="minimum-passing-grade">${_("Minimum Passing Grade")}</label>
% for requirement in credit_requirements['grade']:
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="${requirement['name']}" value="${'{0:.0f}%'.format(float(requirement['criteria']['min_grade'] or 0)*100)}" readonly />
% endfor
</li>
% endif
% if 'proctored_exam' in credit_requirements:
<li class="field text is-not-editable" id="credit-proctoring-requirements">
<label for="proctoring-requirements">${_("Successful Proctored Exam")}</label>
% for requirement in credit_requirements['proctored_exam']:
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="${requirement['name']}" value="${requirement['display_name']}" readonly />
% endfor
</li>
% endif
% if 'reverification' in credit_requirements:
<li class="field text is-not-editable" id="credit-reverification-requirements">
<label for="reverification-requirements">${_("Successful In Course Reverification")}</label>
% for requirement in credit_requirements['reverification']:
## Translators: 'Access to Assessment 1' means the access for a requirement with name 'Assessment 1'
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="${requirement['name']}" value="${_('Access to {display_name}').format(display_name=requirement['display_name'])}" readonly />
% endfor
</li>
% endif
</ol>
% else:
<p>No credit requirements found.</p>
% endif
</section>
<hr class="divide" />
% endif
<section class="group-settings schedule">
<header>
<h2 class="title-2">${_('Course Schedule')}</h2>
......
......@@ -73,9 +73,26 @@
</li>
</ol>
</section>
<hr class="divide" />
% if settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) and is_credit_course:
<section class="group-settings grade-rules">
<header>
<h2 class="title-2">${_("Credit Grade &amp; Eligibility")}</h2>
<span class="tip">${_("Settings for credit eligibility")}</span>
</header>
<ol class="list-input">
<li class="field text" id="field-course-minimum_grade_credit">
<label for="course-minimum_grade_credit">${_("Minimum Passing Grade to Earn Credit:")}</label>
<input type="text" class="short time" id="course-minimum_grade_credit" value="0" placeholder="80%" autocomplete="off" />
<span class="tip tip-inline">${_("Must be greater than or equal to passing grade")}</span>
</li>
</ol>
</section>
<hr class="divide" />
% endif
<section class="group-settings grade-rules">
<header>
<h2 class="title-2">${_("Grading Rules &amp; Policies")}</h2>
......@@ -90,7 +107,6 @@
</li>
</ol>
</section>
<hr class="divide" />
<section class="group-settings assignment-types">
......
......@@ -200,6 +200,5 @@ class AdvancedSettingsPage(CoursePage):
'annotation_storage_url',
'social_sharing_url',
'teams_configuration',
'minimum_grade_credit',
'video_bumper',
]
""" Contains the APIs for course credit requirements """
"""
Contains the APIs for course credit requirements.
"""
import logging
import uuid
from django.db import transaction
from student.models import User
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from student.models import User
from .exceptions import (
InvalidCreditRequirements,
InvalidCreditCourse,
......@@ -96,32 +101,33 @@ def get_credit_requirements(course_key, namespace=None):
Example:
>>> get_credit_requirements("course-v1-edX-DemoX-1T2015")
{
requirements =
[
{
"namespace": "reverification",
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
"display_name": "Assessment 1",
"criteria": {},
},
{
"namespace": "proctored_exam",
"name": "i4x://edX/DemoX/proctoring-block/final_uuid",
"display_name": "Final Exam",
"criteria": {},
},
{
"namespace": "grade",
"name": "grade",
"display_name": "Grade",
"criteria": {"min_grade": 0.8},
},
]
}
{
requirements =
[
{
"namespace": "reverification",
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
"display_name": "Assessment 1",
"criteria": {},
},
{
"namespace": "proctored_exam",
"name": "i4x://edX/DemoX/proctoring-block/final_uuid",
"display_name": "Final Exam",
"criteria": {},
},
{
"namespace": "grade",
"name": "grade",
"display_name": "Grade",
"criteria": {"min_grade": 0.8},
},
]
}
Returns:
Dict of requirements in the given namespace
"""
requirements = CreditRequirement.get_course_requirements(course_key, namespace)
......@@ -455,3 +461,21 @@ def _validate_requirements(requirements):
)
)
return invalid_requirements
def is_credit_course(course_key):
"""API method to check if course is credit or not.
Args:
course_key(CourseKey): The course identifier string or CourseKey object
Returns:
Bool True if the course is marked credit else False
"""
try:
course_key = CourseKey.from_string(unicode(course_key))
except InvalidKeyError:
return False
return CreditCourse.is_credit_course(course_key=course_key)
......@@ -15,6 +15,6 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=
# Import here, because signal is registered at startup, but items in tasks
# are not yet able to be loaded
from .tasks import update_course_requirements
from .tasks import update_credit_course_requirements
update_course_requirements.delay(unicode(course_key))
update_credit_course_requirements.delay(unicode(course_key))
......@@ -20,7 +20,7 @@ LOGGER = get_task_logger(__name__)
# pylint: disable=not-callable
@task(default_retry_delay=settings.CREDIT_TASK_DEFAULT_RETRY_DELAY, max_retries=settings.CREDIT_TASK_MAX_RETRIES)
def update_course_requirements(course_id):
def update_credit_course_requirements(course_id): # pylint: disable=invalid-name
"""Updates course requirements table for a course.
Args:
......@@ -39,7 +39,7 @@ def update_course_requirements(course_id):
set_credit_requirements(course_key, requirements)
except (InvalidKeyError, ItemNotFoundError, InvalidCreditRequirements) as exc:
LOGGER.error('Error on adding the requirements for course %s - %s', course_id, unicode(exc))
raise update_course_requirements.retry(args=[course_id], exc=exc)
raise update_credit_course_requirements.retry(args=[course_id], exc=exc)
else:
LOGGER.info('Requirements added for course %s', course_id)
......
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