diff --git a/cms/djangoapps/contentstore/rules.py b/cms/djangoapps/contentstore/rules.py new file mode 100644 index 0000000000000000000000000000000000000000..14cb77bc12ec70be8fbf71049c1c2153c57a1a94 --- /dev/null +++ b/cms/djangoapps/contentstore/rules.py @@ -0,0 +1,9 @@ +""" +Authorization rules related to content management. +""" + +from __future__ import absolute_import, unicode_literals + +import user_tasks.rules + +user_tasks.rules.add_rules() diff --git a/cms/envs/common.py b/cms/envs/common.py index 07e17c727cabfa9bd14c96abe079da855561e767..6430250e70b27c4cf4c982aad2a31ca65698dd80 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -39,9 +39,12 @@ When refering to XBlocks, we use the entry-point name. For example, # want to import all variables from base settings files # pylint: disable=unused-import +from __future__ import absolute_import + import imp import os import sys +from datetime import timedelta import lms.envs.common # Although this module itself may not use these imported variables, other dependent modules may. from lms.envs.common import ( @@ -300,6 +303,7 @@ LOGIN_URL = EDX_ROOT_URL + '/signin' # use the ratelimit backend to prevent brute force attacks AUTHENTICATION_BACKENDS = ( + 'rules.permissions.ObjectPermissionBackend', 'ratelimitbackend.backends.RateLimitModelBackend', ) @@ -933,7 +937,13 @@ INSTALLED_APPS = ( 'django_sites_extensions', # additional release utilities to ease automation - 'release_util' + 'release_util', + + # rule-based authorization + 'rules.apps.AutodiscoverRulesConfig', + + # management of user-triggered async tasks (course import/export, etc.) + 'user_tasks', ) @@ -1212,3 +1222,8 @@ OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' # Used with Email sending RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS = 5 RETRY_ACTIVATION_EMAIL_TIMEOUT = 0.5 + +############## DJANGO-USER-TASKS ############## + +# How long until database records about the outcome of a task and its artifacts get deleted? +USER_TASKS_MAX_AGE = timedelta(days=7) diff --git a/cms/tests/test_user_tasks.py b/cms/tests/test_user_tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..ed924d66fc717a2447f0a9be869bc190879ffe71 --- /dev/null +++ b/cms/tests/test_user_tasks.py @@ -0,0 +1,107 @@ +""" +Unit tests for integration of the django-user-tasks app and its REST API. +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from uuid import uuid4 + +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from django.test import override_settings + +from rest_framework.test import APITestCase +from user_tasks.models import UserTaskArtifact, UserTaskStatus +from user_tasks.serializers import ArtifactSerializer, StatusSerializer + + +# Helper functions for stuff that pylint complains about without disable comments + +def _context(response): + """ + Get a context dictionary for a serializer appropriate for the given response. + """ + return {'request': response.wsgi_request} # pylint: disable=no-member + + +def _data(response): + """ + Get the serialized data dictionary from the given REST API test response. + """ + return response.data # pylint: disable=no-member + + +@override_settings(BROKER_URL='memory://localhost/') +class TestUserTasks(APITestCase): + """ + Tests of the django-user-tasks REST API endpoints. + + Detailed tests of the default authorization rules are in the django-user-tasks code. + These tests just verify that the API is exposed and functioning. + """ + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user('test_user', 'test@example.com', 'password') + cls.status = UserTaskStatus.objects.create( + user=cls.user, task_id=str(uuid4()), task_class='test_rest_api.sample_task', name='SampleTask 2', + total_steps=5) + cls.artifact = UserTaskArtifact.objects.create(status=cls.status, text='Lorem ipsum') + + def setUp(self): + super(TestUserTasks, self).setUp() + self.status.refresh_from_db() + self.client.force_authenticate(self.user) # pylint: disable=no-member + + def test_artifact_detail(self): + """ + Users should be able to access artifacts for tasks they triggered. + """ + response = self.client.get(reverse('usertaskartifact-detail', args=[self.artifact.uuid])) + assert response.status_code == 200 + serializer = ArtifactSerializer(self.artifact, context=_context(response)) + assert _data(response) == serializer.data + + def test_artifact_list(self): + """ + Users should be able to access a list of their tasks' artifacts. + """ + response = self.client.get(reverse('usertaskartifact-list')) + assert response.status_code == 200 + serializer = ArtifactSerializer(self.artifact, context=_context(response)) + assert _data(response)['results'] == [serializer.data] + + def test_status_cancel(self): + """ + Users should be able to cancel tasks they no longer wish to complete. + """ + response = self.client.post(reverse('usertaskstatus-cancel', args=[self.status.uuid])) + assert response.status_code == 200 + self.status.refresh_from_db() + assert self.status.state == UserTaskStatus.CANCELED + + def test_status_delete(self): + """ + Users should be able to delete their own task status records when they're done with them. + """ + response = self.client.delete(reverse('usertaskstatus-detail', args=[self.status.uuid])) + assert response.status_code == 204 + assert not UserTaskStatus.objects.filter(pk=self.status.id).exists() + + def test_status_detail(self): + """ + Users should be able to access status records for tasks they triggered. + """ + response = self.client.get(reverse('usertaskstatus-detail', args=[self.status.uuid])) + assert response.status_code == 200 + serializer = StatusSerializer(self.status, context=_context(response)) + assert _data(response) == serializer.data + + def test_status_list(self): + """ + Users should be able to access a list of their tasks' status records. + """ + response = self.client.get(reverse('usertaskstatus-list')) + assert response.status_code == 200 + serializer = StatusSerializer([self.status], context=_context(response), many=True) + assert _data(response)['results'] == serializer.data diff --git a/cms/urls.py b/cms/urls.py index 8c88027e51ba1c9e733bb1ffe09cbdc082ce0706..083e70a378365d2a2e66a23f7fad694c784bb881 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -113,6 +113,7 @@ urlpatterns += patterns( url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)(/)?(?P<group_id>\d+)?$'.format( settings.COURSE_KEY_PATTERN), 'group_configurations_detail_handler'), url(r'^api/val/v0/', include('edxval.urls')), + url(r'^api/tasks/v0/', include('user_tasks.urls')), ) JS_INFO_DICT = { diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index f8bf2c195abdd55fa01cd099ea5322144f00b837..bda6af8ccc1399ad15ac1734bfe292804746e186 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -34,6 +34,7 @@ django-simple-history==1.6.3 django-statici18n==1.1.5 django-storages==1.4.1 django-method-override==0.1.0 +django-user-tasks==0.1.1 # We need a fix to DRF 3.2.x, for now use it from our own cherry-picked repo #djangorestframework>=3.1,<3.2 git+https://github.com/edx/django-rest-framework.git@3c72cb5ee5baebc4328947371195eae2077197b0#egg=djangorestframework==3.2.3 @@ -94,6 +95,7 @@ pysrt==0.4.7 PyYAML==3.10 requests==2.9.1 requests-oauthlib==0.4.1 +rules==1.1.1 scipy==0.14.0 Shapely==1.2.16 singledispatch==3.4.0.2