From b0f715d1c22ba1c50ae45ccf1821a066c44da8d7 Mon Sep 17 00:00:00 2001 From: Michael Roytman Date: Wed, 15 May 2019 12:46:48 -0400 Subject: [PATCH] add a GET endpoint to get course run overviews for a user's program enrollment --- common/djangoapps/student/helpers.py | 32 ++ common/djangoapps/student/views/dashboard.py | 24 +- .../program_enrollments/api/v1/constants.py | 9 + .../program_enrollments/api/v1/serializers.py | 40 ++ .../api/v1/tests/test_views.py | 521 +++++++++++++++++- .../program_enrollments/api/v1/urls.py | 13 +- .../program_enrollments/api/v1/views.py | 321 ++++++++++- 7 files changed, 931 insertions(+), 29 deletions(-) diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index cf4abdf6b2..4aaeecdb71 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -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 diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index 9102752a0c..01fef46e4f 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -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". diff --git a/lms/djangoapps/program_enrollments/api/v1/constants.py b/lms/djangoapps/program_enrollments/api/v1/constants.py index 1597129bee..3b17d20364 100644 --- a/lms/djangoapps/program_enrollments/api/v1/constants.py +++ b/lms/djangoapps/program_enrollments/api/v1/constants.py @@ -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' diff --git a/lms/djangoapps/program_enrollments/api/v1/serializers.py b/lms/djangoapps/program_enrollments/api/v1/serializers.py index b1c701abc9..8fb167ca2d 100644 --- a/lms/djangoapps/program_enrollments/api/v1/serializers.py +++ b/lms/djangoapps/program_enrollments/api/v1/serializers.py @@ -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()) diff --git a/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py index 33e9026ae5..d3ccb01876 100644 --- a/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py +++ b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py @@ -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]) diff --git a/lms/djangoapps/program_enrollments/api/v1/urls.py b/lms/djangoapps/program_enrollments/api/v1/urls.py index 1926eda96b..4089716154 100644 --- a/lms/djangoapps/program_enrollments/api/v1/urls.py +++ b/lms/djangoapps/program_enrollments/api/v1/urls.py @@ -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" + ), ] diff --git a/lms/djangoapps/program_enrollments/api/v1/views.py b/lms/djangoapps/program_enrollments/api/v1/views.py index 319db3fa52..c11493db2e 100644 --- a/lms/djangoapps/program_enrollments/api/v1/views.py +++ b/lms/djangoapps/program_enrollments/api/v1/views.py @@ -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