Files
edx-platform/xmodule/tabs.py
Kshitij Sobti 8169aa99da fix: if pages and resources view is disabled, show all pages in studio (#30550)
In a previous PR #28686, the ability to see and enable/disable wiki and progress tabs was removed from studio along with the ability to re-order non-static tabs. The ability to toggle the Wiki tab was moved to the pages and resources section of the course authoring MFE. If that MFE is unavailable this means there is no way to show/hide the Wiki. This reverts some of the old changes if the pages and resources view is disabled.
2022-06-28 21:19:32 +05:00

653 lines
23 KiB
Python

"""
Implement CourseTab
"""
import logging
from abc import ABCMeta
from django.core.files.storage import get_storage_class
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 = get_storage_class(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