This commit removes several waffle toggles that have been enabled on edx.org for years. It's time to remove the rollout gating for these features and enable them by default. This doesn't directly change any behavior. But it does create new database objects by default now and allows for enabling other schedule based features more easily. Specifically, the following toggles were affected. schedules.create_schedules_for_course - Waffle flag removed as always-enabled - We now always create a schedule when an enrollment is created schedules.send_updates_for_course - Waffle flag removed as always-enabled - Course update emails are sent as long as the ScheduleConfig allows it. - This is not a change in default behavior, because ScheduleConfig is off by default. dynamic_pacing.studio_course_update - Waffle switch removed as always-enabled - Course teams can now always edit course updates directly in Studio ScheduleConfig.create_schedules ScheduleConfig.hold_back_ratio - Model fields for rolling out the schedules feature - Schedules are now always created - This commit only removes references to these fields, they still exist in the database. A future commit will remove them entirely This commit also adds a new has_highlights field to CourseOverview. This is used to cache whether a course has highlights, used to decide which course update email behavior they get. Previously every enrollment had to dig into the modulestore to determine that.
281 lines
11 KiB
Python
281 lines
11 KiB
Python
# lint-amnesty, pylint: disable=missing-module-docstring
|
|
import logging
|
|
import time
|
|
|
|
import numpy as np
|
|
import six
|
|
from edxval.api import get_videos_for_course
|
|
from rest_framework.generics import GenericAPIView
|
|
from rest_framework.response import Response
|
|
from scipy import stats
|
|
|
|
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
|
|
from openedx.core.lib.cache_utils import request_cached
|
|
from openedx.core.lib.graph_traversals import traverse_pre_order
|
|
from xmodule.modulestore.django import modulestore
|
|
|
|
from .utils import course_author_access_required, get_bool_param
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@view_auth_classes()
|
|
class CourseQualityView(DeveloperErrorViewMixin, GenericAPIView):
|
|
"""
|
|
**Use Case**
|
|
|
|
**Example Requests**
|
|
|
|
GET /api/courses/v1/quality/{course_id}/
|
|
|
|
**GET Parameters**
|
|
|
|
A GET request may include the following parameters.
|
|
|
|
* all
|
|
* sections
|
|
* subsections
|
|
* units
|
|
* videos
|
|
* exclude_graded (boolean) - whether to exclude graded subsections in the subsections and units information.
|
|
|
|
**GET Response Values**
|
|
|
|
The HTTP 200 response has the following values.
|
|
|
|
* is_self_paced - whether the course is self-paced.
|
|
* sections
|
|
* total_number - number of sections in the course.
|
|
* total_visible - number of sections visible to learners in the course.
|
|
* number_with_highlights - number of sections that have at least one highlight entered.
|
|
* highlights_enabled - whether highlights are enabled in the course.
|
|
* subsections
|
|
* total_visible - number of subsections visible to learners in the course.
|
|
* num_with_one_block_type - number of visible subsections containing only one type of block.
|
|
* num_block_types - statistics for number of block types across all visible subsections.
|
|
* min
|
|
* max
|
|
* mean
|
|
* median
|
|
* mode
|
|
* units
|
|
* total_visible - number of units visible to learners in the course.
|
|
* num_blocks - statistics for number of block across all visible units.
|
|
* min
|
|
* max
|
|
* mean
|
|
* median
|
|
* mode
|
|
* videos
|
|
* total_number - number of video blocks in the course.
|
|
* num_with_val_id - number of video blocks that include video pipeline IDs.
|
|
* num_mobile_encoded - number of videos encoded through the video pipeline.
|
|
* durations - statistics for video duration across all videos encoded through the video pipeline.
|
|
* min
|
|
* max
|
|
* mean
|
|
* median
|
|
* mode
|
|
|
|
"""
|
|
@course_author_access_required
|
|
def get(self, request, course_key):
|
|
"""
|
|
Returns validation information for the given course.
|
|
"""
|
|
def _execute_method_and_log_time(log_time, func, *args):
|
|
"""
|
|
Call func passed in method with logging the time it took to complete.
|
|
Logging is temporary, we will remove this once we get required information.
|
|
"""
|
|
if log_time:
|
|
start_time = time.time()
|
|
output = func(*args)
|
|
log.info(u'[%s] completed in [%f]', func.__name__, (time.time() - start_time))
|
|
else:
|
|
output = func(*args)
|
|
return output
|
|
|
|
all_requested = get_bool_param(request, 'all', False)
|
|
|
|
store = modulestore()
|
|
with store.bulk_operations(course_key):
|
|
course = store.get_course(course_key, depth=self._required_course_depth(request, all_requested))
|
|
# Added for EDUCATOR-3660
|
|
course_key_harvard = str(course_key) == 'course-v1:HarvardX+SW12.1x+2016'
|
|
|
|
response = dict(
|
|
is_self_paced=course.self_paced,
|
|
)
|
|
if get_bool_param(request, 'sections', all_requested):
|
|
response.update(
|
|
sections=_execute_method_and_log_time(course_key_harvard, self._sections_quality, course)
|
|
)
|
|
if get_bool_param(request, 'subsections', all_requested):
|
|
response.update(
|
|
subsections=_execute_method_and_log_time(
|
|
course_key_harvard, self._subsections_quality, course, request
|
|
)
|
|
)
|
|
if get_bool_param(request, 'units', all_requested):
|
|
response.update(
|
|
units=_execute_method_and_log_time(course_key_harvard, self._units_quality, course, request)
|
|
)
|
|
if get_bool_param(request, 'videos', all_requested):
|
|
response.update(
|
|
videos=_execute_method_and_log_time(course_key_harvard, self._videos_quality, course)
|
|
)
|
|
|
|
return Response(response)
|
|
|
|
def _required_course_depth(self, request, all_requested): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
if get_bool_param(request, 'units', all_requested):
|
|
# The num_blocks metric for "units" requires retrieving all blocks in the graph.
|
|
return None
|
|
elif get_bool_param(request, 'subsections', all_requested):
|
|
# The num_block_types metric for "subsections" requires retrieving all blocks in the graph.
|
|
return None
|
|
elif get_bool_param(request, 'sections', all_requested):
|
|
return 1
|
|
else:
|
|
return 0
|
|
|
|
def _sections_quality(self, course):
|
|
sections, visible_sections = self._get_sections(course)
|
|
sections_with_highlights = [section for section in visible_sections if section.highlights]
|
|
return dict(
|
|
total_number=len(sections),
|
|
total_visible=len(visible_sections),
|
|
number_with_highlights=len(sections_with_highlights),
|
|
highlights_active_for_course=course.highlights_enabled_for_messaging,
|
|
highlights_enabled=True, # used to be controlled by a waffle switch, now just always enabled
|
|
)
|
|
|
|
def _subsections_quality(self, course, request): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
subsection_unit_dict = self._get_subsections_and_units(course, request)
|
|
num_block_types_per_subsection_dict = {}
|
|
for subsection_key, unit_dict in six.iteritems(subsection_unit_dict):
|
|
leaf_block_types_in_subsection = (
|
|
unit_info['leaf_block_types']
|
|
for unit_info in six.itervalues(unit_dict)
|
|
)
|
|
num_block_types_per_subsection_dict[subsection_key] = len(set().union(*leaf_block_types_in_subsection))
|
|
|
|
return dict(
|
|
total_visible=len(num_block_types_per_subsection_dict),
|
|
num_with_one_block_type=list(six.itervalues(num_block_types_per_subsection_dict)).count(1),
|
|
num_block_types=self._stats_dict(list(six.itervalues(num_block_types_per_subsection_dict))),
|
|
)
|
|
|
|
def _units_quality(self, course, request): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
subsection_unit_dict = self._get_subsections_and_units(course, request)
|
|
num_leaf_blocks_per_unit = [
|
|
unit_info['num_leaf_blocks']
|
|
for unit_dict in six.itervalues(subsection_unit_dict)
|
|
for unit_info in six.itervalues(unit_dict)
|
|
]
|
|
return dict(
|
|
total_visible=len(num_leaf_blocks_per_unit),
|
|
num_blocks=self._stats_dict(num_leaf_blocks_per_unit),
|
|
)
|
|
|
|
def _videos_quality(self, course): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
video_blocks_in_course = modulestore().get_items(course.id, qualifiers={'category': 'video'})
|
|
videos, __ = get_videos_for_course(course.id)
|
|
videos_in_val = list(videos)
|
|
video_durations = [video['duration'] for video in videos_in_val]
|
|
|
|
return dict(
|
|
total_number=len(video_blocks_in_course),
|
|
num_mobile_encoded=len(videos_in_val),
|
|
num_with_val_id=len([v for v in video_blocks_in_course if v.edx_video_id]),
|
|
durations=self._stats_dict(video_durations),
|
|
)
|
|
|
|
@classmethod
|
|
@request_cached()
|
|
def _get_subsections_and_units(cls, course, request):
|
|
"""
|
|
Returns {subsection_key: {unit_key: {num_leaf_blocks: <>, leaf_block_types: set(<>) }}}
|
|
for all visible subsections and units.
|
|
"""
|
|
_, visible_sections = cls._get_sections(course)
|
|
subsection_dict = {}
|
|
for section in visible_sections:
|
|
visible_subsections = cls._get_visible_children(section)
|
|
|
|
if get_bool_param(request, 'exclude_graded', False):
|
|
visible_subsections = [s for s in visible_subsections if not s.graded]
|
|
|
|
for subsection in visible_subsections:
|
|
unit_dict = {}
|
|
visible_units = cls._get_visible_children(subsection)
|
|
|
|
for unit in visible_units:
|
|
leaf_blocks = cls._get_leaf_blocks(unit)
|
|
unit_dict[unit.location] = dict(
|
|
num_leaf_blocks=len(leaf_blocks),
|
|
leaf_block_types=set(block.location.block_type for block in leaf_blocks),
|
|
)
|
|
|
|
subsection_dict[subsection.location] = unit_dict
|
|
return subsection_dict
|
|
|
|
@classmethod
|
|
@request_cached()
|
|
def _get_sections(cls, course):
|
|
return cls._get_all_children(course)
|
|
|
|
@classmethod
|
|
def _get_all_children(cls, parent): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
store = modulestore()
|
|
children = [store.get_item(child_usage_key) for child_usage_key in cls._get_children(parent)]
|
|
visible_children = [
|
|
c for c in children
|
|
if not c.visible_to_staff_only and not c.hide_from_toc
|
|
]
|
|
return children, visible_children
|
|
|
|
@classmethod
|
|
def _get_visible_children(cls, parent):
|
|
_, visible_chidren = cls._get_all_children(parent)
|
|
return visible_chidren
|
|
|
|
@classmethod
|
|
def _get_children(cls, parent): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
if not hasattr(parent, 'children'):
|
|
return []
|
|
else:
|
|
return parent.children
|
|
|
|
@classmethod
|
|
def _get_leaf_blocks(cls, unit): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
def leaf_filter(block):
|
|
return (
|
|
block.location.block_type not in ('chapter', 'sequential', 'vertical') and
|
|
len(cls._get_children(block)) == 0
|
|
)
|
|
|
|
return [
|
|
block for block in # lint-amnesty, pylint: disable=unnecessary-comprehension
|
|
traverse_pre_order(unit, cls._get_visible_children, leaf_filter)
|
|
]
|
|
|
|
def _stats_dict(self, data): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
if not data:
|
|
return dict(
|
|
min=None,
|
|
max=None,
|
|
mean=None,
|
|
median=None,
|
|
mode=None,
|
|
)
|
|
else:
|
|
return dict(
|
|
min=min(data),
|
|
max=max(data),
|
|
mean=np.around(np.mean(data)),
|
|
median=np.around(np.median(data)),
|
|
mode=stats.mode(data, axis=None)[0][0],
|
|
)
|