Indicate user locked state and allow unlocking from users admin (#600)

* Use custom template for account lock

* Rename Baby Buddy base template tags

* Add user unlock view

* Add user unlock test
This commit is contained in:
Christopher Charbonneau Wells 2023-02-11 09:02:23 -08:00 committed by GitHub
parent bf2884f544
commit 996d81966c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 136 additions and 23 deletions

View File

@ -337,6 +337,10 @@ AXES_COOLOFF_TIME = 1
AXES_FAILURE_LIMIT = 5 AXES_FAILURE_LIMIT = 5
AXES_LOCKOUT_TEMPLATE = "error/lockout.html"
AXES_LOCKOUT_URL = "/login/lock"
# Session configuration # Session configuration
# Used by RollingSessionMiddleware to determine how often to reset the session. # Used by RollingSessionMiddleware to determine how often to reset the session.
# See https://docs.djangoproject.com/en/4.0/topics/http/sessions/ # See https://docs.djangoproject.com/en/4.0/topics/http/sessions/

View File

@ -1,4 +1,4 @@
{% load babybuddy_tags i18n static %} {% load babybuddy i18n static %}
{% get_current_language as LANGUAGE_CODE %} {% get_current_language as LANGUAGE_CODE %}
{% get_current_locale as LOCALE %} {% get_current_locale as LOCALE %}
{% get_current_timezone as TIMEZONE %} {% get_current_timezone as TIMEZONE %}

View File

@ -1,4 +1,4 @@
{% load i18n widget_tweaks babybuddy_tags qr_code %} {% load i18n widget_tweaks babybuddy qr_code %}
{% url 'babybuddy:root-router' as relative_root_url %} {% url 'babybuddy:root-router' as relative_root_url %}
{% make_absolute_url relative_root_url as absolute_root_url %} {% make_absolute_url relative_root_url as absolute_root_url %}
BABYBUDDY-LOGIN:{"url":"{{ absolute_root_url }}","api_key":"{{ user.settings.api_key }}"} BABYBUDDY-LOGIN:{"url":"{{ absolute_root_url }}","api_key":"{{ user.settings.api_key }}"}

View File

@ -1,5 +1,5 @@
{% extends 'babybuddy/base.html' %} {% extends 'babybuddy/base.html' %}
{% load babybuddy_tags i18n static timers %} {% load babybuddy i18n static timers %}
{% block nav %} {% block nav %}
<nav class="navbar navbar-expand-md navbar-dark bg-dark sticky-top"> <nav class="navbar navbar-expand-md navbar-dark bg-dark sticky-top">

View File

@ -1,4 +1,4 @@
{% load i18n babybuddy_tags %} {% load i18n babybuddy %}
{% if is_paginated %} {% if is_paginated %}
<nav aria-label="Page navigation"> <nav aria-label="Page navigation">

View File

@ -1,5 +1,5 @@
{% extends 'babybuddy/page.html' %} {% extends 'babybuddy/page.html' %}
{% load i18n widget_tweaks babybuddy_tags qr_code %} {% load i18n widget_tweaks babybuddy qr_code %}
{% block title %}{% trans "Add a device" %}{% endblock %} {% block title %}{% trans "Add a device" %}{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends 'babybuddy/page.html' %}
{% load i18n widget_tweaks %}
{% block title %}{% trans "Unlock User" %}{% endblock %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'babybuddy:user-list' %}">{% trans "Users" %}</a></li>
<li class="breadcrumb-item">{{ object }}</li>
<li class="breadcrumb-item active" aria-current="page">{% trans "Unlock" %}</li>
{% endblock %}
{% block content %}
<form role="form" method="post">
{% csrf_token %}
{% blocktrans trimmed %}
<h1>Are you sure you want to unlock <span class="text-info">{{ object }}</span>?</h1>
{% endblocktrans %}
<input type="submit" value="{% trans "Unlock" %}" class="btn btn-danger" />
<a href="{% url 'babybuddy:user-update' object.pk %}" class="btn btn-default">{% trans "Cancel" %}</a>
</form>
{% endblock %}

View File

@ -1,5 +1,5 @@
{% extends 'babybuddy/page.html' %} {% extends 'babybuddy/page.html' %}
{% load i18n %} {% load babybuddy i18n %}
{% block title %} {% block title %}
{% if object %} {% if object %}
@ -21,6 +21,17 @@
{% block content %} {% block content %}
{% if object %} {% if object %}
{% user_is_locked object as is_locked %}
{% if is_locked %}
<div class="alert alert-danger">
<div class="alert-heading h4">
{% blocktrans %}User locked.{% endblocktrans %}
</div>
<a href="{% url 'babybuddy:user-unlock' object.id %}" class="btn btn-danger">
{% blocktrans %}Unlock{% endblocktrans %}
</a>
</div>
{% endif %}
{% blocktrans trimmed %} {% blocktrans trimmed %}
<h1>Update <span class="text-info">{{ object }}</span></h1> <h1>Update <span class="text-info">{{ object }}</span></h1>
{% endblocktrans %} {% endblocktrans %}

View File

@ -1,5 +1,5 @@
{% extends 'babybuddy/page.html' %} {% extends 'babybuddy/page.html' %}
{% load babybuddy_tags bootstrap i18n widget_tweaks %} {% load babybuddy bootstrap i18n widget_tweaks %}
{% block title %}{% trans "Users" %}{% endblock %} {% block title %}{% trans "Users" %}{% endblock %}
@ -18,9 +18,10 @@
<th>{% trans "First Name" %}</th> <th>{% trans "First Name" %}</th>
<th>{% trans "Last Name" %}</th> <th>{% trans "Last Name" %}</th>
<th>{% trans "Email" %}</th> <th>{% trans "Email" %}</th>
<th>{% trans "Read only" %}</th> <th class="text-center">{% trans "Read only" %}</th>
<th>{% trans "Staff" %}</th> <th class="text-center">{% trans "Staff" %}</th>
<th>{% trans "Active" %}</th> <th class="text-center">{% trans "Active" %}</th>
<th class="text-center">{% trans "Locked" %}</th>
<th class="text-center">{% trans "Actions" %}</th> <th class="text-center">{% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
@ -32,9 +33,11 @@
<td>{{ object.last_name }}</td> <td>{{ object.last_name }}</td>
<td>{{ object.email }}</td> <td>{{ object.email }}</td>
{% user_is_read_only object as is_read_only %} {% user_is_read_only object as is_read_only %}
<td>{{ is_read_only|bool_icon }}</td> <td class="text-center">{{ is_read_only|bool_icon }}</td>
<td>{{ object.is_staff|bool_icon }}</td> <td class="text-center">{{ object.is_staff|bool_icon }}</td>
<td>{{ object.is_active|bool_icon }}</td> <td class="text-center">{{ object.is_active|bool_icon }}</td>
{% user_is_locked object as is_locked %}
<td class="text-center">{{ is_locked|bool_icon }}</td>
<td class="text-center"> <td class="text-center">
<div class="btn-group btn-group-sm" role="group" aria-label="Actions"> <div class="btn-group btn-group-sm" role="group" aria-label="Actions">

View File

@ -1,5 +1,5 @@
{% extends 'babybuddy/page.html' %} {% extends 'babybuddy/page.html' %}
{% load i18n widget_tweaks babybuddy_tags %} {% load i18n widget_tweaks babybuddy %}
{% block title %}{% trans "User Settings" %}{% endblock %} {% block title %}{% trans "User Settings" %}{% endblock %}

View File

@ -0,0 +1,8 @@
{% extends 'error/base.html' %}
{% load babybuddy i18n %}
{% block title %}403 {% trans "Too Many Login Attempts" %}{% endblock %}
{% block content %}
<h1>{% axes_lockout_message %}</h1>
{% endblock %}

View File

@ -5,11 +5,19 @@ from django.apps import apps
from django.utils import timezone from django.utils import timezone
from django.utils.translation import to_locale, get_language from django.utils.translation import to_locale, get_language
from axes.helpers import get_lockout_message
from axes.models import AccessAttempt
from core.models import Child from core.models import Child
register = template.Library() register = template.Library()
@register.simple_tag
def axes_lockout_message():
return get_lockout_message()
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def relative_url(context, field_name, value): def relative_url(context, field_name, value):
""" """
@ -65,6 +73,11 @@ def make_absolute_url(context, url):
return abs_url return abs_url
@register.simple_tag()
def user_is_locked(user):
return AccessAttempt.objects.filter(username=user.username).exists()
@register.simple_tag() @register.simple_tag()
def user_is_read_only(user): def user_is_read_only(user):
return user.groups.filter(name="read_only").exists() return user.groups.filter(name="read_only").exists()

View File

@ -5,27 +5,27 @@ from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from core.models import Child from core.models import Child
from babybuddy.templatetags import babybuddy_tags from babybuddy.templatetags import babybuddy
class TemplateTagsTestCase(TestCase): class TemplateTagsTestCase(TestCase):
def test_child_count(self): def test_child_count(self):
self.assertEqual(babybuddy_tags.get_child_count(), 0) self.assertEqual(babybuddy.get_child_count(), 0)
Child.objects.create( Child.objects.create(
first_name="Test", last_name="Child", birth_date=timezone.localdate() first_name="Test", last_name="Child", birth_date=timezone.localdate()
) )
self.assertEqual(babybuddy_tags.get_child_count(), 1) self.assertEqual(babybuddy.get_child_count(), 1)
Child.objects.create( Child.objects.create(
first_name="Test", last_name="Child 2", birth_date=timezone.localdate() first_name="Test", last_name="Child 2", birth_date=timezone.localdate()
) )
self.assertEqual(babybuddy_tags.get_child_count(), 2) self.assertEqual(babybuddy.get_child_count(), 2)
def user_is_read_only(self): def user_is_read_only(self):
user = get_user_model().objects.create_user( user = get_user_model().objects.create_user(
username="readonly", password="readonly", is_superuser=False, is_staf=False username="readonly", password="readonly", is_superuser=False, is_staf=False
) )
self.assertFalse(babybuddy_tags.user_is_read_only(user)) self.assertFalse(babybuddy.user_is_read_only(user))
group = Group.objects.get(name="read_only") group = Group.objects.get(name="read_only")
user.groups.add(group) user.groups.add(group)
self.assertTrue(babybuddy_tags.user_is_read_only(user)) self.assertTrue(babybuddy.user_is_read_only(user))

View File

@ -10,6 +10,8 @@ from django.core.management import call_command
from faker import Faker from faker import Faker
from babybuddy.views import UserUnlock
class ViewsTestCase(TestCase): class ViewsTestCase(TestCase):
@classmethod @classmethod
@ -77,6 +79,20 @@ class ViewsTestCase(TestCase):
page = self.c.get("/users/{}/delete/".format(entry.id)) page = self.c.get("/users/{}/delete/".format(entry.id))
self.assertEqual(page.status_code, 200) self.assertEqual(page.status_code, 200)
def test_user_unlock(self):
# Staff setting is required to unlock users.
self.user.is_staff = True
self.user.save()
entry = get_user_model().objects.first()
url = "/users/{}/unlock/".format(entry.id)
page = self.c.get(url)
self.assertEqual(page.status_code, 200)
page = self.c.post(url, follow=True)
self.assertEqual(page.status_code, 200)
self.assertContains(page, UserUnlock.success_message)
def test_welcome(self): def test_welcome(self):
page = self.c.get("/welcome/") page = self.c.get("/welcome/")
self.assertEqual(page.status_code, 200) self.assertEqual(page.status_code, 200)

View File

@ -39,6 +39,7 @@ app_patterns = [
path("users/", views.UserList.as_view(), name="user-list"), path("users/", views.UserList.as_view(), name="user-list"),
path("users/add/", views.UserAdd.as_view(), name="user-add"), path("users/add/", views.UserAdd.as_view(), name="user-add"),
path("users/<int:pk>/edit/", views.UserUpdate.as_view(), name="user-update"), path("users/<int:pk>/edit/", views.UserUpdate.as_view(), name="user-update"),
path("users/<int:pk>/unlock/", views.UserUnlock.as_view(), name="user-unlock"),
path("users/<int:pk>/delete/", views.UserDelete.as_view(), name="user-delete"), path("users/<int:pk>/delete/", views.UserDelete.as_view(), name="user-delete"),
path("user/password/", views.UserPassword.as_view(), name="user-password"), path("user/password/", views.UserPassword.as_view(), name="user-password"),
path("user/settings/", views.UserSettings.as_view(), name="user-settings"), path("user/settings/", views.UserSettings.as_view(), name="user-settings"),

View File

@ -1,11 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth import get_user_model
from django.contrib.auth.views import LogoutView as LogoutViewBase from django.contrib.auth.views import LogoutView as LogoutViewBase
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import BadRequest from django.core.exceptions import BadRequest
from django.forms import Form
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden
from django.middleware.csrf import REASON_BAD_ORIGIN from django.middleware.csrf import REASON_BAD_ORIGIN
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
@ -21,9 +22,17 @@ from django.views.decorators.csrf import csrf_protect
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.generic import View from django.views.generic import View
from django.views.generic.base import TemplateView, RedirectView from django.views.generic.base import TemplateView, RedirectView
from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.views.generic.detail import BaseDetailView
from django.views.generic.edit import (
CreateView,
DeleteView,
FormMixin,
SingleObjectTemplateResponseMixin,
UpdateView,
)
from django.views.i18n import set_language from django.views.i18n import set_language
from axes.utils import reset
from django_filters.views import FilterView from django_filters.views import FilterView
from babybuddy import forms from babybuddy import forms
@ -114,6 +123,33 @@ class UserUpdate(
success_message = gettext_lazy("User %(username)s updated.") success_message = gettext_lazy("User %(username)s updated.")
class UserUnlock(
StaffOnlyMixin,
PermissionRequiredMixin,
SuccessMessageMixin,
FormMixin,
SingleObjectTemplateResponseMixin,
BaseDetailView,
):
model = get_user_model()
template_name = "babybuddy/user_confirm_unlock.html"
permission_required = ("admin.change_user",)
form_class = Form
success_message = gettext_lazy("User unlocked.")
def post(self, request, *args, **kwargs):
user = self.get_object()
form = self.get_form()
if form.is_valid():
reset(username=user.username)
return self.form_valid(form)
else:
return self.form_invalid(form)
def get_success_url(self):
return reverse("babybuddy:user-update", kwargs={"pk": self.kwargs["pk"]})
class UserDelete( class UserDelete(
StaffOnlyMixin, PermissionRequiredMixin, DeleteView, SuccessMessageMixin StaffOnlyMixin, PermissionRequiredMixin, DeleteView, SuccessMessageMixin
): ):

View File

@ -1,5 +1,5 @@
{% extends 'babybuddy/page.html' %} {% extends 'babybuddy/page.html' %}
{% load babybuddy_tags duration i18n timers %} {% load babybuddy duration i18n timers %}
{% get_child_count as CHILD_COUNT %} {% get_child_count as CHILD_COUNT %}
{% block title %}{{ object }}{% endblock %} {% block title %}{{ object }}{% endblock %}