diff --git a/common/djangoapps/enrollment/urls.py b/common/djangoapps/enrollment/urls.py index 438ad259df..8b7b41652a 100644 --- a/common/djangoapps/enrollment/urls.py +++ b/common/djangoapps/enrollment/urls.py @@ -11,13 +11,13 @@ from .views import ( EnrollmentCourseDetailView ) -USERNAME_PATTERN = '(?P[\w.@+-]+)' urlpatterns = patterns( 'enrollment.views', url( - r'^enrollment/{username},{course_key}$'.format(username=USERNAME_PATTERN, - course_key=settings.COURSE_ID_PATTERN), + r'^enrollment/{username},{course_key}$'.format( + username=settings.USERNAME_PATTERN, course_key=settings.COURSE_ID_PATTERN + ), EnrollmentView.as_view(), name='courseenrollment' ), diff --git a/lms/djangoapps/bookmarks/__init__.py b/lms/djangoapps/bookmarks/__init__.py new file mode 100644 index 0000000000..0c5079caea --- /dev/null +++ b/lms/djangoapps/bookmarks/__init__.py @@ -0,0 +1,15 @@ +""" +Bookmarks module. +""" + +DEFAULT_FIELDS = [ + 'id', + 'course_id', + 'usage_id', + 'created', +] + +OPTIONAL_FIELDS = [ + 'display_name', + 'path', +] diff --git a/lms/djangoapps/bookmarks/api.py b/lms/djangoapps/bookmarks/api.py new file mode 100644 index 0000000000..8171495e94 --- /dev/null +++ b/lms/djangoapps/bookmarks/api.py @@ -0,0 +1,93 @@ +""" +Bookmarks Python API. +""" + +from . import DEFAULT_FIELDS, OPTIONAL_FIELDS +from .models import Bookmark +from .serializers import BookmarkSerializer + + +def get_bookmark(user, usage_key, fields=None): + """ + Return data for a bookmark. + + Arguments: + user (User): The user of the bookmark. + usage_key (UsageKey): The usage_key of the bookmark. + fields (list): List of field names the data should contain (optional). + + Returns: + Dict. + + Raises: + ObjectDoesNotExist: If a bookmark with the parameters does not exist. + """ + bookmark = Bookmark.objects.get(user=user, usage_key=usage_key) + return BookmarkSerializer(bookmark, context={'fields': fields}).data + + +def get_bookmarks(user, course_key=None, fields=None, serialized=True): + """ + Return data for bookmarks of a user. + + Arguments: + user (User): The user of the bookmarks. + course_key (CourseKey): The course_key of the bookmarks (optional). + fields (list): List of field names the data should contain (optional). + N/A if serialized is False. + serialized (bool): Whether to return a queryset or a serialized list of dicts. + Default is True. + + Returns: + List of dicts if serialized is True else queryset. + """ + bookmarks_queryset = Bookmark.objects.filter(user=user) + + if course_key: + bookmarks_queryset = bookmarks_queryset.filter(course_key=course_key) + + bookmarks_queryset = bookmarks_queryset.order_by('-created') + + if serialized: + return BookmarkSerializer(bookmarks_queryset, context={'fields': fields}, many=True).data + + return bookmarks_queryset + + +def create_bookmark(user, usage_key): + """ + Create a bookmark. + + Arguments: + user (User): The user of the bookmark. + usage_key (UsageKey): The usage_key of the bookmark. + + Returns: + Dict. + + Raises: + ItemNotFoundError: If no block exists for the usage_key. + """ + bookmark = Bookmark.create({ + 'user': user, + 'usage_key': usage_key + }) + return BookmarkSerializer(bookmark, context={'fields': DEFAULT_FIELDS + OPTIONAL_FIELDS}).data + + +def delete_bookmark(user, usage_key): + """ + Delete a bookmark. + + Arguments: + user (User): The user of the bookmark. + usage_key (UsageKey): The usage_key of the bookmark. + + Returns: + Dict. + + Raises: + ObjectDoesNotExist: If a bookmark with the parameters does not exist. + """ + bookmark = Bookmark.objects.get(user=user, usage_key=usage_key) + bookmark.delete() diff --git a/lms/djangoapps/bookmarks/migrations/0001_initial.py b/lms/djangoapps/bookmarks/migrations/0001_initial.py new file mode 100644 index 0000000000..d781e01999 --- /dev/null +++ b/lms/djangoapps/bookmarks/migrations/0001_initial.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'Bookmark' + db.create_table('bookmarks_bookmark', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)), + ('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('course_key', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)), + ('usage_key', self.gf('xmodule_django.models.LocationKeyField')(max_length=255, db_index=True)), + ('display_name', self.gf('django.db.models.fields.CharField')(default='', max_length=255)), + ('path', self.gf('jsonfield.fields.JSONField')()), + )) + db.send_create_signal('bookmarks', ['Bookmark']) + + + def backwards(self, orm): + # Deleting model 'Bookmark' + db.delete_table('bookmarks_bookmark') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'bookmarks.bookmark': { + 'Meta': {'object_name': 'Bookmark'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'path': ('jsonfield.fields.JSONField', [], {}), + 'usage_key': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['bookmarks'] diff --git a/lms/djangoapps/bookmarks/migrations/__init__.py b/lms/djangoapps/bookmarks/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/bookmarks/models.py b/lms/djangoapps/bookmarks/models.py new file mode 100644 index 0000000000..7e7c9243d6 --- /dev/null +++ b/lms/djangoapps/bookmarks/models.py @@ -0,0 +1,71 @@ +""" +Models for Bookmarks. +""" + +from django.contrib.auth.models import User +from django.db import models + +from jsonfield.fields import JSONField +from model_utils.models import TimeStampedModel + +from xmodule.modulestore.django import modulestore +from xmodule_django.models import CourseKeyField, LocationKeyField + + +class Bookmark(TimeStampedModel): + """ + Bookmarks model. + """ + user = models.ForeignKey(User, db_index=True) + course_key = CourseKeyField(max_length=255, db_index=True) + usage_key = LocationKeyField(max_length=255, db_index=True) + display_name = models.CharField(max_length=255, default='', help_text='Display name of block') + path = JSONField(help_text='Path in course tree to the block') + + @classmethod + def create(cls, bookmark_data): + """ + Create a Bookmark object. + + Arguments: + bookmark_data (dict): The data to create the object with. + + Returns: + A Bookmark object. + + Raises: + ItemNotFoundError: If no block exists for the usage_key. + """ + usage_key = bookmark_data.pop('usage_key') + usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) + + block = modulestore().get_item(usage_key) + + bookmark_data['course_key'] = usage_key.course_key + bookmark_data['display_name'] = block.display_name + bookmark_data['path'] = cls.get_path(block) + user = bookmark_data.pop('user') + + bookmark, __ = cls.objects.get_or_create(usage_key=usage_key, user=user, defaults=bookmark_data) + return bookmark + + @staticmethod + def get_path(block): + """ + Returns data for the path to the block in the course tree. + + Arguments: + block (XBlock): The block whose path is required. + + Returns: + list of dicts of the form {'usage_id': , 'display_name': }. + """ + parent = block.get_parent() + parents_data = [] + + while parent is not None and parent.location.block_type not in ['course']: + parents_data.append({"display_name": parent.display_name, "usage_id": unicode(parent.location)}) + parent = parent.get_parent() + + parents_data.reverse() + return parents_data diff --git a/lms/djangoapps/bookmarks/serializers.py b/lms/djangoapps/bookmarks/serializers.py new file mode 100644 index 0000000000..daf09ec419 --- /dev/null +++ b/lms/djangoapps/bookmarks/serializers.py @@ -0,0 +1,50 @@ +""" +Serializers for Bookmarks. +""" +from rest_framework import serializers + +from . import DEFAULT_FIELDS +from .models import Bookmark + + +class BookmarkSerializer(serializers.ModelSerializer): + """ + Serializer for the Bookmark model. + """ + id = serializers.SerializerMethodField('resource_id') # pylint: disable=invalid-name + course_id = serializers.Field(source='course_key') + usage_id = serializers.Field(source='usage_key') + path = serializers.Field(source='path') + + def __init__(self, *args, **kwargs): + # Don't pass the 'fields' arg up to the superclass + try: + fields = kwargs['context'].pop('fields', DEFAULT_FIELDS) or DEFAULT_FIELDS + except KeyError: + fields = DEFAULT_FIELDS + # Instantiate the superclass normally + super(BookmarkSerializer, self).__init__(*args, **kwargs) + + # Drop any fields that are not specified in the `fields` argument. + required_fields = set(fields) + all_fields = set(self.fields.keys()) + for field_name in all_fields - required_fields: + self.fields.pop(field_name) + + class Meta(object): + """ Serializer metadata. """ + model = Bookmark + fields = ( + 'id', + 'course_id', + 'usage_id', + 'display_name', + 'path', + 'created', + ) + + def resource_id(self, bookmark): + """ + Return the REST resource id: {username,usage_id}. + """ + return "{0},{1}".format(bookmark.user.username, bookmark.usage_key) diff --git a/lms/djangoapps/bookmarks/services.py b/lms/djangoapps/bookmarks/services.py new file mode 100644 index 0000000000..d5bb446fd4 --- /dev/null +++ b/lms/djangoapps/bookmarks/services.py @@ -0,0 +1,88 @@ +""" +Bookmarks service. +""" +import logging + +from django.core.exceptions import ObjectDoesNotExist + +from xmodule.modulestore.exceptions import ItemNotFoundError + +from . import DEFAULT_FIELDS, OPTIONAL_FIELDS, api + +log = logging.getLogger(__name__) + + +class BookmarksService(object): + """ + A service that provides access to the bookmarks API. + """ + + def __init__(self, user, **kwargs): + super(BookmarksService, self).__init__(**kwargs) + self._user = user + + def bookmarks(self, course_key): + """ + Return a list of bookmarks for the course for the current user. + + Arguments: + course_key: CourseKey of the course for which to retrieve the user's bookmarks for. + + Returns: + list of dict: + """ + return api.get_bookmarks(self._user, course_key=course_key, fields=DEFAULT_FIELDS + OPTIONAL_FIELDS) + + def is_bookmarked(self, usage_key): + """ + Return whether the block has been bookmarked by the user. + + Arguments: + usage_key: UsageKey of the block. + + Returns: + Bool + """ + try: + api.get_bookmark(user=self._user, usage_key=usage_key) + except ObjectDoesNotExist: + log.error(u'Bookmark with usage_id: %s does not exist.', usage_key) + return False + + return True + + def set_bookmarked(self, usage_key): + """ + Adds a bookmark for the block. + + Arguments: + usage_key: UsageKey of the block. + + Returns: + Bool indicating whether the bookmark was added. + """ + try: + api.create_bookmark(user=self._user, usage_key=usage_key) + except ItemNotFoundError: + log.error(u'Block with usage_id: %s not found.', usage_key) + return False + + return True + + def unset_bookmarked(self, usage_key): + """ + Removes the bookmark for the block. + + Arguments: + usage_key: UsageKey of the block. + + Returns: + Bool indicating whether the bookmark was removed. + """ + try: + api.delete_bookmark(self._user, usage_key=usage_key) + except ObjectDoesNotExist: + log.error(u'Bookmark with usage_id: %s does not exist.', usage_key) + return False + + return True diff --git a/lms/djangoapps/bookmarks/tests/__init__.py b/lms/djangoapps/bookmarks/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/bookmarks/tests/factories.py b/lms/djangoapps/bookmarks/tests/factories.py new file mode 100644 index 0000000000..50422ec21b --- /dev/null +++ b/lms/djangoapps/bookmarks/tests/factories.py @@ -0,0 +1,25 @@ +""" +Factories for Bookmark models. +""" + +from factory.django import DjangoModelFactory +from factory import SubFactory +from functools import partial + +from student.tests.factories import UserFactory +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from ..models import Bookmark + +COURSE_KEY = SlashSeparatedCourseKey(u'edX', u'test_course', u'test') +LOCATION = partial(COURSE_KEY.make_usage_key, u'problem') + + +class BookmarkFactory(DjangoModelFactory): + """ Simple factory class for generating Bookmark """ + FACTORY_FOR = Bookmark + + user = SubFactory(UserFactory) + course_key = COURSE_KEY + usage_key = LOCATION('usage_id') + display_name = "" + path = list() diff --git a/lms/djangoapps/bookmarks/tests/test_api.py b/lms/djangoapps/bookmarks/tests/test_api.py new file mode 100644 index 0000000000..796d678d33 --- /dev/null +++ b/lms/djangoapps/bookmarks/tests/test_api.py @@ -0,0 +1,180 @@ +""" +Tests for bookmarks api. +""" + +from django.core.exceptions import ObjectDoesNotExist + +from opaque_keys.edx.keys import UsageKey + +from student.tests.factories import UserFactory + +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from .factories import BookmarkFactory +from .. import api, DEFAULT_FIELDS, OPTIONAL_FIELDS +from ..models import Bookmark + + +class BookmarksAPITests(ModuleStoreTestCase): + """ + These tests cover the parts of the API methods. + """ + + def setUp(self): + super(BookmarksAPITests, self).setUp() + + self.user = UserFactory.create(password='test') + self.other_user = UserFactory.create(password='test') + + self.course = CourseFactory.create(display_name='An Introduction to API Testing') + self.course_id = unicode(self.course.id) + + self.chapter = ItemFactory.create( + parent_location=self.course.location, category='chapter', display_name='Week 1' + ) + self.sequential = ItemFactory.create( + parent_location=self.chapter.location, category='sequential', display_name='Lesson 1' + ) + self.vertical = ItemFactory.create( + parent_location=self.sequential.location, category='vertical', display_name='Subsection 1' + ) + self.vertical_1 = ItemFactory.create( + parent_location=self.sequential.location, category='vertical', display_name='Subsection 1.1' + ) + self.bookmark = BookmarkFactory.create( + user=self.user, + course_key=self.course_id, + usage_key=self.vertical.location, + display_name=self.vertical.display_name + ) + + self.course_2 = CourseFactory.create(display_name='An Introduction to API Testing 2') + self.chapter_2 = ItemFactory.create( + parent_location=self.course_2.location, category='chapter', display_name='Week 2' + ) + self.sequential_2 = ItemFactory.create( + parent_location=self.chapter_2.location, category='sequential', display_name='Lesson 2' + ) + self.vertical_2 = ItemFactory.create( + parent_location=self.sequential_2.location, category='vertical', display_name='Subsection 2' + ) + self.bookmark_2 = BookmarkFactory.create( + user=self.user, + course_key=self.course_2.id, + usage_key=self.vertical_2.location, + display_name=self.vertical_2.display_name + ) + self.all_fields = DEFAULT_FIELDS + OPTIONAL_FIELDS + + def assert_bookmark_response(self, response_data, bookmark, optional_fields=False): + """ + Determines if the given response data (dict) matches the given bookmark. + """ + self.assertEqual(response_data['id'], '%s,%s' % (self.user.username, unicode(bookmark.usage_key))) + self.assertEqual(response_data['course_id'], unicode(bookmark.course_key)) + self.assertEqual(response_data['usage_id'], unicode(bookmark.usage_key)) + self.assertIsNotNone(response_data['created']) + + if optional_fields: + self.assertEqual(response_data['display_name'], bookmark.display_name) + self.assertEqual(response_data['path'], bookmark.path) + + def test_get_bookmark(self): + """ + Verifies that get_bookmark returns data as expected. + """ + bookmark_data = api.get_bookmark(user=self.user, usage_key=self.vertical.location) + self.assert_bookmark_response(bookmark_data, self.bookmark) + + # With Optional fields. + bookmark_data = api.get_bookmark( + user=self.user, + usage_key=self.vertical.location, + fields=self.all_fields + ) + self.assert_bookmark_response(bookmark_data, self.bookmark, optional_fields=True) + + def test_get_bookmark_raises_error(self): + """ + Verifies that get_bookmark raises error as expected. + """ + with self.assertRaises(ObjectDoesNotExist): + api.get_bookmark(user=self.other_user, usage_key=self.vertical.location) + + def test_get_bookmarks(self): + """ + Verifies that get_bookmarks returns data as expected. + """ + # Without course key. + bookmarks_data = api.get_bookmarks(user=self.user) + self.assertEqual(len(bookmarks_data), 2) + # Assert them in ordered manner. + self.assert_bookmark_response(bookmarks_data[0], self.bookmark_2) + self.assert_bookmark_response(bookmarks_data[1], self.bookmark) + + # With course key. + bookmarks_data = api.get_bookmarks(user=self.user, course_key=self.course.id) + self.assertEqual(len(bookmarks_data), 1) + self.assert_bookmark_response(bookmarks_data[0], self.bookmark) + + # With optional fields. + bookmarks_data = api.get_bookmarks(user=self.user, course_key=self.course.id, fields=self.all_fields) + self.assertEqual(len(bookmarks_data), 1) + self.assert_bookmark_response(bookmarks_data[0], self.bookmark, optional_fields=True) + + # Without Serialized. + bookmarks = api.get_bookmarks(user=self.user, course_key=self.course.id, serialized=False) + self.assertEqual(len(bookmarks), 1) + self.assertTrue(bookmarks.model is Bookmark) # pylint: disable=no-member + self.assertEqual(bookmarks[0], self.bookmark) + + def test_create_bookmark(self): + """ + Verifies that create_bookmark create & returns data as expected. + """ + self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.course.id)), 1) + + api.create_bookmark(user=self.user, usage_key=self.vertical_1.location) + + self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.course.id)), 2) + + def test_create_bookmark_do_not_create_duplicates(self): + """ + Verifies that create_bookmark do not create duplicate bookmarks. + """ + self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.course.id)), 1) + bookmark_data = api.create_bookmark(user=self.user, usage_key=self.vertical_1.location) + + self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.course.id)), 2) + + bookmark_data_2 = api.create_bookmark(user=self.user, usage_key=self.vertical_1.location) + self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.course.id)), 2) + self.assertEqual(bookmark_data, bookmark_data_2) + + def test_create_bookmark_raises_error(self): + """ + Verifies that create_bookmark raises error as expected. + """ + with self.assertRaises(ItemNotFoundError): + api.create_bookmark(user=self.user, usage_key=UsageKey.from_string('i4x://brb/100/html/340ef1771a0940')) + + def test_delete_bookmark(self): + """ + Verifies that delete_bookmark removes bookmark as expected. + """ + self.assertEqual(len(api.get_bookmarks(user=self.user)), 2) + + api.delete_bookmark(user=self.user, usage_key=self.vertical.location) + + bookmarks_data = api.get_bookmarks(user=self.user) + self.assertEqual(len(bookmarks_data), 1) + self.assertNotEqual(unicode(self.vertical.location), bookmarks_data[0]['usage_id']) + + def test_delete_bookmark_raises_error(self): + """ + Verifies that delete_bookmark raises error as expected. + """ + with self.assertRaises(ObjectDoesNotExist): + api.delete_bookmark(user=self.other_user, usage_key=self.vertical.location) diff --git a/lms/djangoapps/bookmarks/tests/test_models.py b/lms/djangoapps/bookmarks/tests/test_models.py new file mode 100644 index 0000000000..63df584280 --- /dev/null +++ b/lms/djangoapps/bookmarks/tests/test_models.py @@ -0,0 +1,99 @@ +""" +Tests for Bookmarks models. +""" + +from bookmarks.models import Bookmark +from student.tests.factories import UserFactory + +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +class BookmarkModelTest(ModuleStoreTestCase): + """ + Test the Bookmark model. + """ + def setUp(self): + super(BookmarkModelTest, self).setUp() + + self.user = UserFactory.create(password='test') + + self.course = CourseFactory.create(display_name='An Introduction to API Testing') + self.course_id = unicode(self.course.id) + + self.chapter = ItemFactory.create( + parent_location=self.course.location, category='chapter', display_name='Week 1' + ) + self.sequential = ItemFactory.create( + parent_location=self.chapter.location, category='sequential', display_name='Lesson 1' + ) + self.vertical = ItemFactory.create( + parent_location=self.sequential.location, category='vertical', display_name='Subsection 1' + ) + self.vertical_2 = ItemFactory.create( + parent_location=self.sequential.location, category='vertical', display_name='Subsection 2' + ) + + self.path = [ + {'display_name': self.chapter.display_name, 'usage_id': unicode(self.chapter.location)}, + {'display_name': self.sequential.display_name, 'usage_id': unicode(self.sequential.location)} + ] + + def get_bookmark_data(self, block): + """ + Returns bookmark data for testing. + """ + return { + 'user': self.user, + 'course_key': self.course.id, + 'usage_key': block.location, + 'display_name': block.display_name, + } + + def assert_valid_bookmark(self, bookmark_object, bookmark_data): + """ + Check if the given data matches the specified bookmark. + """ + self.assertEqual(bookmark_object.user, self.user) + self.assertEqual(bookmark_object.course_key, bookmark_data['course_key']) + self.assertEqual(bookmark_object.usage_key, self.vertical.location) + self.assertEqual(bookmark_object.display_name, bookmark_data['display_name']) + self.assertEqual(bookmark_object.path, self.path) + self.assertIsNotNone(bookmark_object.created) + + def test_create_bookmark_success(self): + """ + Tests creation of bookmark. + """ + bookmark_data = self.get_bookmark_data(self.vertical) + bookmark_object = Bookmark.create(bookmark_data) + self.assert_valid_bookmark(bookmark_object, bookmark_data) + + def test_get_path(self): + """ + Tests creation of path with given block. + """ + path_object = Bookmark.get_path(block=self.vertical) + self.assertEqual(path_object, self.path) + + def test_get_path_with_given_chapter_block(self): + """ + Tests path for chapter level block. + """ + path_object = Bookmark.get_path(block=self.chapter) + self.assertEqual(len(path_object), 0) + + def test_get_path_with_given_sequential_block(self): + """ + Tests path for sequential level block. + """ + path_object = Bookmark.get_path(block=self.sequential) + self.assertEqual(len(path_object), 1) + self.assertEqual(path_object[0], self.path[0]) + + def test_get_path_returns_empty_list_for_unreachable_parent(self): + """ + Tests get_path returns empty list if block has no parent. + """ + path = Bookmark.get_path(block=self.course) + self.assertEqual(path, []) diff --git a/lms/djangoapps/bookmarks/tests/test_services.py b/lms/djangoapps/bookmarks/tests/test_services.py new file mode 100644 index 0000000000..25b6a93543 --- /dev/null +++ b/lms/djangoapps/bookmarks/tests/test_services.py @@ -0,0 +1,101 @@ +""" +Tests for bookmark services. +""" + +from opaque_keys.edx.keys import UsageKey + +from .factories import BookmarkFactory +from ..services import BookmarksService + +from student.tests.factories import UserFactory + +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +class BookmarksAPITests(ModuleStoreTestCase): + """ + Tests the Bookmarks service. + """ + + def setUp(self): + super(BookmarksAPITests, self).setUp() + + self.user = UserFactory.create(password='test') + self.other_user = UserFactory.create(password='test') + + self.course = CourseFactory.create(display_name='An Introduction to API Testing') + self.course_id = unicode(self.course.id) + + self.chapter = ItemFactory.create( + parent_location=self.course.location, category='chapter', display_name='Week 1' + ) + self.sequential = ItemFactory.create( + parent_location=self.chapter.location, category='sequential', display_name='Lesson 1' + ) + self.vertical = ItemFactory.create( + parent_location=self.sequential.location, category='vertical', display_name='Subsection 1' + ) + self.vertical_1 = ItemFactory.create( + parent_location=self.sequential.location, category='vertical', display_name='Subsection 1.1' + ) + self.bookmark = BookmarkFactory.create( + user=self.user, + course_key=self.course_id, + usage_key=self.vertical.location, + display_name=self.vertical.display_name + ) + self.bookmark_service = BookmarksService(user=self.user) + + def assert_bookmark_response(self, response_data, bookmark): + """ + Determines if the given response data (dict) matches the specified bookmark. + """ + self.assertEqual(response_data['id'], '%s,%s' % (self.user.username, unicode(bookmark.usage_key))) + self.assertEqual(response_data['course_id'], unicode(bookmark.course_key)) + self.assertEqual(response_data['usage_id'], unicode(bookmark.usage_key)) + self.assertIsNotNone(response_data['created']) + + self.assertEqual(response_data['display_name'], bookmark.display_name) + self.assertEqual(response_data['path'], bookmark.path) + + def test_get_bookmarks(self): + """ + Verifies get_bookmarks returns data as expected. + """ + + bookmarks_data = self.bookmark_service.bookmarks(course_key=self.course.id) + + self.assertEqual(len(bookmarks_data), 1) + self.assert_bookmark_response(bookmarks_data[0], self.bookmark) + + def test_is_bookmarked(self): + """ + Verifies is_bookmarked returns Bool as expected. + """ + self.assertTrue(self.bookmark_service.is_bookmarked(usage_key=self.vertical.location)) + self.assertFalse(self.bookmark_service.is_bookmarked(usage_key=self.vertical_1.location)) + + # Get bookmark that does not exist. + bookmark_service = BookmarksService(self.other_user) + self.assertFalse(bookmark_service.is_bookmarked(usage_key=self.vertical.location)) + + def test_set_bookmarked(self): + """ + Verifies set_bookmarked returns Bool as expected. + """ + # Assert False for item that does not exist. + self.assertFalse( + self.bookmark_service.set_bookmarked(usage_key=UsageKey.from_string("i4x://ed/ed/ed/interactive")) + ) + + self.assertTrue(self.bookmark_service.set_bookmarked(usage_key=self.vertical_1.location)) + + def test_unset_bookmarked(self): + """ + Verifies unset_bookmarked returns Bool as expected. + """ + self.assertFalse( + self.bookmark_service.unset_bookmarked(usage_key=UsageKey.from_string("i4x://ed/ed/ed/interactive")) + ) + self.assertTrue(self.bookmark_service.unset_bookmarked(usage_key=self.vertical.location)) diff --git a/lms/djangoapps/bookmarks/tests/test_views.py b/lms/djangoapps/bookmarks/tests/test_views.py new file mode 100644 index 0000000000..2cfbeba866 --- /dev/null +++ b/lms/djangoapps/bookmarks/tests/test_views.py @@ -0,0 +1,482 @@ +""" +Tests for bookmark views. +""" + +import ddt +import json +import urllib +from django.core.urlresolvers import reverse +from rest_framework.test import APIClient + +from student.tests.factories import UserFactory +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from .factories import BookmarkFactory + +# pylint: disable=no-member + + +class BookmarksViewTestsMixin(ModuleStoreTestCase): + """ + Mixin for bookmarks views tests. + """ + test_password = 'test' + + def setUp(self): + super(BookmarksViewTestsMixin, self).setUp() + + self.anonymous_client = APIClient() + self.user = UserFactory.create(password=self.test_password) + self.create_test_data() + self.client = self.login_client(user=self.user) + + def login_client(self, user): + """ + Helper method for getting the client and user and logging in. Returns client. + """ + client = APIClient() + client.login(username=user.username, password=self.test_password) + return client + + def create_test_data(self): + """ + Creates the bookmarks test data. + """ + with self.store.default_store(ModuleStoreEnum.Type.split): + + self.course = CourseFactory.create() + self.course_id = unicode(self.course.id) + + chapter_1 = ItemFactory.create( + parent_location=self.course.location, category='chapter', display_name='Week 1' + ) + sequential_1 = ItemFactory.create( + parent_location=chapter_1.location, category='sequential', display_name='Lesson 1' + ) + self.vertical_1 = ItemFactory.create( + parent_location=sequential_1.location, category='vertical', display_name='Subsection 1' + ) + self.bookmark_1 = BookmarkFactory.create( + user=self.user, + course_key=self.course_id, + usage_key=self.vertical_1.location, + display_name=self.vertical_1.display_name + ) + chapter_2 = ItemFactory.create( + parent_location=self.course.location, category='chapter', display_name='Week 2' + ) + sequential_2 = ItemFactory.create( + parent_location=chapter_2.location, category='sequential', display_name='Lesson 2' + ) + vertical_2 = ItemFactory.create( + parent_location=sequential_2.location, category='vertical', display_name='Subsection 2' + ) + self.vertical_3 = ItemFactory.create( + parent_location=sequential_2.location, category='vertical', display_name='Subsection 3' + ) + self.bookmark_2 = BookmarkFactory.create( + user=self.user, + course_key=self.course_id, + usage_key=vertical_2.location, + display_name=vertical_2.display_name + ) + + # Other Course + self.other_course = CourseFactory.create(display_name='An Introduction to API Testing 2') + other_chapter = ItemFactory.create( + parent_location=self.other_course.location, category='chapter', display_name='Other Week 1' + ) + other_sequential = ItemFactory.create( + parent_location=other_chapter.location, category='sequential', display_name='Other Lesson 1' + ) + self.other_vertical = ItemFactory.create( + parent_location=other_sequential.location, category='vertical', display_name='Other Subsection 1' + ) + self.other_bookmark = BookmarkFactory.create( + user=self.user, + course_key=unicode(self.other_course.id), + usage_key=self.other_vertical.location, + display_name=self.other_vertical.display_name + ) + + def assert_valid_bookmark_response(self, response_data, bookmark, optional_fields=False): + """ + Determines if the given response data (dict) matches the specified bookmark. + """ + self.assertEqual(response_data['id'], '%s,%s' % (self.user.username, unicode(bookmark.usage_key))) + self.assertEqual(response_data['course_id'], unicode(bookmark.course_key)) + self.assertEqual(response_data['usage_id'], unicode(bookmark.usage_key)) + self.assertIsNotNone(response_data['created']) + + if optional_fields: + self.assertEqual(response_data['display_name'], bookmark.display_name) + self.assertEqual(response_data['path'], bookmark.path) + + def send_get(self, client, url, query_parameters=None, expected_status=200): + """ + Helper method for sending a GET to the server. Verifies the expected status and returns the response. + """ + url = url + '?' + query_parameters if query_parameters else url + response = client.get(url) + self.assertEqual(expected_status, response.status_code) + return response + + def send_post(self, client, url, data, content_type='application/json', expected_status=201): + """ + Helper method for sending a POST to the server. Verifies the expected status and returns the response. + """ + response = client.post(url, data=json.dumps(data), content_type=content_type) + self.assertEqual(expected_status, response.status_code) + return response + + def send_delete(self, client, url, expected_status=204): + """ + Helper method for sending a DELETE to the server. Verifies the expected status and returns the response. + """ + response = client.delete(url) + self.assertEqual(expected_status, response.status_code) + return response + + +@ddt.ddt +class BookmarksListViewTests(BookmarksViewTestsMixin): + """ + This contains the tests for GET & POST methods of bookmark.views.BookmarksListView class + GET /api/bookmarks/v0/bookmarks/?course_id={course_id1} + POST /api/bookmarks/v0/bookmarks + """ + @ddt.data( + ('course_id={}', False), + ('course_id={}&fields=path,display_name', True), + ) + @ddt.unpack + def test_get_bookmarks_successfully(self, query_params, check_optionals): + """ + Test that requesting bookmarks for a course returns records successfully in + expected order without optional fields. + """ + response = self.send_get( + client=self.client, + url=reverse('bookmarks'), + query_parameters=query_params.format(urllib.quote(self.course_id)) + ) + + bookmarks = response.data['results'] + + self.assertEqual(len(bookmarks), 2) + self.assertEqual(response.data['count'], 2) + self.assertEqual(response.data['num_pages'], 1) + + # As bookmarks are sorted by -created so we will compare in that order. + self.assert_valid_bookmark_response(bookmarks[0], self.bookmark_2, optional_fields=check_optionals) + self.assert_valid_bookmark_response(bookmarks[1], self.bookmark_1, optional_fields=check_optionals) + + def test_get_bookmarks_with_pagination(self): + """ + Test that requesting bookmarks for a course return results with pagination 200 code. + """ + query_parameters = 'course_id={}&page_size=1'.format(urllib.quote(self.course_id)) + response = self.send_get(client=self.client, url=reverse('bookmarks'), query_parameters=query_parameters) + + bookmarks = response.data['results'] + + # Pagination assertions. + self.assertEqual(response.data['count'], 2) + self.assertIn('page=2&page_size=1', response.data['next']) + self.assertEqual(response.data['num_pages'], 2) + + self.assertEqual(len(bookmarks), 1) + self.assert_valid_bookmark_response(bookmarks[0], self.bookmark_2) + + def test_get_bookmarks_with_invalid_data(self): + """ + Test that requesting bookmarks with invalid data returns 0 records. + """ + # Invalid course id. + response = self.send_get(client=self.client, url=reverse('bookmarks'), query_parameters='course_id=invalid') + bookmarks = response.data['results'] + self.assertEqual(len(bookmarks), 0) + + def test_get_all_bookmarks_when_course_id_not_given(self): + """ + Test that requesting bookmarks returns all records for that user. + """ + # Without course id we would return all the bookmarks for that user. + response = self.send_get(client=self.client, url=reverse('bookmarks')) + bookmarks = response.data['results'] + self.assertEqual(len(bookmarks), 3) + self.assert_valid_bookmark_response(bookmarks[0], self.other_bookmark) + self.assert_valid_bookmark_response(bookmarks[1], self.bookmark_2) + self.assert_valid_bookmark_response(bookmarks[2], self.bookmark_1) + + def test_anonymous_access(self): + """ + Test that an anonymous client (not logged in) cannot call GET or POST. + """ + query_parameters = 'course_id={}'.format(self.course_id) + self.send_get( + client=self.anonymous_client, + url=reverse('bookmarks'), + query_parameters=query_parameters, + expected_status=401 + ) + self.send_post( + client=self.anonymous_client, + url=reverse('bookmarks'), + data={'usage_id': 'test'}, + expected_status=401 + ) + + def test_post_bookmark_successfully(self): + """ + Test that posting a bookmark successfully returns newly created data with 201 code. + """ + response = self.send_post( + client=self.client, + url=reverse('bookmarks'), + data={'usage_id': unicode(self.vertical_3.location)} + ) + + # Assert Newly created bookmark. + self.assertEqual(response.data['id'], '%s,%s' % (self.user.username, unicode(self.vertical_3.location))) + self.assertEqual(response.data['course_id'], self.course_id) + self.assertEqual(response.data['usage_id'], unicode(self.vertical_3.location)) + self.assertIsNotNone(response.data['created']) + self.assertEqual(len(response.data['path']), 2) + self.assertEqual(response.data['display_name'], self.vertical_3.display_name) + + def test_post_bookmark_with_invalid_data(self): + """ + Test that posting a bookmark for a block with invalid usage id returns a 400. + Scenarios: + 1) Invalid usage id. + 2) Without usage id. + 3) With empty request.DATA + """ + # Send usage_id with invalid format. + response = self.send_post( + client=self.client, + url=reverse('bookmarks'), + data={'usage_id': 'invalid'}, + expected_status=400 + ) + self.assertEqual(response.data['user_message'], u'Invalid usage_id: invalid.') + + # Send data without usage_id. + response = self.send_post( + client=self.client, + url=reverse('bookmarks'), + data={'course_id': 'invalid'}, + expected_status=400 + ) + self.assertEqual(response.data['user_message'], u'Parameter usage_id not provided.') + self.assertEqual(response.data['developer_message'], u'Parameter usage_id not provided.') + + # Send empty data dictionary. + response = self.send_post( + client=self.client, + url=reverse('bookmarks'), + data={}, + expected_status=400 + ) + self.assertEqual(response.data['user_message'], u'No data provided.') + self.assertEqual(response.data['developer_message'], u'No data provided.') + + def test_post_bookmark_for_non_existing_block(self): + """ + Test that posting a bookmark for a block that does not exist returns a 400. + """ + response = self.send_post( + client=self.client, + url=reverse('bookmarks'), + data={'usage_id': 'i4x://arbi/100/html/340ef1771a094090ad260ec940d04a21'}, + expected_status=400 + ) + self.assertEqual( + response.data['user_message'], + u'Block with usage_id: i4x://arbi/100/html/340ef1771a094090ad260ec940d04a21 not found.' + ) + self.assertEqual( + response.data['developer_message'], + u'Block with usage_id: i4x://arbi/100/html/340ef1771a094090ad260ec940d04a21 not found.' + ) + + def test_unsupported_methods(self): + """ + Test that DELETE and PUT are not supported. + """ + self.client.login(username=self.user.username, password=self.test_password) + self.assertEqual(405, self.client.put(reverse('bookmarks')).status_code) + self.assertEqual(405, self.client.delete(reverse('bookmarks')).status_code) + + +@ddt.ddt +class BookmarksDetailViewTests(BookmarksViewTestsMixin): + """ + This contains the tests for GET & DELETE methods of bookmark.views.BookmarksDetailView class + """ + @ddt.data( + ('', False), + ('fields=path,display_name', True) + ) + @ddt.unpack + def test_get_bookmark_successfully(self, query_params, check_optionals): + """ + Test that requesting bookmark returns data with 200 code. + """ + response = self.send_get( + client=self.client, + url=reverse( + 'bookmarks_detail', + kwargs={'username': self.user.username, 'usage_id': unicode(self.vertical_1.location)} + ), + query_parameters=query_params + ) + data = response.data + self.assertIsNotNone(data) + self.assert_valid_bookmark_response(data, self.bookmark_1, optional_fields=check_optionals) + + def test_get_bookmark_that_belongs_to_other_user(self): + """ + Test that requesting bookmark that belongs to other user returns 404 status code. + """ + self.send_get( + client=self.client, + url=reverse( + 'bookmarks_detail', + kwargs={'username': 'other', 'usage_id': unicode(self.vertical_1.location)} + ), + expected_status=404 + ) + + def test_get_bookmark_that_does_not_exist(self): + """ + Test that requesting bookmark that does not exist returns 404 status code. + """ + response = self.send_get( + client=self.client, + url=reverse( + 'bookmarks_detail', + kwargs={'username': self.user.username, 'usage_id': 'i4x://arbi/100/html/340ef1771a0940'} + ), + expected_status=404 + ) + self.assertEqual( + response.data['user_message'], + 'Bookmark with usage_id: i4x://arbi/100/html/340ef1771a0940 does not exist.' + ) + self.assertEqual( + response.data['developer_message'], + 'Bookmark with usage_id: i4x://arbi/100/html/340ef1771a0940 does not exist.' + ) + + def test_get_bookmark_with_invalid_usage_id(self): + """ + Test that requesting bookmark with invalid usage id returns 400. + """ + response = self.send_get( + client=self.client, + url=reverse( + 'bookmarks_detail', + kwargs={'username': self.user.username, 'usage_id': 'i4x'} + ), + expected_status=404 + ) + self.assertEqual(response.data['user_message'], u'Invalid usage_id: i4x.') + + def test_anonymous_access(self): + """ + Test that an anonymous client (not logged in) cannot call GET or DELETE. + """ + url = reverse('bookmarks_detail', kwargs={'username': self.user.username, 'usage_id': 'i4x'}) + self.send_get( + client=self.anonymous_client, + url=url, + expected_status=401 + ) + self.send_delete( + client=self.anonymous_client, + url=url, + expected_status=401 + ) + + def test_delete_bookmark_successfully(self): + """ + Test that delete bookmark returns 204 status code with success. + """ + query_parameters = 'course_id={}'.format(urllib.quote(self.course_id)) + response = self.send_get(client=self.client, url=reverse('bookmarks'), query_parameters=query_parameters) + data = response.data + bookmarks = data['results'] + self.assertEqual(len(bookmarks), 2) + + self.send_delete( + client=self.client, + url=reverse( + 'bookmarks_detail', + kwargs={'username': self.user.username, 'usage_id': unicode(self.vertical_1.location)} + ) + ) + response = self.send_get(client=self.client, url=reverse('bookmarks'), query_parameters=query_parameters) + bookmarks = response.data['results'] + + self.assertEqual(len(bookmarks), 1) + + def test_delete_bookmark_that_belongs_to_other_user(self): + """ + Test that delete bookmark that belongs to other user returns 404. + """ + self.send_delete( + client=self.client, + url=reverse( + 'bookmarks_detail', + kwargs={'username': 'other', 'usage_id': unicode(self.vertical_1.location)} + ), + expected_status=404 + ) + + def test_delete_bookmark_that_does_not_exist(self): + """ + Test that delete bookmark that does not exist returns 404. + """ + response = self.send_delete( + client=self.client, + url=reverse( + 'bookmarks_detail', + kwargs={'username': self.user.username, 'usage_id': 'i4x://arbi/100/html/340ef1771a0940'} + ), + expected_status=404 + ) + self.assertEqual( + response.data['user_message'], + u'Bookmark with usage_id: i4x://arbi/100/html/340ef1771a0940 does not exist.' + ) + self.assertEqual( + response.data['developer_message'], + 'Bookmark with usage_id: i4x://arbi/100/html/340ef1771a0940 does not exist.' + ) + + def test_delete_bookmark_with_invalid_usage_id(self): + """ + Test that delete bookmark with invalid usage id returns 400. + """ + response = self.send_delete( + client=self.client, + url=reverse( + 'bookmarks_detail', + kwargs={'username': self.user.username, 'usage_id': 'i4x'} + ), + expected_status=404 + ) + self.assertEqual(response.data['user_message'], u'Invalid usage_id: i4x.') + + def test_unsupported_methods(self): + """ + Test that POST and PUT are not supported. + """ + url = reverse('bookmarks_detail', kwargs={'username': self.user.username, 'usage_id': 'i4x'}) + self.client.login(username=self.user.username, password=self.test_password) + self.assertEqual(405, self.client.put(url).status_code) + self.assertEqual(405, self.client.post(url).status_code) diff --git a/lms/djangoapps/bookmarks/urls.py b/lms/djangoapps/bookmarks/urls.py new file mode 100644 index 0000000000..2b0553df40 --- /dev/null +++ b/lms/djangoapps/bookmarks/urls.py @@ -0,0 +1,26 @@ +""" +URL routes for the bookmarks app. +""" + +from django.conf import settings +from django.conf.urls import patterns, url + +from .views import BookmarksListView, BookmarksDetailView + + +urlpatterns = patterns( + 'bookmarks', + url( + r'^v1/bookmarks/$', + BookmarksListView.as_view(), + name='bookmarks' + ), + url( + r'^v1/bookmarks/{username},{usage_key}/$'.format( + username=settings.USERNAME_PATTERN, + usage_key=settings.USAGE_ID_PATTERN + ), + BookmarksDetailView.as_view(), + name='bookmarks_detail' + ), +) diff --git a/lms/djangoapps/bookmarks/views.py b/lms/djangoapps/bookmarks/views.py new file mode 100644 index 0000000000..985e4afb4e --- /dev/null +++ b/lms/djangoapps/bookmarks/views.py @@ -0,0 +1,300 @@ +""" +HTTP end-points for the Bookmarks API. + +For more information, see: +https://openedx.atlassian.net/wiki/display/TNL/Bookmarks+API +""" +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 OAuth2Authentication, SessionAuthentication +from rest_framework.generics import ListCreateAPIView +from rest_framework.response import Response +from rest_framework.views import APIView + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey + +from openedx.core.lib.api.permissions import IsUserInUrl +from openedx.core.lib.api.serializers import PaginationSerializer + +from xmodule.modulestore.exceptions import ItemNotFoundError + +from lms.djangoapps.lms_xblock.runtime import unquote_slashes + +from . import DEFAULT_FIELDS, OPTIONAL_FIELDS, api +from .serializers import BookmarkSerializer + +log = logging.getLogger(__name__) + + +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, message, 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. + """ + return Response( + { + "developer_message": message, + "user_message": _(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=" + 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=". + + 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": } + + **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": , 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) + permission_classes = (permissions.IsAuthenticated,) + + paginate_by = 10 + max_paginate_by = 500 + paginate_by_param = 'page_size' + pagination_serializer_class = PaginationSerializer + 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, serialized=False) + + def post(self, request): + """ + POST /api/bookmarks/v1/bookmarks/ + Request data: {"usage_id": ""} + """ + if not request.DATA: + return self.error_response(ugettext_noop(u'No data provided.')) + + usage_id = request.DATA.get('usage_id', None) + if not usage_id: + return self.error_response(ugettext_noop(u'Parameter usage_id not provided.')) + + 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) + + 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) + + 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": , 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, 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, 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, status.HTTP_404_NOT_FOUND) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/lms/djangoapps/mobile_api/users/urls.py b/lms/djangoapps/mobile_api/users/urls.py index 093f1f65ac..64f1ad4a55 100644 --- a/lms/djangoapps/mobile_api/users/urls.py +++ b/lms/djangoapps/mobile_api/users/urls.py @@ -6,17 +6,16 @@ from django.conf import settings from .views import UserDetail, UserCourseEnrollmentsList, UserCourseStatus -USERNAME_PATTERN = r'(?P[\w.+-]+)' urlpatterns = patterns( 'mobile_api.users.views', - url('^' + USERNAME_PATTERN + '$', UserDetail.as_view(), name='user-detail'), + url('^' + settings.USERNAME_PATTERN + '$', UserDetail.as_view(), name='user-detail'), url( - '^' + USERNAME_PATTERN + '/course_enrollments/$', + '^' + settings.USERNAME_PATTERN + '/course_enrollments/$', UserCourseEnrollmentsList.as_view(), name='courseenrollment-detail' ), - url('^{}/course_status_info/{}'.format(USERNAME_PATTERN, settings.COURSE_ID_PATTERN), + url('^{}/course_status_info/{}'.format(settings.USERNAME_PATTERN, settings.COURSE_ID_PATTERN), UserCourseStatus.as_view(), name='user-course-status') ) diff --git a/lms/envs/common.py b/lms/envs/common.py index c3a4b23e53..1e3951e9b2 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -574,6 +574,7 @@ USAGE_KEY_PATTERN = r'(?P(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@ ASSET_KEY_PATTERN = r'(?P(?:/?c4x(:/)?/[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))' USAGE_ID_PATTERN = r'(?P(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))' +USERNAME_PATTERN = r'(?P[\w.@+-]+)' ############################## EVENT TRACKING ################################# LMS_SEGMENT_KEY = None @@ -1906,6 +1907,9 @@ INSTALLED_APPS = ( 'xblock_django', + # Bookmarks + 'bookmarks', + # programs support 'openedx.core.djangoapps.programs', diff --git a/lms/urls.py b/lms/urls.py index 1d7d984fdd..39bd78fb60 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -89,6 +89,9 @@ urlpatterns = ( # User API endpoints url(r'^api/user/', include('openedx.core.djangoapps.user_api.urls')), + # Bookmarks API endpoints + url(r'^api/bookmarks/', include('bookmarks.urls')), + # Profile Images API endpoints url(r'^api/profile_images/', include('openedx.core.djangoapps.profile_images.urls')), diff --git a/openedx/core/djangoapps/profile_images/urls.py b/openedx/core/djangoapps/profile_images/urls.py index 65f72a6458..9f922e59d6 100644 --- a/openedx/core/djangoapps/profile_images/urls.py +++ b/openedx/core/djangoapps/profile_images/urls.py @@ -9,18 +9,18 @@ NOTE: These views are deprecated. These routes are superseded by from django.conf.urls import patterns, url from .views import ProfileImageUploadView, ProfileImageRemoveView +from django.conf import settings -USERNAME_PATTERN = r'(?P[\w.+-]+)' urlpatterns = patterns( '', url( - r'^v1/' + USERNAME_PATTERN + '/upload$', + r'^v1/' + settings.USERNAME_PATTERN + '/upload$', ProfileImageUploadView.as_view(), name="profile_image_upload" ), url( - r'^v1/' + USERNAME_PATTERN + '/remove$', + r'^v1/' + settings.USERNAME_PATTERN + '/remove$', ProfileImageRemoveView.as_view(), name="profile_image_remove" ), diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index 8f143a741a..b0d9c80e94 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -8,6 +8,8 @@ from ..profile_images.views import ProfileImageView from .accounts.views import AccountView from .preferences.views import PreferencesView, PreferencesDetailView +from django.conf.urls import patterns, url + USERNAME_PATTERN = r'(?P[\w.+-]+)' urlpatterns = patterns(