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

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