diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 32d19d19b5..0e69850520 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -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( diff --git a/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py b/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py new file mode 100644 index 0000000000..d24007129d --- /dev/null +++ b/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py @@ -0,0 +1,59 @@ +""" +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") diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index eb86f7e636..5585dd9442 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -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 @@ -118,6 +121,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): """ diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 3516ae6176..b381ba0f15 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -44,7 +44,8 @@ class CourseMetadata(object): 'is_entrance_exam', 'in_entrance_exam', 'language', - 'certificates' + 'certificates', + 'minimum_grade_credit', ] @classmethod diff --git a/cms/envs/common.py b/cms/envs/common.py index 57c8b10cb2..bb47f87384 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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 diff --git a/cms/static/js/factories/settings.js b/cms/static/js/factories/settings.js index 09ec56c0e8..7edf14b191 100644 --- a/cms/static/js/factories/settings.js +++ b/cms/static/js/factories/settings.js @@ -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(); }, diff --git a/cms/static/js/models/settings/course_grading_policy.js b/cms/static/js/models/settings/course_grading_policy.js index d034aa2cef..258996c107 100644 --- a/cms/static/js/models/settings/course_grading_policy.js +++ b/cms/static/js/models/settings/course_grading_policy.js @@ -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 + ) + }; + } + } } }); diff --git a/cms/static/js/views/settings/grading.js b/cms/static/js/views/settings/grading.js index 06fbee8b78..0af6632c7f 100644 --- a/cms/static/js/views/settings/grading.js +++ b/cms/static/js/views/settings/grading.js @@ -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; diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js index 78b5dbc491..441d12d875 100644 --- a/cms/static/js/views/settings/main.js +++ b/cms/static/js/views/settings/main.js @@ -1,7 +1,8 @@ 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(' <%= filename %>'); // 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() { diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index b6b3ef4e02..14fb5750e7 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -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 +} diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 937e174bb1..31876cb599 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -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}'; + <%block name="requirejs"> require(["js/factories/settings"], function(SettingsFactory) { - SettingsFactory("${details_url}"); + SettingsFactory("${details_url}", ${json.dumps(show_min_grade_warning)}); }); @@ -116,9 +118,54 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; % endif -
+ % if credit_eligibility_enabled and is_credit_course: +
+
+

${_("Credit Eligibility Requirements")}

+ ${_("Steps needed for credit eligibility")} +
+ % if credit_requirements: +
    + % if 'grade' in credit_requirements: +
  1. + + % for requirement in credit_requirements['grade']: + + % endfor +
  2. + % endif + + % if 'proctored_exam' in credit_requirements: +
  3. + + % for requirement in credit_requirements['proctored_exam']: + + % endfor +
  4. + % endif + + % if 'reverification' in credit_requirements: +
  5. + + % for requirement in credit_requirements['reverification']: + ## Translators: 'Access to Assessment 1' means the access for a requirement with name 'Assessment 1' + + % endfor +
  6. + % endif +
+ % else: +

No credit requirements found.

+ % endif +
+
+ % endif +

${_('Course Schedule')}

diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html index 75bb5782f6..701ad854c9 100644 --- a/cms/templates/settings_graders.html +++ b/cms/templates/settings_graders.html @@ -73,9 +73,26 @@
-
+ % if settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) and is_credit_course: +
+
+

${_("Credit Grade & Eligibility")}

+ ${_("Settings for credit eligibility")} +
+ +
    +
  1. + + + ${_("Must be greater than or equal to passing grade")} +
  2. +
+
+
+ % endif +

${_("Grading Rules & Policies")}

@@ -90,7 +107,6 @@
-
diff --git a/common/test/acceptance/pages/studio/settings_advanced.py b/common/test/acceptance/pages/studio/settings_advanced.py index 9a4bf0a82a..dc94a08920 100644 --- a/common/test/acceptance/pages/studio/settings_advanced.py +++ b/common/test/acceptance/pages/studio/settings_advanced.py @@ -200,6 +200,5 @@ class AdvancedSettingsPage(CoursePage): 'annotation_storage_url', 'social_sharing_url', 'teams_configuration', - 'minimum_grade_credit', 'video_bumper', ] diff --git a/openedx/core/djangoapps/credit/api.py b/openedx/core/djangoapps/credit/api.py index df7d0de56d..4fa4e55bd3 100644 --- a/openedx/core/djangoapps/credit/api.py +++ b/openedx/core/djangoapps/credit/api.py @@ -1,11 +1,16 @@ -""" 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) diff --git a/openedx/core/djangoapps/credit/signals.py b/openedx/core/djangoapps/credit/signals.py index 2c9fcb12da..f0efdb0a89 100644 --- a/openedx/core/djangoapps/credit/signals.py +++ b/openedx/core/djangoapps/credit/signals.py @@ -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)) diff --git a/openedx/core/djangoapps/credit/tasks.py b/openedx/core/djangoapps/credit/tasks.py index d2bc418273..27bf4dc30f 100644 --- a/openedx/core/djangoapps/credit/tasks.py +++ b/openedx/core/djangoapps/credit/tasks.py @@ -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)