Expose LoginFailure model to Django's Admin UI to allow admin users

unlock student accounts that have been locked out for excessive login
failures. This change will allow to quickly unlock student accounts when
time is of the essence (e.g. Timed exams).
This commit is contained in:
Josue Balandrano Coronel
2019-02-26 15:49:07 -06:00
parent 4af18655e0
commit 78f4ccb50a
5 changed files with 263 additions and 3 deletions

View File

@@ -1,14 +1,19 @@
""" Django admin pages for student app """
from functools import wraps
from config_models.admin import ConfigurationModelAdmin
from django import forms
from django.db import router, transaction
from django.contrib import admin
from django.contrib.admin.sites import NotRegistered
from django.contrib.admin.utils import unquote
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import ReadOnlyPasswordHashField, UserChangeForm as BaseUserChangeForm
from django.db import models
from django.http import HttpResponseRedirect
from django.http.request import QueryDict
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ugettext_lazy as _, ngettext
from django.urls import reverse
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
@@ -27,7 +32,8 @@ from student.models import (
RegistrationCookieConfiguration,
UserAttribute,
UserProfile,
UserTestGroup
UserTestGroup,
LoginFailures,
)
from student.roles import REGISTERED_ACCESS_ROLES
from xmodule.modulestore.django import modulestore
@@ -316,6 +322,112 @@ class CourseEnrollmentAllowedAdmin(admin.ModelAdmin):
model = CourseEnrollmentAllowed
@admin.register(LoginFailures)
class LoginFailuresAdmin(admin.ModelAdmin):
"""Admin interface for the LoginFailures model. """
list_display = ('user', 'failure_count', 'lockout_until')
raw_id_fields = ('user',)
search_fields = ('user__username', 'user__email', 'user__first_name', 'user__last_name')
actions = ['unlock_student_accounts']
change_form_template = 'admin/student/loginfailures/change_form_template.html'
class _Feature(object):
"""
Inner feature class to implement decorator.
"""
@classmethod
def is_enabled(cls, func):
"""
Check if feature is enabled.
"""
@wraps(func)
def decorator(*args, **kwargs):
"""Decorator class to return"""
if not LoginFailures.is_feature_enabled():
return False
return func(*args, **kwargs)
return decorator
@_Feature.is_enabled
def has_module_permission(self, request):
"""
Only enabled if feature is enabled.
"""
return super(LoginFailuresAdmin, self).has_module_permission(request)
@_Feature.is_enabled
def has_delete_permission(self, request, obj=None):
"""
Only enabled if feature is enabled.
"""
return super(LoginFailuresAdmin, self).has_delete_permission(request, obj)
@_Feature.is_enabled
def has_change_permission(self, request, obj=None):
"""
Only enabled if feature is enabled.
"""
return super(LoginFailuresAdmin, self).has_change_permission(request, obj)
@_Feature.is_enabled
def has_add_permission(self, request):
"""
Only enabled if feature is enabled.
"""
return super(LoginFailuresAdmin, self).has_add_permission(request)
def unlock_student_accounts(self, request, queryset):
"""
Unlock student accounts with login failures.
"""
count = 0
with transaction.atomic(using=router.db_for_write(self.model)):
for obj in queryset:
self.unlock_student(request, obj=obj)
count += 1
self.message_user(
request,
ngettext(
'%(count)d student account was unlocked.',
'%(count)d student accounts were unlocked.',
count
) % {
'count': count
}
)
def change_view(self, request, object_id, form_url='', extra_context=None):
"""
Change View.
This is overridden so we can add a custom button to unlock an account in the record's details.
"""
if '_unlock' in request.POST:
with transaction.atomic(using=router.db_for_write(self.model)):
self.unlock_student(request, object_id=object_id)
url = reverse('admin:student_loginfailures_changelist', current_app=self.admin_site.name)
return HttpResponseRedirect(url)
return super(LoginFailuresAdmin, self).change_view(request, object_id, form_url, extra_context)
def get_actions(self, request):
"""
Get actions for model admin and remove delete action.
"""
actions = super(LoginFailuresAdmin, self).get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
def unlock_student(self, request, object_id=None, obj=None):
"""
Unlock student account.
"""
if object_id:
obj = self.get_object(request, unquote(object_id))
self.model.clear_lockout_counter(obj.user)
admin.site.register(UserTestGroup)
admin.site.register(Registration)
admin.site.register(PendingNameChange)

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.20 on 2019-02-27 20:19
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('student', '0019_auto_20181221_0540'),
]
operations = [
migrations.AlterModelOptions(
name='loginfailures',
options={'verbose_name': 'Login Failure', 'verbose_name_plural': 'Login Failures'},
),
]

View File

@@ -854,6 +854,7 @@ EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated'
EVENT_NAME_ENROLLMENT_MODE_CHANGED = 'edx.course.enrollment.mode_changed'
@six.python_2_unicode_compatible
class LoginFailures(models.Model):
"""
This model will keep track of failed login attempts.
@@ -930,6 +931,34 @@ class LoginFailures(models.Model):
except ObjectDoesNotExist:
return
def __repr__(self):
"""Repr -> LoginFailures(username, count, date)"""
date_str = '-'
if self.lockout_until is not None:
date_str = self.lockout_until.isoformat()
return u'LoginFailures({username}, {count}, {date})'.format(
username=unicode(self.user.username, 'utf-8'),
count=self.failure_count,
date=date_str
)
def __str__(self):
"""Str -> Username: count - date."""
date_str = '-'
if self.lockout_until is not None:
date_str = self.lockout_until.isoformat()
return u'{username}: {count} - {date}'.format(
username=unicode(self.user.username, 'utf-8'),
count=self.failure_count,
date=date_str
)
class Meta:
verbose_name = 'Login Failure'
verbose_name_plural = 'Login Failures'
class CourseEnrollmentException(Exception):
pass

View File

@@ -1,15 +1,18 @@
"""
Tests student admin.py
"""
import datetime
import ddt
from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import User
from django.conf import settings
from django.forms import ValidationError
from django.urls import reverse
from django.test import TestCase
from django.test import TestCase, override_settings
from mock import Mock
from student.admin import COURSE_ENROLLMENT_ADMIN_SWITCH, UserAdmin
from student.models import LoginFailures
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -298,3 +301,68 @@ class CourseEnrollmentAdminTest(SharedModuleStoreTestCase):
reverse('admin:student_courseenrollment_change', args=(self.course_enrollment.id, )),
data=data,
)
@ddt.ddt
class LoginFailuresAdminTest(TestCase):
"""Test Login Failures Admin."""
@classmethod
def setUpClass(cls):
"""Setup class"""
super(LoginFailuresAdminTest, cls).setUpClass()
cls.user = UserFactory.create(is_staff=True, is_superuser=True)
cls.user.save()
def setUp(self):
"""Setup."""
super(LoginFailuresAdminTest, self).setUp()
self.client.login(username=self.user.username, password='test')
user = UserFactory.create()
LoginFailures.objects.create(user=self.user, failure_count=10, lockout_until=datetime.datetime.now())
LoginFailures.objects.create(user=user, failure_count=2)
def tearDown(self):
"""Tear Down."""
super(LoginFailuresAdminTest, self).tearDown()
LoginFailures.objects.all().delete()
@ddt.data(
reverse('admin:student_loginfailures_changelist'),
reverse('admin:student_loginfailures_add'),
reverse('admin:student_loginfailures_change', args=(1,)),
reverse('admin:student_loginfailures_delete', args=(1,)),
)
def test_feature_disabled(self, url):
"""Test if feature is disabled there's no access to the admin module."""
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
@override_settings(FEATURES={'ENABLE_MAX_FAILED_LOGIN_ATTEMPTS': True})
def test_unlock_student_accounts(self):
"""Test batch unlock student accounts."""
url = reverse('admin:student_loginfailures_changelist')
self.client.post(
url,
data={
'action': 'unlock_student_accounts',
'_selected_action': [unicode(o.pk) for o in LoginFailures.objects.all()]
},
follow=True
)
count = LoginFailures.objects.count()
self.assertEqual(count, 0)
@override_settings(FEATURES={'ENABLE_MAX_FAILED_LOGIN_ATTEMPTS': True})
def test_unlock_account(self):
"""Test unlock single student account."""
url = reverse('admin:student_loginfailures_change', args=(1, ))
start_count = LoginFailures.objects.count()
self.client.post(
url,
data={'_unlock': 1}
)
count = LoginFailures.objects.count()
self.assertEqual(count, start_count - 1)

View File

@@ -0,0 +1,32 @@
{% extends 'admin/change_form.html' %}
{% load i18n admin_urls %}
{% block submit_buttons_top %}
<div class="submit-row">
{% if original.lockout_until %}
<input type="submit"
value="{% trans "Unlock Account" %}"
name="_unlock"
class="deletelink">
{% endif %}
<a href="{% url opts|admin_urlname:'changelist' %}"
class="closelink">
{% trans 'Close' %}
</a>
</div>
{% endblock %}
{% block submit_buttons_bottom %}
<div class="submit-row">
{% if original.lockout_until %}
<p class="deletelink-box">
<input type="submit"
value="{% trans "Unlock Account" %}"
name="_unlock"
class="deletelink">
</p>
{% endif %}
<a href="{% url opts|admin_urlname:'changelist' %}"
class="closelink">
{% trans 'Close' %}
</a>
</div>
{% endblock %}