Merge pull request #8367 from edx/diana/merge-course-view-and-tab

Refactor and merge CourseViewType and CourseTab.
This commit is contained in:
Diana Huang
2015-06-08 16:08:27 -04:00
28 changed files with 442 additions and 514 deletions

View File

@@ -5,16 +5,16 @@ Registers the CCX feature for the edX platform.
from django.conf import settings
from django.utils.translation import ugettext as _
from openedx.core.djangoapps.course_views.course_views import CourseViewType
from xmodule.tabs import CourseTab
from student.roles import CourseCcxCoachRole
class CcxCourseViewType(CourseViewType):
class CcxCourseTab(CourseTab):
"""
The representation of the CCX course view type.
The representation of the CCX course tab
"""
name = "ccx_coach"
type = "ccx_coach"
title = _("CCX Coach")
view_name = "ccx_coach_dashboard"
is_dynamic = True # The CCX view is dynamically added to the set of tabs when it is enabled

View File

@@ -6,15 +6,15 @@ a user has on an article.
from django.conf import settings
from django.utils.translation import ugettext as _
from courseware.tabs import EnrolledCourseViewType
from courseware.tabs import EnrolledTab
class WikiCourseViewType(EnrolledCourseViewType):
class WikiTab(EnrolledTab):
"""
Defines the Wiki view type that is shown as a course tab.
"""
name = "wiki"
type = "wiki"
title = _('Wiki')
view_name = "course_wiki"
is_hideable = True
@@ -28,4 +28,4 @@ class WikiCourseViewType(EnrolledCourseViewType):
return False
if course.allow_public_wiki_access:
return True
return super(WikiCourseViewType, cls).is_enabled(course, user=user)
return super(WikiTab, cls).is_enabled(course, user=user)

View File

@@ -7,12 +7,12 @@ from django.utils.translation import ugettext as _
from courseware.access import has_access
from courseware.entrance_exams import user_must_complete_entrance_exam
from openedx.core.djangoapps.course_views.course_views import CourseViewTypeManager, CourseViewType, StaticTab
from openedx.core.lib.course_tabs import CourseTabPluginManager
from student.models import CourseEnrollment
from xmodule.tabs import CourseTab, CourseTabList, key_checker
class EnrolledCourseViewType(CourseViewType):
class EnrolledTab(CourseTab):
"""
A base class for any view types that require a user to be enrolled.
"""
@@ -23,22 +23,22 @@ class EnrolledCourseViewType(CourseViewType):
return CourseEnrollment.is_enrolled(user, course.id) or has_access(user, 'staff', course, course.id)
class CoursewareViewType(EnrolledCourseViewType):
class CoursewareTab(EnrolledTab):
"""
The main courseware view.
"""
name = 'courseware'
type = 'courseware'
title = _('Courseware')
priority = 10
view_name = 'courseware'
is_movable = False
class CourseInfoViewType(CourseViewType):
class CourseInfoTab(CourseTab):
"""
The course info view.
"""
name = 'course_info'
type = 'course_info'
title = _('Course Info')
priority = 20
view_name = 'info'
@@ -50,27 +50,28 @@ class CourseInfoViewType(CourseViewType):
return True
class SyllabusCourseViewType(EnrolledCourseViewType):
class SyllabusTab(EnrolledTab):
"""
A tab for the course syllabus.
"""
name = 'syllabus'
type = 'syllabus'
title = _('Syllabus')
priority = 30
view_name = 'syllabus'
allow_multiple = True
@classmethod
def is_enabled(cls, course, user=None): # pylint: disable=unused-argument
if not super(SyllabusCourseViewType, cls).is_enabled(course, user=user):
if not super(SyllabusTab, cls).is_enabled(course, user=user):
return False
return getattr(course, 'syllabus_present', False)
class ProgressCourseViewType(EnrolledCourseViewType):
class ProgressTab(EnrolledTab):
"""
The course progress view.
"""
name = 'progress'
type = 'progress'
title = _('Progress')
priority = 40
view_name = 'progress'
@@ -78,12 +79,12 @@ class ProgressCourseViewType(EnrolledCourseViewType):
@classmethod
def is_enabled(cls, course, user=None): # pylint: disable=unused-argument
if not super(ProgressCourseViewType, cls).is_enabled(course, user=user):
if not super(ProgressTab, cls).is_enabled(course, user=user):
return False
return not course.hide_progress_tab
class TextbookCourseViewsBase(CourseViewType):
class TextbookTabsBase(CourseTab):
"""
Abstract class for textbook collection tabs classes.
"""
@@ -104,17 +105,17 @@ class TextbookCourseViewsBase(CourseViewType):
raise NotImplementedError()
class TextbookCourseViews(TextbookCourseViewsBase):
class TextbookTabs(TextbookTabsBase):
"""
A tab representing the collection of all textbook tabs.
"""
name = 'textbooks'
type = 'textbooks'
priority = None
view_name = 'book'
@classmethod
def is_enabled(cls, course, user=None): # pylint: disable=unused-argument
parent_is_enabled = super(TextbookCourseViews, cls).is_enabled(course, user)
parent_is_enabled = super(TextbookTabs, cls).is_enabled(course, user)
return settings.FEATURES.get('ENABLE_TEXTBOOK') and parent_is_enabled
@classmethod
@@ -128,11 +129,11 @@ class TextbookCourseViews(TextbookCourseViewsBase):
)
class PDFTextbookCourseViews(TextbookCourseViewsBase):
class PDFTextbookTabs(TextbookTabsBase):
"""
A tab representing the collection of all PDF textbook tabs.
"""
name = 'pdf_textbooks'
type = 'pdf_textbooks'
priority = None
view_name = 'pdf_book'
@@ -147,11 +148,11 @@ class PDFTextbookCourseViews(TextbookCourseViewsBase):
)
class HtmlTextbookCourseViews(TextbookCourseViewsBase):
class HtmlTextbookTabs(TextbookTabsBase):
"""
A tab representing the collection of all Html textbook tabs.
"""
name = 'html_textbooks'
type = 'html_textbooks'
priority = None
view_name = 'html_book'
@@ -166,89 +167,6 @@ class HtmlTextbookCourseViews(TextbookCourseViewsBase):
)
class StaticCourseViewType(CourseViewType):
"""
The view type that shows a static tab.
"""
name = 'static_tab'
is_default = False # A static tab is never added to a course by default
allow_multiple = True
@classmethod
def is_enabled(cls, course, user=None): # pylint: disable=unused-argument
"""
Static tabs are viewable to everyone, even anonymous users.
"""
return True
@classmethod
def validate(cls, tab_dict, raise_error=True):
"""
Ensures that the specified tab_dict is valid.
"""
return (super(StaticCourseViewType, cls).validate(tab_dict, raise_error)
and key_checker(['name', 'url_slug'])(tab_dict, raise_error))
@classmethod
def create_tab(cls, tab_dict):
"""
Returns the tab that will be shown to represent an instance of a view.
"""
return StaticTab(tab_dict)
class ExternalDiscussionCourseViewType(EnrolledCourseViewType):
"""
A course view links to an external discussion service.
"""
name = 'external_discussion'
# Translators: 'Discussion' refers to the tab in the courseware that leads to the discussion forums
title = _('Discussion')
priority = None
@classmethod
def create_tab(cls, tab_dict):
"""
Returns the tab that will be shown to represent an instance of a view.
"""
return LinkTab(tab_dict, cls.title)
@classmethod
def validate(cls, tab_dict, raise_error=True):
""" Validate that the tab_dict for this course view has the necessary information to render. """
return (super(ExternalDiscussionCourseViewType, cls).validate(tab_dict, raise_error) and
key_checker(['link'])(tab_dict, raise_error))
@classmethod
def is_enabled(cls, course, user=None): # pylint: disable=unused-argument
if not super(ExternalDiscussionCourseViewType, cls).is_enabled(course, user=user):
return False
return course.discussion_link
class ExternalLinkCourseViewType(EnrolledCourseViewType):
"""
A course view containing an external link.
"""
name = 'external_link'
priority = None
is_default = False # An external link tab is not added to a course by default
@classmethod
def create_tab(cls, tab_dict):
"""
Returns the tab that will be shown to represent an instance of a view.
"""
return LinkTab(tab_dict)
@classmethod
def validate(cls, tab_dict, raise_error=True):
""" Validate that the tab_dict for this course view has the necessary information to render. """
return (super(ExternalLinkCourseViewType, cls).validate(tab_dict, raise_error) and
key_checker(['link', 'name'])(tab_dict, raise_error))
class LinkTab(CourseTab):
"""
Abstract class for tabs that contain external links.
@@ -264,11 +182,9 @@ class LinkTab(CourseTab):
self.type = tab_dict['type']
super(LinkTab, self).__init__(
name=tab_dict['name'] if tab_dict else name,
tab_id=None,
link_func=link_value_func,
)
tab_dict['link_func'] = link_value_func
super(LinkTab, self).__init__(tab_dict)
def __getitem__(self, key):
if key == 'link':
@@ -292,6 +208,49 @@ class LinkTab(CourseTab):
return False
return self.link_value == other.get('link')
@classmethod
def is_enabled(cls, course, user=None): # pylint: disable=unused-argument
return True
class ExternalDiscussionCourseTab(LinkTab):
"""
A course tab that links to an external discussion service.
"""
type = 'external_discussion'
# Translators: 'Discussion' refers to the tab in the courseware that leads to the discussion forums
title = _('Discussion')
priority = None
@classmethod
def validate(cls, tab_dict, raise_error=True):
""" Validate that the tab_dict for this course tab has the necessary information to render. """
return (super(ExternalDiscussionCourseTab, cls).validate(tab_dict, raise_error) and
key_checker(['link'])(tab_dict, raise_error))
@classmethod
def is_enabled(cls, course, user=None): # pylint: disable=unused-argument
if not super(ExternalDiscussionCourseTab, cls).is_enabled(course, user=user):
return False
return course.discussion_link
class ExternalLinkCourseTab(LinkTab):
"""
A course tab containing an external link.
"""
type = 'external_link'
priority = None
is_default = False # An external link tab is not added to a course by default
allow_multiple = True
@classmethod
def validate(cls, tab_dict, raise_error=True):
""" Validate that the tab_dict for this course tab has the necessary information to render. """
return (super(ExternalLinkCourseTab, cls).validate(tab_dict, raise_error) and
key_checker(['link', 'name'])(tab_dict, raise_error))
class SingleTextbookTab(CourseTab):
"""
@@ -308,7 +267,11 @@ class SingleTextbookTab(CourseTab):
""" Constructs a link for textbooks from a view name, a course, and an index. """
return reverse_func(view_name, args=[unicode(course.id), index])
super(SingleTextbookTab, self).__init__(name, tab_id, link_func)
tab_dict = dict()
tab_dict['name'] = name
tab_dict['tab_id'] = tab_id
tab_dict['link_func'] = link_func
super(SingleTextbookTab, self).__init__(tab_dict)
def to_json(self):
raise NotImplementedError('SingleTextbookTab should not be serialized.')
@@ -347,9 +310,9 @@ def _get_dynamic_tabs(course, user):
instead added dynamically based upon the user's role.
"""
dynamic_tabs = list()
for tab_type in CourseViewTypeManager.get_course_view_types():
for tab_type in CourseTabPluginManager.get_tab_types():
if getattr(tab_type, "is_dynamic", False):
tab = tab_type.create_tab(dict())
tab = tab_type(dict())
if tab.is_enabled(course, user=user):
dynamic_tabs.append(tab)
dynamic_tabs.sort(key=lambda dynamic_tab: dynamic_tab.name)

View File

@@ -11,8 +11,8 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.courses import get_course_by_id
from courseware.tabs import (
get_course_tab_list, CoursewareViewType, CourseInfoViewType, ProgressCourseViewType,
StaticCourseViewType, ExternalDiscussionCourseViewType, ExternalLinkCourseViewType
get_course_tab_list, CoursewareTab, CourseInfoTab, ProgressTab,
ExternalDiscussionCourseTab, ExternalLinkCourseTab
)
from courseware.tests.helpers import get_request_for_user, LoginEnrollmentTestCase
from courseware.tests.factories import InstructorFactory, StaffFactory
@@ -85,7 +85,7 @@ class TabTestCase(ModuleStoreTestCase):
Can be 'None' if the given tab class does not have any keys to validate.
"""
# create tab
tab = tab_class.create_tab(tab_dict=dict_tab)
tab = tab_class(tab_dict=dict_tab)
# name is as expected
self.assertEqual(tab.name, expected_name)
@@ -475,17 +475,17 @@ class TabListTestCase(TabTestCase):
# invalid tabs
self.invalid_tabs = [
# less than 2 tabs
[{'type': CoursewareViewType.name}],
[{'type': CoursewareTab.type}],
# missing course_info
[{'type': CoursewareViewType.name}, {'type': 'discussion', 'name': 'fake_name'}],
[{'type': CoursewareTab.type}, {'type': 'discussion', 'name': 'fake_name'}],
# incorrect order
[{'type': CourseInfoViewType.name, 'name': 'fake_name'}, {'type': CoursewareViewType.name}],
[{'type': CourseInfoTab.type, 'name': 'fake_name'}, {'type': CoursewareTab.type}],
]
# tab types that should appear only once
unique_tab_types = [
CoursewareViewType.name,
CourseInfoViewType.name,
CoursewareTab.type,
CourseInfoTab.type,
'textbooks',
'pdf_textbooks',
'html_textbooks',
@@ -493,8 +493,8 @@ class TabListTestCase(TabTestCase):
for unique_tab_type in unique_tab_types:
self.invalid_tabs.append([
{'type': CoursewareViewType.name},
{'type': CourseInfoViewType.name, 'name': 'fake_name'},
{'type': CoursewareTab.type},
{'type': CourseInfoTab.type, 'name': 'fake_name'},
# add the unique tab multiple times
{'type': unique_tab_type},
{'type': unique_tab_type},
@@ -502,26 +502,27 @@ class TabListTestCase(TabTestCase):
# valid tabs
self.valid_tabs = [
# empty list
# any empty list is valid because a default list of tabs will be
# generated to replace the empty list.
[],
# all valid tabs
[
{'type': CoursewareViewType.name},
{'type': CourseInfoViewType.name, 'name': 'fake_name'},
{'type': CoursewareTab.type},
{'type': CourseInfoTab.type, 'name': 'fake_name'},
{'type': 'discussion', 'name': 'fake_name'},
{'type': ExternalLinkCourseViewType.name, 'name': 'fake_name', 'link': 'fake_link'},
{'type': ExternalLinkCourseTab.type, 'name': 'fake_name', 'link': 'fake_link'},
{'type': 'textbooks'},
{'type': 'pdf_textbooks'},
{'type': 'html_textbooks'},
{'type': ProgressCourseViewType.name, 'name': 'fake_name'},
{'type': StaticCourseViewType.name, 'name': 'fake_name', 'url_slug': 'schlug'},
{'type': ProgressTab.type, 'name': 'fake_name'},
{'type': xmodule_tabs.StaticTab.type, 'name': 'fake_name', 'url_slug': 'schlug'},
{'type': 'syllabus'},
],
# with external discussion
[
{'type': CoursewareViewType.name},
{'type': CourseInfoViewType.name, 'name': 'fake_name'},
{'type': ExternalDiscussionCourseViewType.name, 'name': 'fake_name', 'link': 'fake_link'}
{'type': CoursewareTab.type},
{'type': CourseInfoTab.type, 'name': 'fake_name'},
{'type': ExternalDiscussionCourseTab.type, 'name': 'fake_name', 'link': 'fake_link'}
],
]
@@ -550,8 +551,8 @@ class ValidateTabsTestCase(TabListTestCase):
tab_list = xmodule_tabs.CourseTabList()
self.assertEquals(
len(tab_list.from_json([
{'type': CoursewareViewType.name},
{'type': CourseInfoViewType.name, 'name': 'fake_name'},
{'type': CoursewareTab.type},
{'type': CourseInfoTab.type, 'name': 'fake_name'},
{'type': 'no_such_type'}
])),
2
@@ -660,10 +661,10 @@ class ProgressTestCase(TabTestCase):
def check_progress_tab(self):
"""Helper function for verifying the progress tab."""
return self.check_tab(
tab_class=ProgressCourseViewType,
dict_tab={'type': ProgressCourseViewType.name, 'name': 'same'},
tab_class=ProgressTab,
dict_tab={'type': ProgressTab.type, 'name': 'same'},
expected_link=self.reverse('progress', args=[self.course.id.to_deprecated_string()]),
expected_tab_id=ProgressCourseViewType.name,
expected_tab_id=ProgressTab.type,
invalid_dict_tab=None,
)
@@ -692,8 +693,8 @@ class StaticTabTestCase(TabTestCase):
url_slug = 'schmug'
tab = self.check_tab(
tab_class=StaticCourseViewType,
dict_tab={'type': StaticCourseViewType.name, 'name': 'same', 'url_slug': url_slug},
tab_class=xmodule_tabs.StaticTab,
dict_tab={'type': xmodule_tabs.StaticTab.type, 'name': 'same', 'url_slug': url_slug},
expected_link=self.reverse('static_tab', args=[self.course.id.to_deprecated_string(), url_slug]),
expected_tab_id='static_tab_schmug',
invalid_dict_tab=self.fake_dict_tab,

View File

@@ -1150,9 +1150,9 @@ def notification_image_for_tab(course_tab, user, course):
"""
tab_notification_handlers = {
StaffGradingTab.name: open_ended_notifications.staff_grading_notifications,
PeerGradingTab.name: open_ended_notifications.peer_grading_notifications,
OpenEndedGradingTab.name: open_ended_notifications.combined_notifications
StaffGradingTab.type: open_ended_notifications.staff_grading_notifications,
PeerGradingTab.type: open_ended_notifications.peer_grading_notifications,
OpenEndedGradingTab.type: open_ended_notifications.combined_notifications
}
if course_tab.name in tab_notification_handlers:

View File

@@ -25,7 +25,7 @@ from openedx.core.djangoapps.course_groups.cohorts import (
get_course_cohorts,
is_commentable_cohorted
)
from courseware.tabs import EnrolledCourseViewType
from courseware.tabs import EnrolledTab
from courseware.access import has_access
from xmodule.modulestore.django import modulestore
from ccx.overrides import get_current_ccx
@@ -49,19 +49,19 @@ PAGES_NEARBY_DELTA = 2
log = logging.getLogger("edx.discussions")
class DiscussionCourseViewType(EnrolledCourseViewType):
class DiscussionTab(EnrolledTab):
"""
A tab for the cs_comments_service forums.
"""
name = 'discussion'
type = 'discussion'
title = _('Discussion')
priority = None
view_name = 'django_comment_client.forum.views.forum_form_discussion'
@classmethod
def is_enabled(cls, course, user=None):
if not super(DiscussionCourseViewType, cls).is_enabled(course, user):
if not super(DiscussionTab, cls).is_enabled(course, user):
return False
if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):

View File

@@ -4,15 +4,15 @@ Registers the "edX Notes" feature for the edX platform.
from django.utils.translation import ugettext as _
from courseware.tabs import EnrolledCourseViewType
from courseware.tabs import EnrolledTab
class EdxNotesCourseViewType(EnrolledCourseViewType):
class EdxNotesTab(EnrolledTab):
"""
The representation of the edX Notes course view type.
The representation of the edX Notes course tab type.
"""
name = "edxnotes"
type = "edxnotes"
title = _("Notes")
view_name = "edxnotes"
@@ -25,6 +25,6 @@ class EdxNotesCourseViewType(EnrolledCourseViewType):
settings (dict): a dict of configuration settings
user (User): the user interacting with the course
"""
if not super(EdxNotesCourseViewType, cls).is_enabled(course, user=user):
if not super(EdxNotesTab, cls).is_enabled(course, user=user):
return False
return course.edxnotes

View File

@@ -26,6 +26,7 @@ from lms.djangoapps.lms_xblock.runtime import quote_slashes
from openedx.core.lib.xblock_utils import wrap_xblock
from xmodule.html_module import HtmlDescriptor
from xmodule.modulestore.django import modulestore
from xmodule.tabs import CourseTab
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from courseware.access import has_access
@@ -38,7 +39,6 @@ from course_modes.models import CourseMode, CourseModesArchive
from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole
from certificates.models import CertificateGenerationConfiguration
from certificates import api as certs_api
from openedx.core.djangoapps.course_views.course_views import CourseViewType
from class_dashboard.dashboard_data import get_section_display_name, get_array_section_has_problem
from .tools import get_units_with_due_date, title_or_url, bulk_email_is_enabled_for_course
@@ -47,12 +47,12 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
log = logging.getLogger(__name__)
class InstructorDashboardViewType(CourseViewType):
class InstructorDashboardTab(CourseTab):
"""
Defines the Instructor Dashboard view type that is shown as a course tab.
"""
name = "instructor"
type = "instructor"
title = _('Instructor')
view_name = "instructor_dashboard"
is_dynamic = True # The "Instructor" tab is instead dynamically added when it is enabled

View File

@@ -9,7 +9,7 @@ from django.http import Http404
from edxmako.shortcuts import render_to_response
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.courses import get_course_with_access
from courseware.tabs import EnrolledCourseViewType
from courseware.tabs import EnrolledTab
from notes.models import Note
from notes.utils import notes_enabled_for_course
from xmodule.annotator_token import retrieve_token
@@ -40,16 +40,16 @@ def notes(request, course_id):
return render_to_response('notes.html', context)
class NotesCourseViewType(EnrolledCourseViewType):
class NotesTab(EnrolledTab):
"""
A tab for the course notes.
"""
name = 'notes'
type = 'notes'
title = _("My Notes")
view_name = "notes"
@classmethod
def is_enabled(cls, course, user=None):
if not super(NotesCourseViewType, cls).is_enabled(course, user):
if not super(NotesTab, cls).is_enabled(course, user):
return False
return settings.FEATURES.get('ENABLE_STUDENT_NOTES') and "notes" in course.advanced_modules

View File

@@ -4,11 +4,10 @@ from django.views.decorators.cache import cache_control
from edxmako.shortcuts import render_to_response
from django.core.urlresolvers import reverse
from openedx.core.djangoapps.course_views.course_views import CourseViewType
from courseware.courses import get_course_with_access
from courseware.access import has_access
from courseware.tabs import EnrolledCourseViewType
from courseware.tabs import EnrolledTab
from xmodule.open_ended_grading_classes.grading_service_module import GradingServiceError
import json
@@ -66,11 +65,11 @@ ALERT_DICT = {
}
class StaffGradingTab(CourseViewType):
class StaffGradingTab(EnrolledTab):
"""
A tab for staff grading.
"""
name = 'staff_grading'
type = 'staff_grading'
title = _("Staff grading")
view_name = "staff_grading"
@@ -81,11 +80,11 @@ class StaffGradingTab(CourseViewType):
return "combinedopenended" in course.advanced_modules
class PeerGradingTab(EnrolledCourseViewType):
class PeerGradingTab(EnrolledTab):
"""
A tab for peer grading.
"""
name = 'peer_grading'
type = 'peer_grading'
# Translators: "Peer grading" appears on a tab that allows
# students to view open-ended problems that require grading
title = _("Peer grading")
@@ -98,11 +97,11 @@ class PeerGradingTab(EnrolledCourseViewType):
return "combinedopenended" in course.advanced_modules
class OpenEndedGradingTab(EnrolledCourseViewType):
class OpenEndedGradingTab(EnrolledTab):
"""
A tab for open ended grading.
"""
name = 'open_ended'
type = 'open_ended'
# Translators: "Open Ended Panel" appears on a tab that, when clicked, opens up a panel that
# displays information about open-ended problems that a user has submitted or needs to grade
title = _("Open Ended Panel")

View File

@@ -3,16 +3,16 @@ Definition of the course team feature.
"""
from django.utils.translation import ugettext as _
from courseware.tabs import EnrolledCourseViewType
from courseware.tabs import EnrolledTab
from .views import is_feature_enabled
class TeamsCourseViewType(EnrolledCourseViewType):
class TeamsTab(EnrolledTab):
"""
The representation of the course teams view type.
"""
name = "teams"
type = "teams"
title = _("Teams")
view_name = "teams_dashboard"
@@ -24,7 +24,7 @@ class TeamsCourseViewType(EnrolledCourseViewType):
course (CourseDescriptor): the course using the feature
user (User): the user interacting with the course
"""
if not super(TeamsCourseViewType, cls).is_enabled(course, user=user):
if not super(TeamsTab, cls).is_enabled(course, user=user):
return False
return is_feature_enabled(course)