Files
edx-platform/xmodule/course_block.py
Akanshu Aich 2d82d90279 refactor: migrated FEATURES dict settings to top-level in core files and fixed related test files. (#37389)
* refactor: moved remaining feature dicts settings into top-level settings.

* refactor: moved remaining feature dicts settings into top-level settings.

* fix: fixed the test files

* fix: fixed tehe pylint errors

* fix: fixation of the cms ci failure

* fix: fixed remaining feature settings for cms

* fix: added fix for requirements

* fix: added fix for lms tests

* fix: resolved the test views issue

* fix: configured views file and test_views

* fix: fixed lint errors and assertion issues

* fix: added fix for base url issue in test view

* fix: added fix for base_url and assertion issue

* fix: added configurations for base utl fix

* fix: handled none issue for mfe config

* fix: corrected override settings in test views

* fix: added getattr defensive technique for view settings

* fix: reverted views and test_views file

* fix: added settings in views file

* fix: added with patch within functions in test view

* fix: rearranged the features in default_legacy_config

* fix: fixing the tests  with clearing cache

* fix: reverted test views to verify the CI check

* fix: added cache clear in mfe config test

* fix: fixed the patch toggles to override settings

* fix: fixed the lint errors

* fix: changed patch toggle to override settings
2026-01-20 11:55:26 -05:00

1679 lines
64 KiB
Python

"""
Django module container for classes and operations related to the "Course Block" content type
"""
import json
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import dateutil.parser
import requests
from django.conf import settings
from django.core.validators import validate_email
from edx_toggles.toggles import SettingToggle
from lazy import lazy
from lxml import etree
from path import Path as path
from xblock.fields import Boolean, Date, Dict, Float, Integer, List, Scope, String
from openedx.core.djangoapps.video_pipeline.models import VideoUploadsEnabledByDefault
from openedx.core.djangoapps.video_config.sharing import (
COURSE_VIDEO_SHARING_ALL_VIDEOS,
COURSE_VIDEO_SHARING_NONE,
COURSE_VIDEO_SHARING_PER_VIDEO,
)
from openedx.core.lib.license import LicenseMixin
from openedx.core.lib.teams_config import TeamsConfig # lint-amnesty, pylint: disable=unused-import
from xmodule import course_metadata_utils
from xmodule.course_metadata_utils import DEFAULT_GRADING_POLICY, DEFAULT_START_DATE
from xmodule.data import CertificatesDisplayBehaviors
from xmodule.graders import grader_from_conf
from xmodule.seq_block import SequenceBlock
from xmodule.tabs import CourseTabList, InvalidTabsException
from .modulestore.exceptions import InvalidProctoringProvider
log = logging.getLogger(__name__)
# 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
CATALOG_VISIBILITY_CATALOG_AND_ABOUT = "both"
CATALOG_VISIBILITY_ABOUT = "about"
CATALOG_VISIBILITY_NONE = "none"
DEFAULT_COURSE_VISIBILITY_IN_CATALOG = getattr(
settings,
'DEFAULT_COURSE_VISIBILITY_IN_CATALOG',
'both'
)
DEFAULT_MOBILE_AVAILABLE = getattr(settings, 'DEFAULT_MOBILE_AVAILABLE', False)
# Note: updating assets does not have settings defined, so using `getattr`.
EXAM_SETTINGS_HTML_VIEW_ENABLED = getattr(settings, 'FEATURES', {}).get('ENABLE_EXAM_SETTINGS_HTML_VIEW', False)
SPECIAL_EXAMS_ENABLED = getattr(settings, 'FEATURES', {}).get('ENABLE_SPECIAL_EXAMS', False)
COURSE_VISIBILITY_PRIVATE = 'private'
COURSE_VISIBILITY_PUBLIC_OUTLINE = 'public_outline'
COURSE_VISIBILITY_PUBLIC = 'public'
# .. toggle_name: CREATE_COURSE_WITH_DEFAULT_ENROLLMENT_START_DATE
# .. toggle_implementation: SettingToggle
# .. toggle_default: False
# .. toggle_description: The default behavior, when this is disabled, is that a newly created course has no
# enrollment_start date set. When the feature is enabled - the newly created courses will have the
# enrollment_start_date set to DEFAULT_START_DATE. This is intended to be a permanent option.
# This toggle affects the course listing pages (platform's index page, /courses page) when course search is
# performed using the `lms.djangoapp.branding.get_visible_courses` method and the
# COURSE_CATALOG_VISIBILITY_PERMISSION setting is set to 'see_exists'. Switching the toggle to True will prevent
# the newly created (empty) course from appearing in the course listing.
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2023-06-22
CREATE_COURSE_WITH_DEFAULT_ENROLLMENT_START_DATE = SettingToggle(
"CREATE_COURSE_WITH_DEFAULT_ENROLLMENT_START_DATE", default=False, module_name=__name__
)
class StringOrDate(Date): # lint-amnesty, pylint: disable=missing-class-docstring
def from_json(self, value): # lint-amnesty, pylint: disable=arguments-differ
"""
Parse an optional metadata key containing a time or a string:
if present, assume it's a string if it doesn't parse.
"""
try:
result = super().from_json(value)
except ValueError:
return value
if result is None:
return value
else:
return result
def to_json(self, value):
"""
Convert a time struct or string to a string.
"""
try:
result = super().to_json(value)
except: # lint-amnesty, pylint: disable=bare-except
return value
if result is None:
return value
else:
return result
class EmailString(String):
"""
Parse String with email validation
"""
def from_json(self, value):
if value:
validate_email(value)
return value
else:
return None
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True)
_cached_toc = {}
class Textbook: # lint-amnesty, pylint: disable=missing-class-docstring
def __init__(self, title, book_url):
self.title = title
self.book_url = book_url
@lazy
def start_page(self):
return int(self.table_of_contents[0].attrib['page'])
@lazy
def end_page(self): # lint-amnesty, pylint: disable=missing-function-docstring
# The last page should be the last element in the table of contents,
# but it may be nested. So recurse all the way down the last element
last_el = self.table_of_contents[-1]
while last_el.getchildren():
last_el = last_el[-1]
return int(last_el.attrib['page'])
@lazy
def table_of_contents(self):
"""
Accesses the textbook's table of contents (default name "toc.xml") at the URL self.book_url
Returns XML tree representation of the table of contents
"""
toc_url = self.book_url + 'toc.xml'
# cdodge: I've added this caching of TOC because in Mongo-backed instances (but not Filesystem stores)
# course blocks have a very short lifespan and are constantly being created and torn down.
# Since this module in the __init__() method does a synchronous call to AWS to get the TOC
# this is causing a big performance problem. So let's be a bit smarter about this and cache
# each fetch and store in-mem for 10 minutes.
# NOTE: I have to get this onto sandbox ASAP as we're having runtime failures. I'd like to swing back and
# rewrite to use the traditional Django in-memory cache.
try:
# see if we already fetched this
if toc_url in _cached_toc:
(table_of_contents, timestamp) = _cached_toc[toc_url]
age = datetime.now(ZoneInfo("UTC")) - timestamp
# expire every 10 minutes
if age.seconds < 600:
return table_of_contents
except Exception as err: # lint-amnesty, pylint: disable=broad-except, unused-variable
pass
# Get the table of contents from S3
log.info("Retrieving textbook table of contents from %s", toc_url)
try:
r = requests.get(toc_url)
except Exception as err:
msg = f'Error {err}: Unable to retrieve textbook table of contents at {toc_url}'
log.error(msg)
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from
# TOC is XML. Parse it
try:
table_of_contents = etree.fromstring(r.text)
except Exception as err:
msg = f'Error {err}: Unable to parse XML for textbook table of contents at {toc_url}'
log.error(msg)
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from
return table_of_contents
def __eq__(self, other):
return (self.title == other.title and
self.book_url == other.book_url)
def __ne__(self, other):
return not self == other
class TextbookList(List): # lint-amnesty, pylint: disable=missing-class-docstring
def from_json(self, values): # lint-amnesty, pylint: disable=arguments-differ
textbooks = []
for title, book_url in values:
try:
textbooks.append(Textbook(title, book_url))
except: # lint-amnesty, pylint: disable=bare-except
# If we can't get to S3 (e.g. on a train with no internet), don't break
# the rest of the courseware.
log.exception(f"Couldn't load textbook ({title}, {book_url})")
continue
return textbooks
def to_json(self, values): # lint-amnesty, pylint: disable=arguments-differ
json_data = []
for val in values:
if isinstance(val, Textbook):
json_data.append((val.title, val.book_url))
elif isinstance(val, tuple):
json_data.append(val)
else:
continue
return json_data
class ProctoringProvider(String):
"""
ProctoringProvider field, which includes validation of the provider
and default that pulls from edx platform settings.
"""
def from_json(self, value, validate_providers=False):
"""
Return ProctoringProvider as full featured Python type. Perform validation on the provider
and include any inherited values from the platform default.
"""
value = super().from_json(value)
if settings.FEATURES.get('ENABLE_PROCTORED_EXAMS'):
# Only validate the provider value if ProctoredExams are enabled on the environment
# Otherwise, the passed in provider does not matter. We should always return default
if validate_providers:
self._validate_proctoring_provider(value)
value = self._get_proctoring_value(value)
return value
else:
return self.default
def _get_proctoring_value(self, value):
"""
Return a proctoring value that includes any inherited attributes from the platform defaults
for the provider.
"""
# if provider is missing from the value, return the default
if value is None:
return self.default
return value
def _validate_proctoring_provider(self, value):
"""
Validate the value for the proctoring provider. If the proctoring provider value is
specified, and it is not one of the providers configured at the platform level, return
a list of error messages to the caller.
"""
available_providers = get_available_providers()
if value is not None and value not in available_providers:
raise InvalidProctoringProvider(value, available_providers)
@property
def default(self):
"""
Return default value for ProctoringProvider.
"""
default = super().default
proctoring_backend_settings = getattr(settings, 'PROCTORING_BACKENDS', None)
if proctoring_backend_settings:
return proctoring_backend_settings.get('DEFAULT', None)
return default
def get_available_providers() -> list[str]:
"""
Return list of available proctoring providers.
"""
proctoring_backend_settings = getattr(
settings,
'PROCTORING_BACKENDS',
{}
)
available_providers = [provider for provider in proctoring_backend_settings if provider != 'DEFAULT']
available_providers.append('lti_external')
available_providers.sort()
return available_providers
def get_requires_escalation_email_providers() -> list[str]:
"""
Return list of available proctoring providers that require an escalation email.
"""
requires_escalation_email_providers = [
provider
for provider in settings.PROCTORING_BACKENDS
if provider != "DEFAULT"
and settings.PROCTORING_BACKENDS[provider].get(
"requires_escalation_email", False
)
]
# Add lti_external unconditionally since it always requires an escalation email
requires_escalation_email_providers.append('lti_external')
requires_escalation_email_providers.sort()
return requires_escalation_email_providers
class TeamsConfigField(Dict):
"""
XBlock field for teams configuration, including definitions for teamsets.
Serializes to JSON dictionary.
"""
_default = TeamsConfig({})
def from_json(self, value):
"""
Return a TeamsConfig instance from a dict.
"""
return TeamsConfig(value)
def to_json(self, value):
"""
Convert a TeamsConfig instance back to a dict.
If we have the data that was used to build the TeamsConfig instance,
return that instead of `value.cleaned_data`, thus preserving the
data in the form that the user entered it.
"""
if value.source_data is not None:
return value.source_data
return value.cleaned_data
class CourseFields: # lint-amnesty, pylint: disable=missing-class-docstring
lti_passports = List(
display_name=_("LTI Passports"),
help=_('Enter the passports for course LTI tools in the following format: "id:client_key:client_secret".'),
scope=Scope.settings
)
textbooks = TextbookList(
help=_("List of Textbook objects with (title, url) for textbooks used in this course"),
default=[],
scope=Scope.content
)
wiki_slug = String(help=_("Slug that points to the wiki for this course"), scope=Scope.content)
enrollment_start = Date(
help=_("Date that enrollment for this class is opened"),
default=DEFAULT_START_DATE if CREATE_COURSE_WITH_DEFAULT_ENROLLMENT_START_DATE.is_enabled() else None,
scope=Scope.settings
)
enrollment_end = Date(help=_("Date that enrollment for this class is closed"), scope=Scope.settings)
start = Date(
help=_("Start time when this block is visible"),
default=DEFAULT_START_DATE,
scope=Scope.settings
)
end = Date(help=_("Date that this class ends"), scope=Scope.settings)
certificate_available_date = Date(
help=_("Date that certificates become available to learners"),
scope=Scope.content
)
cosmetic_display_price = Integer(
display_name=_("Cosmetic Course Display Price"),
help=_(
"The cost displayed to students for enrolling in the course. If a paid course registration price is "
"set by an administrator in the database, that price will be displayed instead of this one."
),
default=0,
scope=Scope.settings,
)
advertised_start = String(
display_name=_("Course Advertised Start"),
help=_(
"Enter the text that you want to use as the advertised starting time frame for the course, "
"such as \"Winter 2018\". If you enter null for this value, the start date that you have set "
"for this course is used."
),
scope=Scope.settings
)
pre_requisite_courses = List(
display_name=_("Pre-Requisite Courses"),
help=_("Pre-Requisite Course key if this course has a pre-requisite course"),
scope=Scope.settings
)
grading_policy = Dict(
help=_("Grading policy definition for this class"),
default=DEFAULT_GRADING_POLICY,
scope=Scope.content
)
show_calculator = Boolean(
display_name=_("Show Calculator"),
help=_("Enter true or false. When true, students can see the calculator in the course."),
default=False,
scope=Scope.settings
)
display_name = String(
help=_("Enter the name of the course as it should appear in the course list."),
default="Empty",
display_name=_("Course Display Name"),
scope=Scope.settings,
)
course_edit_method = String(
display_name=_("Course Editor"),
help=_('Enter the method by which this course is edited ("XML" or "Studio").'),
default="Studio",
scope=Scope.settings,
deprecated=True # Deprecated because someone would not edit this value within Studio.
)
tabs = CourseTabList(help="List of tabs to enable in this course", scope=Scope.settings, default=[])
end_of_course_survey_url = String(
display_name=_("Course Survey URL"),
help=_("Enter the URL for the end-of-course survey. If your course does not have a survey, enter null."),
scope=Scope.settings,
deprecated=True # We wish to remove this entirely, TNL-3399
)
discussion_blackouts = List(
display_name=_("Discussion Blackout Dates"),
help=_(
'Enter pairs of dates between which students cannot post to discussion forums. Inside the provided '
'brackets, enter an additional set of square brackets surrounding each pair of dates you add. '
'Format each pair of dates as ["YYYY-MM-DD", "YYYY-MM-DD"]. To specify times as well as dates, '
'format each pair as ["YYYY-MM-DDTHH:MM", "YYYY-MM-DDTHH:MM"]. Be sure to include the "T" between '
'the date and time. For example, an entry defining two blackout periods looks like this, including '
'the outer pair of square brackets: [["2015-09-15", "2015-09-21"], ["2015-10-01", "2015-10-08"]] '
),
scope=Scope.settings
)
discussion_topics = Dict(
display_name=_("Discussion Topic Mapping"),
help=_(
'Enter discussion categories in the following format: "CategoryName": '
'{"id": "i4x-InstitutionName-CourseNumber-course-CourseRun"}. For example, one discussion '
'category may be "Lydian Mode": {"id": "i4x-UniversityX-MUS101-course-2015_T1"}. The "id" '
'value for each category must be unique. In "id" values, the only special characters that are '
'supported are underscore, hyphen, and period. You can also specify a category as the default '
'for new posts in the Discussion page by setting its "default" attribute to true. For example, '
'"Lydian Mode": {"id": "i4x-UniversityX-MUS101-course-2015_T1", "default": true}.'
),
scope=Scope.settings
)
discussions_settings = Dict(
display_name=_("Discussions Plugin Settings"),
scope=Scope.settings,
help=_("Settings for discussions plugins."),
default={
"enable_in_context": True,
"enable_graded_units": False,
"unit_level_visibility": True,
}
)
announcement = Date(
display_name=_("Course Announcement Date"),
help=_("Enter the date to announce your course."),
scope=Scope.settings
)
cohort_config = Dict(
display_name=_("Cohort Configuration"),
help=_(
"Enter policy keys and values to enable the cohort feature, define automated student assignment to "
"groups, or identify any course-wide discussion topics as private to cohort members."
),
scope=Scope.settings
)
is_new = Boolean(
display_name=_("Course Is New"),
help=_(
"Enter true or false. If true, the course appears in the list of new courses, and a New! "
"badge temporarily appears next to the course image."
),
scope=Scope.settings
)
mobile_available = Boolean(
display_name=_("Mobile Course Available"),
help=_("Enter true or false. If true, the course will be available to mobile devices."),
default=DEFAULT_MOBILE_AVAILABLE,
scope=Scope.settings
)
video_upload_pipeline = Dict(
display_name=_("Video Upload Credentials"),
help=_(
"Enter the unique identifier for your course's video files provided by {platform_name}."
).format(platform_name=settings.PLATFORM_NAME),
scope=Scope.settings
)
no_grade = Boolean(
display_name=_("Course Not Graded"),
help=_("Enter true or false. If true, the course will not be graded."),
default=False,
scope=Scope.settings
)
disable_progress_graph = Boolean(
display_name=_("Disable Progress Graph"),
help=_("Enter true or false. If true, students cannot view the progress graph."),
default=False,
scope=Scope.settings
)
pdf_textbooks = List(
display_name=_("PDF Textbooks"),
help=_("List of dictionaries containing pdf_textbook configuration"), scope=Scope.settings
)
html_textbooks = List(
display_name=_("HTML Textbooks"),
help=_(
"For HTML textbooks that appear as separate tabs in the course, enter the name of the tab (usually "
"the title of the book) as well as the URLs and titles of each chapter in the book."
),
scope=Scope.settings
)
remote_gradebook = Dict(
display_name=_("Remote Gradebook"),
help=_(
"Enter the remote gradebook mapping. Only use this setting when "
"REMOTE_GRADEBOOK_URL has been specified."
),
scope=Scope.settings
)
enable_ccx = Boolean(
# Translators: Custom Courses for edX (CCX) is an edX feature for re-using course content. CCX Coach is
# a role created by a course Instructor to enable a person (the "Coach") to manage the custom course for
# his students.
display_name=_("Enable CCX"),
help=_(
# Translators: Custom Courses for edX (CCX) is an edX feature for re-using course content. CCX Coach is
# a role created by a course Instructor to enable a person (the "Coach") to manage the custom course for
# his students.
"Allow course instructors to assign CCX Coach roles, and allow coaches to manage "
"Custom Courses on {platform_name}. When false, Custom Courses cannot be created, "
"but existing Custom Courses will be preserved."
).format(platform_name=settings.PLATFORM_NAME),
default=False,
scope=Scope.settings
)
ccx_connector = String(
# Translators: Custom Courses for edX (CCX) is an edX feature for re-using course content.
display_name=_("CCX Connector URL"),
# Translators: Custom Courses for edX (CCX) is an edX feature for re-using course content.
help=_(
"URL for CCX Connector application for managing creation of CCXs. (optional)."
" Ignored unless 'Enable CCX' is set to 'true'."
),
scope=Scope.settings, default=""
)
allow_anonymous = Boolean(
display_name=_("Allow Anonymous Discussion Posts"),
help=_("Enter true or false. If true, students can create discussion posts that are anonymous to all users."),
scope=Scope.settings, default=True
)
allow_anonymous_to_peers = Boolean(
display_name=_("Allow Anonymous Discussion Posts to Peers"),
help=_(
"Enter true or false. If true, students can create discussion posts that are anonymous to other "
"students. This setting does not make posts anonymous to course staff."
),
scope=Scope.settings, default=False
)
advanced_modules = List(
display_name=_("Advanced Module List"),
help=_("Enter the names of the advanced modules to use in your course."),
scope=Scope.settings
)
has_children = True
show_timezone = Boolean(
help=_(
"True if timezones should be shown on dates in the course. "
"Deprecated in favor of due_date_display_format."
),
scope=Scope.settings, default=True
)
due_date_display_format = String(
display_name=_("Due Date Display Format"),
help=_(
"Enter the format for due dates. The default is Mon DD, YYYY. Enter \"%m-%d-%Y\" for MM-DD-YYYY, "
"\"%d-%m-%Y\" for DD-MM-YYYY, \"%Y-%m-%d\" for YYYY-MM-DD, or \"%Y-%d-%m\" for YYYY-DD-MM."
),
scope=Scope.settings, default=None
)
enrollment_domain = String(
display_name=_("External Login Domain"),
help=_("Enter the external login method students can use for the course."),
scope=Scope.settings
)
certificates_show_before_end = Boolean(
display_name=_("Certificates Downloadable Before End"),
help=_(
"Enter true or false. If true, students can download certificates before the course ends, if they've "
"met certificate requirements."
),
scope=Scope.settings,
default=False,
deprecated=True
)
certificates_display_behavior = String(
display_name=_("Certificates Display Behavior"),
help=_(
"This field, together with certificate_available_date will determine when a "
"user can see their certificate for the course"
),
scope=Scope.settings,
default=CertificatesDisplayBehaviors.END.value,
)
course_image = String(
display_name=_("Course About Page Image"),
help=_(
"Edit the name of the course image file. You must upload this file on the Files & Uploads page. "
"You can also set the course image on the Settings & Details page."
),
scope=Scope.settings,
# Ensure that courses imported from XML keep their image
default="images_course_image.jpg",
hide_on_enabled_publisher=True
)
banner_image = String(
display_name=_("Course Banner Image"),
help=_(
"Edit the name of the banner image file. "
"You can set the banner image on the Settings & Details page."
),
scope=Scope.settings,
# Ensure that courses imported from XML keep their image
default="images_course_image.jpg"
)
video_thumbnail_image = String(
display_name=_("Course Video Thumbnail Image"),
help=_(
"Edit the name of the video thumbnail image file. "
"You can set the video thumbnail image on the Settings & Details page."
),
scope=Scope.settings,
# Ensure that courses imported from XML keep their image
default="images_course_image.jpg"
)
## Course level Certificate Name overrides.
cert_name_short = String(
help=_(
'Use this setting only when generating PDF certificates. '
'Between quotation marks, enter the short name of the type of certificate that '
'students receive when they complete the course. For instance, "Certificate".'
),
display_name=_("Certificate Name (Short)"),
scope=Scope.settings,
default=""
)
cert_name_long = String(
help=_(
'Use this setting only when generating PDF certificates. '
'Between quotation marks, enter the long name of the type of certificate that students '
'receive when they complete the course. For instance, "Certificate of Achievement".'
),
display_name=_("Certificate Name (Long)"),
scope=Scope.settings,
default=""
)
cert_html_view_enabled = Boolean(
display_name=_("Certificate Web/HTML View Enabled"),
help=_("If true, certificate Web/HTML views are enabled for the course."),
scope=Scope.settings,
default=True,
deprecated=True
)
cert_html_view_overrides = Dict(
# Translators: This field is the container for course-specific certificate configuration values
display_name=_("Certificate Web/HTML View Overrides"),
# Translators: These overrides allow for an alternative configuration of the certificate web view
help=_("Enter course-specific overrides for the Web/HTML template parameters here (JSON format)"),
scope=Scope.settings,
)
# Specific certificate information managed via Studio (should eventually fold other cert settings into this)
certificates = Dict(
# Translators: This field is the container for course-specific certificate configuration values
display_name=_("Certificate Configuration"),
# Translators: These overrides allow for an alternative configuration of the certificate web view
help=_("Enter course-specific configuration information here (JSON format)"),
scope=Scope.settings,
)
# An extra property is used rather than the wiki_slug/number because
# there are courses that change the number for different runs. This allows
# courses to share the same css_class across runs even if they have
# different numbers.
#
# TODO get rid of this as soon as possible or potentially build in a robust
# way to add in course-specific styling. There needs to be a discussion
# about the right way to do this, but arjun will address this ASAP. Also
# note that the courseware template needs to change when this is removed.
css_class = String(
display_name=_("CSS Class for Course Reruns"),
help=_("Allows courses to share the same css class across runs even if they have different numbers."),
scope=Scope.settings, default="",
deprecated=True
)
# TODO: This is a quick kludge to allow CS50 (and other courses) to
# specify their own discussion forums as external links by specifying a
# "discussion_link" in their policy JSON file. This should later get
# folded in with Syllabus, Course Info, and additional Custom tabs in a
# more sensible framework later.
discussion_link = String(
display_name=_("Discussion Forum External Link"),
help=_("Allows specification of an external link to replace discussion forums."),
scope=Scope.settings,
deprecated=True
)
# TODO: same as above, intended to let internal CS50 hide the progress tab
# until we get grade integration set up.
# Explicit comparison to True because we always want to return a bool.
hide_progress_tab = Boolean(
display_name=_("Hide Progress Tab"),
help=_("Allows hiding of the progress tab."),
scope=Scope.settings,
deprecated=True
)
display_organization = String(
display_name=_("Course Organization Display String"),
help=_(
"Enter the course organization that you want to appear in the course. This setting overrides the "
"organization that you entered when you created the course. To use the organization that you entered "
"when you created the course, enter null."
),
scope=Scope.settings
)
display_coursenumber = String(
display_name=_("Course Number Display String"),
help=_(
"Enter the course number that you want to appear in the course. This setting overrides the course "
"number that you entered when you created the course. To use the course number that you entered when "
"you created the course, enter null."
),
scope=Scope.settings,
default=""
)
max_student_enrollments_allowed = Integer(
display_name=_("Course Maximum Student Enrollment"),
help=_(
"Enter the maximum number of students that can enroll in the course. To allow an unlimited number of "
"students, enter null."
),
scope=Scope.settings
)
allow_public_wiki_access = Boolean(
display_name=_("Allow Public Wiki Access"),
help=_(
"Enter true or false. If true, students can view the course wiki even "
"if they're not enrolled in the course."
),
default=False,
scope=Scope.settings
)
invitation_only = Boolean(
display_name=_("Invitation Only"),
help=_("Whether to restrict enrollment to invitation by the course staff."),
default=False,
scope=Scope.settings
)
course_survey_name = String(
display_name=_("Pre-Course Survey Name"),
help=_("Name of SurveyForm to display as a pre-course survey to the user."),
default=None,
scope=Scope.settings,
deprecated=True
)
course_survey_required = Boolean(
display_name=_("Pre-Course Survey Required"),
help=_(
"Specify whether students must complete a survey before they can view your course content. If you "
"set this value to true, you must add a name for the survey to the Course Survey Name setting above."
),
default=False,
scope=Scope.settings,
deprecated=True
)
catalog_visibility = String(
display_name=_("Course Visibility In Catalog"),
help=_(
# Translators: the quoted words 'both', 'about', and 'none' must be
# left untranslated. Leave them as English words.
"Defines the access permissions for showing the course in the course catalog. This can be set to one "
"of three values: 'both' (show in catalog and allow access to about page), 'about' (only allow access "
"to about page), 'none' (do not show in catalog and do not allow access to an about page)."
),
default=DEFAULT_COURSE_VISIBILITY_IN_CATALOG,
scope=Scope.settings,
values=[
{"display_name": "Both", "value": CATALOG_VISIBILITY_CATALOG_AND_ABOUT},
{"display_name": "About", "value": CATALOG_VISIBILITY_ABOUT},
{"display_name": "None", "value": CATALOG_VISIBILITY_NONE},
],
)
entrance_exam_enabled = Boolean(
display_name=_("Entrance Exam Enabled"),
help=_(
"Specify whether students must complete an entrance exam before they can view your course content. "
"Note, you must enable Entrance Exams for this course setting to take effect."
),
default=False,
scope=Scope.settings,
)
# Note: Although users enter the entrance exam minimum score
# as a percentage value, it is internally converted and stored
# as a decimal value less than 1.
entrance_exam_minimum_score_pct = Float(
display_name=_("Entrance Exam Minimum Score (%)"),
help=_(
"Specify a minimum percentage score for an entrance exam before students can view your course content. "
"Note, you must enable Entrance Exams for this course setting to take effect."
),
default=65,
scope=Scope.settings,
)
entrance_exam_id = String(
display_name=_("Entrance Exam ID"),
help=_("Content block identifier (location) of entrance exam."),
default=None,
scope=Scope.settings,
)
social_sharing_url = String(
display_name=_("Social Media Sharing URL"),
help=_(
"If dashboard social sharing and custom course URLs are enabled, you can provide a URL "
"(such as the URL to a course About page) that social media sites can link to. URLs must "
"be fully qualified. For example: http://www.edx.org/course/Introduction-to-MOOCs-ITM001"
),
default=None,
scope=Scope.settings,
)
language = String(
display_name=_("Course Language"),
help=_("Specify the language of your course."),
default=None,
scope=Scope.settings
)
teams_configuration = TeamsConfigField(
display_name=_("Teams Configuration"),
# Translators: please don't translate "id".
help=_(
'Configure team sets, limit team sizes, and set visibility settings using JSON. See '
'<a target="&#95;blank" href="https://docs.openedx.org/en/latest/educators/references/'
'advanced_features/teams_configuration_options.html>teams '
'configuration documentation</a> for help and examples.'
),
scope=Scope.settings,
)
enable_proctored_exams = Boolean(
display_name=_("Enable Proctored Exams"),
help=_(
"Enter true or false. If this value is true, proctored exams are enabled in your course. "
"Note that enabling proctored exams will also enable timed exams."
),
default=False,
scope=Scope.settings,
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
)
proctoring_provider = ProctoringProvider(
display_name=_("Proctoring Provider"),
help=_(
"Enter the proctoring provider you want to use for this course run. "
"Choose from the following options: {available_providers}."),
help_format_args=dict(
# Put the available providers into a format variable so that translators
# don't translate them.
available_providers=(
', '.join(get_available_providers())
),
),
scope=Scope.settings,
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
)
proctoring_escalation_email = EmailString(
display_name=_("Proctoring Exam Escalation Contact"),
help=_(
"Required if 'requires_escalation_email' is set in the proctoring backend."
"Enter an email address to be contacted by the support team whenever there are escalations "
"(e.g. appeals, delayed reviews, etc.)."
),
default=None,
scope=Scope.settings,
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
)
allow_proctoring_opt_out = Boolean(
display_name=_("Allow Opting Out of Proctored Exams"),
help=_(
"Enter true or false. If this value is true, learners can choose to take proctored exams "
"without proctoring. If this value is false, all learners must take the exam with proctoring. "
"This setting only applies if proctored exams are enabled for the course."
),
default=False,
scope=Scope.settings,
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
)
create_zendesk_tickets = Boolean(
display_name=_("Create Zendesk Tickets For Suspicious Proctored Exam Attempts"),
help=_(
"Enter true or false. If this value is true, a Zendesk ticket will be created for suspicious attempts."
),
default=True,
scope=Scope.settings,
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
)
enable_timed_exams = Boolean(
display_name=_("Enable Timed Exams"),
help=_(
"Enter true or false. If this value is true, timed exams are enabled in your course. "
"Regardless of this setting, timed exams are enabled if Enable Proctored Exams is set to true."
),
default=SPECIAL_EXAMS_ENABLED,
scope=Scope.settings,
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
)
minimum_grade_credit = Float(
display_name=_("Minimum Grade for Credit"),
help=_(
"The minimum grade that a learner must earn to receive credit in the course, "
"as a decimal between 0.0 and 1.0. For example, for 75%, enter 0.75."
),
default=0.8,
scope=Scope.settings,
)
self_paced = Boolean(
display_name=_("Self Paced"),
help=_(
"Set this to \"true\" to mark this course as self-paced. Self-paced courses do not have "
"due dates for assignments, and students can progress through the course at any rate before "
"the course ends."
),
default=False,
scope=Scope.settings
)
enable_subsection_gating = Boolean(
display_name=_("Enable Subsection Prerequisites"),
help=_(
"Enter true or false. If this value is true, you can hide a "
"subsection until learners earn a minimum score in another, "
"prerequisite subsection."
),
default=False,
scope=Scope.settings
)
learning_info = List(
display_name=_("Course Learning Information"),
help=_("Specify what student can learn from the course."),
default=[],
scope=Scope.settings
)
course_visibility = String(
display_name=_("Course Visibility For Unenrolled Learners"),
help=_(
# Translators: the quoted words 'private', 'public_outline', and 'public'
# must be left untranslated. Leave them as English words.
"Defines the access permissions for unenrolled learners. This can be set to one of three values: "
"'private' (default visibility, only allowed for enrolled students), 'public_outline' "
"(allow access to course outline) and 'public' (allow access to both outline and course content)."
),
default=COURSE_VISIBILITY_PRIVATE,
scope=Scope.settings,
values=[
{"display_name": "private", "value": COURSE_VISIBILITY_PRIVATE},
{"display_name": "public_outline", "value": COURSE_VISIBILITY_PUBLIC_OUTLINE},
{"display_name": "public", "value": COURSE_VISIBILITY_PUBLIC},
],
)
video_sharing_options = String(
display_name=_("Video Sharing Options"),
help=_(
"Specify the video sharing options for the course. "
"This can be set to one of three values: "
"'all-on', 'all-off' and 'per-video'. with 'per-video' as the default."
),
default=COURSE_VIDEO_SHARING_PER_VIDEO,
scope=Scope.settings,
values=[
{"display_name": "all-on", "value": COURSE_VIDEO_SHARING_ALL_VIDEOS},
{"display_name": "all-off", "value": COURSE_VIDEO_SHARING_NONE},
{"display_name": "per-video", "value": COURSE_VIDEO_SHARING_PER_VIDEO},
]
)
force_on_flexible_peer_openassessments = Boolean(
display_name=_("Force Flexible Grading for Peer ORAs"),
help=_("Setting this flag will force on the flexible grading option for all peer-graded ORAs in this course."),
scope=Scope.settings,
default=False,
)
"""
instructor_info dict structure:
{
"instructors": [
{
"name": "",
"title": "",
"organization": "",
"image": "",
"bio": ""
}
]
}
"""
instructor_info = Dict(
display_name=_("Course Instructor"),
help=_("Enter the details for Course Instructor"),
default={
"instructors": []
},
scope=Scope.settings
)
allow_unsupported_xblocks = Boolean(
display_name=_("Add Unsupported Problems and Tools"),
help=_(
"Enter true or false. If true, you can add unsupported problems and tools to your course in Studio. "
"Unsupported problems and tools are not recommended for use in courses due to non-compliance with one or "
"more of the base requirements, such as testing, accessibility, internationalization, and documentation."
),
scope=Scope.settings, default=False
)
highlights_enabled_for_messaging = Boolean(
display_name=_("Highlights Enabled for Messaging"),
help=_(
"Enter true or false. If true, any highlights associated with content in the course will be messaged "
"to learners at their scheduled time."
),
scope=Scope.settings, default=False
)
course_wide_js = List(
display_name=_("Course-wide Custom JS"),
help=_('Enter Javascript resource URLs you want to be loaded globally throughout the course pages.'),
scope=Scope.settings,
)
course_wide_css = List(
display_name=_("Course-wide Custom CSS"),
help=_('Enter CSS resource URLs you want to be loaded globally throughout the course pages.'),
scope=Scope.settings,
)
other_course_settings = Dict(
display_name=_("Other Course Settings"),
help=_(
"Any additional information about the course that the platform needs or that allows integration with "
"external systems such as CRM software. Enter a dictionary of values in JSON format, such as "
"{ \"my_custom_setting\": \"value\", \"other_setting\": \"value\" }"
),
scope=Scope.settings
)
class CourseBlock(
CourseFields,
SequenceBlock,
LicenseMixin,
): # pylint: disable=abstract-method
"""
The Course XBlock.
"""
resources_dir = None
def __init__(self, *args, **kwargs):
"""
Expects the same arguments as XModuleDescriptor.__init__
"""
super().__init__(*args, **kwargs)
_ = self.runtime.service(self, "i18n").ugettext
self._gating_prerequisites = None
if self.wiki_slug is None:
self.wiki_slug = self.location.course
if self.due_date_display_format is None and self.show_timezone is False:
# For existing courses with show_timezone set to False (and no due_date_display_format specified),
# set the due_date_display_format to what would have been shown previously (with no timezone).
# Then remove show_timezone so that if the user clears out the due_date_display_format,
# they get the default date display.
self.due_date_display_format = "DATE_TIME"
del self.show_timezone
# NOTE: relies on the modulestore to call set_grading_policy() right after
# init. (Modulestore is in charge of figuring out where to load the policy from)
# NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically
# disable the syllabus content for courses that do not provide a syllabus
if self.runtime.resources_fs is None:
self.syllabus_present = False
else:
self.syllabus_present = self.runtime.resources_fs.exists(path('syllabus'))
self._grading_policy = {}
self.set_grading_policy(self.grading_policy)
if not self.discussion_topics:
self.discussion_topics = {_('General'): {'id': self.location.html_id()}}
try:
if not getattr(self, "tabs", []):
CourseTabList.initialize_default(self)
except InvalidTabsException as err:
raise type(err)(f'{str(err)} For course: {str(self.id)}') # lint-amnesty, pylint: disable=line-too-long
def set_grading_policy(self, course_policy):
"""
The JSON object can have the keys GRADER and GRADE_CUTOFFS. If either is
missing, it reverts to the default.
"""
if course_policy is None:
course_policy = {}
# Load the global settings as a dictionary
grading_policy = self.grading_policy
# BOY DO I HATE THIS grading_policy CODE ACROBATICS YET HERE I ADD MORE (dhm)--this fixes things persisted w/
# defective grading policy values (but not None)
if 'GRADER' not in grading_policy:
grading_policy['GRADER'] = CourseFields.grading_policy.default['GRADER']
if 'GRADE_CUTOFFS' not in grading_policy:
grading_policy['GRADE_CUTOFFS'] = CourseFields.grading_policy.default['GRADE_CUTOFFS']
# Override any global settings with the course settings
grading_policy.update(course_policy)
# Here is where we should parse any configurations, so that we can fail early
# Use setters so that side effecting to .definitions works
self.raw_grader = grading_policy['GRADER'] # used for cms access
self.grade_cutoffs = grading_policy['GRADE_CUTOFFS']
def set_default_certificate_available_date(self):
if (not self.certificate_available_date) and self.end:
self.certificate_available_date = self.end + timedelta(days=2)
@classmethod
def read_grading_policy(cls, paths, system):
"""Load a grading policy from the specified paths, in order, if it exists."""
# Default to a blank policy dict
policy_str = '{}'
for policy_path in paths:
if not system.resources_fs.exists(policy_path):
continue
log.debug(f"Loading grading policy from {policy_path}")
try:
with system.resources_fs.open(policy_path) as grading_policy_file:
policy_str = grading_policy_file.read()
# if we successfully read the file, stop looking at backups
break
except OSError:
msg = f"Unable to load course settings file from '{policy_path}'"
log.warning(msg)
return policy_str
@classmethod
def parse_xml(cls, node, runtime, keys):
instance = super().parse_xml(node, runtime, keys)
policy_dir = None
url_name = node.get('url_name')
if url_name:
policy_dir = 'policies/' + url_name
# Try to load grading policy
paths = ['grading_policy.json']
if policy_dir:
paths = [policy_dir + '/grading_policy.json'] + paths
try:
policy = json.loads(cls.read_grading_policy(paths, runtime))
except ValueError:
runtime.error_tracker("Unable to decode grading policy as json")
policy = {}
# now set the current instance. set_grading_policy() will apply some inheritance rules
instance.set_grading_policy(policy)
return instance
@classmethod
def definition_from_xml(cls, xml_object, system):
textbooks = []
for textbook in xml_object.findall("textbook"):
textbooks.append((textbook.get('title'), textbook.get('book_url')))
xml_object.remove(textbook)
# Load the wiki tag if it exists
wiki_slug = None
wiki_tag = xml_object.find("wiki")
if wiki_tag is not None:
wiki_slug = wiki_tag.attrib.get("slug", default=None)
xml_object.remove(wiki_tag)
definition, children = super().definition_from_xml(xml_object, system)
definition['textbooks'] = textbooks
definition['wiki_slug'] = wiki_slug
# load license if it exists
definition = LicenseMixin.parse_license_from_xml(definition, xml_object)
return definition, children
def definition_to_xml(self, resource_fs):
xml_object = super().definition_to_xml(resource_fs)
if self.textbooks:
textbook_xml_object = etree.Element('textbook')
for textbook in self.textbooks: # lint-amnesty, pylint: disable=not-an-iterable
textbook_xml_object.set('title', textbook.title)
textbook_xml_object.set('book_url', textbook.book_url)
xml_object.append(textbook_xml_object)
if self.wiki_slug is not None:
wiki_xml_object = etree.Element('wiki')
wiki_xml_object.set('slug', self.wiki_slug)
xml_object.append(wiki_xml_object)
# handle license specifically. Default the course to have a license
# of "All Rights Reserved", if a license is not explicitly set.
self.add_license_to_xml(xml_object, default="all-rights-reserved")
return xml_object
def has_ended(self):
"""
Returns True if the current time is after the specified course end date.
Returns False if there is no end date specified.
"""
return course_metadata_utils.has_course_ended(self.end)
def has_started(self):
return course_metadata_utils.has_course_started(self.start)
def is_enrollment_open(self):
"""
Returns True if course enrollment is open
"""
return course_metadata_utils.is_enrollment_open(self.enrollment_start, self.enrollment_end)
@property
def grader(self):
return grader_from_conf(self.raw_grader)
@property
def raw_grader(self): # lint-amnesty, pylint: disable=missing-function-docstring
# force the caching of the xblock value so that it can detect the change
# pylint: disable=pointless-statement
self.grading_policy['GRADER']
return self._grading_policy['RAW_GRADER']
@raw_grader.setter
def raw_grader(self, value):
# NOTE WELL: this change will not update the processed graders.
# If we need that, this needs to call grader_from_conf.
self._grading_policy['RAW_GRADER'] = value
self.grading_policy['GRADER'] = value
@property
def grade_cutoffs(self):
return self._grading_policy['GRADE_CUTOFFS']
@grade_cutoffs.setter
def grade_cutoffs(self, value):
self._grading_policy['GRADE_CUTOFFS'] = value
# XBlock fields don't update after mutation
policy = self.grading_policy
policy['GRADE_CUTOFFS'] = value
self.grading_policy = policy
@property
def lowest_passing_grade(self):
return min(self._grading_policy['GRADE_CUTOFFS'].values())
@property
def is_cohorted(self):
"""
Return whether the course is cohorted.
Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings.
"""
config = self.cohort_config
if config is None:
return False
return bool(config.get("cohorted"))
@property
def auto_cohort(self):
"""
Return whether the course is auto-cohorted.
Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings.
"""
if not self.is_cohorted:
return False
return bool(self.cohort_config.get(
"auto_cohort", False))
@property
def auto_cohort_groups(self):
"""
Return the list of groups to put students into. Returns [] if not
specified. Returns specified list even if is_cohorted and/or auto_cohort are
false.
Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings.
"""
if self.cohort_config is None:
return []
else:
return self.cohort_config.get("auto_cohort_groups", [])
@property
def top_level_discussion_topic_ids(self):
"""
Return list of topic ids defined in course policy.
"""
topics = self.discussion_topics
return [d["id"] for d in topics.values()]
@property
def cohorted_discussions(self):
"""
Return the set of discussions that is explicitly cohorted. It may be
the empty set. Note that all inline discussions are automatically
cohorted based on the course's is_cohorted setting.
Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings.
"""
config = self.cohort_config
if config is None:
return set()
return set(config.get("cohorted_discussions", []))
@property
def always_cohort_inline_discussions(self):
"""
This allow to change the default behavior of inline discussions cohorting. By
setting this to 'True', all inline discussions are cohorted. The default value is
now `False`, meaning that inline discussions are not cohorted unless their discussion IDs
are specifically listed as cohorted.
Note: No longer used except to get the initial value when cohorts are first enabled on a course
(and for migrating old courses). See openedx.core.djangoapps.course_groups.models.CourseCohortSettings.
"""
config = self.cohort_config
if config is None:
# This value sets the default for newly created courses.
return False
return bool(config.get("always_cohort_inline_discussions", False))
@property
def is_newish(self):
"""
Returns if the course has been flagged as new. If
there is no flag, return a heuristic value considering the
announcement and the start dates.
"""
flag = self.is_new
if flag is None:
# Use a heuristic if the course has not been flagged
announcement, start, now = course_metadata_utils.sorting_dates(
self.start, self.advertised_start, self.announcement
)
if announcement and (now - announcement).days < 30:
# The course has been announced for less that month
return True
elif (now - start).days < 1:
# The course has not started yet
return True
else:
return False
elif isinstance(flag, str):
return flag.lower() in ['true', 'yes', 'y']
else:
return bool(flag)
@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)
@staticmethod
def make_id(org, course, url_name):
return '/'.join([org, course, url_name])
@property
def id(self):
"""Return the course_id for this course"""
return self.location.course_key
@property
def start_date_is_still_default(self):
"""
Checks if the start date set for the course is still default, i.e. .start has not been modified,
and .advertised_start has not been set.
"""
return course_metadata_utils.course_start_date_is_default(
self.start,
self.advertised_start
)
def get_discussion_blackout_datetimes(self):
"""
Get a list of dicts with start and end fields with datetime values from
the discussion_blackouts setting
"""
blackout_dates = self.discussion_blackouts
date_proxy = Date()
if blackout_dates and type(blackout_dates[0]) not in (list, tuple):
blackout_dates = [blackout_dates]
try:
ret = [
{"start": date_proxy.from_json(start), "end": date_proxy.from_json(end)}
for start, end
in [blackout_date for blackout_date in blackout_dates if blackout_date]
]
for blackout in ret:
if not blackout["start"] or not blackout["end"]:
raise ValueError
return ret
except (TypeError, ValueError):
log.info(
"Error parsing discussion_blackouts %s for course %s",
blackout_dates,
self.id
)
return []
@property
def forum_posts_allowed(self):
"""
Return whether forum posts are allowed by the discussion_blackouts setting
Checks if posting restrictions are enabled or if there's a currently ongoing blackout period.
"""
blackouts = self.get_discussion_blackout_datetimes()
posting_restrictions = self.discussions_settings.get('posting_restrictions', 'disabled')
now = datetime.now(ZoneInfo("UTC"))
if posting_restrictions == 'enabled':
return False
return all(not (blackout["start"] <= now <= blackout["end"]) for blackout in blackouts)
@property
def number(self):
"""
Returns this course's number.
This is a "number" in the sense of the "course numbers" that you see at
lots of universities. For example, given a course
"Intro to Computer Science" with the course key "edX/CS-101/2014", the
course number would be "CS-101"
"""
return course_metadata_utils.number_for_course_location(self.location)
@property
def display_number_with_default(self):
"""
Return a display course number if it has been specified, otherwise return the 'course' that is in the location
"""
if self.display_coursenumber:
return self.display_coursenumber
return self.number
@property
def org(self):
return self.location.org
@property
def display_org_with_default(self):
"""
Return a display organization if it has been specified, otherwise return the 'org' that is in the location
"""
if self.display_organization:
return self.display_organization
return self.org
@property
def video_pipeline_configured(self):
"""
Returns whether the video pipeline advanced setting is configured for this course.
"""
return VideoUploadsEnabledByDefault.feature_enabled(course_id=self.id) or (
self.video_upload_pipeline is not None and
'course_video_upload_token' in self.video_upload_pipeline
)
def clean_id(self, padding_char='='):
"""
Returns a unique deterministic base32-encoded ID for the course.
The optional padding_char parameter allows you to override the "=" character used for padding.
"""
return course_metadata_utils.clean_course_key(self.location.course_key, padding_char)
@property
def teams_enabled(self):
"""
Alias to `self.teams_configuration.is_enabled`, for convenience.
Returns bool.
"""
return self.teams_configuration.is_enabled # pylint: disable=no-member
@property
def teamsets(self):
"""
Alias to `self.teams_configuration.teamsets`, for convenience.
Returns list[TeamsetConfig].
"""
return self.teams_configuration.teamsets # pylint: disable=no-member
@property
def teamsets_by_id(self):
"""
Alias to `self.teams_configuration.teamsets_by_id`, for convenience.
Returns dict[str: TeamsetConfig].
"""
return self.teams_configuration.teamsets_by_id
def set_user_partitions_for_scheme(self, partitions, scheme):
"""
Set the user partitions for a particular scheme.
Preserves partitions associated with other schemes.
Arguments:
scheme (object): The user partition scheme.
Returns:
list of `UserPartition`
"""
other_partitions = [
p for p in self.user_partitions # pylint: disable=access-member-before-definition
if p.scheme != scheme
]
self.user_partitions = other_partitions + partitions # pylint: disable=attribute-defined-outside-init
@property
def can_toggle_course_pacing(self):
"""
Whether or not the course can be set to self-paced at this time.
Returns:
bool: False if the course has already started or no start date set, True otherwise.
"""
if not self.start:
return False
return datetime.now(ZoneInfo("UTC")) <= self.start
class CourseSummary:
"""
A lightweight course summary class, which constructs split/mongo course summary without loading
the course. It is used at cms for listing courses to global staff user.
"""
course_info_fields = ['display_name', 'display_coursenumber', 'display_organization', 'end']
def __init__(self, course_locator, display_name="Empty", display_coursenumber=None, display_organization=None,
end=None):
"""
Initialize and construct course summary
Arguments:
course_locator (CourseLocator): CourseLocator object of the course.
display_name (unicode): display name of the course. When you create a course from console, display_name
isn't set (course block has no key `display_name`). "Empty" name is returned when we load the course.
If `display_name` isn't present in the course block, use the `Empty` as default display name.
We can set None as a display_name in Course Advance Settings; Do not use "Empty" when display_name is
set to None.
display_coursenumber (unicode|None): Course number that is specified & appears in the courseware
display_organization (unicode|None): Course organization that is specified & appears in the courseware
end (unicode|None): Course end date. Must contain timezone.
"""
self.display_coursenumber = display_coursenumber
self.display_organization = display_organization
self.display_name = display_name
self.id = course_locator # pylint: disable=invalid-name
self.location = course_locator.make_usage_key('course', 'course')
self.end = end
if end is not None and not isinstance(end, datetime):
self.end = dateutil.parser.parse(end)
@property
def display_org_with_default(self):
"""
Return a display organization if it has been specified, otherwise return the 'org' that
is in the location
"""
if self.display_organization:
return self.display_organization
return self.location.org
@property
def display_number_with_default(self):
"""
Return a display course number if it has been specified, otherwise return the 'course' that
is in the location
"""
if self.display_coursenumber:
return self.display_coursenumber
return self.location.course
def has_ended(self):
"""
Returns whether the course has ended.
"""
try:
return course_metadata_utils.has_course_ended(self.end)
except TypeError as e:
log.warning(
"Course '{course_id}' has an improperly formatted end date '{end_date}'. Error: '{err}'.".format(
course_id=str(self.id), end_date=self.end, err=e
)
)
modified_end = self.end.replace(tzinfo=ZoneInfo("UTC"))
return course_metadata_utils.has_course_ended(modified_end)