Skip to content

Commit

Permalink
ENT-2117 | Adding enterprise_learner_portal djangoapp to house endpoi…
Browse files Browse the repository at this point in the history
…nts needed for enterprise learner portal frontend (#544)

Fixing indent

Removing some whitespace

Adding logic to serializer to make sure it is installed into openedx instance

cleaning up imports;

Adding tests

Adding staff check on new endpoint. Also removing print statements

Attempting to fix travis build

Adding quality fixes

more quality fixes

Folding in refactors to get_course_run_status similar to what was done in edx-platform

Changing user to query for based on discussion. changing tests

Oops. missed a name change

Tweaks around serializer request/user

adding package to setup.py

Updating readme and changelog

Fixing changelog
  • Loading branch information
christopappas authored Aug 15, 2019
1 parent 0eda44d commit cd05c57
Show file tree
Hide file tree
Showing 20 changed files with 541 additions and 1 deletion.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ Change Log
Unreleased
----------

[1.9.0] - 2019-08-12
--------------------

* Adding enterprise_learner_portal app to support data needs of frontend enterprise learner portal app

[1.8.9] - 2019-08-15
--------------------

Expand Down
2 changes: 1 addition & 1 deletion enterprise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

from __future__ import absolute_import, unicode_literals

__version__ = "1.8.9"
__version__ = "1.9.0"

default_app_config = "enterprise.apps.EnterpriseConfig" # pylint: disable=invalid-name
5 changes: 5 additions & 0 deletions enterprise/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,9 @@
include('integrated_channels.cornerstone.urls'),
name='cornerstone'
),
url(
r'^enterprise_learner_portal/',
include('enterprise_learner_portal.urls'),
name='enterprise_learner_portal_api'
),
]
Empty file.
Empty file.
11 changes: 11 additions & 0 deletions enterprise_learner_portal/api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
"""
URL definitions for enterprise_learner_portal API endpoint.
"""
from __future__ import absolute_import, unicode_literals

from django.conf.urls import include, url

urlpatterns = [
url(r'^v1/', include('enterprise_learner_portal.api.v1.urls'), name='v1')
]
Empty file.
73 changes: 73 additions & 0 deletions enterprise_learner_portal/api/v1/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""
enterprise_learner_portal serializer
"""
from __future__ import absolute_import, unicode_literals

from django.utils.translation import ugettext as _

from rest_framework import serializers

try:
from lms.djangoapps.certificates.api import get_certificate_for_user
from lms.djangoapps.program_enrollments.api.api import (
get_due_dates,
get_course_run_url,
get_emails_enabled,
)
except ImportError:
get_certificate_for_user = None
get_due_dates = None
get_course_run_url = None
get_emails_enabled = None

from enterprise.utils import NotConnectedToOpenEdX
from enterprise_learner_portal.utils import get_course_run_status


class EnterpriseCourseEnrollmentSerializer(serializers.Serializer):
"""
A serializer for course enrollment information for a given course
and enterprise customer user.
"""

def __init__(self, *args, **kwargs):
if get_certificate_for_user is None:
raise NotConnectedToOpenEdX(
_('To use this EnterpriseCourseEnrollmentSerializer, this package must be '
'installed in an Open edX environment.')
)
super(EnterpriseCourseEnrollmentSerializer, self).__init__(*args, **kwargs)

def to_representation(self, instance):
representation = super(EnterpriseCourseEnrollmentSerializer, self).to_representation(instance)

request = self.context['request']
user = request.user

# Certificate
certificate_info = get_certificate_for_user(
user.username,
instance['id']
)
representation['certificate_download_url'] = certificate_info.get('download_url')

# Email enabled
emails_enabled = get_emails_enabled(user, instance['id'])
if emails_enabled is not None:
representation['emails_enabled'] = emails_enabled

representation['course_run_id'] = instance['id']
representation['course_run_status'] = get_course_run_status(
instance,
certificate_info,
)
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']

return representation
13 changes: 13 additions & 0 deletions enterprise_learner_portal/api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""
URL definitions for enterprise_learner_portal API endpoint.
"""
from __future__ import absolute_import, unicode_literals

from django.conf.urls import include, url

from enterprise_learner_portal.api.v1.views import EnterpriseCourseEnrollmentView

urlpatterns = [
url(r'^enterprise_course_enrollments/$', EnterpriseCourseEnrollmentView.as_view(), name="enterprise-learner-portal-course-enrollment-list"),
]
58 changes: 58 additions & 0 deletions enterprise_learner_portal/api/v1/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals


from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext as _

from rest_framework import permissions
from rest_framework.authentication import SessionAuthentication
from rest_framework.response import Response
from rest_framework.views import APIView

from edx_rest_framework_extensions.auth.bearer.authentication import BearerAuthentication
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication

from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser
from enterprise.utils import NotConnectedToOpenEdX
from enterprise_learner_portal.api.v1.serializers import EnterpriseCourseEnrollmentSerializer

try:
from openedx.core.djangoapps.content.course_overviews.api import get_course_overviews
except ImportError:
get_course_overviews = None

User = get_user_model()


class EnterpriseCourseEnrollmentView(APIView):
permission_classes = (permissions.IsAuthenticated,)
authentication_classes = (JwtAuthentication, BearerAuthentication, SessionAuthentication,)

def get(self, request):
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_user = get_object_or_404(
EnterpriseCustomerUser,
user_id=user.id
)
course_ids_for_ent_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)

data = EnterpriseCourseEnrollmentSerializer(
overviews,
many=True,
context={'request': request},
).data

return Response(data)
8 changes: 8 additions & 0 deletions enterprise_learner_portal/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.apps import AppConfig


class EnterpriseLearnerPortalConfig(AppConfig):
name = 'enterprise_learner_portal'
Empty file.
11 changes: 11 additions & 0 deletions enterprise_learner_portal/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
"""
URL definitions for enterprise API endpoint.
"""
from __future__ import absolute_import, unicode_literals

from django.conf.urls import include, url

urlpatterns = [
url(r'^api/', include('enterprise_learner_portal.api.urls'), name='api')
]
60 changes: 60 additions & 0 deletions enterprise_learner_portal/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""
enterprise_learner_portal utils
"""
from __future__ import absolute_import, unicode_literals

from datetime import datetime, timedelta
from pytz import UTC


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'


def get_course_run_status(course_overview, certificate_info):
"""
Get the progress status of a course run, given the state of a user's certificate in the course.
In the case of self-paced course runs, the run is considered completed when either the course run has ended
OR the user has earned a passing certificate 30 days ago or longer.
Arguments:
course_overview (CourseOverview): the overview for the course run
certificate_info: A dict containing the following keys:
``is_passing``: whether the user has a passing certificate in the course run
``created``: the date the certificate was created
Returns:
status: one of (
CourseRunProgressStatuses.COMPLETE,
CourseRunProgressStatuses.IN_PROGRESS,
CourseRunProgressStatuses.UPCOMING,
)
None if pacing type is not matched
"""
is_certificate_passing = certificate_info.get('is_passing', False)
certificate_creation_date = certificate_info.get('created', datetime.max)

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':
thirty_days_ago = datetime.now(UTC) - timedelta(30)
certificate_completed = is_certificate_passing and (certificate_creation_date <= thirty_days_ago)
if course_overview['has_ended'] or certificate_completed:
return CourseRunProgressStatuses.COMPLETED
elif course_overview['has_started']:
return CourseRunProgressStatuses.IN_PROGRESS
else:
return CourseRunProgressStatuses.UPCOMING
return None
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ def get_requirements(requirements_file):
"integrated_channels.cornerstone",
"integrated_channels.sap_success_factors",
"integrated_channels.xapi",
"enterprise_learner_portal",
],
include_package_data=True,
install_requires=REQUIREMENTS,
Expand Down
Empty file.
Empty file.
98 changes: 98 additions & 0 deletions tests/test_enterprise_learner_portal/api/test_serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
"""
Tests for the EnterpriseCourseEnrollmentview of the enterprise_learner_portal app.
"""

from __future__ import absolute_import, unicode_literals

from collections import OrderedDict

import mock
from pytest import mark

from django.test import RequestFactory, TestCase

from enterprise.utils import NotConnectedToOpenEdX
from enterprise_learner_portal.api.v1.serializers import EnterpriseCourseEnrollmentSerializer
from test_utils import factories


@mark.django_db
class TestEnterpriseCourseEnrollmentSerializer(TestCase):
"""
EnterpriseCourseEnrollmentSerializer tests.
"""

def setUp(self):
super(TestEnterpriseCourseEnrollmentSerializer, self).setUp()

self.user = factories.UserFactory.create(is_staff=True, is_active=True)
self.factory = RequestFactory()

@mock.patch('enterprise_learner_portal.api.v1.serializers.get_course_run_status')
@mock.patch('enterprise_learner_portal.api.v1.serializers.get_emails_enabled')
@mock.patch('enterprise_learner_portal.api.v1.serializers.get_course_run_url')
@mock.patch('enterprise_learner_portal.api.v1.serializers.get_due_dates')
@mock.patch('enterprise_learner_portal.api.v1.serializers.get_certificate_for_user')
def test_serializer_representation(
self,
mock_get_cert,
mock_get_due_dates,
mock_get_course_run_url,
mock_get_emails_enabled,
mock_get_course_run_status,
):
"""
EnterpriseCourseEnrollmentSerializer should create proper representation
based on the instance data it receives (a course_overview)
"""
mock_get_cert.return_value = {
'download_url': 'example.com',
'is_passing': True,
'created': 'a datetime object',
}
mock_get_due_dates.return_value = ['some', 'dates']
mock_get_course_run_url.return_value = 'example.com'
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',
}

request = self.factory.get('/')
request.user = self.user

serializer = EnterpriseCourseEnrollmentSerializer(
[input_data],
many=True,
context={'request': request},
)

expected = OrderedDict([
('certificate_download_url', 'example.com'),
('emails_enabled', True),
('course_run_id', 'some+id+here'),
('course_run_status', 'completed'),
('start_date', 'a datetime object'),
('end_date', 'a datetime object'),
('display_name', 'a default name'),
('course_run_url', 'example.com'),
('due_dates', ['some', 'dates']),
('pacing', 'instructor'),
('org_name', 'my university'),
])
actual = serializer.data[0]
self.assertDictEqual(actual, expected)

def test_view_requires_openedx_installation(self):
"""
View should raise error if imports to helper methods fail.
"""
with self.assertRaises(NotConnectedToOpenEdX):
EnterpriseCourseEnrollmentSerializer({})
Loading

0 comments on commit cd05c57

Please sign in to comment.