Files
edx-platform/lms/djangoapps/mobile_api/course_info/views.py

448 lines
18 KiB
Python

"""
Views for course info API
"""
import logging
from typing import Dict, Optional, Union
import django
from django.contrib.auth import get_user_model
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import generics, status
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
from common.djangoapps.static_replace import make_static_urls_absolute
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.models import User as StudentUser
from lms.djangoapps.course_api.blocks.views import BlocksInCourseView
from lms.djangoapps.course_goals.models import UserActivity
from lms.djangoapps.courseware.courses import get_assignments_grades, get_course_info_section_block
from lms.djangoapps.mobile_api.course_info.constants import BLOCK_STRUCTURE_CACHE_TIMEOUT
from lms.djangoapps.mobile_api.course_info.serializers import (
CourseAccessSerializer,
CourseDetailSerializer,
CourseInfoOverviewSerializer,
MobileCourseEnrollmentSerializer
)
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.video_pipeline.config.waffle import DEPRECATE_YOUTUBE
from openedx.core.lib.api.view_utils import view_auth_classes
from openedx.core.lib.xblock_utils import get_course_update_items
from openedx.features.course_experience import ENABLE_COURSE_GOALS
from ..decorators import mobile_course_access, mobile_view
from .utils import get_user_certificate_download_url
User = get_user_model()
log = logging.getLogger(__name__)
UserType = Union[django.contrib.auth.models.User, django.contrib.auth.models.AnonymousUser, StudentUser]
@mobile_view()
class CourseUpdatesList(generics.ListAPIView):
"""
**Use Case**
Get the content for course updates.
**Example Request**
GET /api/mobile/v0.5/course_info/{course_id}/updates
**Response Values**
If the request is successful, the request returns an HTTP 200 "OK"
response along with an array of course updates. Each course update
contains the following values.
* content: The content, as an HTML string, of the course update.
* date: The date of the course update.
* id: The unique identifier of the update.
* status: Whether the update is visible or not.
"""
@mobile_course_access()
def list(self, request, course, *args, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
course_updates_block = get_course_info_section_block(request, request.user, course, 'updates')
update_items = get_course_update_items(course_updates_block)
updates_to_show = [
update for update in update_items
if update.get("status") != "deleted"
]
for item in updates_to_show:
item['content'] = apply_wrappers_to_content(item['content'], course_updates_block, request)
return Response(updates_to_show)
@mobile_view()
class CourseHandoutsList(generics.ListAPIView):
"""
**Use Case**
Get the HTML for course handouts.
**Example Request**
GET /api/mobile/v0.5/course_info/{course_id}/handouts
**Response Values**
If the request is successful, the request returns an HTTP 200 "OK"
response along with the following value.
* handouts_html: The HTML for course handouts.
"""
@mobile_course_access()
def list(self, request, course, *args, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
course_handouts_block = get_course_info_section_block(request, request.user, course, 'handouts')
if course_handouts_block:
if course_handouts_block.data == "<ol></ol>":
handouts_html = None
else:
handouts_html = apply_wrappers_to_content(course_handouts_block.data, course_handouts_block, request)
return Response({'handouts_html': handouts_html})
else:
# course_handouts_block could be None if there are no handouts
return Response({'handouts_html': None})
def apply_wrappers_to_content(content, block, request):
"""
Updates a piece of html content with the filter functions stored in its module system, then replaces any
static urls with absolute urls.
Args:
content: The html content to which to apply the content wrappers generated for this module system.
block: The block containing a reference to the module system which contains functions to apply to the
content. These functions include:
* Replacing static url's
* Replacing course url's
* Replacing jump to id url's
request: The request, used to replace static URLs with absolute URLs.
Returns: A piece of html content containing the original content updated by each wrapper.
"""
content = block.runtime.service(block, "replace_urls").replace_urls(content)
return make_static_urls_absolute(request, content)
@mobile_view()
class CourseGoalsRecordUserActivity(APIView):
"""
API that allows the mobile_apps to record activity for course goals to the user activity table
"""
def post(self, request, *args, **kwargs):
"""
Handle the POST request
Populate the user activity table.
"""
user_id = request.data.get('user_id')
course_key = request.data.get('course_key')
if not user_id or not course_key:
log.error('User id and course key are required. %s %s', user_id, course_key)
return Response(
'User id and course key are required',
status=status.HTTP_400_BAD_REQUEST,
)
try:
user_id = int(user_id)
user = User.objects.get(id=user_id)
except User.DoesNotExist:
log.error('Provided user id does not correspond to an existing user %s', user_id)
return Response(
'Provided user id does not correspond to an existing user',
status=status.HTTP_400_BAD_REQUEST,
)
try:
course_key = CourseKey.from_string(course_key)
except InvalidKeyError:
log.error('Provided course key is not valid %s', course_key)
return Response(
'Provided course key is not valid',
status=status.HTTP_400_BAD_REQUEST,
)
if not ENABLE_COURSE_GOALS.is_enabled(course_key):
log.warning('For this mobile request, user activity is not enabled for this user {} and course {}'.format(
str(user_id), str(course_key))
)
return Response(status=(200))
# Populate user activity for tracking progress towards a user's course goals
UserActivity.record_user_activity(user, course_key)
return Response(status=(200))
@view_auth_classes(is_authenticated=False)
class BlocksInfoInCourseView(BlocksInCourseView):
"""
**Use Case**
This API endpoint is specifically optimized for the course homepage on Mobile Apps.
The endpoint returns the blocks in the course according to the requesting user's access level.
Additionally, response encompasses info fields with information about the course,
including certificate URL, media dictionary with course image URLs, start and end dates for the course.
**Example requests**:
This api works with all versions {api_version}, you can use: v0.5, v1, v2 or v3
GET /api/mobile/{api_version}/course_info/blocks/?course_id=<course_id>
GET /api/mobile/{api_version}/course_info/blocks/?course_id=<course_id>
&username=anjali
&depth=all
&requested_fields=graded,format,student_view_multi_device,lti_url
&block_counts=video
&student_view_data=video
&block_types_filter=problem,html
**Parameters:**
username (str): The username of the specified user for whom the course data
is being accessed.
depth (integer, str, None): Optional number of blocks you receive in response
course nesting depth, you can get only sections, sections and subsections,
or provide string 'all' to receive all blocks of the course.
requested_field (list): Optional list of names of additional fields to return for each block.
Supported fields can be found in transformers.SUPPORTED_FIELDS.
block_counts (list): Optional list of names of block types for which an aggregated count
of blocks is returned.
student_view_data (list): Optional list of names of block types for
which student_view_data is returned.
block_types_filter (list): Filter by block types:
'video', 'discussion', 'html', 'chapter', 'sequential', 'vertical'.
return_type (list, dict): Optional list or dictionary of block's fields based on 'return_type'.
**Response example**
Body consists of the following fields, you received this response if you use
'return_type=dict' in query params:
root: (str) The ID of the root node of the requested course block structure.\
blocks: (dict) A dictionary or list, based on the value of the
"return_type" parameter. Maps block usage IDs to a collection of
information about each block. Each block contains the following
fields.
id: (str) The Course's id (Course Run key)
name: (str) The course's name
number: (str) The course's number
org: (str) The course's organisation
start: (str) Date the course begins, in ISO 8601 notation
start_display: (str) Readably formatted start of the course
start_type: (str) Hint describing how `start_display` is set. One of:
* `"string"`: manually set by the course author
* `"timestamp"`: generated from the `start` timestamp
* `"empty"`: no start date is specified
end: (str) Date the course ends, in ISO 8601 notation
media: (dict) An object that contains named media items. Included here:
* course_image: An image to show for the course. Represented
as an object with the following fields:
* uri: The location of the image
certificate: (dict) Information about the user's earned certificate in the course.
Included here:
* uri: The location of the user's certificate
is_self_paced: (bool) Indicates if the course is self paced
Body consists of the following fields, you received this response if you use
'return_type=list' in query params:
id: (str) The Course's id (Course Run key)
block_id: (str) The unique identifier for the block_id
lms_web_url: (str) The URL to the navigational container of the xBlock on the web.
legacy_web_url: (str) Like `lms_web_url`, but always directs to
the "Legacy" frontend experience.
student_view_url: (str) The URL to retrieve the HTML rendering
of this block's student view
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.
course_progress: (dict) Contains information about how many assignments are in the course
and how many assignments the student has completed.
Included here:
* total_assignments_count: (int) Total course's assignments count.
* assignments_completed: (int) Assignments witch the student has completed.
**Returns**
* 200 on success with above fields.
* 400 if an invalid parameter was sent or the username was not provided
* 401 unauthorized, the provided access token has expired and is no longer valid
for an authenticated request.
* 403 if a user who does not have permission to masquerade as
another user specifies a username other than their own.
* 404 if the course is not available or cannot be seen.
"""
def get_requested_user(self, user: UserType, username: Optional[str] = None) -> Union[UserType, None]:
"""
Return a user for whom the course blocks are fetched.
Arguments:
user: current user from request.
username: string with username.
Returns: A user object or None.
"""
if user.is_anonymous:
return None
if not username or (username and user.username == username):
return user
if username and (user.is_staff or user.is_superuser):
try:
return User.objects.get(username=username)
except User.DoesNotExist:
log.warning('Provided username does not correspond to an existing user %s', username)
return None
def list(self, request, **kwargs): # pylint: disable=W0221
"""
REST API endpoint for listing all the blocks information in the course and
information about the course considering user access and roles.
Arguments:
request - Django request object
"""
response = super().list(request, kwargs)
if request.GET.get('return_type', 'dict') == 'dict':
api_version = self.kwargs.get('api_version')
course_id = request.query_params.get('course_id', None)
course_key = CourseKey.from_string(course_id)
course_overview = CourseOverview.get_from_id(course_key)
requested_username = request.query_params.get('username', None)
course_data = {
'id': course_id,
'course_updates': reverse(
'course-updates-list',
kwargs={'api_version': api_version, 'course_id': course_id},
request=request,
),
'course_handouts': reverse(
'course-handouts-list',
kwargs={'api_version': api_version, 'course_id': course_id},
request=request,
),
'deprecate_youtube': DEPRECATE_YOUTUBE.is_enabled(course_key)
}
course_info_context = {}
if requested_user := self.get_requested_user(request.user, requested_username):
self._extend_sequential_info_with_assignment_progress(
requested_user,
course_key,
response.data['blocks'],
)
course_info_context = {
'user': requested_user,
}
user_enrollment = CourseEnrollment.get_enrollment(user=requested_user, course_key=course_key)
course_data.update({
'discussion_url': reverse(
'discussion_course',
kwargs={'course_id': course_id},
request=request,
) if course_overview.is_discussion_tab_enabled(requested_user) else None,
'course_access_details': CourseAccessSerializer({
'user': requested_user,
'course': course_overview,
'course_id': course_key
}).data,
'certificate': get_user_certificate_download_url(request, requested_user, course_key),
'enrollment_details': MobileCourseEnrollmentSerializer(user_enrollment).data,
})
course_data.update(CourseInfoOverviewSerializer(course_overview, context=course_info_context).data)
response.data.update(course_data)
return response
@staticmethod
def _extend_sequential_info_with_assignment_progress(
requested_user: User,
course_id: CourseKey,
blocks_info_data: Dict[str, Dict],
) -> None:
"""
Extends sequential xblock info with assignment's name and progress.
"""
subsection_grades = get_assignments_grades(requested_user, course_id, BLOCK_STRUCTURE_CACHE_TIMEOUT)
grades_with_locations = {str(grade.location): grade for grade in subsection_grades}
for block_id, block_info in blocks_info_data.items():
if block_info['type'] == 'sequential':
grade = grades_with_locations.get(block_id)
if grade:
graded_total = grade.graded_total if grade.graded else None
points_earned = graded_total.earned if graded_total else 0
points_possible = graded_total.possible if graded_total else 0
assignment_type = grade.format
else:
points_earned, points_possible, assignment_type = 0, 0, None
block_info.update(
{
'assignment_progress': {
'assignment_type': assignment_type,
'num_points_earned': points_earned,
'num_points_possible': points_possible,
}
}
)
@mobile_view()
class CourseEnrollmentDetailsView(APIView):
"""
API that returns course details for logged-in user in the given course
**Example requests**:
This api works with all versions {api_version}, you can use: v0.5, v1, v2 or v3
GET /api/mobile/{api_version}/course_info/{course_id}/enrollment_details
"""
def get(self, request, *args, **kwargs):
"""
Handle the GET request
Returns user enrollment and course details.
"""
course_key_string = kwargs.get('course_id')
try:
course_key = CourseKey.from_string(course_key_string)
except InvalidKeyError:
error = {'error': f"'{str(course_key_string)}' is not a valid course key."}
return Response(data=error, status=status.HTTP_400_BAD_REQUEST)
data = {
'api_version': self.kwargs.get('api_version'),
'course_id': course_key,
'user': request.user,
'request': request,
}
course_detail = CourseDetailSerializer(data).data
return Response(data=course_detail, status=status.HTTP_200_OK)