diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index e00a324b5bde4f2b1951bc4352d8f4af03ece8c8..818a3b2094660075193ae2a82651e63c11b8a6b4 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -47,8 +47,8 @@ Eligibility: """ import json import logging -import uuid import os +import uuid from django.contrib.auth.models import User from django.core.exceptions import ValidationError @@ -62,12 +62,14 @@ from django_extensions.db.fields import CreationDateTimeField from django_extensions.db.fields.json import JSONField from model_utils import Choices from model_utils.models import TimeStampedModel -from xmodule.modulestore.django import modulestore +from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED + from config_models.models import ConfigurationModel -from xmodule_django.models import CourseKeyField, NoneToEmptyManager -from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled from course_modes.models import CourseMode from instructor_task.models import InstructorTask +from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled +from xmodule.modulestore.django import modulestore +from xmodule_django.models import CourseKeyField, NoneToEmptyManager LOGGER = logging.getLogger(__name__) @@ -292,6 +294,24 @@ class GeneratedCertificate(models.Model): """ return self.status == CertificateStatuses.downloadable + def save(self, *args, **kwargs): + """ + After the base save() method finishes, fire the COURSE_CERT_AWARDED + signal iff we have stored a record of a learner passing the course. + + The learner is assumed to have passed the course if certificate status + is either 'generating' or 'downloadable'. + """ + super(GeneratedCertificate, self).save(*args, **kwargs) + if self.status in [CertificateStatuses.generating, CertificateStatuses.downloadable]: + COURSE_CERT_AWARDED.send_robust( + sender=self.__class__, + user=self.user, + course_key=self.course_id, + mode=self.mode, + status=self.status, + ) + class CertificateGenerationHistory(TimeStampedModel): """ @@ -401,16 +421,13 @@ class CertificateInvalidation(TimeStampedModel): return data -@receiver(post_save, sender=GeneratedCertificate) -def handle_post_cert_generated(sender, instance, **kwargs): # pylint: disable=unused-argument +@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) +def handle_course_cert_awarded(sender, user, course_key, **kwargs): # pylint: disable=unused-argument """ - Handles post_save signal of GeneratedCertificate, and mark user collected - course milestone entry if user has passed the course. - User is assumed to have passed the course if certificate status is either 'generating' or 'downloadable'. + Mark a milestone entry if user has passed the course. """ - allowed_cert_states = [CertificateStatuses.generating, CertificateStatuses.downloadable] - if is_prerequisite_courses_enabled() and instance.status in allowed_cert_states: - fulfill_course_milestone(instance.course_id, instance.user) + if is_prerequisite_courses_enabled(): + fulfill_course_milestone(course_key, user) def certificate_status_for_student(student, course_id): @@ -1065,25 +1082,25 @@ class CertificateTemplateAsset(TimeStampedModel): app_label = "certificates" -@receiver(post_save, sender=GeneratedCertificate) +@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) #pylint: disable=unused-argument -def create_badge(sender, instance, **kwargs): +def create_badge(sender, user, course_key, status, **kwargs): """ Standard signal hook to create badges when a certificate has been generated. """ if not settings.FEATURES.get('ENABLE_OPENBADGES', False): return - if not modulestore().get_course(instance.course_id).issue_badges: + if not modulestore().get_course(course_key).issue_badges: LOGGER.info("Course is not configured to issue badges.") return - if BadgeAssertion.objects.filter(user=instance.user, course_id=instance.course_id): + if BadgeAssertion.objects.filter(user=user, course_id=course_key): LOGGER.info("Badge already exists for this user on this course.") # Badge already exists. Skip. return # Don't bake a badge until the certificate is available. Prevents user-facing requests from being paused for this # by making sure it only gets run on the callback during normal workflow. - if not instance.status == CertificateStatuses.downloadable: + if not status == CertificateStatuses.downloadable: return from .badge_handler import BadgeHandler - handler = BadgeHandler(instance.course_id) - handler.award(instance.user) + handler = BadgeHandler(course_key) + handler.award(user) diff --git a/openedx/core/djangoapps/programs/migrations/0004_programsapiconfig_enable_certification.py b/openedx/core/djangoapps/programs/migrations/0004_programsapiconfig_enable_certification.py new file mode 100644 index 0000000000000000000000000000000000000000..dd27de259cfb09bd184182c993160901966801e9 --- /dev/null +++ b/openedx/core/djangoapps/programs/migrations/0004_programsapiconfig_enable_certification.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programs', '0003_auto_20151120_1613'), + ] + + operations = [ + migrations.AddField( + model_name='programsapiconfig', + name='enable_certification', + field=models.BooleanField(default=False, verbose_name='Enable Program Certificate Generation'), + ), + ] diff --git a/openedx/core/djangoapps/programs/models.py b/openedx/core/djangoapps/programs/models.py index 6229e140e1fe2c1fc27634287dcf0b5e1f5ae7b6..069051ef51137137d77c8a894be6cb736ff0ef40 100644 --- a/openedx/core/djangoapps/programs/models.py +++ b/openedx/core/djangoapps/programs/models.py @@ -54,11 +54,17 @@ class ProgramsApiConfig(ConfigurationModel): verbose_name=_("Enable Student Dashboard Displays"), default=False ) + enable_studio_tab = models.BooleanField( verbose_name=_("Enable Studio Authoring Interface"), default=False ) + enable_certification = models.BooleanField( + verbose_name=_("Enable Program Certificate Generation"), + default=False + ) + @property def internal_api_url(self): """ @@ -109,3 +115,11 @@ class ProgramsApiConfig(ConfigurationModel): bool(self.authoring_app_js_path) and bool(self.authoring_app_css_path) ) + + @property + def is_certification_enabled(self): + """ + Indicates whether background tasks should be initiated to grant + certificates for Program completion. + """ + return self.enabled and self.enable_certification diff --git a/openedx/core/djangoapps/programs/signals.py b/openedx/core/djangoapps/programs/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..405f154d409104fe7dc048de66f2bbe1b5a35697 --- /dev/null +++ b/openedx/core/djangoapps/programs/signals.py @@ -0,0 +1,51 @@ +""" +This module contains signals / handlers related to programs. +""" +import logging + +from django.dispatch import receiver + +from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED +from openedx.core.djangoapps.programs.models import ProgramsApiConfig + + +LOGGER = logging.getLogger(__name__) + + +@receiver(COURSE_CERT_AWARDED) +def handle_course_cert_awarded(sender, user, course_key, mode, status, **kwargs): # pylint: disable=unused-argument + """ + If programs is enabled and a learner is awarded a course certificate, + schedule a celery task to process any programs certificates for which + the learner may now be eligible. + + Args: + sender: + class of the object instance that sent this signal + user: + django.contrib.auth.User - the user to whom a cert was awarded + course_key: + refers to the course run for which the cert was awarded + mode: + mode / certificate type, e.g. "verified" + status: + either "downloadable" or "generating" + + Returns: + None + + """ + if not ProgramsApiConfig.current().is_certification_enabled: + return + + # schedule background task to process + LOGGER.debug( + 'handling COURSE_CERT_AWARDED: username=%s, course_key=%s, mode=%s, status=%s', + user, + course_key, + mode, + status, + ) + # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded + from openedx.core.djangoapps.programs import tasks + tasks.award_program_certificates.delay(user.username) diff --git a/openedx/core/djangoapps/programs/tasks.py b/openedx/core/djangoapps/programs/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..07337082ded86fe8a5b02242b4cf110a43e5d78c --- /dev/null +++ b/openedx/core/djangoapps/programs/tasks.py @@ -0,0 +1,55 @@ +""" +This file contains celery tasks for programs-related functionality. +""" + +from celery import task +from celery.utils.log import get_task_logger # pylint: disable=no-name-in-module, import-error + +from lms.djangoapps.certificates.api import get_certificates_for_user + +LOGGER = get_task_logger(__name__) + + +@task +def award_program_certificates(username): + """ + This task is designed to be called whenever a user's completion status + changes with respect to one or more courses (primarily, when a course + certificate is awarded). + + It will consult with a variety of APIs to determine whether or not the + specified user should be awarded a certificate in one or more programs, and + use the credentials service to create said certificates if so. + + This task may also be invoked independently of any course completion status + change - for example, to backpopulate missing program credentials for a + user. + + TODO: this is shelled out and incomplete for now. + """ + + # fetch the set of all course runs for which the user has earned a certificate + LOGGER.debug('fetching all completed courses for user %s', username) + user_certs = get_certificates_for_user(username) + course_certs = [ + {'course_id': uc['course_id'], 'mode': uc['mode']} + for uc in user_certs + if uc['status'] in ('downloadable', 'generating') + ] + + # invoke the Programs API completion check endpoint to identify any programs + # that are satisfied by these course completions + LOGGER.debug('determining completed programs for courses: %r', course_certs) + program_ids = [] # TODO + + # determine which program certificates the user has already been awarded, if + # any, and remove those, since they already exist. + LOGGER.debug('fetching existing program certificates for %s', username) + existing_program_ids = [] # TODO + new_program_ids = list(set(program_ids) - set(existing_program_ids)) + + # generate a new certificate for each of the remaining programs. + LOGGER.debug('generating new program certificates for %s in programs: %r', username, new_program_ids) + for program_id in new_program_ids: + LOGGER.debug('calling credentials service to issue certificate for user %s in program %s', username, program_id) + # TODO diff --git a/openedx/core/djangoapps/programs/tests/mixins.py b/openedx/core/djangoapps/programs/tests/mixins.py index 5d9f450d6a07e25bf70c43b3c65c398e45b56fe0..b78862202e0b0e91793de4ad55a30bd121482076 100644 --- a/openedx/core/djangoapps/programs/tests/mixins.py +++ b/openedx/core/djangoapps/programs/tests/mixins.py @@ -19,6 +19,7 @@ class ProgramsApiConfigMixin(object): 'cache_ttl': 0, 'enable_student_dashboard': True, 'enable_studio_tab': True, + 'enable_certification': True, } def create_programs_config(self, **kwargs): diff --git a/openedx/core/djangoapps/programs/tests/test_models.py b/openedx/core/djangoapps/programs/tests/test_models.py index 5f03842d4138b80dc7a9124564201795d9d81232..5f0c3f7cfe99cc6f8ac17299036f6cb5f3fd81d4 100644 --- a/openedx/core/djangoapps/programs/tests/test_models.py +++ b/openedx/core/djangoapps/programs/tests/test_models.py @@ -76,3 +76,17 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase): programs_config = self.create_programs_config() self.assertTrue(programs_config.is_studio_tab_enabled) + + def test_is_certification_enabled(self, _mock_cache): + """ + Verify that the property controlling certification-related functionality + for Programs behaves as expected. + """ + programs_config = self.create_programs_config(enabled=False) + self.assertFalse(programs_config.is_certification_enabled) + + programs_config = self.create_programs_config(enable_certification=False) + self.assertFalse(programs_config.is_certification_enabled) + + programs_config = self.create_programs_config() + self.assertTrue(programs_config.is_certification_enabled) diff --git a/openedx/core/djangoapps/programs/tests/test_signals.py b/openedx/core/djangoapps/programs/tests/test_signals.py new file mode 100644 index 0000000000000000000000000000000000000000..80545f25e324ae4ee3e999426427233ddea29dcd --- /dev/null +++ b/openedx/core/djangoapps/programs/tests/test_signals.py @@ -0,0 +1,71 @@ +""" +This module contains tests for programs-related signals and signal handlers. +""" + +from django.test import TestCase +import mock + +from student.tests.factories import UserFactory + +from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED +from openedx.core.djangoapps.programs.signals import handle_course_cert_awarded + + +TEST_USERNAME = 'test-user' + + +@mock.patch('openedx.core.djangoapps.programs.tasks.award_program_certificates.delay') +@mock.patch( + 'openedx.core.djangoapps.programs.models.ProgramsApiConfig.is_certification_enabled', + new_callable=mock.PropertyMock, + return_value=False, +) +class CertAwardedReceiverTest(TestCase): + """ + Tests for the `handle_course_cert_awarded` signal handler function. + """ + + @property + def signal_kwargs(self): + """ + DRY helper. + """ + return dict( + sender=self.__class__, + user=UserFactory.create(username=TEST_USERNAME), + course_key='test-course', + mode='test-mode', + status='test-status', + ) + + def test_signal_received(self, mock_is_certification_enabled, mock_task): # pylint: disable=unused-argument + """ + Ensures the receiver function is invoked when COURSE_CERT_AWARDED is + sent. + + Suboptimal: because we cannot mock the receiver function itself (due + to the way django signals work), we mock a configuration call that is + known to take place inside the function. + """ + COURSE_CERT_AWARDED.send(**self.signal_kwargs) + self.assertEqual(mock_is_certification_enabled.call_count, 1) + + def test_programs_disabled(self, mock_is_certification_enabled, mock_task): + """ + Ensures that the receiver function does nothing when the programs API + configuration is not enabled. + """ + handle_course_cert_awarded(**self.signal_kwargs) + self.assertEqual(mock_is_certification_enabled.call_count, 1) + self.assertEqual(mock_task.call_count, 0) + + def test_programs_enabled(self, mock_is_certification_enabled, mock_task): + """ + Ensures that the receiver function invokes the expected celery task + when the programs API configuration is enabled. + """ + mock_is_certification_enabled.return_value = True + handle_course_cert_awarded(**self.signal_kwargs) + self.assertEqual(mock_is_certification_enabled.call_count, 1) + self.assertEqual(mock_task.call_count, 1) + self.assertEqual(mock_task.call_args[0], (TEST_USERNAME,)) diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py index 57200789f836dccdfe6258f44d937338a746d430..13e7ff6a8467d619852470b396165a85a49ab5d3 100644 --- a/openedx/core/djangoapps/signals/signals.py +++ b/openedx/core/djangoapps/signals/signals.py @@ -7,3 +7,8 @@ from django.dispatch import Signal # Signal that fires when a user is graded (in lms/courseware/grades.py) GRADES_UPDATED = Signal(providing_args=["username", "grade_summary", "course_key", "deadline"]) + +# Signal that fires when a user is awarded a certificate in a course (in the certificates django app) +# TODO: runtime coupling between apps will be reduced if this event is changed to carry a username +# rather than a User object; however, this will require changes to the milestones and badges APIs +COURSE_CERT_AWARDED = Signal(providing_args=["user", "course_key", "mode", "status"])