Commit 7fe8e475 by Vedran Karacic Committed by Marko Jevtic

[SOL-2133] Add user deactivation endpoint.

parent c2d7e446
# -*- 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'),)},
),
]
...@@ -234,6 +234,7 @@ class UserProfile(models.Model): ...@@ -234,6 +234,7 @@ class UserProfile(models.Model):
class Meta(object): class Meta(object):
db_table = "auth_userprofile" db_table = "auth_userprofile"
permissions = (("can_deactivate_users", "Can deactivate, but NOT delete users"),)
# CRITICAL TODO/SECURITY # CRITICAL TODO/SECURITY
# Sanitize all fields. # Sanitize all fields.
......
...@@ -6,7 +6,8 @@ from student.models import (User, UserProfile, Registration, ...@@ -6,7 +6,8 @@ from student.models import (User, UserProfile, Registration,
PendingEmailChange, UserStanding, PendingEmailChange, UserStanding,
CourseAccessRole) CourseAccessRole)
from course_modes.models import CourseMode 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 from datetime import datetime
import factory import factory
from factory import lazy_attribute from factory import lazy_attribute
...@@ -18,6 +19,8 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey ...@@ -18,6 +19,8 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
# Factories are self documenting # Factories are self documenting
# pylint: disable=missing-docstring # pylint: disable=missing-docstring
TEST_PASSWORD = 'test'
class GroupFactory(DjangoModelFactory): class GroupFactory(DjangoModelFactory):
class Meta(object): class Meta(object):
...@@ -123,6 +126,10 @@ class AdminFactory(UserFactory): ...@@ -123,6 +126,10 @@ class AdminFactory(UserFactory):
is_staff = True is_staff = True
class SuperuserFactory(UserFactory):
is_superuser = True
class CourseEnrollmentFactory(DjangoModelFactory): class CourseEnrollmentFactory(DjangoModelFactory):
class Meta(object): class Meta(object):
model = CourseEnrollment model = CourseEnrollment
...@@ -161,3 +168,18 @@ class PendingEmailChangeFactory(DjangoModelFactory): ...@@ -161,3 +168,18 @@ class PendingEmailChangeFactory(DjangoModelFactory):
user = factory.SubFactory(UserFactory) user = factory.SubFactory(UserFactory)
new_email = factory.Sequence(u'new+email+{0}@edx.org'.format) new_email = factory.Sequence(u'new+email+{0}@edx.org'.format)
activation_key = factory.Sequence(u'{:0<30d}'.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)
"""
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')
"""
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)
...@@ -2,30 +2,35 @@ ...@@ -2,30 +2,35 @@
""" """
Test cases to cover Accounts-related behaviors of the User API application Test cases to cover Accounts-related behaviors of the User API application
""" """
from collections import OrderedDict
from copy import deepcopy
import datetime import datetime
import ddt import ddt
import hashlib import hashlib
import json import json
import unittest
from collections import OrderedDict
from copy import deepcopy
from mock import patch from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from pytz import UTC from pytz import UTC
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.testcases import TransactionTestCase from django.test.testcases import TransactionTestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from rest_framework import status
from rest_framework.test import APITestCase, APIClient from rest_framework.test import APITestCase, APIClient
from openedx.core.djangoapps.user_api.models import UserPreference
from student.tests.factories import UserFactory from .. import PRIVATE_VISIBILITY, ALL_USERS_VISIBILITY
from student.models import UserProfile, LanguageProficiency, PendingEmailChange
from openedx.core.djangoapps.user_api.accounts import ACCOUNT_VISIBILITY_PREF_KEY 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.djangoapps.user_api.preferences.api import set_user_preference
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms 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) TEST_PROFILE_IMAGE_UPLOADED_AT = datetime.datetime(2002, 1, 9, 15, 43, 01, tzinfo=UTC)
...@@ -40,23 +45,22 @@ class UserAPITestCase(APITestCase): ...@@ -40,23 +45,22 @@ class UserAPITestCase(APITestCase):
""" """
The base class for all tests of the User API The base class for all tests of the User API
""" """
test_password = "test"
def setUp(self): def setUp(self):
super(UserAPITestCase, self).setUp() super(UserAPITestCase, self).setUp()
self.anonymous_client = APIClient() 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.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.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): def login_client(self, api_client, user):
"""Helper method for getting the client and user and logging in. Returns client. """ """Helper method for getting the client and user and logging in. Returns client. """
client = getattr(self, api_client) client = getattr(self, api_client)
user = getattr(self, user) user = getattr(self, user)
client.login(username=user.username, password=self.test_password) client.login(username=user.username, password=TEST_PASSWORD)
return client return client
def send_patch(self, client, json_data, content_type="application/merge-patch+json", expected_status=200): def send_patch(self, client, json_data, content_type="application/merge-patch+json", expected_status=200):
...@@ -168,7 +172,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -168,7 +172,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
""" """
Test that a client (logged in) can get her own username. 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) self._verify_get_own_username(15)
def test_get_username_inactive(self): def test_get_username_inactive(self):
...@@ -176,7 +180,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -176,7 +180,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
Test that a logged-in client can get their Test that a logged-in client can get their
username, even if inactive. 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.is_active = False
self.user.save() self.user.save()
self._verify_get_own_username(15) self._verify_get_own_username(15)
...@@ -271,7 +275,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -271,7 +275,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
""" """
Test that DELETE, POST, and PUT are not supported. 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.put(self.url).status_code)
self.assertEqual(405, self.client.post(self.url).status_code) self.assertEqual(405, self.client.post(self.url).status_code)
self.assertEqual(405, self.client.delete(self.url).status_code) self.assertEqual(405, self.client.delete(self.url).status_code)
...@@ -298,7 +302,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -298,7 +302,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
Test that a client (logged in) can only get the shareable fields for a different user. 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". 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) self.create_mock_profile(self.user)
with self.assertNumQueries(19): with self.assertNumQueries(19):
response = self.send_get(self.different_client) response = self.send_get(self.different_client)
...@@ -313,7 +317,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -313,7 +317,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
Test that a client (logged in) can only get the shareable fields for a different user. 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". 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) self.create_mock_profile(self.user)
with self.assertNumQueries(19): with self.assertNumQueries(19):
response = self.send_get(self.different_client) response = self.send_get(self.different_client)
...@@ -389,7 +393,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -389,7 +393,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
# Badges aren't on by default, so should not be present. # Badges aren't on by default, so should not be present.
self.assertEqual(False, data["accomplishments_shared"]) 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) verify_get_own_information(17)
# Now make sure that the user can get the same information, even if not active # Now make sure that the user can get the same information, even if not active
...@@ -408,7 +412,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -408,7 +412,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
legacy_profile.bio = "" legacy_profile.bio = ""
legacy_profile.save() 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): with self.assertNumQueries(17):
response = self.send_get(self.client) response = self.send_get(self.client)
for empty_field in ("level_of_education", "gender", "country", "bio"): for empty_field in ("level_of_education", "gender", "country", "bio"):
...@@ -499,7 +503,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -499,7 +503,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
def test_patch_inactive_user(self): def test_patch_inactive_user(self):
""" Verify that a user can patch her own account, even if inactive. """ """ 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.is_active = False
self.user.save() self.user.save()
response = self.send_patch(self.client, {"goals": "to not activate account"}) response = self.send_patch(self.client, {"goals": "to not activate account"})
...@@ -541,7 +545,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -541,7 +545,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
""" """
Test the behavior of patch when an incorrect content_type is specified. 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/json", expected_status=415)
self.send_patch(self.client, {}, content_type="application/xml", expected_status=415) self.send_patch(self.client, {}, content_type="application/xml", expected_status=415)
...@@ -550,7 +554,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -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. 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. 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"]: for field_name in ["gender", "level_of_education", "country"]:
response = self.send_patch(self.client, {field_name: ""}) response = self.send_patch(self.client, {field_name: ""})
# Although throwing a 400 might be reasonable, the default DRF behavior with ModelSerializer # Although throwing a 400 might be reasonable, the default DRF behavior with ModelSerializer
...@@ -586,7 +590,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -586,7 +590,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
get_response = self.send_get(self.client) get_response = self.send_get(self.client)
self.assertEqual(new_name, get_response.data["name"]) 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) legacy_profile = UserProfile.objects.get(id=self.user.id)
self.assertEqual({}, legacy_profile.get_meta()) self.assertEqual({}, legacy_profile.get_meta())
old_name = legacy_profile.name old_name = legacy_profile.name
...@@ -706,7 +710,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -706,7 +710,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
Test that AccountUpdateErrors are passed through to the response. Test that AccountUpdateErrors are passed through to the response.
""" """
serializer_save.side_effect = [Exception("bummer"), None] 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) error_response = self.send_patch(self.client, {"goals": "save an account field"}, expected_status=400)
self.assertEqual( self.assertEqual(
"Error thrown when saving account updates: 'bummer'", "Error thrown when saving account updates: 'bummer'",
...@@ -721,7 +725,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -721,7 +725,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
with a '/', the API generates the full URL to profile images based on with a '/', the API generates the full URL to profile images based on
the URL of the request. 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) response = self.send_get(self.client)
self.assertEqual( self.assertEqual(
response.data["profile_image"], response.data["profile_image"],
...@@ -787,12 +791,11 @@ class TestAccountAPITransactions(TransactionTestCase): ...@@ -787,12 +791,11 @@ class TestAccountAPITransactions(TransactionTestCase):
""" """
Tests the transactional behavior of the account API Tests the transactional behavior of the account API
""" """
test_password = "test"
def setUp(self): def setUp(self):
super(TestAccountAPITransactions, self).setUp() super(TestAccountAPITransactions, self).setUp()
self.client = APIClient() 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}) self.url = reverse("accounts_api", kwargs={'username': self.user.username})
@patch('student.views.do_email_change_request') @patch('student.views.do_email_change_request')
...@@ -804,7 +807,7 @@ class TestAccountAPITransactions(TransactionTestCase): ...@@ -804,7 +807,7 @@ class TestAccountAPITransactions(TransactionTestCase):
# Throw an error from the method that is used to process the email change request # 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. # (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"] 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 old_email = self.user.email
json_data = {"email": "foo@bar.com", "gender": "o"} json_data = {"email": "foo@bar.com", "gender": "o"}
...@@ -816,3 +819,65 @@ class TestAccountAPITransactions(TransactionTestCase): ...@@ -816,3 +819,65 @@ class TestAccountAPITransactions(TransactionTestCase):
data = response.data data = response.data
self.assertEqual(old_email, data["email"]) self.assertEqual(old_email, data["email"])
self.assertEqual(u"m", data["gender"]) 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
)
...@@ -10,15 +10,18 @@ from edx_rest_framework_extensions.authentication import JwtAuthentication ...@@ -10,15 +10,18 @@ from edx_rest_framework_extensions.authentication import JwtAuthentication
from rest_framework import permissions from rest_framework import permissions
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet 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 ( from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser,
OAuth2AuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser,
) )
from openedx.core.lib.api.parsers import MergePatchParser from openedx.core.lib.api.parsers import MergePatchParser
from .api import get_account_settings, update_account_settings from student.models import User
from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError
class AccountViewSet(ViewSet): class AccountViewSet(ViewSet):
...@@ -219,3 +222,23 @@ class AccountViewSet(ViewSet): ...@@ -219,3 +222,23 @@ class AccountViewSet(ViewSet):
) )
return Response(account_settings) 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)
...@@ -10,7 +10,7 @@ from mock import patch ...@@ -10,7 +10,7 @@ from mock import patch
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test.testcases import TransactionTestCase from django.test.testcases import TransactionTestCase
from rest_framework.test import APIClient 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 openedx.core.djangolib.testing.utils import skip_unless_lms
from ...accounts.tests.test_views import UserAPITestCase from ...accounts.tests.test_views import UserAPITestCase
...@@ -42,7 +42,7 @@ class TestPreferencesAPI(UserAPITestCase): ...@@ -42,7 +42,7 @@ class TestPreferencesAPI(UserAPITestCase):
""" """
Test that DELETE, POST, and PUT are not supported. 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.put(self.url).status_code)
self.assertEqual(405, self.client.post(self.url).status_code) self.assertEqual(405, self.client.post(self.url).status_code)
self.assertEqual(405, self.client.delete(self.url).status_code) self.assertEqual(405, self.client.delete(self.url).status_code)
...@@ -51,7 +51,7 @@ class TestPreferencesAPI(UserAPITestCase): ...@@ -51,7 +51,7 @@ class TestPreferencesAPI(UserAPITestCase):
""" """
Test that a client (logged in) cannot get the preferences information for a different client. 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) self.send_get(self.different_client, expected_status=404)
@ddt.data( @ddt.data(
...@@ -72,7 +72,7 @@ class TestPreferencesAPI(UserAPITestCase): ...@@ -72,7 +72,7 @@ class TestPreferencesAPI(UserAPITestCase):
Test that a client (logged in) can get her own preferences information (verifying the default Test that a client (logged in) can get her own preferences information (verifying the default
state before any preferences are stored). 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) response = self.send_get(self.client)
self.assertEqual({}, response.data) self.assertEqual({}, response.data)
...@@ -117,7 +117,7 @@ class TestPreferencesAPI(UserAPITestCase): ...@@ -117,7 +117,7 @@ class TestPreferencesAPI(UserAPITestCase):
""" """
Test the behavior of patch when an incorrect content_type is specified. 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/json", expected_status=415)
self.send_patch(self.client, {}, content_type="application/xml", expected_status=415) self.send_patch(self.client, {}, content_type="application/xml", expected_status=415)
...@@ -137,7 +137,7 @@ class TestPreferencesAPI(UserAPITestCase): ...@@ -137,7 +137,7 @@ class TestPreferencesAPI(UserAPITestCase):
""" """
Internal helper to generalize the creation of a set of preferences 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: if not is_active:
self.user.is_active = False self.user.is_active = False
self.user.save() self.user.save()
...@@ -182,7 +182,7 @@ class TestPreferencesAPI(UserAPITestCase): ...@@ -182,7 +182,7 @@ class TestPreferencesAPI(UserAPITestCase):
set_user_preference(self.user, "time_zone", "Asia/Macau") set_user_preference(self.user, "time_zone", "Asia/Macau")
# Send the patch request # 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.send_patch(
self.client, self.client,
{ {
...@@ -215,7 +215,7 @@ class TestPreferencesAPI(UserAPITestCase): ...@@ -215,7 +215,7 @@ class TestPreferencesAPI(UserAPITestCase):
set_user_preference(self.user, "time_zone", "Pacific/Midway") set_user_preference(self.user, "time_zone", "Pacific/Midway")
# Send the patch request # 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( response = self.send_patch(
self.client, self.client,
{ {
...@@ -266,7 +266,7 @@ class TestPreferencesAPI(UserAPITestCase): ...@@ -266,7 +266,7 @@ class TestPreferencesAPI(UserAPITestCase):
""" """
Test that a client (logged in) receives appropriate errors for a bad request. 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 # Verify a non-dict request
response = self.send_patch(self.client, "non_dict_request", expected_status=400) response = self.send_patch(self.client, "non_dict_request", expected_status=400)
...@@ -325,7 +325,7 @@ class TestPreferencesAPITransactions(TransactionTestCase): ...@@ -325,7 +325,7 @@ class TestPreferencesAPITransactions(TransactionTestCase):
def setUp(self): def setUp(self):
super(TestPreferencesAPITransactions, self).setUp() super(TestPreferencesAPITransactions, self).setUp()
self.client = APIClient() 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}) self.url = reverse("preferences_api", kwargs={'username': self.user.username})
@patch('openedx.core.djangoapps.user_api.models.UserPreference.delete') @patch('openedx.core.djangoapps.user_api.models.UserPreference.delete')
...@@ -342,7 +342,7 @@ class TestPreferencesAPITransactions(TransactionTestCase): ...@@ -342,7 +342,7 @@ class TestPreferencesAPITransactions(TransactionTestCase):
# after one of the updates has happened, in which case the whole operation # after one of the updates has happened, in which case the whole operation
# should be rolled back. # should be rolled back.
delete_user_preference.side_effect = [Exception, None] 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 = { json_data = {
"a": "2", "a": "2",
"b": None, "b": None,
...@@ -396,7 +396,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): ...@@ -396,7 +396,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
""" """
Test that POST and PATCH are not supported. 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.post(self.url).status_code)
self.assertEqual(405, self.client.patch(self.url).status_code) self.assertEqual(405, self.client.patch(self.url).status_code)
...@@ -404,7 +404,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): ...@@ -404,7 +404,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
""" """
Test that a client (logged in) cannot manipulate a preference for a different client. 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_get(self.different_client, expected_status=404)
self.send_put(self.different_client, "new_value", expected_status=404) self.send_put(self.different_client, "new_value", expected_status=404)
self.send_delete(self.different_client, expected_status=404) self.send_delete(self.different_client, expected_status=404)
...@@ -429,7 +429,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): ...@@ -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. 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._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) response = self.send_get(self.client, expected_status=404)
self.assertIsNone(response.data) self.assertIsNone(response.data)
...@@ -469,7 +469,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): ...@@ -469,7 +469,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
""" """
Generalization of the actual test workflow 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: if not is_active:
self.user.is_active = False self.user.is_active = False
self.user.save() self.user.save()
...@@ -490,7 +490,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): ...@@ -490,7 +490,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
Test that a client (logged in) cannot create an empty preference. Test that a client (logged in) cannot create an empty preference.
""" """
self._set_url("new_key") 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) response = self.send_put(self.client, preference_value, expected_status=400)
self.assertEqual( self.assertEqual(
response.data, response.data,
...@@ -505,7 +505,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): ...@@ -505,7 +505,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
""" """
Test that a client cannot create preferences with bad keys 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 too_long_preference_key = "x" * 256
new_value = "new value" new_value = "new value"
...@@ -544,7 +544,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): ...@@ -544,7 +544,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
""" """
Test that a client (logged in) can update a preference. 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) self.send_put(self.client, preference_value)
response = self.send_get(self.client) response = self.send_get(self.client)
self.assertEqual(unicode(preference_value), response.data) self.assertEqual(unicode(preference_value), response.data)
...@@ -572,7 +572,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): ...@@ -572,7 +572,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
""" """
Test that a client (logged in) cannot update a preference to null. 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) response = self.send_put(self.client, preference_value, expected_status=400)
self.assertEqual( self.assertEqual(
response.data, response.data,
...@@ -588,7 +588,7 @@ class TestPreferencesDetailAPI(UserAPITestCase): ...@@ -588,7 +588,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
""" """
Test that a client (logged in) can delete her own preference. 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 # Verify that a preference can be deleted
self.send_delete(self.client) self.send_delete(self.client)
......
...@@ -6,7 +6,7 @@ from django.conf import settings ...@@ -6,7 +6,7 @@ from django.conf import settings
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
from ..profile_images.views import ProfileImageView from ..profile_images.views import ProfileImageView
from .accounts.views import AccountViewSet from .accounts.views import AccountDeactivationView, AccountViewSet
from .preferences.views import PreferencesView, PreferencesDetailView from .preferences.views import PreferencesView, PreferencesDetailView
from .verification_api.views import PhotoVerificationStatusView from .verification_api.views import PhotoVerificationStatusView
...@@ -34,6 +34,11 @@ urlpatterns = patterns( ...@@ -34,6 +34,11 @@ urlpatterns = patterns(
name='accounts_profile_image_api' name='accounts_profile_image_api'
), ),
url( 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), r'^v1/accounts/{}/verification_status/$'.format(settings.USERNAME_PATTERN),
PhotoVerificationStatusView.as_view(), PhotoVerificationStatusView.as_view(),
name='verification_status' name='verification_status'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment