Commit 12d98dae by brianhw

Merge pull request #1376 from edx/adam/disable-accounts-2

Adam/disable accounts 2
parents a4009d78 085d679a
......@@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Common: Adds ability to disable a student's account. Students with disabled
accounts will be prohibited from site access.
LMS: Fix issue with CourseMode expiration dates
LMS: Ported bulk emailing to the beta instructor dashboard.
......
......@@ -150,6 +150,7 @@ MIDDLEWARE_CLASSES = (
# Instead of AuthenticationMiddleware, we use a cache-backed version
'cache_toolbox.middleware.CacheBackedAuthenticationMiddleware',
'student.middleware.UserStandingMiddleware',
'contentserver.middleware.StaticContentServer',
'django.contrib.messages.middleware.MessageMiddleware',
......
"""
Middleware that checks user standing for the purpose of keeping users with
disabled accounts from accessing the site.
"""
from django.http import HttpResponseForbidden
from django.utils.translation import ugettext as _
from django.conf import settings
from student.models import UserStanding
class UserStandingMiddleware(object):
"""
Checks a user's standing on request. Returns a 403 if the user's
status is 'disabled'.
"""
def process_request(self, request):
user = request.user
try:
user_account = UserStanding.objects.get(user=user.id)
# because user is a unique field in UserStanding, there will either be
# one or zero user_accounts associated with a UserStanding
except UserStanding.DoesNotExist:
pass
else:
if user_account.account_status == UserStanding.ACCOUNT_DISABLED:
msg = _(
'Your account has been disabled. If you believe '
'this was done in error, please contact us at '
'{link_start}{support_email}{link_end}'
).format(
support_email=settings.DEFAULT_FEEDBACK_EMAIL,
link_start=u'<a href="mailto:{address}?subject={subject_line}">'.format(
address=settings.DEFAULT_FEEDBACK_EMAIL,
subject_line=_('Disabled Account'),
),
link_end=u'</a>'
)
return HttpResponseForbidden(msg)
......@@ -33,6 +33,27 @@ from pytz import UTC
log = logging.getLogger(__name__)
AUDIT_LOG = logging.getLogger("audit")
class UserStanding(models.Model):
"""
This table contains a student's account's status.
Currently, we're only disabling accounts; in the future we can imagine
taking away more specific privileges, like forums access, or adding
more specific karma levels or probationary stages.
"""
ACCOUNT_DISABLED = "disabled"
ACCOUNT_ENABLED = "enabled"
USER_STANDING_CHOICES = (
(ACCOUNT_DISABLED, u"Account Disabled"),
(ACCOUNT_ENABLED, u"Account Enabled"),
)
user = models.ForeignKey(User, db_index=True, related_name='standing', unique=True)
account_status = models.CharField(
blank=True, max_length=31, choices=USER_STANDING_CHOICES
)
changed_by = models.ForeignKey(User, blank=True)
standing_last_changed_at = models.DateTimeField(auto_now=True)
class UserProfile(models.Model):
"""This is where we store all the user demographic fields. We have a
......
from student.models import (User, UserProfile, Registration,
CourseEnrollmentAllowed, CourseEnrollment,
PendingEmailChange)
PendingEmailChange, UserStanding,
)
from django.contrib.auth.models import Group
from datetime import datetime
from factory import DjangoModelFactory, SubFactory, PostGenerationMethodCall, post_generation, Sequence
......@@ -16,6 +17,13 @@ class GroupFactory(DjangoModelFactory):
name = u'staff_MITx/999/Robot_Super_Course'
class UserStandingFactory(DjangoModelFactory):
FACTORY_FOR = UserStanding
user = None
account_status = None
changed_by = None
class UserProfileFactory(DjangoModelFactory):
FACTORY_FOR = UserProfile
......
"""
These are tests for disabling and enabling student accounts, and for making sure
that students with disabled accounts are unable to access the courseware.
"""
from student.tests.factories import UserFactory, UserStandingFactory
from student.models import UserStanding
from django.test import TestCase, Client
from django.core.urlresolvers import reverse, NoReverseMatch
from nose.plugins.skip import SkipTest
class UserStandingTest(TestCase):
"""test suite for user standing view for enabling and disabling accounts"""
def setUp(self):
# create users
self.bad_user = UserFactory.create(
username='bad_user',
)
self.good_user = UserFactory.create(
username='good_user',
)
self.non_staff = UserFactory.create(
username='non_staff',
)
self.admin = UserFactory.create(
username='admin',
is_staff=True,
)
# create clients
self.bad_user_client = Client()
self.good_user_client = Client()
self.non_staff_client = Client()
self.admin_client = Client()
for user, client in [
(self.bad_user, self.bad_user_client),
(self.good_user, self.good_user_client),
(self.non_staff, self.non_staff_client),
(self.admin, self.admin_client),
]:
client.login(username=user.username, password='test')
UserStandingFactory.create(
user=self.bad_user,
account_status=UserStanding.ACCOUNT_DISABLED,
changed_by=self.admin
)
# set different stock urls for lms and cms
# to test disabled accounts' access to site
try:
self.some_url = reverse('dashboard')
except NoReverseMatch:
self.some_url = reverse('index')
# since it's only possible to disable accounts from lms, we're going
# to skip tests for cms
def test_disable_account(self):
self.assertEqual(
UserStanding.objects.filter(user=self.good_user).count(), 0
)
try:
response = self.admin_client.post(reverse('disable_account_ajax'), {
'username': self.good_user.username,
'account_action': 'disable',
})
except NoReverseMatch:
raise SkipTest()
self.assertEqual(
UserStanding.objects.get(user=self.good_user).account_status,
UserStanding.ACCOUNT_DISABLED
)
def test_disabled_account_403s(self):
response = self.bad_user_client.get(self.some_url)
self.assertEqual(response.status_code, 403)
def test_reenable_account(self):
try:
response = self.admin_client.post(reverse('disable_account_ajax'), {
'username': self.bad_user.username,
'account_action': 'reenable'
})
except NoReverseMatch:
raise SkipTest()
self.assertEqual(
UserStanding.objects.get(user=self.bad_user).account_status,
UserStanding.ACCOUNT_ENABLED
)
def test_non_staff_cant_access_disable_view(self):
try:
response = self.non_staff_client.get(reverse('manage_user_standing'), {
'user': self.non_staff,
})
except NoReverseMatch:
raise SkipTest()
self.assertEqual(response.status_code, 404)
def test_non_staff_cant_disable_account(self):
try:
response = self.non_staff_client.post(reverse('disable_account_ajax'), {
'username': self.good_user.username,
'user': self.non_staff,
'account_action': 'disable'
})
except NoReverseMatch:
raise SkipTest()
self.assertEqual(response.status_code, 404)
self.assertEqual(
UserStanding.objects.filter(user=self.good_user).count(), 0
)
......@@ -16,6 +16,7 @@ from django.contrib.auth import logout, authenticate, login
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import password_reset_confirm
# from django.contrib.sessions.models import Session
from django.core.cache import cache
from django.core.context_processors import csrf
from django.core.mail import send_mail
......@@ -29,18 +30,21 @@ from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie
from django.utils.http import cookie_date, base36_to_int, urlencode
from django.utils.translation import ugettext as _
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_POST, require_GET
from django.contrib.admin.views.decorators import staff_member_required
from django.utils.translation import ugettext as _u
from ratelimitbackend.exceptions import RateLimitException
from mitxmako.shortcuts import render_to_response, render_to_string
from course_modes.models import CourseMode
from student.models import (Registration, UserProfile, TestCenterUser, TestCenterUserForm,
TestCenterRegistration, TestCenterRegistrationForm,
PendingNameChange, PendingEmailChange,
CourseEnrollment, unique_id_for_user,
get_testcenter_registration, CourseEnrollmentAllowed)
from student.models import (
Registration, UserProfile, TestCenterUser, TestCenterUserForm,
TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange,
PendingEmailChange, CourseEnrollment, unique_id_for_user,
get_testcenter_registration, CourseEnrollmentAllowed, UserStanding,
)
from student.forms import PasswordResetFormNoActive
from certificates.models import CertificateStatuses, certificate_status_for_student
......@@ -65,6 +69,8 @@ import track.views
from dogapi import dog_stats_api
from pytz import UTC
from util.json_request import JsonResponse
log = logging.getLogger("mitx.student")
AUDIT_LOG = logging.getLogger("audit")
......@@ -597,6 +603,81 @@ def logout_user(request):
domain=settings.SESSION_COOKIE_DOMAIN)
return response
@require_GET
@login_required
@ensure_csrf_cookie
def manage_user_standing(request):
"""
Renders the view used to manage user standing. Also displays a table
of user accounts that have been disabled and who disabled them.
"""
if not request.user.is_staff:
raise Http404
all_disabled_accounts = UserStanding.objects.filter(
account_status=UserStanding.ACCOUNT_DISABLED
)
all_disabled_users = [standing.user for standing in all_disabled_accounts]
headers = ['username', 'account_changed_by']
rows = []
for user in all_disabled_users:
row = [user.username, user.standing.all()[0].changed_by]
rows.append(row)
context = {'headers': headers, 'rows': rows}
return render_to_response("manage_user_standing.html", context)
@require_POST
@login_required
@ensure_csrf_cookie
def disable_account_ajax(request):
"""
Ajax call to change user standing. Endpoint of the form
in manage_user_standing.html
"""
if not request.user.is_staff:
raise Http404
username = request.POST.get('username')
context = {}
if username is None or username.strip() == '':
context['message'] = _u('Please enter a username')
return JsonResponse(context, status=400)
account_action = request.POST.get('account_action')
if account_action is None:
context['message'] = _u('Please choose an option')
return JsonResponse(context, status=400)
username = username.strip()
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
context['message'] = _u("User with username {} does not exist").format(username)
return JsonResponse(context, status=400)
else:
user_account, _ = UserStanding.objects.get_or_create(
user=user, defaults={'changed_by': request.user},
)
if account_action == 'disable':
user_account.account_status = UserStanding.ACCOUNT_DISABLED
context['message'] = _u("Successfully disabled {}'s account").format(username)
log.info("{} disabled {}'s account".format(request.user, username))
elif account_action == 'reenable':
user_account.account_status = UserStanding.ACCOUNT_ENABLED
context['message'] = _u("Successfully reenabled {}'s account").format(username)
log.info("{} reenabled {}'s account".format(request.user, username))
else:
context['message'] = _u("Unexpected account status")
return JsonResponse(context, status=400)
user_account.changed_by = request.user
user_account.standing_last_changed_at = datetime.datetime.now(UTC)
user_account.save()
return JsonResponse(context)
@login_required
@ensure_csrf_cookie
......
......@@ -569,6 +569,7 @@ MIDDLEWARE_CLASSES = (
# Instead of AuthenticationMiddleware, we use a cached backed version
#'django.contrib.auth.middleware.AuthenticationMiddleware',
'cache_toolbox.middleware.CacheBackedAuthenticationMiddleware',
'student.middleware.UserStandingMiddleware',
'contentserver.middleware.StaticContentServer',
'django.contrib.messages.middleware.MessageMiddleware',
......
<%! from django.utils.translation import ugettext as _ %>
<%inherit file="main.html" />
<%namespace name='static' file='static_content.html'/>
......
<%inherit file="main.html" />
<%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %>
<h2>${_("Disable or Reenable student accounts")}</h2>
<form action="${reverse('disable_account_ajax')}" method="post" data-remote="true" id="disable-form">
<label for="username">${_("Username:")}</label>
<input type="text" id="username" name="username" required="true">
<br>
<label for="account_action">${_("Disable Account")}</label>
<input type="radio" name="account_action" value="disable" id="account_action">
<br>
<label for="account_action">${_("Reenable Account")}</label>
<input type="radio" name="account_action" value="reenable" id="account_action">
<br>
<br>
</form>
<button id="submit-form">${_("Submit")}</button>
<br>
<br>
<p id="account-change-status"></p>
<h2>${_("Students whose accounts have been disabled")}</h2>
<p>${_("(reload your page to refresh)")}</p>
<table id="account-table" border='1'>
<tr>
% for header in headers:
<th>${header}</th>
% endfor
</tr>
% for row in rows:
<tr>
% for cell in row:
<td>${cell}</td>
% endfor
</tr>
% endfor
</table>
<script type="text/javascript">
$(function() {
var form = $("#disable-form");
$("#submit-form").click(function(){
$("#account-change-status").html(gettext("working..."));
$.ajax({
type: "POST",
url: form.attr('action'),
data: form.serialize(),
success: function(response){
$("#account-change-status").html(response.message);
},
});
});
});
</script>
......@@ -30,6 +30,10 @@ urlpatterns = ('', # nopep8
url(r'^t/(?P<template>[^/]*)$', 'static_template_view.views.index'), # TODO: Is this used anymore? What is STATIC_GRAB?
url(r'^accounts/login$', 'student.views.accounts_login', name="accounts_login"),
url(r'^accounts/manage_user_standing', 'student.views.manage_user_standing',
name='manage_user_standing'),
url(r'^accounts/disable_account_ajax$', 'student.views.disable_account_ajax',
name="disable_account_ajax"),
url(r'^login_ajax$', 'student.views.login_user', name="login"),
url(r'^login_ajax/(?P<error>[^/]*)$', 'student.views.login_user'),
......
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