Skip to content

Commit

Permalink
feat: [AXIMST-584] create view for course navigation sidebar (#2511)
Browse files Browse the repository at this point in the history
* feat: [AXIMST-584] create view for course navigation sidebar

* test: [AXIMST-584] add tests for CourseSidebarBlocksView
  • Loading branch information
NiedielnitsevIvan authored Mar 12, 2024
1 parent 24659c8 commit 8292187
Show file tree
Hide file tree
Showing 4 changed files with 333 additions and 2 deletions.
3 changes: 2 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 Down
233 changes: 233 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,236 @@ 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-sidebar-blocks', 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-sidebar-blocks', 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})
def test_proctored_exam(self):
"""
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)

url = reverse('course-home:course-sidebar-blocks', 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['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'] == 'sequential')

# 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
88 changes: 88 additions & 0 deletions lms/djangoapps/course_home_api/outline/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
)
from lms.djangoapps.course_goals.models import CourseGoal
from lms.djangoapps.course_home_api.outline.serializers import (
CourseBlockSerializer,
OutlineTabSerializer,
)
from lms.djangoapps.course_home_api.utils import get_course_or_403
Expand Down Expand Up @@ -375,6 +376,93 @@ def finalize_response(self, request, response, *args, **kwargs):
return expose_header('Date', response)


class CourseSidebarBlocksView(RetrieveAPIView):
"""
**Use Cases**
Request details for the sidebar navigation of the course.
**Example Requests**
GET api/course_home/v1/sidebar/{course_key}
**Response Values**
For a good 200 response, the response will include:
blocks: List of serialized Course Block objects. Each serialization has the following fields:
id: (str) The usage ID of the block.
type: (str) The type of block. Possible values the names of any
XBlock type in the system, including custom blocks. Examples are
course, chapter, sequential, vertical, html, problem, video, and
discussion.
display_name: (str) The display name of the block.
lms_web_url: (str) The URL to the navigational container of the
xBlock on the web LMS.
children: (list) If the block has child blocks, a list of IDs of
the child blocks.
resume_block: (bool) Whether the block is the resume block
has_scheduled_content: (bool) Whether the block has more content scheduled for the future
**Returns**
* 200 on success.
* 403 if the user does not currently have access to the course and should be redirected.
* 404 if the course is not available or cannot be seen.
"""

authentication_classes = (
JwtAuthentication,
BearerAuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)

serializer_class = CourseBlockSerializer

def get(self, request, *args, **kwargs):
"""
Get the visible course blocks (from course to vertical types) for the given course.
"""
course_key_string = kwargs.get('course_key_string')
course_key = CourseKey.from_string(course_key_string)
course = get_course_or_403(request.user, 'load', course_key, check_if_enrolled=False)

masquerade_object, request.user = setup_masquerade(
request,
course_key,
staff_access=has_access(request.user, 'staff', course_key),
reset_masquerade_data=True,
)

user_is_masquerading = is_masquerading(request.user, course_key, course_masquerade=masquerade_object)

enrollment = CourseEnrollment.get_enrollment(request.user, course_key)
allow_anonymous = COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course_key)
allow_public = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC
allow_public_outline = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC_OUTLINE
course_blocks = None

is_staff = bool(has_access(request.user, 'staff', course_key))
if getattr(enrollment, 'is_active', False) or is_staff:
course_blocks = get_course_outline_block_tree(request, course_key_string, request.user)
elif allow_public_outline or allow_public or user_is_masquerading:
course_blocks = get_course_outline_block_tree(request, course_key_string, None)

if course_blocks:
user_course_outline = get_user_course_outline(course_key, request.user, datetime.now(tz=timezone.utc))
available_section_ids = {str(section.usage_key) for section in user_course_outline.sections}
available_sequence_ids = {str(usage_key) for usage_key in user_course_outline.sequences}

course_blocks['children'] = [
chapter_data for chapter_data in course_blocks.get('children', [])
if chapter_data['id'] in available_section_ids
]

for chapter_data in course_blocks['children']:
chapter_data['children'] = [
seq_data for seq_data in chapter_data['children']
if (seq_data['id'] in available_sequence_ids or seq_data['type'] != 'sequential')
] if 'children' in chapter_data else []

context = self.get_serializer_context()
context['include_vertical'] = True
serializer = self.get_serializer_class()(course_blocks, context=context)

return Response(serializer.data)


@api_view(['POST'])
@permission_classes((IsAuthenticated,))
def dismiss_welcome_message(request): # pylint: disable=missing-function-docstring
Expand Down
11 changes: 10 additions & 1 deletion lms/djangoapps/course_home_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
from lms.djangoapps.course_home_api.course_metadata.views import CourseHomeMetadataView
from lms.djangoapps.course_home_api.dates.views import DatesTabView
from lms.djangoapps.course_home_api.outline.views import (
OutlineTabView, dismiss_welcome_message, save_course_goal, unsubscribe_from_course_goal_by_token,
CourseSidebarBlocksView,
OutlineTabView,
dismiss_welcome_message,
save_course_goal,
unsubscribe_from_course_goal_by_token,
)
from lms.djangoapps.course_home_api.progress.views import ProgressTabView

Expand Down Expand Up @@ -44,6 +48,11 @@
OutlineTabView.as_view(),
name='outline-tab'
),
re_path(
fr'sidebar/{settings.COURSE_KEY_PATTERN}',
CourseSidebarBlocksView.as_view(),
name='course-sidebar-blocks'
),
re_path(
r'dismiss_welcome_message',
dismiss_welcome_message,
Expand Down

0 comments on commit 8292187

Please sign in to comment.