From 8063dcec668ea21931ffe59c4e9220f3df3047a5 Mon Sep 17 00:00:00 2001 From: catborise Date: Sun, 25 Oct 2026 20:38:05 +0300 Subject: [PATCH] feat(admin): the user list shows how many API tokens each user has Which users can reach the API with a token was seen only by opening each user. The user list now has an API tokens column: the number of the user's tokens that still work (expired ones are left out; they are deleted only at the user's next token), linked to the user's token tab. One query counts them for all users. --- accounts/tests/test_api_tokens.py | 16 ++++++++++++++++ admin/templates/admin/user_list.html | 2 ++ admin/views.py | 6 +++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/accounts/tests/test_api_tokens.py b/accounts/tests/test_api_tokens.py index 0993f382..8104ae24 100644 --- a/accounts/tests/test_api_tokens.py +++ b/accounts/tests/test_api_tokens.py @@ -12,6 +12,7 @@ from django.test import TestCase from django.urls import reverse from django.utils import timezone from logs.models import Logs +from lxml import html class ApiTokenTests(TestCase): @@ -176,3 +177,18 @@ class AdminApiTokenTests(TestCase): self.client.force_login(self.admin) self.assertEqual(self.client.post(self.delete_url(other)).status_code, 404) self.assertTrue(ApiToken.objects.exists()) + + def test_the_user_list_counts_each_users_live_tokens(self): + self.client.force_login(self.user) + self.client.post(reverse("accounts:api_token_create"), {"name": "old", "days": "30"}) + ApiToken.objects.filter(name="old").update(expiry=timezone.now() - timedelta(days=1)) + self.client.force_login(self.admin) + page = html.fromstring(self.client.get(reverse("admin:user_list")).content) + headers = [th.text_content().strip() for th in page.xpath("//thead//th")] + column = headers.index("API tokens") + cells = {row.xpath("td")[0].text_content().strip(): row.xpath("td")[column] for row in page.xpath("//tbody/tr")} + # the expired one is not counted; the count opens the user's tokens + self.assertEqual(cells["tok-user"].text_content().strip(), "1") + self.assertEqual(cells["tok-user"].xpath(".//a/@href"), + [reverse("accounts:account", args=[self.user.id]) + "#apitokens"]) + self.assertEqual(cells["tok-admin"].text_content().strip(), "") diff --git a/admin/templates/admin/user_list.html b/admin/templates/admin/user_list.html index 3972d0ab..108a968f 100644 --- a/admin/templates/admin/user_list.html +++ b/admin/templates/admin/user_list.html @@ -35,6 +35,7 @@ {% trans "Staff" %} {% trans "Superuser" %} {% trans "Can Clone" %} + {% trans "API tokens" %} {% trans "Actions" %} @@ -56,6 +57,7 @@ {% if user.is_staff %}{% bs_icon 'check' %}{% endif %} {% if user.is_superuser %}{% bs_icon 'check' %}{% endif %} {% if can_clone %}{% bs_icon 'check' %}{% endif %} + {% if user.api_tokens %}{{ user.api_tokens }}{% endif %}
{% bs_icon 'eye-fill' %} diff --git a/admin/views.py b/admin/views.py index 6d4196e8..bf74928f 100644 --- a/admin/views.py +++ b/admin/views.py @@ -9,7 +9,9 @@ from django.contrib.auth.forms import AdminPasswordChangeForm from django.contrib.auth.models import Group, User from django.core.paginator import Paginator from django.db import transaction +from django.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_POST @@ -96,7 +98,9 @@ def group_delete(request, pk): @superuser_only def user_list(request): - users = User.objects.all() + # API tokens that still work: an expired one is deleted only at the user's next create + live = Q(auth_token_set__expiry__isnull=True) | Q(auth_token_set__expiry__gt=timezone.now()) + users = User.objects.annotate(api_tokens=Count("auth_token_set", filter=live)) return render( request, "admin/user_list.html",