Commit 9d141ae7 by jsa

Add config switch + signal for generating program certs.

ECOM-3523
parent 45db325a
......@@ -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):
"""
......
# -*- 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
"""
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)
"""
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)
"""
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"])
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment