Merge pull request #19095 from edx/expired_dashboard_message
Add course duration limit and dashboard expiration code
This commit is contained in:
@@ -1161,6 +1161,8 @@ INSTALLED_APPS = [
|
||||
|
||||
# API Documentation
|
||||
'rest_framework_swagger',
|
||||
|
||||
'openedx.features.course_duration_limits',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2286,6 +2286,7 @@ INSTALLED_APPS = [
|
||||
'openedx.features.learner_profile',
|
||||
'openedx.features.learner_analytics',
|
||||
'openedx.features.portfolio_project',
|
||||
'openedx.features.course_duration_limits',
|
||||
|
||||
'experiments',
|
||||
|
||||
|
||||
@@ -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">
|
||||
${_('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"> ${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"> ${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"> ${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"> ${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">
|
||||
${_('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():
|
||||
|
||||
0
openedx/features/course_duration_limits/__init__.py
Normal file
0
openedx/features/course_duration_limits/__init__.py
Normal file
75
openedx/features/course_duration_limits/access.py
Normal file
75
openedx/features/course_duration_limits/access.py
Normal 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
|
||||
11
openedx/features/course_duration_limits/apps.py
Normal file
11
openedx/features/course_duration_limits/apps.py
Normal 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'
|
||||
13
openedx/features/course_duration_limits/config.py
Normal file
13
openedx/features/course_duration_limits/config.py
Normal 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
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user