diff --git a/lms/djangoapps/certificates/apps.py b/lms/djangoapps/certificates/apps.py index 15db77dfb8..31c87c5e58 100644 --- a/lms/djangoapps/certificates/apps.py +++ b/lms/djangoapps/certificates/apps.py @@ -5,6 +5,8 @@ Signal handlers are connected here. """ from django.apps import AppConfig +from django.conf import settings +from edx_proctoring.runtime import set_runtime_service class CertificatesConfig(AppConfig): @@ -20,3 +22,6 @@ class CertificatesConfig(AppConfig): # Can't import models at module level in AppConfigs, and models get # included from the signal handlers from . import signals # pylint: disable=unused-variable + if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): + from .services import CertificateService + set_runtime_service('certificates', CertificateService()) diff --git a/lms/djangoapps/certificates/services.py b/lms/djangoapps/certificates/services.py new file mode 100644 index 0000000000..5356c42d7f --- /dev/null +++ b/lms/djangoapps/certificates/services.py @@ -0,0 +1,41 @@ +""" +Certificate service +""" +import logging + +from django.core.exceptions import ObjectDoesNotExist +from lms.djangoapps.utils import _get_key +from opaque_keys.edx.keys import CourseKey + +from .models import GeneratedCertificate + +log = logging.getLogger(__name__) + + +class CertificateService(object): + """ + User Certificate service + """ + + def invalidate_certificate(self, user_id, course_key_or_id): + """ + Invalidate the user certificate in a given course if it exists. + """ + course_key = _get_key(course_key_or_id, CourseKey) + try: + generated_certificate = GeneratedCertificate.objects.get( + user=user_id, + course_id=course_key + ) + generated_certificate.invalidate() + log.info( + u'Certificate invalidated for user %d in course %s', + user_id, + course_key + ) + except ObjectDoesNotExist: + log.warning( + u'Invalidation failed because a certificate for user %d in course %s does not exist.', + user_id, + course_key + ) diff --git a/lms/djangoapps/certificates/tests/factories.py b/lms/djangoapps/certificates/tests/factories.py index ab893b1fee..2f0d490c38 100644 --- a/lms/djangoapps/certificates/tests/factories.py +++ b/lms/djangoapps/certificates/tests/factories.py @@ -24,6 +24,7 @@ class GeneratedCertificateFactory(DjangoModelFactory): mode = GeneratedCertificate.MODES.honor name = '' verify_uuid = uuid4().hex + grade = '' class CertificateWhitelistFactory(DjangoModelFactory): diff --git a/lms/djangoapps/certificates/tests/test_services.py b/lms/djangoapps/certificates/tests/test_services.py new file mode 100644 index 0000000000..ed9e7307c3 --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_services.py @@ -0,0 +1,60 @@ +""" +Unit Tests for the Certificate service +""" +from certificates.models import CertificateStatuses, GeneratedCertificate +from certificates.services import CertificateService +from certificates.tests.factories import GeneratedCertificateFactory +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class CertificateServiceTests(ModuleStoreTestCase): + """ + Tests for the Certificate service + """ + def setUp(self): + super(CertificateServiceTests, self).setUp() + self.service = CertificateService() + self.course = CourseFactory() + self.user = UserFactory() + self.user_id = self.user.id + self.course_id = self.course.id + GeneratedCertificateFactory.create( + status=CertificateStatuses.downloadable, + user=self.user, + course_id=self.course.id, + grade=1.0 + ) + + def generated_certificate_to_dict(self, generated_certificate): + """ + Converts a Generated Certificate instance to a Python dictionary + """ + return { + 'verify_uuid': generated_certificate.verify_uuid, + 'download_uuid': generated_certificate.download_uuid, + 'download_url': generated_certificate.download_url, + 'grade': generated_certificate.grade, + 'status': generated_certificate.status + } + + def test_invalidate_certificate(self): + """ + Verify that CertificateService invalidates the user certificate + """ + self.service.invalidate_certificate(self.user_id, self.course_id) + invalid_generated_certificate = GeneratedCertificate.objects.get( + user=self.user_id, + course_id=self.course_id + ) + self.assertDictEqual( + self.generated_certificate_to_dict(invalid_generated_certificate), + { + 'verify_uuid': '', + 'download_uuid': '', + 'download_url': '', + 'grade': '', + 'status': CertificateStatuses.unavailable + } + ) diff --git a/lms/djangoapps/grades/course_grade_factory.py b/lms/djangoapps/grades/course_grade_factory.py index c51a4f0b3a..275b450d38 100644 --- a/lms/djangoapps/grades/course_grade_factory.py +++ b/lms/djangoapps/grades/course_grade_factory.py @@ -1,3 +1,6 @@ +""" +Course Grade Factory Class +""" from collections import namedtuple from logging import getLogger @@ -152,7 +155,7 @@ class CourseGradeFactory(object): course_data, persistent_grade.percent_grade, persistent_grade.letter_grade, - persistent_grade.passed_timestamp is not None, + persistent_grade.letter_grade is not u'' ) @staticmethod diff --git a/lms/djangoapps/grades/services.py b/lms/djangoapps/grades/services.py index c0dc48ae4d..6e7130fc6e 100644 --- a/lms/djangoapps/grades/services.py +++ b/lms/djangoapps/grades/services.py @@ -1,7 +1,11 @@ +""" +Grade service +""" from datetime import datetime import pytz +from lms.djangoapps.utils import _get_key from opaque_keys.edx.keys import CourseKey, UsageKey from track.event_transaction_utils import create_new_event_transaction_id, set_event_transaction_type @@ -12,18 +16,6 @@ from .models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride from .signals.signals import SUBSECTION_OVERRIDE_CHANGED -def _get_key(key_or_id, key_cls): - """ - Helper method to get a course/usage key either from a string or a key_cls, - where the key_cls (CourseKey or UsageKey) will simply be returned. - """ - return ( - key_cls.from_string(key_or_id) - if isinstance(key_or_id, basestring) - else key_or_id - ) - - class GradesService(object): """ Course grade service diff --git a/lms/djangoapps/grades/tests/test_models.py b/lms/djangoapps/grades/tests/test_models.py index dd904addc0..ae9da575e4 100644 --- a/lms/djangoapps/grades/tests/test_models.py +++ b/lms/djangoapps/grades/tests/test_models.py @@ -386,7 +386,7 @@ class PersistentCourseGradesTest(GradesModelTestCase): self.assertEqual(grade.letter_grade, u'A') self.assertEqual(grade.passed_timestamp, passed_timestamp) - # If the grade later reverts to a failing grade, they keep their passed_timestamp + # If the grade later reverts to a failing grade, passed_timestamp remains the same. self.params.update({ u'percent_grade': 20.0, u'letter_grade': u'', diff --git a/lms/djangoapps/grades/tests/test_services.py b/lms/djangoapps/grades/tests/test_services.py index 1a6cac7fa0..f444ec8d1c 100644 --- a/lms/djangoapps/grades/tests/test_services.py +++ b/lms/djangoapps/grades/tests/test_services.py @@ -1,11 +1,13 @@ +""" +Grades Service Tests +""" +from datetime import datetime import ddt import pytz -from datetime import datetime from freezegun import freeze_time from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride -from lms.djangoapps.grades.services import GradesService, _get_key +from lms.djangoapps.grades.services import GradesService from mock import patch, call -from opaque_keys.edx.keys import CourseKey, UsageKey from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @@ -14,7 +16,7 @@ from ..config.waffle import REJECTED_EXAM_OVERRIDES_GRADE from ..constants import ScoreDatabaseTableEnum -class MockWaffleFlag(): +class MockWaffleFlag(object): def __init__(self, state): self.state = state @@ -27,7 +29,7 @@ class GradesServiceTests(ModuleStoreTestCase): """ Tests for the Grades service """ - def setUp(self, **kwargs): + def setUp(self): super(GradesServiceTests, self).setUp() self.service = GradesService() self.course = CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course') @@ -215,20 +217,6 @@ class GradesServiceTests(ModuleStoreTestCase): ) ) - @ddt.data( - ['edX/DemoX/Demo_Course', CourseKey.from_string('edX/DemoX/Demo_Course'), CourseKey], - ['course-v1:edX+DemoX+Demo_Course', CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'), CourseKey], - [CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'), - CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'), CourseKey], - ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow', - UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow'), UsageKey], - [UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow'), - UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow'), UsageKey], - ) - @ddt.unpack - def test_get_key(self, input_key, output_key, key_cls): - self.assertEqual(_get_key(input_key, key_cls), output_key) - def test_should_override_grade_on_rejected_exam(self): self.assertTrue(self.service.should_override_grade_on_rejected_exam('course-v1:edX+DemoX+Demo_Course')) self.mock_waffle_flags.return_value = { diff --git a/lms/djangoapps/tests/__init__.py b/lms/djangoapps/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/tests/test_utils.py b/lms/djangoapps/tests/test_utils.py new file mode 100644 index 0000000000..c7463d9bbc --- /dev/null +++ b/lms/djangoapps/tests/test_utils.py @@ -0,0 +1,26 @@ +""" +Unit Tests for Utils Class +""" +from unittest import TestCase + +import ddt + +from lms.djangoapps.utils import _get_key +from opaque_keys.edx.keys import CourseKey, UsageKey + + +@ddt.ddt +class UtilsTests(TestCase): + @ddt.data( + ['edX/DemoX/Demo_Course', CourseKey.from_string('edX/DemoX/Demo_Course'), CourseKey], + ['course-v1:edX+DemoX+Demo_Course', CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'), CourseKey], + [CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'), + CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'), CourseKey], + ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow', + UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow'), UsageKey], + [UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow'), + UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow'), UsageKey], + ) + @ddt.unpack + def test_get_key(self, input_key, output_key, key_cls): + self.assertEqual(_get_key(input_key, key_cls), output_key) diff --git a/lms/djangoapps/utils.py b/lms/djangoapps/utils.py new file mode 100644 index 0000000000..0d0280f96a --- /dev/null +++ b/lms/djangoapps/utils.py @@ -0,0 +1,15 @@ +""" +Helper Methods +""" + + +def _get_key(key_or_id, key_cls): + """ + Helper method to get a course/usage key either from a string or a key_cls, + where the key_cls (CourseKey or UsageKey) will simply be returned. + """ + return ( + key_cls.from_string(key_or_id) + if isinstance(key_or_id, basestring) + else key_or_id + )