Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [FC-0056] Implement Sidebar Navigation #34457

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
28 changes: 27 additions & 1 deletion lms/djangoapps/course_home_api/outline/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ class CourseBlockSerializer(serializers.Serializer):
def get_blocks(self, block): # pylint: disable=missing-function-docstring
block_key = block['id']
block_type = block['type']
children = block.get('children', []) if block_type != 'sequential' else [] # Don't descend past sequential
last_parent_block_type = 'vertical' if self.context.get('include_vertical') else 'sequential'
children = block.get('children', []) if block_type != last_parent_block_type else []
description = block.get('format')
display_name = block['display_name']
enable_links = self.context.get('enable_links')
Expand All @@ -35,10 +36,16 @@ def get_blocks(self, block): # pylint: disable=missing-function-docstring
if graded and scored:
icon = 'fa-pencil-square-o'

if block_type == 'vertical':
icon = self.get_vertical_icon_class(block)

if 'special_exam_info' in block:
description = block['special_exam_info'].get('short_description')
icon = block['special_exam_info'].get('suggested_icon', 'fa-pencil-square-o')

if self.context.get('enable_prerequisite_block_type', False) and block.get('accessible') is False:
block_type = 'lock'

serialized = {
block_key: {
'children': [child['id'] for child in children],
Expand All @@ -57,10 +64,29 @@ def get_blocks(self, block): # pylint: disable=missing-function-docstring
'hide_from_toc': block.get('hide_from_toc'),
},
}
if 'special_exam_info' in self.context.get('extra_fields', []) and block.get('special_exam_info'):
serialized[block_key]['special_exam_info'] = block.get('special_exam_info').get('short_description')

for child in children:
serialized.update(self.get_blocks(child))
return serialized

@staticmethod
def get_vertical_icon_class(block):
"""
Get the icon class for a vertical block based priority of child blocks types.
Currently, the priority for the icon is as follows:
problem
video
"""
children = block.get('children', [])
child_classes = {child.get('type') for child in children}
if 'problem' in child_classes:
return 'problem'
if 'video' in child_classes:
return 'video'
return 'other'


class CourseGoalsSerializer(serializers.Serializer):
"""
Expand Down
239 changes: 239 additions & 0 deletions lms/djangoapps/course_home_api/outline/tests/test_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,242 @@ def test_cannot_enroll_if_full(self):
self.update_course_and_overview()
CourseEnrollment.enroll(UserFactory(), self.course.id) # grr, some rando took our spot!
self.assert_can_enroll(False)


@ddt.ddt
class SidebarBlocksTestViews(BaseCourseHomeTests):
"""
Tests for the Course Sidebar Blocks API
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.chapter = ''
self.sequential = ''
self.vertical = ''
self.ungraded_sequential = ''
self.ungraded_vertical = ''
self.url = ''

def setUp(self):
super().setUp()
self.url = reverse('course-home:course-navigation', args=[self.course.id])

def update_course_and_overview(self):
"""
Update the course and course overview records.
"""
self.update_course(self.course, self.user.id)
CourseOverview.load_from_module_store(self.course.id)

def add_blocks_to_course(self):
"""
Add test blocks to the self course.
"""
with self.store.bulk_operations(self.course.id):
self.chapter = BlockFactory.create(category='chapter', parent_location=self.course.location)
self.sequential = BlockFactory.create(
display_name='Test',
category='sequential',
graded=True,
has_score=True,
parent_location=self.chapter.location
)
self.vertical = BlockFactory.create(
category='problem',
graded=True,
has_score=True,
parent_location=self.sequential.location
)
self.ungraded_sequential = BlockFactory.create(
display_name='Ungraded',
category='sequential',
parent_location=self.chapter.location
)
self.ungraded_vertical = BlockFactory.create(
category='problem',
parent_location=self.ungraded_sequential.location
)
update_outline_from_modulestore(self.course.id)

@ddt.data(CourseMode.AUDIT, CourseMode.VERIFIED)
def test_get_authenticated_enrolled_user(self, enrollment_mode):
"""
Test that the API returns the correct data for an authenticated, enrolled user.
"""
self.add_blocks_to_course()
CourseEnrollment.enroll(self.user, self.course.id, enrollment_mode)

response = self.client.get(self.url)
assert response.status_code == 200

chapter_data = response.data['blocks'][str(self.chapter.location)]
assert str(self.sequential.location) in chapter_data['children']

sequential_data = response.data['blocks'][str(self.sequential.location)]
assert str(self.vertical.location) in sequential_data['children']

vertical_data = response.data['blocks'][str(self.vertical.location)]
assert vertical_data['children'] == []

@ddt.data(True, False)
def test_get_authenticated_user_not_enrolled(self, has_previously_enrolled):
"""
Test that the API returns an empty response for an authenticated user who is not enrolled in the course.
"""
if has_previously_enrolled:
CourseEnrollment.enroll(self.user, self.course.id)
CourseEnrollment.unenroll(self.user, self.course.id)

response = self.client.get(self.url)
assert response.status_code == 200
assert response.data == {}

def test_get_unauthenticated_user(self):
"""
Test that the API returns an empty response for an unauthenticated user.
"""
self.client.logout()
response = self.client.get(self.url)

assert response.status_code == 200
assert response.data.get('blocks') is None

def test_course_staff_can_see_non_user_specific_content_in_masquerade(self):
"""
Test that course staff can see the outline and other non-user-specific content when masquerading as a learner
"""
instructor = UserFactory(username='instructor', email='instructor@example.com', password='foo', is_staff=False)
CourseInstructorRole(self.course.id).add_users(instructor)
self.client.login(username=instructor, password='foo')
self.update_masquerade(role='student')
response = self.client.get(self.url)
assert response.data['blocks'] is not None

def test_get_unknown_course(self):
"""
Test that the API returns a 404 when the course is not found.
"""
url = reverse('course-home:course-navigation', args=['course-v1:unknown+course+2T2020'])
response = self.client.get(url)
assert response.status_code == 404

@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True})
@patch('lms.djangoapps.course_api.blocks.transformers.milestones.get_attempt_status_summary')
def test_proctored_exam(self, mock_summary):
"""
Test that the API returns the correct data for a proctored exam.
"""
course = CourseFactory.create(
org='edX',
course='900',
run='test_run',
enable_proctored_exams=True,
proctoring_provider=settings.PROCTORING_BACKENDS['DEFAULT'],
)
chapter = BlockFactory.create(parent=course, category='chapter', display_name='Test Section')
sequence = BlockFactory.create(
parent=chapter,
category='sequential',
display_name='Test Proctored Exam',
graded=True,
is_time_limited=True,
default_time_limit_minutes=10,
is_practice_exam=True,
due=datetime.now(),
exam_review_rules='allow_use_of_paper',
hide_after_due=False,
is_onboarding_exam=False,
)
sequence.is_proctored_exam = True
update_outline_from_modulestore(course.id)
CourseEnrollment.enroll(self.user, course.id)
mock_summary.return_value = {
'short_description': 'My Exam',
'suggested_icon': 'fa-foo-bar',
}

url = reverse('course-home:course-navigation', args=[course.id])
response = self.client.get(url)
assert response.status_code == 200

exam_data = response.data['blocks'][str(sequence.location)]
assert not exam_data['complete']
assert exam_data['display_name'] == 'Test Proctored Exam'
assert exam_data['special_exam_info'] == 'My Exam'
assert exam_data['due'] is not None

def test_assignment(self):
"""
Test that the API returns the correct data for an assignment.
"""
self.add_blocks_to_course()
CourseEnrollment.enroll(self.user, self.course.id)

response = self.client.get(self.url)
assert response.status_code == 200

exam_data = response.data['blocks'][str(self.sequential.location)]
assert exam_data['display_name'] == 'Test (1 Question)'
assert exam_data['icon'] == 'fa-pencil-square-o'
assert str(self.vertical.location) in exam_data['children']

ungraded_data = response.data['blocks'][str(self.ungraded_sequential.location)]
assert ungraded_data['display_name'] == 'Ungraded'
assert ungraded_data['icon'] is None
assert str(self.ungraded_vertical.location) in ungraded_data['children']

@override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, active=True)
@ddt.data(*itertools.product(
[True, False], [True, False], [None, COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE]
))
@ddt.unpack
def test_visibility(self, is_enrolled, is_staff, course_visibility):
"""
Test that the API returns the correct data based on the user's enrollment status and the course's visibility.
"""
if is_enrolled:
CourseEnrollment.enroll(self.user, self.course.id)
if is_staff:
self.user.is_staff = True
self.user.save()
if course_visibility:
self.course.course_visibility = course_visibility
self.update_course_and_overview()

show_enrolled = is_enrolled or is_staff
is_public = course_visibility == COURSE_VISIBILITY_PUBLIC
is_public_outline = course_visibility == COURSE_VISIBILITY_PUBLIC_OUTLINE

data = self.client.get(self.url).data
if not (show_enrolled or is_public or is_public_outline):
assert data == {}
else:
assert (data['blocks'] is not None) == (show_enrolled or is_public or is_public_outline)

def test_hide_learning_sequences(self):
"""
Check that Learning Sequences filters out sequences.
"""
CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED)
response = self.client.get(self.url)
assert response.status_code == 200

blocks = response.data['blocks']
seq_block_id = next(block_id for block_id, block in blocks.items() if block['type'] in ('sequential', 'lock'))

# With a course outline loaded, the same sequence is removed.
new_learning_seq_outline = CourseOutlineData(
course_key=self.course.id,
title='Test Course Outline!',
published_at=datetime(2021, 6, 14, tzinfo=timezone.utc),
published_version='5ebece4b69dd593d82fe2022',
entrance_exam_id=None,
days_early_for_beta=None,
sections=[],
self_paced=False,
course_visibility=CourseVisibility.PRIVATE
)
replace_course_outline(new_learning_seq_outline)
blocks = self.client.get(self.url).data['blocks']
assert seq_block_id not in blocks