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_LOCKOUT_TEMPLATE = "error/lockout.html"
AXES_LOCKOUT_URL = "/login/lock"
# Session configuration
# Used by RollingSessionMiddleware to determine how often to reset the session.
# 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_locale as LOCALE %}
{% 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 %}
{% make_absolute_url relative_root_url as absolute_root_url %}
BABYBUDDY-LOGIN:{"url":"{{ absolute_root_url }}","api_key":"{{ user.settings.api_key }}"}

View File

@ -1,5 +1,5 @@
{% extends 'babybuddy/base.html' %}
{% load babybuddy_tags i18n static timers %}
{% load babybuddy i18n static timers %}
{% block nav %}
<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 %}
<nav aria-label="Page navigation">

View File

@ -1,5 +1,5 @@
{% 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 %}

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' %}
{% load i18n %}
{% load babybuddy i18n %}
{% block title %}
{% if object %}
@ -21,6 +21,17 @@
{% block content %}
{% 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 %}
<h1>Update <span class="text-info">{{ object }}</span></h1>
{% endblocktrans %}

View File

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

View File

@ -1,5 +1,5 @@
{% extends 'babybuddy/page.html' %}
{% load i18n widget_tweaks babybuddy_tags %}
{% load i18n widget_tweaks babybuddy %}
{% 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.translation import to_locale, get_language
from axes.helpers import get_lockout_message
from axes.models import AccessAttempt
from core.models import Child
register = template.Library()
@register.simple_tag
def axes_lockout_message():
return get_lockout_message()
@register.simple_tag(takes_context=True)
def relative_url(context, field_name, value):
"""
@ -65,6 +73,11 @@ def make_absolute_url(context, url):
return abs_url
@register.simple_tag()
def user_is_locked(user):
return AccessAttempt.objects.filter(username=user.username).exists()
@register.simple_tag()
def user_is_read_only(user):
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 core.models import Child
from babybuddy.templatetags import babybuddy_tags
from babybuddy.templatetags import babybuddy
class TemplateTagsTestCase(TestCase):
def test_child_count(self):
self.assertEqual(babybuddy_tags.get_child_count(), 0)
self.assertEqual(babybuddy.get_child_count(), 0)
Child.objects.create(
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(
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):
user = get_user_model().objects.create_user(
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")
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 babybuddy.views import UserUnlock
class ViewsTestCase(TestCase):
@classmethod
@ -77,6 +79,20 @@ class ViewsTestCase(TestCase):
page = self.c.get("/users/{}/delete/".format(entry.id))
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):
page = self.c.get("/welcome/")
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/add/", views.UserAdd.as_view(), name="user-add"),
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("user/password/", views.UserPassword.as_view(), name="user-password"),
path("user/settings/", views.UserSettings.as_view(), name="user-settings"),

View File

@ -1,11 +1,12 @@
# -*- coding: utf-8 -*-
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.forms import PasswordChangeForm
from django.contrib.auth import get_user_model
from django.contrib.auth.views import LogoutView as LogoutViewBase
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import BadRequest
from django.forms import Form
from django.http import HttpResponseForbidden
from django.middleware.csrf import REASON_BAD_ORIGIN
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.generic import View
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 axes.utils import reset
from django_filters.views import FilterView
from babybuddy import forms
@ -114,6 +123,33 @@ class UserUpdate(
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(
StaffOnlyMixin, PermissionRequiredMixin, DeleteView, SuccessMessageMixin
):

View File

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