When considering if an assignment is past due for the dates tab, only look at the scored and graded units in the subsection (i.e. ignore reading and video units). This still leaves the "complete" field alone -- i.e. those subsections will still be left incomplete generally. But for assignment-focused tasks, they will instead be considered complete.
329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""
|
|
Common utilities for the course experience, including course outline.
|
|
"""
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
from completion.models import BlockCompletion
|
|
from django.db.models import Q
|
|
from django.utils import timezone
|
|
from opaque_keys.edx.keys import CourseKey
|
|
from six.moves import range
|
|
|
|
from course_modes.models import CourseMode
|
|
from lms.djangoapps.course_api.blocks.api import get_blocks
|
|
from lms.djangoapps.course_blocks.api import get_course_blocks
|
|
from lms.djangoapps.course_blocks.utils import get_student_module_as_dict
|
|
from lms.djangoapps.courseware.access import has_access
|
|
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
|
from openedx.core.lib.cache_utils import request_cached
|
|
from openedx.features.course_experience import RELATIVE_DATES_FLAG
|
|
from student.models import CourseEnrollment
|
|
from xmodule.modulestore.django import modulestore
|
|
|
|
|
|
@request_cached()
|
|
def get_course_outline_block_tree(request, course_id, user=None, allow_start_dates_in_future=False):
|
|
"""
|
|
Returns the root block of the course outline, with children as blocks.
|
|
|
|
allow_start_dates_in_future (bool): When True, will allow blocks to be
|
|
returned that can bypass the StartDateTransformer's filter to show
|
|
blocks with start dates in the future.
|
|
"""
|
|
|
|
assert user is None or user.is_authenticated
|
|
|
|
def populate_children(block, all_blocks):
|
|
"""
|
|
Replace each child id with the full block for the child.
|
|
|
|
Given a block, replaces each id in its children array with the full
|
|
representation of that child, which will be looked up by id in the
|
|
passed all_blocks dict. Recursively do the same replacement for children
|
|
of those children.
|
|
"""
|
|
children = block.get('children', [])
|
|
|
|
for i in range(len(children)):
|
|
child_id = block['children'][i]
|
|
child_detail = populate_children(all_blocks[child_id], all_blocks)
|
|
block['children'][i] = child_detail
|
|
|
|
return block
|
|
|
|
def set_last_accessed_default(block):
|
|
"""
|
|
Set default of False for resume_block on all blocks.
|
|
"""
|
|
block['resume_block'] = False
|
|
block['complete'] = False
|
|
for child in block.get('children', []):
|
|
set_last_accessed_default(child)
|
|
|
|
def mark_blocks_completed(block, user, course_key):
|
|
"""
|
|
Walk course tree, marking block completion.
|
|
Mark 'most recent completed block as 'resume_block'
|
|
|
|
"""
|
|
last_completed_child_position = BlockCompletion.get_latest_block_completed(user, course_key)
|
|
|
|
if last_completed_child_position:
|
|
# Mutex w/ NOT 'course_block_completions'
|
|
recurse_mark_complete(
|
|
course_block_completions=BlockCompletion.get_learning_context_completions(user, course_key),
|
|
latest_completion=last_completed_child_position,
|
|
block=block
|
|
)
|
|
|
|
def recurse_mark_complete(course_block_completions, latest_completion, block):
|
|
"""
|
|
Helper function to walk course tree dict,
|
|
marking blocks as 'complete' and 'last_complete'
|
|
|
|
If all blocks are complete, mark parent block complete
|
|
mark parent blocks of 'last_complete' as 'last_complete'
|
|
|
|
:param course_block_completions: dict[course_completion_object] = completion_value
|
|
:param latest_completion: course_completion_object
|
|
:param block: course_outline_root_block block object or child block
|
|
|
|
:return:
|
|
block: course_outline_root_block block object or child block
|
|
"""
|
|
block_key = block.serializer.instance
|
|
|
|
if course_block_completions.get(block_key):
|
|
block['complete'] = True
|
|
if block_key == latest_completion.full_block_key:
|
|
block['resume_block'] = True
|
|
|
|
if block.get('children'):
|
|
for idx in range(len(block['children'])):
|
|
recurse_mark_complete(
|
|
course_block_completions,
|
|
latest_completion,
|
|
block=block['children'][idx]
|
|
)
|
|
if block['children'][idx].get('resume_block') is True:
|
|
block['resume_block'] = True
|
|
|
|
completable_blocks = [child for child in block['children']
|
|
if child.get('type') != 'discussion']
|
|
if all(child.get('complete') for child in completable_blocks):
|
|
block['complete'] = True
|
|
|
|
def mark_last_accessed(user, course_key, block):
|
|
"""
|
|
Recursively marks the branch to the last accessed block.
|
|
"""
|
|
block_key = block.serializer.instance
|
|
student_module_dict = get_student_module_as_dict(user, course_key, block_key)
|
|
|
|
last_accessed_child_position = student_module_dict.get('position')
|
|
if last_accessed_child_position and block.get('children'):
|
|
block['resume_block'] = True
|
|
if last_accessed_child_position <= len(block['children']):
|
|
last_accessed_child_block = block['children'][last_accessed_child_position - 1]
|
|
last_accessed_child_block['resume_block'] = True
|
|
mark_last_accessed(user, course_key, last_accessed_child_block)
|
|
else:
|
|
# We should be using an id in place of position for last accessed.
|
|
# However, while using position, if the child block is no longer accessible
|
|
# we'll use the last child.
|
|
block['children'][-1]['resume_block'] = True
|
|
|
|
def recurse_mark_scored(block):
|
|
"""
|
|
Mark this block as 'scored' if any of its descendents are 'scored' (that is, 'has_score' and 'weight' > 0).
|
|
"""
|
|
is_scored = block.get('has_score', False) and block.get('weight', 1) > 0
|
|
# Use a list comprehension to force the recursion over all children, rather than just stopping
|
|
# at the first child that is scored.
|
|
children_scored = any([recurse_mark_scored(child) for child in block.get('children', [])])
|
|
if is_scored or children_scored:
|
|
block['scored'] = True
|
|
return True
|
|
else:
|
|
block['scored'] = False
|
|
return False
|
|
|
|
def recurse_num_graded_problems(block):
|
|
"""
|
|
Marks each block with the number of graded and scored leaf blocks below it as 'num_graded_problems'
|
|
"""
|
|
is_scored = block.get('has_score') and block.get('weight', 1) > 0
|
|
is_graded = block.get('graded')
|
|
is_countable = block.get('type') not in ('lti', 'lti_consumer')
|
|
is_graded_problem = is_scored and is_graded and is_countable
|
|
|
|
num_graded_problems = 1 if is_graded_problem else 0
|
|
num_graded_problems += sum(recurse_num_graded_problems(child) for child in block.get('children', []))
|
|
|
|
block['num_graded_problems'] = num_graded_problems
|
|
return num_graded_problems
|
|
|
|
def recurse_mark_auth_denial(block):
|
|
"""
|
|
Mark this block as 'scored' if any of its descendents are 'scored' (that is, 'has_score' and 'weight' > 0).
|
|
"""
|
|
own_denial_reason = {block['authorization_denial_reason']} if 'authorization_denial_reason' in block else set()
|
|
# Use a list comprehension to force the recursion over all children, rather than just stopping
|
|
# at the first child that is scored.
|
|
child_denial_reasons = own_denial_reason.union(
|
|
*(recurse_mark_auth_denial(child) for child in block.get('children', []))
|
|
)
|
|
if child_denial_reasons:
|
|
block['all_denial_reasons'] = child_denial_reasons
|
|
return child_denial_reasons
|
|
|
|
course_key = CourseKey.from_string(course_id)
|
|
course_usage_key = modulestore().make_course_usage_key(course_key)
|
|
|
|
# Deeper query for course tree traversing/marking complete
|
|
# and last completed block
|
|
block_types_filter = [
|
|
'course',
|
|
'chapter',
|
|
'sequential',
|
|
'vertical',
|
|
'html',
|
|
'problem',
|
|
'video',
|
|
'discussion',
|
|
'drag-and-drop-v2',
|
|
'poll',
|
|
'word_cloud',
|
|
'lti',
|
|
'lti_consumer',
|
|
]
|
|
all_blocks = get_blocks(
|
|
request,
|
|
course_usage_key,
|
|
user=user,
|
|
nav_depth=3,
|
|
requested_fields=[
|
|
'children',
|
|
'display_name',
|
|
'type',
|
|
'start',
|
|
'contains_gated_content',
|
|
'due',
|
|
'graded',
|
|
'has_score',
|
|
'weight',
|
|
'special_exam_info',
|
|
'show_gated_sections',
|
|
'format'
|
|
],
|
|
block_types_filter=block_types_filter,
|
|
allow_start_dates_in_future=allow_start_dates_in_future,
|
|
)
|
|
|
|
course_outline_root_block = all_blocks['blocks'].get(all_blocks['root'], None)
|
|
if course_outline_root_block:
|
|
populate_children(course_outline_root_block, all_blocks['blocks'])
|
|
recurse_mark_scored(course_outline_root_block)
|
|
recurse_num_graded_problems(course_outline_root_block)
|
|
recurse_mark_auth_denial(course_outline_root_block)
|
|
if user:
|
|
set_last_accessed_default(course_outline_root_block)
|
|
mark_blocks_completed(
|
|
block=course_outline_root_block,
|
|
user=user,
|
|
course_key=course_key
|
|
)
|
|
return course_outline_root_block
|
|
|
|
|
|
def get_resume_block(block):
|
|
"""
|
|
Gets the deepest block marked as 'resume_block'.
|
|
|
|
"""
|
|
if block.get('authorization_denial_reason') or not block['resume_block']:
|
|
return None
|
|
if not block.get('children'):
|
|
return block
|
|
|
|
for child in block['children']:
|
|
resume_block = get_resume_block(child)
|
|
if resume_block:
|
|
return resume_block
|
|
return block
|
|
|
|
|
|
def dates_banner_should_display(course_key, user):
|
|
"""
|
|
Return whether or not the reset banner should display,
|
|
determined by whether or not a course has any past-due,
|
|
incomplete sequentials and which enrollment mode is being
|
|
dealt with for the current user and course.
|
|
|
|
Returns:
|
|
(missed_deadlines, missed_gated_content):
|
|
missed_deadlines is True if the user has missed any graded content deadlines
|
|
missed_gated_content is True if the first content that the user missed was gated content
|
|
"""
|
|
if not RELATIVE_DATES_FLAG.is_enabled(course_key):
|
|
return False, False
|
|
|
|
course_overview = CourseOverview.objects.get(id=str(course_key))
|
|
course_end_date = getattr(course_overview, 'end_date', None)
|
|
is_self_paced = getattr(course_overview, 'self_paced', False)
|
|
|
|
# Only display the banner for self-paced courses
|
|
if not is_self_paced:
|
|
return False, False
|
|
|
|
# Only display the banner for enrolled users
|
|
if not CourseEnrollment.is_enrolled(user, course_key):
|
|
return False, False
|
|
|
|
# Don't display the banner for course staff
|
|
is_course_staff = bool(
|
|
user and course_overview and has_access(user, 'staff', course_overview, course_overview.id)
|
|
)
|
|
if is_course_staff:
|
|
return False, False
|
|
|
|
# Don't display the banner if the course has ended
|
|
if course_end_date and course_end_date < timezone.now():
|
|
return False, False
|
|
|
|
store = modulestore()
|
|
course_usage_key = store.make_course_usage_key(course_key)
|
|
block_data = get_course_blocks(user, course_usage_key, include_completion=True)
|
|
for section_key in block_data.get_children(course_usage_key):
|
|
for subsection_key in block_data.get_children(section_key):
|
|
subsection_due_date = block_data.get_xblock_field(subsection_key, 'due', None)
|
|
if (subsection_due_date and subsection_due_date < timezone.now() and
|
|
not is_block_structure_complete_for_assignments(block_data, subsection_key)):
|
|
# Display the banner if the due date for an incomplete graded subsection has passed
|
|
return True, block_data.get_xblock_field(subsection_key, 'contains_gated_content', False)
|
|
|
|
# Don't display the banner if there were no missed deadlines
|
|
return False, False
|
|
|
|
|
|
def is_block_structure_complete_for_assignments(block_data, block_key):
|
|
"""
|
|
Considers a block complete only if all scored & graded leaf blocks are complete.
|
|
|
|
This is different from the normal `complete` flag because children of the block that are informative (like
|
|
readings or videos) do not count. We only care about actual homework content.
|
|
"""
|
|
children = block_data.get_children(block_key)
|
|
if children:
|
|
return all(is_block_structure_complete_for_assignments(block_data, child_key) for child_key in children)
|
|
|
|
complete = block_data.get_xblock_field(block_key, 'complete', False)
|
|
graded = block_data.get_xblock_field(block_key, 'graded', False)
|
|
has_score = block_data.get_xblock_field(block_key, 'has_score', False)
|
|
weight = block_data.get_xblock_field(block_key, 'weight', 1)
|
|
scored = has_score and (weight is None or weight > 0)
|
|
|
|
return complete or not graded or not scored
|