From 7fe8e47554dcb75f82f90dadd573b1b740be051f Mon Sep 17 00:00:00 2001 From: Vedran Karacic Date: Thu, 24 Nov 2016 12:58:55 +0000 Subject: [PATCH] [SOL-2133] Add user deactivation endpoint. --- .../migrations/0009_auto_20170111_0422.py | 18 +++ common/djangoapps/student/models.py | 1 + common/djangoapps/student/tests/factories.py | 24 +++- .../user_api/accounts/permissions.py | 15 +++ .../accounts/tests/test_permissions.py | 40 ++++++ .../user_api/accounts/tests/test_views.py | 119 ++++++++++++++---- .../djangoapps/user_api/accounts/views.py | 27 +++- .../user_api/preferences/tests/test_views.py | 40 +++--- openedx/core/djangoapps/user_api/urls.py | 7 +- 9 files changed, 240 insertions(+), 51 deletions(-) create mode 100644 common/djangoapps/student/migrations/0009_auto_20170111_0422.py create mode 100644 openedx/core/djangoapps/user_api/accounts/permissions.py create mode 100644 openedx/core/djangoapps/user_api/accounts/tests/test_permissions.py diff --git a/common/djangoapps/student/migrations/0009_auto_20170111_0422.py b/common/djangoapps/student/migrations/0009_auto_20170111_0422.py new file mode 100644 index 0000000000..44642ce91e --- /dev/null +++ b/common/djangoapps/student/migrations/0009_auto_20170111_0422.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('student', '0008_auto_20161117_1209'), + ] + + operations = [ + migrations.AlterModelOptions( + name='userprofile', + options={'permissions': (('can_deactivate_users', 'Can deactivate, but NOT delete users'),)}, + ), + ] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 689369a7c9..dd718b1843 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -234,6 +234,7 @@ class UserProfile(models.Model): class Meta(object): db_table = "auth_userprofile" + permissions = (("can_deactivate_users", "Can deactivate, but NOT delete users"),) # CRITICAL TODO/SECURITY # Sanitize all fields. diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py index 6f246dc413..792c2124de 100644 --- a/common/djangoapps/student/tests/factories.py +++ b/common/djangoapps/student/tests/factories.py @@ -6,7 +6,8 @@ from student.models import (User, UserProfile, Registration, PendingEmailChange, UserStanding, CourseAccessRole) from course_modes.models import CourseMode -from django.contrib.auth.models import Group, AnonymousUser +from django.contrib.auth.models import AnonymousUser, Group, Permission +from django.contrib.contenttypes.models import ContentType from datetime import datetime import factory from factory import lazy_attribute @@ -18,6 +19,8 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey # Factories are self documenting # pylint: disable=missing-docstring +TEST_PASSWORD = 'test' + class GroupFactory(DjangoModelFactory): class Meta(object): @@ -123,6 +126,10 @@ class AdminFactory(UserFactory): is_staff = True +class SuperuserFactory(UserFactory): + is_superuser = True + + class CourseEnrollmentFactory(DjangoModelFactory): class Meta(object): model = CourseEnrollment @@ -161,3 +168,18 @@ class PendingEmailChangeFactory(DjangoModelFactory): user = factory.SubFactory(UserFactory) new_email = factory.Sequence(u'new+email+{0}@edx.org'.format) activation_key = factory.Sequence(u'{:0<30d}'.format) + + +class ContentTypeFactory(DjangoModelFactory): + class Meta(object): + model = ContentType + + app_label = factory.Faker('app_name') + + +class PermissionFactory(DjangoModelFactory): + class Meta(object): + model = Permission + + codename = factory.Faker('codename') + content_type = factory.SubFactory(ContentTypeFactory) diff --git a/openedx/core/djangoapps/user_api/accounts/permissions.py b/openedx/core/djangoapps/user_api/accounts/permissions.py new file mode 100644 index 0000000000..d2dbc78020 --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/permissions.py @@ -0,0 +1,15 @@ +""" +Permissions classes for User accounts API views. +""" +from __future__ import unicode_literals + +from rest_framework import permissions + + +class CanDeactivateUser(permissions.BasePermission): + """ + Grants access to AccountDeactivationView if the requesting user is a superuser + or has the explicit permission to deactivate a User account. + """ + def has_permission(self, request, view): + return request.user.has_perm('student.can_deactivate_users') diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_permissions.py b/openedx/core/djangoapps/user_api/accounts/tests/test_permissions.py new file mode 100644 index 0000000000..c114865685 --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_permissions.py @@ -0,0 +1,40 @@ +""" +Tests for User deactivation API permissions +""" +from django.test import TestCase, RequestFactory + +from openedx.core.djangoapps.user_api.accounts.permissions import CanDeactivateUser +from student.tests.factories import ContentTypeFactory, PermissionFactory, SuperuserFactory, UserFactory + + +class CanDeactivateUserTest(TestCase): + """ Tests for user deactivation API permissions """ + + def setUp(self): + super(CanDeactivateUserTest, self).setUp() + self.request = RequestFactory().get('/test/url') + + def test_api_permission_superuser(self): + self.request.user = SuperuserFactory() + + result = CanDeactivateUser().has_permission(self.request, None) + self.assertTrue(result) + + def test_api_permission_user_granted_permission(self): + user = UserFactory() + permission = PermissionFactory( + codename='can_deactivate_users', + content_type=ContentTypeFactory( + app_label='student' + ) + ) + user.user_permissions.add(permission) # pylint: disable=no-member + self.request.user = user + + result = CanDeactivateUser().has_permission(self.request, None) + self.assertTrue(result) + + def test_api_permission_user_without_permission(self): + self.request.user = UserFactory() + result = CanDeactivateUser().has_permission(self.request, None) + self.assertFalse(result) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 0a45abe8f0..dcdfadfafb 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -2,30 +2,35 @@ """ Test cases to cover Accounts-related behaviors of the User API application """ -from collections import OrderedDict -from copy import deepcopy import datetime import ddt import hashlib import json +import unittest +from collections import OrderedDict +from copy import deepcopy from mock import patch from nose.plugins.attrib import attr from pytz import UTC from django.conf import settings from django.core.urlresolvers import reverse +from django.test import TestCase from django.test.testcases import TransactionTestCase from django.test.utils import override_settings +from rest_framework import status from rest_framework.test import APITestCase, APIClient -from openedx.core.djangoapps.user_api.models import UserPreference -from student.tests.factories import UserFactory -from student.models import UserProfile, LanguageProficiency, PendingEmailChange +from .. import PRIVATE_VISIBILITY, ALL_USERS_VISIBILITY from openedx.core.djangoapps.user_api.accounts import ACCOUNT_VISIBILITY_PREF_KEY +from openedx.core.djangoapps.user_api.models import UserPreference from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms -from .. import PRIVATE_VISIBILITY, ALL_USERS_VISIBILITY +from student.models import UserProfile, LanguageProficiency, PendingEmailChange +from student.tests.factories import ( + AdminFactory, ContentTypeFactory, TEST_PASSWORD, PermissionFactory, SuperuserFactory, UserFactory +) TEST_PROFILE_IMAGE_UPLOADED_AT = datetime.datetime(2002, 1, 9, 15, 43, 01, tzinfo=UTC) @@ -40,23 +45,22 @@ class UserAPITestCase(APITestCase): """ The base class for all tests of the User API """ - test_password = "test" def setUp(self): super(UserAPITestCase, self).setUp() self.anonymous_client = APIClient() - self.different_user = UserFactory.create(password=self.test_password) + self.different_user = UserFactory.create(password=TEST_PASSWORD) self.different_client = APIClient() - self.staff_user = UserFactory(is_staff=True, password=self.test_password) + self.staff_user = UserFactory(is_staff=True, password=TEST_PASSWORD) self.staff_client = APIClient() - self.user = UserFactory.create(password=self.test_password) # will be assigned to self.client by default + self.user = UserFactory.create(password=TEST_PASSWORD) # will be assigned to self.client by default def login_client(self, api_client, user): """Helper method for getting the client and user and logging in. Returns client. """ client = getattr(self, api_client) user = getattr(self, user) - client.login(username=user.username, password=self.test_password) + client.login(username=user.username, password=TEST_PASSWORD) return client def send_patch(self, client, json_data, content_type="application/merge-patch+json", expected_status=200): @@ -168,7 +172,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase): """ Test that a client (logged in) can get her own username. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) self._verify_get_own_username(15) def test_get_username_inactive(self): @@ -176,7 +180,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase): Test that a logged-in client can get their username, even if inactive. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) self.user.is_active = False self.user.save() self._verify_get_own_username(15) @@ -271,7 +275,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): """ Test that DELETE, POST, and PUT are not supported. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) self.assertEqual(405, self.client.put(self.url).status_code) self.assertEqual(405, self.client.post(self.url).status_code) self.assertEqual(405, self.client.delete(self.url).status_code) @@ -298,7 +302,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): Test that a client (logged in) can only get the shareable fields for a different user. This is the case when default_visibility is set to "all_users". """ - self.different_client.login(username=self.different_user.username, password=self.test_password) + self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD) self.create_mock_profile(self.user) with self.assertNumQueries(19): response = self.send_get(self.different_client) @@ -313,7 +317,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): Test that a client (logged in) can only get the shareable fields for a different user. This is the case when default_visibility is set to "private". """ - self.different_client.login(username=self.different_user.username, password=self.test_password) + self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD) self.create_mock_profile(self.user) with self.assertNumQueries(19): response = self.send_get(self.different_client) @@ -389,7 +393,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): # Badges aren't on by default, so should not be present. self.assertEqual(False, data["accomplishments_shared"]) - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) verify_get_own_information(17) # Now make sure that the user can get the same information, even if not active @@ -408,7 +412,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): legacy_profile.bio = "" legacy_profile.save() - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) with self.assertNumQueries(17): response = self.send_get(self.client) for empty_field in ("level_of_education", "gender", "country", "bio"): @@ -499,7 +503,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): def test_patch_inactive_user(self): """ Verify that a user can patch her own account, even if inactive. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) self.user.is_active = False self.user.save() response = self.send_patch(self.client, {"goals": "to not activate account"}) @@ -541,7 +545,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): """ Test the behavior of patch when an incorrect content_type is specified. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) self.send_patch(self.client, {}, content_type="application/json", expected_status=415) self.send_patch(self.client, {}, content_type="application/xml", expected_status=415) @@ -550,7 +554,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): Tests the behavior of patch when attempting to set fields with a select list of options to the empty string. Also verifies the behaviour when setting to None. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) for field_name in ["gender", "level_of_education", "country"]: response = self.send_patch(self.client, {field_name: ""}) # Although throwing a 400 might be reasonable, the default DRF behavior with ModelSerializer @@ -586,7 +590,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): get_response = self.send_get(self.client) self.assertEqual(new_name, get_response.data["name"]) - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) legacy_profile = UserProfile.objects.get(id=self.user.id) self.assertEqual({}, legacy_profile.get_meta()) old_name = legacy_profile.name @@ -706,7 +710,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): Test that AccountUpdateErrors are passed through to the response. """ serializer_save.side_effect = [Exception("bummer"), None] - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) error_response = self.send_patch(self.client, {"goals": "save an account field"}, expected_status=400) self.assertEqual( "Error thrown when saving account updates: 'bummer'", @@ -721,7 +725,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): with a '/', the API generates the full URL to profile images based on the URL of the request. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) response = self.send_get(self.client) self.assertEqual( response.data["profile_image"], @@ -787,12 +791,11 @@ class TestAccountAPITransactions(TransactionTestCase): """ Tests the transactional behavior of the account API """ - test_password = "test" def setUp(self): super(TestAccountAPITransactions, self).setUp() self.client = APIClient() - self.user = UserFactory.create(password=self.test_password) + self.user = UserFactory.create(password=TEST_PASSWORD) self.url = reverse("accounts_api", kwargs={'username': self.user.username}) @patch('student.views.do_email_change_request') @@ -804,7 +807,7 @@ class TestAccountAPITransactions(TransactionTestCase): # Throw an error from the method that is used to process the email change request # (this is the last thing done in the api method). Verify that the profile did not change. mock_email_change.side_effect = [ValueError, "mock value error thrown"] - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) old_email = self.user.email json_data = {"email": "foo@bar.com", "gender": "o"} @@ -816,3 +819,65 @@ class TestAccountAPITransactions(TransactionTestCase): data = response.data self.assertEqual(old_email, data["email"]) self.assertEqual(u"m", data["gender"]) + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS') +class TestAccountDeactivation(TestCase): + """ + Tests the account deactivation endpoint. + """ + + def setUp(self): + super(TestAccountDeactivation, self).setUp() + self.superuser = SuperuserFactory() + self.staff_user = AdminFactory() + self.test_user = UserFactory() + self.url = reverse('accounts_deactivation', kwargs={'username': self.test_user.username}) + + def assert_activation_status(self, expected_status=status.HTTP_200_OK, expected_activation_status=False): + """ + Helper function for making a request to the deactivation endpoint, and asserting the status. + + Args: + expected_status(int): Expected request's response status. + expected_activation_status(bool): Expected user has_usable_password attribute value. + """ + response = self.client.post(self.url) + self.assertEqual(response.status_code, expected_status) + self.test_user.refresh_from_db() # pylint: disable=no-member + self.assertEqual(self.test_user.has_usable_password(), expected_activation_status) # pylint: disable=no-member + + def test_superuser_deactivates_user(self): + """ + Verify a user is deactivated when a superuser posts to the deactivation endpoint. + """ + self.client.login(username=self.superuser.username, password=TEST_PASSWORD) + self.assertTrue(self.test_user.has_usable_password()) # pylint: disable=no-member + self.assert_activation_status() + + def test_user_with_permission_deactivates_user(self): + """ + Verify a user is deactivated when a user with permission posts to the deactivation endpoint. + """ + user = UserFactory() + permission = PermissionFactory( + codename='can_deactivate_users', + content_type=ContentTypeFactory( + app_label='student' + ) + ) + user.user_permissions.add(permission) # pylint: disable=no-member + self.client.login(username=user.username, password=TEST_PASSWORD) + self.assertTrue(self.test_user.has_usable_password()) # pylint: disable=no-member + self.assert_activation_status() + + def test_unauthorized_rejection(self): + """ + Verify unauthorized users cannot deactivate accounts. + """ + self.client.login(username=self.test_user.username, password=TEST_PASSWORD) + self.assertTrue(self.test_user.has_usable_password()) # pylint: disable=no-member + self.assert_activation_status( + expected_status=status.HTTP_403_FORBIDDEN, + expected_activation_status=True + ) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 1678a86559..de79f61fb1 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -10,15 +10,18 @@ from edx_rest_framework_extensions.authentication import JwtAuthentication from rest_framework import permissions from rest_framework import status from rest_framework.response import Response +from rest_framework.views import APIView from rest_framework.viewsets import ViewSet +from .api import get_account_settings, update_account_settings +from .permissions import CanDeactivateUser +from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError from openedx.core.lib.api.authentication import ( SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser, ) from openedx.core.lib.api.parsers import MergePatchParser -from .api import get_account_settings, update_account_settings -from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError +from student.models import User class AccountViewSet(ViewSet): @@ -219,3 +222,23 @@ class AccountViewSet(ViewSet): ) return Response(account_settings) + + +class AccountDeactivationView(APIView): + """ + Account deactivation viewset. Currently only supports POST requests. + Only admins can deactivate accounts. + """ + permission_classes = (permissions.IsAuthenticated, CanDeactivateUser) + + def post(self, request, username): + """ + POST /api/user/v1/accounts/{username}/deactivate/ + + Marks the user as having no password set for deactivation purposes. + """ + user = User.objects.get(username=username) + user.set_unusable_password() + user.save() + account_settings = get_account_settings(request, [username])[0] + return Response(account_settings) diff --git a/openedx/core/djangoapps/user_api/preferences/tests/test_views.py b/openedx/core/djangoapps/user_api/preferences/tests/test_views.py index 9e9af11b43..aeb68a9392 100644 --- a/openedx/core/djangoapps/user_api/preferences/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/preferences/tests/test_views.py @@ -10,7 +10,7 @@ from mock import patch from django.core.urlresolvers import reverse from django.test.testcases import TransactionTestCase from rest_framework.test import APIClient -from student.tests.factories import UserFactory +from student.tests.factories import UserFactory, TEST_PASSWORD from openedx.core.djangolib.testing.utils import skip_unless_lms from ...accounts.tests.test_views import UserAPITestCase @@ -42,7 +42,7 @@ class TestPreferencesAPI(UserAPITestCase): """ Test that DELETE, POST, and PUT are not supported. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) self.assertEqual(405, self.client.put(self.url).status_code) self.assertEqual(405, self.client.post(self.url).status_code) self.assertEqual(405, self.client.delete(self.url).status_code) @@ -51,7 +51,7 @@ class TestPreferencesAPI(UserAPITestCase): """ Test that a client (logged in) cannot get the preferences information for a different client. """ - self.different_client.login(username=self.different_user.username, password=self.test_password) + self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD) self.send_get(self.different_client, expected_status=404) @ddt.data( @@ -72,7 +72,7 @@ class TestPreferencesAPI(UserAPITestCase): Test that a client (logged in) can get her own preferences information (verifying the default state before any preferences are stored). """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) response = self.send_get(self.client) self.assertEqual({}, response.data) @@ -117,7 +117,7 @@ class TestPreferencesAPI(UserAPITestCase): """ Test the behavior of patch when an incorrect content_type is specified. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) self.send_patch(self.client, {}, content_type="application/json", expected_status=415) self.send_patch(self.client, {}, content_type="application/xml", expected_status=415) @@ -137,7 +137,7 @@ class TestPreferencesAPI(UserAPITestCase): """ Internal helper to generalize the creation of a set of preferences """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) if not is_active: self.user.is_active = False self.user.save() @@ -182,7 +182,7 @@ class TestPreferencesAPI(UserAPITestCase): set_user_preference(self.user, "time_zone", "Asia/Macau") # Send the patch request - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) self.send_patch( self.client, { @@ -215,7 +215,7 @@ class TestPreferencesAPI(UserAPITestCase): set_user_preference(self.user, "time_zone", "Pacific/Midway") # Send the patch request - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) response = self.send_patch( self.client, { @@ -266,7 +266,7 @@ class TestPreferencesAPI(UserAPITestCase): """ Test that a client (logged in) receives appropriate errors for a bad request. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) # Verify a non-dict request response = self.send_patch(self.client, "non_dict_request", expected_status=400) @@ -325,7 +325,7 @@ class TestPreferencesAPITransactions(TransactionTestCase): def setUp(self): super(TestPreferencesAPITransactions, self).setUp() self.client = APIClient() - self.user = UserFactory.create(password=self.test_password) + self.user = UserFactory.create(password=TEST_PASSWORD) self.url = reverse("preferences_api", kwargs={'username': self.user.username}) @patch('openedx.core.djangoapps.user_api.models.UserPreference.delete') @@ -342,7 +342,7 @@ class TestPreferencesAPITransactions(TransactionTestCase): # after one of the updates has happened, in which case the whole operation # should be rolled back. delete_user_preference.side_effect = [Exception, None] - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) json_data = { "a": "2", "b": None, @@ -396,7 +396,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): """ Test that POST and PATCH are not supported. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) self.assertEqual(405, self.client.post(self.url).status_code) self.assertEqual(405, self.client.patch(self.url).status_code) @@ -404,7 +404,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): """ Test that a client (logged in) cannot manipulate a preference for a different client. """ - self.different_client.login(username=self.different_user.username, password=self.test_password) + self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD) self.send_get(self.different_client, expected_status=404) self.send_put(self.different_client, "new_value", expected_status=404) self.send_delete(self.different_client, expected_status=404) @@ -429,7 +429,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): Test that a 404 is returned if the user does not have a preference with the given preference_key. """ self._set_url("does_not_exist") - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) response = self.send_get(self.client, expected_status=404) self.assertIsNone(response.data) @@ -469,7 +469,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): """ Generalization of the actual test workflow """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) if not is_active: self.user.is_active = False self.user.save() @@ -490,7 +490,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): Test that a client (logged in) cannot create an empty preference. """ self._set_url("new_key") - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) response = self.send_put(self.client, preference_value, expected_status=400) self.assertEqual( response.data, @@ -505,7 +505,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): """ Test that a client cannot create preferences with bad keys """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) too_long_preference_key = "x" * 256 new_value = "new value" @@ -544,7 +544,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): """ Test that a client (logged in) can update a preference. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) self.send_put(self.client, preference_value) response = self.send_get(self.client) self.assertEqual(unicode(preference_value), response.data) @@ -572,7 +572,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): """ Test that a client (logged in) cannot update a preference to null. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) response = self.send_put(self.client, preference_value, expected_status=400) self.assertEqual( response.data, @@ -588,7 +588,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): """ Test that a client (logged in) can delete her own preference. """ - self.client.login(username=self.user.username, password=self.test_password) + self.client.login(username=self.user.username, password=TEST_PASSWORD) # Verify that a preference can be deleted self.send_delete(self.client) diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index d390ea11f2..d6ccbf329f 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -6,7 +6,7 @@ from django.conf import settings from django.conf.urls import patterns, url from ..profile_images.views import ProfileImageView -from .accounts.views import AccountViewSet +from .accounts.views import AccountDeactivationView, AccountViewSet from .preferences.views import PreferencesView, PreferencesDetailView from .verification_api.views import PhotoVerificationStatusView @@ -33,6 +33,11 @@ urlpatterns = patterns( ProfileImageView.as_view(), name='accounts_profile_image_api' ), + url( + r'^v1/accounts/{}/deactivate/$'.format(settings.USERNAME_PATTERN), + AccountDeactivationView.as_view(), + name='accounts_deactivation' + ), url( r'^v1/accounts/{}/verification_status/$'.format(settings.USERNAME_PATTERN), PhotoVerificationStatusView.as_view(),