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)
...@@ -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