- % if show_courseware_link or is_unfulfilled_entitlement:
+ % if (show_courseware_link or is_unfulfilled_entitlement) and not is_course_expired:
% if course_overview.has_ended():
% if not is_course_blocked:
-
${_('View Archived Course')} ${course_overview.display_name_with_default}
+
${_('View Archived Course')} ${course_overview.display_name_with_default}
% else:
-
${_('View Archived Course')} ${course_overview.display_name_with_default}
+
${_('View Archived Course')} ${course_overview.display_name_with_default}
% endif
% else:
% if resume_button_url != '':
${_('Resume Course')}
@@ -185,7 +194,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
% elif not is_course_blocked:
${_('View Course')}
@@ -202,14 +211,6 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
% endif
% endif
- % elif hasattr(show_courseware_link, 'user_message'):
-
- ${show_courseware_link.user_message}
-
- ${_('for {course_display_name}').format(course_display_name=course_overview.display_name_with_default)}
-
-
% endif
% if show_courseware_link or course_overview.has_social_sharing_url() or course_overview.has_marketing_url():
diff --git a/openedx/features/course_duration_limits/__init__.py b/openedx/features/course_duration_limits/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/features/course_duration_limits/access.py b/openedx/features/course_duration_limits/access.py
new file mode 100644
index 0000000000..6a523d57f2
--- /dev/null
+++ b/openedx/features/course_duration_limits/access.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+"""
+Contains code related to computing content gating course duration limits
+and course access based on these limits.
+"""
+from datetime import timedelta
+
+from django.apps import apps
+from django.utils import timezone
+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.content.course_overviews.models import CourseOverview
+
+
+class AuditExpiredError(AccessError):
+ """
+ Access denied because the user's audit timespan has expired
+ """
+ def __init__(self, user, course, expiration_date):
+ error_code = "audit_expired"
+ developer_message = "User {} had access to {} until {}".format(user, course, expiration_date)
+ expiration_date = strftime_localized(expiration_date, DEFAULT_SHORT_DATE_FORMAT)
+ user_message = _("Access expired on {expiration_date}").format(expiration_date=expiration_date)
+ try:
+ course_name = CourseOverview.get_from_id(course.id).display_name_with_default
+ additional_context_user_message = _("Access to {course_name} expired on {expiration_date}").format(
+ course_name=course_name,
+ expiration_date=expiration_date
+ )
+ except CourseOverview.DoesNotExist:
+ additional_context_user_message = _("Access to the course you were looking"
+ "for expired on {expiration_date}").format(
+ expiration_date=expiration_date
+ )
+ super(AuditExpiredError, self).__init__(error_code, developer_message, user_message,
+ additional_context_user_message)
+
+
+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.
+ """
+ # TODO: Update business logic based on REV-531
+ CourseEnrollment = apps.get_model('student.CourseEnrollment')
+ enrollment = CourseEnrollment.get_enrollment(user, course.id)
+ if enrollment is None or enrollment.mode == 'verified':
+ return None
+
+ try:
+ start_date = enrollment.schedule.start
+ 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
+
+ expiration_date = start_date + access_duration
+ return expiration_date
+
+
+def check_course_expired(user, course):
+ """
+ Check if the course expired for the user.
+ """
+ expiration_date = get_user_course_expiration_date(user, course)
+ if expiration_date and timezone.now() > expiration_date:
+ return AuditExpiredError(user, course, expiration_date)
+
+ return ACCESS_GRANTED
diff --git a/openedx/features/course_duration_limits/apps.py b/openedx/features/course_duration_limits/apps.py
new file mode 100644
index 0000000000..d88cc87209
--- /dev/null
+++ b/openedx/features/course_duration_limits/apps.py
@@ -0,0 +1,11 @@
+"""
+Course duration limits application configuration
+"""
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.apps import AppConfig
+
+
+class CourseDurationLimitsConfig(AppConfig):
+ name = 'openedx.features.course_duration_limits'
diff --git a/openedx/features/course_duration_limits/config.py b/openedx/features/course_duration_limits/config.py
new file mode 100644
index 0000000000..eff4f675e7
--- /dev/null
+++ b/openedx/features/course_duration_limits/config.py
@@ -0,0 +1,13 @@
+"""
+Content type gating waffle flag
+"""
+from openedx.core.djangoapps.waffle_utils import WaffleFlagNamespace, WaffleFlag
+
+
+WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name=u'content_type_gating')
+
+CONTENT_TYPE_GATING_FLAG = WaffleFlag(
+ waffle_namespace=WAFFLE_FLAG_NAMESPACE,
+ flag_name=u'debug',
+ flag_undefined_default=False
+)
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 ad298ca7d0..cfbcac43f3 100644
--- a/openedx/features/course_experience/tests/views/test_course_home.py
+++ b/openedx/features/course_experience/tests/views/test_course_home.py
@@ -20,7 +20,10 @@ from courseware.tests.factories import StaffFactory
from lms.djangoapps.commerce.models import CommerceConfiguration
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.course_goals.api import add_course_goal, remove_course_goal
+from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
+from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES, override_waffle_flag
+from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_FLAG
from openedx.features.course_experience import (
SHOW_REVIEWS_TOOL_FLAG,
SHOW_UPGRADE_MSG_ON_COURSE_HOME,
@@ -165,6 +168,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
response = self.client.get(url)
self.assertContains(response, TEST_COURSE_UPDATES_TOOL, status_code=200)
+ @override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
def test_queries(self):
"""
Verify that the view's query count doesn't regress.
@@ -173,7 +177,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
course_home_url(self.course)
# Fetch the view and verify the query counts
- with self.assertNumQueries(54, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
+ with self.assertNumQueries(66, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4):
url = course_home_url(self.course)
self.client.get(url)
@@ -317,6 +321,35 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
)
self.assertRedirects(response, expected_url)
+ @override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
+ @mock.patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False})
+ def test_expired_course(self):
+ """
+ Ensure that a user accessing an expired course sees a redirect to
+ the student dashboard, not a 404.
+ """
+ three_years_ago = now() - timedelta(days=(365 * 3))
+ course = CourseFactory.create(start=three_years_ago)
+ user = self.create_user_for_course(course, CourseUserType.ENROLLED)
+ enrollment = CourseEnrollment.get_enrollment(user, course.id)
+ ScheduleFactory(start=three_years_ago, enrollment=enrollment)
+
+ url = course_home_url(course)
+ response = self.client.get(url)
+
+ expiration_date = strftime_localized(course.start + timedelta(weeks=8), '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(
+ run=course_name,
+ expiration_date=expiration_date
+ )
+ expected_url = '{url}?{params}'.format(
+ url=reverse('dashboard'),
+ params=expected_params.urlencode()
+ )
+ self.assertRedirects(response, expected_url)
+
@mock.patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False})
@mock.patch("util.date_utils.strftime_localized")
def test_non_live_course_other_language(self, mock_strftime_localized):
diff --git a/openedx/features/course_experience/tests/views/test_course_updates.py b/openedx/features/course_experience/tests/views/test_course_updates.py
index 5891c4688f..fe06df99cc 100644
--- a/openedx/features/course_experience/tests/views/test_course_updates.py
+++ b/openedx/features/course_experience/tests/views/test_course_updates.py
@@ -3,7 +3,8 @@ Tests for the course updates page.
"""
from courseware.courses import get_course_info_usage_key
from django.urls import reverse
-from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES
+from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES, override_waffle_flag
+from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_FLAG
from openedx.features.course_experience.views.course_updates import STATUS_VISIBLE
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
@@ -117,6 +118,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
self.assertContains(response, 'First Message')
self.assertContains(response, 'Second Message')
+ @override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
def test_queries(self):
create_course_update(self.course, self.user, 'First Message')
@@ -124,7 +126,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
course_updates_url(self.course)
# Fetch the view and verify that the query counts haven't changed
- with self.assertNumQueries(34, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
+ with self.assertNumQueries(38, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4):
url = course_updates_url(self.course)
self.client.get(url)