Skip to content
Snippets Groups Projects
Commit a48b8b27 authored by Jim Abramson's avatar Jim Abramson
Browse files

Merge pull request #11318 from edx/jsa/ecom-3523

Add COURSE_CERT_AWARDED signal and celery task stub
parents 640b5d31 56d3fa01
No related merge requests found
......@@ -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)
# -*- 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"])
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