Commit 327ffa92 by Marko Jevtić Committed by GitHub

Merge pull request #14051 from edx/vkaracic/SOL-2133

[SOL-2133] Add user deactivation endpoint.
parents c2d7e446 7fe8e475
# -*- 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):
class Meta(object):
db_table = "auth_userprofile"
permissions = (("can_deactivate_users", "Can deactivate, but NOT delete users"),)
# CRITICAL TODO/SECURITY
# Sanitize all fields.
......
......@@ -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)
"""
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
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)
......@@ -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)
......
......@@ -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
......@@ -34,6 +34,11 @@ urlpatterns = patterns(
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(),
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