Skip to content

Commit

Permalink
feat: [FC-0056] Implement Sidebar Navigation
Browse files Browse the repository at this point in the history
  • Loading branch information
NiedielnitsevIvan committed Apr 26, 2024
1 parent 9630d40 commit 8f23703
Show file tree
Hide file tree
Showing 5 changed files with 504 additions and 2 deletions.
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='[email protected]', 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
Loading

0 comments on commit 8f23703

Please sign in to comment.