diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ed2150f049..6f6ba380ba 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,11 @@ Change Log Unreleased ---------- +[1.9.12] - 2019-09-06 +--------------------- + +* Implement "move to completed" functionality for Enterprise Enrollments. + [1.9.11] - 2019-09-05 --------------------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 55bd9de0cc..9be1acf0a6 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -4,6 +4,6 @@ from __future__ import absolute_import, unicode_literals -__version__ = "1.9.11" +__version__ = "1.9.12" default_app_config = "enterprise.apps.EnterpriseConfig" # pylint: disable=invalid-name diff --git a/enterprise_learner_portal/api/v1/serializers.py b/enterprise_learner_portal/api/v1/serializers.py index e4f812af29..8103897ee2 100644 --- a/enterprise_learner_portal/api/v1/serializers.py +++ b/enterprise_learner_portal/api/v1/serializers.py @@ -43,28 +43,43 @@ def to_representation(self, instance): representation = super(EnterpriseCourseEnrollmentSerializer, self).to_representation(instance) request = self.context['request'] + course_run_id = instance.course_id user = request.user + # Course Overview + course_overview = self._get_course_overview(course_run_id) + # Certificate - certificate_info = get_certificate_for_user(user.username, instance['id']) or {} + certificate_info = get_certificate_for_user(user.username, course_run_id) or {} representation['certificate_download_url'] = certificate_info.get('download_url') # Email enabled - emails_enabled = get_emails_enabled(user, instance['id']) + emails_enabled = get_emails_enabled(user, course_run_id) if emails_enabled is not None: representation['emails_enabled'] = emails_enabled - representation['course_run_id'] = instance['id'] + representation['course_run_id'] = course_run_id representation['course_run_status'] = get_course_run_status( - instance, + course_overview, certificate_info, + instance ) - representation['start_date'] = instance['start'] - representation['end_date'] = instance['end'] - representation['display_name'] = instance['display_name_with_default'] - representation['course_run_url'] = get_course_run_url(request, instance['id']) - representation['due_dates'] = get_due_dates(request, instance['id'], user) - representation['pacing'] = instance['pacing'] - representation['org_name'] = instance['display_org_with_default'] + representation['start_date'] = course_overview['start'] + representation['end_date'] = course_overview['end'] + representation['display_name'] = course_overview['display_name_with_default'] + representation['course_run_url'] = get_course_run_url(request, course_run_id) + representation['due_dates'] = get_due_dates(request, course_run_id, user) + representation['pacing'] = course_overview['pacing'] + representation['org_name'] = course_overview['display_org_with_default'] return representation + + def _get_course_overview(self, course_run_id): + """ + Get the appropriate course overview from the context. + """ + for overview in self.context['course_overviews']: + if overview['id'] == course_run_id: + return overview + + return None diff --git a/enterprise_learner_portal/api/v1/views.py b/enterprise_learner_portal/api/v1/views.py index 024b5aa3dc..d56ad36e53 100644 --- a/enterprise_learner_portal/api/v1/views.py +++ b/enterprise_learner_portal/api/v1/views.py @@ -55,16 +55,59 @@ def get(self, request): user_id=user.id, enterprise_customer__uuid=enterprise_customer_id, ) - course_ids_for_ent_enrollments = EnterpriseCourseEnrollment.objects.filter( + enterprise_enrollments = EnterpriseCourseEnrollment.objects.filter( enterprise_customer_user=enterprise_customer_user - ).values_list('course_id', flat=True) + ) - overviews = get_course_overviews(course_ids_for_ent_enrollments) + course_overviews = get_course_overviews(enterprise_enrollments.values_list('course_id', flat=True)) data = EnterpriseCourseEnrollmentSerializer( - overviews, + enterprise_enrollments, many=True, - context={'request': request}, + context={'request': request, 'course_overviews': course_overviews}, + ).data + + return Response(data) + + def patch(self, request): + """ + Patch method for the view. + """ + if get_course_overviews is None: + raise NotConnectedToOpenEdX( + _('To use this endpoint, this package must be ' + 'installed in an Open edX environment.') + ) + + user = request.user + enterprise_customer_id = request.query_params.get('enterprise_id', None) + course_id = request.query_params.get('course_id', None) + marked_done = request.query_params.get('marked_done', None) + if not enterprise_customer_id or not course_id or marked_done is None: + return Response( + {'error': 'enterprise_id, course_id, and marked_done must be provided as query parameters'}, + status=HTTP_400_BAD_REQUEST + ) + + enterprise_customer_user = get_object_or_404( + EnterpriseCustomerUser, + user_id=user.id, + enterprise_customer__uuid=enterprise_customer_id, + ) + + enterprise_enrollment = get_object_or_404( + EnterpriseCourseEnrollment, + enterprise_customer_user=enterprise_customer_user, + course_id=course_id + ) + + enterprise_enrollment.marked_done = marked_done + enterprise_enrollment.save() + + course_overviews = get_course_overviews([course_id]) + data = EnterpriseCourseEnrollmentSerializer( + enterprise_enrollment, + context={'request': request, 'course_overviews': course_overviews}, ).data return Response(data) diff --git a/enterprise_learner_portal/utils.py b/enterprise_learner_portal/utils.py index 8849345581..197f7715fd 100644 --- a/enterprise_learner_portal/utils.py +++ b/enterprise_learner_portal/utils.py @@ -19,7 +19,7 @@ class CourseRunProgressStatuses(object): COMPLETED = 'completed' -def get_course_run_status(course_overview, certificate_info): +def get_course_run_status(course_overview, certificate_info, enterprise_enrollment): """ Get the progress status of a course run, given the state of a user's certificate in the course. @@ -43,7 +43,9 @@ def get_course_run_status(course_overview, certificate_info): is_certificate_passing = certificate_info.get('is_passing', False) certificate_creation_date = certificate_info.get('created', datetime.max) - if course_overview['pacing'] == 'instructor': + if enterprise_enrollment and enterprise_enrollment.marked_done: + return CourseRunProgressStatuses.COMPLETED + elif course_overview['pacing'] == 'instructor': if course_overview['has_ended']: return CourseRunProgressStatuses.COMPLETED elif course_overview['has_started']: diff --git a/tests/test_enterprise_learner_portal/api/test_serializers.py b/tests/test_enterprise_learner_portal/api/test_serializers.py index 934d4a1c14..5beab55343 100644 --- a/tests/test_enterprise_learner_portal/api/test_serializers.py +++ b/tests/test_enterprise_learner_portal/api/test_serializers.py @@ -28,6 +28,7 @@ def setUp(self): self.user = factories.UserFactory.create(is_staff=True, is_active=True) self.factory = RequestFactory() + self.enterprise_customer_user = factories.EnterpriseCustomerUserFactory.create(user_id=self.user.id) @mock.patch('enterprise_learner_portal.api.v1.serializers.get_course_run_status') @mock.patch('enterprise_learner_portal.api.v1.serializers.get_emails_enabled') @@ -44,8 +45,18 @@ def test_serializer_representation( ): """ EnterpriseCourseEnrollmentSerializer should create proper representation - based on the instance data it receives (a course_overview) + based on the instance data it receives (an enterprise course enrollment) """ + course_run_id = 'some+id+here' + course_overviews = [{ + 'id': course_run_id, + 'start': 'a datetime object', + 'end': 'a datetime object', + 'display_name_with_default': 'a default name', + 'pacing': 'instructor', + 'display_org_with_default': 'my university', + }] + mock_get_cert.return_value = { 'download_url': 'example.com', 'is_passing': True, @@ -56,28 +67,24 @@ def test_serializer_representation( mock_get_emails_enabled.return_value = True mock_get_course_run_status.return_value = 'completed' - input_data = { - 'id': 'some+id+here', - 'start': 'a datetime object', - 'end': 'a datetime object', - 'display_name_with_default': 'a default name', - 'pacing': 'instructor', - 'display_org_with_default': 'my university', - } + enterprise_enrollment = factories.EnterpriseCourseEnrollmentFactory.create( + enterprise_customer_user=self.enterprise_customer_user, + course_id=course_run_id + ) request = self.factory.get('/') request.user = self.user serializer = EnterpriseCourseEnrollmentSerializer( - [input_data], + [enterprise_enrollment], many=True, - context={'request': request}, + context={'request': request, 'course_overviews': course_overviews}, ) expected = OrderedDict([ ('certificate_download_url', 'example.com'), ('emails_enabled', True), - ('course_run_id', 'some+id+here'), + ('course_run_id', course_run_id), ('course_run_status', 'completed'), ('start_date', 'a datetime object'), ('end_date', 'a datetime object'), diff --git a/tests/test_enterprise_learner_portal/api/test_views.py b/tests/test_enterprise_learner_portal/api/test_views.py index 81b474d5b7..35413384c8 100644 --- a/tests/test_enterprise_learner_portal/api/test_views.py +++ b/tests/test_enterprise_learner_portal/api/test_views.py @@ -9,12 +9,12 @@ import mock from pytest import mark +from six.moves.urllib.parse import urlencode # pylint: disable=import-error from django.conf import settings from django.core.urlresolvers import reverse from django.test import Client, TestCase -from enterprise.models import EnterpriseCourseEnrollment from enterprise.utils import NotConnectedToOpenEdX from test_utils import factories @@ -36,27 +36,27 @@ def data(self): def setUp(self): super(TestEnterpriseCourseEnrollmentView, self).setUp() - self.enterprise_customer = factories.EnterpriseCustomerFactory() + self.enterprise_customer = factories.EnterpriseCustomerFactory.create() # Create our user we will enroll in a course self.user = factories.UserFactory.create(is_staff=True, is_active=True) self.user.set_password("QWERTY") self.user.save() - enrolled_ent_customer_user = factories.EnterpriseCustomerUserFactory( + enrolled_ent_customer_user = factories.EnterpriseCustomerUserFactory.create( user_id=self.user.id, enterprise_customer=self.enterprise_customer, ) - course_run_id = 'course-v1:edX+DemoX+Demo_Course' - EnterpriseCourseEnrollment.objects.create( + self.course_run_id = 'course-v1:edX+DemoX+Demo_Course' + self.enterprise_enrollment = factories.EnterpriseCourseEnrollmentFactory.create( enterprise_customer_user=enrolled_ent_customer_user, - course_id=course_run_id, + course_id=self.course_run_id, ) # Create a user we will not enroll in a course - not_enrolled_user = factories.UserFactory( + not_enrolled_user = factories.UserFactory.create( email='not_enrolled@example.com', ) - factories.EnterpriseCustomerUserFactory( + factories.EnterpriseCustomerUserFactory.create( user_id=not_enrolled_user.id, enterprise_customer=self.enterprise_customer, ) @@ -71,7 +71,6 @@ def test_view_returns_information(self, mock_get_overviews, mock_serializer): View should return data created by EnterpriseCourseEnrollmentSerializer (which we mock in this case) """ - mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} mock_serializer.return_value = self.MockSerializer() @@ -134,3 +133,124 @@ def test_view_requires_openedx_installation(self): enterprise_id=str(self.enterprise_customer.uuid) ) ) + + with self.assertRaises(NotConnectedToOpenEdX): + query_params = { + 'enterprise_id': str(self.enterprise_customer.uuid), + 'course_id': self.course_run_id, + 'marked_done': True, + } + + self.client.patch( + '{host}{path}?{query_params}'.format( + host=settings.TEST_SERVER, + path=reverse('enterprise-learner-portal-course-enrollment-list'), + query_params=urlencode(query_params), + ) + ) + + @mock.patch('enterprise_learner_portal.api.v1.views.EnterpriseCourseEnrollmentSerializer') + @mock.patch('enterprise_learner_portal.api.v1.views.get_course_overviews') + def test_patch_success(self, mock_get_overviews, mock_serializer): + """ + View should update the enrollment's marked_done field and return serialized data from + EnterpriseCourseEnrollmentSerializer for the enrollment (which we mock in this case) + """ + mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} + mock_serializer.return_value = self.MockSerializer() + query_params = { + 'enterprise_id': str(self.enterprise_customer.uuid), + 'course_id': self.course_run_id, + 'marked_done': True, + } + + assert not self.enterprise_enrollment.marked_done + + resp = self.client.patch( + '{host}{path}?{query_params}'.format( + host=settings.TEST_SERVER, + path=reverse('enterprise-learner-portal-course-enrollment-list'), + query_params=urlencode(query_params), + ) + ) + assert resp.status_code == 200 + assert resp.json() == {'hooray': 'here is the data'} + + self.enterprise_enrollment.refresh_from_db() + assert self.enterprise_enrollment.marked_done + + @mock.patch('enterprise_learner_portal.api.v1.views.EnterpriseCourseEnrollmentSerializer') + @mock.patch('enterprise_learner_portal.api.v1.views.get_course_overviews') + def test_patch_missing_params(self, mock_get_overviews, mock_serializer): + """ + View should return 400 when called with missing required query_params. + """ + mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} + mock_serializer.return_value = self.MockSerializer() + query_params_full = { + 'enterprise_id': str(self.enterprise_customer.uuid), + 'course_id': self.course_run_id, + 'marked_done': True, + } + for key in query_params_full: + query_params = query_params_full.copy() + del query_params[key] + resp = self.client.patch( + '{host}{path}?{query_params}'.format( + host=settings.TEST_SERVER, + path=reverse('enterprise-learner-portal-course-enrollment-list'), + query_params=urlencode(query_params), + ) + ) + assert resp.status_code == 400 + assert resp.json() == { + 'error': 'enterprise_id, course_id, and marked_done must be provided as query parameters' + } + + @mock.patch('enterprise_learner_portal.api.v1.views.EnterpriseCourseEnrollmentSerializer') + @mock.patch('enterprise_learner_portal.api.v1.views.get_course_overviews') + def test_patch_returns_not_found_unlinked_enterprise(self, mock_get_overviews, mock_serializer): + """ + View should return 404 when called with an enterprise_id not associated with the user. + """ + mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} + mock_serializer.return_value = self.MockSerializer() + query_params = { + 'enterprise_id': str(uuid.uuid4()), + 'course_id': self.course_run_id, + 'marked_done': True, + } + + resp = self.client.patch( + '{host}{path}?{query_params}'.format( + host=settings.TEST_SERVER, + path=reverse('enterprise-learner-portal-course-enrollment-list'), + query_params=urlencode(query_params), + ) + ) + assert resp.status_code == 404 + assert resp.json() == {'detail': 'Not found.'} + + @mock.patch('enterprise_learner_portal.api.v1.views.EnterpriseCourseEnrollmentSerializer') + @mock.patch('enterprise_learner_portal.api.v1.views.get_course_overviews') + def test_patch_returns_not_found_no_enrollment(self, mock_get_overviews, mock_serializer): + """ + View should return 404 when called with an enterprise_id not associated with the user. + """ + mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} + mock_serializer.return_value = self.MockSerializer() + query_params = { + 'enterprise_id': str(self.enterprise_customer.uuid), + 'course_id': 'random_course_id', + 'marked_done': True, + } + + resp = self.client.patch( + '{host}{path}?{query_params}'.format( + host=settings.TEST_SERVER, + path=reverse('enterprise-learner-portal-course-enrollment-list'), + query_params=urlencode(query_params), + ) + ) + assert resp.status_code == 404 + assert resp.json() == {'detail': 'Not found.'} diff --git a/tests/test_enterprise_learner_portal/test_utils.py b/tests/test_enterprise_learner_portal/test_utils.py index ba225a03c0..8016cc9c5f 100644 --- a/tests/test_enterprise_learner_portal/test_utils.py +++ b/tests/test_enterprise_learner_portal/test_utils.py @@ -7,13 +7,16 @@ from datetime import datetime import ddt +from pytest import mark from pytz import UTC from django.test import TestCase from enterprise_learner_portal.utils import CourseRunProgressStatuses, get_course_run_status +from test_utils import factories +@mark.django_db @ddt.ddt class TestUtils(TestCase): """ @@ -32,6 +35,7 @@ class TestUtils(TestCase): 'is_passing': True, 'created': NOW, }, + False, CourseRunProgressStatuses.COMPLETED, ), ( @@ -44,6 +48,7 @@ class TestUtils(TestCase): 'is_passing': True, 'created': NOW, }, + False, CourseRunProgressStatuses.IN_PROGRESS, ), ( @@ -56,6 +61,7 @@ class TestUtils(TestCase): 'is_passing': True, 'created': NOW, }, + False, CourseRunProgressStatuses.UPCOMING, ), ( @@ -68,6 +74,7 @@ class TestUtils(TestCase): 'is_passing': True, 'created': NOW, }, + False, CourseRunProgressStatuses.COMPLETED, ), ( @@ -80,6 +87,7 @@ class TestUtils(TestCase): 'is_passing': False, 'created': NOW, }, + False, CourseRunProgressStatuses.IN_PROGRESS, ), ( @@ -92,22 +100,39 @@ class TestUtils(TestCase): 'is_passing': False, 'created': NOW, }, + False, CourseRunProgressStatuses.UPCOMING, ), + ( + { + 'pacing': 'instructor', + 'has_ended': False, + 'has_started': True, + }, + { + 'is_passing': False, + 'created': NOW, + }, + True, + CourseRunProgressStatuses.COMPLETED, + ), ) @ddt.unpack def test_get_course_run_status( self, course_overview, certificate_info, + marked_done, expected, ): """ get_course_run_status should return the proper results based on input parameters """ + enterprise_enrollment = factories.EnterpriseCourseEnrollmentFactory.create(marked_done=marked_done) actual = get_course_run_status( course_overview, certificate_info, + enterprise_enrollment ) assert actual == expected