From 9fd72a71b864039e875ba45a59484a718206e6f4 Mon Sep 17 00:00:00 2001 From: Cliff Dyer Date: Wed, 30 Aug 2017 17:53:12 -0400 Subject: [PATCH] MCKIN-5415 Interim Completion API (#839) --- lms/djangoapps/completion_api/__init__.py | 0 lms/djangoapps/completion_api/models.py | 266 ++++++++++++++++ lms/djangoapps/completion_api/serializers.py | 69 ++++ .../completion_api/tests/__init__.py | 0 .../completion_api/tests/test_serializers.py | 297 ++++++++++++++++++ .../completion_api/tests/test_views.py | 148 +++++++++ lms/djangoapps/completion_api/urls.py | 11 + lms/djangoapps/completion_api/views.py | 120 +++++++ lms/envs/aws.py | 1 + lms/envs/test.py | 1 + lms/urls.py | 1 + 11 files changed, 914 insertions(+) create mode 100644 lms/djangoapps/completion_api/__init__.py create mode 100644 lms/djangoapps/completion_api/models.py create mode 100644 lms/djangoapps/completion_api/serializers.py create mode 100644 lms/djangoapps/completion_api/tests/__init__.py create mode 100644 lms/djangoapps/completion_api/tests/test_serializers.py create mode 100644 lms/djangoapps/completion_api/tests/test_views.py create mode 100644 lms/djangoapps/completion_api/urls.py create mode 100644 lms/djangoapps/completion_api/views.py diff --git a/lms/djangoapps/completion_api/__init__.py b/lms/djangoapps/completion_api/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/completion_api/models.py b/lms/djangoapps/completion_api/models.py new file mode 100644 index 000000000000..328e4826f464 --- /dev/null +++ b/lms/djangoapps/completion_api/models.py @@ -0,0 +1,266 @@ +""" +Model code for completion API, including django models and facade classes +wrapping progress extension models. +""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +import itertools + +from opaque_keys.edx.keys import UsageKey + +from lms.djangoapps.course_blocks.api import get_course_blocks +from openedx.core.djangoapps.content.block_structure.api import get_course_in_cache +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +from progress.models import CourseModuleCompletion + + +AGGREGATE_CATEGORIES = { + 'course', + 'chapter', + 'sequential', + 'vertical', +} + +IGNORE_CATEGORIES = { + # Non-completable types + 'discussion-course', + 'group-project', + 'discussion-forum', + 'eoc-journal', + + # GP v2 categories + 'gp-v2-project', +} + + +class CompletionDataMixin(object): + """ + Common calculations for completion values of courses or blocks within courses. + + Classes using this mixin must implement: + + * self.earned (float) + * self.blocks (BlockStructureBlockData) + """ + + _completable_blocks = None + + def _recurse_completable_blocks(self, block): + """ + Return a list of all completable blocks within the subtree under `block`. + """ + if block.block_type in IGNORE_CATEGORIES: + return [] + elif block.block_type in AGGREGATE_CATEGORIES: + return list(itertools.chain( + *[self._recurse_completable_blocks(child) + for child in self.blocks.get_children(block)] + )) + else: + return [block] + + @property + def completable_blocks(self): + """ + Return a list of UsageKeys for all blocks that can be completed that are + visible to self.user. + + This method encapsulates the facade's access to the modulestore, making + it a useful candidate for mocking. + + In case the course structure is a DAG, nodes with multiple parents will + be represented multiple times in the list. + """ + if self._completable_blocks is None: + self._completable_blocks = self._recurse_completable_blocks(self.blocks.root_block_usage_key) + return self._completable_blocks + + @property + def possible(self): + """ + Return the maximum number of completions the user could earn in the + course. + """ + return float(len(self.completable_blocks)) + + @property + def ratio(self): + """ + Return the fraction of the course completed by the user. + + Ratio is returned as a float in the range [0.0, 1.0]. + """ + if self.possible == 0: + ratio = 1.0 + else: + ratio = self.earned / self.possible + return ratio + + +class CourseCompletionFacade(CompletionDataMixin, object): + """ + Facade wrapping progress.models.StudentProgress model. + """ + + _blocks = None + _collected = None + _completed_modules = None + + def __init__(self, inner): + self._inner = inner + self._completions_in_category = {} + + @property + def collected(self): + """ + Return the collected block structure for this course + """ + if self._collected is None: + self._collected = get_course_in_cache(self.course_key) + return self._collected + + @property + def blocks(self): + """ + Return an + `openedx.core.lib.block_structure.block_structure.BlockStructureBlockData` + collection which behaves as dict that maps + `opaque_keys.edx.locator.BlockUsageLocator`s to + `openedx.core.lib.block_structure.block_structure.BlockData` objects + for all blocks in the course. + """ + if self._blocks is None: + course_location = CourseOverview.load_from_module_store(self.course_key).location + self._blocks = get_course_blocks( + self.user, + course_location, + collected_block_structure=self.collected, + ) + return self._blocks + + @property + def user(self): + """ + Return the StudentProgress user + """ + return self._inner.user + + @property + def course_key(self): + """ + Return the StudentProgress course_key + """ + return self._inner.course_id + + @property + def earned(self): + """ + Return the number of completions earned by the user. + """ + return self._inner.completions + + def iter_block_keys_in_category(self, category): + """ + Yields the UsageKey for all blocks of the specified category. + """ + return (block for block in self.blocks if block.block_type == category) + + def get_completions_in_category(self, category): + """ + Returns a list of BlockCompletions for each block of the requested category. + """ + if category not in self._completions_in_category: + completions = [ + BlockCompletion(self.user, block_key, self) for block_key in self.iter_block_keys_in_category(category) + ] + self._completions_in_category[category] = completions + return self._completions_in_category[category] + + @property + def completed_modules(self): + """ + Returns a list of usage keys for modules that have been completed. + """ + if self._completed_modules is None: + modules = CourseModuleCompletion.objects.filter( + user=self.user, + course_id=self.course_key + ) + self._completed_modules = { + UsageKey.from_string(mod.content_id).map_into_course(self.course_key) + for mod in modules + } + return self._completed_modules + + @property + def chapter(self): + """ + Return a list of BlockCompletions for each chapter in the course. + """ + return self.get_completions_in_category('chapter') + + @property + def sequential(self): + """ + Return a list of BlockCompletions for each sequential in the course. + """ + return self.get_completions_in_category('sequential') + + @property + def vertical(self): + """ + Return a list of BlockCompletions for each vertical in the course. + """ + return self.get_completions_in_category('vertical') + + +class BlockCompletion(CompletionDataMixin, object): + """ + Class to represent completed blocks within a given block of the course. + """ + + def __init__(self, user, block_key, course_completion): + self.user = user + self.block_key = block_key + self.course_key = block_key.course_key + self.course_completion = course_completion + self._blocks = None + self._completable_blocks = None + self._completed_blocks = None + + @property + def blocks(self): + """ + Return an `openedx.core.lib.block_structure.block_structure.BlockStructureBlockData` + object which behaves as dict that maps `opaque_keys.edx.locator.BlockUsageLocator`s + to `openedx.core.lib.block_structure.block_structure.BlockData` objects + for all blocks in the sub-tree (or DAG) under self.block_key. + """ + if self._blocks is None: + + self._blocks = get_course_blocks( + self.user, + self.block_key, + collected_block_structure=self.course_completion.collected, + ) + return self._blocks + + @property + def completed_blocks(self): + """ + Return the list of UsageKeys of all blocks within self.block that have been + completed by self.user. + """ + if self._completed_blocks is None: + modules = self.course_completion.completed_modules + self._completed_blocks = [blk for blk in self.completable_blocks if blk in modules] + return self._completed_blocks + + @property + def earned(self): + """ + The number of earned completions within self.block. + """ + return float(len(self.completed_blocks)) diff --git a/lms/djangoapps/completion_api/serializers.py b/lms/djangoapps/completion_api/serializers.py new file mode 100644 index 000000000000..3d2d824a29d0 --- /dev/null +++ b/lms/djangoapps/completion_api/serializers.py @@ -0,0 +1,69 @@ +""" +Serializers for the completion api +""" + +#pylint: disable=abstract-method + +from __future__ import absolute_import, division, print_function, unicode_literals + +from rest_framework import serializers +import six + + +class _CompletionSerializer(serializers.Serializer): + """ + Inner serializer for actual completion data. + """ + earned = serializers.FloatField() + possible = serializers.FloatField() + ratio = serializers.FloatField() + + +class CourseCompletionSerializer(serializers.Serializer): + """ + Serialize completions at the course level. + """ + course_key = serializers.CharField() + completion = _CompletionSerializer(source='*') + + +class BlockCompletionSerializer(serializers.Serializer): + """ + A serializer that represents nested aggregations of sub-graphs + of xblocks. + """ + course_key = serializers.CharField() + block_key = serializers.CharField() + completion = _CompletionSerializer(source='*') + + +def native_identifier(string): + """ + Convert identifiers to the the native str type for the current version of + python. This is required for the first argument to three-argument-`type()`. + + This function expects all identifiers comprise only ascii characters. + """ + if six.PY2: + if isinstance(string, unicode): + # Python 2 identifiers are required to be ascii + string = string.encode('ascii') + elif isinstance(string, bytes): + # Python 3 identifiers can technically be non-ascii, but don't. + string = string.decode('ascii') + return string + + +def course_completion_serializer_factory(requested_fields): + """ + Create a CourseCompletionSerializer that nests appropriate + BlockCompletionSerializers for the specified requested_fields. + """ + dunder_dict = { + field: BlockCompletionSerializer(many=True) for field in requested_fields + } + return type( + native_identifier('CourseCompletionSerializerWithAggregates'), + (CourseCompletionSerializer,), + dunder_dict, + ) diff --git a/lms/djangoapps/completion_api/tests/__init__.py b/lms/djangoapps/completion_api/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/completion_api/tests/test_serializers.py b/lms/djangoapps/completion_api/tests/test_serializers.py new file mode 100644 index 000000000000..1c5a770c12d9 --- /dev/null +++ b/lms/djangoapps/completion_api/tests/test_serializers.py @@ -0,0 +1,297 @@ +""" +Test serialization of completion data. +""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +from operator import itemgetter + +import ddt +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.test.utils import override_settings + +from opaque_keys.edx.keys import CourseKey, UsageKey +from progress import models + +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import ToyCourseFactory +from ..serializers import course_completion_serializer_factory +from ..models import CourseCompletionFacade + + +User = get_user_model() # pylint: disable=invalid-name + + +class MockCourseCompletion(CourseCompletionFacade): + """ + Provide CourseCompletion info without hitting the modulestore. + """ + def __init__(self, progress): + super(MockCourseCompletion, self).__init__(progress) + self._possible = 19 + + @property + def possible(self): + """ + Make up a number of possible blocks. This prevents completable_blocks + from being called, which prevents hitting the modulestore. + """ + return self._possible + + @possible.setter + def possible(self, value): # pylint: disable=arguments-differ + self._possible = value + + @property + def sequential(self): + return [ + {'course_key': self.course_key, 'block_key': 'block1', 'earned': 6.0, 'possible': 7.0, 'ratio': 6 / 7}, + {'course_key': self.course_key, 'block_key': 'block2', 'earned': 10.0, 'possible': 12.0, 'ratio': 5 / 6}, + ] + + +@ddt.ddt +class CourseCompletionSerializerTestCase(TestCase): + """ + Test that the CourseCompletionSerializer returns appropriate results. + """ + + def setUp(self): + super(CourseCompletionSerializerTestCase, self).setUp() + self.test_user = User.objects.create( + username='test_user', + email='test_user@example.com', + ) + + @ddt.data( + [course_completion_serializer_factory([]), {}], + [ + course_completion_serializer_factory(['sequential']), + { + 'sequential': [ + { + 'course_key': 'course-v1:abc+def+ghi', + 'block_key': 'block1', + 'completion': {'earned': 6.0, 'possible': 7.0, 'ratio': 6 / 7}, + }, + { + 'course_key': 'course-v1:abc+def+ghi', + 'block_key': 'block2', + 'completion': {'earned': 10.0, 'possible': 12.0, 'ratio': 5 / 6}, + }, + ] + } + ] + ) + @ddt.unpack + def test_serialize_student_progress_object(self, serializer_cls, extra_body): + progress = models.StudentProgress.objects.create( + user=self.test_user, + course_id=CourseKey.from_string('course-v1:abc+def+ghi'), + completions=16, + ) + completion = MockCourseCompletion(progress) + serial = serializer_cls(completion) + expected = { + 'course_key': 'course-v1:abc+def+ghi', + 'completion': { + 'earned': 16.0, + 'possible': 19.0, + 'ratio': 16 / 19, + }, + } + expected.update(extra_body) + self.assertEqual( + serial.data, + expected, + ) + + def test_zero_possible(self): + progress = models.StudentProgress.objects.create( + user=self.test_user, + course_id=CourseKey.from_string('course-v1:abc+def+ghi'), + completions=0, + ) + completion = MockCourseCompletion(progress) + completion.possible = 0 + serial = course_completion_serializer_factory([])(completion) + self.assertEqual( + serial.data['completion'], + { + 'earned': 0.0, + 'possible': 0.0, + 'ratio': 1.0, + }, + ) + + +@override_settings(STUDENT_GRADEBOOK=True) +class ToyCourseCompletionTestCase(SharedModuleStoreTestCase): + """ + Test that the CourseCompletionFacade handles modulestore data appropriately, + and that it interacts properly with the serializer. + """ + + @classmethod + def setUpClass(cls): + super(ToyCourseCompletionTestCase, cls).setUpClass() + cls.course = ToyCourseFactory.create() + + def setUp(self): + super(ToyCourseCompletionTestCase, self).setUp() + self.test_user = User.objects.create( + username='test_user', + email='test_user@example.com' + ) + + def test_no_completions(self): + progress = models.StudentProgress.objects.create( + user=self.test_user, + course_id=self.course.id, + completions=0, + ) + completion = CourseCompletionFacade(progress) + self.assertEqual(completion.earned, 0.0) + self.assertEqual(completion.possible, 12.0) + serial = course_completion_serializer_factory([])(completion) + self.assertEqual( + serial.data, + { + 'course_key': 'edX/toy/2012_Fall', + 'completion': { + 'earned': 0.0, + 'possible': 12.0, + 'ratio': 0.0, + } + } + ) + + def test_with_completions(self): + progress = models.StudentProgress.objects.create( + user=self.test_user, + course_id=self.course.id, + completions=3, + ) + completion = CourseCompletionFacade(progress) + self.assertEqual(completion.earned, 3) + self.assertEqual(completion.possible, 12) + # A sequential exists, but isn't included in the output + self.assertEqual(len(completion.sequential), 1) + serial = course_completion_serializer_factory([])(completion) + self.assertEqual( + serial.data, + { + 'course_key': 'edX/toy/2012_Fall', + 'completion': { + 'earned': 3.0, + 'possible': 12.0, + 'ratio': 1 / 4, + } + } + ) + + def test_with_sequentials(self): + block_key = UsageKey.from_string("i4x://edX/toy/video/sample_video") + block_key = block_key.map_into_course(self.course.id) + models.CourseModuleCompletion.objects.create( + user=self.test_user, + course_id=self.course.id, + content_id=block_key, + ) + progress = models.StudentProgress.objects.create( + user=self.test_user, + course_id=self.course.id, + completions=1, + ) + completion = CourseCompletionFacade(progress) + serial = course_completion_serializer_factory(['sequential'])(completion) + self.assertEqual( + serial.data, + { + 'course_key': 'edX/toy/2012_Fall', + 'completion': { + 'earned': 1.0, + 'possible': 12.0, + 'ratio': 1 / 12, + }, + 'sequential': [ + { + 'course_key': u'edX/toy/2012_Fall', + 'block_key': u'i4x://edX/toy/sequential/vertical_sequential', + 'completion': {'earned': 1.0, 'possible': 5.0, 'ratio': 0.20}, + }, + ] + } + ) + + def test_with_all_requested_fields(self): + block_key = UsageKey.from_string("i4x://edX/toy/video/sample_video") + block_key = block_key.map_into_course(self.course.id) + models.CourseModuleCompletion.objects.create( + user=self.test_user, + course_id=self.course.id, + content_id=block_key, + ) + progress = models.StudentProgress.objects.create( + user=self.test_user, + course_id=self.course.id, + completions=1, + ) + completion = CourseCompletionFacade(progress) + serial = course_completion_serializer_factory(['chapter', 'sequential', 'vertical'])(completion) + data = serial.data + # Modulestore returns the blocks in non-deterministic order. + # Don't require a particular ordering here. + chapters = sorted(data.pop('chapter'), key=itemgetter('block_key')) + self.assertEqual( + chapters, + [ + { + 'course_key': u'edX/toy/2012_Fall', + 'block_key': u'i4x://edX/toy/chapter/Overview', + 'completion': {'earned': 0.0, 'possible': 4.0, 'ratio': 0.0}, + }, + { + 'course_key': u'edX/toy/2012_Fall', + 'block_key': u'i4x://edX/toy/chapter/handout_container', + 'completion': {'earned': 0.0, 'possible': 1.0, 'ratio': 0.0}, + }, + { + 'course_key': u'edX/toy/2012_Fall', + 'block_key': u'i4x://edX/toy/chapter/poll_test', + 'completion': {'earned': 0.0, 'possible': 1.0, 'ratio': 0.0}, + }, + { + 'course_key': u'edX/toy/2012_Fall', + 'block_key': u'i4x://edX/toy/chapter/secret:magic', + 'completion': {'earned': 0.0, 'possible': 1.0, 'ratio': 0.0}, + }, + { + 'course_key': u'edX/toy/2012_Fall', + 'block_key': u'i4x://edX/toy/chapter/vertical_container', + 'completion': {'earned': 1.0, 'possible': 5.0, 'ratio': 0.2}, + }, + ] + ) + self.assertEqual( + data, + { + 'course_key': u'edX/toy/2012_Fall', + 'completion': {'earned': 1.0, 'possible': 12.0, 'ratio': 1 / 12}, + u'sequential': [ + { + 'course_key': u'edX/toy/2012_Fall', + 'block_key': u'i4x://edX/toy/sequential/vertical_sequential', + 'completion': {'earned': 1.0, 'possible': 5.0, 'ratio': 0.2}, + }, + ], + u'vertical': [ + { + 'course_key': u'edX/toy/2012_Fall', + 'block_key': u'i4x://edX/toy/vertical/vertical_test', + 'completion': {'earned': 1.0, 'possible': 4.0, 'ratio': 0.25} + } + ] + } + ) diff --git a/lms/djangoapps/completion_api/tests/test_views.py b/lms/djangoapps/completion_api/tests/test_views.py new file mode 100644 index 000000000000..40746dd2bba7 --- /dev/null +++ b/lms/djangoapps/completion_api/tests/test_views.py @@ -0,0 +1,148 @@ +""" +Test serialization of completion data. +""" +from __future__ import absolute_import, division, print_function, unicode_literals + +from django.test.utils import override_settings +from rest_framework.test import APIClient + +from opaque_keys.edx.keys import UsageKey +from progress import models + +from student.tests.factories import AdminFactory, UserFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import ToyCourseFactory + + +@override_settings(STUDENT_GRADEBOOK=True) +class CompletionViewTestCase(SharedModuleStoreTestCase): + """ + Test that the CourseCompletionFacade handles modulestore data appropriately, + and that it interacts properly with the serializer. + """ + + @classmethod + def setUpClass(cls): + super(CompletionViewTestCase, cls).setUpClass() + cls.course = ToyCourseFactory.create() + + def setUp(self): + super(CompletionViewTestCase, self).setUp() + self.test_user = UserFactory.create( + username='test_user', + email='test_user@example.com', + password='test_pass', + ) + self.mark_completions() + self.client = APIClient() + self.client.force_authenticate(user=self.test_user) + + def mark_completions(self): + """ + Create completion data to test against. + """ + models.CourseModuleCompletion.objects.create( + user=self.test_user, + course_id=self.course.id, + content_id=UsageKey.from_string('i4x://edX/toy/video/sample_video').map_into_course(self.course.id), + ) + models.StudentProgress.objects.create( + user=self.test_user, + course_id=self.course.id, + completions=1, + ) + + def test_list_view(self): + response = self.client.get('/api/completion/v0/course/') + self.assertEqual(response.status_code, 200) + expected = { + 'pagination': {'count': 1, 'previous': None, 'num_pages': 1, 'next': None}, + 'results': [ + { + 'course_key': 'edX/toy/2012_Fall', + 'completion': { + 'earned': 1.0, + 'possible': 12.0, + 'ratio': 1 / 12, + }, + } + ], + } + self.assertEqual(response.data, expected) # pylint: disable=no-member + + def test_list_view_with_sequentials(self): + response = self.client.get('/api/completion/v0/course/?requested_fields=sequential') + self.assertEqual(response.status_code, 200) + expected = { + 'pagination': {'count': 1, 'previous': None, 'num_pages': 1, 'next': None}, + 'results': [ + { + 'course_key': 'edX/toy/2012_Fall', + 'completion': { + 'earned': 1.0, + 'possible': 12.0, + 'ratio': 1 / 12, + }, + 'sequential': [ + { + 'course_key': u'edX/toy/2012_Fall', + 'block_key': u'i4x://edX/toy/sequential/vertical_sequential', + 'completion': {'earned': 1.0, 'possible': 5.0, 'ratio': 0.2}, + }, + ] + } + ], + } + self.assertEqual(response.data, expected) # pylint: disable=no-member + + def test_detail_view(self): + response = self.client.get('/api/completion/v0/course/edX/toy/2012_Fall/') + self.assertEqual(response.status_code, 200) + expected = { + 'course_key': 'edX/toy/2012_Fall', + 'completion': { + 'earned': 1.0, + 'possible': 12.0, + 'ratio': 1 / 12, + }, + } + self.assertEqual(response.data, expected) # pylint: disable=no-member + + def test_detail_view_with_sequentials(self): + response = self.client.get('/api/completion/v0/course/edX/toy/2012_Fall/?requested_fields=sequential') + self.assertEqual(response.status_code, 200) + expected = { + 'course_key': 'edX/toy/2012_Fall', + 'completion': { + 'earned': 1.0, + 'possible': 12.0, + 'ratio': 1 / 12, + }, + 'sequential': [ + { + 'course_key': u'edX/toy/2012_Fall', + 'block_key': u'i4x://edX/toy/sequential/vertical_sequential', + 'completion': {'earned': 1.0, 'possible': 5.0, 'ratio': 0.2}, + }, + ] + } + self.assertEqual(response.data, expected) # pylint: disable=no-member + + def test_unauthenticated(self): + self.client.force_authenticate(None) + detailresponse = self.client.get('/api/completion/v0/course/edX/toy/2012_Fall/') + self.assertEqual(detailresponse.status_code, 403) + listresponse = self.client.get('/api/completion/v0/course/') + self.assertEqual(listresponse.status_code, 403) + + def test_wrong_user(self): + user = UserFactory.create(username='wrong') + self.client.force_authenticate(user) + response = self.client.get('/api/completion/v0/course/?user=test_user') + self.assertEqual(response.status_code, 404) + + def test_staff_access(self): + user = AdminFactory.create(username='staff') + self.client.force_authenticate(user) + response = self.client.get('/api/completion/v0/course/?user=test_user') + self.assertEqual(response.status_code, 200) diff --git a/lms/djangoapps/completion_api/urls.py b/lms/djangoapps/completion_api/urls.py new file mode 100644 index 000000000000..1092c19711de --- /dev/null +++ b/lms/djangoapps/completion_api/urls.py @@ -0,0 +1,11 @@ +""" +URLs for the completion API +""" + +from django.conf.urls import url +from . import views + +urlpatterns = [ + url(r'^course/$', views.CompletionListView.as_view()), + url(r'^course/(?P.*)/$', views.CompletionDetailView.as_view()), +] diff --git a/lms/djangoapps/completion_api/views.py b/lms/djangoapps/completion_api/views.py new file mode 100644 index 000000000000..bf9bcfe3ba7b --- /dev/null +++ b/lms/djangoapps/completion_api/views.py @@ -0,0 +1,120 @@ +""" +API views to read completion information. +""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +from django.contrib.auth import get_user_model +from opaque_keys.edx.keys import CourseKey +from progress.models import StudentProgress +from rest_framework.exceptions import NotAuthenticated, NotFound, ParseError, PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from openedx.core.lib.api import authentication, paginators + +from .models import CourseCompletionFacade, AGGREGATE_CATEGORIES +from .serializers import course_completion_serializer_factory + +User = get_user_model() # pylint: disable=invalid-name + + +class CompletionViewMixin(object): + """ + Common functionality for completion views. + """ + + _allowed_requested_fields = AGGREGATE_CATEGORIES + + authentication_classes = ( + authentication.OAuth2AuthenticationAllowInactiveUser, + authentication.SessionAuthenticationAllowInactiveUser + ) + permission_classes = (IsAuthenticated,) + + def get_user(self): + """ + Return the effective user. + + Usually the requesting user, but a staff user can override this. + """ + requested_username = self.request.GET.get('user') + if requested_username is None: + user = self.request.user + else: + if self.request.user.is_staff: + try: + user = User.objects.get(username=requested_username) + except User.DoesNotExist: + raise PermissionDenied() + else: + if self.request.user.username.lower() == requested_username.lower(): + user = self.request.user + else: + raise NotFound() + if not user.is_authenticated(): + raise NotAuthenticated() + return user + + def get_progress_queryset(self): + """ + Build a base queryset of relevant StudentProgress objects. + """ + objs = StudentProgress.objects.filter(user=self.get_user()) + return objs + + def get_requested_fields(self): + """ + Parse and return value for requested_fields parameter. + """ + fields = set(field for field in self.request.GET.get('requested_fields', '').split(',') if field) + diff = fields - self._allowed_requested_fields + if diff: + msg = 'Invalid requested_fields value: {}. Allowed values: {}' + raise ParseError(msg.format(fields, self._allowed_requested_fields)) + return fields + + def get_serializer(self): + """ + Return the appropriate serializer. + """ + return course_completion_serializer_factory(self.get_requested_fields()) + + +class CompletionListView(APIView, CompletionViewMixin): + """ + API view to render lists of serialized CourseCompletions. + + This is a transitional implementation that uses the + edx-solutions/progress-edx-platform-extensions models as a backing store. + """ + + pagination_class = paginators.NamespacedPageNumberPagination + + def get(self, request): + """ + Handler for GET requests. + """ + self.paginator = self.pagination_class() # pylint: disable=attribute-defined-outside-init + paginated = self.paginator.paginate_queryset(self.get_progress_queryset(), self.request, view=self) + completions = [CourseCompletionFacade(progress) for progress in paginated] + return self.paginator.get_paginated_response(self.get_serializer()(completions, many=True).data) + + +class CompletionDetailView(APIView, CompletionViewMixin): + """ + API view to render serialized CourseCompletions. + + This is a transitional implementation that uses the + edx-solutions/progress-edx-platform-extensions models as a backing store. + """ + + def get(self, request, course_key): + """ + Handler for GET requests. + """ + course_key = CourseKey.from_string(course_key) + progress = self.get_progress_queryset().get(course_id=course_key) + completion = CourseCompletionFacade(progress) + return Response(self.get_serializer()(completion).data) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index b7abc0dca348..3c1836671dd2 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -828,6 +828,7 @@ 'social_engagement', 'gradebook', 'progress', + 'completion_api', 'edx_solutions_projects', 'edx_solutions_organizations', ) diff --git a/lms/envs/test.py b/lms/envs/test.py index cfb16ea824e6..8b4601056bae 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -579,6 +579,7 @@ 'social_engagement', 'gradebook', 'progress', + 'completion_api', 'edx_solutions_projects', 'edx_solutions_organizations', ) diff --git a/lms/urls.py b/lms/urls.py index 60b1d46fe4d2..5ff53cfeedde 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -128,6 +128,7 @@ if settings.FEATURES.get('EDX_SOLUTIONS_API'): urlpatterns += ( url(r'^api/server/', include('edx_solutions_api_integration.urls')), + url(r'^api/completion/v0/', include('lms.djangoapps.completion_api.urls')), ) # OPEN EDX USER API