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: [AXIMST-584] create view for course navigation sidebar #2511

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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading