diff --git a/lms/djangoapps/program_enrollments/api/api.py b/lms/djangoapps/program_enrollments/api/api.py new file mode 100644 index 0000000000..af3ec29e39 --- /dev/null +++ b/lms/djangoapps/program_enrollments/api/api.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +""" +ProgramEnrollment internal api +""" +from __future__ import absolute_import, unicode_literals + +from datetime import datetime, timedelta +from pytz import UTC + +from django.urls import reverse + +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 xmodule.modulestore.django import modulestore +from lms.djangoapps.program_enrollments.api.v1.constants import ( + CourseRunProgressStatuses, +) + + +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 + + +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) + + +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) + return None + + +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, + ) + """ + 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 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 ffe0dd0035..d1785ff82d 100644 --- a/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py +++ b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py @@ -1604,7 +1604,7 @@ class ProgramCourseEnrollmentOverviewViewTests(ProgramCacheTestCaseMixin, Shared display_name='unit_1' ) - with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_dates_for_course') as mock_get_dates: + with mock.patch('lms.djangoapps.program_enrollments.api.api.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, diff --git a/lms/djangoapps/program_enrollments/api/v1/views.py b/lms/djangoapps/program_enrollments/api/v1/views.py index 0554c04c5f..1e94c932b1 100644 --- a/lms/djangoapps/program_enrollments/api/v1/views.py +++ b/lms/djangoapps/program_enrollments/api/v1/views.py @@ -5,13 +5,10 @@ ProgramEnrollment Views from __future__ import absolute_import, unicode_literals import logging -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 django.utils.functional import cached_property from edx_rest_framework_extensions import permissions from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication @@ -23,13 +20,10 @@ from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from six import iteritems, text_type -from six.moves import zip +from six import text_type from ccx_keys.locator import CCXLocator -from bulk_email.api import is_bulk_email_feature_enabled, is_user_opted_out_for_course from course_modes.models import CourseMode -from edx_when.api import get_dates_for_course from lms.djangoapps.certificates.api import get_certificate_for_user from lms.djangoapps.grades.api import ( CourseGradeFactory, @@ -39,10 +33,15 @@ from lms.djangoapps.grades.api import ( from lms.djangoapps.grades.rest_api.v1.utils import CourseEnrollmentPagination from lms.djangoapps.program_enrollments.api.v1.constants import ( CourseEnrollmentResponseStatuses, - CourseRunProgressStatuses, MAX_ENROLLMENT_RECORDS, ProgramEnrollmentResponseStatuses, ) +from lms.djangoapps.program_enrollments.api.api import ( + get_due_dates, + get_course_run_url, + get_emails_enabled, + get_course_run_status +) from lms.djangoapps.program_enrollments.api.v1.serializers import ( CourseRunOverviewListSerializer, ProgramCourseEnrollmentListSerializer, @@ -59,7 +58,6 @@ from lms.djangoapps.program_enrollments.utils import get_user_by_program_id, Pro from student.helpers import get_resume_urls_for_enrollments from student.models import CourseEnrollment from student.roles import CourseInstructorRole, CourseStaffRole, UserBasedRole -from xmodule.modulestore.django import modulestore from openedx.core.djangoapps.catalog.utils import ( course_run_keys_for_program, get_programs, @@ -1053,14 +1051,14 @@ class ProgramCourseEnrollmentOverviewView(DeveloperErrorViewMixin, ProgramSpecif course_run_dict = { 'course_run_id': enrollment.course_id, 'display_name': overview.display_name_with_default, - 'course_run_status': self.get_course_run_status(overview, certificate_info), - 'course_run_url': self.get_course_run_url(request, enrollment.course_id), + 'course_run_status': get_course_run_status(overview, certificate_info), + 'course_run_url': get_course_run_url(request, enrollment.course_id), 'start_date': overview.start, 'end_date': overview.end, - 'due_dates': self.get_due_dates(request, enrollment.course_id, user), + 'due_dates': get_due_dates(request, enrollment.course_id, user), } - emails_enabled = self.get_emails_enabled(user, enrollment.course_id) + emails_enabled = get_emails_enabled(user, enrollment.course_id) if emails_enabled is not None: course_run_dict['emails_enabled'] = emails_enabled @@ -1091,120 +1089,6 @@ class ProgramCourseEnrollmentOverviewView(DeveloperErrorViewMixin, ProgramSpecif if not program_enrollments: raise PermissionDenied - @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) - return None - - @staticmethod - 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, - ) - """ - 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 - class ProgramCourseGradesView( DeveloperErrorViewMixin, diff --git a/openedx/core/djangoapps/content/course_overviews/api.py b/openedx/core/djangoapps/content/course_overviews/api.py new file mode 100644 index 0000000000..2b1f24c393 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/api.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +""" +CourseOverview internal api +""" +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +from openedx.core.djangoapps.content.course_overviews.serializers import ( + CourseOverviewBaseSerializer, +) + + +def get_course_overviews(course_ids): + """ + Return course_overview data for a given list of opaque_key course_ids. + """ + overviews = CourseOverview.objects.filter(id__in=course_ids) + return CourseOverviewBaseSerializer(overviews, many=True).data diff --git a/openedx/core/djangoapps/content/course_overviews/serializers.py b/openedx/core/djangoapps/content/course_overviews/serializers.py new file mode 100644 index 0000000000..69936200f1 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/serializers.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +""" +CourseOverview serializers +""" +from rest_framework import serializers + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + +class CourseOverviewBaseSerializer(serializers.ModelSerializer): + """ + Serializer for a course run overview. + """ + + class Meta(object): + model = CourseOverview + fields = '__all__' + + def to_representation(self, instance): + representation = super(CourseOverviewBaseSerializer, self).to_representation(instance) + representation['display_name_with_default'] = instance.display_name_with_default + representation['has_started'] = instance.has_started + representation['has_ended'] = instance.has_ended + representation['pacing'] = instance.pacing + return representation diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_api.py b/openedx/core/djangoapps/content/course_overviews/tests/test_api.py new file mode 100644 index 0000000000..0735151fb2 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_api.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +""" +course_overview api tests +""" +from django.test import TestCase + +from openedx.core.djangoapps.content.course_overviews.api import get_course_overviews +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory + +from ..models import CourseOverview + + +class TestCourseOverviewsApi(TestCase): + """ + TestCourseOverviewsApi tests. + """ + + def setUp(self): + super(TestCourseOverviewsApi, self).setUp() + for _ in range(3): + CourseOverviewFactory.create() + + def test_get_course_overviews(self): + """ + get_course_overviews should return the expected CourseOverview data + in serialized form (a list of dicts) + """ + course_ids = [] + course_ids.append(str(CourseOverview.objects.first().id)) + course_ids.append(str(CourseOverview.objects.last().id)) + + data = get_course_overviews(course_ids) + assert len(data) == 2 + for overview in data: + assert overview['id'] in course_ids + + fields = [ + 'display_name_with_default', + 'has_started', + 'has_ended', + 'pacing', + ] + for field in fields: + assert field in data[0] diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_serializers.py b/openedx/core/djangoapps/content/course_overviews/tests/test_serializers.py new file mode 100644 index 0000000000..7182f0243e --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_serializers.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +""" +CourseOverviewSerializer tests +""" +from django.test import TestCase + +from openedx.core.djangoapps.content.course_overviews.serializers import CourseOverviewBaseSerializer +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory + +from ..models import CourseOverview + + +class TestCourseOverviewSerializer(TestCase): + """ + TestCourseOverviewSerializer tests. + """ + + def setUp(self): + super(TestCourseOverviewSerializer, self).setUp() + CourseOverviewFactory.create() + + def test_get_course_overview_serializer(self): + """ + CourseOverviewBaseSerializer should add additional fields in the + to_representation method that is overridden. + """ + overview = CourseOverview.objects.first() + data = CourseOverviewBaseSerializer(overview).data + + fields = [ + 'display_name_with_default', + 'has_started', + 'has_ended', + 'pacing', + ] + for field in fields: + assert field in data