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" ...@@ -23,8 +23,9 @@ VERIFY_STATUS_MISSED_DEADLINE = "verify_missed_deadline"
VERIFY_STATUS_NEED_TO_REVERIFY = "verify_need_to_reverify" VERIFY_STATUS_NEED_TO_REVERIFY = "verify_need_to_reverify"
def check_verify_status_by_course(user, course_enrollment_pairs, all_course_modes): def check_verify_status_by_course(user, course_enrollments, all_course_modes):
"""Determine the per-course verification statuses for a given user. """
Determine the per-course verification statuses for a given user.
The possible statuses are: The possible statuses are:
* VERIFY_STATUS_NEED_TO_VERIFY: The student has not yet submitted photos for verification. * 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 ...@@ -46,8 +47,7 @@ def check_verify_status_by_course(user, course_enrollment_pairs, all_course_mode
Arguments: Arguments:
user (User): The currently logged-in user. user (User): The currently logged-in user.
course_enrollment_pairs (list): The courses the user is enrolled in. course_enrollments (list[CourseEnrollment]): The courses the user is enrolled in.
The list should contain tuples of `(Course, CourseEnrollment)`.
all_course_modes (list): List of all course modes for the student's enrolled courses, all_course_modes (list): List of all course modes for the student's enrolled courses,
including modes that have expired. including modes that have expired.
...@@ -75,15 +75,15 @@ def check_verify_status_by_course(user, course_enrollment_pairs, all_course_mode ...@@ -75,15 +75,15 @@ def check_verify_status_by_course(user, course_enrollment_pairs, all_course_mode
recent_verification_datetime = None 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 # Get the verified mode (if any) for this course
# We pass in the course modes we have already loaded to avoid # We pass in the course modes we have already loaded to avoid
# another database hit, as well as to ensure that expired # another database hit, as well as to ensure that expired
# course modes are included in the search. # course modes are included in the search.
verified_mode = CourseMode.verified_mode_for_course( verified_mode = CourseMode.verified_mode_for_course(
course.id, enrollment.course_id,
modes=all_course_modes[course.id] modes=all_course_modes[enrollment.course_id]
) )
# If no verified mode has ever been offered, or the user hasn't enrolled # 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 ...@@ -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: if deadline is not None and deadline > now:
days_until_deadline = (deadline - now).days days_until_deadline = (deadline - now).days
status_by_course[course.id] = { status_by_course[enrollment.course_id] = {
'status': status, 'status': status,
'days_until_deadline': days_until_deadline 'days_until_deadline': days_until_deadline
} }
......
...@@ -850,6 +850,13 @@ class CourseEnrollment(models.Model): ...@@ -850,6 +850,13 @@ class CourseEnrollment(models.Model):
unique_together = (('user', 'course_id'),) unique_together = (('user', 'course_id'),)
ordering = ('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): def __unicode__(self):
return ( return (
"[CourseEnrollment] {}: {} ({}); active: ({})" "[CourseEnrollment] {}: {} ({}); active: ({})"
...@@ -1318,10 +1325,21 @@ class CourseEnrollment(models.Model): ...@@ -1318,10 +1325,21 @@ class CourseEnrollment(models.Model):
@property @property
def course_overview(self): def course_overview(self):
""" """
Return a CourseOverview of this enrollment's course. Returns a CourseOverview of the course to which this enrollment refers.
""" Returns None if an error occurred while trying to load the course.
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
return CourseOverview.get_from_id(self.course_id) 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): def is_verified_enrollment(self):
""" """
......
...@@ -14,7 +14,7 @@ from xmodule.modulestore.django import modulestore ...@@ -14,7 +14,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from django.test.client import Client from django.test.client import Client
from student.models import CourseEnrollment 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 ( from util.milestones_helpers import (
get_pre_requisite_courses_not_completed, get_pre_requisite_courses_not_completed,
set_prerequisite_courses, set_prerequisite_courses,
...@@ -73,13 +73,13 @@ class TestCourseListing(ModuleStoreTestCase): ...@@ -73,13 +73,13 @@ class TestCourseListing(ModuleStoreTestCase):
self._create_course_with_access_groups(course_location) self._create_course_with_access_groups(course_location)
# get dashboard # 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(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) CourseEnrollment.unenroll(self.student, course_location)
# get dashboard # 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) self.assertEqual(len(courses_list), 0)
def test_errored_course_regular_access(self): def test_errored_course_regular_access(self):
...@@ -95,7 +95,7 @@ class TestCourseListing(ModuleStoreTestCase): ...@@ -95,7 +95,7 @@ class TestCourseListing(ModuleStoreTestCase):
self.assertIsInstance(modulestore().get_course(course_key), ErrorDescriptor) self.assertIsInstance(modulestore().get_course(course_key), ErrorDescriptor)
# get courses through iterating all courses # 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, []) self.assertEqual(courses_list, [])
def test_course_listing_errored_deleted_courses(self): def test_course_listing_errored_deleted_courses(self):
...@@ -112,9 +112,9 @@ class TestCourseListing(ModuleStoreTestCase): ...@@ -112,9 +112,9 @@ class TestCourseListing(ModuleStoreTestCase):
self._create_course_with_access_groups(course_location, default_store=ModuleStoreEnum.Type.mongo) self._create_course_with_access_groups(course_location, default_store=ModuleStoreEnum.Type.mongo)
mongo_store.delete_course(course_location, ModuleStoreEnum.UserID.test) 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(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}) @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_course_listing_has_pre_requisite_courses(self): def test_course_listing_has_pre_requisite_courses(self):
...@@ -142,9 +142,11 @@ class TestCourseListing(ModuleStoreTestCase): ...@@ -142,9 +142,11 @@ class TestCourseListing(ModuleStoreTestCase):
set_prerequisite_courses(course_location, pre_requisite_courses) set_prerequisite_courses(course_location, pre_requisite_courses)
# get dashboard # get dashboard
course_enrollment_pairs = list(get_course_enrollment_pairs(self.student, None, [])) course_enrollments = list(get_course_enrollments(self.student, None, []))
courses_having_prerequisites = frozenset(course.id for course, _enrollment in course_enrollment_pairs courses_having_prerequisites = frozenset(
if course.pre_requisite_courses) 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( courses_requirements_not_met = get_pre_requisite_courses_not_completed(
self.student, self.student,
courses_having_prerequisites courses_having_prerequisites
......
...@@ -15,7 +15,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -15,7 +15,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from course_modes.tests.factories import CourseModeFactory from course_modes.tests.factories import CourseModeFactory
from student.models import CourseEnrollment, DashboardConfiguration 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') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
...@@ -67,7 +67,7 @@ class TestRecentEnrollments(ModuleStoreTestCase): ...@@ -67,7 +67,7 @@ class TestRecentEnrollments(ModuleStoreTestCase):
self._configure_message_timeout(60) self._configure_message_timeout(60)
# get courses through iterating all courses # 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) self.assertEqual(len(courses_list), 2)
recent_course_list = _get_recently_enrolled_courses(courses_list) recent_course_list = _get_recently_enrolled_courses(courses_list)
...@@ -78,7 +78,7 @@ class TestRecentEnrollments(ModuleStoreTestCase): ...@@ -78,7 +78,7 @@ class TestRecentEnrollments(ModuleStoreTestCase):
Tests that the recent enrollment list is empty if configured to zero seconds. Tests that the recent enrollment list is empty if configured to zero seconds.
""" """
self._configure_message_timeout(0) 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) self.assertEqual(len(courses_list), 2)
recent_course_list = _get_recently_enrolled_courses(courses_list) recent_course_list = _get_recently_enrolled_courses(courses_list)
...@@ -106,16 +106,16 @@ class TestRecentEnrollments(ModuleStoreTestCase): ...@@ -106,16 +106,16 @@ class TestRecentEnrollments(ModuleStoreTestCase):
enrollment.save() enrollment.save()
courses.append(course) 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) self.assertEqual(len(courses_list), 6)
recent_course_list = _get_recently_enrolled_courses(courses_list) recent_course_list = _get_recently_enrolled_courses(courses_list)
self.assertEqual(len(recent_course_list), 5) self.assertEqual(len(recent_course_list), 5)
self.assertEqual(recent_course_list[1][0], courses[0]) self.assertEqual(recent_course_list[1].course, courses[0])
self.assertEqual(recent_course_list[2][0], courses[1]) self.assertEqual(recent_course_list[2].course, courses[1])
self.assertEqual(recent_course_list[3][0], courses[2]) self.assertEqual(recent_course_list[3].course, courses[2])
self.assertEqual(recent_course_list[4][0], courses[3]) self.assertEqual(recent_course_list[4].course, courses[3])
def test_dashboard_rendering(self): def test_dashboard_rendering(self):
""" """
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
This file demonstrates writing tests using the unittest module. These will pass Miscellaneous tests for the student app.
when you run "manage.py test".
Replace this with more appropriate tests for your application.
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
...@@ -28,8 +25,8 @@ from student.views import (process_survey_link, _cert_info, ...@@ -28,8 +25,8 @@ from student.views import (process_survey_link, _cert_info,
from student.tests.factories import UserFactory, CourseModeFactory from student.tests.factories import UserFactory, CourseModeFactory
from util.testing import EventTestMixin from util.testing import EventTestMixin
from util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME from util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ModuleStoreEnum
# These imports refer to lms djangoapps. # These imports refer to lms djangoapps.
# Their testcases are only run under lms. # Their testcases are only run under lms.
...@@ -193,6 +190,7 @@ class CourseEndingTest(TestCase): ...@@ -193,6 +190,7 @@ class CourseEndingTest(TestCase):
self.assertIsNone(_cert_info(user, course2, cert_status, course_mode)) self.assertIsNone(_cert_info(user, course2, cert_status, course_mode))
@ddt.ddt
class DashboardTest(ModuleStoreTestCase): class DashboardTest(ModuleStoreTestCase):
""" """
Tests for dashboard utility functions Tests for dashboard utility functions
...@@ -487,6 +485,48 @@ class DashboardTest(ModuleStoreTestCase): ...@@ -487,6 +485,48 @@ class DashboardTest(ModuleStoreTestCase):
) )
self.assertContains(response, expected_url) 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): class UserSettingsEventTestMixin(EventTestMixin):
""" """
......
...@@ -4,7 +4,6 @@ Student Views ...@@ -4,7 +4,6 @@ Student Views
import datetime import datetime
import logging import logging
import uuid import uuid
import time
import json import json
import warnings import warnings
from datetime import timedelta from datetime import timedelta
...@@ -115,7 +114,6 @@ from student.helpers import ( ...@@ -115,7 +114,6 @@ from student.helpers import (
) )
from student.cookies import set_logged_in_cookies, delete_logged_in_cookies from student.cookies import set_logged_in_cookies, delete_logged_in_cookies
from student.models import anonymous_id_for_user from student.models import anonymous_id_for_user
from xmodule.error_module import ErrorDescriptor
from shoppingcart.models import DonationConfiguration, CourseRegistrationCode from shoppingcart.models import DonationConfiguration, CourseRegistrationCode
from embargo import api as embargo_api from embargo import api as embargo_api
...@@ -185,33 +183,42 @@ def process_survey_link(survey_link, user): ...@@ -185,33 +183,42 @@ def process_survey_link(survey_link, user):
return survey_link.format(UNIQUE_ID=unique_id_for_user(user)) return survey_link.format(UNIQUE_ID=unique_id_for_user(user))
def cert_info(user, course, course_mode): def cert_info(user, course_overview, course_mode):
""" """
Get the certificate info needed to render the dashboard section for the given Get the certificate info needed to render the dashboard section for the given
student and course. Returns a dictionary with keys: student and course.
'status': one of 'generating', 'ready', 'notpassing', 'processing', 'restricted' Arguments:
'show_download_url': bool user (User): A user.
'download_url': url, only present if show_download_url is True course_overview (CourseOverview): A course.
'show_disabled_download_button': bool -- true if state is 'generating' course_mode (str): The enrollment mode (honor, verified, audit, etc.)
'show_survey_button': bool
'survey_url': url, only if show_survey_button is True Returns:
'grade': if status is not 'processing' dict: A dictionary with keys:
'status': one of 'generating', 'ready', 'notpassing', 'processing', 'restricted'
'show_download_url': bool
'download_url': url, only present if show_download_url is True
'show_disabled_download_button': bool -- true if state is 'generating'
'show_survey_button': bool
'survey_url': url, only if show_survey_button is True
'grade': if status is not 'processing'
""" """
if not course.may_certify(): if not course_overview.may_certify():
return {} return {}
return _cert_info(
return _cert_info(user, course, certificate_status_for_student(user, course.id), course_mode) user,
course_overview,
certificate_status_for_student(user, course_overview.id),
course_mode
)
def reverification_info(course_enrollment_pairs, user, statuses): def reverification_info(statuses):
""" """
Returns reverification-related information for *all* of user's enrollments whose Returns reverification-related information for *all* of user's enrollments whose
reverification status is in status_list reverification status is in statuses.
Args: Args:
course_enrollment_pairs (list): list of (course, enrollment) tuples
user (User): the user whose information we want
statuses (list): a list of reverification statuses we want information for statuses (list): a list of reverification statuses we want information for
example: ["must_reverify", "denied"] example: ["must_reverify", "denied"]
...@@ -229,39 +236,56 @@ def reverification_info(course_enrollment_pairs, user, statuses): ...@@ -229,39 +236,56 @@ def reverification_info(course_enrollment_pairs, user, statuses):
return reverifications return reverifications
def get_course_enrollment_pairs(user, course_org_filter, org_filter_out_set): def get_course_enrollments(user, org_to_include, orgs_to_exclude):
""" """
Get the relevant set of (Course, CourseEnrollment) pairs to be displayed on Given a user, return a filtered set of his or her course enrollments.
a student's dashboard.
Arguments:
user (User): the user in question.
org_to_include (str): for use in Microsites. If not None, ONLY courses
of this org will be returned.
orgs_to_exclude (list[str]): If org_to_include is not None, this
argument is ignored. Else, courses of this org will be excluded.
Returns:
generator[CourseEnrollment]: a sequence of enrollments to be displayed
on the user's dashboard.
""" """
for enrollment in CourseEnrollment.enrollments_for_user(user): for enrollment in CourseEnrollment.enrollments_for_user(user):
store = modulestore()
with store.bulk_operations(enrollment.course_id):
course = store.get_course(enrollment.course_id)
if course and not isinstance(course, ErrorDescriptor):
# if we are in a Microsite, then filter out anything that is not
# attributed (by ORG) to that Microsite
if course_org_filter and course_org_filter != course.location.org:
continue
# Conversely, if we are not in a Microsite, then let's filter out any enrollments
# with courses attributed (by ORG) to Microsites
elif course.location.org in org_filter_out_set:
continue
yield (course, enrollment)
else:
log.error(
u"User %s enrolled in %s course %s",
user.username,
"broken" if course else "non-existent",
enrollment.course_id
)
# If the course is missing or broken, log an error and skip it.
course_overview = enrollment.course_overview
if not course_overview:
log.error(
"User %s enrolled in broken or non-existent course %s",
user.username,
enrollment.course_id
)
continue
# If we are in a Microsite, then filter out anything that is not
# attributed (by ORG) to that Microsite.
if org_to_include and course_overview.location.org != org_to_include:
continue
def _cert_info(user, course, cert_status, course_mode): # Conversely, if we are not in a Microsite, then filter out any enrollments
# with courses attributed (by ORG) to Microsites.
elif course_overview.location.org in orgs_to_exclude:
continue
# Else, include the enrollment.
else:
yield enrollment
def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disable=unused-argument
""" """
Implements the logic for cert_info -- split out for testing. Implements the logic for cert_info -- split out for testing.
Arguments:
user (User): A user.
course_overview (CourseOverview): A course.
course_mode (str): The enrollment mode (honor, verified, audit, etc.)
""" """
# simplify the status for the template using this lookup table # simplify the status for the template using this lookup table
template_state = { template_state = {
...@@ -285,7 +309,7 @@ def _cert_info(user, course, cert_status, course_mode): ...@@ -285,7 +309,7 @@ def _cert_info(user, course, cert_status, course_mode):
is_hidden_status = cert_status['status'] in ('unavailable', 'processing', 'generating', 'notpassing') is_hidden_status = cert_status['status'] in ('unavailable', 'processing', 'generating', 'notpassing')
if course.certificates_display_behavior == 'early_no_info' and is_hidden_status: if course_overview.certificates_display_behavior == 'early_no_info' and is_hidden_status:
return None return None
status = template_state.get(cert_status['status'], default_status) status = template_state.get(cert_status['status'], default_status)
...@@ -299,20 +323,20 @@ def _cert_info(user, course, cert_status, course_mode): ...@@ -299,20 +323,20 @@ def _cert_info(user, course, cert_status, course_mode):
} }
if (status in ('generating', 'ready', 'notpassing', 'restricted') and if (status in ('generating', 'ready', 'notpassing', 'restricted') and
course.end_of_course_survey_url is not None): course_overview.end_of_course_survey_url is not None):
status_dict.update({ status_dict.update({
'show_survey_button': True, 'show_survey_button': True,
'survey_url': process_survey_link(course.end_of_course_survey_url, user)}) 'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)})
else: else:
status_dict['show_survey_button'] = False status_dict['show_survey_button'] = False
if status == 'ready': if status == 'ready':
# showing the certificate web view button if certificate is ready state and feature flags are enabled. # showing the certificate web view button if certificate is ready state and feature flags are enabled.
if has_html_certificates_enabled(course.id, course): if has_html_certificates_enabled(course_overview.id, course_overview):
if get_active_web_certificate(course) is not None: if course_overview.has_any_active_web_certificate:
certificate_url = get_certificate_url( certificate_url = get_certificate_url(
user_id=user.id, user_id=user.id,
course_id=unicode(course.id) course_id=unicode(course_overview.id),
) )
status_dict.update({ status_dict.update({
'show_cert_web_view': True, 'show_cert_web_view': True,
...@@ -325,7 +349,7 @@ def _cert_info(user, course, cert_status, course_mode): ...@@ -325,7 +349,7 @@ def _cert_info(user, course, cert_status, course_mode):
log.warning( log.warning(
u"User %s has a downloadable cert for %s, but no download url", u"User %s has a downloadable cert for %s, but no download url",
user.username, user.username,
course.id course_overview.id
) )
return default_info return default_info
else: else:
...@@ -337,8 +361,8 @@ def _cert_info(user, course, cert_status, course_mode): ...@@ -337,8 +361,8 @@ def _cert_info(user, course, cert_status, course_mode):
linkedin_config = LinkedInAddToProfileConfiguration.current() linkedin_config = LinkedInAddToProfileConfiguration.current()
if linkedin_config.enabled: if linkedin_config.enabled:
status_dict['linked_in_url'] = linkedin_config.add_to_profile_url( status_dict['linked_in_url'] = linkedin_config.add_to_profile_url(
course.id, course_overview.id,
course.display_name, course_overview.display_name,
cert_status.get('mode'), cert_status.get('mode'),
cert_status['download_url'] cert_status['download_url']
) )
...@@ -506,13 +530,13 @@ def dashboard(request): ...@@ -506,13 +530,13 @@ def dashboard(request):
# Build our (course, enrollment) list for the user, but ignore any courses that no # Build our (course, enrollment) list for the user, but ignore any courses that no
# longer exist (because the course IDs have changed). Still, we don't delete those # longer exist (because the course IDs have changed). Still, we don't delete those
# enrollments, because it could have been a data push snafu. # enrollments, because it could have been a data push snafu.
course_enrollment_pairs = list(get_course_enrollment_pairs(user, course_org_filter, org_filter_out_set)) course_enrollments = list(get_course_enrollments(user, course_org_filter, org_filter_out_set))
# sort the enrollment pairs by the enrollment date # sort the enrollment pairs by the enrollment date
course_enrollment_pairs.sort(key=lambda x: x[1].created, reverse=True) course_enrollments.sort(key=lambda x: x.created, reverse=True)
# Retrieve the course modes for each course # Retrieve the course modes for each course
enrolled_course_ids = [course.id for course, __ in course_enrollment_pairs] enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments]
all_course_modes, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids) all_course_modes, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids)
course_modes_by_course = { course_modes_by_course = {
course_id: { course_id: {
...@@ -525,14 +549,9 @@ def dashboard(request): ...@@ -525,14 +549,9 @@ def dashboard(request):
# Check to see if the student has recently enrolled in a course. # Check to see if the student has recently enrolled in a course.
# If so, display a notification message confirming the enrollment. # If so, display a notification message confirming the enrollment.
enrollment_message = _create_recent_enrollment_message( enrollment_message = _create_recent_enrollment_message(
course_enrollment_pairs, course_modes_by_course course_enrollments, course_modes_by_course
) )
# Retrieve the course modes for each course
enrolled_courses_dict = {}
for course, __ in course_enrollment_pairs:
enrolled_courses_dict[unicode(course.id)] = course
course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True) course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
message = "" message = ""
...@@ -551,20 +570,20 @@ def dashboard(request): ...@@ -551,20 +570,20 @@ def dashboard(request):
errored_courses = modulestore().get_errored_courses() errored_courses = modulestore().get_errored_courses()
show_courseware_links_for = frozenset( show_courseware_links_for = frozenset(
course.id for course, _enrollment in course_enrollment_pairs enrollment.course_id for enrollment in course_enrollments
if has_access(request.user, 'load', course) if has_access(request.user, 'load', enrollment.course_overview)
and has_access(request.user, 'view_courseware_with_prerequisites', course) and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview)
) )
# Construct a dictionary of course mode information # Construct a dictionary of course mode information
# used to render the course list. We re-use the course modes dict # used to render the course list. We re-use the course modes dict
# we loaded earlier to avoid hitting the database. # we loaded earlier to avoid hitting the database.
course_mode_info = { course_mode_info = {
course.id: complete_course_mode_info( enrollment.course_id: complete_course_mode_info(
course.id, enrollment, enrollment.course_id, enrollment,
modes=course_modes_by_course[course.id] modes=course_modes_by_course[enrollment.course_id]
) )
for course, enrollment in course_enrollment_pairs for enrollment in course_enrollments
} }
# Determine the per-course verification status # Determine the per-course verification status
...@@ -583,20 +602,20 @@ def dashboard(request): ...@@ -583,20 +602,20 @@ def dashboard(request):
# there is no verification messaging to display. # there is no verification messaging to display.
verify_status_by_course = check_verify_status_by_course( verify_status_by_course = check_verify_status_by_course(
user, user,
course_enrollment_pairs, course_enrollments,
all_course_modes all_course_modes
) )
cert_statuses = { cert_statuses = {
course.id: cert_info(request.user, course, _enrollment.mode) enrollment.course_id: cert_info(request.user, enrollment.course_overview, enrollment.mode)
for course, _enrollment in course_enrollment_pairs for enrollment in course_enrollments
} }
# only show email settings for Mongo course and when bulk email is turned on # only show email settings for Mongo course and when bulk email is turned on
show_email_settings_for = frozenset( show_email_settings_for = frozenset(
course.id for course, _enrollment in course_enrollment_pairs if ( enrollment.course_id for enrollment in course_enrollments if (
settings.FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and settings.FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and
modulestore().get_modulestore_type(course.id) != ModuleStoreEnum.Type.xml and modulestore().get_modulestore_type(enrollment.course_id) != ModuleStoreEnum.Type.xml and
CourseAuthorization.instructor_email_enabled(course.id) CourseAuthorization.instructor_email_enabled(enrollment.course_id)
) )
) )
...@@ -606,16 +625,29 @@ def dashboard(request): ...@@ -606,16 +625,29 @@ def dashboard(request):
# Gets data for midcourse reverifications, if any are necessary or have failed # Gets data for midcourse reverifications, if any are necessary or have failed
statuses = ["approved", "denied", "pending", "must_reverify"] statuses = ["approved", "denied", "pending", "must_reverify"]
reverifications = reverification_info(course_enrollment_pairs, user, statuses) reverifications = reverification_info(statuses)
show_refund_option_for = frozenset(course.id for course, _enrollment in course_enrollment_pairs show_refund_option_for = frozenset(
if _enrollment.refundable()) enrollment.course_id for enrollment in course_enrollments
if enrollment.refundable()
)
block_courses = frozenset(course.id for course, enrollment in course_enrollment_pairs block_courses = frozenset(
if is_course_blocked(request, CourseRegistrationCode.objects.filter(course_id=course.id, registrationcoderedemption__redeemed_by=request.user), course.id)) enrollment.course_id for enrollment in course_enrollments
if is_course_blocked(
request,
CourseRegistrationCode.objects.filter(
course_id=enrollment.course_id,
registrationcoderedemption__redeemed_by=request.user
),
enrollment.course_id
)
)
enrolled_courses_either_paid = frozenset(course.id for course, _enrollment in course_enrollment_pairs enrolled_courses_either_paid = frozenset(
if _enrollment.is_paid_course()) enrollment.course_id for enrollment in course_enrollments
if enrollment.is_paid_course()
)
# If there are *any* denied reverifications that have not been toggled off, # If there are *any* denied reverifications that have not been toggled off,
# we'll display the banner # we'll display the banner
...@@ -625,8 +657,10 @@ def dashboard(request): ...@@ -625,8 +657,10 @@ def dashboard(request):
order_history_list = order_history(user, course_org_filter=course_org_filter, org_filter_out_set=org_filter_out_set) order_history_list = order_history(user, course_org_filter=course_org_filter, org_filter_out_set=org_filter_out_set)
# get list of courses having pre-requisites yet to be completed # get list of courses having pre-requisites yet to be completed
courses_having_prerequisites = frozenset(course.id for course, _enrollment in course_enrollment_pairs courses_having_prerequisites = frozenset(
if course.pre_requisite_courses) 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(user, courses_having_prerequisites) courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)
ccx_membership_triplets = [] ccx_membership_triplets = []
...@@ -638,7 +672,7 @@ def dashboard(request): ...@@ -638,7 +672,7 @@ def dashboard(request):
context = { context = {
'enrollment_message': enrollment_message, 'enrollment_message': enrollment_message,
'course_enrollment_pairs': course_enrollment_pairs, 'course_enrollments': course_enrollments,
'course_optouts': course_optouts, 'course_optouts': course_optouts,
'message': message, 'message': message,
'staff_access': staff_access, 'staff_access': staff_access,
...@@ -646,7 +680,7 @@ def dashboard(request): ...@@ -646,7 +680,7 @@ def dashboard(request):
'show_courseware_links_for': show_courseware_links_for, 'show_courseware_links_for': show_courseware_links_for,
'all_course_modes': course_mode_info, 'all_course_modes': course_mode_info,
'cert_statuses': cert_statuses, 'cert_statuses': cert_statuses,
'credit_statuses': _credit_statuses(user, course_enrollment_pairs), 'credit_statuses': _credit_statuses(user, course_enrollments),
'show_email_settings_for': show_email_settings_for, 'show_email_settings_for': show_email_settings_for,
'reverifications': reverifications, 'reverifications': reverifications,
'verification_status': verification_status, 'verification_status': verification_status,
...@@ -669,13 +703,15 @@ def dashboard(request): ...@@ -669,13 +703,15 @@ def dashboard(request):
return render_to_response('dashboard.html', context) return render_to_response('dashboard.html', context)
def _create_recent_enrollment_message(course_enrollment_pairs, course_modes): def _create_recent_enrollment_message(course_enrollments, course_modes): # pylint: disable=invalid-name
"""Builds a recent course enrollment message """
Builds a recent course enrollment message.
Constructs a new message template based on any recent course enrollments for the student. Constructs a new message template based on any recent course enrollments
for the student.
Args: Args:
course_enrollment_pairs (list): A list of tuples containing courses, and the associated enrollment information. course_enrollments (list[CourseEnrollment]): a list of course enrollments.
course_modes (dict): Mapping of course ID's to course mode dictionaries. course_modes (dict): Mapping of course ID's to course mode dictionaries.
Returns: Returns:
...@@ -683,16 +719,16 @@ def _create_recent_enrollment_message(course_enrollment_pairs, course_modes): ...@@ -683,16 +719,16 @@ def _create_recent_enrollment_message(course_enrollment_pairs, course_modes):
None if there are no recently enrolled courses. None if there are no recently enrolled courses.
""" """
recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollment_pairs) recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollments)
if recently_enrolled_courses: if recently_enrolled_courses:
messages = [ messages = [
{ {
"course_id": course.id, "course_id": enrollment.course_overview.id,
"course_name": course.display_name, "course_name": enrollment.course_overview.display_name,
"allow_donation": _allow_donation(course_modes, course.id, enrollment) "allow_donation": _allow_donation(course_modes, enrollment.course_overview.id, enrollment)
} }
for course, enrollment in recently_enrolled_courses for enrollment in recently_enrolled_courses
] ]
platform_name = microsite.get_value('platform_name', settings.PLATFORM_NAME) platform_name = microsite.get_value('platform_name', settings.PLATFORM_NAME)
...@@ -703,22 +739,20 @@ def _create_recent_enrollment_message(course_enrollment_pairs, course_modes): ...@@ -703,22 +739,20 @@ def _create_recent_enrollment_message(course_enrollment_pairs, course_modes):
) )
def _get_recently_enrolled_courses(course_enrollment_pairs): def _get_recently_enrolled_courses(course_enrollments):
"""Checks to see if the student has recently enrolled in courses. """
Given a list of enrollments, filter out all but recent enrollments.
Checks to see if any of the enrollments in the course_enrollment_pairs have been recently created and activated.
Args: Args:
course_enrollment_pairs (list): A list of tuples containing courses, and the associated enrollment information. course_enrollments (list[CourseEnrollment]): A list of course enrollments.
Returns: Returns:
A list of courses list[CourseEnrollment]: A list of recent course enrollments.
""" """
seconds = DashboardConfiguration.current().recent_enrollment_time_delta seconds = DashboardConfiguration.current().recent_enrollment_time_delta
time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds)) time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds))
return [ return [
(course, enrollment) for course, enrollment in course_enrollment_pairs enrollment for enrollment in course_enrollments
# If the enrollment has no created date, we are explicitly excluding the course # If the enrollment has no created date, we are explicitly excluding the course
# from the list of recent enrollments. # from the list of recent enrollments.
if enrollment.is_active and enrollment.created > time_delta if enrollment.is_active and enrollment.created > time_delta
...@@ -752,7 +786,7 @@ def _update_email_opt_in(request, org): ...@@ -752,7 +786,7 @@ def _update_email_opt_in(request, org):
preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean) preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean)
def _credit_statuses(user, course_enrollment_pairs): def _credit_statuses(user, course_enrollments):
""" """
Retrieve the status for credit courses. Retrieve the status for credit courses.
...@@ -768,7 +802,8 @@ def _credit_statuses(user, course_enrollment_pairs): ...@@ -768,7 +802,8 @@ def _credit_statuses(user, course_enrollment_pairs):
Arguments: Arguments:
user (User): The currently logged-in user. user (User): The currently logged-in user.
course_enrollment_pairs (list): List of (Course, CourseEnrollment) tuples. course_enrollments (list[CourseEnrollment]): List of enrollments for the
user.
Returns: dict Returns: dict
...@@ -785,7 +820,7 @@ def _credit_statuses(user, course_enrollment_pairs): ...@@ -785,7 +820,7 @@ def _credit_statuses(user, course_enrollment_pairs):
so the user should contact the support team. so the user should contact the support team.
Example: Example:
>>> _credit_statuses(user, course_enrollment_pairs) >>> _credit_statuses(user, course_enrollments)
{ {
CourseKey.from_string("edX/DemoX/Demo_Course"): { CourseKey.from_string("edX/DemoX/Demo_Course"): {
"course_key": "edX/DemoX/Demo_Course", "course_key": "edX/DemoX/Demo_Course",
...@@ -812,8 +847,8 @@ def _credit_statuses(user, course_enrollment_pairs): ...@@ -812,8 +847,8 @@ def _credit_statuses(user, course_enrollment_pairs):
} }
credit_enrollments = { credit_enrollments = {
course.id: enrollment enrollment.course_id: enrollment
for course, enrollment in course_enrollment_pairs for enrollment in course_enrollments
if enrollment.mode == "credit" if enrollment.mode == "credit"
} }
......
...@@ -9,6 +9,7 @@ from django.utils.translation import ugettext as _ ...@@ -9,6 +9,7 @@ from django.utils.translation import ugettext as _
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
NAMESPACE_CHOICES = { NAMESPACE_CHOICES = {
...@@ -86,33 +87,46 @@ def set_prerequisite_courses(course_key, prerequisite_course_keys): ...@@ -86,33 +87,46 @@ def set_prerequisite_courses(course_key, prerequisite_course_keys):
add_prerequisite_course(course_key, prerequisite_course_key) 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 Makes a dict mapping courses to their unfulfilled milestones using the
user has enrolled in. It calls the fulfilment api of milestones app and fulfillment API of the milestones app.
iterates over all fulfilment milestones not achieved to make dict of
prerequisite courses yet to be completed. 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 = {} pre_requisite_courses = {}
if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False):
from milestones import api as milestones_api for course_key in enrolled_courses:
for course_key in enrolled_courses: required_courses = []
required_courses = [] fulfillment_paths = milestones_api.get_course_milestones_fulfillment_paths(course_key, {'id': user.id})
fulfilment_paths = milestones_api.get_course_milestones_fulfillment_paths(course_key, {'id': user.id}) for __, milestone_value in fulfillment_paths.items():
for milestone_key, milestone_value in fulfilment_paths.items(): # pylint: disable=unused-variable for key, value in milestone_value.items():
for key, value in milestone_value.items(): if key == 'courses' and value:
if key == 'courses' and value: for required_course in value:
for required_course in value: required_course_key = CourseKey.from_string(required_course)
required_course_key = CourseKey.from_string(required_course) required_course_overview = CourseOverview.get_from_id(required_course_key)
required_course_descriptor = modulestore().get_course(required_course_key) required_courses.append({
required_courses.append({ 'key': required_course_key,
'key': required_course_key, 'display': get_course_display_string(required_course_overview)
'display': get_course_display_name(required_course_descriptor) })
}) # If there are required courses, add them to the result dict.
if required_courses:
# if there are required courses add to dict pre_requisite_courses[course_key] = {'courses': required_courses}
if required_courses:
pre_requisite_courses[course_key] = {'courses': required_courses}
return pre_requisite_courses return pre_requisite_courses
...@@ -129,15 +143,18 @@ def get_prerequisite_courses_display(course_descriptor): ...@@ -129,15 +143,18 @@ def get_prerequisite_courses_display(course_descriptor):
required_course_descriptor = modulestore().get_course(course_key) required_course_descriptor = modulestore().get_course(course_key)
prc = { prc = {
'key': course_key, 'key': course_key,
'display': get_course_display_name(required_course_descriptor) 'display': get_course_display_string(required_course_descriptor)
} }
pre_requisite_courses.append(prc) pre_requisite_courses.append(prc)
return pre_requisite_courses 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([ return ' '.join([
descriptor.display_org_with_default, descriptor.display_org_with_default,
......
...@@ -12,6 +12,7 @@ from django.core.urlresolvers import reverse ...@@ -12,6 +12,7 @@ from django.core.urlresolvers import reverse
from eventtracking import tracker from eventtracking import tracker
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from certificates.models import ( from certificates.models import (
...@@ -216,13 +217,18 @@ def generate_example_certificates(course_key): ...@@ -216,13 +217,18 @@ def generate_example_certificates(course_key):
def has_html_certificates_enabled(course_key, course=None): 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 html_certificates_enabled = False
try: try:
if not isinstance(course_key, CourseKey): if not isinstance(course_key, CourseKey):
course_key = CourseKey.from_string(course_key) 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: if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False) and course.cert_html_view_enabled:
html_certificates_enabled = True html_certificates_enabled = True
except: # pylint: disable=bare-except except: # pylint: disable=bare-except
......
...@@ -33,6 +33,7 @@ from xmodule.util.django import get_current_request_hostname ...@@ -33,6 +33,7 @@ from xmodule.util.django import get_current_request_hostname
from external_auth.models import ExternalAuthMap from external_auth.models import ExternalAuthMap
from courseware.masquerade import get_masquerade_role, is_masquerading_as_student from courseware.masquerade import get_masquerade_role, is_masquerading_as_student
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student import auth from student import auth
from student.models import CourseEnrollmentAllowed from student.models import CourseEnrollmentAllowed
from student.roles import ( from student.roles import (
...@@ -100,6 +101,9 @@ def has_access(user, action, obj, course_key=None): ...@@ -100,6 +101,9 @@ def has_access(user, action, obj, course_key=None):
if isinstance(obj, CourseDescriptor): if isinstance(obj, CourseDescriptor):
return _has_access_course_desc(user, action, obj) return _has_access_course_desc(user, action, obj)
if isinstance(obj, CourseOverview):
return _has_access_course_overview(user, action, obj)
if isinstance(obj, ErrorDescriptor): if isinstance(obj, ErrorDescriptor):
return _has_access_error_desc(user, action, obj, course_key) return _has_access_error_desc(user, action, obj, course_key)
...@@ -129,6 +133,87 @@ def has_access(user, action, obj, course_key=None): ...@@ -129,6 +133,87 @@ def has_access(user, action, obj, course_key=None):
# ================ Implementation helpers ================================ # ================ Implementation helpers ================================
def _can_access_descriptor_with_start_date(user, descriptor, course_key): # pylint: disable=invalid-name
"""
Checks if a user has access to a descriptor based on its start date.
If there is no start date specified, grant access.
Else, check if we're past the start date.
Note:
We do NOT check whether the user is staff or if the descriptor
is detached... it is assumed both of these are checked by the caller.
Arguments:
user (User): the user whose descriptor access we are checking.
descriptor (AType): the descriptor for which we are checking access.
where AType is any descriptor that has the attributes .location and
.days_early_for_beta
"""
start_dates_disabled = settings.FEATURES['DISABLE_START_DATES']
if start_dates_disabled and not is_masquerading_as_student(user, course_key):
return True
else:
now = datetime.now(UTC())
effective_start = _adjust_start_date_for_beta_testers(
user,
descriptor,
course_key=course_key
)
return (
descriptor.start is None
or now > effective_start
or in_preview_mode()
)
def _can_view_courseware_with_prerequisites(user, course): # pylint: disable=invalid-name
"""
Checks if a user has access to a course based on its prerequisites.
If the user is staff or anonymous, immediately grant access.
Else, return whether or not the prerequisite courses have been passed.
Arguments:
user (User): the user whose course access we are checking.
course (AType): the course for which we are checking access.
where AType is CourseDescriptor, CourseOverview, or any other class that
represents a course and has the attributes .location and .id.
"""
return (
not settings.FEATURES['ENABLE_PREREQUISITE_COURSES']
or _has_staff_access_to_descriptor(user, course, course.id)
or not course.pre_requisite_courses
or user.is_anonymous()
or not get_pre_requisite_courses_not_completed(user, [course.id])
)
def _can_load_course_on_mobile(user, course):
"""
Checks if a user can view the given course on a mobile device.
This function only checks mobile-specific access restrictions. Other access
restrictions such as start date and the .visible_to_staff_only flag must
be checked by callers in *addition* to the return value of this function.
Arguments:
user (User): the user whose course access we are checking.
course (CourseDescriptor|CourseOverview): the course for which we are
checking access.
Returns:
bool: whether the course can be accessed on mobile.
"""
return (
is_mobile_available_for_user(user, course) and
(
_has_staff_access_to_descriptor(user, course, course.id) or
not any_unfulfilled_milestones(course.id, user.id)
)
)
def _has_access_course_desc(user, action, course): def _has_access_course_desc(user, action, course):
""" """
Check if user has access to a course descriptor. Check if user has access to a course descriptor.
...@@ -154,23 +239,6 @@ def _has_access_course_desc(user, action, course): ...@@ -154,23 +239,6 @@ def _has_access_course_desc(user, action, course):
# delegate to generic descriptor check to check start dates # delegate to generic descriptor check to check start dates
return _has_access_descriptor(user, 'load', course, course.id) return _has_access_descriptor(user, 'load', course, course.id)
def can_load_mobile():
"""
Can this user access this course from a mobile device?
"""
return (
# check start date
can_load() and
# check mobile_available flag
is_mobile_available_for_user(user, course) and
(
# either is a staff user or
_has_staff_access_to_descriptor(user, course, course.id) or
# check for unfulfilled milestones
not any_unfulfilled_milestones(course.id, user.id)
)
)
def can_enroll(): def can_enroll():
""" """
First check if restriction of enrollment by login method is enabled, both First check if restriction of enrollment by login method is enabled, both
...@@ -274,25 +342,11 @@ def _has_access_course_desc(user, action, course): ...@@ -274,25 +342,11 @@ def _has_access_course_desc(user, action, course):
_has_staff_access_to_descriptor(user, course, course.id) _has_staff_access_to_descriptor(user, course, course.id)
) )
def can_view_courseware_with_prerequisites(): # pylint: disable=invalid-name
"""
Checks if prerequisite courses feature is enabled and course has prerequisites
and user is neither staff nor anonymous then it returns False if user has not
passed prerequisite courses otherwise return True.
"""
if settings.FEATURES['ENABLE_PREREQUISITE_COURSES'] \
and not _has_staff_access_to_descriptor(user, course, course.id) \
and course.pre_requisite_courses \
and not user.is_anonymous() \
and get_pre_requisite_courses_not_completed(user, [course.id]):
return False
else:
return True
checkers = { checkers = {
'load': can_load, 'load': can_load,
'view_courseware_with_prerequisites': can_view_courseware_with_prerequisites, 'view_courseware_with_prerequisites':
'load_mobile': can_load_mobile, lambda: _can_view_courseware_with_prerequisites(user, course),
'load_mobile': lambda: can_load() and _can_load_course_on_mobile(user, course),
'enroll': can_enroll, 'enroll': can_enroll,
'see_exists': see_exists, 'see_exists': see_exists,
'staff': lambda: _has_staff_access_to_descriptor(user, course, course.id), 'staff': lambda: _has_staff_access_to_descriptor(user, course, course.id),
...@@ -304,6 +358,51 @@ def _has_access_course_desc(user, action, course): ...@@ -304,6 +358,51 @@ def _has_access_course_desc(user, action, course):
return _dispatch(checkers, action, user, course) return _dispatch(checkers, action, user, course)
def _can_load_course_overview(user, course_overview):
"""
Check if a user can load a course overview.
Arguments:
user (User): the user whose course access we are checking.
course_overview (CourseOverview): a course overview.
Note:
The user doesn't have to be enrolled in the course in order to have load
load access.
"""
return (
not course_overview.visible_to_staff_only
and _can_access_descriptor_with_start_date(user, course_overview, course_overview.id)
) or _has_staff_access_to_descriptor(user, course_overview, course_overview.id)
_COURSE_OVERVIEW_CHECKERS = {
'load': _can_load_course_overview,
'load_mobile': lambda user, course_overview: (
_can_load_course_overview(user, course_overview)
and _can_load_course_on_mobile(user, course_overview)
),
'view_courseware_with_prerequisites': _can_view_courseware_with_prerequisites
}
COURSE_OVERVIEW_SUPPORTED_ACTIONS = _COURSE_OVERVIEW_CHECKERS.keys() # pylint: disable=invalid-name
def _has_access_course_overview(user, action, course_overview):
"""
Check if user has access to a course overview.
Arguments:
user (User): the user whose course access we are checking.
action (str): the action the user is trying to perform.
See COURSE_OVERVIEW_SUPPORTED_ACTIONS for valid values.
course_overview (CourseOverview): overview of the course in question.
"""
if action in _COURSE_OVERVIEW_CHECKERS:
return _COURSE_OVERVIEW_CHECKERS[action](user, course_overview)
else:
raise ValueError(u"Unknown action for object type 'CourseOverview': '{}'".format(action))
def _has_access_error_desc(user, action, descriptor, course_key): def _has_access_error_desc(user, action, descriptor, course_key):
""" """
Only staff should see error descriptors. Only staff should see error descriptors.
...@@ -408,38 +507,14 @@ def _has_access_descriptor(user, action, descriptor, course_key=None): ...@@ -408,38 +507,14 @@ def _has_access_descriptor(user, action, descriptor, course_key=None):
students to see modules. If not, views should check the course, so we students to see modules. If not, views should check the course, so we
don't have to hit the enrollments table on every module load. don't have to hit the enrollments table on every module load.
""" """
if descriptor.visible_to_staff_only and not _has_staff_access_to_descriptor(user, descriptor, course_key): return (
return False not descriptor.visible_to_staff_only
and _has_group_access(descriptor, user, course_key)
# enforce group access and (
if not _has_group_access(descriptor, user, course_key): 'detached' in descriptor._class_tags # pylint: disable=protected-access
# if group_access check failed, deny access unless the requestor is staff, or _can_access_descriptor_with_start_date(user, descriptor, course_key)
# in which case immediately grant access.
return _has_staff_access_to_descriptor(user, descriptor, course_key)
# If start dates are off, can always load
if settings.FEATURES['DISABLE_START_DATES'] and not is_masquerading_as_student(user, course_key):
debug("Allow: DISABLE_START_DATES")
return True
# Check start date
if 'detached' not in descriptor._class_tags and descriptor.start is not None:
now = datetime.now(UTC())
effective_start = _adjust_start_date_for_beta_testers(
user,
descriptor,
course_key=course_key
) )
if in_preview_mode() or now > effective_start: ) or _has_staff_access_to_descriptor(user, descriptor, course_key)
# after start date, everyone can see it
debug("Allow: now > effective start date")
return True
# otherwise, need staff access
return _has_staff_access_to_descriptor(user, descriptor, course_key)
# No start date, so can always load.
debug("Allow: no start date")
return True
checkers = { checkers = {
'load': can_load, 'load': can_load,
...@@ -700,4 +775,4 @@ def in_preview_mode(): ...@@ -700,4 +775,4 @@ def in_preview_mode():
Returns whether the user is in preview mode or not. Returns whether the user is in preview mode or not.
""" """
hostname = get_current_request_hostname() hostname = get_current_request_hostname()
return hostname and settings.PREVIEW_DOMAIN in hostname.split('.') return bool(hostname and settings.PREVIEW_DOMAIN in hostname.split('.'))
...@@ -152,6 +152,16 @@ def find_file(filesystem, dirs, filename): ...@@ -152,6 +152,16 @@ def find_file(filesystem, dirs, filename):
raise ResourceNotFoundError(u"Could not find {0}".format(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): def get_course_about_section(course, section_key):
""" """
This returns the snippet of html to be rendered on the course about page, 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): ...@@ -227,7 +237,7 @@ def get_course_about_section(course, section_key):
elif section_key == "title": elif section_key == "title":
return course.display_name_with_default return course.display_name_with_default
elif section_key == "university": elif section_key == "university":
return course.display_org_with_default return get_course_university_about_section(course)
elif section_key == "number": elif section_key == "number":
return course.display_number_with_default return course.display_number_with_default
......
...@@ -5,6 +5,8 @@ from django.core.urlresolvers import reverse ...@@ -5,6 +5,8 @@ from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory 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 from student.models import Registration
...@@ -137,3 +139,50 @@ class LoginEnrollmentTestCase(TestCase): ...@@ -137,3 +139,50 @@ class LoginEnrollmentTestCase(TestCase):
'course_id': course.id.to_deprecated_string(), 'course_id': course.id.to_deprecated_string(),
} }
self.assert_request_status_code(200, url, method="POST", data=request_data) 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 datetime
import ddt
import itertools
import pytz import pytz
from django.test import TestCase from django.test import TestCase
...@@ -9,8 +11,9 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey ...@@ -9,8 +11,9 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
import courseware.access as access import courseware.access as access
from courseware.masquerade import CourseMasquerade 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 courseware.tests.helpers import LoginEnrollmentTestCase
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.tests.factories import AnonymousUserFactory, CourseEnrollmentAllowedFactory, CourseEnrollmentFactory from student.tests.factories import AnonymousUserFactory, CourseEnrollmentAllowedFactory, CourseEnrollmentFactory
from xmodule.course_module import ( from xmodule.course_module import (
CATALOG_VISIBILITY_CATALOG_AND_ABOUT, CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_CATALOG_AND_ABOUT, CATALOG_VISIBILITY_ABOUT,
...@@ -18,6 +21,7 @@ from xmodule.course_module import ( ...@@ -18,6 +21,7 @@ from xmodule.course_module import (
) )
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from util.milestones_helpers import fulfill_course_milestone
from util.milestones_helpers import ( from util.milestones_helpers import (
set_prerequisite_courses, set_prerequisite_courses,
...@@ -424,3 +428,91 @@ class UserRoleTestCase(TestCase): ...@@ -424,3 +428,91 @@ class UserRoleTestCase(TestCase):
'student', 'student',
access.get_user_role(self.anonymous_user, self.course_key) 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 ...@@ -6,7 +6,7 @@ from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from courseware.access import has_access from courseware.access import has_access
from courseware.tests.helpers import LoginEnrollmentTestCase from courseware.tests.helpers import CourseAccessTestMixin, LoginEnrollmentTestCase
from courseware.tests.factories import ( from courseware.tests.factories import (
BetaTesterFactory, BetaTesterFactory,
StaffFactory, StaffFactory,
...@@ -389,7 +389,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -389,7 +389,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase):
@attr('shard_1') @attr('shard_1')
class TestBetatesterAccess(ModuleStoreTestCase): class TestBetatesterAccess(ModuleStoreTestCase, CourseAccessTestMixin):
""" """
Tests for the beta tester feature Tests for the beta tester feature
""" """
...@@ -411,12 +411,8 @@ class TestBetatesterAccess(ModuleStoreTestCase): ...@@ -411,12 +411,8 @@ class TestBetatesterAccess(ModuleStoreTestCase):
Check that beta-test access works for courses. Check that beta-test access works for courses.
""" """
self.assertFalse(self.course.has_started()) self.assertFalse(self.course.has_started())
self.assertCannotAccessCourse(self.normal_student, 'load', self.course)
# student user shouldn't see it self.assertCanAccessCourse(self.beta_tester, 'load', self.course)
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))
@patch.dict('courseware.access.settings.FEATURES', {'DISABLE_START_DATES': False}) @patch.dict('courseware.access.settings.FEATURES', {'DISABLE_START_DATES': False})
def test_content_beta_period(self): 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 _ from django.utils.translation import ugettext as _
...@@ -7,7 +7,7 @@ from courseware.courses import course_image_url, get_course_about_section ...@@ -7,7 +7,7 @@ from courseware.courses import course_image_url, get_course_about_section
from ccx_keys.locator import CCXLocator 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"> <li class="course-item">
<article class="course"> <article class="course">
...@@ -16,16 +16,16 @@ from ccx_keys.locator import CCXLocator ...@@ -16,16 +16,16 @@ from ccx_keys.locator import CCXLocator
% if show_courseware_link: % if show_courseware_link:
% if not is_course_blocked: % if not is_course_blocked:
<a href="${ccx_target}" class="cover"> <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> </a>
% else: % else:
<a class="fade-cover"> <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> </a>
% endif % endif
% else: % else:
<a class="cover"> <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> </a>
% endif % endif
</div> </div>
...@@ -43,7 +43,7 @@ from ccx_keys.locator import CCXLocator ...@@ -43,7 +43,7 @@ from ccx_keys.locator import CCXLocator
</h3> </h3>
<div class="course-info"> <div class="course-info">
<span class="info-university">${get_course_about_section(course, 'university')} - </span> <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"> <span class="info-date-block" data-tooltip="Hi">
% if ccx.has_ended(): % if ccx.has_ended():
${_("Ended - {end_date}").format(end_date=ccx.end_datetime_text("SHORT_DATE"))} ${_("Ended - {end_date}").format(end_date=ccx.end_datetime_text("SHORT_DATE"))}
......
...@@ -70,28 +70,28 @@ from django.core.urlresolvers import reverse ...@@ -70,28 +70,28 @@ from django.core.urlresolvers import reverse
</header> </header>
% if len(course_enrollment_pairs) > 0: % if len(course_enrollments) > 0:
<ul class="listing-courses"> <ul class="listing-courses">
<% share_settings = settings.FEATURES.get('SOCIAL_SHARING_SETTINGS', {}) %> <% share_settings = settings.FEATURES.get('SOCIAL_SHARING_SETTINGS', {}) %>
% for dashboard_index, (course, enrollment) in enumerate(course_enrollment_pairs): % for dashboard_index, enrollment in enumerate(course_enrollments):
<% show_courseware_link = (course.id in show_courseware_links_for) %> <% show_courseware_link = (enrollment.course_id in show_courseware_links_for) %>
<% cert_status = cert_statuses.get(course.id) %> <% cert_status = cert_statuses.get(enrollment.course_id) %>
<% credit_status = credit_statuses.get(course.id) %> <% credit_status = credit_statuses.get(enrollment.course_id) %>
<% show_email_settings = (course.id in show_email_settings_for) %> <% show_email_settings = (enrollment.course_id in show_email_settings_for) %>
<% course_mode_info = all_course_modes.get(course.id) %> <% course_mode_info = all_course_modes.get(enrollment.course_id) %>
<% show_refund_option = (course.id in show_refund_option_for) %> <% show_refund_option = (enrollment.course_id in show_refund_option_for) %>
<% is_paid_course = (course.id in enrolled_courses_either_paid) %> <% is_paid_course = (enrollment.course_id in enrolled_courses_either_paid) %>
<% is_course_blocked = (course.id in block_courses) %> <% is_course_blocked = (enrollment.course_id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(course.id, {}) %> <% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %>
<% course_requirements = courses_requirements_not_met.get(course.id) %> <% course_requirements = courses_requirements_not_met.get(enrollment.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" /> <%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 % endfor
% if settings.FEATURES.get('CUSTOM_COURSES_EDX', False): % if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
% for ccx, membership, course in ccx_membership_triplets: % for ccx, membership, course in ccx_membership_triplets:
<% show_courseware_link = ccx.has_started() %> <% show_courseware_link = ccx.has_started() %>
<% is_course_blocked = False %> <% 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 % endfor
% endif % endif
......
<%page args="cert_status, course, enrollment" /> <%page args="cert_status, course_overview, enrollment" />
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -7,11 +7,11 @@ from course_modes.models import CourseMode ...@@ -7,11 +7,11 @@ from course_modes.models import CourseMode
<%namespace name='static' file='../static_content.html'/> <%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 == "": if cert_name_short == "":
cert_name_short = settings.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 == "": if cert_name_long == "":
cert_name_long = settings.CERT_NAME_LONG cert_name_long = settings.CERT_NAME_LONG
%> %>
...@@ -35,7 +35,7 @@ else: ...@@ -35,7 +35,7 @@ else:
<span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>. <span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>.
% if cert_status['status'] == 'notpassing' and enrollment.mode != 'audit': % 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"> ${_("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': % elif cert_status['status'] == 'restricted' and enrollment.mode == 'verified':
<p class="message-copy"> <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)} ${_("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: ...@@ -88,7 +88,7 @@ else:
<li class="action action-share"> <li class="action action-share">
<a class="action-linkedin-profile" target="_blank" href="${cert_status['linked_in_url']}" <a class="action-linkedin-profile" target="_blank" href="${cert_status['linked_in_url']}"
title="${_('Add Certificate to LinkedIn Profile')}" 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']}" data-certificate-mode="${cert_status['mode']}"
> >
<img class="action-linkedin-profile-img" <img class="action-linkedin-profile-img"
......
<%page args="course, enrollment, show_courseware_link, cert_status, credit_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings" /> <%page args="course_overview, enrollment, show_courseware_link, cert_status, credit_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings" />
<%! <%!
import urllib import urllib
...@@ -7,7 +7,7 @@ from django.utils.translation import ugettext as _ ...@@ -7,7 +7,7 @@ from django.utils.translation import ugettext as _
from django.utils.translation import ungettext from django.utils.translation import ungettext
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from markupsafe import escape from markupsafe import escape
from courseware.courses import course_image_url, get_course_about_section from courseware.courses import get_course_university_about_section
from course_modes.models import CourseMode from course_modes.models import CourseMode
from student.helpers import ( from student.helpers import (
VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_NEED_TO_VERIFY,
...@@ -19,11 +19,11 @@ from student.helpers import ( ...@@ -19,11 +19,11 @@ from student.helpers import (
%> %>
<% <%
cert_name_short = course.cert_name_short cert_name_short = course_overview.cert_name_short
if cert_name_short == "": if cert_name_short == "":
cert_name_short = settings.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 == "": if cert_name_long == "":
cert_name_long = settings.CERT_NAME_LONG cert_name_long = settings.CERT_NAME_LONG
billing_email = settings.PAYMENT_SUPPORT_EMAIL billing_email = settings.PAYMENT_SUPPORT_EMAIL
...@@ -44,22 +44,22 @@ from student.helpers import ( ...@@ -44,22 +44,22 @@ from student.helpers import (
% endif % endif
<article class="course${mode_class}"> <article class="course${mode_class}">
<% course_target = reverse('info', args=[unicode(course.id)]) %> <% course_target = reverse('info', args=[unicode(course_overview.id)]) %>
<section class="details"> <section class="details">
<div class="wrapper-course-image" aria-hidden="true"> <div class="wrapper-course-image" aria-hidden="true">
% if show_courseware_link: % if show_courseware_link:
% if not is_course_blocked: % if not is_course_blocked:
<a href="${course_target}" class="cover"> <a href="${course_target}" class="cover">
<img src="${course_image_url(course)}" class="course-image" alt="${_('{course_number} {course_name} Home Page').format(course_number=course.number, course_name=course.display_name_with_default) |h}" /> <img src="${course_overview.course_image_url}" class="course-image" alt="${_('{course_number} {course_name} Home Page').format(course_number=course_overview.number, course_name=course_overview.display_name_with_default) |h}" />
</a> </a>
% else: % else:
<a class="fade-cover"> <a class="fade-cover">
<img src="${course_image_url(course)}" class="course-image" alt="${_('{course_number} {course_name} Cover Image').format(course_number=course.number, course_name=course.display_name_with_default) |h}" /> <img src="${course_overview.course_image_url}" class="course-image" alt="${_('{course_number} {course_name} Cover Image').format(course_number=course_overview.number, course_name=course_overview.display_name_with_default) |h}" />
</a> </a>
% endif % endif
% else: % else:
<a class="cover"> <a class="cover">
<img src="${course_image_url(course)}" class="course-image" alt="${_('{course_number} {course_name} Cover Image').format(course_number=course.number, course_name=course.display_name_with_default) | h}" /> <img src="${course_overview.course_image_url}" class="course-image" alt="${_('{course_number} {course_name} Cover Image').format(course_number=course_overview.number, course_name=course_overview.display_name_with_default) | h}" />
</a> </a>
% endif % endif
% if settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES'): % if settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES'):
...@@ -76,55 +76,55 @@ from student.helpers import ( ...@@ -76,55 +76,55 @@ from student.helpers import (
<h3 class="course-title"> <h3 class="course-title">
% if show_courseware_link: % if show_courseware_link:
% if not is_course_blocked: % if not is_course_blocked:
<a href="${course_target}">${course.display_name_with_default}</a> <a href="${course_target}">${course_overview.display_name_with_default}</a>
% else: % else:
<a class="disable-look">${course.display_name_with_default}</a> <a class="disable-look">${course_overview.display_name_with_default}</a>
% endif % endif
% else: % else:
<span>${course.display_name_with_default}</span> <span>${course_overview.display_name_with_default}</span>
% endif % endif
</h3> </h3>
<div class="course-info"> <div class="course-info">
<span class="info-university">${get_course_about_section(course, 'university')} - </span> <span class="info-university">${get_course_university_about_section(course_overview)} - </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"> <span class="info-date-block" data-tooltip="Hi">
% if course.has_ended(): % if course_overview.has_ended():
${_("Ended - {end_date}").format(end_date=course.end_datetime_text("SHORT_DATE"))} ${_("Ended - {end_date}").format(end_date=course_overview.end_datetime_text("SHORT_DATE"))}
% elif course.has_started(): % elif course_overview.has_started():
${_("Started - {start_date}").format(start_date=course.start_datetime_text("SHORT_DATE"))} ${_("Started - {start_date}").format(start_date=course_overview.start_datetime_text("SHORT_DATE"))}
% elif course.start_date_is_still_default: # Course start date TBD % elif course_overview.start_date_is_still_default: # Course start date TBD
${_("Coming Soon")} ${_("Coming Soon")}
% else: # hasn't started yet % else: # hasn't started yet
${_("Starts - {start_date}").format(start_date=course.start_datetime_text("SHORT_DATE"))} ${_("Starts - {start_date}").format(start_date=course_overview.start_datetime_text("SHORT_DATE"))}
% endif % endif
</span> </span>
</div> </div>
<div class="wrapper-course-actions"> <div class="wrapper-course-actions">
<div class="course-actions"> <div class="course-actions">
% if show_courseware_link: % if show_courseware_link:
% if course.has_ended(): % if course_overview.has_ended():
% if not is_course_blocked: % if not is_course_blocked:
<a href="${course_target}" class="enter-course archived">${_('View Archived Course')}<span class="sr">&nbsp;${course.display_name_with_default}</span></a> <a href="${course_target}" class="enter-course archived">${_('View Archived Course')}<span class="sr">&nbsp;${course_overview.display_name_with_default}</span></a>
% else: % else:
<a class="enter-course-blocked archived">${_('View Archived Course')}<span class="sr">&nbsp;${course.display_name_with_default}</span></a> <a class="enter-course-blocked archived">${_('View Archived Course')}<span class="sr">&nbsp;${course_overview.display_name_with_default}</span></a>
% endif % endif
% else: % else:
% if not is_course_blocked: % if not is_course_blocked:
<a href="${course_target}" class="enter-course">${_('View Course')}<span class="sr">&nbsp;${course.display_name_with_default}</span></a> <a href="${course_target}" class="enter-course">${_('View Course')}<span class="sr">&nbsp;${course_overview.display_name_with_default}</span></a>
% else: % else:
<a class="enter-course-blocked">${_('View Course')}<span class="sr">&nbsp;${course.display_name_with_default}</span></a> <a class="enter-course-blocked">${_('View Course')}<span class="sr">&nbsp;${course_overview.display_name_with_default}</span></a>
% endif % endif
% endif % endif
% if share_settings: % if share_settings:
<% <%
if share_settings.get("CUSTOM_COURSE_URLS", False): if share_settings.get("CUSTOM_COURSE_URLS", False):
if course.social_sharing_url: if course_overview.social_sharing_url:
share_url = urllib.quote_plus(course.social_sharing_url) share_url = urllib.quote_plus(course_overview.social_sharing_url)
else: else:
share_url = '' share_url = ''
else: else:
share_url = urllib.quote_plus(request.build_absolute_uri(reverse('about_course', args=[unicode(course.id)]))) share_url = urllib.quote_plus(request.build_absolute_uri(reverse('about_course', args=[unicode(course_overview.id)])))
share_window_name = 'shareWindow' share_window_name = 'shareWindow'
share_window_config = 'toolbar=no, location=no, status=no, menubar=no, scrollbars=yes, resizable=yes, width=640, height=480' share_window_config = 'toolbar=no, location=no, status=no, menubar=no, scrollbars=yes, resizable=yes, width=640, height=480'
%> %>
...@@ -163,7 +163,7 @@ from student.helpers import ( ...@@ -163,7 +163,7 @@ from student.helpers import (
% endif % endif
% endif % endif
<div class="wrapper-action-more"> <div class="wrapper-action-more">
<a href="#actions-dropdown-${dashboard_index}" class="action action-more" id="actions-dropdown-link-${dashboard_index}" aria-haspopup="true" aria-expanded="false" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}"> <a href="#actions-dropdown-${dashboard_index}" class="action action-more" id="actions-dropdown-link-${dashboard_index}" aria-haspopup="true" aria-expanded="false" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}">
<span class="sr">${_('Course options dropdown')}</span> <span class="sr">${_('Course options dropdown')}</span>
<i class="fa fa-cog" aria-hidden="true"></i> <i class="fa fa-cog" aria-hidden="true"></i>
</a> </a>
...@@ -173,12 +173,12 @@ from student.helpers import ( ...@@ -173,12 +173,12 @@ from student.helpers import (
% if is_paid_course and show_refund_option: % if is_paid_course and show_refund_option:
## Translators: The course name will be added to the end of this sentence. ## Translators: The course name will be added to the end of this sentence.
% if not is_course_blocked: % if not is_course_blocked:
<a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" data-course-id="${course.id | h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" <a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" data-course-id="${course_overview.id | h}" data-course_overview-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}"
onclick="set_unenroll_message('${_("Are you sure you want to unenroll from the purchased course %(course_number)s?")}', '${_("You will be refunded the amount you paid.")}')"> onclick="set_unenroll_message('${_("Are you sure you want to unenroll from the purchased course %(course_number)s?")}', '${_("You will be refunded the amount you paid.")}')">
${_('Unenroll')} ${_('Unenroll')}
</a> </a>
% else: % else:
<a class="action action-unenroll is-disabled" data-course-id="${course.id | h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" <a class="action action-unenroll is-disabled" data-course-id="${course_overview.id | h}" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}"
onclick="set_unenroll_message('${_("Are you sure you want to unenroll from the purchased course %(course_number)s?")}', '${_("You will be refunded the amount you paid.")}')"> onclick="set_unenroll_message('${_("Are you sure you want to unenroll from the purchased course %(course_number)s?")}', '${_("You will be refunded the amount you paid.")}')">
${_('Unenroll')} ${_('Unenroll')}
</a> </a>
...@@ -186,12 +186,12 @@ from student.helpers import ( ...@@ -186,12 +186,12 @@ from student.helpers import (
% elif is_paid_course and not show_refund_option: % elif is_paid_course and not show_refund_option:
## Translators: The course's name will be added to the end of this sentence. ## Translators: The course's name will be added to the end of this sentence.
% if not is_course_blocked: % if not is_course_blocked:
<a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" data-course-id="${course.id | h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" <a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" data-course-id="${course_overview.id | h}" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}"
onclick="set_unenroll_message('${_("Are you sure you want to unenroll from the purchased course %(course_number)s?")}', '${_("You will not be refunded the amount you paid.")}')"> onclick="set_unenroll_message('${_("Are you sure you want to unenroll from the purchased course %(course_number)s?")}', '${_("You will not be refunded the amount you paid.")}')">
${_('Unenroll')} ${_('Unenroll')}
</a> </a>
% else: % else:
<a class="action action-unenroll is-disabled" data-course-id="${course.id | h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" <a class="action action-unenroll is-disabled" data-course-id="${course_overview.id | h}" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}"
onclick="set_unenroll_message('${_("Are you sure you want to unenroll from the purchased course %(course_number)s?")}', '${_("You will not be refunded the amount you paid.")}')"> onclick="set_unenroll_message('${_("Are you sure you want to unenroll from the purchased course %(course_number)s?")}', '${_("You will not be refunded the amount you paid.")}')">
${_('Unenroll')} ${_('Unenroll')}
</a> </a>
...@@ -199,12 +199,12 @@ from student.helpers import ( ...@@ -199,12 +199,12 @@ from student.helpers import (
% elif enrollment.mode != "verified": % elif enrollment.mode != "verified":
## Translators: The course's name will be added to the end of this sentence. ## Translators: The course's name will be added to the end of this sentence.
% if not is_course_blocked: % if not is_course_blocked:
<a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" data-course-id="${course.id | h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" <a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" data-course-id="${course_overview.id | h}" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}"
onclick="set_unenroll_message('${_("Are you sure you want to unenroll from %(course_number)s?")}', '')"> onclick="set_unenroll_message('${_("Are you sure you want to unenroll from %(course_number)s?")}', '')">
${_('Unenroll')} ${_('Unenroll')}
</a> </a>
% else: % else:
<a class="action action-unenroll is-disabled" data-course-id="${course.id | h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" <a class="action action-unenroll is-disabled" data-course-id="${course_overview.id | h}" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}"
onclick="set_unenroll_message('${_("Are you sure you want to unenroll from %(course_number)s?")}', '')"> onclick="set_unenroll_message('${_("Are you sure you want to unenroll from %(course_number)s?")}', '')">
${_('Unenroll')} ${_('Unenroll')}
</a> </a>
...@@ -212,7 +212,7 @@ from student.helpers import ( ...@@ -212,7 +212,7 @@ from student.helpers import (
% elif show_refund_option: % elif show_refund_option:
## Translators: The course's name will be added to the end of this sentence. ## Translators: The course's name will be added to the end of this sentence.
% if not is_course_blocked: % if not is_course_blocked:
<a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" data-course-id="${course.id | h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" <a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" data-course-id="${course_overview.id | h}" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}"
onclick="set_unenroll_message( onclick="set_unenroll_message(
'${_("Are you sure you want to unenroll from the verified {cert_name_long} track of %(course_number)s?").format(cert_name_long=cert_name_long)}', '${_("Are you sure you want to unenroll from the verified {cert_name_long} track of %(course_number)s?").format(cert_name_long=cert_name_long)}',
'${_("You will be refunded the amount you paid.")}' '${_("You will be refunded the amount you paid.")}'
...@@ -221,7 +221,7 @@ from student.helpers import ( ...@@ -221,7 +221,7 @@ from student.helpers import (
${_('Unenroll')} ${_('Unenroll')}
</a> </a>
% else: % else:
<a class="action action-unenroll is-disabled" data-course-id="${course.id | h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" <a class="action action-unenroll is-disabled" data-course-id="${course_overview.id | h}" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}"
onclick="set_unenroll_message( onclick="set_unenroll_message(
'${_("Are you sure you want to unenroll from the verified {cert_name_long} track of %(course_number)s?").format(cert_name_long=cert_name_long)}', '${_("Are you sure you want to unenroll from the verified {cert_name_long} track of %(course_number)s?").format(cert_name_long=cert_name_long)}',
'${_("You will be refunded the amount you paid.")}' '${_("You will be refunded the amount you paid.")}'
...@@ -233,7 +233,7 @@ from student.helpers import ( ...@@ -233,7 +233,7 @@ from student.helpers import (
% else: % else:
## Translators: The course's name will be added to the end of this sentence. ## Translators: The course's name will be added to the end of this sentence.
% if not is_course_blocked: % if not is_course_blocked:
<a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" data-course-id="${course.id | h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" <a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" data-course-id="${course_overview.id | h}" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}"
onclick="set_unenroll_message( onclick="set_unenroll_message(
'${_("Are you sure you want to unenroll from the verified {cert_name_long} track of %(course_number)s?").format(cert_name_long=cert_name_long)}', '${_("Are you sure you want to unenroll from the verified {cert_name_long} track of %(course_number)s?").format(cert_name_long=cert_name_long)}',
'${_("The refund deadline for this course has passed, so you will not receive a refund.")}' '${_("The refund deadline for this course has passed, so you will not receive a refund.")}'
...@@ -242,7 +242,7 @@ from student.helpers import ( ...@@ -242,7 +242,7 @@ from student.helpers import (
${_('Unenroll')} ${_('Unenroll')}
</a> </a>
% else: % else:
<a class="action action-unenroll is-disabled" data-course-id="${course.id | h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" <a class="action action-unenroll is-disabled" data-course-id="${course_overview.id | h}" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}"
onclick="set_unenroll_message( onclick="set_unenroll_message(
'${_("Are you sure you want to unenroll from the verified {cert_name_long} track of %(course_number)s?").format(cert_name_long=cert_name_long)}', '${_("Are you sure you want to unenroll from the verified {cert_name_long} track of %(course_number)s?").format(cert_name_long=cert_name_long)}',
'${_("The refund deadline for this course has passed, so you will not receive a refund.")}' '${_("The refund deadline for this course has passed, so you will not receive a refund.")}'
...@@ -256,9 +256,9 @@ from student.helpers import ( ...@@ -256,9 +256,9 @@ from student.helpers import (
<li class="actions-item" id="actions-item-email-settings-${dashboard_index}"> <li class="actions-item" id="actions-item-email-settings-${dashboard_index}">
% if show_email_settings: % if show_email_settings:
% if not is_course_blocked: % if not is_course_blocked:
<a href="#email-settings-modal" class="action action-email-settings" rel="leanModal" data-course-id="${course.id | h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" data-optout="${unicode(course.id) in course_optouts}">${_('Email Settings')}</a> <a href="#email-settings-modal" class="action action-email-settings" rel="leanModal" data-course-id="${course_overview.id | h}" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}" data-optout="${unicode(course_overview.id) in course_optouts}">${_('Email Settings')}</a>
% else: % else:
<a class="action action-email-settings is-disabled" data-course-id="${course.id| h}" data-course-number="${course.number | h}" data-dashboard-index="${dashboard_index}" data-optout="${unicode(course.id) in course_optouts}">${_('Email Settings')}</a> <a class="action action-email-settings is-disabled" data-course-id="${course_overview.id| h}" data-course-number="${course_overview.number | h}" data-dashboard-index="${dashboard_index}" data-optout="${unicode(course_overview.id) in course_optouts}">${_('Email Settings')}</a>
% endif % endif
% endif % endif
</li> </li>
...@@ -271,8 +271,8 @@ from student.helpers import ( ...@@ -271,8 +271,8 @@ from student.helpers import (
</section> </section>
<footer class="wrapper-messages-primary"> <footer class="wrapper-messages-primary">
<ul class="messages-list"> <ul class="messages-list">
% if course.may_certify() and cert_status: % if course_overview.may_certify() and cert_status:
<%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course=course, enrollment=enrollment'/> <%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course_overview=course_overview, enrollment=enrollment'/>
% endif % endif
% if credit_status is not None: % if credit_status is not None:
...@@ -296,7 +296,7 @@ from student.helpers import ( ...@@ -296,7 +296,7 @@ from student.helpers import (
% endif % endif
</div> </div>
<div class="verification-cta"> <div class="verification-cta">
<a href="${reverse('verify_student_verify_now', kwargs={'course_id': unicode(course.id)})}" class="cta" data-course-id="${course.id | h}">${_('Verify Now')}</a> <a href="${reverse('verify_student_verify_now', kwargs={'course_id': unicode(course_overview.id)})}" class="cta" data-course-id="${course_overview.id | h}">${_('Verify Now')}</a>
</div> </div>
% elif verification_status['status'] == VERIFY_STATUS_SUBMITTED: % elif verification_status['status'] == VERIFY_STATUS_SUBMITTED:
<h4 class="message-title">${_('You have already verified your ID!')}</h4> <h4 class="message-title">${_('You have already verified your ID!')}</h4>
...@@ -329,7 +329,7 @@ from student.helpers import ( ...@@ -329,7 +329,7 @@ from student.helpers import (
<ul class="actions message-actions"> <ul class="actions message-actions">
<li class="action-item"> <li class="action-item">
<a class="action action-upgrade" href="${reverse('verify_student_upgrade_and_verify', kwargs={'course_id': unicode(course.id)})}" data-course-id="${course.id | h}" data-user="${user.username | h}"> <a class="action action-upgrade" href="${reverse('verify_student_upgrade_and_verify', kwargs={'course_id': unicode(course_overview.id)})}" data-course-id="${course_overview.id | h}" data-user="${user.username | h}">
<img class="deco-graphic" src="${static.url('images/vcert-ribbon-s.png')}" alt="${_("ID Verified Ribbon/Badge")}"> <img class="deco-graphic" src="${static.url('images/vcert-ribbon-s.png')}" alt="${_("ID Verified Ribbon/Badge")}">
<span class="wrapper-copy"> <span class="wrapper-copy">
<span class="copy" id="upgrade-to-verified">${_("Upgrade to Verified Track")}</span> <span class="copy" id="upgrade-to-verified">${_("Upgrade to Verified Track")}</span>
...@@ -354,8 +354,8 @@ from student.helpers import ( ...@@ -354,8 +354,8 @@ from student.helpers import (
'<a id="unregister_block_course" rel="leanModal" ' '<a id="unregister_block_course" rel="leanModal" '
'data-course-id="{course_id}" data-course-number="{course_number}" ' 'data-course-id="{course_id}" data-course-number="{course_number}" '
'href="#unenroll-modal">'.format( 'href="#unenroll-modal">'.format(
course_id=escape(course.id), course_id=escape(course_overview.id),
course_number=escape(course.number), course_number=escape(course_overview.number),
) )
), ),
unenroll_link_end="</a>", unenroll_link_end="</a>",
......
# -*- 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 ...@@ -5,13 +5,13 @@ Declaration of CourseOverview model
import json import json
import django.db.models 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 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 util.date_utils import strftime_localized
from xmodule import course_metadata_utils 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.modulestore.django import modulestore
from xmodule_django.models import CourseKeyField, UsageKeyField from xmodule_django.models import CourseKeyField, UsageKeyField
...@@ -46,6 +46,7 @@ class CourseOverview(django.db.models.Model): ...@@ -46,6 +46,7 @@ class CourseOverview(django.db.models.Model):
# Certification data # Certification data
certificates_display_behavior = TextField(null=True) certificates_display_behavior = TextField(null=True)
certificates_show_before_end = BooleanField() certificates_show_before_end = BooleanField()
cert_html_view_enabled = BooleanField()
has_any_active_web_certificate = BooleanField() has_any_active_web_certificate = BooleanField()
cert_name_short = TextField() cert_name_short = TextField()
cert_name_long = TextField() cert_name_long = TextField()
...@@ -54,6 +55,7 @@ class CourseOverview(django.db.models.Model): ...@@ -54,6 +55,7 @@ class CourseOverview(django.db.models.Model):
lowest_passing_grade = DecimalField(max_digits=5, decimal_places=2) lowest_passing_grade = DecimalField(max_digits=5, decimal_places=2)
# Access parameters # Access parameters
days_early_for_beta = FloatField(null=True)
mobile_available = BooleanField() mobile_available = BooleanField()
visible_to_staff_only = BooleanField() visible_to_staff_only = BooleanField()
_pre_requisite_courses_json = TextField() # JSON representation of list of CourseKey strings _pre_requisite_courses_json = TextField() # JSON representation of list of CourseKey strings
...@@ -72,6 +74,9 @@ class CourseOverview(django.db.models.Model): ...@@ -72,6 +74,9 @@ class CourseOverview(django.db.models.Model):
Returns: Returns:
CourseOverview: overview extracted from the given course 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( return CourseOverview(
id=course.id, id=course.id,
_location=course.location, _location=course.location,
...@@ -89,12 +94,14 @@ class CourseOverview(django.db.models.Model): ...@@ -89,12 +94,14 @@ class CourseOverview(django.db.models.Model):
certificates_display_behavior=course.certificates_display_behavior, certificates_display_behavior=course.certificates_display_behavior,
certificates_show_before_end=course.certificates_show_before_end, 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), has_any_active_web_certificate=(get_active_web_certificate(course) is not None),
cert_name_short=course.cert_name_short, cert_name_short=course.cert_name_short,
cert_name_long=course.cert_name_long, cert_name_long=course.cert_name_long,
lowest_passing_grade=course.lowest_passing_grade, lowest_passing_grade=course.lowest_passing_grade,
end_of_course_survey_url=course.end_of_course_survey_url, 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, mobile_available=course.mobile_available,
visible_to_staff_only=course.visible_to_staff_only, visible_to_staff_only=course.visible_to_staff_only,
_pre_requisite_courses_json=json.dumps(course.pre_requisite_courses) _pre_requisite_courses_json=json.dumps(course.pre_requisite_courses)
...@@ -111,10 +118,17 @@ class CourseOverview(django.db.models.Model): ...@@ -111,10 +118,17 @@ class CourseOverview(django.db.models.Model):
future use. future use.
Arguments: 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: 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 course_overview = None
try: try:
...@@ -123,9 +137,17 @@ class CourseOverview(django.db.models.Model): ...@@ -123,9 +137,17 @@ class CourseOverview(django.db.models.Model):
store = modulestore() store = modulestore()
with store.bulk_operations(course_id): with store.bulk_operations(course_id):
course = store.get_course(course_id) course = store.get_course(course_id)
if course: if isinstance(course, CourseDescriptor):
course_overview = CourseOverview._create_from_course(course) 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 return course_overview
def clean_id(self, padding_char='='): def clean_id(self, padding_char='='):
......
...@@ -6,13 +6,16 @@ import ddt ...@@ -6,13 +6,16 @@ import ddt
import itertools import itertools
import pytz import pytz
import math import math
import mock
from django.utils import timezone from django.utils import timezone
from lms.djangoapps.certificates.api import get_active_web_certificate from lms.djangoapps.certificates.api import get_active_web_certificate
from lms.djangoapps.courseware.courses import course_image_url from lms.djangoapps.courseware.courses import course_image_url
from xmodule.course_metadata_utils import DEFAULT_START_DATE from xmodule.course_metadata_utils import DEFAULT_START_DATE
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls, check_mongo_calls_range from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls, check_mongo_calls_range
...@@ -41,12 +44,18 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -41,12 +44,18 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
- the CourseDescriptor itself - the CourseDescriptor itself
- a CourseOverview that was newly constructed from _create_from_course - a CourseOverview that was newly constructed from _create_from_course
- a CourseOverview that was loaded from the MySQL database - a CourseOverview that was loaded from the MySQL database
Arguments:
course (CourseDescriptor): the course to be checked.
""" """
def get_seconds_since_epoch(date_time): def get_seconds_since_epoch(date_time):
""" """
Returns the number of seconds between the Unix Epoch and the given Returns the number of seconds between the Unix Epoch and the given
datetime. If the given datetime is None, return None. datetime. If the given datetime is None, return None.
Arguments:
date_time (datetime): the datetime in question.
""" """
if date_time is None: if date_time is None:
return None return None
...@@ -189,18 +198,14 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -189,18 +198,14 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
by comparing pairs of them given a variety of scenarios. by comparing pairs of them given a variety of scenarios.
Arguments: Arguments:
course_kwargs (dict): kwargs to be passed to course constructor course_kwargs (dict): kwargs to be passed to course constructor.
modulestore_type (ModuleStoreEnum.Type) modulestore_type (ModuleStoreEnum.Type): type of store to create the
is_user_enrolled (bool) course in.
""" """
# Note: We specify a value for 'run' here because, for some reason,
course = CourseFactory.create( # .create raises an InvalidKeyError if we don't (even though my
course="TEST101", # other test functions don't specify a run but work fine).
org="edX", course = CourseFactory.create(default_store=modulestore_type, run="TestRun", **course_kwargs)
run="Run1",
default_store=modulestore_type,
**course_kwargs
)
self.check_course_overview_against_course(course) self.check_course_overview_against_course(course)
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
...@@ -208,17 +213,15 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -208,17 +213,15 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
""" """
Tests that when a course is published, the corresponding Tests that when a course is published, the corresponding
course_overview is removed from the cache. 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): with self.store.default_store(modulestore_type):
# Create a course where mobile_available is True. # Create a course where mobile_available is True.
course = CourseFactory.create( course = CourseFactory.create(mobile_available=True, default_store=modulestore_type)
course="TEST101",
org="edX",
run="Run1",
mobile_available=True,
default_store=modulestore_type
)
course_overview_1 = CourseOverview.get_from_id(course.id) course_overview_1 = CourseOverview.get_from_id(course.id)
self.assertTrue(course_overview_1.mobile_available) self.assertTrue(course_overview_1.mobile_available)
...@@ -238,14 +241,16 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -238,14 +241,16 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
def test_course_overview_caching(self, modulestore_type, min_mongo_calls, max_mongo_calls): def test_course_overview_caching(self, modulestore_type, min_mongo_calls, max_mongo_calls):
""" """
Tests that CourseOverview structures are actually getting cached. 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 = CourseFactory.create(default_store=modulestore_type)
course="TEST101",
org="edX",
run="Run1",
mobile_available=True,
default_store=modulestore_type
)
# The first time we load a CourseOverview, it will be a cache miss, so # The first time we load a CourseOverview, it will be a cache miss, so
# we expect the modulestore to be queried. # we expect the modulestore to be queried.
...@@ -256,3 +261,36 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -256,3 +261,36 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
# we expect no modulestore queries to be made. # we expect no modulestore queries to be made.
with check_mongo_calls(0): with check_mongo_calls(0):
_course_overview_2 = CourseOverview.get_from_id(course.id) _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