The learning_sequences app has its own model for Course Outlines. Prior to this commit, these course outlines were only populated by a management command in the learning_sequences app that queried modulestore. This commit does a few things: 1. Move the update_course_outline command to live in contentstore (i.e. Studio). This makes learning_sequences unaware of modulestore, and makes it easier for us to extract it from edx-platform (or to plug in different kinds of course outlines). 2. Add tests. 3. Add performance and debug logging to course outline creation. 4. Make course outline creation happen every time a course publish happens. This will allow us to start collecting data about how long building course outlines takes, and get error reporting around any content edge cases that break the course outline code.
202 lines
7.8 KiB
Python
202 lines
7.8 KiB
Python
"""
|
|
This is where Studio interacts with the learning_sequences application, which
|
|
is responsible for holding course outline data. Studio _pushes_ that data into
|
|
learning_sequences at publish time.
|
|
"""
|
|
from datetime import timezone
|
|
|
|
from edx_django_utils.monitoring import function_trace, set_custom_attribute
|
|
|
|
from openedx.core.djangoapps.content.learning_sequences.api import replace_course_outline
|
|
from openedx.core.djangoapps.content.learning_sequences.data import (
|
|
CourseLearningSequenceData,
|
|
CourseOutlineData,
|
|
CourseSectionData,
|
|
CourseVisibility,
|
|
ExamData,
|
|
VisibilityData,
|
|
)
|
|
from xmodule.modulestore import ModuleStoreEnum
|
|
from xmodule.modulestore.django import modulestore
|
|
|
|
|
|
class CourseStructureError(Exception):
|
|
"""
|
|
Raise this if we can't create an outline because of the course structure.
|
|
|
|
Courses built in Studio conform to a hierarchy that looks like:
|
|
Course -> Section (a.k.a. Chapter) -> Subsection (a.k.a. Sequence)
|
|
|
|
OLX imports are much more freeform and can generate unusual structures that
|
|
we won't know how to handle.
|
|
"""
|
|
|
|
|
|
def _check_sequence_fields(sequence):
|
|
"""
|
|
Raise CourseStructureError if `sequence` is missing a required field.
|
|
|
|
Do this instead of checking against specific block types to better future
|
|
proof ourselves against new sequence-types, aliases, changes in the way
|
|
dynamic mixing of XBlock types happens, as well as deprecation/removal of
|
|
the specific fields we care about. If it quacks like a duck...
|
|
"""
|
|
expected_fields = [
|
|
'display_name',
|
|
'hide_after_due',
|
|
'hide_from_toc',
|
|
'is_practice_exam',
|
|
'is_proctored_enabled',
|
|
'is_time_limited',
|
|
'visible_to_staff_only',
|
|
]
|
|
for field in expected_fields:
|
|
if not hasattr(sequence, field):
|
|
msg = (
|
|
f"Cannot create CourseOutline: Expected a Sequence at "
|
|
f"{sequence.location} (child of {sequence.parent}), "
|
|
f"but this object does not have sequence field {field}."
|
|
)
|
|
raise CourseStructureError(msg)
|
|
|
|
|
|
def _check_section_fields(section):
|
|
"""
|
|
Raise CourseStructureError if `section` is missing a required field.
|
|
|
|
Do this instead of checking against specific block types to better future
|
|
proof ourselves against new sequence-types, aliases, changes in the way
|
|
dynamic mixing of XBlock types happens, as well as deprecation/removal of
|
|
the specific fields we care about. If it quacks like a duck...
|
|
"""
|
|
expected_fields = [
|
|
'children',
|
|
'hide_from_toc',
|
|
'visible_to_staff_only',
|
|
]
|
|
for field in expected_fields:
|
|
if not hasattr(section, field):
|
|
msg = (
|
|
f"Cannot create CourseOutline: Expected a Section at "
|
|
f"{section.location} (child of {section.parent}), "
|
|
f"but this object does not have Section field {field}."
|
|
)
|
|
raise CourseStructureError(msg)
|
|
|
|
|
|
def _remove_version_info(usage_key):
|
|
"""
|
|
When we ask modulestore for the published branch in the Studio process
|
|
after catching a publish signal, the items that have been changed will
|
|
return UsageKeys that have full version information in their attached
|
|
CourseKeys. This makes them hash and serialize differently. We want to
|
|
strip this information and have everything use a CourseKey with no
|
|
version information attached.
|
|
|
|
The fact that this versioned CourseKey appears is likely an unintended
|
|
side-effect, rather than an intentional part of the API contract. It
|
|
also likely doesn't happen when the modulestore is being processed from
|
|
a different process than the one doing the writing (e.g. a celery task
|
|
running on any environment other than devstack). But stripping this
|
|
version information out is necessary to make devstack and tests work
|
|
properly.
|
|
"""
|
|
unversioned_course_key = usage_key.course_key.replace(branch=None, version_guid=None)
|
|
return usage_key.map_into_course(unversioned_course_key)
|
|
|
|
|
|
def _make_section_data(section):
|
|
"""
|
|
Generate a CourseSectionData from a SectionDescriptor.
|
|
|
|
This method does a lot of the work to convert modulestore fields to an input
|
|
that the learning_sequences app expects. It doesn't check for specific
|
|
classes (i.e. you could create your own Sequence-like XBlock), but it will
|
|
raise a CourseStructureError if anything you pass in is missing fields that
|
|
we expect in a SectionDescriptor or its SequenceDescriptor children.
|
|
"""
|
|
_check_section_fields(section)
|
|
|
|
sequences_data = []
|
|
for sequence in section.get_children():
|
|
_check_sequence_fields(sequence)
|
|
sequences_data.append(
|
|
CourseLearningSequenceData(
|
|
usage_key=_remove_version_info(sequence.location),
|
|
title=sequence.display_name,
|
|
inaccessible_after_due=sequence.hide_after_due,
|
|
exam=ExamData(
|
|
is_practice_exam=sequence.is_practice_exam,
|
|
is_proctored_enabled=sequence.is_proctored_enabled,
|
|
is_time_limited=sequence.is_time_limited,
|
|
),
|
|
visibility=VisibilityData(
|
|
hide_from_toc=sequence.hide_from_toc,
|
|
visible_to_staff_only=sequence.visible_to_staff_only,
|
|
),
|
|
)
|
|
)
|
|
|
|
section_data = CourseSectionData(
|
|
usage_key=_remove_version_info(section.location),
|
|
title=section.display_name,
|
|
sequences=sequences_data,
|
|
visibility=VisibilityData(
|
|
hide_from_toc=section.hide_from_toc,
|
|
visible_to_staff_only=section.visible_to_staff_only,
|
|
),
|
|
)
|
|
return section_data
|
|
|
|
|
|
@function_trace('get_outline_from_modulestore')
|
|
def get_outline_from_modulestore(course_key):
|
|
"""
|
|
Get a learning_sequence.data.CourseOutlineData for a param:course_key
|
|
"""
|
|
store = modulestore()
|
|
|
|
with store.branch_setting(ModuleStoreEnum.Branch.published_only, course_key):
|
|
course = store.get_course(course_key, depth=2)
|
|
sections_data = []
|
|
for section in course.get_children():
|
|
section_data = _make_section_data(section)
|
|
sections_data.append(section_data)
|
|
|
|
course_outline_data = CourseOutlineData(
|
|
course_key=course_key,
|
|
title=course.display_name,
|
|
|
|
# subtree_edited_on has a tzinfo of bson.tz_util.FixedOffset (which
|
|
# maps to UTC), but for consistency, we're going to use the standard
|
|
# python timezone.utc (which is what the learning_sequence app will
|
|
# return from MySQL). They will compare as equal.
|
|
published_at=course.subtree_edited_on.replace(tzinfo=timezone.utc),
|
|
|
|
# .course_version is a BSON obj, so we convert to str (MongoDB-
|
|
# specific objects don't go into CourseOutlineData).
|
|
published_version=str(course.course_version),
|
|
|
|
entrance_exam_id=course.entrance_exam_id,
|
|
days_early_for_beta=course.days_early_for_beta,
|
|
sections=sections_data,
|
|
self_paced=course.self_paced,
|
|
course_visibility=CourseVisibility(course.course_visibility),
|
|
)
|
|
return course_outline_data
|
|
|
|
|
|
def update_outline_from_modulestore(course_key):
|
|
"""
|
|
Update the CourseOutlineData for course_key in the learning_sequences with
|
|
ModuleStore data (i.e. what was most recently published in Studio).
|
|
"""
|
|
# Set the course_id attribute first so that if getting the information
|
|
# from the modulestore errors out, we still have the course_key reported in
|
|
# New Relic for easier trace debugging.
|
|
set_custom_attribute('course_id', str(course_key))
|
|
|
|
course_outline_data = get_outline_from_modulestore(course_key)
|
|
set_custom_attribute('num_sequences', len(course_outline_data.sequences))
|
|
replace_course_outline(course_outline_data)
|