""" HTTP end-points for the Bookmarks API. For more information, see: https://openedx.atlassian.net/wiki/display/TNL/Bookmarks+API """ import eventtracking import logging from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import ugettext as _, ugettext_noop from rest_framework import status from rest_framework import permissions 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 opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from django.conf import settings from openedx.core.djangoapps.bookmarks.api import BookmarksLimitReachedError from openedx.core.lib.api.permissions import IsUserInUrl from xmodule.modulestore.exceptions import ItemNotFoundError from openedx.core.lib.api.paginators import DefaultPagination from openedx.core.lib.url_utils import unquote_slashes 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) # pylint: disable=translation-of-non-string }, status=error_status ) class BookmarksListView(ListCreateAPIView, BookmarksViewMixin): """ **Use Case** * 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" * 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** GET /api/bookmarks/v1/bookmarks/?course_id={course_id1}&fields=display_name,path POST /api/bookmarks/v1/bookmarks/ Request data: {"usage_id": <usage-id>} **Response Values** * count: The number of bookmarks in a course. * next: The URI to the next page of bookmarks. * previous: The URI to the previous page of bookmarks. * num_pages: The number of pages listing bookmarks. * results: A list of bookmarks returned. Each collection in the list contains these fields. * 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: String. (optional) Display name of the XBlock. * path: List. (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. """ authentication_classes = (OAuth2Authentication, SessionAuthentication) pagination_class = BookmarksPagination permission_classes = (permissions.IsAuthenticated,) serializer_class = BookmarkSerializer 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 def post(self, request): """ 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) def get(self, request, username=None, usage_id=None): # pylint: disable=unused-argument """ 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)