653 lines
23 KiB
Python
653 lines
23 KiB
Python
"""
|
|
Implement CourseTab
|
|
"""
|
|
|
|
|
|
import logging
|
|
from abc import ABCMeta
|
|
|
|
from django.utils.module_loading import import_string
|
|
from xblock.fields import List
|
|
|
|
from edx_django_utils.plugins import PluginError
|
|
|
|
log = logging.getLogger("edx.courseware")
|
|
|
|
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
|
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
|
_ = lambda text: text
|
|
|
|
# A list of attributes on course tabs that can not be updated
|
|
READ_ONLY_COURSE_TAB_ATTRIBUTES = ['type']
|
|
|
|
|
|
class CourseTab(metaclass=ABCMeta):
|
|
"""
|
|
The Course Tab class is a data abstraction for all tabs (i.e., course navigation links) within a course.
|
|
It is an abstract class - to be inherited by various tab types.
|
|
Derived classes are expected to override methods as needed.
|
|
When a new tab class is created, it should define the type and add it in this class' factory method.
|
|
"""
|
|
|
|
# Class property that specifies the type of the tab. It is generally a constant value for a
|
|
# subclass, shared by all instances of the subclass.
|
|
type = ''
|
|
|
|
# The title of the tab, which should be internationalized using
|
|
# ugettext_noop since the user won't be available in this context.
|
|
title = None
|
|
|
|
# HTML class to add to the tab page's body, or None if no class it to be added
|
|
body_class = None
|
|
|
|
# Token to identify the online help URL, or None if no help is provided
|
|
online_help_token = None
|
|
|
|
# Class property that specifies whether the tab can be hidden for a particular course
|
|
is_hideable = False
|
|
|
|
# Class property that specifies whether the tab is hidden for a particular course
|
|
is_hidden = False
|
|
|
|
# The relative priority of this view that affects the ordering (lower numbers shown first)
|
|
priority = None
|
|
|
|
# Class property that specifies whether the tab can be moved within a course's list of tabs
|
|
is_movable = False
|
|
|
|
# Class property that specifies whether the tab is a collection of other tabs
|
|
is_collection = False
|
|
|
|
# True if this tab is dynamically added to the list of tabs
|
|
is_dynamic = False
|
|
|
|
# True if this tab is a default for the course (when enabled)
|
|
is_default = True
|
|
|
|
# True if this tab can be included more than once for a course.
|
|
allow_multiple = False
|
|
|
|
# If there is a single view associated with this tab, this is the name of it
|
|
view_name = None
|
|
|
|
# True if this tab should be displayed only for instructors
|
|
course_staff_only = False
|
|
|
|
# True if this tab supports showing staff users a preview menu
|
|
supports_preview_menu = False
|
|
|
|
def __init__(self, tab_dict):
|
|
"""
|
|
Initializes class members with values passed in by subclasses.
|
|
|
|
Args:
|
|
tab_dict (dict) - a dictionary of parameters used to build the tab.
|
|
"""
|
|
super().__init__()
|
|
self.name = tab_dict.get('name', self.title)
|
|
self.tab_id = tab_dict.get('tab_id', getattr(self, 'tab_id', self.type))
|
|
self.course_staff_only = tab_dict.get('course_staff_only', False)
|
|
self.is_hidden = tab_dict.get('is_hidden', False)
|
|
|
|
self.tab_dict = tab_dict
|
|
|
|
@property
|
|
def link_func(self):
|
|
"""
|
|
Returns a function that takes a course and reverse function and will
|
|
compute the course URL for this tab.
|
|
"""
|
|
return self.tab_dict.get('link_func', course_reverse_func(self.view_name))
|
|
|
|
@classmethod
|
|
def is_enabled(cls, course, user=None):
|
|
"""Returns true if this course tab is enabled in the course.
|
|
|
|
Args:
|
|
course (CourseBlock): the course using the feature
|
|
user (User): an optional user interacting with the course (defaults to None)
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def get(self, key, default=None):
|
|
"""
|
|
Akin to the get method on Python dictionary objects, gracefully returns the value associated with the
|
|
given key, or the default if key does not exist.
|
|
"""
|
|
try:
|
|
return self[key]
|
|
except KeyError:
|
|
return default
|
|
|
|
def __getitem__(self, key):
|
|
"""
|
|
This method allows callers to access CourseTab members with the d[key] syntax as is done with
|
|
Python dictionary objects.
|
|
"""
|
|
if hasattr(self, key):
|
|
return getattr(self, key, None)
|
|
else:
|
|
raise KeyError(f'Key {key} not present in tab {self.to_json()}')
|
|
|
|
def __setitem__(self, key, value):
|
|
"""
|
|
This method allows callers to change CourseTab members with the d[key]=value syntax as is done with
|
|
Python dictionary objects. For example: course_tab['name'] = new_name
|
|
|
|
Note: the 'type' member can be 'get', but not 'set'.
|
|
"""
|
|
if hasattr(self, key) and key not in READ_ONLY_COURSE_TAB_ATTRIBUTES:
|
|
setattr(self, key, value)
|
|
else:
|
|
raise KeyError(f'Key {key} cannot be set in tab {self.to_json()}')
|
|
|
|
def __eq__(self, other):
|
|
"""
|
|
Overrides the equal operator to check equality of member variables rather than the object's address.
|
|
Also allows comparison with dict-type tabs (needed to support callers implemented before this class
|
|
was implemented).
|
|
"""
|
|
|
|
if isinstance(other, dict) and not self.validate(other, raise_error=False):
|
|
# 'other' is a dict-type tab and did not validate
|
|
return False
|
|
|
|
# allow tabs without names; if a name is required, its presence was checked in the validator.
|
|
name_is_eq = (other.get('name') is None or self.name == other['name'])
|
|
|
|
# only compare the persisted/serialized members: 'type' and 'name'
|
|
return self.type == other.get('type') and name_is_eq
|
|
|
|
def __ne__(self, other):
|
|
"""
|
|
Overrides the not equal operator as a partner to the equal operator.
|
|
"""
|
|
return not self == other
|
|
|
|
def __hash__(self):
|
|
""" Return a hash representation of Tab Object. """
|
|
return hash(repr(self))
|
|
|
|
@classmethod
|
|
def validate(cls, tab_dict, raise_error=True):
|
|
"""
|
|
Validates the given dict-type tab object to ensure it contains the expected keys.
|
|
This method should be overridden by subclasses that require certain keys to be persisted in the tab.
|
|
"""
|
|
return key_checker(['type'])(tab_dict, raise_error)
|
|
|
|
@classmethod
|
|
def load(cls, type_name, **kwargs):
|
|
"""
|
|
Constructs a tab of the given type_name.
|
|
|
|
Args:
|
|
type_name (str) - the type of tab that should be constructed
|
|
**kwargs - any other keyword arguments needed for constructing this tab
|
|
|
|
Returns:
|
|
an instance of the CourseTab subclass that matches the type_name
|
|
"""
|
|
json_dict = kwargs.copy()
|
|
json_dict['type'] = type_name
|
|
return cls.from_json(json_dict)
|
|
|
|
def to_json(self):
|
|
"""
|
|
Serializes the necessary members of the CourseTab object to a json-serializable representation.
|
|
This method is overridden by subclasses that have more members to serialize.
|
|
|
|
Returns:
|
|
a dictionary with keys for the properties of the CourseTab object.
|
|
"""
|
|
to_json_val = {'type': self.type, 'name': self.name, 'course_staff_only': self.course_staff_only}
|
|
if self.is_hidden:
|
|
to_json_val.update({'is_hidden': True})
|
|
return to_json_val
|
|
|
|
@staticmethod
|
|
def from_json(tab_dict):
|
|
"""
|
|
Deserializes a CourseTab from a json-like representation.
|
|
|
|
The subclass that is instantiated is determined by the value of the 'type' key in the
|
|
given dict-type tab. The given dict-type tab is validated before instantiating the CourseTab object.
|
|
|
|
If the tab_type is not recognized, then an exception is logged and None is returned.
|
|
The intention is that the user should still be able to use the course even if a
|
|
particular tab is not found for some reason.
|
|
|
|
Args:
|
|
tab: a dictionary with keys for the properties of the tab.
|
|
|
|
Raises:
|
|
InvalidTabsException if the given tab doesn't have the right keys.
|
|
"""
|
|
# TODO: don't import openedx capabilities from common
|
|
from openedx.core.lib.course_tabs import CourseTabPluginManager
|
|
tab_type_name = tab_dict.get('type')
|
|
if tab_type_name is None:
|
|
log.error('No type included in tab_dict: %r', tab_dict)
|
|
return None
|
|
try:
|
|
tab_type = CourseTabPluginManager.get_plugin(tab_type_name)
|
|
except PluginError:
|
|
log.exception(
|
|
"Unknown tab type %r Known types: %r.",
|
|
tab_type_name,
|
|
CourseTabPluginManager.get_tab_types()
|
|
)
|
|
return None
|
|
|
|
tab_type.validate(tab_dict)
|
|
return tab_type(tab_dict=tab_dict)
|
|
|
|
|
|
class TabFragmentViewMixin:
|
|
"""
|
|
A mixin for tabs that render themselves as web fragments.
|
|
"""
|
|
fragment_view_name = None
|
|
|
|
def __init__(self, tab_dict):
|
|
super().__init__(tab_dict)
|
|
self._fragment_view = None
|
|
|
|
@property
|
|
def link_func(self):
|
|
""" Returns a function that returns the course tab's URL. """
|
|
|
|
# If a view_name is specified, then use the default link function
|
|
if self.view_name:
|
|
return super().link_func
|
|
|
|
# If not, then use the generic course tab URL
|
|
def link_func(course, reverse_func):
|
|
""" Returns a function that returns the course tab's URL. """
|
|
return reverse_func("course_tab_view", args=[str(course.id), self.type])
|
|
|
|
return link_func
|
|
|
|
@property
|
|
def url_slug(self):
|
|
"""
|
|
Returns the slug to be included in this tab's URL.
|
|
"""
|
|
return "tab/" + self.type
|
|
|
|
@property
|
|
def fragment_view(self):
|
|
"""
|
|
Returns the view that will be used to render the fragment.
|
|
"""
|
|
if not self._fragment_view:
|
|
self._fragment_view = import_string(self.fragment_view_name)()
|
|
return self._fragment_view
|
|
|
|
def render_to_fragment(self, request, course, **kwargs):
|
|
"""
|
|
Renders this tab to a web fragment.
|
|
"""
|
|
return self.fragment_view.render_to_fragment(request, course_id=str(course.id), **kwargs)
|
|
|
|
def __hash__(self):
|
|
""" Return a hash representation of Tab Object. """
|
|
return hash(repr(self))
|
|
|
|
|
|
class StaticTab(CourseTab):
|
|
"""
|
|
A custom tab.
|
|
"""
|
|
type = 'static_tab'
|
|
is_default = False # A static tab is never added to a course by default
|
|
is_movable = True
|
|
allow_multiple = True
|
|
priority = 100
|
|
|
|
def __init__(self, tab_dict=None, name=None, url_slug=None):
|
|
def link_func(course, reverse_func):
|
|
""" Returns a function that returns the static tab's URL. """
|
|
return reverse_func(self.type, args=[str(course.id), self.url_slug])
|
|
|
|
self.url_slug = tab_dict.get('url_slug') if tab_dict else url_slug
|
|
|
|
if tab_dict is None:
|
|
tab_dict = {}
|
|
|
|
if name is not None:
|
|
tab_dict['name'] = name
|
|
|
|
tab_dict['link_func'] = link_func
|
|
tab_dict['tab_id'] = f'static_tab_{self.url_slug}'
|
|
|
|
super().__init__(tab_dict)
|
|
|
|
@classmethod
|
|
def is_enabled(cls, course, user=None):
|
|
"""
|
|
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().validate(tab_dict, raise_error)
|
|
and key_checker(['name', 'url_slug'])(tab_dict, raise_error))
|
|
|
|
def __getitem__(self, key):
|
|
if key == 'url_slug':
|
|
return self.url_slug
|
|
else:
|
|
return super().__getitem__(key)
|
|
|
|
def __setitem__(self, key, value):
|
|
if key == 'url_slug':
|
|
self.url_slug = value
|
|
else:
|
|
super().__setitem__(key, value)
|
|
|
|
def to_json(self):
|
|
""" Return a dictionary representation of this tab. """
|
|
to_json_val = super().to_json()
|
|
to_json_val.update({'url_slug': self.url_slug})
|
|
return to_json_val
|
|
|
|
def __eq__(self, other):
|
|
if not super().__eq__(other):
|
|
return False
|
|
return self.url_slug == other.get('url_slug')
|
|
|
|
def __hash__(self):
|
|
""" Return a hash representation of Tab Object. """
|
|
return hash(repr(self))
|
|
|
|
|
|
class CourseTabList(List):
|
|
"""
|
|
An XBlock field class that encapsulates a collection of Tabs in a course.
|
|
It is automatically created and can be retrieved through a CourseBlock object: course.tabs
|
|
"""
|
|
|
|
# TODO: Ideally, we'd like for this list of tabs to be dynamically
|
|
# generated by the tabs plugin code. For now, we're leaving it like this to
|
|
# preserve backwards compatibility.
|
|
@staticmethod
|
|
def initialize_default(course):
|
|
"""
|
|
An explicit initialize method is used to set the default values, rather than implementing an
|
|
__init__ method. This is because the default values are dependent on other information from
|
|
within the course.
|
|
"""
|
|
course_tabs = [
|
|
CourseTab.load('courseware')
|
|
]
|
|
|
|
# Presence of syllabus tab is indicated by a course attribute
|
|
if hasattr(course, 'syllabus_present') and course.syllabus_present:
|
|
course_tabs.append(CourseTab.load('syllabus'))
|
|
|
|
# If the course has a discussion link specified, use that even if we feature
|
|
# flag discussions off. Disabling that is mostly a server safety feature
|
|
# at this point, and we don't need to worry about external sites.
|
|
if course.discussion_link:
|
|
discussion_tab = CourseTab.load(
|
|
'external_discussion', name=_('External Discussion'), link=course.discussion_link
|
|
)
|
|
else:
|
|
discussion_tab = CourseTab.load('discussion')
|
|
|
|
course_tabs.extend([
|
|
CourseTab.load('textbooks'),
|
|
discussion_tab,
|
|
CourseTab.load('wiki'),
|
|
CourseTab.load('progress'),
|
|
CourseTab.load('dates'),
|
|
])
|
|
|
|
# Cross reference existing slugs with slugs this method would add to not add duplicates.
|
|
existing_tab_slugs = {tab.type for tab in course.tabs if course.tabs}
|
|
tabs_to_add = []
|
|
for tab in course_tabs:
|
|
if tab.type not in existing_tab_slugs:
|
|
tabs_to_add.append(tab)
|
|
|
|
if tabs_to_add:
|
|
tabs_to_add.extend(course.tabs)
|
|
tabs_to_add.sort(key=lambda tab: tab.priority or float('inf'))
|
|
course.tabs = tabs_to_add
|
|
|
|
@staticmethod
|
|
def get_discussion(course):
|
|
"""
|
|
Returns the discussion tab for the given course. It can be either of type 'discussion'
|
|
or 'external_discussion'. The returned tab object is self-aware of the 'link' that it corresponds to.
|
|
"""
|
|
|
|
# the discussion_link setting overrides everything else, even if there is a discussion tab in the course tabs
|
|
if course.discussion_link:
|
|
return CourseTab.load(
|
|
'external_discussion', name=_('External Discussion'), link=course.discussion_link
|
|
)
|
|
|
|
# find one of the discussion tab types in the course tabs
|
|
for tab in course.tabs:
|
|
if tab.type in ('discussion', 'external_discussion'):
|
|
return tab
|
|
return None
|
|
|
|
@staticmethod
|
|
def get_tab_by_slug(tab_list, url_slug):
|
|
"""
|
|
Look for a tab with the specified 'url_slug'. Returns the tab or None if not found.
|
|
"""
|
|
return next((tab for tab in tab_list if tab.get('url_slug') == url_slug), None)
|
|
|
|
@staticmethod
|
|
def get_tab_by_type(tab_list, tab_type):
|
|
"""
|
|
Look for a tab with the specified type. Returns the first matching tab.
|
|
"""
|
|
return next((tab for tab in tab_list if tab.type == tab_type), None)
|
|
|
|
@staticmethod
|
|
def get_tab_by_id(tab_list, tab_id):
|
|
"""
|
|
Look for a tab with the specified tab_id. Returns the first matching tab.
|
|
"""
|
|
return next((tab for tab in tab_list if tab.tab_id == tab_id), None)
|
|
|
|
@staticmethod
|
|
def iterate_displayable(course, user=None, inline_collections=True, include_hidden=False):
|
|
"""
|
|
Generator method for iterating through all tabs that can be displayed for the given course and
|
|
the given user with the provided access settings.
|
|
"""
|
|
for tab in course.tabs:
|
|
if tab.is_enabled(course, user=user) and (include_hidden or not (user and tab.is_hidden)):
|
|
if tab.is_collection:
|
|
# If rendering inline that add each item in the collection,
|
|
# else just show the tab itself as long as it is not empty.
|
|
if inline_collections:
|
|
yield from tab.items(course)
|
|
elif len(list(tab.items(course))) > 0:
|
|
yield tab
|
|
else:
|
|
yield tab
|
|
|
|
@classmethod
|
|
def upgrade_tabs(cls, tabs):
|
|
"""
|
|
Remove course_info tab, and rename courseware tab to Course if needed.
|
|
"""
|
|
if tabs and len(tabs) > 1:
|
|
# Reverse them so that course_info is first, and rename courseware to Course
|
|
if tabs[0].get('type') == 'courseware' and tabs[1].get('type') == 'course_info':
|
|
tabs[0], tabs[1] = tabs[1], tabs[0]
|
|
tabs[1]['name'] = _('Course')
|
|
|
|
# NOTE: this check used for legacy courses containing the course_info tab. course_info
|
|
# should be removed according to https://github.com/openedx/public-engineering/issues/56.
|
|
if tabs[0].get('type') == 'course_info':
|
|
tabs.pop(0)
|
|
return tabs
|
|
|
|
@classmethod
|
|
def validate_tabs(cls, tabs):
|
|
"""
|
|
Check that the tabs set for the specified course is valid. If it
|
|
isn't, raise InvalidTabsException with the complaint.
|
|
|
|
Specific rules checked:
|
|
- if no tabs specified, that's fine
|
|
- if tabs specified, first must have type 'courseware'.
|
|
|
|
"""
|
|
if tabs is None or len(tabs) == 0:
|
|
return
|
|
|
|
if tabs[0].get('type') != 'courseware':
|
|
raise InvalidTabsException(
|
|
f"Expected first tab to have type 'courseware'. tabs: '{tabs}'")
|
|
|
|
# the following tabs should appear only once
|
|
# TODO: don't import openedx capabilities from common
|
|
from openedx.core.lib.course_tabs import CourseTabPluginManager
|
|
for tab_type in CourseTabPluginManager.get_tab_types():
|
|
if not tab_type.allow_multiple:
|
|
cls._validate_num_tabs_of_type(tabs, tab_type.type, 1)
|
|
|
|
@staticmethod
|
|
def _validate_num_tabs_of_type(tabs, tab_type, max_num):
|
|
"""
|
|
Check that the number of times that the given 'tab_type' appears in 'tabs' is less than or equal to 'max_num'.
|
|
"""
|
|
count = sum(1 for tab in tabs if tab.get('type') == tab_type)
|
|
if count > max_num:
|
|
msg = (
|
|
"Tab of type '{type}' appears {count} time(s). "
|
|
"Expected maximum of {max} time(s)."
|
|
).format(
|
|
type=tab_type, count=count, max=max_num,
|
|
)
|
|
raise InvalidTabsException(msg)
|
|
|
|
def to_json(self, values): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""
|
|
Overrides the to_json method to serialize all the CourseTab objects to a json-serializable representation.
|
|
"""
|
|
json_data = []
|
|
if values:
|
|
for val in values:
|
|
if isinstance(val, CourseTab):
|
|
json_data.append(val.to_json())
|
|
elif isinstance(val, dict):
|
|
json_data.append(val)
|
|
else:
|
|
continue
|
|
return json_data
|
|
|
|
def from_json(self, values): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""
|
|
Overrides the from_json method to de-serialize the CourseTab objects from a json-like representation.
|
|
"""
|
|
self.upgrade_tabs(values)
|
|
self.validate_tabs(values)
|
|
tabs = []
|
|
for tab_dict in values:
|
|
tab = CourseTab.from_json(tab_dict)
|
|
if tab:
|
|
tabs.append(tab)
|
|
return tabs
|
|
|
|
|
|
# Validators
|
|
# A validator takes a dict and raises InvalidTabsException if required fields are missing or otherwise wrong.
|
|
# (e.g. "is there a 'name' field?). Validators can assume that the type field is valid.
|
|
def key_checker(expected_keys):
|
|
"""
|
|
Returns a function that checks that specified keys are present in a dict.
|
|
"""
|
|
|
|
def check(actual_dict, raise_error=True):
|
|
"""
|
|
Function that checks whether all keys in the expected_keys object is in the given actual_dict object.
|
|
"""
|
|
missing = set(expected_keys) - set(actual_dict.keys())
|
|
if not missing:
|
|
return True
|
|
if raise_error: # lint-amnesty, pylint: disable=no-else-raise
|
|
raise InvalidTabsException(
|
|
f"Expected keys '{expected_keys}' are not present in the given dict: {actual_dict}"
|
|
)
|
|
else:
|
|
return False
|
|
|
|
return check
|
|
|
|
|
|
def course_reverse_func(reverse_name):
|
|
"""
|
|
Returns a function that will determine a course URL for the provided
|
|
reverse_name.
|
|
|
|
See documentation for course_reverse_func_from_name_func. This function
|
|
simply calls course_reverse_func_from_name_func after wrapping reverse_name
|
|
in a function.
|
|
"""
|
|
return course_reverse_func_from_name_func(lambda course: reverse_name)
|
|
|
|
|
|
def course_reverse_func_from_name_func(reverse_name_func):
|
|
"""
|
|
Returns a function that will determine a course URL for the provided
|
|
reverse_name_func.
|
|
|
|
Use this when the calculation of the reverse_name is dependent on the
|
|
course. Otherwise, use the simpler course_reverse_func.
|
|
|
|
This can be used to generate the url for a CourseTab without having
|
|
immediate access to Django's reverse function.
|
|
|
|
Arguments:
|
|
reverse_name_func (function): A function that takes a single argument
|
|
(Course) and returns the name to be used with the reverse function.
|
|
|
|
Returns:
|
|
A function that takes in two arguments:
|
|
course (Course): the course in question.
|
|
reverse_url_func (function): a reverse function for a course URL
|
|
that uses the course ID in the url.
|
|
When called, the returned function will return the course URL as
|
|
determined by calling reverse_url_func with the reverse_name and the
|
|
course's ID.
|
|
"""
|
|
return lambda course, reverse_url_func: reverse_url_func(
|
|
reverse_name_func(course),
|
|
args=[str(course.id)]
|
|
)
|
|
|
|
|
|
def need_name(dictionary, raise_error=True):
|
|
"""
|
|
Returns whether the 'name' key exists in the given dictionary.
|
|
"""
|
|
return key_checker(['name'])(dictionary, raise_error)
|
|
|
|
|
|
class InvalidTabsException(Exception):
|
|
"""
|
|
A complaint about invalid tabs.
|
|
"""
|
|
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
|
|
|
|
|
class UnequalTabsException(Exception):
|
|
"""
|
|
A complaint about tab lists being unequal
|
|
"""
|
|
pass # lint-amnesty, pylint: disable=unnecessary-pass
|