diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 2a80fe7f57..f110d09002 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -166,7 +166,7 @@ class CourseMode(models.Model): return [mode.to_tuple() for mode in found_course_modes] @classmethod - def modes_for_course(cls, course_id, only_selectable=True): + def modes_for_course(cls, course_id, include_expired=False, only_selectable=True): """ Returns a list of the non-expired modes for a given course id @@ -176,6 +176,9 @@ class CourseMode(models.Model): course_id (CourseKey): Search for course modes for this course. Keyword Arguments: + include_expired (bool): If True, expired course modes will be included + in the returned JSON data. If False, these modes will be omitted. + only_selectable (bool): If True, include only modes that are shown to users on the track selection page. (Currently, "credit" modes aren't available to users until they complete the course, so @@ -186,9 +189,14 @@ class CourseMode(models.Model): """ now = datetime.now(pytz.UTC) - found_course_modes = cls.objects.filter( - Q(course_id=course_id) & (Q(expiration_datetime__isnull=True) | Q(expiration_datetime__gte=now)) - ) + + found_course_modes = cls.objects.filter(course_id=course_id) + + # Filter out expired course modes if include_expired is not set + if not include_expired: + found_course_modes = found_course_modes.filter( + Q(expiration_datetime__isnull=True) | Q(expiration_datetime__gte=now) + ) # Credit course modes are currently not shown on the track selection page; # they're available only when students complete a course. For this reason, diff --git a/common/djangoapps/enrollment/api.py b/common/djangoapps/enrollment/api.py index e5d1f3ef16..2345b025f5 100644 --- a/common/djangoapps/enrollment/api.py +++ b/common/djangoapps/enrollment/api.py @@ -235,7 +235,7 @@ def update_enrollment(user_id, course_id, mode=None, is_active=None): return enrollment -def get_course_enrollment_details(course_id): +def get_course_enrollment_details(course_id, include_expired=False): """Get the course modes for course. Also get enrollment start and end date, invite only, etc. Given a course_id, return a serializable dictionary of properties describing course enrollment information. @@ -243,6 +243,9 @@ def get_course_enrollment_details(course_id): Args: course_id (str): The Course to get enrollment information for. + include_expired (bool): Boolean denoting whether expired course modes + should be included in the returned JSON data. + Returns: A serializable dictionary of course enrollment information. @@ -270,8 +273,10 @@ def get_course_enrollment_details(course_id): } """ - cache_key = u"enrollment.course.details.{course_id}".format(course_id=course_id) - + cache_key = u'enrollment.course.details.{course_id}.{include_expired}'.format( + course_id=course_id, + include_expired=include_expired + ) cached_enrollment_data = None try: cached_enrollment_data = cache.get(cache_key) @@ -283,7 +288,7 @@ def get_course_enrollment_details(course_id): log.info(u"Get enrollment data for course %s (cached)", course_id) return cached_enrollment_data - course_enrollment_details = _data_api().get_course_enrollment_info(course_id) + course_enrollment_details = _data_api().get_course_enrollment_info(course_id, include_expired) try: cache_time_out = getattr(settings, 'ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT', 60) diff --git a/common/djangoapps/enrollment/data.py b/common/djangoapps/enrollment/data.py index 1e855a132e..ce05cea6c3 100644 --- a/common/djangoapps/enrollment/data.py +++ b/common/djangoapps/enrollment/data.py @@ -142,7 +142,7 @@ def _update_enrollment(enrollment, is_active=None, mode=None): return CourseEnrollmentSerializer(enrollment).data # pylint: disable=no-member -def get_course_enrollment_info(course_id): +def get_course_enrollment_info(course_id, include_expired=False): """Returns all course enrollment information for the given course. Based on the course id, return all related course information.. @@ -150,6 +150,9 @@ def get_course_enrollment_info(course_id): Args: course_id (str): The course to retrieve enrollment information for. + include_expired (bool): Boolean denoting whether expired course modes + should be included in the returned JSON data. + Returns: A serializable dictionary representing the course's enrollment information. @@ -163,4 +166,4 @@ def get_course_enrollment_info(course_id): msg = u"Requested enrollment information for unknown course {course}".format(course=course_id) log.warning(msg) raise CourseNotFoundError(msg) - return CourseField().to_native(course) + return CourseField().to_native(course, include_expired=include_expired) diff --git a/common/djangoapps/enrollment/serializers.py b/common/djangoapps/enrollment/serializers.py index d7f4960e4e..4513b4ed95 100644 --- a/common/djangoapps/enrollment/serializers.py +++ b/common/djangoapps/enrollment/serializers.py @@ -38,10 +38,10 @@ class CourseField(serializers.RelatedField): """ - def to_native(self, course): + def to_native(self, course, **kwargs): course_id = unicode(course.id) course_modes = ModeSerializer( - CourseMode.modes_for_course(course.id, only_selectable=False) + CourseMode.modes_for_course(course.id, kwargs.get('include_expired', False), only_selectable=False) ).data # pylint: disable=no-member return { @@ -94,7 +94,7 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): """Retrieves the username from the associated model.""" return model.username - class Meta: # pylint: disable=missing-docstring + class Meta(object): # pylint: disable=missing-docstring model = CourseEnrollment fields = ('created', 'mode', 'is_active', 'course_details', 'user') lookup_field = 'username' diff --git a/common/djangoapps/enrollment/tests/fake_data_api.py b/common/djangoapps/enrollment/tests/fake_data_api.py index adee6dd320..3115ce1cbb 100644 --- a/common/djangoapps/enrollment/tests/fake_data_api.py +++ b/common/djangoapps/enrollment/tests/fake_data_api.py @@ -46,7 +46,7 @@ def update_course_enrollment(student_id, course_id, mode=None, is_active=None): return enrollment -def get_course_enrollment_info(course_id): +def get_course_enrollment_info(course_id, include_expired=False): """Stubbed out Enrollment data request.""" return _get_fake_course_info(course_id) diff --git a/common/djangoapps/enrollment/tests/test_views.py b/common/djangoapps/enrollment/tests/test_views.py index c7fcd125aa..a9d97308de 100644 --- a/common/djangoapps/enrollment/tests/test_views.py +++ b/common/djangoapps/enrollment/tests/test_views.py @@ -476,6 +476,40 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase): self.assertTrue(is_active) self.assertEqual(course_mode, 'professional') + def test_enrollment_includes_expired_verified(self): + """With the right API key, request that expired course verifications are still returned. """ + # Create a honor mode for a course. + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=CourseMode.HONOR, + mode_display_name=CourseMode.HONOR, + ) + + # Create a verified mode for a course. + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=CourseMode.VERIFIED, + mode_display_name=CourseMode.VERIFIED, + expiration_datetime='1970-01-01 05:00:00' + ) + + # Passes the include_expired parameter to the API call + v_response = self.client.get( + reverse('courseenrollmentdetails', kwargs={"course_id": unicode(self.course.id)}), {'include_expired': True} + ) + v_data = json.loads(v_response.content) + + # Ensure that both course modes are returned + self.assertEqual(len(v_data['course_modes']), 2) + + # Omits the include_expired parameter from the API call + h_response = self.client.get(reverse('courseenrollmentdetails', kwargs={"course_id": unicode(self.course.id)})) + h_data = json.loads(h_response.content) + + # Ensure that only one course mode is returned and that it is honor + self.assertEqual(len(h_data['course_modes']), 1) + self.assertEqual(h_data['course_modes'][0]['slug'], CourseMode.HONOR) + def test_update_enrollment_with_mode(self): """With the right API key, update an existing enrollment with a new mode. """ # Create an honor and verified mode for a course. This allows an update. diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py index a5c610c5d5..f8080bedf2 100644 --- a/common/djangoapps/enrollment/views.py +++ b/common/djangoapps/enrollment/views.py @@ -163,12 +163,17 @@ class EnrollmentCourseDetailView(APIView): Get enrollment details for a course. + Response values include the course schedule and enrollment modes supported by the course. + Use the parameter include_expired=1 to include expired enrollment modes in the response. + **Note:** Getting enrollment details for a course does not require authentication. **Example Requests**: GET /api/enrollment/v1/course/{course_id} + GET /api/v1/enrollment/course/{course_id}?include_expired=1 + **Response Values** @@ -184,7 +189,10 @@ class EnrollmentCourseDetailView(APIView): * course_end: The date and time at which the course closes. If null, the course never ends. - * course_modes: An array of data about the enrollment modes supported for the course. Each enrollment mode collection includes: + * course_modes: An array containing details about the enrollment modes supported for the course. + If the request uses the parameter include_expired=1, the array also includes expired enrollment modes. + + Each enrollment mode collection includes: * slug: The short name for the enrollment mode. * name: The full name of the enrollment mode. @@ -217,7 +225,7 @@ class EnrollmentCourseDetailView(APIView): """ try: - return Response(api.get_course_enrollment_details(course_id)) + return Response(api.get_course_enrollment_details(course_id, bool(request.GET.get('include_expired', '')))) except CourseNotFoundError: return Response( status=status.HTTP_400_BAD_REQUEST, diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py index 0a57b95aec..7965cdd186 100644 --- a/common/djangoapps/student/tests/factories.py +++ b/common/djangoapps/student/tests/factories.py @@ -53,6 +53,7 @@ class CourseModeFactory(DjangoModelFactory): min_price = 0 suggested_prices = '' currency = 'usd' + expiration_datetime = None class RegistrationFactory(DjangoModelFactory): diff --git a/common/test/acceptance/tests/discussion/test_discussion.py b/common/test/acceptance/tests/discussion/test_discussion.py index 03c1fe356e..9c14ba822f 100644 --- a/common/test/acceptance/tests/discussion/test_discussion.py +++ b/common/test/acceptance/tests/discussion/test_discussion.py @@ -6,6 +6,7 @@ import datetime from pytz import UTC from uuid import uuid4 from nose.plugins.attrib import attr +from flaky import flaky from .helpers import BaseDiscussionTestCase from ..helpers import UniqueCourseTest @@ -217,6 +218,7 @@ class DiscussionTabSingleThreadTest(BaseDiscussionTestCase, DiscussionResponsePa self.thread_page = self.create_single_thread_page(thread_id) # pylint: disable=attribute-defined-outside-init self.thread_page.visit() + @flaky # TODO fix this, see TNL-2419 def test_mathjax_rendering(self): thread_id = "test_thread_{}".format(uuid4().hex) diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py index 51954d7a06..3f3b3c0ddf 100644 --- a/common/test/acceptance/tests/studio/test_studio_library.py +++ b/common/test/acceptance/tests/studio/test_studio_library.py @@ -4,6 +4,7 @@ Acceptance tests for Content Libraries in Studio from ddt import ddt, data from unittest import skip from nose.plugins.attrib import attr +from flaky import flaky from .base_studio_test import StudioLibraryTest from ...fixtures.course import XBlockFixtureDesc @@ -129,6 +130,7 @@ class LibraryEditPageTest(StudioLibraryTest): """ self.assertFalse(self.browser.find_elements_by_css_selector('span.large-discussion-icon')) + @flaky # TODO fix this, see TNL-2322 def test_library_pagination(self): """ Scenario: Ensure that adding several XBlocks to a library results in pagination.