Merge pull request #19095 from edx/expired_dashboard_message

Add course duration limit and dashboard expiration code
This commit is contained in:
Matthew Piatetsky
2018-10-23 12:17:58 -04:00
committed by GitHub
20 changed files with 348 additions and 96 deletions

View File

@@ -1161,6 +1161,8 @@ INSTALLED_APPS = [
# API Documentation
'rest_framework_swagger',
'openedx.features.course_duration_limits',
]

View File

@@ -27,7 +27,11 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context
from pyquery import PyQuery as pq
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
from openedx.core.djangoapps.user_authn.cookies import _get_user_info_cookie_data
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_FLAG
from student.helpers import DISABLE_UNENROLL_CERT_STATES
from student.models import CourseEnrollment, UserProfile
from student.signals import REFUND_ORDER
@@ -306,7 +310,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin,
'type': 'verified'
}
response = self.client.get(self.path)
self.assertIn('class="enter-course hidden"', response.content)
self.assertIn('class="course-target-link enter-course hidden"', response.content)
self.assertIn('You must select a session to access the course.', response.content)
self.assertIn('<div class="course-entitlement-selection-container ">', response.content)
self.assertIn('Related Programs:', response.content)
@@ -580,7 +584,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin,
def _get_html_for_view_course_button(course_key_string, course_run_string):
return '''
<a href="/courses/{course_key}/course/"
class="enter-course "
class="course-target-link enter-course"
data-course-key="{course_key}">
View Course
<span class="sr">
@@ -593,7 +597,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin,
def _get_html_for_resume_course_button(course_key_string, resume_block_key_string, course_run_string):
return '''
<a href="/courses/{course_key}/jump_to/{url_to_block}"
class="enter-course "
class="course-target-link enter-course"
data-course-key="{course_key}">
Resume Course
<span class="sr">
@@ -718,6 +722,38 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin,
dashboard_html
)
@override_waffle_flag(COURSE_UPDATE_WAFFLE_FLAG, True)
@override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
def test_content_gating_course_card_changes(self):
"""
When a course is expired, the links on the course card should be removed.
Links will be removed from the course title, course image and button (View Course/Resume Course).
The course card should have an access expired message.
"""
self.override_waffle_switch(True)
course = CourseFactory.create(start=self.THREE_YEARS_AGO)
enrollment = CourseEnrollmentFactory.create(
user=self.user,
course_id=course.id
)
schedule = ScheduleFactory(start=self.THREE_YEARS_AGO, enrollment=enrollment)
response = self.client.get(reverse('dashboard'))
dashboard_html = self._remove_whitespace_from_html_string(response.content)
access_expired_substring = 'Accessexpired'
course_link_class = 'course-target-link'
self.assertNotIn(
course_link_class,
dashboard_html
)
self.assertIn(
access_expired_substring,
dashboard_html
)
def test_dashboard_with_resume_buttons_and_view_buttons(self):
'''
The Test creates a four-course-card dashboard. The user completes course

View File

@@ -767,6 +767,9 @@ def student_dashboard(request):
redirect_message = _("The course you are looking for is closed for enrollment as of {date}.").format(
date=request.GET['course_closed']
)
elif 'access_response_error' in request.GET:
# This can be populated in a generalized way with fields from access response errors
redirect_message = request.GET['access_response_error']
else:
redirect_message = ''

View File

@@ -21,6 +21,7 @@ from lms.djangoapps.ccx.tests.factories import CcxFactory
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.block_structure.api import get_course_in_cache
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES
from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_FLAG
from pytz import UTC
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
@@ -194,7 +195,8 @@ class FieldOverridePerformanceTestCase(FieldOverrideTestMixin, ProceduralCourseT
XBLOCK_FIELD_DATA_WRAPPERS=[],
MODULESTORE_FIELD_OVERRIDE_PROVIDERS=[],
)
def test_field_overrides(self, overrides, course_width, enable_ccx, view_as_ccx):
@mock.patch.object(CONTENT_TYPE_GATING_FLAG, 'is_enabled', return_value=True)
def test_field_overrides(self, overrides, course_width, enable_ccx, view_as_ccx, _mock_flag):
"""
Test without any field overrides.
"""
@@ -235,18 +237,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase):
# # of sql queries to default,
# # of mongo queries,
# )
('no_overrides', 1, True, False): (18, 1),
('no_overrides', 2, True, False): (18, 1),
('no_overrides', 3, True, False): (18, 1),
('ccx', 1, True, False): (18, 1),
('ccx', 2, True, False): (18, 1),
('ccx', 3, True, False): (18, 1),
('no_overrides', 1, False, False): (18, 1),
('no_overrides', 2, False, False): (18, 1),
('no_overrides', 3, False, False): (18, 1),
('ccx', 1, False, False): (18, 1),
('ccx', 2, False, False): (18, 1),
('ccx', 3, False, False): (18, 1),
('no_overrides', 1, True, False): (20, 1),
('no_overrides', 2, True, False): (20, 1),
('no_overrides', 3, True, False): (20, 1),
('ccx', 1, True, False): (20, 1),
('ccx', 2, True, False): (20, 1),
('ccx', 3, True, False): (20, 1),
('no_overrides', 1, False, False): (20, 1),
('no_overrides', 2, False, False): (20, 1),
('no_overrides', 3, False, False): (20, 1),
('ccx', 1, False, False): (20, 1),
('ccx', 2, False, False): (20, 1),
('ccx', 3, False, False): (20, 1),
}
@@ -258,19 +260,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase):
__test__ = True
TEST_DATA = {
('no_overrides', 1, True, False): (18, 3),
('no_overrides', 2, True, False): (18, 3),
('no_overrides', 3, True, False): (18, 3),
('ccx', 1, True, False): (18, 3),
('ccx', 2, True, False): (18, 3),
('ccx', 3, True, False): (18, 3),
('ccx', 1, True, True): (19, 3),
('ccx', 2, True, True): (19, 3),
('ccx', 3, True, True): (19, 3),
('no_overrides', 1, False, False): (18, 3),
('no_overrides', 2, False, False): (18, 3),
('no_overrides', 3, False, False): (18, 3),
('ccx', 1, False, False): (18, 3),
('ccx', 2, False, False): (18, 3),
('ccx', 3, False, False): (18, 3),
('no_overrides', 1, True, False): (20, 3),
('no_overrides', 2, True, False): (20, 3),
('no_overrides', 3, True, False): (20, 3),
('ccx', 1, True, False): (20, 3),
('ccx', 2, True, False): (20, 3),
('ccx', 3, True, False): (20, 3),
('ccx', 1, True, True): (21, 3),
('ccx', 2, True, True): (21, 3),
('ccx', 3, True, True): (21, 3),
('no_overrides', 1, False, False): (20, 3),
('no_overrides', 2, False, False): (20, 3),
('no_overrides', 3, False, False): (20, 3),
('ccx', 1, False, False): (20, 3),
('ccx', 2, False, False): (20, 3),
('ccx', 3, False, False): (20, 3),
}

View File

@@ -40,6 +40,8 @@ from lms.djangoapps.ccx.models import CustomCourseForEdX
from mobile_api.models import IgnoreMobileAvailableFlagConfig
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
from openedx.features.course_duration_limits.access import check_course_expired
from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_FLAG
from student import auth
from student.models import CourseEnrollmentAllowed
from student.roles import (
@@ -318,16 +320,53 @@ def _has_access_course(user, action, courselike):
NOTE: this is not checking whether user is actually enrolled in the course.
"""
response = (
_visible_to_nonstaff_users(courselike) and
check_course_open_for_learner(user, courselike) and
_can_view_courseware_with_prerequisites(user, courselike)
)
# N.B. I'd love a better way to handle this pattern, without breaking the
# shortcircuiting logic. Maybe AccessResponse needs to grow a
# fluent interface?
#
# return (
# _visible_to_nonstaff_users(courselike).and(
# check_course_open_for_learner, user, courselike
# ).and(
# _can_view_courseware_with_prerequisites, user, courselike
# )
# ).or(
# _has_staff_access_to_descriptor, user, courselike, courselike.id
# )
visible_to_nonstaff = _visible_to_nonstaff_users(courselike)
if not visible_to_nonstaff:
staff_access = _has_staff_access_to_descriptor(user, courselike, courselike.id)
if staff_access:
return staff_access
else:
return visible_to_nonstaff
return (
ACCESS_GRANTED if (response or _has_staff_access_to_descriptor(user, courselike, courselike.id))
else response
)
open_for_learner = check_course_open_for_learner(user, courselike)
if not open_for_learner:
staff_access = _has_staff_access_to_descriptor(user, courselike, courselike.id)
if staff_access:
return staff_access
else:
return open_for_learner
view_with_prereqs = _can_view_courseware_with_prerequisites(user, courselike)
if not view_with_prereqs:
staff_access = _has_staff_access_to_descriptor(user, courselike, courselike.id)
if staff_access:
return staff_access
else:
return view_with_prereqs
if CONTENT_TYPE_GATING_FLAG.is_enabled():
has_not_expired = check_course_expired(user, courselike)
if not has_not_expired:
staff_access = _has_staff_access_to_descriptor(user, courselike, courselike.id)
if staff_access:
return staff_access
else:
return has_not_expired
return ACCESS_GRANTED
def can_enroll():
"""

View File

@@ -9,7 +9,8 @@ from xmodule.course_metadata_utils import DEFAULT_START_DATE
class AccessResponse(object):
"""Class that represents a response from a has_access permission check."""
def __init__(self, has_access, error_code=None, developer_message=None, user_message=None, user_fragment=None):
def __init__(self, has_access, error_code=None, developer_message=None, user_message=None,
additional_context_user_message=None, user_fragment=None):
"""
Creates an AccessResponse object.
@@ -21,13 +22,16 @@ class AccessResponse(object):
to show the developer
user_message (String): optional - default is None. Message to
show the user
additional_context_user_message (String): optional - default is None. Message to
show the user when additional context like the course name is necessary
user_fragment (:py:class:`~web_fragments.fragment.Fragment`): optional -
An html fragment to display to the user if their access is denied.
An html fragment to display to the user if their access is denied
"""
self.has_access = has_access
self.error_code = error_code
self.developer_message = developer_message
self.user_message = user_message
self.additional_context_user_message = additional_context_user_message
self.user_fragment = user_fragment
if has_access:
assert error_code is None
@@ -58,15 +62,17 @@ class AccessResponse(object):
"error_code": self.error_code,
"developer_message": self.developer_message,
"user_message": self.user_message,
"additional_context_user_message": self.additional_context_user_message,
"user_fragment": self.user_fragment,
}
def __repr__(self):
return "AccessResponse({!r}, {!r}, {!r}, {!r}, {!r})".format(
return "AccessResponse({!r}, {!r}, {!r}, {!r}, {!r}, {!r})".format(
self.has_access,
self.error_code,
self.developer_message,
self.user_message,
self.additional_context_user_message,
self.user_fragment,
)
@@ -79,6 +85,7 @@ class AccessResponse(object):
self.error_code == other.error_code and
self.developer_message == other.developer_message and
self.user_message == other.user_message and
self.additional_context_user_message == other.additional_context_user_message and
self.user_fragment == other.user_fragment
)
@@ -89,7 +96,8 @@ class AccessError(AccessResponse):
denial in has_access. Contains the error code, user and developer
messages. Subclasses represent specific errors.
"""
def __init__(self, error_code, developer_message, user_message, user_fragment=None):
def __init__(self, error_code, developer_message, user_message,
additional_context_user_message=None, user_fragment=None):
"""
Creates an AccessError object.
@@ -100,10 +108,12 @@ class AccessError(AccessResponse):
error_code (String): unique identifier for the specific type of
error developer_message (String): message to show the developer
user_message (String): message to show the user
additional_context_user_message (String): message to show user with additional context like the course name
user_fragment (:py:class:`~web_fragments.fragment.Fragment`): HTML to show the user
"""
super(AccessError, self).__init__(False, error_code, developer_message, user_message, user_fragment)
super(AccessError, self).__init__(False, error_code, developer_message, user_message,
additional_context_user_message, user_fragment)
class StartDateError(AccessError):

View File

@@ -8,6 +8,7 @@ from datetime import datetime
import branding
import pytz
from openedx.features.course_duration_limits.access import AuditExpiredError
from courseware.access import has_access
from courseware.access_response import StartDateError, MilestoneAccessError
from courseware.date_summary import (
@@ -143,6 +144,15 @@ def check_course_access(course, user, action, check_if_enrolled=False, check_sur
params=params.urlencode()
), access_response)
# Redirect if AuditExpiredError
if isinstance(access_response, AuditExpiredError):
params = QueryDict(mutable=True)
params['access_response_error'] = access_response.additional_context_user_message
raise CourseAccessRedirect('{dashboard_url}?{params}'.format(
dashboard_url=reverse('dashboard'),
params=params.urlencode()
), access_response)
# Redirect if the user must answer a survey before entering the course.
if isinstance(access_response, MilestoneAccessError):
raise CourseAccessRedirect('{dashboard_url}'.format(

View File

@@ -29,8 +29,9 @@ from courseware.tests.factories import (
from courseware.tests.helpers import LoginEnrollmentTestCase, masquerade_as_group_member
from lms.djangoapps.ccx.models import CustomCourseForEdX
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
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.core.lib.tests import attr
from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_FLAG
from student.models import CourseEnrollment
from student.roles import CourseCcxCoachRole, CourseStaffRole
from student.tests.factories import (
@@ -826,6 +827,7 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase):
)
@ddt.unpack
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False})
@override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
def test_course_catalog_access_num_queries(self, user_attr_name, action, course_attr_name):
course = getattr(self, course_attr_name)
@@ -836,15 +838,15 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase):
user = getattr(self, user_attr_name)
user = User.objects.get(id=user.id)
if (user_attr_name == 'user_staff' and
action == 'see_exists' and
course_attr_name in
['course_default', 'course_not_started']):
if user_attr_name == 'user_staff' and action == 'see_exists':
# checks staff role
num_queries = 1
elif user_attr_name == 'user_normal' and action == 'see_exists' and course_attr_name != 'course_started':
# checks staff role and enrollment data
num_queries = 2
elif user_attr_name == 'user_normal' and action == 'see_exists':
if course_attr_name == 'course_started':
num_queries = 1
else:
# checks staff role and enrollment data
num_queries = 2
else:
num_queries = 0

View File

@@ -15,6 +15,7 @@ from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES, override_waffle_flag
from openedx.core.lib.tests import attr
from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_FLAG
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
from pyquery import PyQuery as pq
@@ -430,8 +431,10 @@ class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
@override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
def test_num_queries_instructor_paced(self):
self.fetch_course_info_with_queries(self.instructor_paced_course, 28, 3)
self.fetch_course_info_with_queries(self.instructor_paced_course, 29, 3)
@override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
def test_num_queries_self_paced(self):
self.fetch_course_info_with_queries(self.self_paced_course, 28, 3)
self.fetch_course_info_with_queries(self.self_paced_course, 29, 3)

View File

@@ -64,6 +64,7 @@ from openedx.core.djangolib.testing.utils import get_mock_request
from openedx.core.lib.gating import api as gating_api
from openedx.core.lib.tests import attr
from openedx.core.lib.url_utils import quote_slashes
from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_FLAG
from openedx.features.course_experience import COURSE_OUTLINE_PAGE_FLAG, UNIFIED_COURSE_TAB_FLAG
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
from student.models import CourseEnrollment
@@ -204,9 +205,10 @@ class IndexQueryTestCase(ModuleStoreTestCase):
CREATE_USER = False
NUM_PROBLEMS = 20
@override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
@ddt.data(
(ModuleStoreEnum.Type.mongo, 10, 147),
(ModuleStoreEnum.Type.split, 4, 147),
(ModuleStoreEnum.Type.mongo, 10, 157),
(ModuleStoreEnum.Type.split, 4, 153),
)
@ddt.unpack
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
@@ -1429,9 +1431,10 @@ class ProgressPageTests(ProgressPageBaseTests):
resp = self._get_progress_page()
self.assertContains(resp, u"Download Your Certificate")
@override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
@ddt.data(
(True, 38),
(False, 37)
(True, 40),
(False, 39)
)
@ddt.unpack
def test_progress_queries_paced_courses(self, self_paced, query_count):
@@ -1440,10 +1443,11 @@ class ProgressPageTests(ProgressPageBaseTests):
with self.assertNumQueries(query_count, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1):
self._get_progress_page()
@override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
@patch.dict(settings.FEATURES, {'ASSUME_ZERO_GRADE_IF_ABSENT_FOR_ALL_TESTS': False})
@ddt.data(
(False, 45, 28),
(True, 37, 24)
(False, 47, 30),
(True, 39, 26)
)
@ddt.unpack
def test_progress_queries(self, enable_waffle, initial, subsequent):

View File

@@ -45,7 +45,8 @@ from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase
from openedx.core.djangoapps.util.testing import ContentGroupTestCase
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.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
from student.roles import CourseStaffRole, UserBasedRole
from student.tests.factories import CourseEnrollmentFactory, UserFactory
@@ -423,6 +424,7 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase):
def setUp(self):
super(SingleThreadQueryCountTestCase, self).setUp()
@override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
@ddt.data(
# Old mongo with cache. There is an additional SQL query for old mongo
# because the first time that disabled_xblocks is queried is in call_single_thread,
@@ -430,18 +432,18 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase):
# course is outside the context manager that is verifying the number of queries,
# and with split mongo, that method ends up querying disabled_xblocks (which is then
# cached and hence not queried as part of call_single_thread).
(ModuleStoreEnum.Type.mongo, False, 1, 5, 2, 17, 5),
(ModuleStoreEnum.Type.mongo, False, 50, 5, 2, 17, 5),
(ModuleStoreEnum.Type.mongo, False, 1, 5, 2, 19, 7),
(ModuleStoreEnum.Type.mongo, False, 50, 5, 2, 19, 7),
# split mongo: 3 queries, regardless of thread response size.
(ModuleStoreEnum.Type.split, False, 1, 3, 3, 17, 5),
(ModuleStoreEnum.Type.split, False, 50, 3, 3, 17, 5),
(ModuleStoreEnum.Type.split, False, 1, 3, 3, 19, 7),
(ModuleStoreEnum.Type.split, False, 50, 3, 3, 19, 7),
# Enabling Enterprise integration should have no effect on the number of mongo queries made.
(ModuleStoreEnum.Type.mongo, True, 1, 5, 2, 17, 5),
(ModuleStoreEnum.Type.mongo, True, 50, 5, 2, 17, 5),
(ModuleStoreEnum.Type.mongo, True, 1, 5, 2, 19, 7),
(ModuleStoreEnum.Type.mongo, True, 50, 5, 2, 19, 7),
# split mongo: 3 queries, regardless of thread response size.
(ModuleStoreEnum.Type.split, True, 1, 3, 3, 17, 5),
(ModuleStoreEnum.Type.split, True, 50, 3, 3, 17, 5),
(ModuleStoreEnum.Type.split, True, 1, 3, 3, 19, 7),
(ModuleStoreEnum.Type.split, True, 50, 3, 3, 19, 7),
)
@ddt.unpack
def test_number_of_mongo_queries(

View File

@@ -37,8 +37,9 @@ from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMe
from lms.lib.comment_client import Thread
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
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.core.lib.tests import attr
from openedx.features.course_duration_limits.config import CONTENT_TYPE_GATING_FLAG
from student.roles import CourseStaffRole, UserBasedRole
from student.tests.factories import CourseAccessRoleFactory, CourseEnrollmentFactory, UserFactory
from util.testing import UrlResetMixin
@@ -402,18 +403,20 @@ class ViewsQueryCountTestCase(
func(self, *args, **kwargs)
return inner
@override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
@ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 4, 35),
(ModuleStoreEnum.Type.split, 3, 13, 35),
(ModuleStoreEnum.Type.mongo, 3, 4, 37),
(ModuleStoreEnum.Type.split, 3, 13, 37),
)
@ddt.unpack
@count_queries
def test_create_thread(self, mock_request):
self.create_thread_helper(mock_request)
@override_waffle_flag(CONTENT_TYPE_GATING_FLAG, True)
@ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 3, 31),
(ModuleStoreEnum.Type.split, 3, 10, 31),
(ModuleStoreEnum.Type.mongo, 3, 3, 33),
(ModuleStoreEnum.Type.split, 3, 10, 33),
)
@ddt.unpack
@count_queries

View File

@@ -2286,6 +2286,7 @@ INSTALLED_APPS = [
'openedx.features.learner_profile',
'openedx.features.learner_analytics',
'openedx.features.portfolio_project',
'openedx.features.course_duration_limits',
'experiments',

View File

@@ -33,6 +33,8 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
if cert_name_long == "":
cert_name_long = settings.CERT_NAME_LONG
billing_email = settings.PAYMENT_SUPPORT_EMAIL
is_course_expired = hasattr(show_courseware_link, 'error_code') and show_courseware_link.error_code == 'audit_expired'
%>
<%namespace name='static' file='../static_content.html'/>
@@ -65,8 +67,8 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
<h2 class="hd hd-2 sr" id="details-heading-${enrollment.course_id}">${_('Course details')}</h2>
<div class="wrapper-course-image" aria-hidden="true">
% if show_courseware_link and not is_unfulfilled_entitlement:
% if not is_course_blocked:
<a href="${course_target}" data-course-key="${enrollment.course_id}" class="cover" tabindex="-1">
% if not is_course_blocked and not is_course_expired:
<a href="${course_target}" data-course-key="${enrollment.course_id}" class="cover course-target-link" tabindex="-1">
<img src="${course_overview.image_urls['small']}" class="course-image" alt="${_('{course_number} {course_name} Home Page').format(course_number=course_overview.number, course_name=course_overview.display_name_with_default)}" />
</a>
% else:
@@ -92,8 +94,8 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
<div class="wrapper-course-details">
<h3 class="course-title" id="course-title-${enrollment.course_id}">
% if show_courseware_link and not is_unfulfilled_entitlement:
% if not is_course_blocked:
<a data-course-key="${enrollment.course_id}" href="${course_target}">${course_overview.display_name_with_default}</a>
% if not is_course_blocked and not is_course_expired:
<a data-course-key="${enrollment.course_id}" href="${course_target}" class="course-target-link">${course_overview.display_name_with_default}</a>
% else:
<a class="disable-look" data-course-key="${enrollment.course_id}">${course_overview.display_name_with_default}</a>
% endif
@@ -128,7 +130,14 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
%>
<span class="info-date-block-container">
% if is_unfulfilled_entitlement:
% if not is_unfulfilled_entitlement and is_course_expired:
<span class="info-date-block" data-course-key="${enrollment.course_id}">
${show_courseware_link.user_message}
<span class="sr">
&nbsp;${_('for {course_display_name}').format(course_display_name=course_overview.display_name_with_default)}
</span>
</span>
% elif is_unfulfilled_entitlement:
<span class="info-date-block" aria-live="polite">
<span class="icon fa fa-warning" aria-hidden="true"></span>
% if not entitlement_expired_at:
@@ -165,18 +174,18 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
</div>
<div class="wrapper-course-actions">
<div class="course-actions">
% 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:
<a href="${course_target}" class="enter-course archived" data-course-key="${enrollment.course_id}">${_('View Archived Course')}<span class="sr">&nbsp;${course_overview.display_name_with_default}</span></a>
<a href="${course_target}" class="enter-course archived course-target-link" data-course-key="${enrollment.course_id}">${_('View Archived Course')}<span class="sr">&nbsp;${course_overview.display_name_with_default}</span></a>
% else:
<a class="enter-course-blocked archived" data-course-key="${enrollment.course_id}">${_('View Archived Course')}<span class="sr">&nbsp;${course_overview.display_name_with_default}</span></a>
<a class="enter-course-blocked archived course-target-link" data-course-key="${enrollment.course_id}">${_('View Archived Course')}<span class="sr">&nbsp;${course_overview.display_name_with_default}</span></a>
% endif
% else:
% if resume_button_url != '':
<a href="${resume_button_url}"
class="enter-course ${'hidden' if is_unfulfilled_entitlement else ''}"
class="course-target-link enter-course ${'hidden' if is_unfulfilled_entitlement else ''}"
data-course-key="${enrollment.course_id}">
${_('Resume Course')}
<span class="sr">
@@ -185,7 +194,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
</a>
% elif not is_course_blocked:
<a href="${course_target}"
class="enter-course ${'hidden' if is_unfulfilled_entitlement else ''}"
class="course-target-link enter-course ${'hidden' if is_unfulfilled_entitlement else ''}"
data-course-key="${enrollment.course_id}">
${_('View Course')}
<span class="sr">
@@ -202,14 +211,6 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
</a>
% endif
% endif
% elif hasattr(show_courseware_link, 'user_message'):
<span class="enter-course-blocked"
data-course-key="${enrollment.course_id}">
${show_courseware_link.user_message}
<span class="sr">
&nbsp;${_('for {course_display_name}').format(course_display_name=course_overview.display_name_with_default)}
</span>
</span>
% endif
% if show_courseware_link or course_overview.has_social_sharing_url() or course_overview.has_marketing_url():

View File

@@ -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

View File

@@ -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'

View File

@@ -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
)

View File

@@ -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):

View File

@@ -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)