From 6a01093423be51ddca765c95eee853d23aed65b3 Mon Sep 17 00:00:00 2001 From: Matt Tuchfarber <mtuchfarber@edx.org> Date: Mon, 22 Oct 2018 21:35:08 -0400 Subject: [PATCH] Flesh out expiration date for user-course combo logic --- lms/djangoapps/courseware/tests/test_views.py | 8 +- .../grades/api/v1/tests/test_views.py | 2 +- .../features/course_duration_limits/access.py | 35 +++++++-- .../course_duration_limits/tests/__init__.py | 0 .../tests/test_course_expiration.py | 76 +++++++++++++++++++ .../tests/views/test_course_home.py | 2 +- 6 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 openedx/features/course_duration_limits/tests/__init__.py create mode 100644 openedx/features/course_duration_limits/tests/test_course_expiration.py diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 0ed6538ba01..100fa6d5808 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -1433,8 +1433,8 @@ class ProgressPageTests(ProgressPageBaseTests): @override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True) @ddt.data( - (True, 40), - (False, 39) + (True, 39), + (False, 38) ) @ddt.unpack def test_progress_queries_paced_courses(self, self_paced, query_count): @@ -1446,8 +1446,8 @@ class ProgressPageTests(ProgressPageBaseTests): @override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True) @patch.dict(settings.FEATURES, {'ASSUME_ZERO_GRADE_IF_ABSENT_FOR_ALL_TESTS': False}) @ddt.data( - (False, 47, 30), - (True, 39, 26) + (False, 46, 29), + (True, 38, 25) ) @ddt.unpack def test_progress_queries(self, enable_waffle, initial, subsequent): diff --git a/lms/djangoapps/grades/api/v1/tests/test_views.py b/lms/djangoapps/grades/api/v1/tests/test_views.py index 6d76ab4827c..54389d5f283 100644 --- a/lms/djangoapps/grades/api/v1/tests/test_views.py +++ b/lms/djangoapps/grades/api/v1/tests/test_views.py @@ -14,7 +14,7 @@ from rest_framework import status from rest_framework.test import APITestCase from six import text_type -from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, StaffFactory +from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory from lms.djangoapps.grades.api.v1.views import CourseGradesView from lms.djangoapps.grades.config.waffle import waffle_flags, WRITABLE_GRADEBOOK from lms.djangoapps.grades.course_data import CourseData diff --git a/openedx/features/course_duration_limits/access.py b/openedx/features/course_duration_limits/access.py index 6a523d57f25..7f6c46bdb71 100644 --- a/openedx/features/course_duration_limits/access.py +++ b/openedx/features/course_duration_limits/access.py @@ -12,9 +12,14 @@ from django.utils.translation import ugettext as _ from util.date_utils import DEFAULT_SHORT_DATE_FORMAT, strftime_localized from lms.djangoapps.courseware.access_response import AccessError from lms.djangoapps.courseware.access_utils import ACCESS_GRANTED +from openedx.core.djangoapps.catalog.utils import get_course_run_details from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +MIN_DURATION = timedelta(weeks=4) +MAX_DURATION = timedelta(weeks=12) + + class AuditExpiredError(AccessError): """ Access denied because the user's audit timespan has expired @@ -43,11 +48,19 @@ def get_user_course_expiration_date(user, course): """ Return course expiration date for given user course pair. Return None if the course does not expire. + Defaults to MIN_DURATION. + + Business Logic: + - + - should be bounded with min / max + - if fields are missing, default to minimum time """ - # TODO: Update business logic based on REV-531 + + access_duration = MIN_DURATION + CourseEnrollment = apps.get_model('student.CourseEnrollment') enrollment = CourseEnrollment.get_enrollment(user, course.id) - if enrollment is None or enrollment.mode == 'verified': + if enrollment is None or enrollment.mode != 'audit': return None try: @@ -55,13 +68,19 @@ def get_user_course_expiration_date(user, course): except CourseEnrollment.schedule.RelatedObjectDoesNotExist: start_date = max(enrollment.created, course.start) - access_duration = timedelta(weeks=8) - if hasattr(course, 'pacing') and course.pacing == 'instructor': - if course.end and course.start: - access_duration = course.end - course.start + if course.self_paced: + # self-paced expirations should be start date plus the marketing course length discovery + discovery_course_details = get_course_run_details(course.id, ['weeks_to_complete']) + expected_weeks = discovery_course_details['weeks_to_complete'] or int(MIN_DURATION.days / 7) + access_duration = timedelta(weeks=expected_weeks) + elif not course.self_paced and course.end and course.start: + # instructor-paced expirations should be the start date plus the length of the course + access_duration = course.end - course.start + + # available course time should bound my the min and max duration + access_duration = max(MIN_DURATION, min(MAX_DURATION, access_duration)) - expiration_date = start_date + access_duration - return expiration_date + return start_date + access_duration def check_course_expired(user, course): diff --git a/openedx/features/course_duration_limits/tests/__init__.py b/openedx/features/course_duration_limits/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openedx/features/course_duration_limits/tests/test_course_expiration.py b/openedx/features/course_duration_limits/tests/test_course_expiration.py new file mode 100644 index 00000000000..07e1d859ab1 --- /dev/null +++ b/openedx/features/course_duration_limits/tests/test_course_expiration.py @@ -0,0 +1,76 @@ +""" +Contains tests to verify correctness of course expiration functionality +""" +from datetime import timedelta +from django.utils.timezone import now +import ddt +import mock + + +from course_modes.models import CourseMode +from openedx.features.course_duration_limits.access import get_user_course_expiration_date, MIN_DURATION, MAX_DURATION +from student.models import CourseEnrollment +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +@ddt.ddt +class CourseExpirationTestCase(ModuleStoreTestCase): + """Tests to verify the get_user_course_expiration_date function is working correctly""" + def setUp(self): + super(CourseExpirationTestCase, self).setUp() + self.course = CourseFactory( + start=now() - timedelta(weeks=10), + ) + self.user = UserFactory() + + def tearDown(self): + CourseEnrollment.unenroll(self.user, self.course.id) + super(CourseExpirationTestCase, self).tearDown() + + def test_enrollment_mode(self): + """Tests that verified enrollments do not have an expiration""" + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) + result = get_user_course_expiration_date(self.user, self.course) + self.assertEqual(result, None) + + def test_instructor_paced(self): + """Tests that instructor paced courses give the learner start_date - end_date time in the course""" + expected_difference = timedelta(weeks=6) + self.course.self_paced = False + self.course.end = self.course.start + expected_difference + enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + result = get_user_course_expiration_date(self.user, self.course) + self.assertEqual(result, enrollment.created + expected_difference) + + def test_instructor_paced_no_end_date(self): + """Tests that instructor paced with no end dates returns default (minimum)""" + self.course.self_paced = False + enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + result = get_user_course_expiration_date(self.user, self.course) + self.assertEqual(result, enrollment.created + MIN_DURATION) + + @mock.patch("openedx.features.course_duration_limits.access.get_course_run_details") + @ddt.data( + [int(MIN_DURATION.days / 7) - 1, MIN_DURATION], + [7, timedelta(weeks=7)], + [int(MAX_DURATION.days / 7) + 1, MAX_DURATION], + [None, MIN_DURATION], + ) + @ddt.unpack + def test_self_paced_with_weeks_to_complete( + self, + weeks_to_complete, + expected_difference, + mock_get_course_run_details, + ): + """ + Tests that self paced courses allow for a (bounded) # of weeks in courses determined via + weeks_to_complete field in discovery. If the field doesn't exist, it should return default (minimum) + """ + self.course.self_paced = True + mock_get_course_run_details.return_value = {'weeks_to_complete': weeks_to_complete} + enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + result = get_user_course_expiration_date(self.user, self.course) + self.assertEqual(result, enrollment.created + expected_difference) diff --git a/openedx/features/course_experience/tests/views/test_course_home.py b/openedx/features/course_experience/tests/views/test_course_home.py index cfbcac43f31..d414c945a37 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -337,7 +337,7 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): url = course_home_url(course) response = self.client.get(url) - expiration_date = strftime_localized(course.start + timedelta(weeks=8), 'SHORT_DATE') + expiration_date = strftime_localized(course.start + timedelta(weeks=4), 'SHORT_DATE') expected_params = QueryDict(mutable=True) course_name = CourseOverview.get_from_id(course.id).display_name_with_default expected_params['access_response_error'] = 'Access to {run} expired on {expiration_date}'.format( -- GitLab