diff --git a/lms/djangoapps/certificates/admin.py b/lms/djangoapps/certificates/admin.py new file mode 100644 index 0000000000..715c339d1e --- /dev/null +++ b/lms/djangoapps/certificates/admin.py @@ -0,0 +1,8 @@ +""" +django admin pages for certificates models +""" +from django.contrib import admin +from certificates.models import CertificateGenerationConfiguration + + +admin.site.register(CertificateGenerationConfiguration) diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py new file mode 100644 index 0000000000..c2c2191b3b --- /dev/null +++ b/lms/djangoapps/certificates/api.py @@ -0,0 +1,65 @@ +""" +Certificates API +""" + +import logging +from certificates.models import CertificateStatuses as cert_status, certificate_status_for_student +from certificates.queue import XQueueCertInterface + +log = logging.getLogger("edx.certificate") + + +def generate_user_certificates(student, course): + """ + It will add the add-cert request into the xqueue. + + Args: + student (object): user + course (object): course + + Returns: + returns status of generated certificate + """ + xqueue = XQueueCertInterface() + ret = xqueue.add_cert(student, course.id, course=course) + log.info( + ( + u"Added a certificate generation task to the XQueue " + u"for student %s in course '%s'. " + u"The new certificate status is '%s'." + ), + student.id, + unicode(course.id), + ret + ) + return ret + + +def certificate_downloadable_status(student, course_key): + """ + Check the student existing certificates against a given course. + if status is not generating and not downloadable or error then user can view the generate button. + + Args: + student (user object): logged-in user + course_key (CourseKey): ID associated with the course + + Returns: + Dict containing student passed status also download url for cert if available + """ + current_status = certificate_status_for_student(student, course_key) + + # If the certificate status is an error user should view that status is "generating". + # On the back-end, need to monitor those errors and re-submit the task. + + response_data = { + 'is_downloadable': False, + 'is_generating': True if current_status['status'] in [cert_status.generating, cert_status.error] else False, + 'download_url': None + } + + if current_status['status'] == cert_status.downloadable: + response_data['is_downloadable'] = True + response_data['download_url'] = current_status['download_url'] + + return response_data diff --git a/lms/djangoapps/certificates/migrations/0016_change_course_key_fields.py b/lms/djangoapps/certificates/migrations/0016_change_course_key_fields.py new file mode 100644 index 0000000000..6893750903 --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0016_change_course_key_fields.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Changing field 'GeneratedCertificate.course_id' + db.alter_column('certificates_generatedcertificate', 'course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255)) + + # Changing field 'CertificateWhitelist.course_id' + db.alter_column('certificates_certificatewhitelist', 'course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255)) + + def backwards(self, orm): + + # Changing field 'GeneratedCertificate.course_id' + db.alter_column('certificates_generatedcertificate', 'course_id', self.gf('django.db.models.fields.CharField')(max_length=255)) + + # Changing field 'CertificateWhitelist.course_id' + db.alter_column('certificates_certificatewhitelist', 'course_id', self.gf('django.db.models.fields.CharField')(max_length=255)) + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'certificates.certificatewhitelist': { + 'Meta': {'object_name': 'CertificateWhitelist'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'whitelist': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'certificates.generatedcertificate': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'GeneratedCertificate'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}), + 'distinction': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '128', 'blank': 'True'}), + 'download_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'error_reason': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}), + 'grade': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '5', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '32'}), + 'modified_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'unavailable'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'verify_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['certificates'] diff --git a/lms/djangoapps/certificates/migrations/0017_auto__add_certificategenerationconfiguration.py b/lms/djangoapps/certificates/migrations/0017_auto__add_certificategenerationconfiguration.py new file mode 100644 index 0000000000..5bf618a49d --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0017_auto__add_certificategenerationconfiguration.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'CertificateGenerationConfiguration' + db.create_table('certificates_certificategenerationconfiguration', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + )) + db.send_create_signal('certificates', ['CertificateGenerationConfiguration']) + + def backwards(self, orm): + # Deleting model 'CertificateGenerationConfiguration' + db.delete_table('certificates_certificategenerationconfiguration') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'certificates.certificategenerationconfiguration': { + 'Meta': {'object_name': 'CertificateGenerationConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'certificates.certificatewhitelist': { + 'Meta': {'object_name': 'CertificateWhitelist'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'whitelist': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'certificates.generatedcertificate': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'GeneratedCertificate'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}), + 'distinction': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '128', 'blank': 'True'}), + 'download_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'error_reason': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}), + 'grade': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '5', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '32'}), + 'modified_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'unavailable'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'verify_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['certificates'] diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 82100008c0..0783bb3bb6 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -52,6 +52,7 @@ from django.dispatch import receiver from django.conf import settings from datetime import datetime from model_utils import Choices +from config_models.models import ConfigurationModel from xmodule_django.models import CourseKeyField, NoneToEmptyManager from util.milestones_helpers import fulfill_course_milestone @@ -176,3 +177,8 @@ def certificate_status_for_student(student, course_id): except GeneratedCertificate.DoesNotExist: pass return {'status': CertificateStatuses.unavailable, 'mode': GeneratedCertificate.MODES.honor} + + +class CertificateGenerationConfiguration(ConfigurationModel): + """Configure certificate generation.""" + pass diff --git a/lms/djangoapps/certificates/tests/tests_api.py b/lms/djangoapps/certificates/tests/tests_api.py new file mode 100644 index 0000000000..6a277dfda6 --- /dev/null +++ b/lms/djangoapps/certificates/tests/tests_api.py @@ -0,0 +1,141 @@ +""" +Tests for the certificates api and helper function. +""" +from django.test import RequestFactory +from django.test.utils import override_settings +from mock import patch, Mock +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from certificates.api import certificate_downloadable_status, generate_user_certificates +from student.models import CourseEnrollment + +from student.tests.factories import UserFactory +from certificates.models import CertificateStatuses +from certificates.tests.factories import GeneratedCertificateFactory + + +class CertificateDownloadableStatusTests(ModuleStoreTestCase): + """ + Tests for the certificate_downloadable_status helper function + """ + + def setUp(self): + super(CertificateDownloadableStatusTests, self).setUp() + + self.student = UserFactory() + self.student_no_cert = UserFactory() + self.course = CourseFactory.create( + org='edx', + number='verified', + display_name='Verified Course' + ) + + self.request_factory = RequestFactory() + + def test_user_cert_status_with_generating(self): + """ + in case of certificate with error means means is_generating is True and is_downloadable is False + """ + GeneratedCertificateFactory.create( + user=self.student, + course_id=self.course.id, + status=CertificateStatuses.generating, + mode='verified' + ) + + self.assertEqual( + certificate_downloadable_status(self.student, self.course.id), + { + 'is_downloadable': False, + 'is_generating': True, + 'download_url': None + } + ) + + def test_user_cert_status_with_error(self): + """ + in case of certificate with error means means is_generating is True and is_downloadable is False + """ + + GeneratedCertificateFactory.create( + user=self.student, + course_id=self.course.id, + status=CertificateStatuses.error, + mode='verified' + ) + + self.assertEqual( + certificate_downloadable_status(self.student, self.course.id), + { + 'is_downloadable': False, + 'is_generating': True, + 'download_url': None + } + ) + + def test_user_with_out_cert(self): + """ + in case of no certificate means is_generating is False and is_downloadable is False + """ + self.assertEqual( + certificate_downloadable_status(self.student_no_cert, self.course.id), + { + 'is_downloadable': False, + 'is_generating': False, + 'download_url': None + } + ) + + def test_user_with_downloadable_cert(self): + """ + in case of downloadable certificate means is_generating is False and is_downloadable is True + download_url has cert link + """ + + GeneratedCertificateFactory.create( + user=self.student, + course_id=self.course.id, + status=CertificateStatuses.downloadable, + mode='verified', + download_url='www.google.com' + ) + + self.assertEqual( + certificate_downloadable_status(self.student, self.course.id), + { + 'is_downloadable': True, + 'is_generating': False, + 'download_url': 'www.google.com' + } + ) + + +class GenerateUserCertificatesTest(ModuleStoreTestCase): + """ + Tests for the generate_user_certificates helper function + """ + + def setUp(self): + super(GenerateUserCertificatesTest, self).setUp() + + self.student = UserFactory() + self.student_no_cert = UserFactory() + self.course = CourseFactory.create( + org='edx', + number='verified', + display_name='Verified Course', + grade_cutoffs={'cutoff': 0.75, 'Pass': 0.5} + ) + self.enrollment = CourseEnrollment.enroll(self.student, self.course.id, mode='honor') + self.request_factory = RequestFactory() + + @override_settings(CERT_QUEUE='certificates') + @patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75})) + def test_new_cert_requests_into_xqueue_returns_generating(self): + """ + mocking grade.grade and returns a summary with passing score. + new requests saves into xqueue and returns the status + """ + with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_send_to_queue: + mock_send_to_queue.return_value = (0, "Successfully queued") + self.assertEqual(generate_user_certificates(self.student, self.course), 'generating') diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 002b94bcff..6f74f15474 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -11,13 +11,15 @@ import ddt from django.conf import settings from django.contrib.auth.models import User, AnonymousUser from django.core.urlresolvers import reverse -from django.http import Http404 +from django.http import Http404, HttpResponseBadRequest from django.test import TestCase from django.test.client import RequestFactory from django.test.utils import override_settings +from certificates.models import CertificateStatuses, CertificateGenerationConfiguration +from certificates.tests.factories import GeneratedCertificateFactory from edxmako.middleware import MakoMiddleware from edxmako.tests import mako_middleware_process_request -from mock import MagicMock, patch, create_autospec +from mock import MagicMock, patch, create_autospec, Mock from opaque_keys.edx.locations import Location, SlashSeparatedCourseKey import courseware.views as views @@ -671,6 +673,11 @@ class ProgressPageTests(ModuleStoreTestCase): resp = views.progress(self.request, course_id=self.course.id.to_deprecated_string()) self.assertEqual(resp.status_code, 200) + def test_resp_with_generate_cert_config_enabled(self): + CertificateGenerationConfiguration(enabled=True).save() + resp = views.progress(self.request, course_id=unicode(self.course.id)) + self.assertEqual(resp.status_code, 200) + class VerifyCourseKeyDecoratorTests(TestCase): """ @@ -695,3 +702,126 @@ class VerifyCourseKeyDecoratorTests(TestCase): view_function = ensure_valid_course_key(mocked_view) self.assertRaises(Http404, view_function, self.request, course_id=self.invalid_course_id) self.assertFalse(mocked_view.called) + + +class IsCoursePassedTests(ModuleStoreTestCase): + """ + Tests for the is_course_passed helper function + """ + + def setUp(self): + super(IsCoursePassedTests, self).setUp() + + self.student = UserFactory() + self.course = CourseFactory.create( + org='edx', + number='verified', + display_name='Verified Course', + grade_cutoffs={'cutoff': 0.75, 'Pass': 0.5} + ) + self.request = RequestFactory() + + def test_user_fails_if_not_clear_exam(self): + # If user has not grade then false will return + self.assertFalse(views.is_course_passed(self.course, None, self.student, self.request)) + + @patch('courseware.grades.grade', Mock(return_value={'percent': 0.9})) + def test_user_pass_if_percent_appears_above_passing_point(self): + # Mocking the grades.grade + # If user has above passing marks then True will return + self.assertTrue(views.is_course_passed(self.course, None, self.student, self.request)) + + @patch('courseware.grades.grade', Mock(return_value={'percent': 0.2})) + def test_user_fail_if_percent_appears_below_passing_point(self): + # Mocking the grades.grade + # If user has below passing marks then False will return + self.assertFalse(views.is_course_passed(self.course, None, self.student, self.request)) + + +class GenerateUserCertTests(ModuleStoreTestCase): + """ + Tests for the view function Generated User Certs + """ + + def setUp(self): + super(GenerateUserCertTests, self).setUp() + + self.student = UserFactory(username='dummy', password='123456', email='test@mit.edu') + self.course = CourseFactory.create( + org='edx', + number='verified', + display_name='Verified Course', + grade_cutoffs={'cutoff': 0.75, 'Pass': 0.5} + ) + self.enrollment = CourseEnrollment.enroll(self.student, self.course.id, mode='honor') + self.request = RequestFactory() + self.client.login(username=self.student, password='123456') + self.url = reverse('generate_user_cert', kwargs={'course_id': unicode(self.course.id)}) + + def test_user_with_out_passing_grades(self): + # If user has no grading then json will return failed message and badrequest code + resp = self.client.post(self.url) + self.assertEqual(resp.status_code, HttpResponseBadRequest.status_code) + self.assertIn("Your certificate will be available when you pass the course.", resp.content) + + @patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75})) + @override_settings(CERT_QUEUE='certificates') + def test_user_with_passing_grade(self): + # If user has above passing grading then json will return cert generating message and + # status valid code + # mocking xqueue + + with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_send_to_queue: + mock_send_to_queue.return_value = (0, "Successfully queued") + resp = self.client.post(self.url) + self.assertEqual(resp.status_code, 200) + self.assertIn("Creating certificate", resp.content) + + @patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75})) + def test_user_with_passing_existing_generating_cert(self): + # If user has passing grade but also has existing generating cert + # then json will return cert generating message with bad request code + GeneratedCertificateFactory.create( + user=self.student, + course_id=self.course.id, + status=CertificateStatuses.generating, + mode='verified' + ) + resp = self.client.post(self.url) + self.assertEqual(resp.status_code, HttpResponseBadRequest.status_code) + self.assertIn("Creating certificate", resp.content) + + @patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75})) + def test_user_with_passing_existing_downloadable_cert(self): + # If user has passing grade but also has existing downloadable cert + # then json will return cert generating message with bad request code + GeneratedCertificateFactory.create( + user=self.student, + course_id=self.course.id, + status=CertificateStatuses.downloadable, + mode='verified' + ) + resp = self.client.post(self.url) + self.assertEqual(resp.status_code, HttpResponseBadRequest.status_code) + self.assertIn("Creating certificate", resp.content) + + def test_user_with_non_existing_course(self): + # If try to access a course with valid key pattern then it will return + # bad request code with course is not valid message + resp = self.client.post('/courses/def/abc/in_valid/generate_user_cert') + self.assertEqual(resp.status_code, HttpResponseBadRequest.status_code) + self.assertIn("Course is not valid", resp.content) + + def test_user_with_invalid_course_id(self): + # If try to access a course with invalid key pattern then 404 will return + resp = self.client.post('/courses/def/generate_user_cert') + self.assertEqual(resp.status_code, 404) + + def test_user_without_login_return_error(self): + # If user try to access without login should see a bad request status code with message + self.client.logout() + resp = self.client.post(self.url) + self.assertEqual(resp.status_code, HttpResponseBadRequest.status_code) + self.assertIn("You must be signed in to {platform_name} to create a certificate.".format( + platform_name=settings.PLATFORM_NAME + ), resp.content) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index cb7aa58fb0..942d05f1ec 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -20,9 +20,11 @@ from django.core.urlresolvers import reverse from django.contrib.auth.models import User, AnonymousUser from django.contrib.auth.decorators import login_required from django.utils.timezone import UTC -from django.views.decorators.http import require_GET -from django.http import Http404, HttpResponse +from django.views.decorators.http import require_GET, require_POST +from django.http import Http404, HttpResponse, HttpResponseBadRequest from django.shortcuts import redirect +from certificates.api import certificate_downloadable_status, generate_user_certificates +from certificates.models import CertificateGenerationConfiguration from edxmako.shortcuts import render_to_response, render_to_string, marketing_link from django_future.csrf import ensure_csrf_cookie from django.views.decorators.cache import cache_control @@ -60,6 +62,7 @@ from util.milestones_helpers import get_prerequisite_courses_display from microsite_configuration import microsite from opaque_keys.edx.locations import SlashSeparatedCourseKey +from opaque_keys.edx.keys import CourseKey from instructor.enrollment import uses_shib from util.db import commit_on_success_with_read_committed @@ -1007,6 +1010,9 @@ def _progress(request, course_key, student_id): #This means the student didn't have access to the course (which the instructor requested) raise Http404 + # checking certificate generation configuration + show_generate_cert_btn = CertificateGenerationConfiguration.current().enabled + context = { 'course': course, 'courseware_summary': courseware_summary, @@ -1014,9 +1020,14 @@ def _progress(request, course_key, student_id): 'grade_summary': grade_summary, 'staff_access': staff_access, 'student': student, - 'reverifications': fetch_reverify_banner_info(request, course_key) + 'reverifications': fetch_reverify_banner_info(request, course_key), + 'passed': is_course_passed(course, grade_summary) if show_generate_cert_btn else False, + 'show_generate_cert_btn': show_generate_cert_btn } + if show_generate_cert_btn: + context.update(certificate_downloadable_status(student, course_key)) + with grades.manual_transaction(): response = render_to_response('courseware/progress.html', context) @@ -1231,3 +1242,72 @@ def course_survey(request, course_id): redirect_url=redirect_url, is_required=course.course_survey_required, ) + + +def is_course_passed(course, grade_summary=None, student=None, request=None): + """ + check user's course passing status. return True if passed + + Arguments: + course : course object + grade_summary (dict) : contains student grade details. + student : user object + request (HttpRequest) + + Returns: + returns bool value + """ + nonzero_cutoffs = [cutoff for cutoff in course.grade_cutoffs.values() if cutoff > 0] + success_cutoff = min(nonzero_cutoffs) if nonzero_cutoffs else None + + if grade_summary is None: + grade_summary = grades.grade(student, request, course) + + return success_cutoff and grade_summary['percent'] > success_cutoff + + +@ensure_csrf_cookie +@require_POST +def generate_user_cert(request, course_id): + """ + It will check all validation and on clearance will add the new-certificate request into the xqueue. + + Args: + request (django request object): the HTTP request object that triggered this view function + course_id (unicode): id associated with the course + + Returns: + returns json response + """ + + if not request.user.is_authenticated(): + log.info(u"Anon user trying to generate certificate for %s", course_id) + return HttpResponseBadRequest( + _('You must be signed in to {platform_name} to create a certificate.').format( + platform_name=settings.PLATFORM_NAME + ) + ) + + student = request.user + + course_key = CourseKey.from_string(course_id) + + course = modulestore().get_course(course_key, depth=2) + if not course: + return HttpResponseBadRequest(_("Course is not valid")) + + if not is_course_passed(course, None, student, request): + return HttpResponseBadRequest(_("Your certificate will be available when you pass the course.")) + + certificate_status = certificate_downloadable_status(student, course.id) + + if not certificate_status["is_downloadable"] and not certificate_status["is_generating"]: + generate_user_certificates(student, course) + return HttpResponse(_("Creating certificate")) + + # if certificate_status is not is_downloadable and is_generating or + # if any error appears during certificate generation return the message cert is generating. + # with badrequest + # at backend debug the issue and re-submit the task. + + return HttpResponseBadRequest(_("Creating certificate")) diff --git a/lms/envs/common.py b/lms/envs/common.py index 661268a902..d0365412f7 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -299,10 +299,6 @@ FEATURES = { # Turn on Advanced Security by default 'ADVANCED_SECURITY': True, - # Show a "Download your certificate" on the Progress page if the lowest - # nonzero grade cutoff is met - 'SHOW_PROGRESS_SUCCESS_BUTTON': False, - # When a logged in user goes to the homepage ('/') should the user be # redirected to the dashboard - this is default Open edX behavior. Set to # False to not redirect the user @@ -1737,10 +1733,6 @@ GRADES_DOWNLOAD = { 'ROOT_PATH': '/tmp/edx-s3/grades', } -######################## PROGRESS SUCCESS BUTTON ############################## -# The following fields are available in the URL: {course_id} {student_id} -PROGRESS_SUCCESS_BUTTON_URL = 'http:////{course_id}' -PROGRESS_SUCCESS_BUTTON_TEXT_OVERRIDE = None #### PASSWORD POLICY SETTINGS ##### PASSWORD_MIN_LENGTH = 8 diff --git a/lms/static/js/courseware/certificates_api.js b/lms/static/js/courseware/certificates_api.js new file mode 100644 index 0000000000..3efaaec7c5 --- /dev/null +++ b/lms/static/js/courseware/certificates_api.js @@ -0,0 +1,19 @@ +$(document).ready(function() { + $("#btn_generate_cert").click(function(e){ + e.preventDefault(); + var post_url = $("#btn_generate_cert").data("endpoint"); + $('#btn_generate_cert').prop("disabled", true); + $.ajax({ + type: "POST", + url: post_url, + dataType: 'text', + success: function () { + location.reload(); + }, + error: function(jqXHR, textStatus, errorThrown) { + $('#errors-info').html(jqXHR.responseText); + $('#btn_generate_cert').prop("disabled", false); + } + }); + }); +}); diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index 36957afd6b..d01eb22040 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -8,6 +8,7 @@ <%static:css group='style-course'/> + <%namespace name="progress_graph" file="/courseware/progress_graph.js"/> <%block name="pagetitle">${_("{course_number} Progress").format(course_number=course.display_number_with_default) | h} @@ -27,6 +28,7 @@ from django.utils.http import urlquote_plus + @@ -50,23 +52,27 @@ from django.utils.http import urlquote_plus

${_("Course Progress for Student '{username}' ({email})").format(username=student.username, email=student.email)}

- %if settings.FEATURES.get("SHOW_PROGRESS_SUCCESS_BUTTON"): - <% - SUCCESS_BUTTON_URL = settings.PROGRESS_SUCCESS_BUTTON_URL.format( - course_id=urlquote_plus(unicode(course.id)), - student_id=urlquote_plus(student.id) - ) - nonzero_cutoffs = [cutoff for cutoff in course.grade_cutoffs.values() if cutoff > 0] - success_cutoff = min(nonzero_cutoffs) if nonzero_cutoffs else None - %> - %if success_cutoff and grade_summary['percent'] > success_cutoff: - - %endif - %endif + %if show_generate_cert_btn: +
+ %if passed: + % if is_downloadable and download_url: + + ${_("Download Your Certificate")} + + %elif is_generating: + +

${_("Creating certificate")}

+ %else: + + %endif + %else: + +

${_("Your certificate will be available when you pass the course.")}

+ %endif +
+
+ %endif %if not course.disable_progress_graph: @@ -138,3 +144,4 @@ from django.utils.http import urlquote_plus + diff --git a/lms/urls.py b/lms/urls.py index a9ac601513..3ae637d130 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -415,6 +415,11 @@ if settings.COURSEWARE_ENABLED: 'courseware.masquerade.handle_ajax', name="masquerade_update"), ) + urlpatterns += ( + url(r'^courses/{}/generate_user_cert'.format(settings.COURSE_ID_PATTERN), + 'courseware.views.generate_user_cert', name="generate_user_cert"), + ) + # discussion forums live within courseware, so courseware must be enabled first if settings.FEATURES.get('ENABLE_DISCUSSION_SERVICE'): urlpatterns += (