diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ff74b9873b..d0e8af652e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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. diff --git a/cms/envs/common.py b/cms/envs/common.py index 13faf5520e..a82ec4ab41 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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', diff --git a/common/djangoapps/student/middleware.py b/common/djangoapps/student/middleware.py new file mode 100644 index 0000000000..b4f6ad0686 --- /dev/null +++ b/common/djangoapps/student/middleware.py @@ -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''.format( + address=settings.DEFAULT_FEEDBACK_EMAIL, + subject_line=_('Disabled Account'), + ), + link_end=u'' + ) + return HttpResponseForbidden(msg) diff --git a/common/djangoapps/student/migrations/0028_auto__add_userstanding.py b/common/djangoapps/student/migrations/0028_auto__add_userstanding.py new file mode 100644 index 0000000000..d26699448a --- /dev/null +++ b/common/djangoapps/student/migrations/0028_auto__add_userstanding.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'UserStanding' + db.create_table('student_userstanding', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='standing', unique=True, to=orm['auth.User'])), + ('account_status', self.gf('django.db.models.fields.CharField')(max_length=31, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], blank=True)), + ('standing_last_changed_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), + )) + db.send_create_signal('student', ['UserStanding']) + + + def backwards(self, orm): + # Deleting model 'UserStanding' + db.delete_table('student_userstanding') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.testcenterregistration': { + 'Meta': {'object_name': 'TestCenterRegistration'}, + 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.testcenteruser': { + 'Meta': {'object_name': 'TestCenterUser'}, + 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), + 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), + 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index da16a2fda2..78e673df71 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -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 diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py index 49864fcbd4..b2f4d95776 100644 --- a/common/djangoapps/student/tests/factories.py +++ b/common/djangoapps/student/tests/factories.py @@ -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 @@ -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 diff --git a/common/djangoapps/student/tests/test_userstanding.py b/common/djangoapps/student/tests/test_userstanding.py new file mode 100644 index 0000000000..730984c918 --- /dev/null +++ b/common/djangoapps/student/tests/test_userstanding.py @@ -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 + ) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index db2ce5b4a4..6360f7f2e6 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -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 diff --git a/lms/envs/common.py b/lms/envs/common.py index 996e0a0d56..2a17accf99 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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', diff --git a/lms/templates/login.html b/lms/templates/login.html index 938b1cc9c8..80dab49365 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -1,5 +1,3 @@ -<%! from django.utils.translation import ugettext as _ %> - <%inherit file="main.html" /> <%namespace name='static' file='static_content.html'/> diff --git a/lms/templates/manage_user_standing.html b/lms/templates/manage_user_standing.html new file mode 100644 index 0000000000..19bc63d667 --- /dev/null +++ b/lms/templates/manage_user_standing.html @@ -0,0 +1,56 @@ +<%inherit file="main.html" /> + +<%! from django.core.urlresolvers import reverse %> +<%! from django.utils.translation import ugettext as _ %> + +
${_("(reload your page to refresh)")}
+| ${header} | + % endfor +
|---|
| ${cell} | + % endfor +