Send program cert visible_date

When sending a program cert to Credentials, also send along a
calculated visible_date along with it.

LEARNER-6262
This commit is contained in:
Diana Huang
2018-08-30 16:00:34 -04:00
committed by Michael Terry
parent 05debb8d54
commit 755ebc8c7f
6 changed files with 207 additions and 157 deletions

View File

@@ -45,6 +45,7 @@ class TestGetCredentials(CredentialsApiConfigMixin, CacheIsolationTestCase):
querystring = {
'username': self.user.username,
'status': 'awarded',
'only_visible': 'True',
}
cache_key = '{}.{}'.format(self.credentials_config.CACHE_KEY, self.user.username)
self.assertEqual(kwargs['querystring'], querystring)
@@ -66,6 +67,7 @@ class TestGetCredentials(CredentialsApiConfigMixin, CacheIsolationTestCase):
querystring = {
'username': self.user.username,
'status': 'awarded',
'only_visible': 'True',
'program_uuid': program_uuid,
}
cache_key = '{}.{}.{}'.format(self.credentials_config.CACHE_KEY, self.user.username, program_uuid)
@@ -84,6 +86,7 @@ class TestGetCredentials(CredentialsApiConfigMixin, CacheIsolationTestCase):
querystring = {
'username': self.user.username,
'status': 'awarded',
'only_visible': 'True',
'type': 'program',
}
self.assertEqual(kwargs['querystring'], querystring)

View File

@@ -64,7 +64,7 @@ def get_credentials(user, program_uuid=None, credential_type=None):
"""
credential_configuration = CredentialsApiConfig.current()
querystring = {'username': user.username, 'status': 'awarded'}
querystring = {'username': user.username, 'status': 'awarded', 'only_visible': 'True'}
if program_uuid:
querystring['program_uuid'] = program_uuid

View File

@@ -29,6 +29,7 @@ MAX_RETRIES = 11
PROGRAM_CERTIFICATE = 'program'
COURSE_CERTIFICATE = 'course-run'
VISIBLE_DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
def get_completed_programs(site, student):
@@ -40,11 +41,11 @@ def get_completed_programs(site, student):
student (User): Representing the student whose completed programs to check for.
Returns:
list of program UUIDs
dict of {program_UUIDs: visible_dates}
"""
meter = ProgramProgressMeter(site, student)
return meter.completed_programs
return meter.completed_programs_with_available_dates
def get_certified_programs(student):
@@ -66,7 +67,7 @@ def get_certified_programs(student):
return certified_programs
def award_program_certificate(client, username, program_uuid):
def award_program_certificate(client, username, program_uuid, visible_date):
"""
Issue a new certificate of completion to the given student for the given program.
@@ -77,6 +78,8 @@ def award_program_certificate(client, username, program_uuid):
The username of the student
program_uuid:
uuid of the completed program
visible_date:
when the program credential should be visible to user
Returns:
None
@@ -88,7 +91,12 @@ def award_program_certificate(client, username, program_uuid):
'type': PROGRAM_CERTIFICATE,
'program_uuid': program_uuid
},
'attributes': []
'attributes': [
{
'name': 'visible_date',
'value': visible_date.strftime(VISIBLE_DATE_FORMAT)
}
]
})
@@ -136,10 +144,10 @@ def award_program_certificates(self, username):
LOGGER.exception('Task award_program_certificates was called with invalid username %s', username)
# Don't retry for this case - just conclude the task.
return
program_uuids = []
completed_programs = {}
for site in Site.objects.all():
program_uuids.extend(get_completed_programs(site, student))
if not program_uuids:
completed_programs.update(get_completed_programs(site, student))
if not completed_programs:
# No reason to continue beyond this point unless/until this
# task gets updated to support revocation of program certs.
LOGGER.info('Task award_program_certificates was called for user %s with no completed programs', username)
@@ -158,7 +166,7 @@ def award_program_certificates(self, username):
# This logic is important, because we will retry the whole task if awarding any particular program cert fails.
#
# N.B. the list is sorted to facilitate deterministic ordering, e.g. for tests.
new_program_uuids = sorted(list(set(program_uuids) - set(existing_program_uuids)))
new_program_uuids = sorted(list(set(completed_programs.keys()) - set(existing_program_uuids)))
if new_program_uuids:
try:
credentials_client = get_credentials_api_client(
@@ -171,8 +179,9 @@ def award_program_certificates(self, username):
failed_program_certificate_award_attempts = []
for program_uuid in new_program_uuids:
visible_date = completed_programs[program_uuid]
try:
award_program_certificate(credentials_client, username, program_uuid)
award_program_certificate(credentials_client, username, program_uuid, visible_date)
LOGGER.info('Awarded certificate for program %s to user %s', program_uuid, username)
except exceptions.HttpNotFoundError:
LOGGER.exception(
@@ -237,7 +246,7 @@ def post_course_certificate(client, username, certificate, visible_date):
'attributes': [
{
'name': 'visible_date',
'value': visible_date.strftime('%Y-%m-%dT%H:%M:%SZ')
'value': visible_date.strftime(VISIBLE_DATE_FORMAT)
}
]
})

View File

@@ -89,7 +89,7 @@ class AwardProgramCertificateTestCase(TestCase):
'http://test-server/credentials/',
)
tasks.award_program_certificate(test_client, test_username, 123)
tasks.award_program_certificate(test_client, test_username, 123, datetime(2010, 5, 30))
expected_body = {
'username': test_username,
@@ -97,7 +97,12 @@ class AwardProgramCertificateTestCase(TestCase):
'program_uuid': 123,
'type': tasks.PROGRAM_CERTIFICATE,
},
'attributes': []
'attributes': [
{
'name': 'visible_date',
'value': '2010-05-30T00:00:00Z',
}
]
}
self.assertEqual(json.loads(httpretty.last_request().body), expected_body)
@@ -154,7 +159,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
Checks that the Credentials API is used to award certificates for
the proper programs.
"""
mock_get_completed_programs.return_value = [1, 2, 3]
mock_get_completed_programs.return_value = {1: 1, 2: 2, 3: 3}
mock_get_certified_programs.return_value = already_awarded_program_uuids
tasks.award_program_certificates.delay(self.student.username).get()
@@ -162,6 +167,9 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
actual_program_uuids = [call[0][2] for call in mock_award_program_certificate.call_args_list]
self.assertEqual(actual_program_uuids, expected_awarded_program_uuids)
actual_visible_dates = [call[0][3] for call in mock_award_program_certificate.call_args_list]
self.assertEqual(actual_visible_dates, expected_awarded_program_uuids) # program uuids are same as mock dates
@ddt.data(
('credentials', 'enable_learner_issuance'),
)
@@ -205,7 +213,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
Checks that the task will be aborted without further action if there
are no programs for which to award a certificate.
"""
mock_get_completed_programs.return_value = []
mock_get_completed_programs.return_value = {}
tasks.award_program_certificates.delay(self.student.username).get()
self.assertTrue(mock_get_completed_programs.called)
self.assertFalse(mock_get_certified_programs.called)
@@ -244,7 +252,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
successfully awarded certs are logged as INFO and warning is logged
for failed requests if there are retries available.
"""
mock_get_completed_programs.return_value = [1, 2]
mock_get_completed_programs.return_value = {1: 1, 2: 2}
mock_get_certified_programs.side_effect = [[], [2]]
mock_award_program_certificate.side_effect = self._make_side_effect([Exception('boom'), None])
@@ -288,7 +296,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
transient API errors) will cause the task to be failed and queued for
retry.
"""
mock_get_completed_programs.return_value = [1, 2]
mock_get_completed_programs.return_value = {1: 1, 2: 2}
mock_get_certified_programs.return_value = [1]
mock_get_certified_programs.side_effect = self._make_side_effect([Exception('boom'), None])
tasks.award_program_certificates.delay(self.student.username).get()
@@ -306,7 +314,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
"""
exception = exceptions.HttpClientError()
exception.response = mock.Mock(status_code=429)
mock_get_completed_programs.return_value = [1, 2]
mock_get_completed_programs.return_value = {1: 1, 2: 2}
mock_award_program_certificate.side_effect = self._make_side_effect(
[exception, None]
)
@@ -326,7 +334,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
"""
exception = exceptions.HttpNotFoundError()
exception.response = mock.Mock(status_code=404)
mock_get_completed_programs.return_value = [1, 2]
mock_get_completed_programs.return_value = {1: 1, 2: 2}
mock_award_program_certificate.side_effect = self._make_side_effect(
[exception, None]
)
@@ -346,7 +354,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
"""
exception = exceptions.HttpClientError()
exception.response = mock.Mock(status_code=418)
mock_get_completed_programs.return_value = [1, 2]
mock_get_completed_programs.return_value = {1: 1, 2: 2}
mock_award_program_certificate.side_effect = self._make_side_effect(
[exception, None]
)

View File

@@ -18,6 +18,7 @@ from entitlements.tests.factories import CourseEntitlementFactory
from waffle.testutils import override_switch
from lms.djangoapps.certificates.api import MODES
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.commerce.tests.test_utils import update_commerce_config
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.grades.tests.utils import mock_passing_grade
@@ -77,6 +78,11 @@ class TestProgramProgressMeter(TestCase):
for course_uuid in course_uuids:
CourseEntitlementFactory(user=self.user, course_uuid=course_uuid)
def _create_certificates(self, *course_keys, **kwargs):
""" Variadic helper used to create course certificates. """
kwargs.setdefault('status', 'downloadable')
return [GeneratedCertificateFactory(user=self.user, course_id=key, **kwargs) for key in course_keys]
def _assert_progress(self, meter, *progresses):
"""Variadic helper used to verify progress calculations."""
self.assertEqual(meter.progress(), list(progresses))
@@ -86,23 +92,6 @@ class TestProgramProgressMeter(TestCase):
for program in programs:
program['detail_url'] = reverse('program_details_view', kwargs={'program_uuid': program['uuid']})
def _make_certificate_result(self, **kwargs):
"""Helper to create dummy results from the certificates API."""
result = {
'username': 'dummy-username',
'course_key': 'dummy-course',
'type': 'dummy-type',
'status': 'dummy-status',
'download_url': 'http://www.example.com/cert.pdf',
'grade': '0.98',
'created': '2015-07-31T00:00:00Z',
'modified': '2015-07-31T00:00:00Z',
}
result.update(**kwargs)
return result
def test_no_enrollments_or_entitlements(self, mock_get_programs):
"""Verify behavior when programs exist, but no relevant enrollments or entitlements do."""
data = [ProgramFactory()]
@@ -112,7 +101,7 @@ class TestProgramProgressMeter(TestCase):
self.assertEqual(meter.engaged_programs, [])
self._assert_progress(meter)
self.assertEqual(meter.completed_programs, [])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
def test_enrollments_but_no_programs(self, mock_get_programs):
"""Verify behavior when enrollments exist, but no matching programs do."""
@@ -124,7 +113,7 @@ class TestProgramProgressMeter(TestCase):
self.assertEqual(meter.engaged_programs, [])
self._assert_progress(meter)
self.assertEqual(meter.completed_programs, [])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
def test_entitlements_but_no_programs(self, mock_get_programs):
""" Verify engaged_programs is empty when entitlements exist, but no matching programs do. """
@@ -163,7 +152,7 @@ class TestProgramProgressMeter(TestCase):
meter,
ProgressFactory(uuid=program['uuid'], in_progress=1, grades={course_run_key: 0.0})
)
self.assertEqual(meter.completed_programs, [])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
def test_single_program_entitlement(self, mock_get_programs):
"""
@@ -387,7 +376,7 @@ class TestProgramProgressMeter(TestCase):
meter,
*(ProgressFactory(uuid=program['uuid'], in_progress=1, grades=grades) for program in programs)
)
self.assertEqual(meter.completed_programs, [])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
def test_multiple_program_entitlement(self, mock_get_programs):
"""
@@ -460,7 +449,7 @@ class TestProgramProgressMeter(TestCase):
meter,
*(ProgressFactory(uuid=program['uuid'], in_progress=1, grades=grades) for program in programs)
)
self.assertEqual(meter.completed_programs, [])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
def test_shared_entitlement_engagement(self, mock_get_programs):
"""
@@ -490,8 +479,7 @@ class TestProgramProgressMeter(TestCase):
programs = data[:3]
self.assertEqual(meter.engaged_programs, programs)
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
def test_simulate_progress(self, mock_completed_course_runs, mock_get_programs):
def test_simulate_progress(self, mock_get_programs):
"""Simulate the entirety of a user's progress through a program."""
first_course_run_key, second_course_run_key = (generate_course_run_key() for __ in range(2))
data = [
@@ -512,7 +500,7 @@ class TestProgramProgressMeter(TestCase):
# No enrollments, no programs in progress.
meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(meter)
self.assertEqual(meter.completed_programs, [])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
# One enrollment, one program in progress.
self._create_enrollments(first_course_run_key)
@@ -522,7 +510,7 @@ class TestProgramProgressMeter(TestCase):
meter,
ProgressFactory(uuid=program_uuid, in_progress=1, not_started=1, grades={first_course_run_key: 0.0})
)
self.assertEqual(meter.completed_programs, [])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
# Two enrollments, all courses in progress.
self._create_enrollments(second_course_run_key)
@@ -538,12 +526,10 @@ class TestProgramProgressMeter(TestCase):
},
)
)
self.assertEqual(meter.completed_programs, [])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
# One valid certificate earned, one course complete.
mock_completed_course_runs.return_value = [
{'course_run_id': first_course_run_key, 'type': MODES.verified},
]
self._create_certificates(first_course_run_key, mode=MODES.verified)
meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(
meter,
@@ -557,13 +543,11 @@ class TestProgramProgressMeter(TestCase):
}
)
)
self.assertEqual(meter.completed_programs, [])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
# Invalid certificate earned, still one course to complete. (invalid because mode doesn't match the course)
second_cert = self._create_certificates(second_course_run_key, mode=MODES.honor)[0]
# Invalid certificate earned, still one course to complete.
mock_completed_course_runs.return_value = [
{'course_run_id': first_course_run_key, 'type': MODES.verified},
{'course_run_id': second_course_run_key, 'type': MODES.honor},
]
meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(
meter,
@@ -577,13 +561,11 @@ class TestProgramProgressMeter(TestCase):
}
)
)
self.assertEqual(meter.completed_programs, [])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
# Second valid certificate obtained, all courses complete.
mock_completed_course_runs.return_value = [
{'course_run_id': first_course_run_key, 'type': MODES.verified},
{'course_run_id': second_course_run_key, 'type': MODES.verified},
]
second_cert.mode = MODES.verified
second_cert.save()
meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(
meter,
@@ -596,10 +578,9 @@ class TestProgramProgressMeter(TestCase):
}
)
)
self.assertEqual(meter.completed_programs, [program_uuid])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [program_uuid])
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
def test_nonverified_course_run_completion(self, mock_completed_course_runs, mock_get_programs):
def test_nonverified_course_run_completion(self, mock_get_programs):
"""
Course runs aren't necessarily of type verified. Verify that a program can
still be completed when this is the case.
@@ -619,9 +600,7 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = data
self._create_enrollments(course_run_key)
mock_completed_course_runs.return_value = [
{'course_run_id': course_run_key, 'type': MODES.honor},
]
self._create_certificates(course_run_key)
meter = ProgramProgressMeter(self.site, self.user)
program, program_uuid = data[0], data[0]['uuid']
@@ -629,71 +608,80 @@ class TestProgramProgressMeter(TestCase):
meter,
ProgressFactory(uuid=program_uuid, completed=1, grades={course_run_key: 0.0})
)
self.assertEqual(meter.completed_programs, [program_uuid])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [program_uuid])
@mock.patch(UTILS_MODULE + '.available_date_for_certificate')
def test_completed_programs_with_available_dates(self, mock_available_date_for_certificate, mock_get_programs):
# First we want to set up the scenario:
# - A program that is incomplete due to no cert (won't show up in result)
# - A program that is incomplete due to cert with wrong mode (won't show up in result)
# - A completed program with the multiple certs, each with specific available dates
run_no_cert = CourseRunFactory()
run_wrong_mode = CourseRunFactory()
run_course1_1 = CourseRunFactory()
run_course1_2 = CourseRunFactory()
run_course2 = CourseRunFactory()
course_no_cert = CourseFactory(course_runs=[run_no_cert])
course_wrong_mode = CourseFactory(course_runs=[run_wrong_mode])
course1 = CourseFactory(course_runs=[run_course1_1, run_course1_2])
course2 = CourseFactory(course_runs=[run_course2])
program_incomplete_no_cert = ProgramFactory(courses=[course_no_cert, course1, course2])
program_incomplete_wrong_mode = ProgramFactory(courses=[course_wrong_mode])
program_complete = ProgramFactory(courses=[course1, course2])
mock_get_programs.return_value = [program_incomplete_no_cert, program_incomplete_wrong_mode, program_complete]
self._create_enrollments(run_no_cert['key'], run_wrong_mode['key'], run_course1_1['key'], run_course1_2['key'],
run_course2['key'])
self._create_certificates(run_wrong_mode['key'], mode=MODES.audit)
self._create_certificates(run_course1_1['key'], run_course1_2['key'], run_course2['key'], mode=MODES.verified)
def available_date_fake(_course, cert):
""" Fake available_date_for_certificate """
if str(cert.course_id) == run_course1_1['key']:
return datetime.datetime(2018, 1, 1)
if str(cert.course_id) == run_course1_2['key']:
return datetime.datetime(2017, 1, 1)
if str(cert.course_id) == run_course2['key']:
return datetime.datetime(2016, 1, 1)
return datetime.datetime(2015, 1, 1)
mock_available_date_for_certificate.side_effect = available_date_fake
def test_empty_programs(self, mock_get_programs):
"""Verify that programs with no courses do not count as completed."""
program = ProgramFactory()
program['courses'] = []
meter = ProgramProgressMeter(self.site, self.user)
program_complete = meter._is_program_complete(program)
self.assertFalse(program_complete)
available_dates = meter.completed_programs_with_available_dates
self.assertDictEqual(available_dates, {
program_complete['uuid']: datetime.datetime(2017, 1, 1)
})
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
def test_completed_programs(self, mock_completed_course_runs, mock_get_programs):
"""Verify that completed programs are correctly identified."""
data = ProgramFactory.create_batch(3)
mock_get_programs.return_value = data
program_uuids = []
course_run_keys = []
for program in data:
program_uuids.append(program['uuid'])
for course in program['courses']:
for course_run in course['course_runs']:
course_run_keys.append(course_run['key'])
# Verify that no programs are complete.
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, [])
# Complete all programs.
self._create_enrollments(*course_run_keys)
mock_completed_course_runs.return_value = [
{'course_run_id': course_run_key, 'type': MODES.verified}
for course_run_key in course_run_keys
]
# Verify that all programs are complete.
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, program_uuids)
@mock.patch(UTILS_MODULE + '.certificate_api.get_certificates_for_user')
def test_completed_course_runs(self, mock_get_certificates_for_user, _mock_get_programs):
def test_completed_course_runs(self, mock_get_programs):
"""
Verify that the method can find course run certificates when not mocked out.
"""
mock_get_certificates_for_user.return_value = [
self._make_certificate_result(
status='downloadable', type=CourseMode.VERIFIED, course_key='downloadable-course'
),
self._make_certificate_result(status='generating', type='honor', course_key='generating-course'),
self._make_certificate_result(status='unknown', course_key='unknown-course'),
]
downloadable = CourseRunFactory()
generating = CourseRunFactory()
unknown = CourseRunFactory()
course = CourseFactory(course_runs=[downloadable, generating, unknown])
program = ProgramFactory(courses=[course])
mock_get_programs.return_value = [program]
self._create_enrollments(downloadable['key'], generating['key'], unknown['key'])
self._create_certificates(downloadable['key'], mode=CourseMode.VERIFIED)
self._create_certificates(generating['key'], status='generating', mode=CourseMode.HONOR)
self._create_certificates(unknown['key'], status='unknown')
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(
self.assertItemsEqual(
meter.completed_course_runs,
[
{'course_run_id': 'downloadable-course', 'type': CourseMode.VERIFIED},
{'course_run_id': 'generating-course', 'type': 'honor'},
{'course_run_id': downloadable['key'], 'type': CourseMode.VERIFIED},
{'course_run_id': generating['key'], 'type': CourseMode.HONOR},
]
)
mock_get_certificates_for_user.assert_called_with(self.user.username)
@mock.patch(UTILS_MODULE + '.certificate_api.get_certificates_for_user')
def test_program_completion_with_no_id_professional(self, mock_get_certificates_for_user, mock_get_programs):
def test_program_completion_with_no_id_professional(self, mock_get_programs):
"""
Verify that 'no-id-professional' certificates are treated as if they were
'professional' certificates when determining program completion.
@@ -707,19 +695,16 @@ class TestProgramProgressMeter(TestCase):
# Verify that the test program is not complete.
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, [])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [])
# Grant a 'no-id-professional' certificate for one of the course runs,
# thereby completing the program.
mock_get_certificates_for_user.return_value = [
self._make_certificate_result(
status='downloadable', type=CourseMode.NO_ID_PROFESSIONAL_MODE, course_key=course_runs[0]['key']
)
]
self._create_enrollments(course_runs[0]['key'])
self._create_certificates(course_runs[0]['key'], mode=CourseMode.NO_ID_PROFESSIONAL_MODE)
# Verify that the program is complete.
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, [program['uuid']])
self.assertEqual(meter.completed_programs_with_available_dates.keys(), [program['uuid']])
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
def test_credit_course_counted_complete_for_verified(self, mock_completed_course_runs, mock_get_programs):

View File

@@ -21,10 +21,12 @@ from requests.exceptions import ConnectionError, Timeout
from course_modes.models import CourseMode
from entitlements.models import CourseEntitlement
from lms.djangoapps.certificates import api as certificate_api
from lms.djangoapps.certificates.models import GeneratedCertificate
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
from openedx.core.djangoapps.catalog.utils import get_programs, get_fulfillable_course_runs_for_entitlement
from openedx.core.djangoapps.certificates.api import available_date_for_certificate
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credentials.utils import get_credentials
@@ -276,27 +278,81 @@ class ProgramProgressMeter(object):
return progress
@property
def completed_programs(self):
"""Identify programs completed by the student.
Returns:
list of UUIDs, each identifying a completed program.
def completed_programs_with_available_dates(self):
"""
return [program['uuid'] for program in self.programs if self._is_program_complete(program)]
Calculate the available date for completed programs based on course runs.
def _is_program_complete(self, program):
"""Check if a user has completed a program.
A program is completed if the user has completed all nested courses.
Arguments:
program (dict): Representing the program whose completion to assess.
Returns:
bool, indicating whether the program is complete.
Returns a dict of {uuid_string: available_datetime}
"""
return all(self._is_course_complete(course) for course in program['courses']) \
and len(program['courses']) > 0
completed = {}
for program in self.programs:
available_date = self._available_date_for_program(program)
if available_date:
completed[program['uuid']] = available_date
return completed
def _available_date_for_program(self, program_data):
"""
Calculate the available date for the program based on the courses within it.
Returns a datetime object or None if the program is not complete.
"""
program_available_date = None
for course in program_data['courses']:
earliest_course_run_date = None
for course_run in course['course_runs']:
key = CourseKey.from_string(course_run['key'])
# Get a certificate if one exists
certificate = GeneratedCertificate.eligible_certificates.filter(user=self.user, course_id=key).first()
if certificate is None:
continue
# Modes must match (see _is_course_complete() comments for why)
course_run_mode = self._course_run_mode_translation(course_run['type'])
certificate_mode = self._certificate_mode_translation(certificate.mode)
modes_match = course_run_mode == certificate_mode
# Grab the available date and keep it if it's the earliest one for this catalog course.
if modes_match and certificate_api.is_passing_status(certificate.status):
course_overview = CourseOverview.get_from_id(key)
available_date = available_date_for_certificate(course_overview, certificate)
earliest_course_run_date = min(filter(None, [available_date, earliest_course_run_date]))
# If we're missing a cert for a course, the program isn't completed and we should just bail now
if earliest_course_run_date is None:
return None
# Keep the catalog course date if it's the latest one
program_available_date = max(filter(None, [earliest_course_run_date, program_available_date]))
return program_available_date
def _course_run_mode_translation(self, course_run_mode):
"""
Returns a canonical mode for a course run (whose data is coming from the program cache).
This mode must match the certificate mode to be counted as complete.
"""
mappings = {
# Runs of type 'credit' are counted as 'verified' since verified
# certificates are earned when credit runs are completed. LEARNER-1274
# tracks a cleaner way to do this using the discovery service's
# applicable_seat_types field.
CourseMode.CREDIT_MODE: CourseMode.VERIFIED,
}
return mappings.get(course_run_mode, course_run_mode)
def _certificate_mode_translation(self, certificate_mode):
"""
Returns a canonical mode for a certificate (whose data is coming from the database).
This mode must match the course run mode to be counted as complete.
"""
mappings = {
# Treat "no-id-professional" certificates as "professional" certificates
CourseMode.NO_ID_PROFESSIONAL_MODE: CourseMode.PROFESSIONAL,
}
return mappings.get(certificate_mode, certificate_mode)
def _is_course_complete(self, course):
"""Check if a user has completed a course.
@@ -325,12 +381,7 @@ class ProgramProgressMeter(object):
# count towards completion of a course in a program). This may change
# in the future to make use of the more rigid set of "applicable seat
# types" associated with each program type in the catalog.
# Runs of type 'credit' are counted as 'verified' since verified
# certificates are earned when credit runs are completed. LEARNER-1274
# tracks a cleaner way to do this using the discovery service's
# applicable_seat_types field.
'type': 'verified' if course_run['type'] == 'credit' else course_run['type'],
'type': self._course_run_mode_translation(course_run['type']),
}
return any(reshape(course_run) in self.completed_course_runs for course_run in course['course_runs'])
@@ -367,15 +418,9 @@ class ProgramProgressMeter(object):
completed_runs, failed_runs = [], []
for certificate in course_run_certificates:
certificate_type = certificate['type']
# Treat "no-id-professional" certificates as "professional" certificates
if certificate_type == CourseMode.NO_ID_PROFESSIONAL_MODE:
certificate_type = CourseMode.PROFESSIONAL
course_data = {
'course_run_id': unicode(certificate['course_key']),
'type': certificate_type,
'type': self._certificate_mode_translation(certificate['type']),
}
if certificate_api.is_passing_status(certificate['status']):