Add config switch + signal for generating program certs.
ECOM-3523
This commit is contained in:
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
51
openedx/core/djangoapps/programs/signals.py
Normal file
51
openedx/core/djangoapps/programs/signals.py
Normal file
@@ -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)
|
||||
55
openedx/core/djangoapps/programs/tasks.py
Normal file
55
openedx/core/djangoapps/programs/tasks.py
Normal file
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
71
openedx/core/djangoapps/programs/tests/test_signals.py
Normal file
71
openedx/core/djangoapps/programs/tests/test_signals.py
Normal file
@@ -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,))
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user