Commit afa77db9 by Kyle McCormick

Merge pull request #8642 from edx/mekkz/course-overviews-dashboard

Update LMS student dashboard to use CourseOverview
parents 7b33b73a 1dfe9ed9
......@@ -23,8 +23,9 @@ VERIFY_STATUS_MISSED_DEADLINE = "verify_missed_deadline"
VERIFY_STATUS_NEED_TO_REVERIFY = "verify_need_to_reverify"
def check_verify_status_by_course(user, course_enrollment_pairs, all_course_modes):
"""Determine the per-course verification statuses for a given user.
def check_verify_status_by_course(user, course_enrollments, all_course_modes):
"""
Determine the per-course verification statuses for a given user.
The possible statuses are:
* VERIFY_STATUS_NEED_TO_VERIFY: The student has not yet submitted photos for verification.
......@@ -46,8 +47,7 @@ def check_verify_status_by_course(user, course_enrollment_pairs, all_course_mode
Arguments:
user (User): The currently logged-in user.
course_enrollment_pairs (list): The courses the user is enrolled in.
The list should contain tuples of `(Course, CourseEnrollment)`.
course_enrollments (list[CourseEnrollment]): The courses the user is enrolled in.
all_course_modes (list): List of all course modes for the student's enrolled courses,
including modes that have expired.
......@@ -75,15 +75,15 @@ def check_verify_status_by_course(user, course_enrollment_pairs, all_course_mode
recent_verification_datetime = None
for course, enrollment in course_enrollment_pairs:
for enrollment in course_enrollments:
# Get the verified mode (if any) for this course
# We pass in the course modes we have already loaded to avoid
# another database hit, as well as to ensure that expired
# course modes are included in the search.
verified_mode = CourseMode.verified_mode_for_course(
course.id,
modes=all_course_modes[course.id]
enrollment.course_id,
modes=all_course_modes[enrollment.course_id]
)
# If no verified mode has ever been offered, or the user hasn't enrolled
......@@ -156,7 +156,7 @@ def check_verify_status_by_course(user, course_enrollment_pairs, all_course_mode
if deadline is not None and deadline > now:
days_until_deadline = (deadline - now).days
status_by_course[course.id] = {
status_by_course[enrollment.course_id] = {
'status': status,
'days_until_deadline': days_until_deadline
}
......
......@@ -850,6 +850,13 @@ class CourseEnrollment(models.Model):
unique_together = (('user', 'course_id'),)
ordering = ('user', 'course_id')
def __init__(self, *args, **kwargs):
super(CourseEnrollment, self).__init__(*args, **kwargs)
# Private variable for storing course_overview to minimize calls to the database.
# When the property .course_overview is accessed for the first time, this variable will be set.
self._course_overview = None
def __unicode__(self):
return (
"[CourseEnrollment] {}: {} ({}); active: ({})"
......@@ -1318,10 +1325,21 @@ class CourseEnrollment(models.Model):
@property
def course_overview(self):
"""
Return a CourseOverview of this enrollment's course.
"""
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
return CourseOverview.get_from_id(self.course_id)
Returns a CourseOverview of the course to which this enrollment refers.
Returns None if an error occurred while trying to load the course.
Note:
If the course is re-published within the lifetime of this
CourseEnrollment object, then the value of this property will
become stale.
"""
if not self._course_overview:
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
try:
self._course_overview = CourseOverview.get_from_id(self.course_id)
except (CourseOverview.DoesNotExist, IOError):
self._course_overview = None
return self._course_overview
def is_verified_enrollment(self):
"""
......
......@@ -14,7 +14,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.error_module import ErrorDescriptor
from django.test.client import Client
from student.models import CourseEnrollment
from student.views import get_course_enrollment_pairs
from student.views import get_course_enrollments
from util.milestones_helpers import (
get_pre_requisite_courses_not_completed,
set_prerequisite_courses,
......@@ -73,13 +73,13 @@ class TestCourseListing(ModuleStoreTestCase):
self._create_course_with_access_groups(course_location)
# get dashboard
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 1)
self.assertEqual(courses_list[0][0].id, course_location)
self.assertEqual(courses_list[0].course_id, course_location)
CourseEnrollment.unenroll(self.student, course_location)
# get dashboard
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 0)
def test_errored_course_regular_access(self):
......@@ -95,7 +95,7 @@ class TestCourseListing(ModuleStoreTestCase):
self.assertIsInstance(modulestore().get_course(course_key), ErrorDescriptor)
# get courses through iterating all courses
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(courses_list, [])
def test_course_listing_errored_deleted_courses(self):
......@@ -112,9 +112,9 @@ class TestCourseListing(ModuleStoreTestCase):
self._create_course_with_access_groups(course_location, default_store=ModuleStoreEnum.Type.mongo)
mongo_store.delete_course(course_location, ModuleStoreEnum.UserID.test)
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 1, courses_list)
self.assertEqual(courses_list[0][0].id, good_location)
self.assertEqual(courses_list[0].course_id, good_location)
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_course_listing_has_pre_requisite_courses(self):
......@@ -142,9 +142,11 @@ class TestCourseListing(ModuleStoreTestCase):
set_prerequisite_courses(course_location, pre_requisite_courses)
# get dashboard
course_enrollment_pairs = list(get_course_enrollment_pairs(self.student, None, []))
courses_having_prerequisites = frozenset(course.id for course, _enrollment in course_enrollment_pairs
if course.pre_requisite_courses)
course_enrollments = list(get_course_enrollments(self.student, None, []))
courses_having_prerequisites = frozenset(
enrollment.course_id for enrollment in course_enrollments
if enrollment.course_overview.pre_requisite_courses
)
courses_requirements_not_met = get_pre_requisite_courses_not_completed(
self.student,
courses_having_prerequisites
......
......@@ -15,7 +15,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from course_modes.tests.factories import CourseModeFactory
from student.models import CourseEnrollment, DashboardConfiguration
from student.views import get_course_enrollment_pairs, _get_recently_enrolled_courses
from student.views import get_course_enrollments, _get_recently_enrolled_courses # pylint: disable=protected-access
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
......@@ -67,7 +67,7 @@ class TestRecentEnrollments(ModuleStoreTestCase):
self._configure_message_timeout(60)
# get courses through iterating all courses
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 2)
recent_course_list = _get_recently_enrolled_courses(courses_list)
......@@ -78,7 +78,7 @@ class TestRecentEnrollments(ModuleStoreTestCase):
Tests that the recent enrollment list is empty if configured to zero seconds.
"""
self._configure_message_timeout(0)
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 2)
recent_course_list = _get_recently_enrolled_courses(courses_list)
......@@ -106,16 +106,16 @@ class TestRecentEnrollments(ModuleStoreTestCase):
enrollment.save()
courses.append(course)
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 6)
recent_course_list = _get_recently_enrolled_courses(courses_list)
self.assertEqual(len(recent_course_list), 5)
self.assertEqual(recent_course_list[1][0], courses[0])
self.assertEqual(recent_course_list[2][0], courses[1])
self.assertEqual(recent_course_list[3][0], courses[2])
self.assertEqual(recent_course_list[4][0], courses[3])
self.assertEqual(recent_course_list[1].course, courses[0])
self.assertEqual(recent_course_list[2].course, courses[1])
self.assertEqual(recent_course_list[3].course, courses[2])
self.assertEqual(recent_course_list[4].course, courses[3])
def test_dashboard_rendering(self):
"""
......
# -*- coding: utf-8 -*-
"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".
Replace this with more appropriate tests for your application.
Miscellaneous tests for the student app.
"""
from datetime import datetime, timedelta
import logging
......@@ -28,8 +25,8 @@ from student.views import (process_survey_link, _cert_info,
from student.tests.factories import UserFactory, CourseModeFactory
from util.testing import EventTestMixin
from util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ModuleStoreEnum
# These imports refer to lms djangoapps.
# Their testcases are only run under lms.
......@@ -193,6 +190,7 @@ class CourseEndingTest(TestCase):
self.assertIsNone(_cert_info(user, course2, cert_status, course_mode))
@ddt.ddt
class DashboardTest(ModuleStoreTestCase):
"""
Tests for dashboard utility functions
......@@ -487,6 +485,48 @@ class DashboardTest(ModuleStoreTestCase):
)
self.assertContains(response, expected_url)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.data((ModuleStoreEnum.Type.mongo, 1), (ModuleStoreEnum.Type.split, 3))
@ddt.unpack
def test_dashboard_metadata_caching(self, modulestore_type, expected_mongo_calls):
"""
Check that the student dashboard makes use of course metadata caching.
The first time the student dashboard displays a specific course, it will
make a call to the module store. After that first request, though, the
course's metadata should be cached as a CourseOverview.
Arguments:
modulestore_type (ModuleStoreEnum.Type): Type of modulestore to create
test course in.
expected_mongo_calls (int >=0): Number of MongoDB queries expected for
a single call to the module store.
Note to future developers:
If you break this test so that the "check_mongo_calls(0)" fails,
please do NOT change it to "check_mongo_calls(n>1)". Instead, change
your code to not load courses from the module store. This may
involve adding fields to CourseOverview so that loading a full
CourseDescriptor isn't necessary.
"""
# Create a course, log in the user, and enroll them in the course.
test_course = CourseFactory.create(default_store=modulestore_type)
self.client.login(username="jack", password="test")
CourseEnrollment.enroll(self.user, test_course.id)
# The first request will result in a modulestore query.
with check_mongo_calls(expected_mongo_calls):
response_1 = self.client.get(reverse('dashboard'))
self.assertEquals(response_1.status_code, 200)
# Subsequent requests will only result in SQL queries to load the
# CourseOverview object that has been created.
with check_mongo_calls(0):
response_2 = self.client.get(reverse('dashboard'))
self.assertEquals(response_2.status_code, 200)
response_3 = self.client.get(reverse('dashboard'))
self.assertEquals(response_3.status_code, 200)
class UserSettingsEventTestMixin(EventTestMixin):
"""
......
......@@ -9,6 +9,7 @@ from django.utils.translation import ugettext as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from xmodule.modulestore.django import modulestore
NAMESPACE_CHOICES = {
......@@ -86,33 +87,46 @@ def set_prerequisite_courses(course_key, prerequisite_course_keys):
add_prerequisite_course(course_key, prerequisite_course_key)
def get_pre_requisite_courses_not_completed(user, enrolled_courses):
def get_pre_requisite_courses_not_completed(user, enrolled_courses): # pylint: disable=invalid-name
"""
It would make dict of prerequisite courses not completed by user among courses
user has enrolled in. It calls the fulfilment api of milestones app and
iterates over all fulfilment milestones not achieved to make dict of
prerequisite courses yet to be completed.
Makes a dict mapping courses to their unfulfilled milestones using the
fulfillment API of the milestones app.
Arguments:
user (User): the user for whom we are checking prerequisites.
enrolled_courses (CourseKey): a list of keys for the courses to be
checked. The given user must be enrolled in all of these courses.
Returns:
dict[CourseKey: dict[
'courses': list[dict['key': CourseKey, 'display': str]]
]]
If a course has no incomplete prerequisites, it will be excluded from the
dictionary.
"""
if not settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False):
return {}
from milestones import api as milestones_api
pre_requisite_courses = {}
if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False):
from milestones import api as milestones_api
for course_key in enrolled_courses:
required_courses = []
fulfilment_paths = milestones_api.get_course_milestones_fulfillment_paths(course_key, {'id': user.id})
for milestone_key, milestone_value in fulfilment_paths.items(): # pylint: disable=unused-variable
for key, value in milestone_value.items():
if key == 'courses' and value:
for required_course in value:
required_course_key = CourseKey.from_string(required_course)
required_course_descriptor = modulestore().get_course(required_course_key)
required_courses.append({
'key': required_course_key,
'display': get_course_display_name(required_course_descriptor)
})
# if there are required courses add to dict
if required_courses:
pre_requisite_courses[course_key] = {'courses': required_courses}
for course_key in enrolled_courses:
required_courses = []
fulfillment_paths = milestones_api.get_course_milestones_fulfillment_paths(course_key, {'id': user.id})
for __, milestone_value in fulfillment_paths.items():
for key, value in milestone_value.items():
if key == 'courses' and value:
for required_course in value:
required_course_key = CourseKey.from_string(required_course)
required_course_overview = CourseOverview.get_from_id(required_course_key)
required_courses.append({
'key': required_course_key,
'display': get_course_display_string(required_course_overview)
})
# If there are required courses, add them to the result dict.
if required_courses:
pre_requisite_courses[course_key] = {'courses': required_courses}
return pre_requisite_courses
......@@ -129,15 +143,18 @@ def get_prerequisite_courses_display(course_descriptor):
required_course_descriptor = modulestore().get_course(course_key)
prc = {
'key': course_key,
'display': get_course_display_name(required_course_descriptor)
'display': get_course_display_string(required_course_descriptor)
}
pre_requisite_courses.append(prc)
return pre_requisite_courses
def get_course_display_name(descriptor):
def get_course_display_string(descriptor):
"""
It would return display name from given course descriptor
Returns a string to display for a course or course overview.
Arguments:
descriptor (CourseDescriptor|CourseOverview): a course or course overview.
"""
return ' '.join([
descriptor.display_org_with_default,
......
......@@ -12,6 +12,7 @@ from django.core.urlresolvers import reverse
from eventtracking import tracker
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from xmodule.modulestore.django import modulestore
from certificates.models import (
......@@ -216,13 +217,18 @@ def generate_example_certificates(course_key):
def has_html_certificates_enabled(course_key, course=None):
"""
It determines if course has html certificates enabled
Determine if a course has html certificates enabled.
Arguments:
course_key (CourseKey|str): A course key or a string representation
of one.
course (CourseDescriptor|CourseOverview): A course.
"""
html_certificates_enabled = False
try:
if not isinstance(course_key, CourseKey):
course_key = CourseKey.from_string(course_key)
course = course if course else modulestore().get_course(course_key, depth=0)
course = course if course else CourseOverview.get_from_id(course_key)
if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False) and course.cert_html_view_enabled:
html_certificates_enabled = True
except: # pylint: disable=bare-except
......
......@@ -152,6 +152,16 @@ def find_file(filesystem, dirs, filename):
raise ResourceNotFoundError(u"Could not find {0}".format(filename))
def get_course_university_about_section(course): # pylint: disable=invalid-name
"""
Returns a snippet of HTML displaying the course's university.
Arguments:
course (CourseDescriptor|CourseOverview): A course.
"""
return course.display_org_with_default
def get_course_about_section(course, section_key):
"""
This returns the snippet of html to be rendered on the course about page,
......@@ -227,7 +237,7 @@ def get_course_about_section(course, section_key):
elif section_key == "title":
return course.display_name_with_default
elif section_key == "university":
return course.display_org_with_default
return get_course_university_about_section(course)
elif section_key == "number":
return course.display_number_with_default
......
......@@ -5,6 +5,8 @@ from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.client import RequestFactory
from courseware.access import has_access, COURSE_OVERVIEW_SUPPORTED_ACTIONS
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.models import Registration
......@@ -137,3 +139,50 @@ class LoginEnrollmentTestCase(TestCase):
'course_id': course.id.to_deprecated_string(),
}
self.assert_request_status_code(200, url, method="POST", data=request_data)
class CourseAccessTestMixin(TestCase):
"""
Utility mixin for asserting access (or lack thereof) to courses.
If relevant, also checks access for courses' corresponding CourseOverviews.
"""
def assertCanAccessCourse(self, user, action, course):
"""
Assert that a user has access to the given action for a given course.
Test with both the given course and, if the action is supported, with
a CourseOverview of the given course.
Arguments:
user (User): a user.
action (str): type of access to test.
See access.py:COURSE_OVERVIEW_SUPPORTED_ACTIONS.
course (CourseDescriptor): a course.
"""
self.assertTrue(has_access(user, action, course))
if action in COURSE_OVERVIEW_SUPPORTED_ACTIONS:
self.assertTrue(has_access(user, action, CourseOverview.get_from_id(course.id)))
def assertCannotAccessCourse(self, user, action, course):
"""
Assert that a user lacks access to the given action the given course.
Test with both the given course and, if the action is supported, with
a CourseOverview of the given course.
Arguments:
user (User): a user.
action (str): type of access to test.
See access.py:COURSE_OVERVIEW_SUPPORTED_ACTIONS.
course (CourseDescriptor): a course.
Note:
It may seem redundant to have one method for testing access
and another method for testing lack thereof (why not just combine
them into one method with a boolean flag?), but it makes reading
stack traces of failed tests easier to understand at a glance.
"""
self.assertFalse(has_access(user, action, course))
if action in COURSE_OVERVIEW_SUPPORTED_ACTIONS:
self.assertFalse(has_access(user, action, CourseOverview.get_from_id(course.id)))
import datetime
import ddt
import itertools
import pytz
from django.test import TestCase
......@@ -9,8 +11,9 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
import courseware.access as access
from courseware.masquerade import CourseMasquerade
from courseware.tests.factories import UserFactory, StaffFactory, InstructorFactory
from courseware.tests.factories import UserFactory, StaffFactory, InstructorFactory, BetaTesterFactory
from courseware.tests.helpers import LoginEnrollmentTestCase
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.tests.factories import AnonymousUserFactory, CourseEnrollmentAllowedFactory, CourseEnrollmentFactory
from xmodule.course_module import (
CATALOG_VISIBILITY_CATALOG_AND_ABOUT, CATALOG_VISIBILITY_ABOUT,
......@@ -18,6 +21,7 @@ from xmodule.course_module import (
)
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from util.milestones_helpers import fulfill_course_milestone
from util.milestones_helpers import (
set_prerequisite_courses,
......@@ -424,3 +428,91 @@ class UserRoleTestCase(TestCase):
'student',
access.get_user_role(self.anonymous_user, self.course_key)
)
@ddt.ddt
class CourseOverviewAccessTestCase(ModuleStoreTestCase):
"""
Tests confirming that has_access works equally on CourseDescriptors and
CourseOverviews.
"""
def setUp(self):
super(CourseOverviewAccessTestCase, self).setUp()
today = datetime.datetime.now(pytz.UTC)
last_week = today - datetime.timedelta(days=7)
next_week = today + datetime.timedelta(days=7)
self.course_default = CourseFactory.create()
self.course_started = CourseFactory.create(start=last_week)
self.course_not_started = CourseFactory.create(start=next_week, days_early_for_beta=10)
self.course_staff_only = CourseFactory.create(visible_to_staff_only=True)
self.course_mobile_available = CourseFactory.create(mobile_available=True)
self.course_with_pre_requisite = CourseFactory.create(
pre_requisite_courses=[str(self.course_started.id)]
)
self.course_with_pre_requisites = CourseFactory.create(
pre_requisite_courses=[str(self.course_started.id), str(self.course_not_started.id)]
)
self.user_normal = UserFactory.create()
self.user_beta_tester = BetaTesterFactory.create(course_key=self.course_not_started.id)
self.user_completed_pre_requisite = UserFactory.create() # pylint: disable=invalid-name
fulfill_course_milestone(self.user_completed_pre_requisite, self.course_started.id)
self.user_staff = UserFactory.create(is_staff=True)
self.user_anonymous = AnonymousUserFactory.create()
LOAD_TEST_DATA = list(itertools.product(
['user_normal', 'user_beta_tester', 'user_staff'],
['load'],
['course_default', 'course_started', 'course_not_started', 'course_staff_only'],
))
LOAD_MOBILE_TEST_DATA = list(itertools.product(
['user_normal', 'user_staff'],
['load_mobile'],
['course_default', 'course_mobile_available'],
))
PREREQUISITES_TEST_DATA = list(itertools.product(
['user_normal', 'user_completed_pre_requisite', 'user_staff', 'user_anonymous'],
['view_courseware_with_prerequisites'],
['course_default', 'course_with_pre_requisite', 'course_with_pre_requisites'],
))
@ddt.data(*(LOAD_TEST_DATA + LOAD_MOBILE_TEST_DATA + PREREQUISITES_TEST_DATA))
@ddt.unpack
def test_course_overview_access(self, user_attr_name, action, course_attr_name):
"""
Check that a user's access to a course is equal to the user's access to
the corresponding course overview.
Instead of taking a user and course directly as arguments, we have to
take their attribute names, as ddt doesn't allow us to reference self.
Arguments:
user_attr_name (str): the name of the attribute on self that is the
User to test with.
action (str): action to test with.
See COURSE_OVERVIEW_SUPPORTED_ACTIONS for valid values.
course_attr_name (str): the name of the attribute on self that is
the CourseDescriptor to test with.
"""
user = getattr(self, user_attr_name)
course = getattr(self, course_attr_name)
course_overview = CourseOverview.get_from_id(course.id)
self.assertEqual(
access.has_access(user, action, course, course_key=course.id),
access.has_access(user, action, course_overview, course_key=course.id)
)
def test_course_overivew_unsupported_action(self):
"""
Check that calling has_access with an unsupported action raises a
ValueError.
"""
overview = CourseOverview.get_from_id(self.course_default.id)
with self.assertRaises(ValueError):
access.has_access(self.user, '_non_existent_action', overview)
......@@ -6,7 +6,7 @@ from mock import patch
from nose.plugins.attrib import attr
from courseware.access import has_access
from courseware.tests.helpers import LoginEnrollmentTestCase
from courseware.tests.helpers import CourseAccessTestMixin, LoginEnrollmentTestCase
from courseware.tests.factories import (
BetaTesterFactory,
StaffFactory,
......@@ -389,7 +389,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase):
@attr('shard_1')
class TestBetatesterAccess(ModuleStoreTestCase):
class TestBetatesterAccess(ModuleStoreTestCase, CourseAccessTestMixin):
"""
Tests for the beta tester feature
"""
......@@ -411,12 +411,8 @@ class TestBetatesterAccess(ModuleStoreTestCase):
Check that beta-test access works for courses.
"""
self.assertFalse(self.course.has_started())
# student user shouldn't see it
self.assertFalse(has_access(self.normal_student, 'load', self.course))
# now the student should see it
self.assertTrue(has_access(self.beta_tester, 'load', self.course))
self.assertCannotAccessCourse(self.normal_student, 'load', self.course)
self.assertCanAccessCourse(self.beta_tester, 'load', self.course)
@patch.dict('courseware.access.settings.FEATURES', {'DISABLE_START_DATES': False})
def test_content_beta_period(self):
......
<%page args="ccx, membership, course, show_courseware_link, is_course_blocked" />
<%page args="ccx, membership, course_overview, show_courseware_link, is_course_blocked" />
<%!
from django.utils.translation import ugettext as _
......@@ -7,7 +7,7 @@ from courseware.courses import course_image_url, get_course_about_section
from ccx_keys.locator import CCXLocator
%>
<%
ccx_target = reverse('info', args=[CCXLocator.from_course_locator(course.id, ccx.id)])
ccx_target = reverse('info', args=[CCXLocator.from_course_locator(course_overview.id, ccx.id)])
%>
<li class="course-item">
<article class="course">
......@@ -16,16 +16,16 @@ from ccx_keys.locator import CCXLocator
% if show_courseware_link:
% if not is_course_blocked:
<a href="${ccx_target}" class="cover">
<img src="${course_image_url(course)}" class="course-image" alt="${_('{course_number} {ccx_name} Cover Image').format(course_number=course.number, ccx_name=ccx.display_name) |h}" />
<img src="${course_overview.course_image_url}" class="course-image" alt="${_('{course_number} {ccx_name} Cover Image').format(course_number=course_overview.number, ccx_name=ccx.display_name) |h}" />
</a>
% else:
<a class="fade-cover">
<img src="${course_image_url(course)}" class="course-image" alt="${_('{course_number} {ccx_name} Cover Image').format(course_number=course.number, ccx_name=ccx.display_name) |h}" />
<img src="${course_overview.course_image_url}" class="course-image" alt="${_('{course_number} {ccx_name} Cover Image').format(course_number=course_overview.number, ccx_name=ccx.display_name) |h}" />
</a>
% endif
% else:
<a class="cover">
<img src="${course_image_url(course)}" class="course-image" alt="${_('{course_number} {ccx_name} Cover Image').format(course_number=course.number, ccx_name=ccx.display_name) |h}" />
<img src="${course_overview.course_image_url}" class="course-image" alt="${_('{course_number} {ccx_name} Cover Image').format(course_number=course_overview.number, ccx_name=ccx.display_name) |h}" />
</a>
% endif
</div>
......@@ -43,7 +43,7 @@ from ccx_keys.locator import CCXLocator
</h3>
<div class="course-info">
<span class="info-university">${get_course_about_section(course, 'university')} - </span>
<span class="info-course-id">${course.display_number_with_default | h}</span>
<span class="info-course-id">${course_overview.display_number_with_default | h}</span>
<span class="info-date-block" data-tooltip="Hi">
% if ccx.has_ended():
${_("Ended - {end_date}").format(end_date=ccx.end_datetime_text("SHORT_DATE"))}
......
......@@ -70,28 +70,28 @@ from django.core.urlresolvers import reverse
</header>
% if len(course_enrollment_pairs) > 0:
% if len(course_enrollments) > 0:
<ul class="listing-courses">
<% share_settings = settings.FEATURES.get('SOCIAL_SHARING_SETTINGS', {}) %>
% for dashboard_index, (course, enrollment) in enumerate(course_enrollment_pairs):
<% show_courseware_link = (course.id in show_courseware_links_for) %>
<% cert_status = cert_statuses.get(course.id) %>
<% credit_status = credit_statuses.get(course.id) %>
<% show_email_settings = (course.id in show_email_settings_for) %>
<% course_mode_info = all_course_modes.get(course.id) %>
<% show_refund_option = (course.id in show_refund_option_for) %>
<% is_paid_course = (course.id in enrolled_courses_either_paid) %>
<% is_course_blocked = (course.id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(course.id, {}) %>
<% course_requirements = courses_requirements_not_met.get(course.id) %>
<%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user" />
% for dashboard_index, enrollment in enumerate(course_enrollments):
<% show_courseware_link = (enrollment.course_id in show_courseware_links_for) %>
<% cert_status = cert_statuses.get(enrollment.course_id) %>
<% credit_status = credit_statuses.get(enrollment.course_id) %>
<% show_email_settings = (enrollment.course_id in show_email_settings_for) %>
<% course_mode_info = all_course_modes.get(enrollment.course_id) %>
<% show_refund_option = (enrollment.course_id in show_refund_option_for) %>
<% is_paid_course = (enrollment.course_id in enrolled_courses_either_paid) %>
<% is_course_blocked = (enrollment.course_id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %>
<% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %>
<%include file='dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user" />
% endfor
% if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
% for ccx, membership, course in ccx_membership_triplets:
<% show_courseware_link = ccx.has_started() %>
<% is_course_blocked = False %>
<%include file='ccx/_dashboard_ccx_listing.html' args="ccx=ccx, membership=membership, course=course, show_courseware_link=show_courseware_link, is_course_blocked=is_course_blocked" />
<%include file='ccx/_dashboard_ccx_listing.html' args="ccx=ccx, membership=membership, course_overview=enrollment.course_overview, show_courseware_link=show_courseware_link, is_course_blocked=is_course_blocked" />
% endfor
% endif
......
<%page args="cert_status, course, enrollment" />
<%page args="cert_status, course_overview, enrollment" />
<%!
from django.utils.translation import ugettext as _
......@@ -7,11 +7,11 @@ from course_modes.models import CourseMode
<%namespace name='static' file='../static_content.html'/>
<%
cert_name_short = course.cert_name_short
cert_name_short = course_overview.cert_name_short
if cert_name_short == "":
cert_name_short = settings.CERT_NAME_SHORT
cert_name_long = course.cert_name_long
cert_name_long = course_overview.cert_name_long
if cert_name_long == "":
cert_name_long = settings.CERT_NAME_LONG
%>
......@@ -35,7 +35,7 @@ else:
<span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>.
% if cert_status['status'] == 'notpassing' and enrollment.mode != 'audit':
${_("Grade required for a {cert_name_short}:").format(cert_name_short=cert_name_short)} <span class="grade-value">
${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}</span>.
${"{0:.0f}%".format(float(course_overview.lowest_passing_grade)*100)}</span>.
% elif cert_status['status'] == 'restricted' and enrollment.mode == 'verified':
<p class="message-copy">
${_("Your verified {cert_name_long} is being held pending confirmation that the issuance of your {cert_name_short} is in compliance with strict U.S. embargoes on Iran, Cuba, Syria and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting {email}. If you would like a refund on your {cert_name_long}, please contact our billing address {billing_email}").format(email='<a class="contact-link" href="mailto:{email}">{email}</a>.'.format(email=settings.CONTACT_EMAIL), billing_email='<a class="contact-link" href="mailto:{email}">{email}</a>'.format(email=settings.PAYMENT_SUPPORT_EMAIL), cert_name_short=cert_name_short, cert_name_long=cert_name_long)}
......@@ -88,7 +88,7 @@ else:
<li class="action action-share">
<a class="action-linkedin-profile" target="_blank" href="${cert_status['linked_in_url']}"
title="${_('Add Certificate to LinkedIn Profile')}"
data-course-id="${unicode(course.id)}"
data-course-id="${unicode(course_overview.id)}"
data-certificate-mode="${cert_status['mode']}"
>
<img class="action-linkedin-profile-img"
......
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'CourseOverview.days_early_for_beta'
# The default value for the days_early_for_beta column is null. However,
# for courses already in the table that have a non-null value for
# days_early_for_beta, this would be invalid. So, we must clear the
# table before adding the new column.
db.clear_table('course_overviews_courseoverview')
db.add_column('course_overviews_courseoverview', 'days_early_for_beta',
self.gf('django.db.models.fields.FloatField')(null=True),
keep_default=False)
def backwards(self, orm):
# Deleting field 'CourseOverview.days_early_for_beta'
db.delete_column('course_overviews_courseoverview', 'days_early_for_beta')
models = {
'course_overviews.courseoverview': {
'Meta': {'object_name': 'CourseOverview'},
'_location': ('xmodule_django.models.UsageKeyField', [], {'max_length': '255'}),
'_pre_requisite_courses_json': ('django.db.models.fields.TextField', [], {}),
'advertised_start': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'cert_name_long': ('django.db.models.fields.TextField', [], {}),
'cert_name_short': ('django.db.models.fields.TextField', [], {}),
'certificates_display_behavior': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'certificates_show_before_end': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'course_image_url': ('django.db.models.fields.TextField', [], {}),
'days_early_for_beta': ('django.db.models.fields.FloatField', [], {'null': 'True'}),
'display_name': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'display_number_with_default': ('django.db.models.fields.TextField', [], {}),
'display_org_with_default': ('django.db.models.fields.TextField', [], {}),
'end': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'end_of_course_survey_url': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'facebook_url': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'has_any_active_web_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'primary_key': 'True', 'db_index': 'True'}),
'lowest_passing_grade': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
'mobile_available': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'social_sharing_url': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'start': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'visible_to_staff_only': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
}
}
complete_apps = ['course_overviews']
\ No newline at end of file
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'CourseOverview.cert_html_view_enabled'
# The default value for the cert_html_view_eanbled column is False.
# However, for courses in the table for which cert_html_view_enabled
# should be True, this would be invalid. So, we must clear the
# table before adding the new column.
db.clear_table('course_overviews_courseoverview')
db.add_column('course_overviews_courseoverview', 'cert_html_view_enabled',
self.gf('django.db.models.fields.BooleanField')(default=False),
keep_default=False)
def backwards(self, orm):
# Deleting field 'CourseOverview.cert_html_view_enabled'
db.delete_column('course_overviews_courseoverview', 'cert_html_view_enabled')
models = {
'course_overviews.courseoverview': {
'Meta': {'object_name': 'CourseOverview'},
'_location': ('xmodule_django.models.UsageKeyField', [], {'max_length': '255'}),
'_pre_requisite_courses_json': ('django.db.models.fields.TextField', [], {}),
'advertised_start': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'cert_html_view_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'cert_name_long': ('django.db.models.fields.TextField', [], {}),
'cert_name_short': ('django.db.models.fields.TextField', [], {}),
'certificates_display_behavior': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'certificates_show_before_end': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'course_image_url': ('django.db.models.fields.TextField', [], {}),
'days_early_for_beta': ('django.db.models.fields.FloatField', [], {'null': 'True'}),
'display_name': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'display_number_with_default': ('django.db.models.fields.TextField', [], {}),
'display_org_with_default': ('django.db.models.fields.TextField', [], {}),
'end': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'end_of_course_survey_url': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'facebook_url': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'has_any_active_web_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'primary_key': 'True', 'db_index': 'True'}),
'lowest_passing_grade': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
'mobile_available': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'social_sharing_url': ('django.db.models.fields.TextField', [], {'null': 'True'}),
'start': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'visible_to_staff_only': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
}
}
complete_apps = ['course_overviews']
\ No newline at end of file
......@@ -5,13 +5,13 @@ Declaration of CourseOverview model
import json
import django.db.models
from django.db.models.fields import BooleanField, DateTimeField, DecimalField, TextField
from django.db.models.fields import BooleanField, DateTimeField, DecimalField, TextField, FloatField
from django.utils.translation import ugettext
from lms.djangoapps.certificates.api import get_active_web_certificate
from lms.djangoapps.courseware.courses import course_image_url
from util.date_utils import strftime_localized
from xmodule import course_metadata_utils
from xmodule.course_module import CourseDescriptor
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore
from xmodule_django.models import CourseKeyField, UsageKeyField
......@@ -46,6 +46,7 @@ class CourseOverview(django.db.models.Model):
# Certification data
certificates_display_behavior = TextField(null=True)
certificates_show_before_end = BooleanField()
cert_html_view_enabled = BooleanField()
has_any_active_web_certificate = BooleanField()
cert_name_short = TextField()
cert_name_long = TextField()
......@@ -54,6 +55,7 @@ class CourseOverview(django.db.models.Model):
lowest_passing_grade = DecimalField(max_digits=5, decimal_places=2)
# Access parameters
days_early_for_beta = FloatField(null=True)
mobile_available = BooleanField()
visible_to_staff_only = BooleanField()
_pre_requisite_courses_json = TextField() # JSON representation of list of CourseKey strings
......@@ -72,6 +74,9 @@ class CourseOverview(django.db.models.Model):
Returns:
CourseOverview: overview extracted from the given course
"""
from lms.djangoapps.certificates.api import get_active_web_certificate
from lms.djangoapps.courseware.courses import course_image_url
return CourseOverview(
id=course.id,
_location=course.location,
......@@ -89,12 +94,14 @@ class CourseOverview(django.db.models.Model):
certificates_display_behavior=course.certificates_display_behavior,
certificates_show_before_end=course.certificates_show_before_end,
cert_html_view_enabled=course.cert_html_view_enabled,
has_any_active_web_certificate=(get_active_web_certificate(course) is not None),
cert_name_short=course.cert_name_short,
cert_name_long=course.cert_name_long,
lowest_passing_grade=course.lowest_passing_grade,
end_of_course_survey_url=course.end_of_course_survey_url,
days_early_for_beta=course.days_early_for_beta,
mobile_available=course.mobile_available,
visible_to_staff_only=course.visible_to_staff_only,
_pre_requisite_courses_json=json.dumps(course.pre_requisite_courses)
......@@ -111,10 +118,17 @@ class CourseOverview(django.db.models.Model):
future use.
Arguments:
course_id (CourseKey): the ID of the course overview to be loaded
course_id (CourseKey): the ID of the course overview to be loaded.
Returns:
CourseOverview: overview of the requested course
CourseOverview: overview of the requested course. If loading course
from the module store failed, returns None.
Raises:
- CourseOverview.DoesNotExist if the course specified by course_id
was not found.
- IOError if some other error occurs while trying to load the
course from the module store.
"""
course_overview = None
try:
......@@ -123,9 +137,17 @@ class CourseOverview(django.db.models.Model):
store = modulestore()
with store.bulk_operations(course_id):
course = store.get_course(course_id)
if course:
if isinstance(course, CourseDescriptor):
course_overview = CourseOverview._create_from_course(course)
course_overview.save() # Save new overview to the cache
course_overview.save()
elif course is not None:
raise IOError(
"Error while loading course {} from the module store: {}",
unicode(course_id),
course.error_msg if isinstance(course, ErrorDescriptor) else unicode(course)
)
else:
raise CourseOverview.DoesNotExist()
return course_overview
def clean_id(self, padding_char='='):
......
......@@ -6,13 +6,16 @@ import ddt
import itertools
import pytz
import math
import mock
from django.utils import timezone
from lms.djangoapps.certificates.api import get_active_web_certificate
from lms.djangoapps.courseware.courses import course_image_url
from xmodule.course_metadata_utils import DEFAULT_START_DATE
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls, check_mongo_calls_range
......@@ -41,12 +44,18 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
- the CourseDescriptor itself
- a CourseOverview that was newly constructed from _create_from_course
- a CourseOverview that was loaded from the MySQL database
Arguments:
course (CourseDescriptor): the course to be checked.
"""
def get_seconds_since_epoch(date_time):
"""
Returns the number of seconds between the Unix Epoch and the given
datetime. If the given datetime is None, return None.
Arguments:
date_time (datetime): the datetime in question.
"""
if date_time is None:
return None
......@@ -189,18 +198,14 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
by comparing pairs of them given a variety of scenarios.
Arguments:
course_kwargs (dict): kwargs to be passed to course constructor
modulestore_type (ModuleStoreEnum.Type)
is_user_enrolled (bool)
course_kwargs (dict): kwargs to be passed to course constructor.
modulestore_type (ModuleStoreEnum.Type): type of store to create the
course in.
"""
course = CourseFactory.create(
course="TEST101",
org="edX",
run="Run1",
default_store=modulestore_type,
**course_kwargs
)
# Note: We specify a value for 'run' here because, for some reason,
# .create raises an InvalidKeyError if we don't (even though my
# other test functions don't specify a run but work fine).
course = CourseFactory.create(default_store=modulestore_type, run="TestRun", **course_kwargs)
self.check_course_overview_against_course(course)
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
......@@ -208,17 +213,15 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
"""
Tests that when a course is published, the corresponding
course_overview is removed from the cache.
Arguments:
modulestore_type (ModuleStoreEnum.Type): type of store to create the
course in.
"""
with self.store.default_store(modulestore_type):
# Create a course where mobile_available is True.
course = CourseFactory.create(
course="TEST101",
org="edX",
run="Run1",
mobile_available=True,
default_store=modulestore_type
)
course = CourseFactory.create(mobile_available=True, default_store=modulestore_type)
course_overview_1 = CourseOverview.get_from_id(course.id)
self.assertTrue(course_overview_1.mobile_available)
......@@ -238,14 +241,16 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
def test_course_overview_caching(self, modulestore_type, min_mongo_calls, max_mongo_calls):
"""
Tests that CourseOverview structures are actually getting cached.
Arguments:
modulestore_type (ModuleStoreEnum.Type): type of store to create the
course in.
min_mongo_calls (int): minimum number of MongoDB queries we expect
to be made.
max_mongo_calls (int): maximum number of MongoDB queries we expect
to be made.
"""
course = CourseFactory.create(
course="TEST101",
org="edX",
run="Run1",
mobile_available=True,
default_store=modulestore_type
)
course = CourseFactory.create(default_store=modulestore_type)
# The first time we load a CourseOverview, it will be a cache miss, so
# we expect the modulestore to be queried.
......@@ -256,3 +261,36 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
# we expect no modulestore queries to be made.
with check_mongo_calls(0):
_course_overview_2 = CourseOverview.get_from_id(course.id)
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_get_non_existent_course(self, modulestore_type):
"""
Tests that requesting a non-existent course from get_from_id raises
CourseOverview.DoesNotExist.
Arguments:
modulestore_type (ModuleStoreEnum.Type): type of store to create the
course in.
"""
store = modulestore()._get_modulestore_by_type(modulestore_type) # pylint: disable=protected-access
with self.assertRaises(CourseOverview.DoesNotExist):
CourseOverview.get_from_id(store.make_course_key('Non', 'Existent', 'Course'))
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_get_errored_course(self, modulestore_type):
"""
Test that getting an ErrorDescriptor back from the module store causes
get_from_id to raise an IOError.
Arguments:
modulestore_type (ModuleStoreEnum.Type): type of store to create the
course in.
"""
course = CourseFactory.create(default_store=modulestore_type)
mock_get_course = mock.Mock(return_value=ErrorDescriptor)
with mock.patch('xmodule.modulestore.mixed.MixedModuleStore.get_course', mock_get_course):
# This mock makes it so when the module store tries to load course data,
# an exception is thrown, which causes get_course to return an ErrorDescriptor,
# which causes get_from_id to raise an IOError.
with self.assertRaises(IOError):
CourseOverview.get_from_id(course.id)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment