Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@

# Instead of AuthenticationMiddleware, we use a cache-backed version
'cache_toolbox.middleware.CacheBackedAuthenticationMiddleware',
'student.middleware.UserStandingMiddleware',
'contentserver.middleware.StaticContentServer',

'django.contrib.messages.middleware.MessageMiddleware',
Expand Down
37 changes: 37 additions & 0 deletions common/djangoapps/student/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
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)
188 changes: 188 additions & 0 deletions common/djangoapps/student/migrations/0028_auto__add_userstanding.py

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions common/djangoapps/student/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@
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
Expand Down
10 changes: 9 additions & 1 deletion common/djangoapps/student/tests/factories.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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
Expand All @@ -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
Expand Down
115 changes: 115 additions & 0 deletions common/djangoapps/student/tests/test_userstanding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""
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
)
93 changes: 87 additions & 6 deletions common/djangoapps/student/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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
Expand All @@ -29,18 +30,21 @@
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
Expand All @@ -65,6 +69,8 @@
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")

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@
# 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',
Expand Down
2 changes: 0 additions & 2 deletions lms/templates/login.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
<%! from django.utils.translation import ugettext as _ %>

<%inherit file="main.html" />

<%namespace name='static' file='static_content.html'/>
Expand Down
Loading