Commit c14c146d by Nimisha Asthagiri

Merge pull request #10841 from edx/mobile/optimize-course-api

Optimize Course Catalog using CourseOverview
parents d54ac1eb 2b8441a0
...@@ -150,10 +150,9 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase): ...@@ -150,10 +150,9 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
throttle = EnrollmentUserThrottle() throttle = EnrollmentUserThrottle()
self.rate_limit, rate_duration = throttle.parse_rate(throttle.rate) self.rate_limit, rate_duration = throttle.parse_rate(throttle.rate)
self.course = CourseFactory.create() # Pass emit_signals when creating the course so it would be cached
# Load a CourseOverview. This initial load should result in a cache # as a CourseOverview.
# miss; the modulestore is queried and course metadata is cached. self.course = CourseFactory.create(emit_signals=True)
__ = CourseOverview.get_from_id(self.course.id)
self.user = UserFactory.create( self.user = UserFactory.create(
username=self.USERNAME, username=self.USERNAME,
...@@ -336,7 +335,7 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase): ...@@ -336,7 +335,7 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
requesting user. requesting user.
""" """
# Create another course, and enroll self.user in both courses. # Create another course, and enroll self.user in both courses.
other_course = CourseFactory.create() other_course = CourseFactory.create(emit_signals=True)
for course in self.course, other_course: for course in self.course, other_course:
CourseModeFactory.create( CourseModeFactory.create(
course_id=unicode(course.id), course_id=unicode(course.id),
...@@ -345,7 +344,7 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase): ...@@ -345,7 +344,7 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
) )
self.assert_enrollment_status( self.assert_enrollment_status(
course_id=unicode(course.id), course_id=unicode(course.id),
max_mongo_calls=1, max_mongo_calls=0,
) )
# Verify the user himself can see both of his enrollments. # Verify the user himself can see both of his enrollments.
self._assert_enrollments_visible_in_list([self.course, other_course]) self._assert_enrollments_visible_in_list([self.course, other_course])
......
...@@ -5,9 +5,10 @@ This is a place to put simple functions that operate on course metadata. It ...@@ -5,9 +5,10 @@ This is a place to put simple functions that operate on course metadata. It
allows us to share code between the CourseDescriptor and CourseOverview allows us to share code between the CourseDescriptor and CourseOverview
classes, which both need these type of functions. classes, which both need these type of functions.
""" """
from datetime import datetime
from datetime import timedelta
from base64 import b32encode from base64 import b32encode
from datetime import datetime, timedelta
import dateutil.parser
from math import exp
from django.utils.timezone import UTC from django.utils.timezone import UTC
...@@ -222,3 +223,43 @@ def may_certify_for_course(certificates_display_behavior, certificates_show_befo ...@@ -222,3 +223,43 @@ def may_certify_for_course(certificates_display_behavior, certificates_show_befo
or certificates_show_before_end or certificates_show_before_end
) )
return show_early or has_ended return show_early or has_ended
def sorting_score(start, advertised_start, announcement):
"""
Returns a tuple that can be used to sort the courses according
to how "new" they are. The "newness" score is computed using a
heuristic that takes into account the announcement and
(advertised) start dates of the course if available.
The lower the number the "newer" the course.
"""
# Make courses that have an announcement date have a lower
# score than courses than don't, older courses should have a
# higher score.
announcement, start, now = sorting_dates(start, advertised_start, announcement)
scale = 300.0 # about a year
if announcement:
days = (now - announcement).days
score = -exp(-days / scale)
else:
days = (now - start).days
score = exp(days / scale)
return score
def sorting_dates(start, advertised_start, announcement):
"""
Utility function to get datetime objects for dates used to
compute the is_new flag and the sorting_score.
"""
try:
start = dateutil.parser.parse(advertised_start)
if start.tzinfo is None:
start = start.replace(tzinfo=UTC())
except (ValueError, AttributeError):
start = start
now = datetime.now(UTC())
return announcement, start, now
...@@ -3,12 +3,10 @@ Django module container for classes and operations related to the "Course Module ...@@ -3,12 +3,10 @@ Django module container for classes and operations related to the "Course Module
""" """
import logging import logging
from cStringIO import StringIO from cStringIO import StringIO
from math import exp
from lxml import etree from lxml import etree
from path import Path as path from path import Path as path
import requests import requests
from datetime import datetime from datetime import datetime
import dateutil.parser
from lazy import lazy from lazy import lazy
from xmodule import course_metadata_utils from xmodule import course_metadata_utils
...@@ -1264,7 +1262,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin): ...@@ -1264,7 +1262,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
flag = self.is_new flag = self.is_new
if flag is None: if flag is None:
# Use a heuristic if the course has not been flagged # Use a heuristic if the course has not been flagged
announcement, start, now = self._sorting_dates() announcement, start, now = course_metadata_utils.sorting_dates(
self.start, self.advertised_start, self.announcement
)
if announcement and (now - announcement).days < 30: if announcement and (now - announcement).days < 30:
# The course has been announced for less that month # The course has been announced for less that month
return True return True
...@@ -1284,41 +1284,11 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin): ...@@ -1284,41 +1284,11 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
Returns a tuple that can be used to sort the courses according Returns a tuple that can be used to sort the courses according
the how "new" they are. The "newness" score is computed using a the how "new" they are. The "newness" score is computed using a
heuristic that takes into account the announcement and heuristic that takes into account the announcement and
(advertized) start dates of the course if available. (advertised) start dates of the course if available.
The lower the number the "newer" the course. The lower the number the "newer" the course.
""" """
# Make courses that have an announcement date shave a lower return course_metadata_utils.sorting_score(self.start, self.advertised_start, self.announcement)
# score than courses than don't, older courses should have a
# higher score.
announcement, start, now = self._sorting_dates()
scale = 300.0 # about a year
if announcement:
days = (now - announcement).days
score = -exp(-days / scale)
else:
days = (now - start).days
score = exp(days / scale)
return score
def _sorting_dates(self):
# utility function to get datetime objects for dates used to
# compute the is_new flag and the sorting_score
announcement = self.announcement
if announcement is not None:
announcement = announcement
try:
start = dateutil.parser.parse(self.advertised_start)
if start.tzinfo is None:
start = start.replace(tzinfo=UTC())
except (ValueError, AttributeError):
start = self.start
now = datetime.now(UTC())
return announcement, start, now
@lazy @lazy
def grading_context(self): def grading_context(self):
......
...@@ -150,14 +150,14 @@ class IsNewCourseTestCase(unittest.TestCase): ...@@ -150,14 +150,14 @@ class IsNewCourseTestCase(unittest.TestCase):
# Needed for test_is_newish # Needed for test_is_newish
datetime_patcher = patch.object( datetime_patcher = patch.object(
xmodule.course_module, 'datetime', xmodule.course_metadata_utils, 'datetime',
Mock(wraps=datetime) Mock(wraps=datetime)
) )
mocked_datetime = datetime_patcher.start() mocked_datetime = datetime_patcher.start()
mocked_datetime.now.return_value = NOW mocked_datetime.now.return_value = NOW
self.addCleanup(datetime_patcher.stop) self.addCleanup(datetime_patcher.stop)
@patch('xmodule.course_module.datetime.now') @patch('xmodule.course_metadata_utils.datetime.now')
def test_sorting_score(self, gmtime_mock): def test_sorting_score(self, gmtime_mock):
gmtime_mock.return_value = NOW gmtime_mock.return_value = NOW
...@@ -208,7 +208,7 @@ class IsNewCourseTestCase(unittest.TestCase): ...@@ -208,7 +208,7 @@ class IsNewCourseTestCase(unittest.TestCase):
(xmodule.course_module.CourseFields.start.default, 'January 2014', 'January 2014', False, 'January 2014'), (xmodule.course_module.CourseFields.start.default, 'January 2014', 'January 2014', False, 'January 2014'),
] ]
@patch('xmodule.course_module.datetime.now') @patch('xmodule.course_metadata_utils.datetime.now')
def test_start_date_text(self, gmtime_mock): def test_start_date_text(self, gmtime_mock):
gmtime_mock.return_value = NOW gmtime_mock.return_value = NOW
for s in self.start_advertised_settings: for s in self.start_advertised_settings:
...@@ -216,7 +216,7 @@ class IsNewCourseTestCase(unittest.TestCase): ...@@ -216,7 +216,7 @@ class IsNewCourseTestCase(unittest.TestCase):
print "Checking start=%s advertised=%s" % (s[0], s[1]) print "Checking start=%s advertised=%s" % (s[0], s[1])
self.assertEqual(d.start_datetime_text(), s[2]) self.assertEqual(d.start_datetime_text(), s[2])
@patch('xmodule.course_module.datetime.now') @patch('xmodule.course_metadata_utils.datetime.now')
def test_start_date_time_text(self, gmtime_mock): def test_start_date_time_text(self, gmtime_mock):
gmtime_mock.return_value = NOW gmtime_mock.return_value = NOW
for setting in self.start_advertised_settings: for setting in self.start_advertised_settings:
......
...@@ -178,7 +178,6 @@ class AdvancedSettingsPage(CoursePage): ...@@ -178,7 +178,6 @@ class AdvancedSettingsPage(CoursePage):
'display_name', 'display_name',
'info_sidebar_name', 'info_sidebar_name',
'is_new', 'is_new',
'ispublic',
'issue_badges', 'issue_badges',
'max_student_enrollments_allowed', 'max_student_enrollments_allowed',
'no_grade', 'no_grade',
......
...@@ -14,27 +14,22 @@ from django.conf import settings ...@@ -14,27 +14,22 @@ from django.conf import settings
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from microsite_configuration import microsite from microsite_configuration import microsite
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
def get_visible_courses(): def get_visible_courses():
""" """
Return the set of CourseDescriptors that should be visible in this branded instance Return the set of CourseDescriptors that should be visible in this branded instance
""" """
filtered_by_org = microsite.get_value('course_org_filter') filtered_by_org = microsite.get_value('course_org_filter')
courses = CourseOverview.get_all_courses(org=filtered_by_org)
_courses = modulestore().get_courses(org=filtered_by_org)
courses = [c for c in _courses
if isinstance(c, CourseDescriptor)]
courses = sorted(courses, key=lambda course: course.number) courses = sorted(courses, key=lambda course: course.number)
subdomain = microsite.get_value('subdomain', 'default')
# See if we have filtered course listings in this domain # See if we have filtered course listings in this domain
filtered_visible_ids = None filtered_visible_ids = None
# this is legacy format which is outside of the microsite feature -- also handle dev case, which should not filter # this is legacy format which is outside of the microsite feature -- also handle dev case, which should not filter
subdomain = microsite.get_value('subdomain', 'default')
if hasattr(settings, 'COURSE_LISTINGS') and subdomain in settings.COURSE_LISTINGS and not settings.DEBUG: if hasattr(settings, 'COURSE_LISTINGS') and subdomain in settings.COURSE_LISTINGS and not settings.DEBUG:
filtered_visible_ids = frozenset( filtered_visible_ids = frozenset(
[SlashSeparatedCourseKey.from_deprecated_string(c) for c in settings.COURSE_LISTINGS[subdomain]] [SlashSeparatedCourseKey.from_deprecated_string(c) for c in settings.COURSE_LISTINGS[subdomain]]
......
...@@ -8,7 +8,7 @@ import json ...@@ -8,7 +8,7 @@ import json
from django.http import HttpResponse from django.http import HttpResponse
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.courses import get_course_with_access from courseware.courses import get_course_overview_with_access
from courseware.access import has_access from courseware.access import has_access
from class_dashboard import dashboard_data from class_dashboard import dashboard_data
...@@ -21,7 +21,7 @@ def has_instructor_access_for_class(user, course_id): ...@@ -21,7 +21,7 @@ def has_instructor_access_for_class(user, course_id):
Returns true if the `user` is an instructor for the course. Returns true if the `user` is an instructor for the course.
""" """
course = get_course_with_access(user, 'staff', course_id, depth=None) course = get_course_overview_with_access(user, 'staff', course_id)
return bool(has_access(user, 'staff', course)) return bool(has_access(user, 'staff', course))
......
...@@ -3,10 +3,13 @@ Course API ...@@ -3,10 +3,13 @@ Course API
""" """
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.http import Http404 from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import NotFound, PermissionDenied
from lms.djangoapps.courseware.courses import get_courses, get_course_with_access from lms.djangoapps.courseware.courses import (
get_courses,
get_course_overview_with_access,
get_permission_for_course_about,
)
from .permissions import can_view_courses_for_username from .permissions import can_view_courses_for_username
...@@ -43,11 +46,11 @@ def course_detail(request, username, course_key): ...@@ -43,11 +46,11 @@ def course_detail(request, username, course_key):
`CourseDescriptor` object representing the requested course `CourseDescriptor` object representing the requested course
""" """
user = get_effective_user(request.user, username) user = get_effective_user(request.user, username)
try: return get_course_overview_with_access(
course = get_course_with_access(user, 'see_exists', course_key) user,
except Http404: get_permission_for_course_about(),
raise NotFound() course_key,
return course )
def list_courses(request, username): def list_courses(request, username):
...@@ -71,5 +74,4 @@ def list_courses(request, username): ...@@ -71,5 +74,4 @@ def list_courses(request, username):
List of `CourseDescriptor` objects representing the collection of courses. List of `CourseDescriptor` objects representing the collection of courses.
""" """
user = get_effective_user(request.user, username) user = get_effective_user(request.user, username)
courses = get_courses(user) return get_courses(user)
return courses
...@@ -39,8 +39,6 @@ class BlocksView(DeveloperErrorViewMixin, ListAPIView): ...@@ -39,8 +39,6 @@ class BlocksView(DeveloperErrorViewMixin, ListAPIView):
* username: (string) The name of the user on whose behalf we want to * username: (string) The name of the user on whose behalf we want to
see the data. see the data.
Default is the logged in user
Example: username=anjali Example: username=anjali
* student_view_data: (list) Indicates for which block types to return * student_view_data: (list) Indicates for which block types to return
......
...@@ -5,42 +5,32 @@ Course API Serializers. Representing course catalog data ...@@ -5,42 +5,32 @@ Course API Serializers. Representing course catalog data
import urllib import urllib
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.template import defaultfilters
from rest_framework import serializers from rest_framework import serializers
from lms.djangoapps.courseware.courses import get_course_about_section
from openedx.core.lib.courses import course_image_url
from openedx.core.djangoapps.models.course_details import CourseDetails
from xmodule.course_module import DEFAULT_START_DATE
class _MediaSerializer(serializers.Serializer): # pylint: disable=abstract-method class _MediaSerializer(serializers.Serializer): # pylint: disable=abstract-method
""" """
Nested serializer to represent a media object. Nested serializer to represent a media object.
""" """
def __init__(self, uri_parser, *args, **kwargs): def __init__(self, uri_attribute, *args, **kwargs):
super(_MediaSerializer, self).__init__(*args, **kwargs) super(_MediaSerializer, self).__init__(*args, **kwargs)
self.uri_parser = uri_parser self.uri_attribute = uri_attribute
uri = serializers.SerializerMethodField(source='*') uri = serializers.SerializerMethodField(source='*')
def get_uri(self, course): def get_uri(self, course_overview):
""" """
Get the representation for the media resource's URI Get the representation for the media resource's URI
""" """
return self.uri_parser(course) return getattr(course_overview, self.uri_attribute)
class _CourseApiMediaCollectionSerializer(serializers.Serializer): # pylint: disable=abstract-method class _CourseApiMediaCollectionSerializer(serializers.Serializer): # pylint: disable=abstract-method
""" """
Nested serializer to represent a collection of media objects Nested serializer to represent a collection of media objects
""" """
course_image = _MediaSerializer(source='*', uri_parser=course_image_url) course_image = _MediaSerializer(source='*', uri_attribute='course_image_url')
course_video = _MediaSerializer( course_video = _MediaSerializer(source='*', uri_attribute='course_video_url')
source='*',
uri_parser=lambda course: CourseDetails.fetch_video_url(course.id),
)
class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-method class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-method
...@@ -52,14 +42,14 @@ class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-meth ...@@ -52,14 +42,14 @@ class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-meth
name = serializers.CharField(source='display_name_with_default') name = serializers.CharField(source='display_name_with_default')
number = serializers.CharField(source='display_number_with_default') number = serializers.CharField(source='display_number_with_default')
org = serializers.CharField(source='display_org_with_default') org = serializers.CharField(source='display_org_with_default')
short_description = serializers.SerializerMethodField() short_description = serializers.CharField()
effort = serializers.SerializerMethodField() effort = serializers.CharField()
media = _CourseApiMediaCollectionSerializer(source='*') media = _CourseApiMediaCollectionSerializer(source='*')
start = serializers.DateTimeField() start = serializers.DateTimeField()
start_type = serializers.SerializerMethodField() start_type = serializers.CharField()
start_display = serializers.SerializerMethodField() start_display = serializers.CharField()
end = serializers.DateTimeField() end = serializers.DateTimeField()
enrollment_start = serializers.DateTimeField() enrollment_start = serializers.DateTimeField()
...@@ -67,46 +57,12 @@ class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-meth ...@@ -67,46 +57,12 @@ class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-meth
blocks_url = serializers.SerializerMethodField() blocks_url = serializers.SerializerMethodField()
def get_start_type(self, course): def get_blocks_url(self, course_overview):
"""
Get the representation for SerializerMethodField `start_type`
"""
if course.advertised_start is not None:
return u'string'
elif course.start != DEFAULT_START_DATE:
return u'timestamp'
else:
return u'empty'
def get_start_display(self, course):
"""
Get the representation for SerializerMethodField `start_display`
"""
if course.advertised_start is not None:
return course.advertised_start
elif course.start != DEFAULT_START_DATE:
return defaultfilters.date(course.start, "DATE_FORMAT")
else:
return None
def get_short_description(self, course):
"""
Get the representation for SerializerMethodField `short_description`
"""
return get_course_about_section(self.context['request'], course, 'short_description').strip()
def get_blocks_url(self, course):
""" """
Get the representation for SerializerMethodField `blocks_url` Get the representation for SerializerMethodField `blocks_url`
""" """
base_url = '?'.join([ base_url = '?'.join([
reverse('blocks_in_course'), reverse('blocks_in_course'),
urllib.urlencode({'course_id': course.id}), urllib.urlencode({'course_id': course_overview.id}),
]) ])
return self.context['request'].build_absolute_uri(base_url) return self.context['request'].build_absolute_uri(base_url)
def get_effort(self, course):
"""
Get the representation for SerializerMethodField `effort`
"""
return CourseDetails.fetch_effort(course.id)
...@@ -26,6 +26,7 @@ class CourseApiFactoryMixin(object): ...@@ -26,6 +26,7 @@ class CourseApiFactoryMixin(object):
end=datetime(2015, 9, 19, 18, 0, 0), end=datetime(2015, 9, 19, 18, 0, 0),
enrollment_start=datetime(2015, 6, 15, 0, 0, 0), enrollment_start=datetime(2015, 6, 15, 0, 0, 0),
enrollment_end=datetime(2015, 7, 15, 0, 0, 0), enrollment_end=datetime(2015, 7, 15, 0, 0, 0),
emit_signals=True,
**kwargs **kwargs
) )
......
...@@ -3,13 +3,15 @@ Test for course API ...@@ -3,13 +3,15 @@ Test for course API
""" """
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from rest_framework.exceptions import NotFound, PermissionDenied from django.http import Http404
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
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.tests.django_utils import SharedModuleStoreTestCase, ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, ModuleStoreTestCase
from xmodule.course_module import CourseDescriptor from xmodule.modulestore.tests.factories import check_mongo_calls
from ..api import course_detail, list_courses from ..api import course_detail, list_courses
from .mixins import CourseApiFactoryMixin from .mixins import CourseApiFactoryMixin
...@@ -23,12 +25,12 @@ class CourseApiTestMixin(CourseApiFactoryMixin): ...@@ -23,12 +25,12 @@ class CourseApiTestMixin(CourseApiFactoryMixin):
def setUpClass(cls): def setUpClass(cls):
super(CourseApiTestMixin, cls).setUpClass() super(CourseApiTestMixin, cls).setUpClass()
cls.request_factory = APIRequestFactory() cls.request_factory = APIRequestFactory()
CourseOverview.get_all_courses() # seed the CourseOverview table
def verify_course(self, course, course_id=u'edX/toy/2012_Fall'): def verify_course(self, course, course_id=u'edX/toy/2012_Fall'):
""" """
Ensure that the returned course is the course we just created Ensure that the returned course is the course we just created
""" """
self.assertIsInstance(course, CourseDescriptor)
self.assertEqual(course_id, str(course.id)) self.assertEqual(course_id, str(course.id))
...@@ -43,7 +45,8 @@ class CourseDetailTestMixin(CourseApiTestMixin): ...@@ -43,7 +45,8 @@ class CourseDetailTestMixin(CourseApiTestMixin):
""" """
request = Request(self.request_factory.get('/')) request = Request(self.request_factory.get('/'))
request.user = requesting_user request.user = requesting_user
return course_detail(request, target_user.username, course_key) with check_mongo_calls(0):
return course_detail(request, target_user.username, course_key)
class TestGetCourseDetail(CourseDetailTestMixin, SharedModuleStoreTestCase): class TestGetCourseDetail(CourseDetailTestMixin, SharedModuleStoreTestCase):
...@@ -64,11 +67,11 @@ class TestGetCourseDetail(CourseDetailTestMixin, SharedModuleStoreTestCase): ...@@ -64,11 +67,11 @@ class TestGetCourseDetail(CourseDetailTestMixin, SharedModuleStoreTestCase):
def test_get_nonexistent_course(self): def test_get_nonexistent_course(self):
course_key = CourseKey.from_string(u'edX/toy/nope') course_key = CourseKey.from_string(u'edX/toy/nope')
with self.assertRaises(NotFound): with self.assertRaises(Http404):
self._make_api_call(self.honor_user, self.honor_user, course_key) self._make_api_call(self.honor_user, self.honor_user, course_key)
def test_hidden_course_for_honor(self): def test_hidden_course_for_honor(self):
with self.assertRaises(NotFound): with self.assertRaises(Http404):
self._make_api_call(self.honor_user, self.honor_user, self.hidden_course.id) self._make_api_call(self.honor_user, self.honor_user, self.hidden_course.id)
def test_hidden_course_for_staff(self): def test_hidden_course_for_staff(self):
...@@ -76,7 +79,7 @@ class TestGetCourseDetail(CourseDetailTestMixin, SharedModuleStoreTestCase): ...@@ -76,7 +79,7 @@ class TestGetCourseDetail(CourseDetailTestMixin, SharedModuleStoreTestCase):
self.verify_course(course, course_id=u'edX/hidden/2012_Fall') self.verify_course(course, course_id=u'edX/hidden/2012_Fall')
def test_hidden_course_for_staff_as_honor(self): def test_hidden_course_for_staff_as_honor(self):
with self.assertRaises(NotFound): with self.assertRaises(Http404):
self._make_api_call(self.staff_user, self.honor_user, self.hidden_course.id) self._make_api_call(self.staff_user, self.honor_user, self.hidden_course.id)
...@@ -91,7 +94,8 @@ class CourseListTestMixin(CourseApiTestMixin): ...@@ -91,7 +94,8 @@ class CourseListTestMixin(CourseApiTestMixin):
""" """
request = Request(self.request_factory.get('/')) request = Request(self.request_factory.get('/'))
request.user = requesting_user request.user = requesting_user
return list_courses(request, specified_user.username) with check_mongo_calls(0):
return list_courses(request, specified_user.username)
def verify_courses(self, courses): def verify_courses(self, courses):
""" """
......
...@@ -5,6 +5,7 @@ Test data created by CourseSerializer ...@@ -5,6 +5,7 @@ Test data created by CourseSerializer
from datetime import datetime from datetime import datetime
from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
from rest_framework.request import Request from rest_framework.request import Request
...@@ -29,7 +30,7 @@ class TestCourseSerializerFields(CourseApiFactoryMixin, ModuleStoreTestCase): ...@@ -29,7 +30,7 @@ class TestCourseSerializerFields(CourseApiFactoryMixin, ModuleStoreTestCase):
def _get_request(self, user=None): def _get_request(self, user=None):
""" """
Build a Request object for the specified user Build a Request object for the specified user.
""" """
if user is None: if user is None:
user = self.honor_user user = self.honor_user
...@@ -37,6 +38,13 @@ class TestCourseSerializerFields(CourseApiFactoryMixin, ModuleStoreTestCase): ...@@ -37,6 +38,13 @@ class TestCourseSerializerFields(CourseApiFactoryMixin, ModuleStoreTestCase):
request.user = user request.user = user
return request return request
def _get_result(self, course):
"""
Return the CourseSerializer for the specified course.
"""
course_overview = CourseOverview.get_from_id(course.id)
return CourseSerializer(course_overview, context={'request': self._get_request()}).data
def test_basic(self): def test_basic(self):
expected_data = { expected_data = {
'course_id': u'edX/toy/2012_Fall', 'course_id': u'edX/toy/2012_Fall',
...@@ -55,15 +63,15 @@ class TestCourseSerializerFields(CourseApiFactoryMixin, ModuleStoreTestCase): ...@@ -55,15 +63,15 @@ class TestCourseSerializerFields(CourseApiFactoryMixin, ModuleStoreTestCase):
'start': u'2015-07-17T12:00:00Z', 'start': u'2015-07-17T12:00:00Z',
'start_type': u'timestamp', 'start_type': u'timestamp',
'start_display': u'July 17, 2015', 'start_display': u'July 17, 2015',
'end': u'2015-09-19T18:00:00', 'end': u'2015-09-19T18:00:00Z',
'enrollment_start': u'2015-06-15T00:00:00', 'enrollment_start': u'2015-06-15T00:00:00Z',
'enrollment_end': u'2015-07-15T00:00:00', 'enrollment_end': u'2015-07-15T00:00:00Z',
'blocks_url': u'http://testserver/api/courses/v1/blocks/?course_id=edX%2Ftoy%2F2012_Fall', 'blocks_url': u'http://testserver/api/courses/v1/blocks/?course_id=edX%2Ftoy%2F2012_Fall',
'effort': u'6 hours', 'effort': u'6 hours',
} }
course = self.create_course() course = self.create_course()
CourseDetails.update_about_video(course, 'test_youtube_id', self.staff_user.id) # pylint: disable=no-member CourseDetails.update_about_video(course, 'test_youtube_id', self.staff_user.id) # pylint: disable=no-member
result = CourseSerializer(course, context={'request': self._get_request()}).data result = self._get_result(course)
self.assertDictEqual(result, expected_data) self.assertDictEqual(result, expected_data)
def test_advertised_start(self): def test_advertised_start(self):
...@@ -72,14 +80,14 @@ class TestCourseSerializerFields(CourseApiFactoryMixin, ModuleStoreTestCase): ...@@ -72,14 +80,14 @@ class TestCourseSerializerFields(CourseApiFactoryMixin, ModuleStoreTestCase):
start=datetime(2015, 3, 15), start=datetime(2015, 3, 15),
advertised_start=u'The Ides of March' advertised_start=u'The Ides of March'
) )
result = CourseSerializer(course, context={'request': self._get_request()}).data result = self._get_result(course)
self.assertEqual(result['course_id'], u'edX/custom/2012_Fall') self.assertEqual(result['course_id'], u'edX/custom/2012_Fall')
self.assertEqual(result['start_type'], u'string') self.assertEqual(result['start_type'], u'string')
self.assertEqual(result['start_display'], u'The Ides of March') self.assertEqual(result['start_display'], u'The Ides of March')
def test_empty_start(self): def test_empty_start(self):
course = self.create_course(start=DEFAULT_START_DATE, course=u'custom') course = self.create_course(start=DEFAULT_START_DATE, course=u'custom')
result = CourseSerializer(course, context={'request': self._get_request()}).data result = self._get_result(course)
self.assertEqual(result['course_id'], u'edX/custom/2012_Fall') self.assertEqual(result['course_id'], u'edX/custom/2012_Fall')
self.assertEqual(result['start_type'], u'empty') self.assertEqual(result['start_type'], u'empty')
self.assertIsNone(result['start_display']) self.assertIsNone(result['start_display'])
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Course API Views Course API Views
""" """
from rest_framework.exceptions import NotFound from django.http import Http404
from rest_framework.generics import ListAPIView, RetrieveAPIView from rest_framework.generics import ListAPIView, RetrieveAPIView
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
...@@ -102,7 +102,7 @@ class CourseDetailView(RetrieveAPIView): ...@@ -102,7 +102,7 @@ class CourseDetailView(RetrieveAPIView):
try: try:
course_key = CourseKey.from_string(course_key_string) course_key = CourseKey.from_string(course_key_string)
except InvalidKeyError: except InvalidKeyError:
raise NotFound() raise Http404()
return course_detail(self.request, username, course_key) return course_detail(self.request, username, course_key)
......
...@@ -6,7 +6,7 @@ from django.shortcuts import redirect ...@@ -6,7 +6,7 @@ from django.shortcuts import redirect
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from wiki.models import reverse from wiki.models import reverse
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access, get_course_overview_with_access
from courseware.access import has_access from courseware.access import has_access
from student.models import CourseEnrollment from student.models import CourseEnrollment
from util.request import course_id_from_url from util.request import course_id_from_url
...@@ -29,7 +29,7 @@ class WikiAccessMiddleware(object): ...@@ -29,7 +29,7 @@ class WikiAccessMiddleware(object):
if course_id: if course_id:
# See if we are able to view the course. If we are, redirect to it # See if we are able to view the course. If we are, redirect to it
try: try:
_course = get_course_with_access(request.user, 'load', course_id) get_course_overview_with_access(request.user, 'load', course_id)
return redirect("/courses/{course_id}/wiki/{path}".format(course_id=course_id.to_deprecated_string(), path=wiki_path)) return redirect("/courses/{course_id}/wiki/{path}".format(course_id=course_id.to_deprecated_string(), path=wiki_path))
except Http404: except Http404:
# Even though we came from the course, we can't see it. So don't worry about it. # Even though we came from the course, we can't see it. So don't worry about it.
......
...@@ -105,10 +105,10 @@ def has_access(user, action, obj, course_key=None): ...@@ -105,10 +105,10 @@ def has_access(user, action, obj, course_key=None):
# delegate the work to type-specific functions. # delegate the work to type-specific functions.
# (start with more specific types, then get more general) # (start with more specific types, then get more general)
if isinstance(obj, CourseDescriptor): if isinstance(obj, CourseDescriptor):
return _has_access_course_desc(user, action, obj) return _has_access_course(user, action, obj)
if isinstance(obj, CourseOverview): if isinstance(obj, CourseOverview):
return _has_access_course_overview(user, action, obj) return _has_access_course(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)
...@@ -202,7 +202,7 @@ def _can_load_course_on_mobile(user, course): ...@@ -202,7 +202,7 @@ def _can_load_course_on_mobile(user, course):
be checked by callers in *addition* to the return value of this function. be checked by callers in *addition* to the return value of this function.
Arguments: Arguments:
user (User): the user whose course access we are checking. user (User): the user whose course access we are checking.
course (CourseDescriptor|CourseOverview): the course for which we are course (CourseDescriptor|CourseOverview): the course for which we are
checking access. checking access.
...@@ -270,17 +270,22 @@ def _can_enroll_courselike(user, courselike): ...@@ -270,17 +270,22 @@ def _can_enroll_courselike(user, courselike):
return ACCESS_DENIED return ACCESS_DENIED
def _has_access_course_desc(user, action, course): def _has_access_course(user, action, courselike):
""" """
Check if user has access to a course descriptor. Check if user has access to a course.
Arguments:
user (User): the user whose course access we are checking.
action (string): The action that is being checked.
courselike (CourseDescriptor or CourseOverview): The object
representing the course that the user wants to access.
Valid actions: Valid actions:
'load' -- load the courseware, see inside the course 'load' -- load the courseware, see inside the course
'load_forum' -- can load and contribute to the forums (one access level for now) 'load_forum' -- can load and contribute to the forums (one access level for now)
'load_mobile' -- can load from a mobile context 'load_mobile' -- can load from a mobile context
'enroll' -- enroll. Checks for enrollment window, 'enroll' -- enroll. Checks for enrollment window.
ACCESS_REQUIRE_STAFF_FOR_COURSE,
'see_exists' -- can see that the course exists. 'see_exists' -- can see that the course exists.
'staff' -- staff access to course. 'staff' -- staff access to course.
'see_in_catalog' -- user is able to see the course listed in the course catalog. 'see_in_catalog' -- user is able to see the course listed in the course catalog.
...@@ -292,36 +297,27 @@ def _has_access_course_desc(user, action, course): ...@@ -292,36 +297,27 @@ def _has_access_course_desc(user, action, course):
NOTE: this is not checking whether user is actually enrolled in the course. NOTE: this is not checking whether user is actually enrolled in the course.
""" """
# delegate to generic descriptor check to check start dates response = (
return _has_access_descriptor(user, 'load', course, course.id) _visible_to_nonstaff_users(courselike) and
_can_access_descriptor_with_start_date(user, courselike, courselike.id)
)
return (
ACCESS_GRANTED if (response or _has_staff_access_to_descriptor(user, courselike, courselike.id))
else response
)
def can_enroll(): def can_enroll():
return _can_enroll_courselike(user, course) """
Returns whether the user can enroll in the course.
"""
return _can_enroll_courselike(user, courselike)
def see_exists(): def see_exists():
""" """
Can see if can enroll, but also if can load it: if user enrolled in a course and now Can see if can enroll, but also if can load it: if user enrolled in a course and now
it's past the enrollment period, they should still see it. it's past the enrollment period, they should still see it.
""" """
# VS[compat] -- this setting should go away once all courses have
# properly configured enrollment_start times (if course should be
# staff-only, set enrollment_start far in the future.)
if settings.FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'):
dog_stats_api.increment(
DEPRECATION_VSCOMPAT_EVENT,
tags=(
"location:has_access_course_desc_see_exists",
u"course:{}".format(course),
)
)
# if this feature is on, only allow courses that have ispublic set to be
# seen by non-staff
if course.ispublic:
debug("Allow: ACCESS_REQUIRE_STAFF_FOR_COURSE and ispublic")
return ACCESS_GRANTED
return _has_staff_access_to_descriptor(user, course, course.id)
return ACCESS_GRANTED if (can_enroll() or can_load()) else ACCESS_DENIED return ACCESS_GRANTED if (can_enroll() or can_load()) else ACCESS_DENIED
def can_see_in_catalog(): def can_see_in_catalog():
...@@ -331,8 +327,8 @@ def _has_access_course_desc(user, action, course): ...@@ -331,8 +327,8 @@ def _has_access_course_desc(user, action, course):
but also allow course staff to see this. but also allow course staff to see this.
""" """
return ( return (
_has_catalog_visibility(course, CATALOG_VISIBILITY_CATALOG_AND_ABOUT) _has_catalog_visibility(courselike, CATALOG_VISIBILITY_CATALOG_AND_ABOUT)
or _has_staff_access_to_descriptor(user, course, course.id) or _has_staff_access_to_descriptor(user, courselike, courselike.id)
) )
def can_see_about_page(): def can_see_about_page():
...@@ -342,75 +338,25 @@ def _has_access_course_desc(user, action, course): ...@@ -342,75 +338,25 @@ def _has_access_course_desc(user, action, course):
but also allow course staff to see this. but also allow course staff to see this.
""" """
return ( return (
_has_catalog_visibility(course, CATALOG_VISIBILITY_CATALOG_AND_ABOUT) _has_catalog_visibility(courselike, CATALOG_VISIBILITY_CATALOG_AND_ABOUT)
or _has_catalog_visibility(course, CATALOG_VISIBILITY_ABOUT) or _has_catalog_visibility(courselike, CATALOG_VISIBILITY_ABOUT)
or _has_staff_access_to_descriptor(user, course, course.id) or _has_staff_access_to_descriptor(user, courselike, courselike.id)
) )
checkers = { checkers = {
'load': can_load, 'load': can_load,
'view_courseware_with_prerequisites': 'view_courseware_with_prerequisites':
lambda: _can_view_courseware_with_prerequisites(user, course), lambda: _can_view_courseware_with_prerequisites(user, courselike),
'load_mobile': lambda: can_load() and _can_load_course_on_mobile(user, course), 'load_mobile': lambda: can_load() and _can_load_course_on_mobile(user, courselike),
'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, courselike, courselike.id),
'instructor': lambda: _has_instructor_access_to_descriptor(user, course, course.id), 'instructor': lambda: _has_instructor_access_to_descriptor(user, courselike, courselike.id),
'see_in_catalog': can_see_in_catalog, 'see_in_catalog': can_see_in_catalog,
'see_about_page': can_see_about_page, 'see_about_page': can_see_about_page,
} }
return _dispatch(checkers, action, user, course) return _dispatch(checkers, action, user, courselike)
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.
"""
response = (
_visible_to_nonstaff_users(course_overview)
and _can_access_descriptor_with_start_date(user, course_overview, course_overview.id)
)
return (
ACCESS_GRANTED if (response or _has_staff_access_to_descriptor(user, course_overview, course_overview.id))
else response
)
_COURSE_OVERVIEW_CHECKERS = {
'enroll': _can_enroll_courselike,
'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()
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):
......
...@@ -14,7 +14,6 @@ from django.conf import settings ...@@ -14,7 +14,6 @@ from django.conf import settings
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from static_replace import replace_static_urls from static_replace import replace_static_urls
...@@ -37,6 +36,7 @@ from student.models import CourseEnrollment ...@@ -37,6 +36,7 @@ from student.models import CourseEnrollment
import branding import branding
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -58,7 +58,6 @@ def get_course(course_id, depth=0): ...@@ -58,7 +58,6 @@ def get_course(course_id, depth=0):
return course return course
# TODO please rename this function to get_course_by_key at next opportunity!
def get_course_by_id(course_key, depth=0): def get_course_by_id(course_key, depth=0):
""" """
Given a course id, return the corresponding course descriptor. Given a course id, return the corresponding course descriptor.
...@@ -94,9 +93,39 @@ def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled= ...@@ -94,9 +93,39 @@ def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=
check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course
or has staff access. or has staff access.
""" """
assert isinstance(course_key, CourseKey) course = get_course_by_id(course_key, depth)
course = get_course_by_id(course_key, depth=depth) check_course_access(course, user, action, check_if_enrolled)
access_response = has_access(user, action, course, course_key) return course
def get_course_overview_with_access(user, action, course_key, check_if_enrolled=False):
"""
Given a course_key, look up the corresponding course overview,
check that the user has the access to perform the specified action
on the course, and return the course overview.
Raises a 404 if the course_key is invalid, or the user doesn't have access.
check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course
or has staff access.
"""
try:
course_overview = CourseOverview.get_from_id(course_key)
except CourseOverview.DoesNotExist:
raise Http404("Course not found.")
check_course_access(course_overview, user, action, check_if_enrolled)
return course_overview
def check_course_access(course, user, action, check_if_enrolled=False):
"""
Check that the user has the access to perform the specified action
on the course (CourseDescriptor|CourseOverview).
check_if_enrolled: If true, additionally verifies that the user is either
enrolled in the course or has staff access.
"""
access_response = has_access(user, action, course, course.id)
if not access_response: if not access_response:
# Deliberately return a non-specific error message to avoid # Deliberately return a non-specific error message to avoid
...@@ -104,12 +133,11 @@ def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled= ...@@ -104,12 +133,11 @@ def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=
raise CoursewareAccessException(access_response) raise CoursewareAccessException(access_response)
if check_if_enrolled: if check_if_enrolled:
# Verify that the user is either enrolled in the course or a staff member. # Verify that the user is either enrolled in the course or a staff
# If user is not enrolled, raise UserNotEnrolled exception that will be caught by middleware. # member. If user is not enrolled, raise UserNotEnrolled exception
if not ((user.id and CourseEnrollment.is_enrolled(user, course_key)) or has_access(user, 'staff', course)): # that will be caught by middleware.
raise UserNotEnrolled(course_key) if not ((user.id and CourseEnrollment.is_enrolled(user, course.id)) or has_access(user, 'staff', course)):
raise UserNotEnrolled(course.id)
return course
def find_file(filesystem, dirs, filename): def find_file(filesystem, dirs, filename):
...@@ -129,16 +157,6 @@ def find_file(filesystem, dirs, filename): ...@@ -129,16 +157,6 @@ 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(request, course, section_key): def get_course_about_section(request, 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,
...@@ -146,9 +164,6 @@ def get_course_about_section(request, course, section_key): ...@@ -146,9 +164,6 @@ def get_course_about_section(request, course, section_key):
Valid keys: Valid keys:
- overview - overview
- title
- university
- number
- short_description - short_description
- description - description
- key_dates (includes start, end, exams, etc) - key_dates (includes start, end, exams, etc)
...@@ -159,6 +174,7 @@ def get_course_about_section(request, course, section_key): ...@@ -159,6 +174,7 @@ def get_course_about_section(request, course, section_key):
- syllabus - syllabus
- textbook - textbook
- faq - faq
- effort
- more_info - more_info
- ocw_links - ocw_links
""" """
...@@ -167,7 +183,6 @@ def get_course_about_section(request, course, section_key): ...@@ -167,7 +183,6 @@ def get_course_about_section(request, course, section_key):
# markup. This can change without effecting this interface when we find a # markup. This can change without effecting this interface when we find a
# good format for defining so many snippets of text/html. # good format for defining so many snippets of text/html.
# TODO: Remove number, instructors from this set
html_sections = { html_sections = {
'short_description', 'short_description',
'description', 'description',
...@@ -180,8 +195,6 @@ def get_course_about_section(request, course, section_key): ...@@ -180,8 +195,6 @@ def get_course_about_section(request, course, section_key):
'textbook', 'textbook',
'faq', 'faq',
'more_info', 'more_info',
'number',
'instructors',
'overview', 'overview',
'effort', 'effort',
'end_date', 'end_date',
...@@ -225,12 +238,6 @@ def get_course_about_section(request, course, section_key): ...@@ -225,12 +238,6 @@ def get_course_about_section(request, course, section_key):
section_key, course.location.to_deprecated_string() section_key, course.location.to_deprecated_string()
) )
return None return None
elif section_key == "title":
return course.display_name_with_default
elif section_key == "university":
return get_course_university_about_section(course)
elif section_key == "number":
return course.display_number_with_default
raise KeyError("Invalid about key " + str(section_key)) raise KeyError("Invalid about key " + str(section_key))
...@@ -366,22 +373,6 @@ def get_course_syllabus_section(course, section_key): ...@@ -366,22 +373,6 @@ def get_course_syllabus_section(course, section_key):
raise KeyError("Invalid about key " + str(section_key)) raise KeyError("Invalid about key " + str(section_key))
def get_courses_by_university(user, domain=None):
'''
Returns dict of lists of courses available, keyed by course.org (ie university).
Courses are sorted by course.number.
'''
# TODO: Clean up how 'error' is done.
# filter out any courses that errored.
visible_courses = get_courses(user, domain)
universities = defaultdict(list)
for course in visible_courses:
universities[course.org].append(course)
return universities
def get_courses(user, domain=None): def get_courses(user, domain=None):
''' '''
Returns a list of courses available, sorted by course.number Returns a list of courses available, sorted by course.number
...@@ -400,6 +391,16 @@ def get_courses(user, domain=None): ...@@ -400,6 +391,16 @@ def get_courses(user, domain=None):
return courses return courses
def get_permission_for_course_about():
"""
Returns the CourseOverview object for the course after checking for access.
"""
return microsite.get_value(
'COURSE_ABOUT_VISIBILITY_PERMISSION',
settings.COURSE_ABOUT_VISIBILITY_PERMISSION
)
def sort_by_announcement(courses): def sort_by_announcement(courses):
""" """
Sorts a list of courses by their announcement date. If the date is Sorts a list of courses by their announcement date. If the date is
......
...@@ -153,7 +153,6 @@ class CommandsTestBase(ModuleStoreTestCase): ...@@ -153,7 +153,6 @@ class CommandsTestBase(ModuleStoreTestCase):
self.assertIn('children', element) self.assertIn('children', element)
self.assertIn('category', element) self.assertIn('category', element)
self.assertIn('inherited_metadata', element) self.assertIn('inherited_metadata', element)
self.assertIsNone(element['inherited_metadata']['ispublic'])
# ... but does not contain inherited metadata containing a default value: # ... but does not contain inherited metadata containing a default value:
self.assertNotIn('due', element['inherited_metadata']) self.assertNotIn('due', element['inherited_metadata'])
...@@ -169,7 +168,6 @@ class CommandsTestBase(ModuleStoreTestCase): ...@@ -169,7 +168,6 @@ class CommandsTestBase(ModuleStoreTestCase):
self.assertIn('children', element) self.assertIn('children', element)
self.assertIn('category', element) self.assertIn('category', element)
self.assertIn('inherited_metadata', element) self.assertIn('inherited_metadata', element)
self.assertIsNone(element['inherited_metadata']['ispublic'])
# ... and contains inherited metadata containing a default value: # ... and contains inherited metadata containing a default value:
self.assertIsNone(element['inherited_metadata']['due']) self.assertIsNone(element['inherited_metadata']['due'])
......
...@@ -5,7 +5,7 @@ from django.core.urlresolvers import reverse ...@@ -5,7 +5,7 @@ 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 courseware.access import has_access
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.models import Registration from student.models import Registration
...@@ -151,30 +151,27 @@ class CourseAccessTestMixin(TestCase): ...@@ -151,30 +151,27 @@ class CourseAccessTestMixin(TestCase):
""" """
Assert that a user has access to the given action for a given 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 Test with both the given course and with a CourseOverview of the given
a CourseOverview of the given course. course.
Arguments: Arguments:
user (User): a user. user (User): a user.
action (str): type of access to test. action (str): type of access to test.
See access.py:COURSE_OVERVIEW_SUPPORTED_ACTIONS.
course (CourseDescriptor): a course. course (CourseDescriptor): a course.
""" """
self.assertTrue(has_access(user, action, 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)))
self.assertTrue(has_access(user, action, CourseOverview.get_from_id(course.id)))
def assertCannotAccessCourse(self, user, action, course): def assertCannotAccessCourse(self, user, action, course):
""" """
Assert that a user lacks access to the given action the given 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 Test with both the given course and with a CourseOverview of the given
a CourseOverview of the given course. course.
Arguments: Arguments:
user (User): a user. user (User): a user.
action (str): type of access to test. action (str): type of access to test.
See access.py:COURSE_OVERVIEW_SUPPORTED_ACTIONS.
course (CourseDescriptor): a course. course (CourseDescriptor): a course.
Note: Note:
...@@ -184,5 +181,4 @@ class CourseAccessTestMixin(TestCase): ...@@ -184,5 +181,4 @@ class CourseAccessTestMixin(TestCase):
stack traces of failed tests easier to understand at a glance. stack traces of failed tests easier to understand at a glance.
""" """
self.assertFalse(has_access(user, action, course)) 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)))
self.assertFalse(has_access(user, action, CourseOverview.get_from_id(course.id)))
...@@ -236,7 +236,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -236,7 +236,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
mock_unit.start = start mock_unit.start = start
self.verify_access(mock_unit, expected_access, expected_error_type) self.verify_access(mock_unit, expected_access, expected_error_type)
def test__has_access_course_desc_can_enroll(self): def test__has_access_course_can_enroll(self):
yesterday = datetime.datetime.now(pytz.utc) - datetime.timedelta(days=1) yesterday = datetime.datetime.now(pytz.utc) - datetime.timedelta(days=1)
tomorrow = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1) tomorrow = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1)
...@@ -248,11 +248,11 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -248,11 +248,11 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
id=SlashSeparatedCourseKey('edX', 'test', '2012_Fall'), enrollment_domain='' id=SlashSeparatedCourseKey('edX', 'test', '2012_Fall'), enrollment_domain=''
) )
CourseEnrollmentAllowedFactory(email=user.email, course_id=course.id) CourseEnrollmentAllowedFactory(email=user.email, course_id=course.id)
self.assertTrue(access._has_access_course_desc(user, 'enroll', course)) self.assertTrue(access._has_access_course(user, 'enroll', course))
# Staff can always enroll even outside the open enrollment period # Staff can always enroll even outside the open enrollment period
user = StaffFactory.create(course_key=course.id) user = StaffFactory.create(course_key=course.id)
self.assertTrue(access._has_access_course_desc(user, 'enroll', course)) self.assertTrue(access._has_access_course(user, 'enroll', course))
# Non-staff cannot enroll if it is between the start and end dates and invitation only # Non-staff cannot enroll if it is between the start and end dates and invitation only
# and not specifically allowed # and not specifically allowed
...@@ -262,7 +262,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -262,7 +262,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
invitation_only=True invitation_only=True
) )
user = UserFactory.create() user = UserFactory.create()
self.assertFalse(access._has_access_course_desc(user, 'enroll', course)) self.assertFalse(access._has_access_course(user, 'enroll', course))
# Non-staff can enroll if it is between the start and end dates and not invitation only # Non-staff can enroll if it is between the start and end dates and not invitation only
course = Mock( course = Mock(
...@@ -270,7 +270,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -270,7 +270,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
id=SlashSeparatedCourseKey('edX', 'test', '2012_Fall'), enrollment_domain='', id=SlashSeparatedCourseKey('edX', 'test', '2012_Fall'), enrollment_domain='',
invitation_only=False invitation_only=False
) )
self.assertTrue(access._has_access_course_desc(user, 'enroll', course)) self.assertTrue(access._has_access_course(user, 'enroll', course))
# Non-staff cannot enroll outside the open enrollment period if not specifically allowed # Non-staff cannot enroll outside the open enrollment period if not specifically allowed
course = Mock( course = Mock(
...@@ -278,7 +278,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -278,7 +278,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
id=SlashSeparatedCourseKey('edX', 'test', '2012_Fall'), enrollment_domain='', id=SlashSeparatedCourseKey('edX', 'test', '2012_Fall'), enrollment_domain='',
invitation_only=False invitation_only=False
) )
self.assertFalse(access._has_access_course_desc(user, 'enroll', course)) self.assertFalse(access._has_access_course(user, 'enroll', course))
def test__user_passed_as_none(self): def test__user_passed_as_none(self):
"""Ensure has_access handles a user being passed as null""" """Ensure has_access handles a user being passed as null"""
...@@ -296,40 +296,30 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -296,40 +296,30 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
id=course_id, id=course_id,
catalog_visibility=CATALOG_VISIBILITY_CATALOG_AND_ABOUT catalog_visibility=CATALOG_VISIBILITY_CATALOG_AND_ABOUT
) )
self.assertTrue(access._has_access_course_desc(user, 'see_in_catalog', course)) self.assertTrue(access._has_access_course(user, 'see_in_catalog', course))
self.assertTrue(access._has_access_course_desc(user, 'see_about_page', course)) self.assertTrue(access._has_access_course(user, 'see_about_page', course))
self.assertTrue(access._has_access_course_desc(staff, 'see_in_catalog', course)) self.assertTrue(access._has_access_course(staff, 'see_in_catalog', course))
self.assertTrue(access._has_access_course_desc(staff, 'see_about_page', course)) self.assertTrue(access._has_access_course(staff, 'see_about_page', course))
# Now set visibility to just about page # Now set visibility to just about page
course = Mock( course = Mock(
id=SlashSeparatedCourseKey('edX', 'test', '2012_Fall'), id=SlashSeparatedCourseKey('edX', 'test', '2012_Fall'),
catalog_visibility=CATALOG_VISIBILITY_ABOUT catalog_visibility=CATALOG_VISIBILITY_ABOUT
) )
self.assertFalse(access._has_access_course_desc(user, 'see_in_catalog', course)) self.assertFalse(access._has_access_course(user, 'see_in_catalog', course))
self.assertTrue(access._has_access_course_desc(user, 'see_about_page', course)) self.assertTrue(access._has_access_course(user, 'see_about_page', course))
self.assertTrue(access._has_access_course_desc(staff, 'see_in_catalog', course)) self.assertTrue(access._has_access_course(staff, 'see_in_catalog', course))
self.assertTrue(access._has_access_course_desc(staff, 'see_about_page', course)) self.assertTrue(access._has_access_course(staff, 'see_about_page', course))
# Now set visibility to none, which means neither in catalog nor about pages # Now set visibility to none, which means neither in catalog nor about pages
course = Mock( course = Mock(
id=SlashSeparatedCourseKey('edX', 'test', '2012_Fall'), id=SlashSeparatedCourseKey('edX', 'test', '2012_Fall'),
catalog_visibility=CATALOG_VISIBILITY_NONE catalog_visibility=CATALOG_VISIBILITY_NONE
) )
self.assertFalse(access._has_access_course_desc(user, 'see_in_catalog', course)) self.assertFalse(access._has_access_course(user, 'see_in_catalog', course))
self.assertFalse(access._has_access_course_desc(user, 'see_about_page', course)) self.assertFalse(access._has_access_course(user, 'see_about_page', course))
self.assertTrue(access._has_access_course_desc(staff, 'see_in_catalog', course)) self.assertTrue(access._has_access_course(staff, 'see_in_catalog', course))
self.assertTrue(access._has_access_course_desc(staff, 'see_about_page', course)) self.assertTrue(access._has_access_course(staff, 'see_about_page', course))
@ddt.data(True, False)
@patch.dict("django.conf.settings.FEATURES", {'ACCESS_REQUIRE_STAFF_FOR_COURSE': True})
def test_see_exists(self, ispublic):
"""
Test if user can see course
"""
user = UserFactory.create(is_staff=False)
course = Mock(ispublic=ispublic)
self.assertEquals(bool(access._has_access_course_desc(user, 'see_exists', course)), ispublic)
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) @patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_access_on_course_with_pre_requisites(self): def test_access_on_course_with_pre_requisites(self):
...@@ -351,16 +341,16 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -351,16 +341,16 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
# user should not be able to load course even if enrolled # user should not be able to load course even if enrolled
CourseEnrollmentFactory(user=user, course_id=course.id) CourseEnrollmentFactory(user=user, course_id=course.id)
response = access._has_access_course_desc(user, 'view_courseware_with_prerequisites', course) response = access._has_access_course(user, 'view_courseware_with_prerequisites', course)
self.assertFalse(response) self.assertFalse(response)
self.assertIsInstance(response, access_response.MilestoneError) self.assertIsInstance(response, access_response.MilestoneError)
# Staff can always access course # Staff can always access course
staff = StaffFactory.create(course_key=course.id) staff = StaffFactory.create(course_key=course.id)
self.assertTrue(access._has_access_course_desc(staff, 'view_courseware_with_prerequisites', course)) self.assertTrue(access._has_access_course(staff, 'view_courseware_with_prerequisites', course))
# User should be able access after completing required course # User should be able access after completing required course
fulfill_course_milestone(pre_requisite_course.id, user) fulfill_course_milestone(pre_requisite_course.id, user)
self.assertTrue(access._has_access_course_desc(user, 'view_courseware_with_prerequisites', course)) self.assertTrue(access._has_access_course(user, 'view_courseware_with_prerequisites', course))
@ddt.data( @ddt.data(
(True, True, True), (True, True, True),
...@@ -377,10 +367,10 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -377,10 +367,10 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
descriptor.mobile_available = mobile_available descriptor.mobile_available = mobile_available
self.assertEqual( self.assertEqual(
bool(access._has_access_course_desc(self.student, 'load_mobile', descriptor)), bool(access._has_access_course(self.student, 'load_mobile', descriptor)),
student_expected student_expected
) )
self.assertEqual(bool(access._has_access_course_desc(self.staff, 'load_mobile', descriptor)), staff_expected) self.assertEqual(bool(access._has_access_course(self.staff, 'load_mobile', descriptor)), staff_expected)
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) @patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_courseware_page_unfulfilled_prereqs(self): def test_courseware_page_unfulfilled_prereqs(self):
...@@ -552,7 +542,6 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase): ...@@ -552,7 +542,6 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase):
user_attr_name (str): the name of the attribute on self that is the user_attr_name (str): the name of the attribute on self that is the
User to test with. User to test with.
action (str): action 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 course_attr_name (str): the name of the attribute on self that is
the CourseDescriptor to test with. the CourseDescriptor to test with.
""" """
......
...@@ -18,7 +18,7 @@ from courseware.courses import ( ...@@ -18,7 +18,7 @@ from courseware.courses import (
get_course_info_section, get_course_about_section, get_cms_block_link get_course_info_section, get_course_about_section, get_cms_block_link
) )
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access, get_course_overview_with_access
from courseware.module_render import get_module_for_descriptor from courseware.module_render import get_module_for_descriptor
from courseware.tests.helpers import get_request_for_user from courseware.tests.helpers import get_request_for_user
from courseware.model_data import FieldDataCache from courseware.model_data import FieldDataCache
...@@ -30,7 +30,7 @@ from xmodule.modulestore import ModuleStoreEnum ...@@ -30,7 +30,7 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.xml_importer import import_course_from_xml from xmodule.modulestore.xml_importer import import_course_from_xml
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
from xmodule.tests.xml import factories as xml from xmodule.tests.xml import factories as xml
from xmodule.tests.xml import XModuleXmlImportTest from xmodule.tests.xml import XModuleXmlImportTest
...@@ -40,6 +40,7 @@ TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT ...@@ -40,6 +40,7 @@ TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
@attr('shard_1') @attr('shard_1')
@ddt.ddt
class CoursesTest(ModuleStoreTestCase): class CoursesTest(ModuleStoreTestCase):
"""Test methods related to fetching courses.""" """Test methods related to fetching courses."""
...@@ -57,16 +58,28 @@ class CoursesTest(ModuleStoreTestCase): ...@@ -57,16 +58,28 @@ class CoursesTest(ModuleStoreTestCase):
cms_url = u"//{}/course/{}".format(CMS_BASE_TEST, unicode(self.course.location)) cms_url = u"//{}/course/{}".format(CMS_BASE_TEST, unicode(self.course.location))
self.assertEqual(cms_url, get_cms_block_link(self.course, 'course')) self.assertEqual(cms_url, get_cms_block_link(self.course, 'course'))
def test_get_course_with_access(self): @ddt.data(get_course_with_access, get_course_overview_with_access)
def test_get_course_func_with_access_error(self, course_access_func):
user = UserFactory.create() user = UserFactory.create()
course = CourseFactory.create(visible_to_staff_only=True) course = CourseFactory.create(visible_to_staff_only=True)
with self.assertRaises(CoursewareAccessException) as error: with self.assertRaises(CoursewareAccessException) as error:
get_course_with_access(user, 'load', course.id) course_access_func(user, 'load', course.id)
self.assertEqual(error.exception.message, "Course not found.") self.assertEqual(error.exception.message, "Course not found.")
self.assertEqual(error.exception.access_response.error_code, "not_visible_to_user") self.assertEqual(error.exception.access_response.error_code, "not_visible_to_user")
self.assertFalse(error.exception.access_response.has_access) self.assertFalse(error.exception.access_response.has_access)
@ddt.data(
(get_course_with_access, 1),
(get_course_overview_with_access, 0),
)
@ddt.unpack
def test_get_course_func_with_access(self, course_access_func, num_mongo_calls):
user = UserFactory.create()
course = CourseFactory.create(emit_signals=True)
with check_mongo_calls(num_mongo_calls):
course_access_func(user, 'load', course.id)
@attr('shard_1') @attr('shard_1')
class ModuleStoreBranchSettingTest(ModuleStoreTestCase): class ModuleStoreBranchSettingTest(ModuleStoreTestCase):
......
...@@ -37,11 +37,17 @@ from courseware.access import has_access, _adjust_start_date_for_beta_testers ...@@ -37,11 +37,17 @@ from courseware.access import has_access, _adjust_start_date_for_beta_testers
from courseware.access_response import StartDateError from courseware.access_response import StartDateError
from courseware.access_utils import in_preview_mode from courseware.access_utils import in_preview_mode
from courseware.courses import ( from courseware.courses import (
get_courses, get_course, get_course_by_id, get_courses,
get_studio_url, get_course_with_access, get_course,
get_course_by_id,
get_permission_for_course_about,
get_studio_url,
get_course_overview_with_access,
get_course_with_access,
sort_by_announcement, sort_by_announcement,
sort_by_start_date, sort_by_start_date,
UserNotEnrolled) UserNotEnrolled
)
from courseware.masquerade import setup_masquerade from courseware.masquerade import setup_masquerade
from openedx.core.djangoapps.credit.api import ( from openedx.core.djangoapps.credit.api import (
get_credit_requirement_status, get_credit_requirement_status,
...@@ -802,11 +808,8 @@ def course_about(request, course_id): ...@@ -802,11 +808,8 @@ def course_about(request, course_id):
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
with modulestore().bulk_operations(course_key): with modulestore().bulk_operations(course_key):
permission_name = microsite.get_value( permission = get_permission_for_course_about()
'COURSE_ABOUT_VISIBILITY_PERMISSION', course = get_course_with_access(request.user, permission, course_key)
settings.COURSE_ABOUT_VISIBILITY_PERMISSION
)
course = get_course_with_access(request.user, permission_name, course_key)
if microsite.get_value('ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False)): if microsite.get_value('ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False)):
return redirect(reverse('info', args=[course.id.to_deprecated_string()])) return redirect(reverse('info', args=[course.id.to_deprecated_string()]))
...@@ -1066,7 +1069,7 @@ def submission_history(request, course_id, student_username, location): ...@@ -1066,7 +1069,7 @@ def submission_history(request, course_id, student_username, location):
except (InvalidKeyError, AssertionError): except (InvalidKeyError, AssertionError):
return HttpResponse(escape(_(u'Invalid location.'))) return HttpResponse(escape(_(u'Invalid location.')))
course = get_course_with_access(request.user, 'load', course_key) course = get_course_overview_with_access(request.user, 'load', course_key)
staff_access = bool(has_access(request.user, 'staff', course)) staff_access = bool(has_access(request.user, 'staff', course))
# Permission Denied if they don't have staff access and are trying to see # Permission Denied if they don't have staff access and are trying to see
......
...@@ -15,7 +15,7 @@ from opaque_keys.edx.keys import CourseKey ...@@ -15,7 +15,7 @@ from opaque_keys.edx.keys import CourseKey
from courseware.access import has_access from courseware.access import has_access
from util.file import store_uploaded_file from util.file import store_uploaded_file
from courseware.courses import get_course_with_access, get_course_by_id from courseware.courses import get_course_with_access, get_course_overview_with_access, get_course_by_id
import django_comment_client.settings as cc_settings import django_comment_client.settings as cc_settings
from django_comment_common.signals import ( from django_comment_common.signals import (
thread_created, thread_created,
...@@ -770,7 +770,7 @@ def users(request, course_id): ...@@ -770,7 +770,7 @@ def users(request, course_id):
course_key = CourseKey.from_string(course_id) course_key = CourseKey.from_string(course_id)
try: try:
get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) get_course_overview_with_access(request.user, 'load', course_key, check_if_enrolled=True)
except Http404: except Http404:
# course didn't exist, or requesting user does not have access to it. # course didn't exist, or requesting user does not have access to it.
return JsonError(status=404) return JsonError(status=404)
......
...@@ -65,11 +65,6 @@ class LmsBlockMixin(XBlockMixin): ...@@ -65,11 +65,6 @@ class LmsBlockMixin(XBlockMixin):
scope=Scope.settings, scope=Scope.settings,
deprecated=True deprecated=True
) )
ispublic = Boolean(
display_name=_("Course Is Public"),
help=_("Enter true or false. If true, the course is open to the public. If false, the course is open only to admins."),
scope=Scope.settings
)
visible_to_staff_only = Boolean( visible_to_staff_only = Boolean(
help=_("If true, can be seen only by course staff, regardless of start date."), help=_("If true, can be seen only by course staff, regardless of start date."),
default=False, default=False,
......
...@@ -4,12 +4,9 @@ Serializer for user API ...@@ -4,12 +4,9 @@ Serializer for user API
from rest_framework import serializers from rest_framework import serializers
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from django.template import defaultfilters
from courseware.access import has_access from courseware.access import has_access
from student.models import CourseEnrollment, User from student.models import CourseEnrollment, User
from certificates.api import certificate_downloadable_status from certificates.api import certificate_downloadable_status
from xmodule.course_module import DEFAULT_START_DATE
class CourseOverviewField(serializers.RelatedField): class CourseOverviewField(serializers.RelatedField):
...@@ -19,17 +16,6 @@ class CourseOverviewField(serializers.RelatedField): ...@@ -19,17 +16,6 @@ class CourseOverviewField(serializers.RelatedField):
def to_representation(self, course_overview): def to_representation(self, course_overview):
course_id = unicode(course_overview.id) course_id = unicode(course_overview.id)
if course_overview.advertised_start is not None:
start_type = 'string'
start_display = course_overview.advertised_start
elif course_overview.start != DEFAULT_START_DATE:
start_type = 'timestamp'
start_display = defaultfilters.date(course_overview.start, 'DATE_FORMAT')
else:
start_type = 'empty'
start_display = None
request = self.context.get('request') request = self.context.get('request')
return { return {
# identifiers # identifiers
...@@ -40,8 +26,8 @@ class CourseOverviewField(serializers.RelatedField): ...@@ -40,8 +26,8 @@ class CourseOverviewField(serializers.RelatedField):
# dates # dates
'start': course_overview.start, 'start': course_overview.start,
'start_display': start_display, 'start_display': course_overview.start_display,
'start_type': start_type, 'start_type': course_overview.start_type,
'end': course_overview.end, 'end': course_overview.end,
# notification info # notification info
......
...@@ -168,8 +168,10 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest ...@@ -168,8 +168,10 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest
@ddt.data( @ddt.data(
(NEXT_WEEK, ADVERTISED_START, ADVERTISED_START, "string"), (NEXT_WEEK, ADVERTISED_START, ADVERTISED_START, "string"),
(NEXT_WEEK, None, defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp"), (NEXT_WEEK, None, defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp"),
(NEXT_WEEK, '', defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp"),
(DEFAULT_START_DATE, ADVERTISED_START, ADVERTISED_START, "string"), (DEFAULT_START_DATE, ADVERTISED_START, ADVERTISED_START, "string"),
(DEFAULT_START_DATE, None, None, "empty") (DEFAULT_START_DATE, '', None, "empty"),
(DEFAULT_START_DATE, None, None, "empty"),
) )
@ddt.unpack @ddt.unpack
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False})
......
...@@ -127,7 +127,6 @@ FEATURES = { ...@@ -127,7 +127,6 @@ FEATURES = {
'DISABLE_LOGIN_BUTTON': False, # used in systems where login is automatic, eg MIT SSL 'DISABLE_LOGIN_BUTTON': False, # used in systems where login is automatic, eg MIT SSL
# extrernal access methods # extrernal access methods
'ACCESS_REQUIRE_STAFF_FOR_COURSE': False,
'AUTH_USE_OPENID': False, 'AUTH_USE_OPENID': False,
'AUTH_USE_CERTIFICATES': False, 'AUTH_USE_CERTIFICATES': False,
'AUTH_USE_OPENID_PROVIDER': False, 'AUTH_USE_OPENID_PROVIDER': False,
......
...@@ -188,7 +188,6 @@ OPEN_ENDED_GRADING_INTERFACE = { ...@@ -188,7 +188,6 @@ OPEN_ENDED_GRADING_INTERFACE = {
############################## LMS Migration ################################## ############################## LMS Migration ##################################
FEATURES['ENABLE_LMS_MIGRATION'] = True FEATURES['ENABLE_LMS_MIGRATION'] = True
FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = False # require that user be in the staff_* group to be able to enroll
FEATURES['XQA_SERVER'] = 'http://xqa:server@content-qa.edX.mit.edu/xqa' FEATURES['XQA_SERVER'] = 'http://xqa:server@content-qa.edX.mit.edu/xqa'
INSTALLED_APPS += ('lms_migration',) INSTALLED_APPS += ('lms_migration',)
......
...@@ -2,30 +2,28 @@ ...@@ -2,30 +2,28 @@
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from courseware.courses import get_course_about_section
from openedx.core.lib.courses import course_image_url
%> %>
<%page args="course" /> <%page args="course" />
<article class="course" id="${course.id | h}" role="region" aria-label="${get_course_about_section(request, course, 'title')}"> <article class="course" id="${course.id | h}" role="region" aria-label="${course.display_name_with_default}">
<a href="${reverse('about_course', args=[course.id.to_deprecated_string()])}"> <a href="${reverse('about_course', args=[course.id.to_deprecated_string()])}">
<header class="course-image"> <header class="course-image">
<div class="cover-image"> <div class="cover-image">
<img src="${course_image_url(course)}" alt="${get_course_about_section(request, course, 'title')} ${course.display_number_with_default}" /> <img src="${course.course_image_url | h}" alt="${course.display_name_with_default} ${course.display_number_with_default | h}" />
<div class="learn-more" aria-hidden=true>${_("LEARN MORE")}</div> <div class="learn-more" aria-hidden=true>${_("LEARN MORE")}</div>
</div> </div>
</header> </header>
<div class="course-info" aria-hidden="true"> <div class="course-info" aria-hidden="true">
<h2 class="course-name"> <h2 class="course-name">
<span class="course-organization">${get_course_about_section(request, course, 'university')}</span> <span class="course-organization">${course.display_org_with_default | h}</span>
<span class="course-code">${course.display_number_with_default}</span> <span class="course-code">${course.display_number_with_default | h}</span>
<span class="course-title">${get_course_about_section(request, course, 'title')}</span> <span class="course-title">${course.display_name_with_default}</span>
</h2> </h2>
<div class="course-date" aria-hidden="true">${_("Starts")}: ${course.start_datetime_text()}</div> <div class="course-date" aria-hidden="true">${_("Starts")}: ${course.start_datetime_text()}</div>
</div> </div>
<div class="sr"> <div class="sr">
<ul> <ul>
<li>${get_course_about_section(request, course, 'university')}</li> <li>${course.display_org_with_default | h}</li>
<li>${course.display_number_with_default}</li> <li>${course.display_number_with_default | h}</li>
<li>${_("Starts")}: <time itemprop="startDate" datetime="${course.start_datetime_text()}">${course.start_datetime_text()}</time></li> <li>${_("Starts")}: <time itemprop="startDate" datetime="${course.start_datetime_text()}">${course.start_datetime_text()}</time></li>
</ul> </ul>
</div> </div>
......
...@@ -13,7 +13,7 @@ from openedx.core.lib.courses import course_image_url ...@@ -13,7 +13,7 @@ from openedx.core.lib.courses import course_image_url
<%block name="headextra"> <%block name="headextra">
## OG (Open Graph) title and description added below to give social media info to display ## OG (Open Graph) title and description added below to give social media info to display
## (https://developers.facebook.com/docs/opengraph/howtos/maximizing-distribution-media-content#tags) ## (https://developers.facebook.com/docs/opengraph/howtos/maximizing-distribution-media-content#tags)
<meta property="og:title" content="${get_course_about_section(request, course, 'title')}" /> <meta property="og:title" content="${course.display_name_with_default}" />
<meta property="og:description" content="${get_course_about_section(request, course, 'short_description')}" /> <meta property="og:description" content="${get_course_about_section(request, course, 'short_description')}" />
</%block> </%block>
...@@ -102,7 +102,7 @@ from openedx.core.lib.courses import course_image_url ...@@ -102,7 +102,7 @@ from openedx.core.lib.courses import course_image_url
<script src="${static.url('js/course_info.js')}"></script> <script src="${static.url('js/course_info.js')}"></script>
</%block> </%block>
<%block name="pagetitle">${get_course_about_section(request, course, "title")}</%block> <%block name="pagetitle">${course.display_name_with_default}</%block>
<section class="course-info"> <section class="course-info">
<header class="course-profile"> <header class="course-profile">
...@@ -111,9 +111,9 @@ from openedx.core.lib.courses import course_image_url ...@@ -111,9 +111,9 @@ from openedx.core.lib.courses import course_image_url
<section class="intro"> <section class="intro">
<hgroup> <hgroup>
<h1> <h1>
${get_course_about_section(request, course, "title")} ${course.display_name_with_default}
% if not self.theme_enabled(): % if not self.theme_enabled():
<a href="#">${get_course_about_section(request, course, "university")}</a> <a href="#">${course.display_org_with_default | h}</a>
% endif % endif
</h1> </h1>
</hgroup> </hgroup>
...@@ -220,10 +220,10 @@ from openedx.core.lib.courses import course_image_url ...@@ -220,10 +220,10 @@ from openedx.core.lib.courses import course_image_url
## or something allowing themes to do whatever they ## or something allowing themes to do whatever they
## want here (and on this whole page, really). ## want here (and on this whole page, really).
% if self.stanford_theme_enabled(): % if self.stanford_theme_enabled():
<a href="http://twitter.com/intent/tweet?text=I+just+enrolled+in+${course.number}+${get_course_about_section(request, course, 'title')}!+(http://class.stanford.edu)" class="share"> <a href="http://twitter.com/intent/tweet?text=I+just+enrolled+in+${course.number}+${course.display_name_with_default}!+(http://class.stanford.edu)" class="share">
<i class="icon fa fa-twitter"></i><span class="sr">${_("Tweet that you've enrolled in this course")}</span> <i class="icon fa fa-twitter"></i><span class="sr">${_("Tweet that you've enrolled in this course")}</span>
</a> </a>
<a href="mailto:?subject=Take%20a%20course%20at%20Stanford%20online!&body=I%20just%20enrolled%20in%20${course.number}%20${get_course_about_section(request, course, 'title')}+(http://class.stanford.edu)" class="share"> <a href="mailto:?subject=Take%20a%20course%20at%20Stanford%20online!&body=I%20just%20enrolled%20in%20${course.number}%20${course.display_name_with_default}+(http://class.stanford.edu)" class="share">
<i class="icon fa fa-envelope"></i><span class="sr">${_("Email someone to say you've enrolled in this course")}</span> <i class="icon fa fa-envelope"></i><span class="sr">${_("Email someone to say you've enrolled in this course")}</span>
</a> </a>
% else: % else:
...@@ -235,7 +235,7 @@ from openedx.core.lib.courses import course_image_url ...@@ -235,7 +235,7 @@ from openedx.core.lib.courses import course_image_url
## Twitter account. {url} should appear at the end of the text. ## Twitter account. {url} should appear at the end of the text.
tweet_text = _("I just enrolled in {number} {title} through {account}: {url}").format( tweet_text = _("I just enrolled in {number} {title} through {account}: {url}").format(
number=course.number, number=course.number,
title=get_course_about_section(request, course, 'title'), title=course.display_name_with_default,
account=microsite.get_value('course_about_twitter_account', settings.PLATFORM_TWITTER_ACCOUNT), account=microsite.get_value('course_about_twitter_account', settings.PLATFORM_TWITTER_ACCOUNT),
url=u"http://{domain}{path}".format( url=u"http://{domain}{path}".format(
domain=site_domain, domain=site_domain,
...@@ -250,7 +250,7 @@ from openedx.core.lib.courses import course_image_url ...@@ -250,7 +250,7 @@ from openedx.core.lib.courses import course_image_url
subject=_("Take a course with {platform} online").format(platform=platform_name), subject=_("Take a course with {platform} online").format(platform=platform_name),
body=_("I just enrolled in {number} {title} through {platform} {url}").format( body=_("I just enrolled in {number} {title} through {platform} {url}").format(
number=course.number, number=course.number,
title=get_course_about_section(request, course, 'title'), title=course.display_name_with_default,
platform=platform_name, platform=platform_name,
url=u"http://{domain}{path}".format( url=u"http://{domain}{path}".format(
domain=site_domain, domain=site_domain,
......
...@@ -7,7 +7,6 @@ from django.utils.translation import ugettext as _ ...@@ -7,7 +7,6 @@ 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 get_course_university_about_section
from course_modes.models import CourseMode from course_modes.models import CourseMode
from course_modes.helpers import enrollment_mode_display from course_modes.helpers import enrollment_mode_display
from student.helpers import ( from student.helpers import (
...@@ -99,7 +98,7 @@ from student.helpers import ( ...@@ -99,7 +98,7 @@ from student.helpers import (
% endif % endif
</h3> </h3>
<div class="course-info"> <div class="course-info">
<span class="info-university">${get_course_university_about_section(course_overview)} - </span> <span class="info-university">${course_overview.display_org_with_default | h} - </span>
<span class="info-course-id">${course_overview.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_overview.has_ended(): % if course_overview.has_ended():
......
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
from django.utils.translation import ugettext as _ 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 courseware.courses import get_course_about_section, get_course_by_id
from markupsafe import escape from markupsafe import escape
from microsite_configuration import microsite from microsite_configuration import microsite
from openedx.core.lib.courses import course_image_url from openedx.core.lib.courses import course_image_url
...@@ -293,7 +292,7 @@ from openedx.core.lib.courses import course_image_url ...@@ -293,7 +292,7 @@ from openedx.core.lib.courses import course_image_url
<div class="clearfix"> <div class="clearfix">
<div class="image"> <div class="image">
<img class="item-image" src="${course_image_url(course)}" <img class="item-image" src="${course_image_url(course)}"
alt="${course.display_number_with_default | h} ${get_course_about_section(request, course, 'title')} Image"/> alt="${course.display_number_with_default | h} ${course.display_name_with_default} Image"/>
</div> </div>
<div class="data-input"> <div class="data-input">
......
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from courseware.courses import get_course_about_section
from openedx.core.lib.courses import course_image_url from openedx.core.lib.courses import course_image_url
%> %>
<%inherit file="../main.html" /> <%inherit file="../main.html" />
...@@ -21,7 +20,7 @@ from openedx.core.lib.courses import course_image_url ...@@ -21,7 +20,7 @@ from openedx.core.lib.courses import course_image_url
<img class="item-image" src="${course_image_url(course)}" <img class="item-image" src="${course_image_url(course)}"
alt="${_("{course_number} {course_title} Cover Image").format( alt="${_("{course_number} {course_title} Cover Image").format(
course_number=course.display_number_with_default, course_number=course.display_number_with_default,
course_title=get_course_about_section(request, course, 'title'), course_title=course.display_name_with_default,
)}"/> )}"/>
</div> </div>
<div class="enrollment-details"> <div class="enrollment-details">
......
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from courseware.courses import get_course_about_section
from openedx.core.lib.courses import course_image_url from openedx.core.lib.courses import course_image_url
%> %>
<%inherit file="../main.html" /> <%inherit file="../main.html" />
...@@ -21,7 +20,7 @@ from openedx.core.lib.courses import course_image_url ...@@ -21,7 +20,7 @@ from openedx.core.lib.courses import course_image_url
<img class="item-image" src="${course_image_url(course)}" <img class="item-image" src="${course_image_url(course)}"
alt="${_("{course_number} {course_title} Cover Image").format( alt="${_("{course_number} {course_title} Cover Image").format(
course_number=course.display_number_with_default, course_number=course.display_number_with_default,
course_title=get_course_about_section(request, course, 'title'), course_title=course.display_name_with_default,
)}" /> )}" />
</div> </div>
<div class="enrollment-details"> <div class="enrollment-details">
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
<%block name="review_highlight">class="active"</%block> <%block name="review_highlight">class="active"</%block>
<%! <%!
from courseware.courses import get_course_about_section
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from edxmako.shortcuts import marketing_link from edxmako.shortcuts import marketing_link
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -67,7 +66,7 @@ from openedx.core.lib.courses import course_image_url ...@@ -67,7 +66,7 @@ from openedx.core.lib.courses import course_image_url
<div class="clearfix"> <div class="clearfix">
<div class="image"> <div class="image">
<img class="item-image" src="${course_image_url(course)}" <img class="item-image" src="${course_image_url(course)}"
alt="${course.display_number_with_default | h} ${get_course_about_section(request, course, 'title')} ${_('Cover Image')}" /> alt="${course.display_number_with_default | h} ${course.display_name_with_default} ${_('Cover Image')}" />
</div> </div>
<div class="data-input"> <div class="data-input">
## Translators: "Registration for:" is followed by a course name ## Translators: "Registration for:" is followed by a course name
......
...@@ -32,11 +32,13 @@ class Command(BaseCommand): ...@@ -32,11 +32,13 @@ class Command(BaseCommand):
) )
def handle(self, *args, **options): def handle(self, *args, **options):
course_keys = []
if options['all']: if options['all']:
course_keys = [course.id for course in modulestore().get_courses()] # Have CourseOverview generate course overviews for all
# the courses in the system.
CourseOverview.get_all_courses(force_reseeding=True)
else: else:
course_keys = []
if len(args) < 1: if len(args) < 1:
raise CommandError('At least one course or --all must be specified.') raise CommandError('At least one course or --all must be specified.')
try: try:
...@@ -44,17 +46,7 @@ class Command(BaseCommand): ...@@ -44,17 +46,7 @@ class Command(BaseCommand):
except InvalidKeyError: except InvalidKeyError:
log.fatal('Invalid key specified.') log.fatal('Invalid key specified.')
if not course_keys: if not course_keys:
log.fatal('No courses specified.') log.fatal('No courses specified.')
log.info('Generating course overview for %d courses.', len(course_keys)) CourseOverview.get_select_courses(course_keys)
log.debug('Generating course overview(s) for the following courses: %s', course_keys)
for course_key in course_keys:
try:
CourseOverview.get_from_id(course_key)
except Exception as ex: # pylint: disable=broad-except
log.exception('An error occurred while generating course overview for %s: %s', unicode(
course_key), ex.message)
log.info('Finished generating course overviews.')
...@@ -64,7 +64,7 @@ class TestGenerateCourseOverview(ModuleStoreTestCase): ...@@ -64,7 +64,7 @@ class TestGenerateCourseOverview(ModuleStoreTestCase):
self.command.handle('not/found', all=False) self.command.handle('not/found', all=False)
self.assertTrue(mock_log.fatal.called) self.assertTrue(mock_log.fatal.called)
@patch('openedx.core.djangoapps.content.course_overviews.management.commands.generate_course_overview.log') @patch('openedx.core.djangoapps.content.course_overviews.models.log')
def test_not_found_key(self, mock_log): def test_not_found_key(self, mock_log):
""" """
Test keys not found are logged. Test keys not found are logged.
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course_overviews', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='courseoverview',
name='announcement',
field=models.DateTimeField(null=True),
),
migrations.AddField(
model_name='courseoverview',
name='catalog_visibility',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='courseoverview',
name='course_video_url',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='courseoverview',
name='effort',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='courseoverview',
name='short_description',
field=models.TextField(null=True),
),
]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
import model_utils.fields
class Migration(migrations.Migration):
dependencies = [
('course_overviews', '0002_add_course_catalog_fields'),
]
operations = [
migrations.CreateModel(
name='CourseOverviewGeneratedHistory',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)),
('num_courses', models.IntegerField(null=True)),
],
options={
'abstract': False,
},
),
]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course_overviews', '0003_courseoverviewgeneratedhistory'),
]
operations = [
migrations.AddField(
model_name='courseoverview',
name='org',
field=models.TextField(default=b'outdated_entry', max_length=255),
),
]
...@@ -2,19 +2,21 @@ ...@@ -2,19 +2,21 @@
Declaration of CourseOverview model Declaration of CourseOverview model
""" """
import json import json
from django.db import models, transaction import logging
from django.db import models, transaction
from django.db.models.fields import BooleanField, DateTimeField, DecimalField, TextField, FloatField, IntegerField from django.db.models.fields import BooleanField, DateTimeField, DecimalField, TextField, FloatField, IntegerField
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.template import defaultfilters
from django.utils.translation import ugettext from django.utils.translation import ugettext
from lms.djangoapps import django_comment_client from lms.djangoapps import django_comment_client
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.models.course_details import CourseDetails
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.course_module import CourseDescriptor, DEFAULT_START_DATE
from xmodule.error_module import ErrorDescriptor 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
...@@ -22,20 +24,26 @@ from xmodule_django.models import CourseKeyField, UsageKeyField ...@@ -22,20 +24,26 @@ from xmodule_django.models import CourseKeyField, UsageKeyField
from ccx_keys.locator import CCXLocator from ccx_keys.locator import CCXLocator
log = logging.getLogger(__name__)
class CourseOverview(TimeStampedModel): class CourseOverview(TimeStampedModel):
""" """
Model for storing and caching basic information about a course. Model for storing and caching basic information about a course.
This model contains basic course metadata such as an ID, display name, This model contains basic course metadata such as an ID, display name,
image URL, and any other information that would be necessary to display image URL, and any other information that would be necessary to display
a course as part of a user dashboard or enrollment API. a course as part of:
user dashboard (enrolled courses)
course catalog (courses to enroll in)
course about (meta data about the course)
""" """
class Meta(object): class Meta(object):
app_label = 'course_overviews' app_label = 'course_overviews'
# IMPORTANT: Bump this whenever you modify this model and/or add a migration. # IMPORTANT: Bump this whenever you modify this model and/or add a migration.
VERSION = 2 VERSION = 3
# Cache entry versioning. # Cache entry versioning.
version = IntegerField() version = IntegerField()
...@@ -43,6 +51,7 @@ class CourseOverview(TimeStampedModel): ...@@ -43,6 +51,7 @@ class CourseOverview(TimeStampedModel):
# Course identification # Course identification
id = CourseKeyField(db_index=True, primary_key=True, max_length=255) id = CourseKeyField(db_index=True, primary_key=True, max_length=255)
_location = UsageKeyField(max_length=255) _location = UsageKeyField(max_length=255)
org = TextField(max_length=255, default='outdated_entry')
display_name = TextField(null=True) display_name = TextField(null=True)
display_number_with_default = TextField() display_number_with_default = TextField()
display_org_with_default = TextField() display_org_with_default = TextField()
...@@ -51,6 +60,7 @@ class CourseOverview(TimeStampedModel): ...@@ -51,6 +60,7 @@ class CourseOverview(TimeStampedModel):
start = DateTimeField(null=True) start = DateTimeField(null=True)
end = DateTimeField(null=True) end = DateTimeField(null=True)
advertised_start = TextField(null=True) advertised_start = TextField(null=True)
announcement = DateTimeField(null=True)
# URLs # URLs
course_image_url = TextField() course_image_url = TextField()
...@@ -82,6 +92,12 @@ class CourseOverview(TimeStampedModel): ...@@ -82,6 +92,12 @@ class CourseOverview(TimeStampedModel):
invitation_only = BooleanField(default=False) invitation_only = BooleanField(default=False)
max_student_enrollments_allowed = IntegerField(null=True) max_student_enrollments_allowed = IntegerField(null=True)
# Catalog information
catalog_visibility = TextField(null=True)
short_description = TextField(null=True)
course_video_url = TextField(null=True)
effort = TextField(null=True)
@classmethod @classmethod
def _create_from_course(cls, course): def _create_from_course(cls, course):
""" """
...@@ -99,6 +115,8 @@ class CourseOverview(TimeStampedModel): ...@@ -99,6 +115,8 @@ class CourseOverview(TimeStampedModel):
from lms.djangoapps.certificates.api import get_active_web_certificate from lms.djangoapps.certificates.api import get_active_web_certificate
from openedx.core.lib.courses import course_image_url from openedx.core.lib.courses import course_image_url
log.info('Creating course overview for %s.', unicode(course.id))
# Workaround for a problem discovered in https://openedx.atlassian.net/browse/TNL-2806. # Workaround for a problem discovered in https://openedx.atlassian.net/browse/TNL-2806.
# If the course has a malformed grading policy such that # If the course has a malformed grading policy such that
# course._grading_policy['GRADE_CUTOFFS'] = {}, then # course._grading_policy['GRADE_CUTOFFS'] = {}, then
...@@ -125,6 +143,7 @@ class CourseOverview(TimeStampedModel): ...@@ -125,6 +143,7 @@ class CourseOverview(TimeStampedModel):
version=cls.VERSION, version=cls.VERSION,
id=course.id, id=course.id,
_location=course.location, _location=course.location,
org=course.location.org,
display_name=display_name, display_name=display_name,
display_number_with_default=course.display_number_with_default, display_number_with_default=course.display_number_with_default,
display_org_with_default=course.display_org_with_default, display_org_with_default=course.display_org_with_default,
...@@ -132,6 +151,7 @@ class CourseOverview(TimeStampedModel): ...@@ -132,6 +151,7 @@ class CourseOverview(TimeStampedModel):
start=start, start=start,
end=end, end=end,
advertised_start=course.advertised_start, advertised_start=course.advertised_start,
announcement=course.announcement,
course_image_url=course_image_url(course), course_image_url=course_image_url(course),
facebook_url=course.facebook_url, facebook_url=course.facebook_url,
...@@ -156,6 +176,11 @@ class CourseOverview(TimeStampedModel): ...@@ -156,6 +176,11 @@ class CourseOverview(TimeStampedModel):
enrollment_domain=course.enrollment_domain, enrollment_domain=course.enrollment_domain,
invitation_only=course.invitation_only, invitation_only=course.invitation_only,
max_student_enrollments_allowed=max_student_enrollments_allowed, max_student_enrollments_allowed=max_student_enrollments_allowed,
catalog_visibility=course.catalog_visibility,
short_description=CourseDetails.fetch_about_attribute(course.id, 'short_description'),
effort=CourseDetails.fetch_about_attribute(course.id, 'effort'),
course_video_url=CourseDetails.fetch_video_url(course.id),
) )
@classmethod @classmethod
...@@ -343,6 +368,42 @@ class CourseOverview(TimeStampedModel): ...@@ -343,6 +368,42 @@ class CourseOverview(TimeStampedModel):
strftime_localized strftime_localized
) )
@property
def sorting_score(self):
"""
Returns a tuple that can be used to sort the courses according
the how "new" they are. The "newness" score is computed using a
heuristic that takes into account the announcement and
(advertised) start dates of the course if available.
The lower the number the "newer" the course.
"""
return course_metadata_utils.sorting_score(self.start, self.advertised_start, self.announcement)
@property
def start_type(self):
"""
Returns the type of the course's 'start' field.
"""
if self.advertised_start:
return u'string'
elif self.start != DEFAULT_START_DATE:
return u'timestamp'
else:
return u'empty'
@property
def start_display(self):
"""
Returns the display value for the course's start date.
"""
if self.advertised_start:
return self.advertised_start
elif self.start != DEFAULT_START_DATE:
return defaultfilters.date(self.start, "DATE_FORMAT")
else:
return None
def may_certify(self): def may_certify(self):
""" """
Returns whether it is acceptable to show the student a certificate Returns whether it is acceptable to show the student a certificate
...@@ -362,6 +423,72 @@ class CourseOverview(TimeStampedModel): ...@@ -362,6 +423,72 @@ class CourseOverview(TimeStampedModel):
return json.loads(self._pre_requisite_courses_json) return json.loads(self._pre_requisite_courses_json)
@classmethod @classmethod
def get_select_courses(cls, course_keys):
"""
Returns CourseOverview objects for the given course_keys.
"""
course_overviews = []
log.info('Generating course overview for %d courses.', len(course_keys))
log.debug('Generating course overview(s) for the following courses: %s', course_keys)
for course_key in course_keys:
try:
course_overviews.append(CourseOverview.get_from_id(course_key))
except Exception as ex: # pylint: disable=broad-except
log.exception(
'An error occurred while generating course overview for %s: %s',
unicode(course_key),
ex.message,
)
log.info('Finished generating course overviews.')
return course_overviews
@classmethod
def get_all_courses(cls, force_reseeding=False, org=None):
"""
Returns all CourseOverview objects in the database.
Arguments:
force_reseeding (bool): Optional parameter.
If True, the modulestore is used as the source of truth for
the list of courses, even if the CourseOverview table was
previously seeded. However, only non-existing CourseOverview
entries or those with older data model versions or will get
populated.
If False, the list of courses is retrieved from the
CourseOverview table if it was previously seeded, falling
back to the modulestore if it wasn't seeded.
org (string): Optional parameter that allows filtering
by organization.
"""
if force_reseeding or not CourseOverviewGeneratedHistory.objects.first():
# Seed the CourseOverview table with data for all
# courses in the system.
course_keys = [course.id for course in modulestore().get_courses()]
course_overviews = cls.get_select_courses(course_keys)
num_courses = len(course_overviews)
CourseOverviewGeneratedHistory.objects.create(num_courses=num_courses)
if org:
course_overviews = [c for c in course_overviews if c.org == org]
else:
# Note: If a newly created course is not returned in this QueryList,
# make sure the "publish" signal was emitted when the course was
# created. For tests using CourseFactory, use emit_signals=True.
# Or pass True for force_reseeding.
course_overviews = CourseOverview.objects.all()
if org:
course_overviews = course_overviews.filter(org=org)
return course_overviews
@classmethod
def get_all_course_keys(cls): def get_all_course_keys(cls):
""" """
Returns all course keys from course overviews. Returns all course keys from course overviews.
...@@ -389,3 +516,14 @@ class CourseOverviewTab(models.Model): ...@@ -389,3 +516,14 @@ class CourseOverviewTab(models.Model):
""" """
tab_id = models.CharField(max_length=50) tab_id = models.CharField(max_length=50)
course_overview = models.ForeignKey(CourseOverview, db_index=True, related_name="tabs") course_overview = models.ForeignKey(CourseOverview, db_index=True, related_name="tabs")
class CourseOverviewGeneratedHistory(TimeStampedModel):
"""
Model for keeping track of when CourseOverview Models are
generated/seeded.
"""
num_courses = IntegerField(null=True)
def __unicode__(self):
return self.num_courses
...@@ -11,8 +11,14 @@ import pytz ...@@ -11,8 +11,14 @@ import pytz
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 openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.core.lib.courses import course_image_url from openedx.core.lib.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.course_module import (
CATALOG_VISIBILITY_CATALOG_AND_ABOUT,
CATALOG_VISIBILITY_ABOUT,
CATALOG_VISIBILITY_NONE,
)
from xmodule.error_module import ErrorDescriptor 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.django import modulestore
...@@ -96,6 +102,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -96,6 +102,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
'enrollment_domain', 'enrollment_domain',
'invitation_only', 'invitation_only',
'max_student_enrollments_allowed', 'max_student_enrollments_allowed',
'catalog_visibility',
] ]
for attribute_name in fields_to_test: for attribute_name in fields_to_test:
course_value = getattr(course, attribute_name) course_value = getattr(course, attribute_name)
...@@ -124,45 +131,49 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -124,45 +131,49 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
self.assertEqual(cache_miss_value, cache_hit_value) self.assertEqual(cache_miss_value, cache_hit_value)
# Other values to test # Other values to test
# Note: we test the start and end attributes here instead of in
# fields_to_test, because I ran into trouble while testing datetimes # Note: we test the time-related attributes here instead of in
# fields_to_test, because we run into trouble while testing datetimes
# for equality. When writing and reading dates from databases, the # for equality. When writing and reading dates from databases, the
# resulting values are often off by fractions of a second. So, as a # resulting values are often off by fractions of a second. So, as a
# workaround, we simply test if the start and end times are the same # workaround, we simply test if the start and end times are the same
# number of seconds from the Unix epoch. # number of seconds from the Unix epoch.
time_field_accessor = lambda object, field_name: get_seconds_since_epoch(getattr(object, field_name))
# The course about fields are accessed through the CourseDetail
# class for the course module, and stored as attributes on the
# CourseOverview objects.
course_about_accessor = lambda object, field_name: CourseDetails.fetch_about_attribute(object.id, field_name)
others_to_test = [ others_to_test = [
('start', time_field_accessor, time_field_accessor),
('end', time_field_accessor, time_field_accessor),
('enrollment_start', time_field_accessor, time_field_accessor),
('enrollment_end', time_field_accessor, time_field_accessor),
('announcement', time_field_accessor, time_field_accessor),
('short_description', course_about_accessor, getattr),
('effort', course_about_accessor, getattr),
( (
course_image_url(course), 'video',
course_overview_cache_miss.course_image_url, lambda c, __: CourseDetails.fetch_video_url(c.id),
course_overview_cache_hit.course_image_url lambda c, __: c.course_video_url,
),
(
get_active_web_certificate(course) is not None,
course_overview_cache_miss.has_any_active_web_certificate,
course_overview_cache_hit.has_any_active_web_certificate
),
(
get_seconds_since_epoch(course.start),
get_seconds_since_epoch(course_overview_cache_miss.start),
get_seconds_since_epoch(course_overview_cache_hit.start),
),
(
get_seconds_since_epoch(course.end),
get_seconds_since_epoch(course_overview_cache_miss.end),
get_seconds_since_epoch(course_overview_cache_hit.end),
), ),
( (
get_seconds_since_epoch(course.enrollment_start), 'course_image_url',
get_seconds_since_epoch(course_overview_cache_miss.enrollment_start), lambda c, __: course_image_url(c),
get_seconds_since_epoch(course_overview_cache_hit.enrollment_start), getattr,
), ),
( (
get_seconds_since_epoch(course.enrollment_end), 'has_any_active_web_certificate',
get_seconds_since_epoch(course_overview_cache_miss.enrollment_end), lambda c, field_name: get_active_web_certificate(c) is not None,
get_seconds_since_epoch(course_overview_cache_hit.enrollment_end), getattr,
), ),
] ]
for (course_value, cache_miss_value, cache_hit_value) in others_to_test: for attribute_name, course_accessor, course_overview_accessor in others_to_test:
course_value = course_accessor(course, attribute_name)
cache_miss_value = course_overview_accessor(course_overview_cache_miss, attribute_name)
cache_hit_value = course_overview_accessor(course_overview_cache_hit, attribute_name)
self.assertEqual(course_value, cache_miss_value) self.assertEqual(course_value, cache_miss_value)
self.assertEqual(cache_miss_value, cache_hit_value) self.assertEqual(cache_miss_value, cache_hit_value)
...@@ -178,6 +189,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -178,6 +189,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
"display_name": "Test Course", # Display name provided "display_name": "Test Course", # Display name provided
"start": LAST_WEEK, # In the middle of the course "start": LAST_WEEK, # In the middle of the course
"end": NEXT_WEEK, "end": NEXT_WEEK,
"announcement": LAST_MONTH, # Announcement date provided
"advertised_start": "2015-01-01 11:22:33", # Parse-able advertised_start "advertised_start": "2015-01-01 11:22:33", # Parse-able advertised_start
"pre_requisite_courses": [ # Has pre-requisites "pre_requisite_courses": [ # Has pre-requisites
'course-v1://edX+test1+run1', 'course-v1://edX+test1+run1',
...@@ -194,6 +206,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -194,6 +206,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
"pre_requisite_courses": [], # No pre-requisites "pre_requisite_courses": [], # No pre-requisites
"static_asset_path": "my/relative/path", # Relative asset path "static_asset_path": "my/relative/path", # Relative asset path
"certificates_show_before_end": False, "certificates_show_before_end": False,
"catalog_visibility": CATALOG_VISIBILITY_CATALOG_AND_ABOUT,
}, },
{ {
"display_name": "", # Empty display name "display_name": "", # Empty display name
...@@ -203,6 +216,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -203,6 +216,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
"pre_requisite_courses": [], # No pre-requisites "pre_requisite_courses": [], # No pre-requisites
"static_asset_path": "", # Empty asset path "static_asset_path": "", # Empty asset path
"certificates_show_before_end": False, "certificates_show_before_end": False,
"catalog_visibility": CATALOG_VISIBILITY_ABOUT,
}, },
{ {
# # Don't set display name # # Don't set display name
...@@ -212,6 +226,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -212,6 +226,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
"pre_requisite_courses": [], # No pre-requisites "pre_requisite_courses": [], # No pre-requisites
"static_asset_path": None, # No asset path "static_asset_path": None, # No asset path
"certificates_show_before_end": False, "certificates_show_before_end": False,
"catalog_visibility": CATALOG_VISIBILITY_NONE,
} }
], ],
[ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split] [ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split]
...@@ -325,7 +340,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -325,7 +340,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
course_overview = CourseOverview._create_from_course(course) # pylint: disable=protected-access course_overview = CourseOverview._create_from_course(course) # pylint: disable=protected-access
self.assertEqual(course_overview.lowest_passing_grade, None) self.assertEqual(course_overview.lowest_passing_grade, None)
@ddt.data((ModuleStoreEnum.Type.mongo, 1, 1), (ModuleStoreEnum.Type.split, 3, 4)) @ddt.data((ModuleStoreEnum.Type.mongo, 4, 4), (ModuleStoreEnum.Type.split, 3, 4))
@ddt.unpack @ddt.unpack
def test_versioning(self, modulestore_type, min_mongo_calls, max_mongo_calls): def test_versioning(self, modulestore_type, min_mongo_calls, max_mongo_calls):
""" """
...@@ -425,3 +440,50 @@ class CourseOverviewTestCase(ModuleStoreTestCase): ...@@ -425,3 +440,50 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
# knows how to write, it's not going to overwrite what's there. # knows how to write, it's not going to overwrite what's there.
unmodified_overview = CourseOverview.get_from_id(course.id) unmodified_overview = CourseOverview.get_from_id(course.id)
self.assertEqual(unmodified_overview.version, 11) self.assertEqual(unmodified_overview.version, 11)
def test_get_select_courses(self):
course_ids = [CourseFactory.create().id for __ in range(3)]
select_course_ids = course_ids[:len(course_ids) - 1] # all items except the last
self.assertSetEqual(
{course_overview.id for course_overview in CourseOverview.get_select_courses(select_course_ids)},
set(select_course_ids),
)
def test_get_all_courses(self):
course_ids = [CourseFactory.create().id for __ in range(3)]
self.assertSetEqual(
{course_overview.id for course_overview in CourseOverview.get_all_courses()},
set(course_ids),
)
with mock.patch(
'openedx.core.djangoapps.content.course_overviews.models.CourseOverview.get_from_id'
) as mock_get_from_id:
CourseOverview.get_all_courses()
self.assertFalse(mock_get_from_id.called)
CourseOverview.get_all_courses(force_reseeding=True)
self.assertTrue(mock_get_from_id.called)
def test_get_all_courses_by_org(self):
org_courses = [] # list of lists of courses
for index in range(2):
org_courses.append([
CourseFactory.create(org='test_org_' + unicode(index))
for __ in range(3)
])
self.assertSetEqual(
{c.id for c in CourseOverview.get_all_courses(org='test_org_0', force_reseeding=True)},
{c.id for c in org_courses[0]},
)
self.assertSetEqual(
{c.id for c in CourseOverview.get_all_courses(org='test_org_1')},
{c.id for c in org_courses[1]},
)
self.assertSetEqual(
{c.id for c in CourseOverview.get_all_courses()},
{c.id for c in org_courses[0] + org_courses[1]},
)
...@@ -60,10 +60,13 @@ class CourseDetails(object): ...@@ -60,10 +60,13 @@ class CourseDetails(object):
self.self_paced = None self.self_paced = None
@classmethod @classmethod
def _fetch_about_attribute(cls, course_key, attribute): def fetch_about_attribute(cls, course_key, attribute):
""" """
Retrieve an attribute from a course's "about" info Retrieve an attribute from a course's "about" info
""" """
if attribute not in ABOUT_ATTRIBUTES + ['video']:
raise ValueError("'{0}' is not a valid course about attribute.".format(attribute))
usage_key = course_key.make_usage_key('about', attribute) usage_key = course_key.make_usage_key('about', attribute)
try: try:
value = modulestore().get_item(usage_key).data value = modulestore().get_item(usage_key).data
...@@ -96,7 +99,7 @@ class CourseDetails(object): ...@@ -96,7 +99,7 @@ class CourseDetails(object):
course_details.intro_video = cls.fetch_youtube_video_id(course_key) course_details.intro_video = cls.fetch_youtube_video_id(course_key)
for attribute in ABOUT_ATTRIBUTES: for attribute in ABOUT_ATTRIBUTES:
value = cls._fetch_about_attribute(course_key, attribute) value = cls.fetch_about_attribute(course_key, attribute)
if value is not None: if value is not None:
setattr(course_details, attribute, value) setattr(course_details, attribute, value)
...@@ -107,7 +110,7 @@ class CourseDetails(object): ...@@ -107,7 +110,7 @@ class CourseDetails(object):
""" """
Returns the course about video ID. Returns the course about video ID.
""" """
raw_video = cls._fetch_about_attribute(course_key, 'video') raw_video = cls.fetch_about_attribute(course_key, 'video')
if raw_video: if raw_video:
return cls.parse_video_tag(raw_video) return cls.parse_video_tag(raw_video)
...@@ -121,13 +124,6 @@ class CourseDetails(object): ...@@ -121,13 +124,6 @@ class CourseDetails(object):
return "http://www.youtube.com/watch?v={0}".format(video_id) return "http://www.youtube.com/watch?v={0}".format(video_id)
@classmethod @classmethod
def fetch_effort(cls, course_key):
"""
Returns the hours per week of effort for the course.
"""
return cls._fetch_about_attribute(course_key, 'effort')
@classmethod
def update_about_item(cls, course, about_key, data, user_id, store=None): def update_about_item(cls, course, about_key, data, user_id, store=None):
""" """
Update the about item with the new data blob. If data is None, Update the about item with the new data blob. If data is None,
......
...@@ -3,6 +3,7 @@ Tests for CourseDetails ...@@ -3,6 +3,7 @@ Tests for CourseDetails
""" """
import datetime import datetime
import ddt
from django.utils.timezone import UTC from django.utils.timezone import UTC
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
...@@ -10,9 +11,10 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -10,9 +11,10 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.models.course_details import CourseDetails, ABOUT_ATTRIBUTES
@ddt.ddt
class CourseDetailsTestCase(ModuleStoreTestCase): class CourseDetailsTestCase(ModuleStoreTestCase):
""" """
Tests the first course settings page (course dates, overview, etc.). Tests the first course settings page (course dates, overview, etc.).
...@@ -111,11 +113,19 @@ class CourseDetailsTestCase(ModuleStoreTestCase): ...@@ -111,11 +113,19 @@ class CourseDetailsTestCase(ModuleStoreTestCase):
) )
self.assertFalse(updated_details.self_paced) self.assertFalse(updated_details.self_paced)
def test_fetch_effort(self): @ddt.data(*ABOUT_ATTRIBUTES)
effort_value = 'test_hours_of_effort' def test_fetch_about_attribute(self, attribute_name):
attribute_value = 'test_value'
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id): with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id):
CourseDetails.update_about_item(self.course, 'effort', effort_value, self.user.id) CourseDetails.update_about_item(self.course, attribute_name, attribute_value, self.user.id)
self.assertEqual(CourseDetails.fetch_effort(self.course.id), effort_value) self.assertEqual(CourseDetails.fetch_about_attribute(self.course.id, attribute_name), attribute_value)
def test_fetch_about_attribute_error(self):
attribute_name = 'not_an_about_attribute'
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id):
CourseDetails.update_about_item(self.course, attribute_name, 'test_value', self.user.id)
with self.assertRaises(ValueError):
CourseDetails.fetch_about_attribute(self.course.id, attribute_name)
def test_fetch_video(self): def test_fetch_video(self):
video_value = 'test_video_id' video_value = 'test_video_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