The API documentation decorators do not have to leak which solution we use to generate the docs. Here, and as discussed in PR #21820, we rename the `openapi` module to `apidocs`, and we make sure that this module includes all the right functions to document API Views without referring to Open API.
360 lines
13 KiB
Python
360 lines
13 KiB
Python
"""
|
|
HTTP end-points for the Bookmarks API.
|
|
|
|
For more information, see:
|
|
https://openedx.atlassian.net/wiki/display/TNL/Bookmarks+API
|
|
"""
|
|
from __future__ import absolute_import
|
|
|
|
import logging
|
|
|
|
import eventtracking
|
|
from django.conf import settings
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
from django.utils.translation import ugettext as _
|
|
from django.utils.translation import ugettext_noop
|
|
from edx_rest_framework_extensions.paginators import DefaultPagination
|
|
from opaque_keys import InvalidKeyError
|
|
from opaque_keys.edx.keys import CourseKey, UsageKey
|
|
from rest_framework import permissions, status
|
|
from rest_framework.authentication import SessionAuthentication
|
|
from rest_framework.generics import ListCreateAPIView
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
from rest_framework_oauth.authentication import OAuth2Authentication
|
|
|
|
from openedx.core.djangoapps.bookmarks.api import BookmarksLimitReachedError
|
|
from openedx.core.lib.api.permissions import IsUserInUrl
|
|
from openedx.core.lib.url_utils import unquote_slashes
|
|
from openedx.core import apidocs
|
|
from xmodule.modulestore.exceptions import ItemNotFoundError
|
|
|
|
from . import DEFAULT_FIELDS, OPTIONAL_FIELDS, api
|
|
from .serializers import BookmarkSerializer
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Default error message for user
|
|
DEFAULT_USER_MESSAGE = ugettext_noop(u'An error has occurred. Please try again.')
|
|
|
|
|
|
class BookmarksPagination(DefaultPagination):
|
|
"""
|
|
Paginator for bookmarks API.
|
|
"""
|
|
page_size = 10
|
|
max_page_size = 100
|
|
|
|
def get_paginated_response(self, data):
|
|
"""
|
|
Annotate the response with pagination information.
|
|
"""
|
|
response = super(BookmarksPagination, self).get_paginated_response(data)
|
|
|
|
# Add `current_page` value, it's needed for pagination footer.
|
|
response.data["current_page"] = self.page.number
|
|
|
|
# Add `start` value, it's needed for the pagination header.
|
|
response.data["start"] = (self.page.number - 1) * self.get_page_size(self.request)
|
|
|
|
return response
|
|
|
|
|
|
class BookmarksViewMixin(object):
|
|
"""
|
|
Shared code for bookmarks views.
|
|
"""
|
|
|
|
def fields_to_return(self, params):
|
|
"""
|
|
Returns names of fields which should be included in the response.
|
|
|
|
Arguments:
|
|
params (dict): The request parameters.
|
|
"""
|
|
optional_fields = params.get('fields', '').split(',')
|
|
return DEFAULT_FIELDS + [field for field in optional_fields if field in OPTIONAL_FIELDS]
|
|
|
|
def error_response(self, developer_message, user_message=None, error_status=status.HTTP_400_BAD_REQUEST):
|
|
"""
|
|
Create and return a Response.
|
|
|
|
Arguments:
|
|
message (string): The message to put in the developer_message
|
|
and user_message fields.
|
|
status: The status of the response. Default is HTTP_400_BAD_REQUEST.
|
|
"""
|
|
if not user_message:
|
|
user_message = developer_message
|
|
|
|
return Response(
|
|
{
|
|
"developer_message": developer_message,
|
|
"user_message": _(user_message)
|
|
},
|
|
status=error_status
|
|
)
|
|
|
|
|
|
class BookmarksListView(ListCreateAPIView, BookmarksViewMixin):
|
|
"""REST endpoints for lists of bookmarks."""
|
|
|
|
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
|
pagination_class = BookmarksPagination
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
serializer_class = BookmarkSerializer
|
|
|
|
@apidocs.schema(
|
|
parameters=[
|
|
apidocs.string_parameter(
|
|
'course_id',
|
|
apidocs.ParameterLocation.QUERY,
|
|
description="The id of the course to limit the list",
|
|
),
|
|
apidocs.string_parameter(
|
|
'fields',
|
|
apidocs.ParameterLocation.QUERY,
|
|
description="The fields to return: display_name, path.",
|
|
),
|
|
],
|
|
)
|
|
def get(self, request, *args, **kwargs):
|
|
"""
|
|
Get a paginated list of bookmarks for a user.
|
|
|
|
The list can be filtered by passing parameter "course_id=<course_id>"
|
|
to only include bookmarks from a particular course.
|
|
|
|
The bookmarks are always sorted in descending order by creation date.
|
|
|
|
Each page in the list contains 10 bookmarks by default. The page
|
|
size can be altered by passing parameter "page_size=<page_size>".
|
|
|
|
To include the optional fields pass the values in "fields" parameter
|
|
as a comma separated list. Possible values are:
|
|
|
|
* "display_name"
|
|
* "path"
|
|
|
|
# Example Requests
|
|
|
|
GET /api/bookmarks/v1/bookmarks/?course_id={course_id1}&fields=display_name,path
|
|
"""
|
|
return super(BookmarksListView, self).get(request, *args, **kwargs)
|
|
|
|
def get_serializer_context(self):
|
|
"""
|
|
Return the context for the serializer.
|
|
"""
|
|
context = super(BookmarksListView, self).get_serializer_context()
|
|
if self.request.method == 'GET':
|
|
context['fields'] = self.fields_to_return(self.request.query_params)
|
|
return context
|
|
|
|
def get_queryset(self):
|
|
"""
|
|
Returns queryset of bookmarks for GET requests.
|
|
|
|
The results will only include bookmarks for the request's user.
|
|
If the course_id is specified in the request parameters,
|
|
the queryset will only include bookmarks from that course.
|
|
"""
|
|
course_id = self.request.query_params.get('course_id', None)
|
|
|
|
if course_id:
|
|
try:
|
|
course_key = CourseKey.from_string(course_id)
|
|
except InvalidKeyError:
|
|
log.error(u'Invalid course_id: %s.', course_id)
|
|
return []
|
|
else:
|
|
course_key = None
|
|
|
|
return api.get_bookmarks(
|
|
user=self.request.user, course_key=course_key,
|
|
fields=self.fields_to_return(self.request.query_params), serialized=False
|
|
)
|
|
|
|
def paginate_queryset(self, queryset):
|
|
""" Override GenericAPIView.paginate_queryset for the purpose of eventing """
|
|
page = super(BookmarksListView, self).paginate_queryset(queryset)
|
|
|
|
course_id = self.request.query_params.get('course_id')
|
|
if course_id:
|
|
try:
|
|
CourseKey.from_string(course_id)
|
|
except InvalidKeyError:
|
|
return page
|
|
|
|
event_data = {
|
|
'list_type': 'all_courses',
|
|
'bookmarks_count': self.paginator.page.paginator.count,
|
|
'page_size': self.paginator.page.paginator.per_page,
|
|
'page_number': self.paginator.page.number,
|
|
}
|
|
if course_id is not None:
|
|
event_data['list_type'] = 'per_course'
|
|
event_data['course_id'] = course_id
|
|
|
|
eventtracking.tracker.emit('edx.bookmark.listed', event_data)
|
|
|
|
return page
|
|
|
|
@apidocs.schema()
|
|
def post(self, request, *unused_args, **unused_kwargs):
|
|
"""Create a new bookmark for a user.
|
|
|
|
The POST request only needs to contain one parameter "usage_id".
|
|
|
|
Http400 is returned if the format of the request is not correct,
|
|
the usage_id is invalid or a block corresponding to the usage_id
|
|
could not be found.
|
|
|
|
# Example Requests
|
|
|
|
POST /api/bookmarks/v1/bookmarks/
|
|
Request data: {"usage_id": <usage-id>}
|
|
"""
|
|
if not request.data:
|
|
return self.error_response(ugettext_noop(u'No data provided.'), DEFAULT_USER_MESSAGE)
|
|
|
|
usage_id = request.data.get('usage_id', None)
|
|
if not usage_id:
|
|
return self.error_response(ugettext_noop(u'Parameter usage_id not provided.'), DEFAULT_USER_MESSAGE)
|
|
|
|
try:
|
|
usage_key = UsageKey.from_string(unquote_slashes(usage_id))
|
|
except InvalidKeyError:
|
|
error_message = ugettext_noop(u'Invalid usage_id: {usage_id}.').format(usage_id=usage_id)
|
|
log.error(error_message)
|
|
return self.error_response(error_message, DEFAULT_USER_MESSAGE)
|
|
|
|
try:
|
|
bookmark = api.create_bookmark(user=self.request.user, usage_key=usage_key)
|
|
except ItemNotFoundError:
|
|
error_message = ugettext_noop(u'Block with usage_id: {usage_id} not found.').format(usage_id=usage_id)
|
|
log.error(error_message)
|
|
return self.error_response(error_message, DEFAULT_USER_MESSAGE)
|
|
except BookmarksLimitReachedError:
|
|
error_message = ugettext_noop(
|
|
u'You can create up to {max_num_bookmarks_per_course} bookmarks.'
|
|
u' You must remove some bookmarks before you can add new ones.'
|
|
).format(max_num_bookmarks_per_course=settings.MAX_BOOKMARKS_PER_COURSE)
|
|
log.info(
|
|
u'Attempted to create more than %s bookmarks',
|
|
settings.MAX_BOOKMARKS_PER_COURSE
|
|
)
|
|
return self.error_response(error_message)
|
|
|
|
return Response(bookmark, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class BookmarksDetailView(APIView, BookmarksViewMixin):
|
|
"""
|
|
**Use Cases**
|
|
|
|
Get or delete a specific bookmark for a user.
|
|
|
|
**Example Requests**:
|
|
|
|
GET /api/bookmarks/v1/bookmarks/{username},{usage_id}/?fields=display_name,path
|
|
|
|
DELETE /api/bookmarks/v1/bookmarks/{username},{usage_id}/
|
|
|
|
**Response for GET**
|
|
|
|
Users can only delete their own bookmarks. If the bookmark_id does not belong
|
|
to a requesting user's bookmark a Http404 is returned. Http404 will also be
|
|
returned if the bookmark does not exist.
|
|
|
|
* id: String. The identifier string for the bookmark: {user_id},{usage_id}.
|
|
|
|
* course_id: String. The identifier string of the bookmark's course.
|
|
|
|
* usage_id: String. The identifier string of the bookmark's XBlock.
|
|
|
|
* display_name: (optional) String. Display name of the XBlock.
|
|
|
|
* path: (optional) List of dicts containing {"usage_id": <usage-id>, display_name: <display-name>}
|
|
for the XBlocks from the top of the course tree till the parent of the bookmarked XBlock.
|
|
|
|
* created: ISO 8601 String. The timestamp of bookmark's creation.
|
|
|
|
**Response for DELETE**
|
|
|
|
Users can only delete their own bookmarks.
|
|
|
|
A successful delete returns a 204 and no content.
|
|
|
|
Users can only delete their own bookmarks. If the bookmark_id does not belong
|
|
to a requesting user's bookmark a 404 is returned. 404 will also be returned
|
|
if the bookmark does not exist.
|
|
"""
|
|
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
|
permission_classes = (permissions.IsAuthenticated, IsUserInUrl)
|
|
|
|
serializer_class = BookmarkSerializer
|
|
|
|
def get_usage_key_or_error_response(self, usage_id):
|
|
"""
|
|
Create and return usage_key or error Response.
|
|
|
|
Arguments:
|
|
usage_id (string): The id of required block.
|
|
"""
|
|
try:
|
|
return UsageKey.from_string(usage_id)
|
|
except InvalidKeyError:
|
|
error_message = ugettext_noop(u'Invalid usage_id: {usage_id}.').format(usage_id=usage_id)
|
|
log.error(error_message)
|
|
return self.error_response(error_message, error_status=status.HTTP_404_NOT_FOUND)
|
|
|
|
@apidocs.schema()
|
|
def get(self, request, username=None, usage_id=None):
|
|
"""
|
|
Get a specific bookmark for a user.
|
|
|
|
# Example Requests
|
|
|
|
GET /api/bookmarks/v1/bookmarks/{username},{usage_id}?fields=display_name,path
|
|
"""
|
|
usage_key_or_response = self.get_usage_key_or_error_response(usage_id=usage_id)
|
|
|
|
if isinstance(usage_key_or_response, Response):
|
|
return usage_key_or_response
|
|
|
|
try:
|
|
bookmark_data = api.get_bookmark(
|
|
user=request.user,
|
|
usage_key=usage_key_or_response,
|
|
fields=self.fields_to_return(request.query_params)
|
|
)
|
|
except ObjectDoesNotExist:
|
|
error_message = ugettext_noop(
|
|
u'Bookmark with usage_id: {usage_id} does not exist.'
|
|
).format(usage_id=usage_id)
|
|
log.error(error_message)
|
|
return self.error_response(error_message, error_status=status.HTTP_404_NOT_FOUND)
|
|
|
|
return Response(bookmark_data)
|
|
|
|
def delete(self, request, username=None, usage_id=None): # pylint: disable=unused-argument
|
|
"""
|
|
DELETE /api/bookmarks/v1/bookmarks/{username},{usage_id}
|
|
"""
|
|
usage_key_or_response = self.get_usage_key_or_error_response(usage_id=usage_id)
|
|
|
|
if isinstance(usage_key_or_response, Response):
|
|
return usage_key_or_response
|
|
|
|
try:
|
|
api.delete_bookmark(user=request.user, usage_key=usage_key_or_response)
|
|
except ObjectDoesNotExist:
|
|
error_message = ugettext_noop(
|
|
u'Bookmark with usage_id: {usage_id} does not exist.'
|
|
).format(usage_id=usage_id)
|
|
log.error(error_message)
|
|
return self.error_response(error_message, error_status=status.HTTP_404_NOT_FOUND)
|
|
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|