Merge pull request #20605 from edx/mroytman/EDUCATOR-4276-get-course-enrollments

program course enrollment overview GET endpoint
This commit is contained in:
Dave St.Germain
2019-05-24 07:39:45 -04:00
committed by GitHub
7 changed files with 931 additions and 29 deletions

View File

@@ -6,9 +6,12 @@ from __future__ import absolute_import
import json
import logging
import mimetypes
from collections import OrderedDict
from datetime import datetime
import six.moves.urllib.parse
from completion.exceptions import UnavailableCompletionData
from completion.utilities import get_key_to_last_completed_course_block
from django.conf import settings
from django.contrib.auth import load_backend
from django.contrib.auth.models import User
@@ -661,3 +664,32 @@ def do_create_account(form, custom_form=None):
raise
return user, profile, registration
def get_resume_urls_for_enrollments(user, enrollments):
'''
For a given user, return a list of urls to the user's last completed block in
a course run for each course run in the user's enrollments.
Arguments:
user: the user object for which we want resume course urls
enrollments (list): a list of user enrollments
Returns:
resume_course_urls (OrderedDict): an OrderdDict of urls
key: CourseKey
value: url to the last completed block
if the value is '', then the user has not completed any blocks in the course run
'''
resume_course_urls = OrderedDict()
for enrollment in enrollments:
try:
block_key = get_key_to_last_completed_course_block(user, enrollment.course_id)
url_to_block = reverse(
'jump_to',
kwargs={'course_id': enrollment.course_id, 'location': block_key}
)
except UnavailableCompletionData:
url_to_block = ''
resume_course_urls[enrollment.course_id] = url_to_block
return resume_course_urls

View File

@@ -8,8 +8,6 @@ import datetime
import logging
from collections import defaultdict
from completion.exceptions import UnavailableCompletionData
from completion.utilities import get_key_to_last_completed_course_block
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
@@ -50,7 +48,7 @@ from openedx.features.enterprise_support.api import get_dashboard_consent_notifi
from openedx.features.journals.api import journals_enabled
from shoppingcart.api import order_history
from shoppingcart.models import CourseRegistrationCode, DonationConfiguration
from student.helpers import cert_info, check_verify_status_by_course
from student.helpers import cert_info, check_verify_status_by_course, get_resume_urls_for_enrollments
from student.models import (
AccountRecovery,
CourseEnrollment,
@@ -533,24 +531,6 @@ def _credit_statuses(user, course_enrollments):
return statuses
def _get_urls_for_resume_buttons(user, enrollments):
'''
Checks whether a user has made progress in any of a list of enrollments.
'''
resume_button_urls = []
for enrollment in enrollments:
try:
block_key = get_key_to_last_completed_course_block(user, enrollment.course_id)
url_to_block = reverse(
'jump_to',
kwargs={'course_id': enrollment.course_id, 'location': block_key}
)
except UnavailableCompletionData:
url_to_block = ''
resume_button_urls.append(url_to_block)
return resume_button_urls
@login_required
@ensure_csrf_cookie
@add_maintenance_banner
@@ -896,7 +876,7 @@ def student_dashboard(request):
# Gather urls for course card resume buttons.
resume_button_urls = ['' for entitlement in course_entitlements]
for url in _get_urls_for_resume_buttons(user, course_enrollments):
for url in get_resume_urls_for_enrollments(user, course_enrollments).values():
resume_button_urls.append(url)
# There must be enough urls for dashboard.html. Template creates course
# cards for "enrollments + entitlements".

View File

@@ -33,3 +33,12 @@ class CourseEnrollmentResponseStatuses(object):
NOT_FOUND,
INTERNAL_ERROR,
)
class CourseRunProgressStatuses(object):
"""
Class to group statuses that a course run can be in with respect to user progress.
"""
IN_PROGRESS = 'in_progress'
UPCOMING = 'upcoming'
COMPLETED = 'completed'

View File

@@ -7,6 +7,7 @@ from rest_framework import serializers
from six import text_type
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from lms.djangoapps.program_enrollments.api.v1.constants import CourseRunProgressStatuses
# pylint: disable=abstract-method
@@ -100,3 +101,42 @@ class ProgramCourseEnrollmentListSerializer(serializers.Serializer):
def get_curriculum_uuid(self, obj):
return text_type(obj.program_enrollment.curriculum_uuid)
class DueDateSerializer(serializers.Serializer):
"""
Serializer for a due date.
"""
name = serializers.CharField()
url = serializers.CharField()
date = serializers.DateTimeField()
class CourseRunOverviewSerializer(serializers.Serializer):
"""
Serializer for a course run overview.
"""
STATUS_CHOICES = [
CourseRunProgressStatuses.IN_PROGRESS,
CourseRunProgressStatuses.UPCOMING,
CourseRunProgressStatuses.COMPLETED
]
course_run_id = serializers.CharField()
display_name = serializers.CharField()
resume_course_run_url = serializers.CharField(required=False)
course_run_url = serializers.CharField()
start_date = serializers.DateTimeField()
end_date = serializers.DateTimeField()
course_run_status = serializers.ChoiceField(allow_blank=False, choices=STATUS_CHOICES)
emails_enabled = serializers.BooleanField(required=False)
due_dates = serializers.ListField(child=DueDateSerializer())
micromasters_title = serializers.CharField(required=False)
certificate_download_url = serializers.CharField(required=False)
class CourseRunOverviewListSerializer(serializers.Serializer):
"""
Serializer for a list of course run overviews.
"""
course_runs = serializers.ListField(child=CourseRunOverviewSerializer())

View File

@@ -3,6 +3,7 @@ Unit tests for ProgramEnrollment views.
"""
from __future__ import absolute_import, unicode_literals
from datetime import datetime, timedelta
import json
from uuid import uuid4
@@ -11,23 +12,34 @@ import mock
from django.contrib.auth.models import User
from django.core.cache import cache
from django.urls import reverse
from freezegun import freeze_time
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
from rest_framework.test import APITestCase
from six import text_type
from six.moves import range, zip
from bulk_email.models import BulkEmailFlag, Optout
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.certificates.models import CertificateStatuses
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory
from lms.djangoapps.program_enrollments.api.v1.constants import MAX_ENROLLMENT_RECORDS, REQUEST_STUDENT_KEY
from lms.djangoapps.program_enrollments.api.v1.constants import CourseEnrollmentResponseStatuses as CourseStatuses
from lms.djangoapps.program_enrollments.api.v1.constants import (
CourseEnrollmentResponseStatuses as CourseStatuses,
CourseRunProgressStatuses,
MAX_ENROLLMENT_RECORDS,
REQUEST_STUDENT_KEY,
)
from lms.djangoapps.program_enrollments.models import ProgramEnrollment, ProgramCourseEnrollment
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL
from openedx.core.djangoapps.catalog.tests.factories import CourseFactory
from openedx.core.djangoapps.catalog.tests.factories import OrganizationFactory as CatalogOrganizationFactory
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangolib.testing.utils import CacheIsolationMixin
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.factories import CourseFactory as ModulestoreCourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from .factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
@@ -222,9 +234,12 @@ class ProgramCacheTestCaseMixin(CacheIsolationMixin):
uuid=program_uuid,
authoring_organizations=[catalog_org]
)
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program_uuid), program, None)
self.set_program_in_catalog_cache(program_uuid, program)
return program
def set_program_in_catalog_cache(self, program_uuid, program):
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program_uuid), program, None)
@ddt.ddt
class BaseCourseEnrollmentTestsMixin(ProgramCacheTestCaseMixin):
@@ -1083,3 +1098,501 @@ class ProgramEnrollmentViewPatchTests(APITestCase):
'user-3': 'withdrawn',
'user-who-is-not-in-program': 'not-in-program',
})
@ddt.ddt
class ProgramCourseEnrollmentOverviewViewTests(ProgramCacheTestCaseMixin, SharedModuleStoreTestCase, APITestCase):
"""
Tests for the ProgramCourseEnrollmentOverview view GET method.
"""
@classmethod
def setUpClass(cls):
super(ProgramCourseEnrollmentOverviewViewTests, cls).setUpClass()
cls.program_uuid = '00000000-1111-2222-3333-444444444444'
cls.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444'
cls.other_curriculum_uuid = 'bbbbbbbb-1111-2222-3333-444444444444'
cls.course_id = CourseKey.from_string('course-v1:edX+ToyX+Toy_Course')
cls.password = 'password'
cls.student = UserFactory.create(username='student', password=cls.password)
# only freeze time when defining these values and not on the whole test case
# as test_multiple_enrollments_all_enrolled relies on actual differences in modified datetimes
with freeze_time('2019-01-01'):
cls.yesterday = datetime.now() - timedelta(1)
cls.tomorrow = datetime.now() + timedelta(1)
cls.certificate_download_url = 'www.certificates.com'
def setUp(self):
super(ProgramCourseEnrollmentOverviewViewTests, self).setUp()
# create program
self.program = self.setup_catalog_cache(self.program_uuid, 'organization_key')
# create program enrollment
self.program_enrollment = ProgramEnrollmentFactory.create(
program_uuid=self.program_uuid,
curriculum_uuid=self.curriculum_uuid,
user=self.student,
)
# create course enrollment
self.course_enrollment = CourseEnrollmentFactory(
course_id=self.course_id,
user=self.student,
)
# create course overview
self.course_overview = CourseOverviewFactory.create(
id=self.course_id,
start=self.yesterday,
end=self.tomorrow,
)
# create program course enrollment
self.program_course_enrollment = ProgramCourseEnrollmentFactory.create(
program_enrollment=self.program_enrollment,
course_enrollment=self.course_enrollment,
course_key=self.course_id,
status='active',
)
def create_generated_certificate(self):
return GeneratedCertificateFactory.create(
user=self.student,
course_id=self.course_id,
status=CertificateStatuses.downloadable,
mode='verified',
download_url=self.certificate_download_url,
grade="0.88",
verify_uuid=uuid4(),
)
def get_url(self, program_uuid=None):
""" Returns the primary URL requested by the test case. """
kwargs = {'program_uuid': program_uuid or self.program_uuid}
return reverse('programs_api:v1:program_course_enrollments_overview', kwargs=kwargs)
def test_401_if_anonymous(self):
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_401_UNAUTHORIZED == response.status_code
def test_404_if_no_program_with_key(self):
self.client.login(username=self.student.username, password=self.password)
self.set_program_in_catalog_cache(self.program_uuid, None)
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_404_NOT_FOUND == response.status_code
def test_403_if_not_enrolled_in_program(self):
# delete program enrollment
ProgramEnrollment.objects.all().delete()
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_403_FORBIDDEN == response.status_code
@ddt.data(
'pending',
'suspended',
'withdrawn',
)
def test_multiple_enrollments_with_not_enrolled(self, program_enrollment_status):
# add a second program enrollment
program_enrollment = ProgramEnrollmentFactory.create(
program_uuid=self.program_uuid,
curriculum_uuid=self.other_curriculum_uuid,
user=self.student,
status=program_enrollment_status,
)
other_course_key_string = 'course-v1:edX+ToyX+Other_Course'
other_course_key = CourseKey.from_string(other_course_key_string)
# add a second course enrollment
course_enrollment = CourseEnrollmentFactory(
course_id=other_course_key,
user=self.student,
)
# add a second program course enrollment
ProgramCourseEnrollmentFactory.create(
program_enrollment=program_enrollment,
course_enrollment=course_enrollment,
course_key=other_course_key,
status='active',
)
# add a course over view for other_course_key
CourseOverviewFactory.create(
id=other_course_key,
start=self.yesterday,
)
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
# we expect data associated with the last modified program enrollment
# with 'enrolled' status
self.assertEqual(
text_type(self.program_course_enrollment.course_key),
response.data['course_runs'][0]['course_run_id']
)
def test_multiple_enrollments_all_enrolled(self):
# add a second program enrollment
program_enrollment = ProgramEnrollmentFactory.create(
program_uuid=self.program_uuid,
curriculum_uuid=self.other_curriculum_uuid,
user=self.student,
)
other_course_key_string = 'course-v1:edX+ToyX+Other_Course'
other_course_key = CourseKey.from_string(other_course_key_string)
# add a second course enrollment
course_enrollment = CourseEnrollmentFactory(
course_id=other_course_key,
user=self.student
)
# add a second program course enrollment
ProgramCourseEnrollmentFactory.create(
program_enrollment=program_enrollment,
course_enrollment=course_enrollment,
course_key=other_course_key,
status='active',
)
# add a course over view for other_course_key
CourseOverviewFactory.create(
id=other_course_key,
start=self.yesterday,
)
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
# we expect data associated with the last modified program enrollment
self.assertEqual(other_course_key_string, response.data['course_runs'][0]['course_run_id'])
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_resume_urls_for_enrollments')
def test_resume_urls(self, mock_get_resume_urls):
self.client.login(username=self.student.username, password=self.password)
mock_get_resume_urls.return_value = {self.course_id: ''}
response = self.client.get(self.get_url(self.program_uuid))
self.assertNotIn('resume_course_run_url', response.data['course_runs'][0])
resume_url = 'www.resume.com'
mock_get_resume_urls.return_value = {self.course_id: resume_url}
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(resume_url, response.data['course_runs'][0]['resume_course_run_url'])
def test_no_certificate_available(self):
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
assert 'certificate_download_url' not in response.data['course_runs'][0]
def test_certificate_available(self):
self.client.login(username=self.student.username, password=self.password)
self.create_generated_certificate()
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(response.data['course_runs'][0]['certificate_download_url'], self.certificate_download_url)
def test_no_due_dates(self):
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
assert [] == response.data['course_runs'][0]['due_dates']
def test_due_dates(self):
course = ModulestoreCourseFactory.create(
org="edX",
course="ToyX",
run="Toy_Course",
)
section_1 = ItemFactory.create(
category='chapter',
start=self.yesterday,
due=self.tomorrow,
parent=course,
display_name='section 1'
)
subsection_1 = ItemFactory.create(
category='sequential',
due=self.tomorrow,
parent=section_1,
display_name='subsection 1'
)
subsection_2 = ItemFactory.create(
category='sequential',
due=self.tomorrow - timedelta(1),
parent=section_1,
display_name='subsection 2'
)
subsection_3 = ItemFactory.create(
category='sequential',
parent=section_1,
display_name='subsection 3'
)
unit_1 = ItemFactory.create(
category='vertical',
due=self.tomorrow + timedelta(2),
parent=subsection_3,
display_name='unit_1'
)
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_dates_for_course') as mock_get_dates:
mock_get_dates.return_value = {
(section_1.location, 'due'): section_1.due,
(section_1.location, 'start'): section_1.start,
(subsection_1.location, 'due'): subsection_1.due,
(subsection_2.location, 'due'): subsection_2.due,
(unit_1.location, 'due'): unit_1.due,
}
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
block_data = [
{
'name': section_1.display_name,
'url': ('http://testserver/courses/course-v1:edX+ToyX+Toy_Course/'
'jump_to/i4x://edX/ToyX/chapter/section_1'),
'date': '2019-01-02T00:00:00Z',
},
{
'name': subsection_1.display_name,
'url': ('http://testserver/courses/course-v1:edX+ToyX+Toy_Course/'
'jump_to/i4x://edX/ToyX/sequential/subsection_1'),
'date': '2019-01-02T00:00:00Z',
},
{
'name': subsection_2.display_name,
'url': ('http://testserver/courses/course-v1:edX+ToyX+Toy_Course/'
'jump_to/i4x://edX/ToyX/sequential/subsection_2'),
'date': '2019-01-01T00:00:00Z',
},
{
'name': unit_1.display_name,
'url': ('http://testserver/courses/course-v1:edX+ToyX+Toy_Course/'
'jump_to/i4x://edX/ToyX/vertical/unit_1'),
'date': '2019-01-04T00:00:00Z',
},
]
due_dates = response.data['course_runs'][0]['due_dates']
for block in block_data:
self.assertIn(block, due_dates)
@mock.patch.object(CourseOverview, 'has_ended')
def test_course_run_status_instructor_paced_completed(self, mock_has_ended):
self.client.login(username=self.student.username, password=self.password)
# set as instructor paced
self.course_overview.self_paced = False
self.course_overview.save()
mock_has_ended.return_value = True
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(CourseRunProgressStatuses.COMPLETED, response.data['course_runs'][0]['course_run_status'])
@mock.patch.object(CourseOverview, 'has_ended')
@mock.patch.object(CourseOverview, 'has_started')
def test_course_run_status_instructor_paced_in_progress(self, mock_has_started, mock_has_ended):
self.client.login(username=self.student.username, password=self.password)
# set as instructor paced
self.course_overview.self_paced = False
self.course_overview.save()
mock_has_started.return_value = True
mock_has_ended.return_value = False
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(CourseRunProgressStatuses.IN_PROGRESS, response.data['course_runs'][0]['course_run_status'])
@mock.patch.object(CourseOverview, 'has_ended')
@mock.patch.object(CourseOverview, 'has_started')
def test_course_run_status_instructor_paced_upcoming(self, mock_has_started, mock_has_ended):
self.client.login(username=self.student.username, password=self.password)
# set as instructor paced
self.course_overview.self_paced = False
self.course_overview.save()
mock_has_started.return_value = False
mock_has_ended.return_value = False
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(CourseRunProgressStatuses.UPCOMING, response.data['course_runs'][0]['course_run_status'])
@mock.patch.object(CourseOverview, 'has_ended')
def test_course_run_status_self_paced_completed(self, mock_has_ended):
self.client.login(username=self.student.username, password=self.password)
# set as self paced
self.course_overview.self_paced = True
self.course_overview.save()
# course run has ended
mock_has_ended.return_value = True
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(CourseRunProgressStatuses.COMPLETED, response.data['course_runs'][0]['course_run_status'])
# course run has not ended and user has earned a passing certificate more than 30 days ago
certificate = self.create_generated_certificate()
certificate.created_date = datetime.now() - timedelta(30)
certificate.save()
mock_has_ended.return_value = False
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(CourseRunProgressStatuses.COMPLETED, response.data['course_runs'][0]['course_run_status'])
# course run has ended and user has earned a passing certificate
mock_has_ended.return_value = True
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(CourseRunProgressStatuses.COMPLETED, response.data['course_runs'][0]['course_run_status'])
@mock.patch.object(CourseOverview, 'has_ended')
@mock.patch.object(CourseOverview, 'has_started')
def test_course_run_status_self_paced_in_progress(self, mock_has_started, mock_has_ended):
self.client.login(username=self.student.username, password=self.password)
# set as self paced
self.course_overview.self_paced = True
self.course_overview.save()
# course run has started and has not ended
mock_has_started.return_value = True
mock_has_ended.return_value = False
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(CourseRunProgressStatuses.IN_PROGRESS, response.data['course_runs'][0]['course_run_status'])
# course run has not ended and user has earned a passing certificate fewer than 30 days ago
certificate = self.create_generated_certificate()
certificate.created_date = datetime.now() - timedelta(5)
certificate.save()
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(CourseRunProgressStatuses.IN_PROGRESS, response.data['course_runs'][0]['course_run_status'])
@mock.patch.object(CourseOverview, 'has_ended')
@mock.patch.object(CourseOverview, 'has_started')
def test_course_run_status_self_paced_upcoming(self, mock_has_started, mock_has_ended):
self.client.login(username=self.student.username, password=self.password)
# set as self paced
self.course_overview.self_paced = True
self.course_overview.save()
# course run has not started and has not ended
mock_has_started.return_value = False
mock_has_ended.return_value = False
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(CourseRunProgressStatuses.UPCOMING, response.data['course_runs'][0]['course_run_status'])
def test_course_run_url(self):
self.client.login(username=self.student.username, password=self.password)
course_run_url = 'http://testserver/courses/{}/course/'.format(text_type(self.course_id))
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(course_run_url, response.data['course_runs'][0]['course_run_url'])
def test_course_run_dates(self):
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
course_run_overview = response.data['course_runs'][0]
self.assertEqual(course_run_overview['start_date'], '2018-12-31T05:00:00Z')
self.assertEqual(course_run_overview['end_date'], '2019-01-02T05:00:00Z')
# course run end date may not exist
self.course_overview.end = None
self.course_overview.save()
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(response.data['course_runs'][0]['end_date'], None)
def test_course_run_id_and_display_name(self):
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
course_run_overview = response.data['course_runs'][0]
self.assertEqual(course_run_overview['course_run_id'], text_type(self.course_id))
self.assertEqual(course_run_overview['display_name'], "{} Course".format(text_type(self.course_id)))
def test_emails_enabled(self):
self.client.login(username=self.student.username, password=self.password)
# by default, BulkEmailFlag is not enabled, so 'emails_enabled' won't be in the response
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertNotIn('emails_enabled', response.data['course_runs'][0])
with mock.patch.object(BulkEmailFlag, 'feature_enabled', return_value=True):
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertTrue(response.data['course_runs'][0]['emails_enabled'])
Optout.objects.create(
user=self.student,
course_id=self.course_id
)
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertFalse(response.data['course_runs'][0]['emails_enabled'])
def test_micromasters_title(self):
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertNotIn('micromasters_title', response.data['course_runs'][0])
self.program['type'] = 'MicroMasters'
# update the program in the catalog cache
self.set_program_in_catalog_cache(self.program_uuid, self.program)
response = self.client.get(self.get_url(self.program_uuid))
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertIn('micromasters_title', response.data['course_runs'][0])

View File

@@ -4,7 +4,11 @@ from __future__ import absolute_import
from django.conf.urls import url
from lms.djangoapps.program_enrollments.api.v1.constants import PROGRAM_UUID_PATTERN
from lms.djangoapps.program_enrollments.api.v1.views import ProgramCourseEnrollmentsView, ProgramEnrollmentsView
from lms.djangoapps.program_enrollments.api.v1.views import (
ProgramEnrollmentsView,
ProgramCourseEnrollmentsView,
ProgramCourseEnrollmentOverviewView,
)
from openedx.core.constants import COURSE_ID_PATTERN
app_name = 'lms.djangoapps.program_enrollments'
@@ -23,4 +27,11 @@ urlpatterns = [
ProgramCourseEnrollmentsView.as_view(),
name="program_course_enrollments"
),
url(
r'^programs/{program_uuid}/overview/'.format(
program_uuid=PROGRAM_UUID_PATTERN,
),
ProgramCourseEnrollmentOverviewView.as_view(),
name="program_course_enrollments_overview"
),
]

View File

@@ -4,38 +4,56 @@ ProgramEnrollment Views
"""
from __future__ import absolute_import, unicode_literals
import logging
from collections import Counter, OrderedDict
from datetime import datetime, timedelta
from functools import wraps
from pytz import UTC
from django.http import Http404
from django.core.exceptions import PermissionDenied
from django.urls import reverse
from edx_rest_framework_extensions import permissions
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.exceptions import ValidationError
from rest_framework.pagination import CursorPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from six import iteritems
from bulk_email.api import is_bulk_email_feature_enabled, is_user_opted_out_for_course
from edx_when.api import get_dates_for_course
from lms.djangoapps.certificates.api import get_certificate_for_user
from lms.djangoapps.program_enrollments.api.v1.constants import (
CourseEnrollmentResponseStatuses,
CourseRunProgressStatuses,
MAX_ENROLLMENT_RECORDS,
REQUEST_STUDENT_KEY,
CourseEnrollmentResponseStatuses
)
from lms.djangoapps.program_enrollments.api.v1.serializers import (
CourseRunOverviewListSerializer,
ProgramCourseEnrollmentListSerializer,
ProgramCourseEnrollmentRequestSerializer,
ProgramEnrollmentListSerializer,
ProgramEnrollmentSerializer
ProgramEnrollmentSerializer,
)
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from lms.djangoapps.program_enrollments.utils import get_user_by_program_id
from student.helpers import get_resume_urls_for_enrollments
from xmodule.modulestore.django import modulestore
from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, PaginatedAPIView, verify_course_exists
from util.query import use_read_replica_if_available
logger = logging.getLogger(__name__)
def verify_program_exists(view_func):
"""
@@ -703,3 +721,302 @@ class ProgramCourseEnrollmentsView(DeveloperErrorViewMixin, ProgramCourseRunSpec
if program_course_enrollment is None:
return CourseEnrollmentResponseStatuses.NOT_FOUND
return program_course_enrollment.change_status(enrollment_request['status'])
class ProgramCourseEnrollmentOverviewView(DeveloperErrorViewMixin, ProgramSpecificViewMixin, APIView):
"""
A view for getting data associated with a user's course enrollments
as part of a program enrollment.
Path: ``/api/program_enrollments/v1/programs/{program_uuid}/overview/``
Accepts: [GET]
------------------------------------------------------------------------------------
GET
------------------------------------------------------------------------------------
**Returns**
* 200: OK - Contains an object of user program course enrollment data.
* 401: Unauthorized - The requesting user is not authenticated.
* 403: Forbidden -The requesting user lacks access for the given program.
* 404: Not Found - The requested program does not exist.
**Response**
In the case of a 200 response code, the response will include a
data set. The `course_runs` section of the response consists of a list of
program course enrollment overview, where each overview contains the following keys:
* course_run_id: the id for the course run
* display_name: display name of the course run
* resume_course_run_url: the url that takes the user back to their position in the course run;
if absent, user has not made progress in the course
* course_run_url: the url for the course run
* start_date: the start date for the course run; null if no start date
* end_date: the end date for the course run' null if no end date
* course_status: the status of the course; one of "in-progress", "upcoming", and "completed"
* emails_enabled: boolean representing whether emails are enabled for the course;
if absent, the bulk email feature is either not enable at the platform level or is not enabled for the course;
if True or False, bulk email feature is enabled, and value represents whether or not user wants to receive emails
* due_dates: a list of subsection due dates for the course run:
** name: name of the subsection
** url: deep link to the subsection
** date: due date for the subsection
* micromasters_title: title of the MicroMasters program that the course run is a part of;
if absent, the course run is not a part of a MicroMasters program
* certificate_download_url: url to download a certificate, if available;
if absent, certificate is not downloadable
**Example**
{
"course_runs": [
{
"course_run_id": "edX+AnimalsX+Aardvarks",
"display_name": "Astonishing Aardvarks",
"course_run_url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Aardvarks/course/",
"start_date": "2017-02-05T05:00:00Z",
"end_date": "2018-02-05T05:00:00Z",
"course_status": "completed"
"emails_enabled": true,
"due_dates": [
{
"name": "Introduction: What even is an aardvark?",
"url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Aardvarks/jump_to/block-v1:edX+AnimalsX+Aardvarks+type@chapter+block@1414ffd5143b4b508f739b563ab468b7",
"date": "2017-05-01T05:00:00Z"
},
{
"name": "Quiz: Aardvark or Anteater?",
"url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Aardvarks/jump_to/block-v1:edX+AnimalsX+Aardvarks+type@sequential+block@edx_introduction",
"date": "2017-03-05T00:00:00Z"
}
],
"micromasters_title": "Animals",
"certificate_download_url": "https://courses.edx.org/certificates/123"
},
{
"course_run_id": "edX+AnimalsX+Baboons",
"display_name": "Breathtaking Baboons",
"course_run_url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Baboons/course/",
"start_date": "2018-02-05T05:00:00Z",
"end_date": null,
"course_status": "in-progress"
"emails_enabled": false,
"due_dates": [],
"micromasters_title": "Animals",
"certificate_download_url": "https://courses.edx.org/certificates/123",
"resume_course_run_url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Baboons/jump_to/block-v1:edX+AnimalsX+Baboons+type@sequential+block@edx_introduction"
}
]
}
"""
authentication_classes = (
JwtAuthentication,
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (IsAuthenticated,)
@verify_program_exists
def get(self, request, program_uuid=None):
"""
Defines the GET endpoint for overviews of course enrollments
for a user as part of a program.
"""
user = request.user
user_program_enrollment = ProgramEnrollment.objects.filter(
program_uuid=program_uuid,
user=user,
status='enrolled',
).order_by(
'-modified',
)
user_program_enrollment_count = user_program_enrollment.count()
if user_program_enrollment_count > 1:
# in the unusual and unlikely case of a user having two
# active program enrollments for the same program,
# choose the most recently modified enrollment and log
# a warning
user_program_enrollment = user_program_enrollment[0]
logger.warning(
('User with user_id {} has more than program enrollment'
'with an enrolled status for program uuid {}.').format(
user.id,
program_uuid,
)
)
elif user_program_enrollment_count == 0:
# if the user is not enrolled in the program, they are not authorized
# to view the information returned by this endpoint
raise PermissionDenied
user_program_course_enrollments = ProgramCourseEnrollment.objects.filter(
program_enrollment=user_program_enrollment
).select_related('course_enrollment')
enrollment_dict = {enrollment.course_key: enrollment.course_enrollment for enrollment in user_program_course_enrollments}
overviews = CourseOverview.get_from_ids_if_exists(enrollment_dict.keys())
resume_course_run_urls = get_resume_urls_for_enrollments(user, enrollment_dict.values())
response = {
'course_runs': [],
}
for enrollment in user_program_course_enrollments:
overview = overviews[enrollment.course_key]
certificate_download_url = None
is_certificate_passing = None
certificate_creation_date = None
certificate_info = get_certificate_for_user(user.username, enrollment.course_key)
if certificate_info:
certificate_download_url = certificate_info['download_url']
is_certificate_passing = certificate_info['is_passing']
certificate_creation_date = certificate_info['created']
course_run_dict = {
'course_run_id': enrollment.course_key,
'display_name': overview.display_name_with_default,
'course_run_status': self.get_course_run_status(overview, is_certificate_passing, certificate_creation_date),
'course_run_url': self.get_course_run_url(request, enrollment.course_key),
'start_date': overview.start,
'end_date': overview.end,
'due_dates': self.get_due_dates(request, enrollment.course_key, user),
}
if certificate_download_url:
course_run_dict['certificate_download_url'] = certificate_download_url
emails_enabled = self.get_emails_enabled(user, enrollment.course_key)
if emails_enabled is not None:
course_run_dict['emails_enabled'] = emails_enabled
micromasters_title = self.program['title'] if self.program['type'] == 'MicroMasters' else None
if micromasters_title:
course_run_dict['micromasters_title'] = micromasters_title
# if the url is '', then the url is None so we can omit it from the response
resume_course_run_url = resume_course_run_urls[enrollment.course_key]
if resume_course_run_url:
course_run_dict['resume_course_run_url'] = resume_course_run_url
response['course_runs'].append(course_run_dict)
serializer = CourseRunOverviewListSerializer(response)
return Response(serializer.data)
@staticmethod
def get_due_dates(request, course_key, user):
"""
Get due date information for a user for blocks in a course.
Arguments:
request: the request object
course_key (CourseKey): the CourseKey for the course
user: the user object for which we want due date information
Returns:
due_dates (list): a list of dictionaries containing due date information
keys:
name: the display name of the block
url: the deep link to the block
date: the due date for the block
"""
dates = get_dates_for_course(
course_key,
user,
)
store = modulestore()
due_dates = []
for (block_key, date_type), date in iteritems(dates):
if date_type == 'due':
block = store.get_item(block_key)
# get url to the block in the course
block_url = reverse('jump_to', args=[course_key, block_key])
block_url = request.build_absolute_uri(block_url)
due_dates.append({
'name': block.display_name,
'url': block_url,
'date': date,
})
return due_dates
@staticmethod
def get_course_run_url(request, course_id):
"""
Get the URL to a course run.
Arguments:
request: the request object
course_id (string): the course id of the course
Returns:
(string): the URL to the course run associated with course_id
"""
course_run_url = reverse('openedx.course_experience.course_home', args=[course_id])
return request.build_absolute_uri(course_run_url)
@staticmethod
def get_emails_enabled(user, course_id):
"""
Get whether or not emails are enabled in the context of a course.
Arguments:
user: the user object for which we want to check whether emails are enabled
course_id (string): the course id of the course
Returns:
(bool): True if emails are enabled for the course associated with course_id for the user;
False otherwise
"""
if is_bulk_email_feature_enabled(course_id=course_id):
return not is_user_opted_out_for_course(user=user, course_id=course_id)
else:
return None
@staticmethod
def get_course_run_status(course_overview, is_certificate_passing, certificate_creation_date):
"""
Get the progress status of a course run.
Arguments:
course_overview (CourseOverview): the overview for the course run
is_certificate_passing (bool): True if the user has a passing certificate in
this course run; False otherwise
certificate_creation_date: the date the certificate was created
Returns:
status: one of CourseRunProgressStatuses.COMPLETE,
CourseRunProgressStatuses.IN_PROGRESS,
or CourseRunProgressStatuses.UPCOMING
"""
if course_overview.pacing == 'instructor':
if course_overview.has_ended():
return CourseRunProgressStatuses.COMPLETED
elif course_overview.has_started():
return CourseRunProgressStatuses.IN_PROGRESS
else:
return CourseRunProgressStatuses.UPCOMING
elif course_overview.pacing == 'self':
has_ended = course_overview.has_ended()
thirty_days_ago = datetime.now(UTC) - timedelta(30)
# a self paced course run is completed when either the course run has ended
# OR the user has earned a certificate 30 days ago or more
if has_ended or is_certificate_passing and (certificate_creation_date and certificate_creation_date <= thirty_days_ago):
return CourseRunProgressStatuses.COMPLETED
elif course_overview.has_started():
return CourseRunProgressStatuses.IN_PROGRESS
else:
return CourseRunProgressStatuses.UPCOMING
return None